BigDecimal в Java: почему 1.0 и 1.00 не равны и как это ломает платежи
PrimeCoder · 2026 · формат: tech
Разбор новинки простыми словами: что это, кому нужно и брать ли.
Сравнение денег через equals в Java рождает ложные события об изменении цены и лишние записи в БД — нормализация масштаба на границе доменной модели дешевле, чем разбор таких инцидентов в проде.
Связанные услуги: каталог, AI Boost Team, стать клиентом.
Цена не менялась. Событие ушло
Клиент открыл карточку товара, поправил описание, нажал «Сохранить». Цену не трогал. Бэкенд сравнил присланную сумму с той, что лежит в базе, увидел расхождение, обновил строку и опубликовал PriceChanged. Дальше по цепочке поехали витрина, кэш, выгрузка в CRM, письмо подписчику о снижении цены. Цены не менялись.
Причина в одной строке: if (!incoming.price().equals(stored.price())). BigDecimal хранит число как пару — целое «немасштабированное» значение плюс scale, количество знаков после запятой. equals сверяет оба компонента. Единица, записанная как 1.1 и как 1.10, для него два разных объекта. Для compareTo — одно и то же число.
Это не редкий случай на стыке экзотических библиотек. Масштаб приходит из базы, из JSON, из арифметики — сразу и помногу. Разбор ниже про то, где именно закладывается расхождение и сколько стоит нормализация на границе доменной модели по сравнению с разбором инцидента в проде.
Что такое scale и почему он вообще есть
Масштаб — не баг, а смысл. Десятичное число с фиксированной точностью нужно там, где важна не только величина, но и количество значащих знаков: бухгалтерская точность, курсы, тарифы, где 1.10 и 1.1 несут разную информацию о точности измерения. Поэтому масштаб входит в состояние объекта и, по логике авторов библиотеки, попадает в равенство.
Дальше начинается расхождение внутри самой стандартной библиотеки. Часть API опирается на equals/hashCode, часть — на compareTo. Документация к BigDecimal прямо предупреждает: естественный порядок считает равными «членов одной когорты» — все записи одного числа с разным масштабом, — а значит, он не согласован с equals. Отсюда практическое правило: смотреть не на тип, а на метод. Каждый метод решает сам, по какую сторону границы он стоит.
Проверяется это в четыре строки. Примеры прогонялись на JDK 25:
var values = List.of(new BigDecimal("1"), new BigDecimal("1.0"),
new BigDecimal("1.00"), new BigDecimal("1.000"));
new HashSet<>(values).size(); // 4
new TreeSet<>(values).size(); // 1
values.stream().distinct().count(); // 4
Хеши у 1.0 и 1.00 тоже разные: 311 и 3102. Сортировка перед distinct() ничего не исправит — distinct() по-прежнему смотрит на equals.
HashSet и distinct() опираются на equals и hashCode, а equals у BigDecimal сравнивает и значение, и масштаб.
Таблица «факт → что это значит»
| Факт | Что это значит для сервиса |
|---|---|
equals сверяет значение и scale, compareTo — только значение | Любое сравнение денег — осознанный выбор метода. По умолчанию выбирают неверный. |
HashSet из 1, 1.0, 1.00, 1.000 даёт 4 элемента, TreeSet — 1 | Дедупликация сумм через distinct() или HashMap молча пропускает дубли. |
HashMap.get(new BigDecimal("990")) не находит тариф, положенный как 990.00 | Справочники с суммой в ключе ломаются на ровном месте. Для ключа нужен TreeMap. |
pgjdbc 42.7.8, PostgreSQL: numeric(10,2) отдаёт 1.10 (scale 2), numeric без ограничения — 1.1 (scale 1) | Записали одно значение, прочитали другое по equals. Ложные события на каждом сохранении. |
Jackson 2.18 сохраняет масштаб из текста JSON: 1.10 != 1.1 | Обновление фронтенда, начавшего слать два знака вместо одного, меняет логику сравнения без единой правки в бэке. |
BigDecimal.ZERO.equals(new BigDecimal("0.00")) → false | Проверка «баланс нулевой?» на колонке numeric(10,2) не срабатывает никогда. |
new BigDecimal(0.1) даёт 0.10000000000000000555… | Двойка приходит в конструктор и остаётся в данных навсегда. |
Откуда берётся масштаб, если его никто не задавал
В коде редко пишут new BigDecimal("1.00") руками. Кажется, что разные масштабы в одном сервисе — редкость. На деле их поставляет каждый источник, и у каждого своя арифметика.
Вычисления. При сложении масштаб результата — наибольший из двух: 1.10 + 2.5 даёт 3.60. При умножении масштабы складываются: 1.10 × 1.10 — это 1.2100. Через две-три операции одна и та же сумма приходит с scale 2, 4 или 6 — смотря по тому, каким путём её считали. Деление отдельная история: без явного масштаба нетерминированная дробь роняет расчёт с ArithmeticException, поэтому divide пишут с явным scale и RoundingMode. MathContext.DECIMAL128 на 34 значащие цифры управляет точностью, но сам по себе не выравнивает масштабы между источниками.
База. Масштаб диктует тип столбца. Значение 1.1, записанное в numeric(10,2) и в numeric без ограничения, читается обратно как 1.10 и 1.1 соответственно. Приложение записало сумму и получило её же — но другой по equals.
JSON. Jackson 2.18 при разборе {"amount": 1.10} в поле типа BigDecimal сохраняет текст числа как есть, с масштабом 2. Отдельная засада — разбор в Map вместо типизированного объекта: тогда дробные числа по умолчанию становятся Double, и хвост 0.10000000000000000001 исчезает раньше, чем кто-то успел обернуть значение в BigDecimal. Флаг DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS заставляет Jackson создавать BigDecimal и в этом случае.
Чем это отличается от «просто double»
Лет десять назад главным разговором про деньги в Java был отказ от double. Аргумент простой: двоичная плавающая точка не представляет 0,1 точно, и 0.1 + 0.2 даёт не 0.3. Переход на BigDecimal эту половину вопроса закрыл — сложение не теряет копейки.
Вторая половина открылась позже, когда сервисы обросли событиями, кэшами и справочниками. Оказалось, что точное число ещё нужно уметь сравнивать, а инструмент сравнения по умолчанию выбирается неверно. Проблема сместилась с арифметики на идентичность: два объекта описывают одну сумму, а система считает их разными.
И третье отличие от мира double: там масштаба нет вовсе, поэтому нет и расхождения. BigDecimal вернул точность и вместе с ней — обязанность договориться о масштабе внутри системы.
Кому это нужно и что делать руками
В первую очередь платёжным и биллинговым сервисам: там сравнение сумм порождает события и записи. Дальше — каталоги и прайсы, где цена выступает ключом поиска в тарифной сетке. И любая система, которая принимает деньги из нескольких источников: API, выгрузка из 1С, ручная правка в админке, импорт из брокерского файла.
Порядок действий, который закрывает большую часть случаев:
- Сравнение изменений — через
compareTo(...) != 0. Это снимает ложныеPriceChangedбез переписывания остального кода. - Ноль и знак — через
signum(). Никогда черезequals(BigDecimal.ZERO). - Ключи справочников —
TreeMapвместоHashMap. Поиск идёт черезcompareToи находит тариф по 990, 990.0 и 990.00 одинаково. Плата — логарифмическое время вместо константного; для справочника на сотню строк, который читают при каждом расчёте и почти никогда не меняют, это не видно ни в одном профиле. - Нормализация на границе домена. Масштаб приводится один раз, при входе суммы в модель. Естественный масштаб задаёт валюта: у рубля два знака, у иены ни одного,
Currency.getDefaultFractionDigits()это знает. Компактный конструктор record делает нормализацию обязательной, а не пожеланием:
record Money(BigDecimal amount, Currency currency) {
Money {
amount = amount.setScale(currency.getDefaultFractionDigits(),
RoundingMode.UNNECESSARY);
}
}
После этого Money.of("1.1", "RUB") и Money.of("1.10", "RUB") равны по сгенерированному equals, в HashSet из 990 и 990.00 остаётся один элемент, а иена хранится как 500 без дробной части. Без такой границы record Price(BigDecimal amount, String currency) наследует все проблемы BigDecimal: его equals сверяет компоненты их собственными equals.
Отдельно про конструкторы. new BigDecimal(double) даёт «несколько непредсказуемый» результат — 0.1 превращается в 0.10000000000000000555…. Строковый конструктор и BigDecimal.valueOf(double) ведут себя корректно. В коде, где суммы приходят из JSON или из чужого API, двойка в конструкторе — прямой путь к расхождению, которое никто не будет искать.
Где подвох
RoundingMode.UNNECESSARY выбран намеренно: Money.of("10.005", "RUB") падает с ArithmeticException: Rounding necessary. Сумма с долями копейки — это ошибка данных или расчёта, и молча округлять её в конструкторе хуже, чем уронить операцию. Но решение придётся принять осознанно: падение в проде требует обработчика, иначе вы обменяли тихое расхождение на громкий 500.
Нормализация не спасёт legacy-данные. Строки, уже записанные с произвольным масштабом, останутся такими до миграции или до первого прохода через новую модель. И не всякий масштаб лишний: в курсах, коэффициентах и ценах за единицу товара знаки после запятой несут смысл, там слепая нормализация до двух знаков — потеря данных. Деньги нормализуются, ставки и коэффициенты — нет.
Наконец, TreeMap и TreeSet решают проблему ключей, но не проблему записей. record зовёт equals сам, и обойти это можно только нормализацией на входе.
Брать или подождать: вердикт за 30 секунд
Брать. Не как срочный рефакторинг всего, а как три правки в текущем спринте: compareTo вместо equals в проверках изменений, signum() вместо сравнения с нулём, компактный конструктор с setScale на входе в доменную модель. Это часы работы. Разбор инцидента, где подписчики получили пачку ложных PriceChanged и кто-то уже успел пересчитать витрину, стоит дороже.
Подождать можно с миграцией исторических данных и с переводом всех справочников на TreeMap — это уже отдельная задача с планом и проверкой. Но новые поля и новые модели заводите сразу с нормализованным масштабом. Задним числом вычищать масштаб из базы и очередей заметно неприятнее.
Вердикт
BigDecimal закрывает арифметику и оставляет открытым сравнение. Кто сравнивает деньги через equals, получает систему, которая считает неизменные цены изменившимися, а одинаковые суммы — разными. Лечится это не библиотекой и не фреймворком, а одним правилом: масштаб нормализуется один раз, на границе, и дальше внутри домена вопросов о нём не возникает.
FAQ
Почему equals и compareTo расходятся?
Так устроен класс: в состояние входит масштаб, поэтому в equals/hashCode он участвует, а в числовом порядке — нет. Документация предупреждает, что естественный порядок не согласован с equals, и советует осторожность с BigDecimal как ключом SortedMap и элементом SortedSet.
Можно ли просто везде использовать compareTo?
В явных сравнениях — да. В коллекциях и record — нет: они вызывают equals сами, и вы туда не вклинитесь. Поэтому нормализация на границе нужна как дополнение, а не как замена.
Что делать с уже накопленными суммами?
Приводить к масштабу валюты при чтении, до сравнения, и отдельной задачей — миграцией. Пока миграции нет, проверка на ноль через equals не работает вообще: BigDecimal.ZERO.equals(new BigDecimal("0.00")) возвращает false.
BigDecimal.ZERO.equals(new BigDecimal("0.00")) возвращает false. Проверка «баланс нулевой?» через equals на значении из numeric(10,2) никогда не сработает, а signum() == 0 срабатывает всегда.
Справочник тарифов действительно нужно переводить на TreeMap?
Если сумма — ключ поиска, да. Иначе получите ровно этот сценарий: тариф за 990 рублей существует, а по сумме 990 не находится.
Тариф за 990 рублей существует, но найти его по сумме 990 нельзя.
Поможет ли USE_BIG_DECIMAL_FOR_FLOATS?
Он закрывает только потерю хвоста при разборе JSON в Map. Масштаб из текста JSON Jackson 2.18 и без флага сохраняет — и там расхождение остаётся.
Что сделать на этой неделе
Откройте поиск по репозиторию и найдите два шаблона: .equals( рядом с полями сумм и сравнения BigDecimal с ZERO. Дальше — по списку: заменить на compareTo(...) != 0 и signum(), добавить в модель денег компактный конструктор с setScale(currency.getDefaultFractionDigits(), RoundingMode.UNNECESSARY), проверить, что деление везде идёт с явным масштабом и RoundingMode, а конструктор new BigDecimal(double) не встречается вовсе. Начните с модуля, который публикует события об изменении цены: там цена ошибки видна быстрее всего.