Проблема точности Float в СУБД и языках программирования

1. Проблема: «У меня баг в компиляторе»
Каждый разработчик проходит через стадию отрицания реальности. Вы пишете простой код, а компьютер выдает результат, который противоречит арифметике начальной школы.
В PostgreSQL вы запускаете простой SELECT SUM(amount) по таблице и получаете результат 10500.55. Через пять минут запускаете тот же запрос, данные не менялись, но результат вдруг стал 10500.5499999998. Вы проверяете логи — изменений не было. Вы думаете, что сходит с ума база данных.
В PHP или JS вы пишете условие if ($a + 1 === $a). Здравый смысл кричит, что это условие никогда не выполнится. Но вдруг скрипт зависает в бесконечном цикле или выдает true.
Новичок бежит создавать ишью в баг-трекере языка. Опытный инженер тяжело вздыхает и вспоминает стандарт IEEE 754.
2. Наивная модель
Наша ментальная модель чисел сформирована школой и привычкой к десятичной системе. Мы считаем, что числа в компьютере ведут себя как абстрактные математические объекты.
Мы верим в два постулата:
- Число
0.1в коде — это ровно одна десятая. - Сложение ассоциативно: [ (a + b) + c = a + (b + c) ]. От перемены мест слагаемых (или порядка действий) сумма не меняется.
В мире типов REAL, FLOAT и DOUBLE PRECISION оба эти постулата ложны.
3. Реальность
Компьютер не хранит числа. Он хранит их двоичные проекции. Проблема возникает на этапе перевода из «человеческого» десятичного языка в «машинный» двоичный.
Вспомните дробь [ 1/3 ]. В десятичной системе записать её точно невозможно: 0.3333... — тройки уходят в бесконечность. У нас физически не хватит бумаги, чтобы записать точное значение. Мы обрываем запись на какой-то цифре, и получаем погрешность.
С числом 0.1 (одна десятая) в двоичной системе происходит то же самое. Дробь конечна только тогда, когда знаменатель является степенью основания системы счисления.
В десятичной системе (основание 10 = 2 × 5) конечны дроби со знаменателями 2, 5, 10, 20 и т.д.
В двоичной системе (основание 2) конечны только дроби со знаменателями 2, 4, 8, 16, 32...
Число 10 содержит множитель 5, которого нет в двойке. Поэтому в двоичном коде 0.1 превращается в бесконечную периодическую дробь: 0.0001100110011....
Тип Float (или Double) — это кусок бумаги фиксированного размера (32 или 64 бита). Компьютер вынужден обрезать этот бесконечный «хвост». В этот момент, еще до начала каких-либо вычислений, 0.1 перестает быть 0.1. Оно становится 0.10000000000000000555....
А 0.2 превращается в нечто чуть большее, чем 0.2. Когда вы складываете две ошибки округления, результат 0.30000000000000004 уже не совпадает с ожидаемым «чистым» 0.3.
Как это проверить на себе?
Самый быстрый способ увидеть эффект — консоль любого браузера. Попробуйте сложить три простых числа:
0.1 + 0.1 + 0.1
Вместо ожидаемых 0.3 вы получите 0.30000000000000004.
Если же вы попробуете прибавить к очень большому числу очень маленькое, например 100000000 + 0.0000001, вы увидите, что результат остался ровно 100000000. Маленькое число просто «стерлось» из-за ограниченной точности разрядной сетки.
4. Ключевой момент: "Большие едят маленьких"
Вторая проблема кроется в устройстве формата плавающей точки. Число хранится как мантисса [^1] и экспонента [^2].
Представьте, что вы можете хранить только 4 значащие цифры.
Вам нужно сложить 1000 и 0.001.
В нормализованном виде это выглядит так:
[ 1.000 x 10^3 ]
плюс
[ 1.000 x 10^-3 ]
Чтобы их сложить, процессор должен выровнять эскпоненты (привести к одной степени). Приводим меньшее к большему: 0.001 превращается в [ 0.000001 x 10^3 ].
Теперь складываем мантиссы: 1.000 + 0.000001.
Результат: 1.000001. Но у нас есть место только для 4 значащих цифр!
Последние цифры отрезаются, и мы получаем 1.000.
Единица была прибавлена, но результат не изменился. Это называется потерей точности из-за разрядности. Большое число «поглотило» маленькое, потому что у типа данных не хватило «разрешающей способности», чтобы увидеть эту крошечную разницу на фоне гиганта.
Именно поэтому порядок действий важен. [ (Small + Small) + Big ] может сохранить информацию, а [ (Big + Small) + Small ] потеряет оба маленьких числа по очереди.
5. PostgreSQL и параллельность
Вернемся к примеру с SUM() в PostgreSQL. Почему результат «плавает» при повторных запросах, хотя данные статичны?
Современные БД умеют считать агрегаты параллельно. Главный процесс разбивает таблицу на куски и раздает их рабочим процессам (Workers). Каждый Worker считает сумму своего куска (Partial Aggregate), а затем передает результат лидеру, который суммирует результаты воркеров.
Суть в том, что порядок, в котором воркеры вернут свои промежуточные суммы, не гарантирован. А распределение строк по батчам может меняться.
Сегодня мы сложили (A + B) + C. Завтра планировщик решил иначе, и мы сложили A + (B + C).
Из-за эффекта «поглощения», описанного выше, и накопления ошибок округления, разный порядок суммирования миллиардов float-чисел дает математически разные итоги. Это не баг PostgreSQL, это фундаментальное свойство арифметики с плавающей точкой.
6. PHP и большие числа
В динамических языках типа PHP или JS нет жесткой типизации переменных, но есть жесткие границы типов внутри интерпретатора.
PHP автоматически конвертирует integer в float, если число превышает PHP_INT_MAX.
В формате Double точность (количество значащих битов) ограничена 53 битами. Как только число становится больше [ 2^{53} ] (примерно 9 квадриллионов), расстояние между соседними представимыми числами (machine epsilon) становится больше 1.
В этом диапазоне компьютер может сохранить число (X) и число (X+2), но число (X+1) физически невозможно представить — оно попадает в «дырку» и округляется обратно к (X).
Именно так работает бесконечный цикл: вы делаете $i++, но для процессора $i и $i+1 — это один и тот же набор битов.
Еще один пример на PHP: Когда арифметика ломает логику
$a = 12345678901234567890;
$b = $a + 1;
var_dump($a === $b, $b); // bool(true), float(1.2345678901235E+19)
Как с этим жить в PHP?
Чтобы не играть в «угадай копейку», в PHP для этих целей существует расширение BCMath. Оно выполняет вычисления программно, работая с числами как со строками, что обеспечивает произвольную точность.
7. Практические решения
Что с этим делать инженеру?
В базах данных (PostgreSQL, MySQL):
Используйте типы NUMERIC или DECIMAL.
Они работают не через процессорный FPU (Floating Point Unit), а программно. Они имитируют «школьную» математику, храня числа как массивы цифр. Это медленнее, но гарантирует, что 0.1 + 0.2 будет ровно 0.3.
Для денег:
Никогда не используйте Float/Double для денег.
Вариант А: DECIMAL в БД.
Вариант Б: Паттерн «Money». Храните деньги в минимальных единицах (копейках, центах) используя BIGINT (целые числа). 100 копеек — это целое число, с ним проблем точности нет.
В коде (PHP, Python, Java): Если нужна абсолютная математическая точность (научные расчеты, биллинг), используйте библиотеки произвольной точности:
- PHP:
BCMathилиGMP. - Python: модуль
decimal. - Java:
BigDecimal.
8. Какой можно сделать вывод?
Золотое правило: Никогда не проверяйте float на равенство (==). Проверяйте, что разница между числами меньше допустимой погрешности (эпсилон):
[ | a - b | < 0.00001 ]
Терминология
Число с плавающей точкой хранится в виде двух частей: мантисса × 2^экспонента.
[^1]: Мантисса — это сами цифры числа, которые компьютер реально запоминает.
[^2]: Экспонента — это показатель степени двойки, который определяет масштаб числа (насколько оно большое или маленькое).
Проще:
- Мантисса — отвечает за точность.
- Экспонента — отвечает за размер числа.
Память под мантиссу ограничена. Поэтому компьютер может хранить только определённое количество цифр. Когда число становится очень большим, «места» для мелких изменений уже не остаётся. Из-за этого:
- большое число может не измениться после прибавления 1
- маленькое число может «исчезнуть» при сложении с большим