伝票を 1 枚ずつ見ると金額は合っている・・・なのに、月末に売上を合計すると銀行の入金と 1 円ずれる。 あるいは、単価に税率を掛けた結果を FLOAT で保存していたら、あとから SUM したときに帳尻が合わない。 こうした「個別は正しいのに、まとめるとずれる」現象の多くは、プログラムのバグではなく、 浮動小数点数(FLOAT)が数を近似でしか持てないことから起きます。
この記事では、なぜずれるのかを式と動くグラフで見ていきます。
なお、型の選び方そのものは、 SQLの数値型を正しく選ぶ技術 で扱っています(金額に FLOAT を使う失敗例、FLOAT と DECIMAL の判断ガイド)。
この記事で分かること
| 疑問 | 答えの場所 |
|---|---|
| なぜ 0.1 のような普通の小数でずれるのか | §1 / 図1 |
| 1 件では見えない誤差が、なぜ合計で見えるのか | 図2 |
| 同じ伝票でも FLOAT と DECIMAL の SUM は本当に分かれるのか | 図3・表1 |
| 有名な 0.1 + 0.2 ≠ 0.3 との関係 | §5 |
| 実務で何をすればよいか | §6 / FAQ |
FLOAT / DOUBLE は、決まったバイト数のなかで「とても広い範囲の数」を表すために、 ほとんどの小数を一番近い表現可能な値へ丸めた近似として持ちます。 誤差は欠陥ではなく、速さと範囲を取るための設計です。
だから金額・単価・税率・請求の合算のように「1 の位まで一致してほしい数」には向きません。 そこは十進で桁を約束できる DECIMAL(NUMERIC)が正解です。 一方、センサ値や統計量のように、もともと測定ノイズがある量では、FLOAT の微小な丸めは実務上問題にならないことが多いです。
この近似は、目盛りの欠けた定規に似ています。 十進で当たり前の 0.1 が、二進の定規では目盛りに乗らない。近い目盛りに吸い寄せられ、 足し算を繰り返すとそのずれが積み上がる ── それが月次合計の 1 円差の正体です。
1. 十進の 0.1 が二進で困る理由
私たちが紙の上で書く 0.1 は、十進ではきれいな有限小数です。ところがコンピュータの FLOAT は、 内部では二進(0 と 1)で数を表します(IEEE 754 という標準です)。二進に直すと、0.1 は循環します。
\[ 0.1_{10} = 0.0001100110011\ldots_{2} \]
有限ビットしか使えないので、どこかで切って「いちばん近い目盛り」に丸めます。 その操作を記号 \(\mathrm{fl}(x)\) と書くと、保存されるのは \(x\) そのものではなく \(\mathrm{fl}(x)\) です。
\[ \mathrm{stored}(x) = \mathrm{fl}(x) \neq x \quad (\text{多くの十進小数で}) \]
1 回のずれはごく小さいことが多いです。問題は、その近似値を何度も足すときに表面化します。
2. 数直線上の「実際に取れる目盛り」(図1)
下の図は、IEEE 754 の単精度(FLOAT32)が、選んだ目標値のまわりで実際に取れる値を点で示します。 破線が「ほしい十進の値」、青い点 fl が「FLOAT32 が持てるいちばん近い目盛り」です。 破線が点に乗らないとき、その差が丸め誤差です。
図1 FLOAT32 が実際に取れる目盛り(数直線と最近傍 fl)
最初は狭い幅(片側 8 ULP=目盛り 8 個ぶん)で開きます。破線(ほしい値)と青い点 fl(FLOAT32 の最近傍)のずれに注目してください。 スライダーで幅を広げると目盛りが密集して見え、狭めると一つ一つの間隔が分かります。
合計を求めるとき、理想では各金額をそのまま足すだけです。FLOAT では、足し算のたびに結果が再び丸められます。
\[ s_0 = 0, \qquad s_{k+1} = \mathrm{fl}(s_k + x_k) \]
1 回の \(\mathrm{fl}\) では誤差が小さくても、\(k\) が増えると \(s_n\) と理想の合計の差が目に見えることがあります。 伝票 1 枚では気にならず、月次の SUM で初めて発覚する、ということも、この繰り返しの丸めから起きます。
# イメージ(疑似コード)── x は伝票ごとに変わってよい
s = 0
for each ticket amount x_k:
s = fl(s + x_k) # 毎回、一番近い目盛りへ
# 理想は x_1+…+x_n。s とは一致しないことがある
3. 伝票を重ねたときの累積誤差(図2)
横軸は足した回数 \(n\)、縦軸は「FLOAT32 で累積した合計」と「十進の理想合計」の差です。 実店舗の伝票のように、1 回あたりの金額 \(x_k\) を循環して変化させています(スライダーは基準額)。
丸めの向きは、そのときの部分和がどの目盛りの間にいるか(数の大きさで目盛り間隔=ULP が変わる)と、 次に足す金額 \(x_k\) によって変わります。 だから誤差は正の側だけに単調に増えるとは限らず、負に振れたり、途中で戻ったり、細かく上下したりします。 大きなうねりは丸めの偏りがしばらく同じ向きに続くため、細かいガタガタは足す金額が変わるたびに丸めの向きが切り替わるためです。
念のため強調しておくと、この揺れは乱数ではありません。 同じ部分和に同じ金額を足せば、毎回同じ結果になります。 図の伝票金額も、決まった倍率パターンの循環であり、実行のたびにサイコロを振っているわけではありません。 「ふらふらして予測しにくい」ように見えても、仕組みとしては決定的です。
図2 同じ列を足し続けたときの累積誤差(FLOAT32 累積 − 理想合計)
「1 件では見えない差が、件数を重ねると表に出る」ことがポイントです。 件数 \(n_{\max}\) を増やしながら、パネルの「最終」の差の桁を見比べてみてください。
4. 同じ伝票を FLOAT と DECIMAL で合計すると(図3)
図2は「ずれの大きさ」を縦軸に取っていました。ここでは視点を変え、 同じ伝票の列を、FLOAT と DECIMAL のそれぞれで合計したときどう分かれるかを、式・表・図でそろえて見ます。 伝票金額は図2と同じく倍率パターンで循環させます(見た目の基準は \(0.10\))。
DECIMAL は各 \(x_k\) を十進のまま足せます。FLOAT32 は足すたびに \(\mathrm{fl}\) が入ります。
\[ S_{\mathrm{DECIMAL}}(n) = x_1 + x_2 + \cdots + x_n \]
\[ S_{\mathrm{FLOAT}}(n) = \mathrm{fl}\bigl(\cdots \mathrm{fl}(\mathrm{fl}(0 + \mathrm{fl}(x_1)) + \mathrm{fl}(x_2)) \cdots + \mathrm{fl}(x_n)\bigr) \]
SQL で書くと、たとえば次のような簡単な例になります。列の型だけが違う、同じ SUM です。
-- SQLでの簡単な例:
-- 行ごとの amount が違っても、型が違うと同じ SUM で結果が分かれうる
SELECT SUM(amount_float) AS sum_float, -- FLOAT / REAL
SUM(amount_decimal) AS sum_decimal -- DECIMAL(10,2) など
FROM sales;
下の表1は、先頭の数行では差がほとんど見えず、件数を重ねた最終行で差が表に出る様子です (DECIMAL 側は整数スケール換算の正確な合計)。
| 行 | 伝票金額(表示) | FLOAT32 累計 | DECIMAL 累計 | 差(F−D) |
|---|
図3は、表1と同じ過程をDECIMAL の合計を基準(差ゼロ)にして見たものです。 縦軸は合計金額ではなく、FLOAT の SUM から DECIMAL の SUM を引いた差です。 オレンジの破線が「差ゼロ(DECIMAL と一致)」、青がその差の推移です。 増分の変え方は図2と同じなので、ここでも細かく揺れます(こちらも乱数ではありません)。
感覚としては「DECIMAL も FLOAT も右上へ合計が増えていき、FLOAT だけがわずかにずれる」で合っています。 ただそのずれは合計の大きさに対してごく小さいので、金額そのものの二本線にすると重なって一本に見えます。 だから図では差だけを拡大しています。青が下がる/上がるのは、合計が減っているのではなく、 丸めの方向が途中で変わり、DECIMAL より下に行ったり近づいたりするためです。
図3 FLOAT の SUM − DECIMAL の SUM(差の推移)
表1=合計額そのもの/図3=その差の拡大、と読み分けてください。 金額列を DECIMAL にする、というのはこの青線のずれを最初からなくす選択です。
5. 0.1 + 0.2 ≠ 0.3 も、同じことが起きている
プログラミング言語の対話環境でよく話題になる例も、同じ「欠けた目盛り」です。 記事冒頭では金額の合計ずれから話を始めましたが、起きていることの核はここに同じ形で現れます。
\[ \mathrm{fl}(0.1) + \mathrm{fl}(0.2) \;\neq\; \mathrm{fl}(0.3) \quad \text{(多くの環境で)} \]
ブラウザの JavaScript でも、次は厳密等価になりません(表示を丸めると隠れることがあります)。
0.1 + 0.2 === 0.3 // false 0.1 + 0.2 // 0.30000000000000004
なお JavaScript の数値は倍精度(FLOAT64)です。図1の FLOAT32 より目盛りがずっと細かいだけで、 「0.1 が目盛りに乗らない」という構図は同じです。 図1で「0.1」「0.2」「0.3」を切り替えると、それぞれが目盛りに乗っていない様子を同じ図で確認できます。 月次合計がずれる現象と、同じ目盛りの欠けが別の場面で見えているだけです。
6. 実務への落とし込み
- 金額・単価・税率・請求額 ── DECIMAL。FLOAT で保存してあとから丸め直しても、既に失った桁は戻らないことがあります。
- センサ・統計・機械学習の特徴量 ── 測定やモデル側のノイズの方が大きいなら FLOAT/DOUBLE で十分なことが多いです。
- 画面表示の桁揃え(
toFixedなど)と、保存・集計の型は別物です。表示だけきれいにしても、DB が FLOAT なら SUM はずれ得ます。
型の分岐表や CREATE TABLE の例は SQLの数値型を正しく選ぶ技術 にまとめてあります。ここではずれの仕組みに絞っています。
まとめ
- FLOAT は広い範囲を速く扱うために、多くの小数を近似で持つ。
- 十進のきれいな値(0.1 など)が二進の目盛りに乗らないことが、ずれの根。
- 1 回では小さくても、加算のたびに丸めが入ると合計で見える。
- 同じ伝票でも FLOAT の SUM と DECIMAL の SUM は分かれうる(図3)。
- 金額の正しさが必要なら DECIMAL。FLOAT の誤差は仕様であり、バグ退治だけでは解けない。
FAQ
Q1. 誤差は具体的にどのくらいですか?
A. FLOAT32 は有効数字およそ 7 桁、DOUBLE(FLOAT64)はおよそ 15〜16 桁です。「何円ずれるか」は値の大きさ・回数・演算順で変わるので、図2で件数を動かして感覚を掴む方が実務的です。
Q2. DOUBLE なら金額に使ってよいですか?
A. いいえ。桁が増えてずれが見えにくくなるだけで、十進の金額を正確に表せる保証にはなりません。金額は DECIMAL が正解です。
Q3. 言語の 0.1+0.2 と同じ話ですか?
A. 同じ系統です。SQL の FLOAT も、多くの言語の既定の浮動小数も、二進の近似という点で共通します。データベース固有の「変なバグ」というより、表現形式の性質です。
Q4. 同じテーブルなのに、実行のたびに FLOAT の SUM が微妙に違うことがあります。なぜですか?
A. 浮動小数の足し算は、足す順番を変えると結果が変わることがあります(結合法則が厳密には成り立ちません)。並列集計やインデックスの違いで行を読む順番が変わると、同じデータでも SUM の下位桁が揺れることがあります。これも図2で見た「丸めの向きが途中経過に依存する」性質の現れで、DECIMAL の SUM なら順番によらず一致します。
Q5. すでに FLOAT で金額を保存してしまいました。どうしますか?
A. まず新規書き込みを DECIMAL に切り替えるのが先です。過去データの矯正は、どの桁まで信頼できるかの業務判断が必要で、機械的に「丸め直せば元通り」とは限りません。SQLの数値型を正しく選ぶ技術の「金額に FLOAT を使う」節も参照してください。

コメントを残す