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

Ошибки точности с плавающей запятой

1. Проблема: «У меня баг в компиляторе»

Каждый разработчик проходит через стадию отрицания реальности. Вы пишете простой код, а компьютер выдает результат, который противоречит арифметике начальной школы.

В PostgreSQL вы запускаете простой SELECT SUM(amount) по таблице и получаете результат 10500.55. Через пять минут запускаете тот же запрос, данные не менялись, но результат вдруг стал 10500.5499999998. Вы проверяете логи — изменений не было. Вы думаете, что сходит с ума база данных.

В PHP или JS вы пишете условие if ($a + 1 === $a). Здравый смысл кричит, что это условие никогда не выполнится. Но вдруг скрипт зависает в бесконечном цикле или выдает true.

Новичок бежит создавать ишью в баг-трекере языка. Опытный инженер тяжело вздыхает и вспоминает стандарт IEEE 754.

2. Наивная модель

Наша ментальная модель чисел сформирована школой и привычкой к десятичной системе. Мы считаем, что числа в компьютере ведут себя как абстрактные математические объекты.

Мы верим в два постулата:

  1. Число 0.1 в коде — это ровно одна десятая.
  2. Сложение ассоциативно: [ (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
  • маленькое число может «исчезнуть» при сложении с большим