Числа и деньги
В конце месяца отчёт по выручке Brew не сошёлся. Касса говорит одно, выгрузка из приложения — на пару копеек меньше, и так в каждой строке. Причина банальна и знаменита: суммы складывали в числах с плавающей точкой. А float не умеет точно представлять десятичные дроби — 0.1 + 0.2 в нём не равно 0.3, и эта погрешность копится по тысячам заказов в заметную дыру.
Цель юнита — закрыть этот класс багов на старте: понять, почему float нельзя для денег, и выбрать представление, которое точно считается и удобно ложится в Go. У Postgres есть numeric (точный, произвольной точности), но в приложении деньги обычно держат ещё проще — целым числом минорных единиц (центов). Именно так устроена схема Brew: drinks.base_price — это BIGINT в центах.
Почему float ломает деньги
float8 (он же double precision) хранит числа в двоичной плавающей точке. Десятичные дроби вроде 0.1 в двоичке периодические — их приходится округлять, и при сложении ошибки округления всплывают наружу. Классическая демонстрация: 0.1 + 0.2 даёт 0.30000000000000004, а не 0.3. Сравнение 0.1 + 0.2 = 0.3 возвращает false.
В numeric тех же чисел нет погрешности: это десятичный тип с точным представлением, и 0.1 + 0.2 = 0.3 там true. Платишь за это скоростью и тем, что в Go numeric приезжает не как простое число, а как pgtype.Numeric (его надо разворачивать).
Деньги как BIGINT-центы
Третий путь — и для приложения чаще всего лучший — вообще не хранить дробь. Цена 3.00 ₽ — это 300 центов, целое число. Сложение, умножение на количество, суммирование по заказу — всё это операции над целыми: точные, быстрые, без сюрпризов. В Go BIGINT — это int64, родной тип, без обёрток. В рубли с копейками разворачиваем только на границе вывода: цена/100 и цена%100.
Схема Brew держит все цены так: drinks.base_price, order_items.unit_price — BIGINT в центах. Отчёт, который не сошёлся, починился бы заменой float-суммы на sum() по целым центам.
Три представления: что выбрать
| Точность | Скорость | В Go | Когда брать | |
|---|---|---|---|---|
float8 | дробь неточна (0.1+0.2≠0.3) | быстрый | float64 | измерения, где погрешность не важна; для денег — никогда |
numeric | точный, произвольная точность | медленнее | pgtype.Numeric (разворачивать) | дробные цены за единицу, налоги, промежуточные расчёты |
BIGINT-центы | точный (целое) | быстрый | int64, родной | деньги в приложении: хранить, складывать, суммировать |
Свернём в одну карточку выбора: деньги на выходе — BIGINT в центах; дробная цена за грамм — numeric; float — никогда.
Что показывает наш код
Три запроса в query.sql. Первый — та самая ловушка на литералах:
-- name: FloatVsNumeric :one
SELECT
(0.1::float8 + 0.2::float8)::float8 AS float_sum,
(0.1::numeric + 0.2::numeric)::text AS numeric_sum,
(0.1::float8 + 0.2::float8 = 0.3::float8) AS float_eq_03,
(0.1::numeric + 0.2::numeric = 0.3::numeric) AS numeric_eq_03;float_sum остаётся float8 (в Go — float64, и ты увидишь «хвост»); numeric_sum приводим к text только ради аккуратной печати (в Go numeric — это pgtype.Numeric). Второй и третий запросы работают с деньгами как с целыми центами:
SELECT id, name, base_price FROM drinks ORDER BY id; -- MenuPriced :many
SELECT coalesce(sum(quantity * unit_price), 0)::bigint AS total_cents -- OrderTotalCents :one
FROM order_items WHERE order_id = $1;base_price и итог — BIGINT → int64. В main.go разворачиваем центы в ₽.коп арифметикой над целыми, без единого float:
fmt.Fprintf(w, "%d\t%s\t%d\t%d.%02d\n", d.ID, d.Name, d.BasePrice, d.BasePrice/100, d.BasePrice%100)Запуск
Подними песочницу (из корня репозитория) и накати схему Brew:
docker compose up -d
make lecture L=01-data-types/01-01-numbers-and-money T=db-reset
make lecture L=01-data-types/01-01-numbers-and-money(T=run — значение по умолчанию. Изнутри каталога юнита это make db-reset, make run.)
Вывод:
1) 0.1 + 0.2 — float8 (Go float64) против numeric:
float: 0.30000000000000004 (= 0.3? false)
numeric: 0.3 (= 0.3? true)
2) Меню Brew — base_price BIGINT в центах, печатаем как ₽.коп:
ID НАЗВАНИЕ ЦЕНТЫ ЦЕНА
1 Эспрессо 300 3.00
2 Капучино 450 4.50
3 Латте 480 4.80
4 Колд брю 520 5.20
5 Зелёный чай 250 2.50
3) Итог заказа #1 — sum в центах: 970 (= 9.70)float дал 0.30000000000000004 и = 0.3 → false — ту самую копеечную дыру. numeric точен. А деньги Brew живут целыми центами: меню разворачивается в ₽.коп без потерь, итог заказа #1 — 970 центов = 9.70.
Заборчик
numeric — не «плохой» тип: для денег он точен, и хранить суммы в numeric(12,2) совершенно нормально. Целые центы мы выбираем потому, что они ложатся в Go int64 без обёртки pgtype.Numeric, а арифметика над ними быстрее. Что мы упростили и что добавит твой расчётный модуль в проде:
- Валюта. Цент доллара ≠ копейка рубля — рядом с суммой нужен код валюты, иначе сложишь несложимое.
- Округление. Банковское округление половин по фиксированному правилу, а не «как выйдет» у
float. - Дробные цены и налоги. Цена за грамм, НДС и промежуточные вычисления ведут в
numeric— в центы их сворачивают только на финальном шаге. - Масштаб. Расчётный модуль хранит и валюту, и число знаков; здесь мы держим одну валюту и целые центы, чтобы не уводить урок в сторону.
Незыблемо одно: деньги никогда не считаются во float.
Что забрать с собой
float/double precisionнеточен для десятичных дробей:0.1 + 0.2 ≠ 0.3. Для денег — никогда.numericточен (0.1 + 0.2 = 0.3), но в Go этоpgtype.Numericи он медленнее целых.- В приложении деньги удобнее всего держать целым числом минорных единиц (центов) как
BIGINT→ Goint64; в₽.копразворачивать только на выводе. Так устроена схема Brew. sum()надBIGINTвозвращаетnumeric— приводи результат к::bigint, если ждёшьint64.
Дальше — юнит 01-02 «text, boolean и тизер NULL»: разберём три «скучных» типа, на которых на самом деле спотыкаются приложения, — почему мы держим text, а не char(n), что такое трёхзначная логика boolean и почему NULL — это не «пусто», а «неизвестно».