jq 是 Linux 里常用的 JSON 数据处理工具,我在编写数据处理的 Shell 脚本或过滤 API 返回值时经常使用,最近因一个生产问题发现了它可能存在的精度丢失问题。
问题
我们的生产服务最近有一个报错,查看日志发现是根据数据库的 A 表里记录的一个关联 ID 在 B 表中查询不到对应记录。这个 ID 是 1721789858467004400,存储在 A 表的一个 JSON 字符串字段中,格式为 {"some_key": 1721789858467004400}
。我们确认了下 B 表中确实不存在这个 ID,但是正常情况不应该出现一个不存在的 ID,那么这个 ID 是怎么来的呢?
排查
首先,我们发现 B 表中虽然不存在 1721789858467004400 这个 ID,但是却存在另一个类似的 ID 1721789858467004418,前者看上去是后者被截断了末尾两位数得到的,所以我们怀疑这个问题很大概率是精度丢失导致的。
前端
第一直觉,这个 ID 可能是前端传入的。因为我们知道 JavaScript 的 Number 类型能精确表达的最大整数是 2^53,所以当整数大于 2^53 时需要转换成字符串传递,否则就会有精度丢失,而这个有问题的 ID 1721789858467004400 就是大于 2^53 的。沿着这个思路,我们梳理了与这个 ID 相关的所有接口,发现都是读接口,而这个 ID 的写入是在后端初始化完成的,所以不可能由前端直接进行修改。
后端
那可能是在后端逻辑中被修改的吗?也不可能,与这个 ID 有关的逻辑都是直接从数据库中读取,设置到 Java 的某个对象中,然后再存储到数据库,不存在对 ID 进行任何修改,而且使用的都是 Long 类型,也不存在精度丢失问题。
脚本
前后端的问题都排除了,那就只有可能是直接操作数据库写入的了。有关精度问题,不太可能是人工手动写入的,大概率是通过脚本写入的。我们梳理了最近几个月操作 A 表的 Shell 脚本,发现有个脚本确实有修改这个字段,它使用 jq 从这个字段中删除一个废弃的 key,最后再将结果回写:
# read field from db
new_field_value=$(jq 'del(.some_deprecated_key)' <<< "$field_value")
# update field into db
看上去毫无问题,只是删除一个 key,没有修改其他 key。但当我们手动验证的时候,就发现了问题:
jq 'del(.key_2)' <<< '{"key_1": 1721789858467004418, "key_2": 1721789858467004418}'
{
"key_1": 1721789858467004400
}
没被修改的 key_1 的值被截断了。如果只是单纯的用 jq 进行解析,问题同样存在:
jq <<< 1721789858467004418
1721789858467004400
现在可以确定,这个问题就是由 jq 的精度丢失引起的了。
jq 精度问题
来源
jq 的 GitHub issues 中有不少关于 jq 精度丢失的问题,其中,jq 的维护者早在 2013 年就在 JSON does allow better than IEEE 754 numbers 中进行了详细的解释:
- JSON 规范并没有区分整数和浮点数
- jq 采用的是 IEEE 754 浮点数标准,这一标准是被所有 JSON 实现所支持的,双精度浮点数能精确表达的整数范围是 -2^53 ~ +2^53
- 对比浮点数和 int64,如果所表示的数均超过其精度范围,那么浮点数的精度丢失发生在第 16 比特位,而 int64 发生在第一位(即标志位,表现为正数变为负数)
虽然维护者说得有一定道理,但还是有不少用户对 jq 在处理 int64 时发生的精度丢失表示惊讶。
解决
好消息是,jq 1.7 版本支持了整数的精度保持(前提是不进行运算),终于把这个坑填上了:
# precision is preserved
$ echo '100000000000000000' | jq .
100000000000000000
# comparison respects precision (this is false in JavaScript)
$ jq -n '100000000000000000 < 100000000000000001'
true
# sort/0 works
$ jq -n -c '[100000000000000001, 100000000000000003, 100000000000000004, 100000000000000002] | sort'
[100000000000000001,100000000000000002,100000000000000003,100000000000000004]
# arithmetic operations might truncate (same as JavaScript)
$ jq -n '100000000000000000 + 10'
100000000000000020
我看了下我们服务器自带的 jq 版本都是 1.6,只要替换下 1.7 版本的二进制就能彻底避免这个问题了。