Однажды в продакшне обнаруживается, что заказ с id 9007199254740993 в базе данных существует, а по этому же id в JavaScript приложении найти его не получается. Не баг базы, не баг API. Баг в самой природе чисел JavaScript, и JSON тут просто оказался посыльным, который принес плохую новость.
Откуда растут ноги проблемы
JavaScript хранит все числа как 64-битные числа с плавающей точкой формата IEEE 754, независимо от того, целое это число или дробное. У такого представления есть жесткий предел точности для целых чисел, Number.MAX_SAFE_INTEGER, что равно 9007199254740991. Выше этого значения соседние целые числа физически не могут быть представлены по отдельности, они начинают округляться до ближайшего представимого значения.
console.log(Number.MAX_SAFE_INTEGER); // 9007199254740991
console.log(9007199254740993 === 9007199254740992); // true, это не опечатка
Базы данных вроде PostgreSQL с типом bigint или Twitter с их знаменитыми snowflake id спокойно оперируют числами куда больше этого предела. Как только такое число проходит через JSON.parse, оно тихо теряет точность, без единого предупреждения или ошибки.
Демонстрация проблемы
const raw = '{"orderId": 9007199254740993}';
const parsed = JSON.parse(raw);
console.log(parsed.orderId); // 9007199254740992, число изменилось
Обратите внимание, ошибки не было. JSON.parse не считает это проблемой, потому что синтаксически число валидно, просто JavaScript физически не может сохранить именно это значение с точностью до единицы.
Способ первый: строка вместо числа в самом API
Самое надежное и самое часто рекомендуемое решение, отдавать большие идентификаторы строками с самого начала, а не числами.
{ "orderId": "9007199254740993" }
| Подход | Плюс | Минус |
|---|---|---|
| Число в JSON | Компактнее, привычнее | Теряет точность выше MAX_SAFE_INTEGER |
| Строка в JSON | Полностью безопасно | Нужно явно приводить к числу при математике |
| BigInt через ревайвер | Точность сохраняется | Требует поддержки BigInt всем кодом дальше по цепочке |
Строка это по сути konsensus индустрии для действительно больших идентификаторов, GitHub API, Twitter API и большинство других сервисов с snowflake-подобными id отдают их именно строками.
Способ второй: reviver функция с BigInt
Если менять формат API невозможно, а точность критична, можно перехватить парсинг через второй аргумент JSON.parse.
const raw = '{"orderId": 9007199254740993}';
const parsed = JSON.parse(raw, (key, value, context) => {
if (typeof value === "number" && !Number.isSafeInteger(value)) {
return BigInt(context.source);
}
return value;
});
Стоит учесть: context.source в reviver доступен только в достаточно новых версиях движка JavaScript, и придется дальше по всему коду аккуратно работать с типом BigInt, который не сериализуется обратно через обычный JSON.stringify без отдельной обработки.
Способ третий: специализированный парсер
Библиотеки вроде json-bigint парсят JSON вручную, а не через встроенный движок, и позволяют явно указать, что большие числа нужно представлять либо строкой, либо BigInt, без риска молчаливой потери точности где-то на полпути.
import JSONbig from "json-bigint";
const parsed = JSONbig.parse(raw);
console.log(parsed.orderId.toString()); // 9007199254740993, без искажения
Как быстро проверить, есть ли у вас эта проблема
Возьмите ваш реальный самый большой идентификатор из базы, оберните в JSON, и вставьте на страницу JSON Formatter, провалидируйте, а после откройте devtools console и сравните строковое представление до и после. Если числа совпадают, вам повезло, диапазон id пока в безопасной зоне. Если нет, самое время выбрать один из трех способов выше, пока это не всплыло в проде в пятницу вечером.
Итоговый чеклист
- Если контролируете формат API, отдавайте id больше 2^53 строками сразу, не оставляйте это на волю парсера
- Если формат API чужой, добавьте reviver или специализированную библиотеку на своей стороне
- Проверяйте реальные максимальные значения id в вашей базе данных, а не полагайтесь на то, что “у нас пока маленькие числа”
- Помните, что проблема касается не только id, но и любых больших чисел, включая финансовые суммы в минимальных единицах вроде копеек или центов при достаточно больших оборотах