單行 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}
一條警示都沒有。如果那個被覆蓋的是 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 執行在解析之後,救不回來,唯一可靠的做法是讓生產方以字串輸出。