单行 JSON 报错怎么定位:从 line 1 column 23611 找到出错那个字节
结论:4MB 单行 JSON 报 line 1 column 23611,行列号形同虚设。本文用本地实测的三份样本,解释 Python、Node、jq 为什么报出三个不同的数字,怎么用字符偏移一次挖到出错字节,以及语法修好后仍然会悄悄丢数据的三类陷阱。

报错里的行列号只有在多行文档里才有意义。JSON 一旦被压缩成一行,所有的错误都住在 line 1,你要找的不是第几行,而是第几个字符。
压缩过的 JSON 只有一个「行」,报错里的 line / column 是把一个四百万字节的位置折叠成的一句话。可行的做法是跳过行列号,直接读字符偏移——那个不起眼的
(char 23610)才是真正能救命的数字。
下面所有数字都是本地实跑出来的:样本是一份 13,000 条账单事件的单行 JSON,共 2,011,084 个字符,在第 154 条事件的 event_id 后面故意删了一个逗号。运行环境 Python 3.11.1、Node 22.13.1、jq 1.7。
一、先看事实:三个解析器给出三个数字
同一份坏文件喂给三个工具:
Python 3.11.1 Expecting ',' delimiter: line 1 column 23611 (char 23610)
Node 22.13.1 Expected ',' or '}' after property value in JSON at position 23610 (line 1 column 23611)
jq 1.7 Expected separator between values at line 1, column 23619 退出码 5
把它们放到一张表里:
| 工具 | 给的位置 | 单位 | 基准点 | 失效原因 |
|---|---|---|---|---|
Python JSONDecodeError.pos |
23610 | 字符偏移 | 缓冲区起点 | 唯一可靠的那个 |
Python lineno / colno |
1 / 23611 | 行列 | 行 | 文档只有一行,等于没给 |
Node position |
23610 | 字符偏移 | 缓冲区起点 | 可靠,但报错文案里不直接暴露这个字段 |
jq column |
23619 | 字符偏移 | 出错 token 的起点 | 与 Python 差 9,来自计数基准不同 |
结论很直接:Python 的 char N 和 Node 的 position N 是同一个数,可以对得上;jq 的那个数字自成一套,别花时间去对齐它。
这三个数字之所以不同,不是谁算错了,而是它们数的东西不一样。Python 和 Node 报告的是「解析器停下来的地方」,jq 报告的是「那个没被解析好的 token 的开头」。两者之间隔着若干个已读字符,本样本里是 9 个。
二、为什么 line 1 column N 在这种文件上等于废话
行号是一个「很久没有换行符」的派生量。压缩过的 JSON 只有一个 \n(通常在文件末尾),于是:
lineno恒为 1;colno退化成一个「从行首数到第 N 个字符」的计数;- 而这一「行」有四百万字符宽。
所以编辑器里跳到第 23611 列,你什么都看不到。更糟的是 less、vim 这类工具在超长行上交互极慢,用方向键一个个挪过去,二十分钟就这么没了。
反过来想:偏移才是原生量,行列是从偏移算出来的。既然如此,直接用原生量就行。
三、方法一:Python 用 e.pos,别读 lineno
JSONDecodeError 身上挂着三个数字,只有 pos 有用。lineno、colno 都是从 pos 派生并在单行文档上退化的赝品。
# locate.py — Python 3.11.1
import json
import sys
path = sys.argv[1]
raw = open(path, encoding='utf-8').read()
try:
json.loads(raw)
except json.JSONDecodeError as error:
print(f'{type(error).__module__}.{type(error).__name__}: {error}')
start = max(0, error.pos - 60)
end = min(len(raw), error.pos + 60)
print(f'\nchar {start}..{end} of {len(raw)}:')
print(raw[start:end])
print(' ' * (error.pos - start) + '^')
sys.exit(1)
print('valid JSON')
在坏文件上跑:
$ python3 locate.py bench/minified-json-error-position/broken.json
json.decoder.JSONDecodeError: Expecting ',' delimiter: line 1 column 23611 (char 23610)
char 23550..23670 of 2011083:
ua":"Mozilla/5.0 Chrome/141.0.0.0"},{"event_id":"evt_000154""user_id":100154,"email":"user154@example.com","plan":"pro",
^
箭头指着的位置缺一个逗号。整件事从二十分钟变成一条命令。
窗口取多大:±60 字符几乎总能覆盖一对完整的键值。太小会切掉上下文,太大会糊满整屏。嵌套很深的文档可以放宽到 ±120。
四、方法二:容器里没 Python,用 jq + cut
生产容器里往往没装 Python,但几乎总有 jq,或者 apt-get install jq 一条命令的事。jq 的价值在于响亮地失败:
$ jq . bench/minified-json-error-position/broken.json
jq: parse error: Expected separator between values at line 1, column 23619
$ echo $?
5
退出码 5 表示输入非法,在 set -e 的脚本里它会像 grep 返回 1 一样直接中断流程。这是件好事。
jq 没有「把出错那几个字节打出来」的参数,所以把位置交给 cut:
# 注意是 -c(按字符),不是 -b(按字节)
$ cut -c23550-23670 bench/minified-json-error-position/broken.json
"ua":"Mozilla/5.0 Chrome/141.0.0.0"},{"event_id":"evt_000154""user_id":100154,"email":"user154@example.com","plan":"pro",
补回逗号之后,jq 就能干它真正擅长的事了:
$ jq -r '.events | length' bench/minified-json-error-position/payload.json
13000
$ jq -r '.events[154] | "\(.event_id) \(.plan) \(.amount_cents)"' bench/minified-json-error-position/payload.json
evt_000154 pro 74479
五、方法三:没环境、或者数据不能落盘,就粘进浏览器
前两种要求机器上有东西、并且允许你把文件写进某个目录。当你手上是一台什么都没装的笔记本,或者这份数据里全是客户邮箱和 IP、「粘贴到某个网站」是事故复盘里不想写进去的一句话时,第三种方式最合适。
把整段粘进 JSON 在线解析,它会把光标直接停在出错字符上并标出行列;本地工具的好处是这 4MB 数据不会离开这台机器,也就不会出现在别人的日志里。
要注意两点:
- 选择明确写着「浏览器内处理」的工具,别用会把内容 POST 到后端的产品;
- 浏览器粘贴对超大文件友好,但超过几十 MB 时标签页本身会吃内存,这时候回到方法一。
六、字符偏移不等于字节偏移:中文文档的第二个坑
这是前两种办法在中英混合数据上都会踩到的雷。
JSONDecodeError.pos 是字符偏移(Unicode 码点数),因为它是对 str 解析的结果。cut -c 按字符切割,与之对齐;但 head -c、tail -c、dd 按字节切割,在 UTF-8 下一错位就全错。
用一份中文样本说明(5 条订单,148 个字符、222 个字节):
>>> raw = open('bench/minified-json-error-position/cn.json', encoding='utf-8').read()
>>> raw[25:35] # 第 25~35 个【字符】
'城市":"杭州"},'
>>> open('bench/minified-json-error-position/cn.json','rb').read()[25:35] # 第 25~35 个【字节】
b'DD00000","'
同一个偏移量,字符视角切出来是字段名,字节视角切出来是订单号的尾巴。一个汉字在 UTF-8 里占 3 字节,误差随位置累积。
规则:拿到 e.pos 之后,用 cut -c 或 Python 切片,不要用 head -c / dd bs=1 skip=N。如果只能用字节工具,先显式换算:len(raw[:error.pos].encode('utf-8'))。
七、语法修好了,数据还是错的:三类静默陷阱
这也是为什么排错不能停在「解析成功」上。以下三种情况要么完全不报错,要么报错的时机远在使用之后。
陷阱 1:重复键,后者胜,无人报警
RFC 8259 第 4 节写得很清楚:对象名 SHOULD 唯一;名字不唯一时行为不可预测,多数实现只保留最后一个,也有实现直接报错。实测三个工具都是后者胜:
输入 {"order_id":"A-1001","total":4200,"order_id":"A-1002"}
Node {"order_id":"A-1002","total":4200}
Python {'order_id': 'A-1002', 'total': 4200}
jq {"order_id":"A-1002","total":4200}
一条 alert 都没有。如果那个被覆盖的是 amount_cents,你会在对账单差了三分钱的时候才知道。
陷阱 2:NaN 与 Infinity,Python 收得下、Node 读不进
RFC 8259 第 6 节明确规定数字字面量里没有 NaN 和 Infinity。但 Python 的 json.loads 默认接受它们,因为它自己的 float 有这两个值:
>>> json.loads('{"discount":NaN}')
{'discount': nan}
>>> json.dumps(json.loads('{"discount":NaN}'))
'{"discount": NaN}' # 原样写回
这份输出交给 Node 就是:
SyntaxError: Unexpected token 'N', "{"discount":NaN}" is not valid JSON
也就是:坏数据过了 Python 这一关,然后在下游消费者手上炸掉。
陷阱 3:超过 2^53 的整数,精度静默丢失
同一份数据在两种语言里是两个值:
JSON 原文 {"order_no":12345678901234567890}
Python 12345678901234567890 ← 原样保留
Node 12345678901234567000 ← 末三位被抹平
原因写成一行:JavaScript 的 Number.MAX_SAFE_INTEGER 是 9007199254740991(2^53-1)。MDN 特别指出,reviver 是在值已经被解析成 JavaScript number 之后才运行的,所以精度损失发生在 reviver 之前——在反序列化侧救不回来,只能让生产方把大 ID 序列化成字符串。
三类陷阱对照:
| 陷阱 | 是否有报错 | 谁会先发现 | 拦截方式 |
|---|---|---|---|
| 重复键 | 无 | 下游对账 | object_pairs_hook 显式检查 |
| NaN / Infinity | Node 侧报错,Python 侧无 | 下游消费者 | parse_constant 在入口拒绝 |
| 超过 2^53 的整数 | 无 | 做等值比较的代码 | 数据侧改字符串传输 |
一次拦住前两种
# check.py — Python 3.11.1
import json
import sys
from collections import Counter
def no_dupes(pairs):
counts = Counter(key for key, _ in pairs)
dupes = sorted(key for key, n in counts.items() if n > 1)
if dupes:
raise ValueError(f'duplicate key(s): {dupes}')
return dict(pairs)
def reject_constant(name):
raise ValueError(f'non-standard JSON constant: {name}')
raw = open(sys.argv[1], encoding='utf-8').read()
try:
data = json.loads(raw, object_pairs_hook=no_dupes, parse_constant=reject_constant)
except json.JSONDecodeError as error:
start, end = max(0, error.pos - 60), min(len(raw), error.pos + 60)
print(f'{error}\n\nchar {start}..{end} of {len(raw)}:')
print(raw[start:end])
print(' ' * (error.pos - start) + '^')
sys.exit(1)
except ValueError as error:
print(f'rejected: {error}')
sys.exit(1)
print(f'valid JSON, {len(raw)} chars')
代价要如实说:这份严格版在同一份 2,011,084 字符的样本上,本机跑 5 轮、每轮 5 次取中位数,宽松版落在 9.4~9.9 ms,严格版落在 35.2~36.2 ms,也就是慢约 3.7 倍(区间 3.6~3.8)——因为 object_pairs_hook 对每个对象都要跑一次 Counter。所以别放进 CI 的 happy path,只在收到可疑投送时切换过去。
八、检查清单
修好之后通常顺手美化一份便于阅读,但同一份样本实测:单行(separators=(',',':'))2,011,084 字符,indent=2 美化后 2,869,100 字符,膨胀 43%。这份体积会一路带进 Git 历史、CI 缓存和网络传输,所以美化版只在本地停留,下发或提交前压回单行。
- [ ] 报错里优先找
(char N)/position N,忽略line 1 column N; - [ ] Python 用
e.pos,jq 用cut -c,两者是同一套字符偏移,jq 的 column 不要拿去对齐; - [ ] 中文文档用字符切割(
cut -c、Python 切片),绝不用head -c/dd; - [ ] 容器脚本依赖 jq 退出码 5 判定输入非法;
- [ ] 可疑数据用
object_pairs_hook+parse_constant复检,接受约 3.7 倍的耗时; - [ ] 超过 2^53 的 ID 一律字符串传输,不要指望 reviver 救回来;
- [ ] 美化版(实测 +43% 体积)只在本地停留,下发或提交前用 /minify 压回单行;几 MB 以上改用流式解析,见大 JSON 的解析、渲染与流式处理。
常见问题
为什么三个工具报的位置都不一样?
因为三者数的东西不同。Python 的 char N 和 Node 的 position N 都是「解析器停下的位置」,实测同一份样本上两者都是 23610,可以对上。jq 报的是「出错 token 的起始位置」,本样本为 23619,比前者多 9。这不是谁算错,也不需要校正,只在同工具内部纵向对比即可。
cut -c 和 head -c 到底该用哪个?
用 cut -c。JSONDecodeError.pos 是按 Unicode 字符计的偏移,cut -c 同样按字符切割,两者对齐。head -c、tail -c、dd 按字节切割,在含中文的 UTF-8 文档上会错位,还可能把汉字切成半截。中文样本实测同一偏移量下,字符视角得到 城市":"杭州"},,字节视角得到 DD00000","。
重复键真的没人管吗?
真的。RFC 8259 第 4 节只写了「名字 SHOULD 唯一」,并明确名字不唯一时行为不可预测。实测 Python、Node、jq 三个解析器都采用「后者覆盖前者」策略且不发出任何告警。想拦住它必须自己写 object_pairs_hook,代价是解析耗时上升到原来的约 3.7 倍。
为什么 Python 能读出 NaN,Node 却直接报错?
RFC 8259 第 6 节不允许 NaN 与 Infinity 作为数字字面量。Python 的 json.loads 默认放宽接受,因为它自身的 float 有这两个值,于是坏数据能过 Python 这一关并被原样写回。Node 的 JSON.parse 严格按规范执行,拿到这份输出就抛 SyntaxError。入口加 parse_constant 可以第一时间拒绝。
20 位订单号用 JSON 传一定会丢精度吗?
只在消费侧把它解析成 IEEE 754 双精度浮点时丢。Python 的 int 是任意精度,实测保留 12345678901234567890;JavaScript 的 Number.MAX_SAFE_INTEGER 是 9007199254740991,同一字段解析为 12345678901234567000。MDN 明确指出 reviver 运行在解析之后,救不回来,唯一可靠的做法是让生产方以字符串输出。