Хранение денежных значений в PostgreSQL: почему numeric незаменим и когда bigint спасает производительность
Выбор типа данных для финансовых транзакций в реляционной базе данных — это фундаментальное архитектурное решение. Ошибка на этапе проектирования оборачивается расхождением балансов, блокировками транзакций и падением скорости аналитических запросов. База данных PostgreSQL предлагает два основных инструмента для работы с денежными величинами: произвольно точный тип numeric и фиксированный 64-битный bigint. За каждым из них стоят принципиально разные компромиссы между математической строгостью и аппаратной скоростью процессора.
Двоичный обман: почему double precision разрушает баланс
Главная ошибка при проектировании финансовых таблиц — использование типов real или double precision. Эти типы данных соответствуют стандарту IEEE 754, где числа хранятся в двоичной экспоненциальной форме. В оборудовании двоичная дробь формируется суммой степеней двойки (1/2, 1/4, 1/8 и далее). Подобно тому как дробь 1/3 невозможно точно записать в виде конечной десятичной дроби, число 0.1 превращается в двоичной системе в бесконечную периодическую дробь.
На уровне единичной операции погрешность кажется микроскопической: сложение 0.1 и 0.2 дает результат 0.30000000000000004. Интерфейс часто скрывает проблему округлением при выводе. Однако в учетном реестре (ledger) тысячи транзакций суммируются, перемножаются на процентные ставки и коэффициенты конвертации. Погрешность накапливается, порождая эффект "исчезающих копеек". В итоге регулярная сверка дебета и кредита выдает ошибку, а закрытие расчетного периода сбоит из-за несовпадения сумм. Финансовая логика требует строгого совпадения каждого десятичного знака, что исключает приблизительные типы из учетных систем.
Анатомия numeric: программная арифметика по основанию 10000
Для гарантированной точности в PostgreSQL реализован тип numeric (синоним — decimal). В документации СУБД он классифицируется как тип с произвольной точностью (arbitrary precision): он вмещает до 131 072 цифр до запятой и до 16 383 знаков после нее. Значение 12.30, сохраненное в numeric, остается точным без каких-либо искажений.
Однако за точность приходится платить ресурсами. Процессор аппаратно складывает и умножает числа только в регистрах фиксированного размера (например, 64-битных). Тип numeric аппаратно не поддерживается. Исходный код PostgreSQL организует хранение числа как динамическую структуру переменной длины:
- Заголовок с числом цифровых блоков, положением десятичной точки, знака и масштаба (scale).
- Массив двухбайтовых целых чисел, представляющих значение по основанию 10 000 (base-10000), где каждая ячейка хранит четыре десятичные цифры (от 0000 до 9999).
Когда запрос вычисляет сумму или произведение колонок numeric, ядро СУБД выполняет программную эмуляцию длинной арифметики «столбиком», пошагово перебирая блоки в памяти и учитывая переносы разрядов. Процессор тратит десятки машинных тактов там, где для целого числа хватило бы одной инструкции ADD. Кроме того, заголовок numeric увеличивает расход памяти, создавая дополнительную нагрузку на диск и кэш shared_buffers.
Целочисленные копейки: архитектура и границы bigint
Альтернативный подход в платежных шлюзах — отказ от дробей в пользу типа bigint. Идея заключается в выборе минимальной неделимой единицы (minor unit): вместо рублей в базу записываются копейки, вместо долларов — центы. Сумма 12 рублей 30 копеек сохраняется в базу как целое число 1230.
Преимущества bigint:
- Фиксированный размер: колонка занимает ровно 8 байт, оптимизируя упаковку строк на страницах базы данных и ускоряя сканирование индексов.
- Аппаратная скорость: процессор выполняет операции над 64-битными целыми числами на предельной частоте регистров без накладных расходов СУБД.
- Огромный диапазон: 64-битное целое со знаком охватывает значения от −9.22 × 10¹⁸ до +9.22 × 10¹⁸. При хранении в копейках максимальная сумма превышает 92 квадриллиона рублей.
Однако переход на bigint переносит сложность в прикладной код. Число 1230 само по себе не несет информации о масштабе: это могут быть 12 рублей 30 копеек, 1230 рублей или 0.01230 токена. Если один сервис запишет сумму в рублях, а другой прочитает в копейках, произойдет сбой списания. Кроме того, не все валюты делятся на 100: японская иена не имеет копеек, а бахрейнский динар делится на 1000 филсов. Без проверок CHECK и слоя бизнес-валидации целочисленный подход провоцирует системные ошибки при интеграциях.
Ловушка дробных расчетов: налоги, проценты и курсы валют
Даже если балансы счетов хранятся в копейках, расчеты неизбежно требуют дробных величин: начисление процентов, расчет НДС (20%), распределение скидок и валютная конвертация генерируют дробные результаты.
Попытка выполнять эти операции исключительно в целых числах ведет к потерям. Если товар стоимостью 19 рублей 99 копеек продается со скидкой 3.5%, расчетная цена составляет 19.29035 рубля. Если округлить копейки сразу в момент деления, а затем умножить на партию из 10 000 штук, погрешность выльется в реальные финансовые убытки.
В финансовых системах действует правило: промежуточные вычисления производятся с повышенной точностью, а округление до минимальной единицы выполняется один раз на границе проводки. Для расчетов подходит numeric с расширенным масштабом, например numeric(20, 6).
Также необходимо зафиксировать единый алгоритм округления:
- Банковское округление к ближайшему четному числу, принятое по умолчанию в бухучете для устранения статистического смещения.
- Арифметическое округление в пользу продавца или покупателя, регламентированное налоговым законодательством.
- Метод усечения (truncate) без округления копеек.
Если доверить округление неявному приведению типов в SQL-запросе, разные версии библиотек применят различные правила, что приведет к расхождениям между счетом и проводкой.
Практический сценарий: гибридная модель биллинга
Оптимальным решением становится разделение схемы базы данных на учетный и расчетный контуры:
- Транзакционный учет (Transaction Ledger):
Таблицы проводок и балансов работают в условиях частых записей. Здесь оправдано использование
bigintс фиксированной единицей учета (копейка или цент). Колонки сопровождаются кодом валюты по ISO 4217 (например,RUB,USD) и ограничениямиCHECK (amount >= 0). Это обеспечивает плотность индексов и скорость вычисления сумм черезSUM(). - Расчетный контур тарифов и скидок:
Таблицы прейскурантов, процентных ставок, налогов и курсов валют проектируются на типе
numeric(precision, scale). Например, курс валют задается какnumeric(18, 6), а скидка —numeric(5, 4). В этом слое выполняются все калькуляции стоимости корзины и комиссий. - Шлюз фиксации проводок:
Преобразование из расчетного слоя в учетный регистр оформляется единой функцией. Она выполняет финальное округление дробного
numericпо регламенту и создает атомарную запись вbigint.
Чек-лист проектирования и валидации финансовых схем
Перед утверждением схемы базы данных инженерам стоит свериться со следующими правилами:
- Исключить типы
realиdouble precisionиз колонок денежных балансов и расчетов. - При выборе
numericявно задавать параметрыprecisionиscale, напримерnumeric(14, 2)для рублей. - При выборе
bigintуказывать единицу измерения в названии колонки (например,amount_centsилиbalance_kopecks) и хранить код валюты рядом. - Контролировать сериализацию в API: в JavaScript тип
Numberпредставляет собой IEEE 754 float, искажающий целые числа больше 2⁵³ − 1 и десятичные дроби. Денежные значения должны передаваться в виде строк (string) либо обрабатываться специализированными библиотеками. - Разработать план миграции: если исторические данные хранились в
double precision, операцияALTER TABLE ... TYPE numericне восстановит потерянную точность; потребуется сверка с первичными выписками. - Протестировать граничные условия: нулевые и отрицательные суммы, лимиты переполнения, взаимные возвраты, конвертацию валют и распределение некратного остатка при делении платежа.
Осознанный выбор между точностью numeric и скоростью bigint позволяет заложить надежный фундамент биллинга, способный выдерживать рост нагрузки без риска финансовых расхождений.
