JSON 转换中的精度丢失问题详解

2023-04-10 前端

前言

JSON(JavaScript Object Notation)作为最流行的数据交换格式,被广泛应用于 Web API、配置文件和数据存储。然而,在处理大整数时,JSON 和 JavaScript 存在一个众所周知的问题:精度丢失。当你在 JavaScript 中解析一个超过 Number.MAX_SAFE_INTEGER(即 2^53 - 1)的整数时,精度会自动丢失。本文将深入分析这一问题的成因、影响范围,并提供各语言的解决方案。

1. 问题现象

1.1 经典案例

// 一个后端返回的大整数 ID
const json = '{"id": 9007199254740993, "name": "test"}';

// 解析 JSON
const obj = JSON.parse(json);

console.log(obj.id);        // 输出:9007199254740992
console.log(obj.id === 9007199254740993);  // false

问题:原始值是 9007199254740993,解析后变成了 9007199254740992!

1.2 问题复现

// 在浏览器控制台或 Node.js 中执行
const id = 9007199254740993;
console.log(id);  // 9007199254740992(精度已丢失)

// 更明显的例子
console.log(9007199254740993 === 9007199254740992);  // true!(显然不对)

2. 问题根源分析

2.1 JavaScript 的数字类型

JavaScript 中所有数字都是 IEEE 754 双精度浮点数(64-bit floating point),遵循以下标准:

双精度浮点数结构:
┌─────────┬──────────┬────────────────────────────┐
│ 符号位   │ 指数位    │ 尾数位(有效数字)          │
│ 1 bit   │ 11 bits  │ 52 bits                   │
└─────────┴──────────┴────────────────────────────┘
  • 符号位:1 位(0 正 1 负)
  • 指数位:11 位(表示 2 的幂次)
  • 尾数位:52 位 + 1 位隐含的 1 = 53 位有效数字

2.2 为什么是 53 位有效位?

双精度浮点数有 52 位尾数位,但 IEEE 754 规定尾数前面有一个隐含的 1(规格化数字),所以实际有效位是 53 位。

最大安全整数 = 2^53 - 1 = 9007199254740991

这意味着:

  • 从 -9007199254740991 到 9007199254740991 之间的整数是安全的
  • 超过这个范围的整数可能无法精确表示

2.3 精度丢失的数学原理

// 以 9007199254740993 为例
// 二进制表示需要 54 位,但只有 53 位有效位
// 所以最后一位会被四舍五入

console.log(Number.MAX_SAFE_INTEGER);       // 9007199254740991
console.log(Number.MAX_SAFE_INTEGER + 1);   // 9007199254740992(正常)
console.log(Number.MAX_SAFE_INTEGER + 2);   // 9007199254740992(异常!精度丢失)
console.log(Number.MAX_SAFE_INTEGER + 3);   // 9007199254740994(正常)

规律:超过 MAX_SAFE_INTEGER 后,偶数可以表示,奇数会被舍入到最近的偶数。

3. 影响范围

3.1 哪些场景会受影响?

场景 说明 风险
数据库主键 ID 雪花算法生成的 ID 通常超过 53 位 高
时间戳(毫秒) 到 2266 年才会超过 低
金额计算 以分为单位时可能超过 中
分布式 ID 如 Snowflake、UUID 数字版 高
科学计算 大整数运算 高

3.2 常见后端 ID 生成策略

策略 ID 范围 是否安全
MySQL AUTO_INCREMENT 通常 < 2^31 安全
MongoDB ObjectId 24 位十六进制字符串 安全(字符串)
Snowflake ID 通常 64 位整数 不安全
UUID(数字版) 128 位 不安全
自增 BIGINT 可达 2^63 - 1 不安全

4. 解决方案

4.1 方案一:字符串传输(推荐)

将大整数以字符串形式传输,避免 JSON 解析时的精度丢失。

后端处理(以 Go 为例)

package main

import (
    "encoding/json"
    "net/http"
)

type Response struct {
    ID    string `json:"id"`     // 使用 string 而非 int64
    Name  string `json:"name"`
}

func handler(w http.ResponseWriter, r *http.Request) {
    resp := Response{
        ID:   "9007199254740993",  // 大整数作为字符串
        Name: "test",
    }
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(resp)
}

前端处理(JavaScript)

// 后端返回字符串,前端可以直接使用
const response = await fetch('/api/data').then(r => r.json());
console.log(response.id);        // "9007199254740993"(字符串)
console.log(typeof response.id); // "string"

// 如需数值计算,转换为 BigInt
const id = BigInt(response.id);
console.log(id.toString());      // "9007199254740993"

4.2 方案二:使用 BigInt

BigInt 是 ES2020 引入的新原始类型,可以表示任意大的整数。

使用 BigInt 解析

// 方法 1:手动转换
const json = '{"id": 9007199254740993}';
const obj = JSON.parse(json, (key, value) => {
    // 判断是否为大整数(简单判断:超过安全范围的整数)
    if (typeof value === 'number' && 
        !Number.isSafeInteger(value) && 
        Number.isInteger(value)) {
        return BigInt(value);
    }
    return value;
});

console.log(obj.id);        // 9007199254740993n(BigInt)
console.log(typeof obj.id); // "bigint"

更健壮的 JSON 解析(使用 reviver)

function parseWithBigInt(jsonString) {
    return JSON.parse(jsonString, (key, value) => {
        // 匹配看起来像整数的字符串或数字
        if (typeof value === 'number') {
            // 检查是否为整数且超出安全范围
            if (!Number.isSafeInteger(value) && Number.isInteger(value)) {
                // 返回字符串,让调用者决定是否转为 BigInt
                return value.toString();
            }
        }
        return value;
    });
}

// 使用
const json = '{"id": 9007199254740993, "small": 123}';
const obj = parseWithBigInt(json);
console.log(obj.id);    // "9007199254740993"(字符串)
console.log(obj.small); // 123(数字)

4.3 方案三:使用第三方库

json-bigint 库

// 安装:npm install json-bigint

const JSONbig = require('json-bigint');

const json = '{"id": 9007199254740993}';
const obj = JSONbig.parse(json);

console.log(obj.id);        // BigInt(9007199254740993)
console.log(obj.id.toString());  // "9007199254740993"

lossless-json 库

// 安装:npm install lossless-json

const { parse, stringify } = require('lossless-json');

const json = '{"id": 9007199254740993}';
const obj = parse(json);

console.log(obj.id.value);  // "9007199254740993"(LosslessNumber)
console.log(obj.id.valueOf());  // 转为 BigInt 或字符串

5. 各语言处理方案对比

5.1 JavaScript/TypeScript

方案 优点 缺点
字符串传输 简单可靠 需要前后端配合
BigInt 原生支持大整数 需要 JSON 解析器支持
json-bigint 兼容性好 额外依赖
自定义 reviver 无依赖 实现复杂

TypeScript 类型定义:

// 定义支持大整数的类型
interface ApiResponse {
    id: string;  // 大整数用字符串
    name: string;
}

// 或使用 BigInt
interface ApiResponseWithBigInt {
    id: bigint;
    name: string;
}

5.2 Java

Java 的 long 类型是 64 位整数,Jackson 等 JSON 库默认可以处理:

// Jackson 配置:支持大整数
ObjectMapper mapper = new ObjectMapper();
mapper.configure(JsonParser.Feature.ALLOW_NON_NUMERIC_NUMBERS, true);

// 或使用字符串
public class Response {
    private String id;  // 使用 String 接收大整数
    private String name;
    // getters and setters
}

如果使用超过 64 位的整数,需要:

// 使用 BigInteger
import java.math.BigInteger;

public class Response {
    private BigInteger id;
    private String name;
}

5.3 Python

Python 的 int 是任意精度整数,没有精度丢失问题:

import json

# Python 的 int 没有精度限制
data = json.loads('{"id": 9007199254740993}')
print(data["id"])          # 9007199254740993
print(type(data["id"]))   # <class 'int'>

# 但是序列化时需要注意
# 如果后端是 Python,前端是 JS,仍然需要字符串传输

Python 发送到前端时的处理:

import json

class BigIntEncoder(json.JSONEncoder):
    def default(self, obj):
        if isinstance(obj, int) and obj > 2**53 - 1:
            return str(obj)  # 转为字符串
        return super().default(obj)

data = {"id": 9007199254740993}
print(json.dumps(data, cls=BigIntEncoder))
# 输出:{"id": "9007199254740993"}

5.4 Go

Go 的 int64 是 64 位整数,JSON 序列化时需要注意:

package main

import (
    "encoding/json"
    "fmt"
)

type Response struct {
    // 方案 1:使用 string 类型
    ID    string `json:"id"`

    // 方案 2:使用自定义 Marshaler
    // ID    json.Number `json:"id"`  // json.Number 是字符串
}

func main() {
    // 序列化时手动处理
    id := int64(9007199254740993)
    resp := map[string]interface{}{
        "id": fmt.Sprintf("%d", id),  // 转为字符串
        "name": "test",
    }

    data, _ := json.Marshal(resp)
    fmt.Println(string(data))
    // 输出:{"id":"9007199254740993","name":"test"}
}

5.5 PHP

PHP 的 int 受平台影响(32 位或 64 位),处理大整数时建议:

<?php
// 使用字符串处理大整数
$data = [
    'id' => '9007199254740993',  // 字符串
    'name' => 'test'
];

echo json_encode($data);
// 输出:{"id":"9007199254740993","name":"test"}

// 使用 bcmath 扩展进行大整数计算
$id = '9007199254740993';
$result = bcadd($id, '1', 0);
echo $result;  // 9007199254740994

6. 最佳实践总结

6.1 前后端协同方案

推荐方案:后端以字符串形式返回大整数 ID
──────────────────────────────────────────────
后端(Go/Java/Python/PHP):
  - 定义一个 JSON 序列化选项
  - 将 > 2^53 - 1 的整数转为字符串

前端(JavaScript):
  - 直接使用字符串 ID
  - 如需计算,使用 BigInt(id)
  - 发送回后端时,仍以字符串发送

6.2 各场景处理建议

场景 推荐方案
数据库主键 字符串传输 或 使用 UUID
API 响应 字符串传输
前端计算 BigInt(str)
存储 后端用原生大整数,前端用字符串
第三方 API 文档确认 ID 类型,必要时用字符串

6.3 代码检查建议

// ESLint 规则:禁止使用可能不安全的整数
// .eslintrc.js
module.exports = {
    rules: {
        'no-restricted-syntax': ['error', {
            selector: 'Literal[value>9007199254740991]',
            message: '超过 MAX_SAFE_INTEGER 的整数可能导致精度丢失'
        }]
    }
};

7. 实际案例分析

7.1 案例:Twitter API 的 ID 问题

Twitter 的推文 ID 使用雪花算法生成,超过 JavaScript 安全整数范围。Twitter API v1.1 曾因返回数字 ID 导致精度丢失,后来在 v2 中改为返回字符串 ID。

// Twitter API v1.1(有问题)
{
    "id": 1234567890123456789  // 解析后可能变成 1234567890123456800
}

// Twitter API v2(已修复)
{
    "id": "1234567890123456789"  // 字符串,安全
}

7.2 案例:雪花算法 ID

// 雪花算法生成的 ID 通常类似这样:
// 时间戳(41位) + 机器ID(10位) + 序列号(12位) = 63位

// 这样的 ID 通常超过 2^53 - 1
const snowflakeId = 9223372036854775807;  // 可能超过安全范围

// 解决方案:后端返回字符串
{ "id": "9223372036854775807" }

8. 调试技巧

8.1 检测精度丢失

function isSafeIntegerString(str) {
    const num = Number(str);
    return Number.isSafeInteger(num) && num.toString() === str;
}

console.log(isSafeIntegerString("9007199254740991"));  // true
console.log(isSafeIntegerString("9007199254740993"));  // false(不安全)

8.2 浏览器开发者工具

// 在 Chrome DevTools 中
// 直接输入大整数会被截断
> 9007199254740993
< 9007199254740992  // 已被截断

// 使用 BigInt
> 9007199254740993n
< 9007199254740993n  // 正确

9. 总结

JSON 转换中的精度丢失问题根源在于 JavaScript 的数字类型限制(IEEE 754 双精度浮点数,53 位有效位)。

核心要点:

  1. 安全范围:[-2^53 + 1, 2^53 - 1],即 [-9007199254740991, 9007199254740991]
  2. 推荐方案:前后端协调,将大整数以字符串形式传输
  3. 前端处理:使用 BigInt 或第三方库(json-bigint)处理大整数
  4. 各语言差异:静态类型语言(Java/Go)通常没有此问题,动态语言需注意

预防措施:

  • 后端 API 文档明确 ID 类型
  • 使用字符串传输超过安全范围的整数
  • 前端收到数字 ID 时,判断是否超过安全范围
  • 关键业务使用 BigInt 或字符串进行计算

掌握这一知识点,可以避免生产环境中因精度丢失导致的严重 bug(如订单 ID 错误、支付金额错误等)。