一次 0.001 的生产数据异常:WMS 出入库数量精度丢失排查
出入库数量从
29056.122变成29056.121,差值只有 0.001,且大部分数据看起来正常。但对库存、订单、供应链系统来说,这是数据正确性问题。本文记录这次真实排查:如何通过落库数据、日志、本地复现,最终定位到JSONTokener.nextValue()把字符串解析成了 Float。
排查完成日期:2026-09-04(本文数据已全部脱敏)
一、问题现象
WMS 通过 WebService 向集成服务推送出入库数据,数量字段 MENGE 原始报文为 29056.122,进入后续系统后变成 29056.121。
两个特点让它隐蔽性很强:
-
差值仅 0.001,业务侧容易忽略;
-
并非所有数据都出错 ------比如
29056.125就一切正常,所以最初看起来像偶发异常。
典型问题数据(已脱敏):执行单 ORD-202608-XXXXXX,物料 MAT-XXXXXX,单位 KG,行号 10。
二、先说结论
WMS 原始数据正确,MQ 没有改数据,下游也没有计算错误。问题出在集成服务内部的 JSON 解析:字符串被 JSONTokener.nextValue() 解析成了 Float,浮点舍入后无法还原。
javascript
"29056.122"(String)
↓ JSONTokener.nextValue()
29056.121(Float,IEEE 754 单精度,舍入发生在这里)
↓ 转回字符串
"29056.121" ------ 原始值已丢失,无法恢复
三、定位过程:三轮证据
生产排查不能一上来就看代码,正确姿势是先逐段比对链路,确认数据在哪一跳发生变化。
证据一:落库的原始报文 vs 解析结果
集成服务消息存储表中,同一记录的原始 XML 和解析后 JSON 并排保存:
| 字段 | 内容 | 值 |
| --- | --- | --- |
| message_content | WMS 原始 XML | <MENGE>29056.122</MENGE> |
| result_content | 服务解析后的 JSON | "menge":"29056.121" |
数据在集成服务内部就已经变了。这一条证据直接排除了 WMS 生成错误、MQ 传输丢精度、下游首次修改、DB 显示误差。
证据二:全链路日志交叉验证
按执行单 + 物料 + 时间(2026-08-11 10:36)追踪同一条数据:
| 链路位置 | 数量 |
| --- | --- |
| WMS → Integration Service | 29056.122 |
| Integration Service → MQ | 29056.121 |
| MQ → Order Service | 29056.121 |
| Order Service → Supply Chain Service | 29056.121 |
变化只发生在 Integration Service 一跳,MQ 和下游只是传播了错误数据。
证据三:本地最小复现
从生产 fat jar 中提取实际运行依赖(org.json、fastjson、json-lib 及传递依赖),按生产调用顺序复刻:
| 步骤 | 操作 | 结果 |
| --- | --- | --- |
| ① | XML.toJSONObject(xml) | 29056.122 |
| ② | fastjson 重解析 | 29056.122 |
| ③ | JSONTokener.nextValue() | Float = 29056.121 |
| ④ | fastjson getString("MENGE") | "29056.121" |
最小复现只有两行:
java
Object value = new JSONTokener("29056.122").nextValue();
System.out.println(value.getClass()); // class java.lang.Float
System.out.println(value); // 29056.121
而 new JSONTokener("29056.125").nextValue() 输出的还是 29056.125。整个过程中没有减法、没有四舍五入、没有截断,仅仅是"字符串 → 浮点数"就改变了数据。是否产生肉眼可见的误差取决于具体数值和它的二进制表示,这正是问题显得"偶发"的原因。
四、根因
业务代码中的这段调用:
java
new JSONTokener(stockReq.getString("StockItem")).nextValue();
nextValue() 遇到数字时走了 Float 路径,精度在这里丢失,之后转回字符串也无法还原。业务数量本应是精确值,却进入了近似浮点域------这就是典型的 String → Float → String 精度丢失。
五、为什么这类问题很难发现?
如果所有数据都异常,反而好查。麻烦在于 IEEE 754 的舍入是"挑值"的:29056.125 正常、29056.122 出错,于是生产上只有少量特定尾数的数据暴露问题,大部分数据看起来毫无异常------问题可以长期潜伏。
另外,代码检索发现 JSONTokener.nextValue() 在该服务中共有 17 处 调用,横跨多个 WebService 方法,潜在受影响字段不止 MENGE,还有 QTY、CHANGE_QTY、ORDER_QTY、GIVE_WAY_QTY 等。凡是"业务字段 → JSONTokener → 低精度浮点"的路径都存在同类风险。
六、一个重要排查经验:源码 ≠ 运行时
公开的 json-lib 2.4 JSONTokener 源码中,普通数字解析路径与本次生产运行结果存在差异。所以严谨的说法不是"json-lib 2.4 一定把小数解析成 Float",而是"本次生产实际运行环境中,nextValue() 对该输入返回了 java.lang.Float"。
排查线上依赖问题时,真正应该相信的是 ClassLoader 实际加载的 class,而不是 pom.xml。建议加上运行时检查:
java
System.out.println(
JSONTokener.class.getProtectionDomain().getCodeSource().getLocation()
);
确认类到底来自哪个 jar,同时排查重复依赖、shade/relocation、多版本共存和 ClassLoader 差异。
七、解决方案
短期止血:
-
将
new JSONTokener(str).nextValue()替换为统一 JSON 框架的解析(如JSON.parse(str)),并同步处理net.sf.json.JSONArray分支,保持单对象/数组逻辑一致。改动小、风险可控,可快速上线; -
数量字段改用
BigDecimal(object.getBigDecimal("MENGE"))或纯透传场景直接getString,原则是数量全程不进 Float/Double; -
对历史数据巡检:比对消息表中原始报文数量与解析结果数量,逐条找出不一致记录,评估修正、重推或补偿。代码修复只救未来,历史上的
29056.121不会自己变回去。
长期治理:
-
逐步移除 json-lib。当前系统同时使用 org.json、fastjson、json-lib 三套框架,一次转换要经历 XML → JSON → JSONObject → JSONTokener → JSONObject → String 的多次变换,这个转换链本身就是风险;
-
数量/金额类字段统一 BigDecimal 或 String,禁止进入浮点类型;
-
增加全链路精度回归测试:用
29056.122、20.000、65535.999等不同数量级和尾数的用例,从 XML 进、到 MQ 出,断言最终值 == 原始值------测的是整条链路,不是单个方法; -
对核心数量字段增加线上入口校验:原始值 != 解析值时记录告警(含单号、字段、两个值),把问题拦在入口,而不是等下游对账或业务投诉。
八、写在最后
29056.122 变成 29056.121,表面上是一次 JSON 解析的坑,本质上是一个设计问题:需要精确表示的业务数量,在中间环节被放进了低精度浮点域。
要记住的不是"某个 JSON 库有问题",而是:库存、订单、出入库、供应链这类系统里,业务数量应该全程走 String / BigDecimal。对这些系统来说,0.001 不是一个显示问题,而是一个数据正确性问题。