PostgreSQL CookbookТипы данныхЧисла и деньги
0 / 63 (0%)

Числа и деньги

Первый отчёт по выручке ушёл утром Эмилю — основателю Brew и в прошлом бариста. До обеда он поднимается в опенспейс сам. В руках — две распечатки.

Эмиль
Два отчёта — два числа. Касса говорит одно, ваши цифры — на пару копеек меньше, и так в каждой строке. Где мои деньги?
Может, касса округляет?
Эмиль
Касса пробивает то, что заплатил гость. Гости платят точно.

Дмитрий пробегает глазами колонку итогов, берёт салфетку и пишет одну строку: 0.1 + 0.2 ≠ 0.3.

В каждом сложении?
Дмитрий
В каждом. Никто не крал. Складывали во float.
Эмиль
Мне всё равно, как это называется. Сделайте, чтобы копейки сходились.

Эмиль уходит вниз, к гостям; распечатки остаются у тебя.

Скрипт выгрузки писали задолго до тебя: суммы в нём складывает float. Цель юнита — закрыть этот класс багов: понять, почему 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. Первый — та самая ловушка на литералах:

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). Второй и третий запросы работают с деньгами как с целыми центами:

sql
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:

go
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:

sh
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.)

Вывод:

plaintext
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 → Go int64; в ₽.коп разворачивать только на выводе. Так устроена схема Brew.
  • sum() над BIGINT возвращает numeric — приводи результат к ::bigint, если ждёшь int64.

Копейки сходятся — распечатки Эмиля можно возвращать. Дальше — юнит 01-02 «text, boolean и тизер NULL»: разберём три «скучных» типа, на которых на самом деле спотыкаются приложения, — почему мы держим text, а не char(n), что такое трёхзначная логика boolean и почему NULL — это не «пусто», а «неизвестно».

·Модуль 02

Этот урок ещё впереди

Курс изучается по порядку — чтобы открыть этот шаг, сначала завершите предыдущие. Так контекст накапливается без пропусков.

/ вы пытались открыть
Типы данных / Числа и деньги