Long类型ID前端精度丢失:从IEEE 754根因到Jackson全局序列化方案

🤔 一、问题切入:一个"找不着"的订单

某天业务反馈:用户在订单详情页点进去一片空白,后台日志里看到查的是 ID 1857353925587607500,但数据库里根本没有这条记录。翻看上游接口的原始响应体,后端明明返回的是 1857353925587607552

差了多少?不多,就差了 52:...552 变成了 ...500。但这 52 的差距足以让一条订单从数据库里彻底"消失"。

写个最简单的演示:

// 后端:Java Long 值
long orderId = 1857353925587607552L;
System.out.println(orderId);  // 输出: 1857353925587607552 ✓

后端没问题。再看前端:

// 前端:直接解析后端返回的 JSON
const json = '{"orderId": 1857353925587607552}';
const obj = JSON.parse(json);
console.log(obj.orderId);  // 输出: 1857353925587607500 ✗

同一个数字,跨了一道 HTTP 就被"阉割"了最后两位精度。这不是哪家框架的 bug,也不是谁写错了代码——根因在 JavaScript Number 的底层存储格式

flowchart TD
classDef data fill:#052e16,stroke:#16a34a,stroke-width:1.5px,color:#bbf7d0,font-weight:bold;
classDef reject fill:#450a0a,stroke:#dc2626,stroke-width:1.5px,color:#fecaca,font-weight:bold;
classDef process fill:#1e1e24,stroke:#6b7280,stroke-width:1.5px,color:#e5e7eb;

    JAVA[Java Long\n1857353925587607552]
    JSON[JSON 数字\n1857353925587607552]
    PARSE[JavaScript JSON.parse]
    NUM[JS Number\n1857353925587607500]
    QUERY[用错误ID查数据库]
    MISS[查不到数据]

    JAVA -->|Jackson序列化| JSON
    JSON -->|HTTP响应| PARSE
    PARSE -->|IEEE 754精度丢失| NUM
    NUM --> QUERY
    QUERY --> MISS

    class JAVA,JSON data;
    class NUM,MISS reject;
    class PARSE,QUERY process;

这个问题的触发条件很具体:后端 Long 值超过 9007199254740991(即 2^53 ~ 1,约 16 位十进制数)时,前端 JSON.parse() 解析出的数字就会丢失精度。雪花算法生成的 ID 通常 17 ~ 19 位,正好踩在坑里。

⚠️ 新手提示:这不是"偶尔丢一点"的随机 bug。同一个 Long 值每次丢的精度是确定性的——IEEE 754 的舍入规则是数学运算,不是概率事件。所以你的测试可能次次踩在同一个坑里。

🏗️ 二、根因:IEEE 754 双精度浮点为什么存不下 17 位整数

JavaScript 只有一种数字类型——NumberNumber 的底层是 IEEE 754 双精度浮点数(64 位),它把一块 64 位的内存拆成三部分:

S
指数(Exponent)11 位
尾数(Mantissa / Fraction)52 位
1
11 位
52 位
共 64 位 = 1 + 11 + 52

Sign(1 位) :符号位,0 正 1 负。

Exponent(11 位) :指数,采用偏移值 1023。实际指数 = 指数字段值 - 1023。

Mantissa(52 位) :尾数,存储有效数字的小数部分。对于规约化数,隐含前导的 1(即实际有效数字为 1.mantissa)。

IEEE 754 双精度能精确表示的连续整数范围-(2^53 ~ 1) ~ (2^53 ~ 1),即 -9007199254740991 ~ 9007199254740991。超出这个范围,相邻两个精确整数的间隔会变成 2、4、8……直到非常大。

整数范围相邻精确整数的间隔说明
-2^53 ~ 12^53 ~ 11所有整数精确表示
2^532^542只能表示偶数
2^542^554只能表示 4 的倍数
2^552^568只能表示 8 的倍数
递增 ×2指数越大间隔越大

雪花算法的标准 ID 是 64 位(1 位符号 + 41 位时间戳 + 10 位机器 ID + 12 位序列号),值域约 2^59 ~ 2^60。在这个范围,相邻精确整数的间隔是 2^7 = 128 或更大——也就是说,后端传 ...552...500,在 JS Number 看来是"同一个值"。

flowchart LR
classDef data fill:#052e16,stroke:#16a34a,stroke-width:1.5px,color:#bbf7d0,font-weight:bold;
classDef highlight fill:#450a0a,stroke:#dc2626,stroke-width:1.5px,color:#fecaca,font-weight:bold;
classDef process fill:#1e1e24,stroke:#6b7280,stroke-width:1.5px,color:#e5e7eb;
classDef reject fill:#450a0a,stroke:#dc2626,stroke-width:1.5px,color:#fecaca,font-weight:bold;

    SNOW[雪花算法ID\n17 ~ 19位十进制]
    SAFE[JS Number安全整数上限\n9007199254740991\n约16位]
    UNSAFE[超出安全范围\n精度丢失区域]

    SNOW -->|远大于| SAFE
    SAFE -->|超出范围| UNSAFE
    UNSAFE -->|舍入误差| LOST[低位被截断或舍入]

    class SAFE data;
    class SNOW,UNSAFE highlight;
    class LOST reject;

📌 前置知识:理解本节需要知道 IEEE 754 浮点数的基本组成(符号位+指数+尾数)以及二进制科学计数法的概念(任何浮点数表示为 ±1.m × 2^e)。

🔧 三、流程深入:Jackson 序列化链路中 Long 是如何变成 JSON 数字的

在动手改代码之前,先把 Jackson 的序列化链路搞清楚——否则改完不知道改了哪个环节。

当一个 Spring Boot 控制器返回一个包含 Long 字段的对象时,Jackson 执行以下序列化流程:

sequenceDiagram
    participant C as Controller
    participant OM as ObjectMapper
    participant SP as SerializerProvider
    participant LS as LongSerializer
    participant J as JsonGenerator
    participant F as 前端

    C->>OM: 返回 Order 对象
    OM->>SP: 查找 Order.id 的序列化器
    SP->>SP: lookup(Long.class)
    SP-->>OM: 返回 NumberSerializer
    OM->>J: serialize(orderId)
    Note over J: 写入 1857353925587607552\n作为 JSON 数字(无引号)
    J-->>F: {"orderId":1857353925587607552}
    F->>F: JSON.parse()
    Note over F: Number(1857353925587607552)\n= 1857353925587607500

关键点在 SerializerProvider 的查找逻辑。Jackson 内部维护了一张从 Java 类型到 JsonSerializer 的映射表。当没有自定义配置时:

  • Long.classNumberSerializer(序列化为 JSON 数字)
  • long.classNumberSerializer(同上)

这个 NumberSerializer 调用 JsonGenerator.writeNumber(long),输出的是不带引号的数字字面量。问题就在这一步。

前端收到的 JSON 里,orderId 是一个裸数字。JSON.parse() 按规范使用 Number 类型存储——IEEE 754 双精度——精度丢失发生在这里。

解决方案很明确:让 Jackson 在序列化 Long 时输出字符串,即在数字两侧加引号

📝 四、源码佐证:JacksonMapper 全局配置

4.1 核心配置类

public class JacksonMapper extends ObjectMapper {
    public JacksonMapper() {
        super();
        // ① 忽略未知的 JSON 属性
        this.configure(JsonGenerator.Feature.IGNORE_UNKNOWN, true);
        // ② BigDecimal 按纯数值输出,避免科学计数法
        this.configure(JsonGenerator.Feature.WRITE_BIGDECIMAL_AS_PLAIN, true);
        // ③ 未知 JSON 属性不抛异常
        this.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);

        SimpleModule simpleModule = new SimpleModule();
        // ④ Long 对象类型 → 字符串
        simpleModule.addSerializer(Long.class, ToStringSerializer.instance);
        // ⑤ long 基本类型(包装) → 字符串
        simpleModule.addSerializer(Long.TYPE, ToStringSerializer.instance);
        // ⑥ long 基本类型 → 字符串(显式覆盖,确保万无一失)
        simpleModule.addSerializer(long.class, ToStringSerializer.instance);
        registerModule(simpleModule);
    }
}

逐行解释

行号配置项作用
IGNORE_UNKNOWN前端多传了字段后端不报错(向前兼容)
WRITE_BIGDECIMAL_AS_PLAINBigDecimal 如 128.50 输出 128.50,而非 1.2850E+2。金额字段千万别用科学计数法
FAIL_ON_UNKNOWN_PROPERTIES同上方向相反——后端收到未知字段不炸
④ ~ ⑥ToStringSerializer核心:让 Jackson 遇到 Long/long 类型时调用 Long.toString() 输出带引号的字符串,而非裸数字

⚠️ 新手提示:④、⑤、⑥ 三行缺一不可。Long.TYPE 就是 long.class——但有些 Jackson 版本下 Long.classlong.class 在序列化器查找时走不同的路径,写全三行是最稳妥的做法。写过的都懂,少注册一个类型然后线上崩了才是真疼。

4.2 MVC 消息转换器注册

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Bean
    public MappingJackson2HttpMessageConverter getMappingJackson2HttpMessageConverter() {
        return new MappingJackson2HttpMessageConverter(new JacksonMapper());
    }
}

Spring Boot 自动配置中有一个 JacksonAutoConfiguration,它默认创建 MappingJackson2HttpMessageConverter 并注入默认的 ObjectMapper。这里通过显式声明同名 Bean,Spring 的 @ConditionalOnMissingBean 检测到已有自定义 Bean 后不再自动创建,全局替换所有 HTTP 消息转换中的 ObjectMapper。

flowchart TD
classDef startEnd fill:#701a4c,stroke:#e11d48,stroke-width:2px,color:#fce7f3,font-weight:bold;
classDef process fill:#1e1e24,stroke:#6b7280,stroke-width:1.5px,color:#e5e7eb;
classDef data fill:#052e16,stroke:#16a34a,stroke-width:1.5px,color:#bbf7d0,font-weight:bold;
classDef highlight fill:#450a0a,stroke:#dc2626,stroke-width:1.5px,color:#fecaca,font-weight:bold;

    REQ[HTTP请求] --> CTR[Controller返回对象]
    CTR --> CONV[MappingJackson2HttpMessageConverter]
    CONV --> CUST[使用自定义JacksonMapper]
    CUST --> SERIAL{序列化字段类型}

    SERIAL -->|Long/long| STR[ToStringSerializer\n输出带引号字符串]
    SERIAL -->|BigDecimal| PLAIN[WRITE_BIGDECIMAL_AS_PLAIN\n输出纯数值]
    SERIAL -->|其他类型| DEF[默认序列化器]

    STR --> JSON["{#quot;orderId#quot;:#quot;1857353925587607552#quot;}"]
    PLAIN --> JSON
    DEF --> JSON

    JSON --> HTTP[HTTP响应]
    HTTP --> FRONT[前端JSON.parse]
    FRONT --> CORRECT[orderId为字符串\n精度零丢失]

    class REQ,CTR process;
    class CONV,CUST highlight;
    class STR,PLAIN,DEF process;
    class JSON data;
    class CORRECT startEnd;

4.3 效果验证

class Demo {
    public static void main(String[] args) throws Exception {
        record Order(Long id, String code) {}
        var mapper = new JacksonMapper();
        var json = mapper.writeValueAsString(
            new Order(100000000000000001L, "T20260205"));
        System.out.println(json);
        // 输出: {"id":"100000000000000001","code":"T20260205"}
        //             ↑ 注意 id 值两侧的引号——这就是关键
    }
}

典型响应体示例:

{
  "code": 200,
  "message": null,
  "data": {
    "orderId": "100000000000000001",
    "code": "T20260205",
    "payAmount": 128.50
  }
}

orderId 被双引号包裹作为字符串传输,前端 JSON.parse() 后它是一个 string 类型,不再走 Number 的 IEEE 754 转换。

🔗 五、前后端协同

方案不能只改后端——两边得对好口径,不然 A 团队改了序列化、B 团队前端代码里还拿 parseInt() 转回去,前面的活就白干了。

后端约定

规则说明
Long/long 统一输出字符串全局 JacksonMapper 配置,一劳永逸
BigDecimal 纯数值输出WRITE_BIGDECIMAL_AS_PLAIN,金额老老实实显示小数
DTO 字段标注语义@ApiModelProperty("字符串化的整型ID,前端勿转为Number")
接口文档示例对齐Swagger/Knife4j 示例中 ID 字段带引号

前端约定

// ✅ 正确:ID 字段声明为 string
interface Order {
  orderId: string;         // Long ID,后端序列化为字符串
  code: string;
  payAmount: number;       // BigDecimal 按纯数值输出,JS Number 安全
}

// ❌ 错误:ID 声明为 number
interface OrderBad {
  orderId: number;         // 大 ID 精度丢失!
  code: string;
  payAmount: number;
}
// ✅ 如需数值计算,使用 BigInt(ES2020+)
const id = BigInt("1857353925587607552");
const nextId = id + 1n;

// ❌ 不要自行 Number() 转换
const id = Number("1857353925587607552");  // 又丢了!

⚠️ 新手提示:BigInt 是 ES2020 引入的类型,需要浏览器或 Node.js 环境支持。如果项目还在跑 IE11 或老版本 Node,需要 polyfill 或改用字符串拼接的方式处理。大部分现代浏览器(Chrome 67+、Edge 79+)已支持。

可选增强:单字段级别控制

如果不想全局替换(比如某些遗留模块依赖 Long 作为数字的默认行为),可以用注解做单字段控制:

public class OrderDTO {
    @JsonSerialize(using = ToStringSerializer.class)
    private Long orderId;     // 仅此字段序列化为字符串

    private Long userId;      // 其他 Long 字段保持默认(数字)
}

但这个方案的问题是:每新增一个 Long ID 字段就得加一次注解,容易漏。建议在项目初期就上全局方案,长痛不如短痛。

🧪 六、验证与排查

配置完不等于完事了,得把验证链条走通。

单元测试

@Test
void longFieldShouldBeSerializedAsString() throws Exception {
    record Order(Long id, String code) {}
    var mapper = new JacksonMapper();
    var json = mapper.writeValueAsString(
        new Order(1857353925587607552L, "T001"));

    // 断言:JSON 中 ID 值带引号
    assertThat(json).contains("\"id\":\"1857353925587607552\"");
}

这个测试跑通了,说明 JacksonMapper 配置已生效。

接口联调

# 用 curl 直接抓接口响应,检查 Long 字段是否带引号
curl -s http://localhost:8080/api/order/1857353925587607552 | jq '.data.orderId'
# 期望输出: "1857353925587607552"(带引号,字符串)

浏览器抓包

打开 DevTools → Network 标签,找到调用后端接口的请求,查看 Response 标签中的原始 JSON。确认 orderId 字段的值被双引号包裹。

🎯 七、总结

维度要点
根因JavaScript Number 是 IEEE 754 双精度浮点,精确整数上限 2^53 ~ 1(约 16 位)。雪花 ID 17 ~ 19 位,超出范围后低位被舍入
后端方案Jackson 全局 ToStringSerializer 注册 Long/long → 字符串;WRITE_BIGDECIMAL_AS_PLAIN 保证金额输出纯数值
前端方案TypeScript ID 字段声明 string;数值计算用 BigInt;不要对后端 ID 做 parseInt()
验证单元测试断言输出 JSON 中 ID 带引号;curl 抓接口确认;DevTools 检查响应体
协同DTO 标注语义、接口文档示例与实现一致、前后端统一把 ID 当字符串处理

这个问题在微服务、分库分表、雪花 ID 普及的当下几乎每个项目都会遇到。后端把序列化配好,前端把类型声明对齐,前后端协同到位之后这坑就再也不会踩了。别等到线上订单"找不到"了才想起来改——从项目第一天就把 JacksonMapper 配好,少一件排查的冤案。

▶ 点击播放
加载中...

前端精度丢失示意