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

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

В конце месяца отчёт по выручке 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_priceBIGINT в центах. Отчёт, который не сошёлся, починился бы заменой 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 и итог — BIGINTint64. В 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.3false — ту самую копеечную дыру. 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

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

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

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