JSON Toolkit

Gỡ lỗi JSON đã nén: từ dòng 1 cột 23611 đến đúng byte bị hỏng

Kết luận: Một tệp JSON 4 MB trên một dòng báo lỗi dòng 1 cột 23611, chẳng nói lên điều gì. Dựa trên các mẫu đo cục bộ, hướng dẫn này giải thích vì sao Python, Node và jq báo ba con số khác nhau, cách một độ lệch ký tự đưa bạn thẳng đến byte bị hỏng, và ba cạm bẫy âm thầm làm hỏng dữ liệu sau khi đã sửa cú pháp.

Nâng caoĐọc khoảng 7 phútXuất bản 2026-11-01Cập nhật 2026-11-01
Ảnh bìa Định vị lỗi trong JSON đã nén

Điểm mấu chốt: số dòng và số cột chỉ có ý nghĩa trong tài liệu nhiều dòng. Một khi JSON đã bị nén thành một dòng duy nhất, mọi lỗi đều nằm ở dòng 1 — con số bạn cần không phải dòng mà là chỉ số ký tự.

Một tài liệu JSON đã nén chỉ có đúng một dòng, nên cặp dòng/cột trong thông báo lỗi sẽ nén một vị trí bốn triệu byte thành một cụm vô dụng. Cách khắc phục là bỏ qua hoàn toàn dòng/cột và đọc độ lệch ký tự thay vào đó — cụm khiêm tốn (char 23610) ở cuối thông báo mới là con số thực sự cứu bạn.

Mọi con số dưới đây đều được tạo cục bộ. Mẫu là một tài liệu một dòng chứa 13,000 sự kiện thanh toán, tổng cộng 2,011,084 ký tự, với một dấu phẩy bị xóa cố ý sau event_id của sự kiện thứ 154. Môi trường là Python 3.11.1, Node 22.13.1 và jq 1.7.

1. Trước hết là sự thật: ba trình phân tích, ba con số

Cùng một tệp hỏng, đưa cho ba công cụ:

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     exit code 5

Đặt cạnh nhau:

Công cụ Giá trị được báo Đơn vị Đếm từ Vì sao dòng/cột vô dụng
Python JSONDecodeError.pos 23610 Độ lệch ký tự Đầu buffer Là thứ duy nhất bạn có thể tin
Python lineno / colno 1 / 23611 Dòng và cột Đầu dòng Chỉ có một dòng, nên chẳng nói lên gì
Node position 23610 Độ lệch ký tự Đầu buffer Đáng tin, dù thông báo giấu trường này
jq column 23619 Độ lệch ký tự Đầu token gây lỗi Khác Python 9 đơn vị

Kết luận thẳng thừng: char N của Python và position N của Node là cùng một con số và khớp nhau. jq dùng một cách tính riêng — đừng mất thời gian hòa giải nó.

Ba con số khác nhau không phải vì ai đó tính sai, mà vì chúng đếm những thứ khác nhau. Python và Node báo nơi trình phân tích dừng lại; jq báo nơi token mà nó phân tích thất bại bắt đầu. Giữa hai điểm đó có một số ký tự đã bị tiêu thụ — trong mẫu này là 9.

2. Vì sao dòng 1 cột N là tiếng ồn trên loại tệp này

Số dòng là một đại lượng dẫn xuất, giả định rằng "đã lâu rồi kể từ lần xuống dòng cuối". Một tài liệu đã nén chỉ có một \n, thường ở cuối, nên:

  • lineno luôn là 1;
  • colno suy thoái thành "bao nhiêu ký tự tính từ đầu dòng duy nhất đó";
  • và dòng đó rộng bốn triệu ký tự.

Nhảy đến cột 23611 trong trình soạn thảo và bạn chẳng thấy gì hữu ích. Tệ hơn, những công cụ như less và vim bò rất chậm trên dòng cực dài; nhích con trỏ bằng các phím mũi tên sẽ đốt mất hai mươi phút.

Đảo lại mà xem: độ lệch là đại lượng gốc, còn dòng/cột được tính ra từ nó. Hãy dùng đại lượng gốc.

3. Cách một: trong Python, dùng e.pos và bỏ qua lineno

JSONDecodeError mang ba con số, và chỉ pos là đáng đọc. lineno và colno là các giá trị dẫn xuất, sụp đổ thành vô nghĩa trên tài liệu một dòng.

# 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')

Chạy nó với tệp hỏng:

$ 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",
                                                            ^

Dấu mũ đánh dấu chỗ thiếu một dấu phẩy. Hai mươi phút biến thành một lệnh.

Cửa sổ nên rộng bao nhiêu: ±60 ký tự gần như luôn bao trùm một cặp key/value hoàn chỉnh. Quá hẹp thì mất ngữ cảnh; quá rộng thì đầu ra đầy màn hình. Tài liệu lồng nhau sâu có thể dùng ±120.

4. Cách hai: container không có Python — dùng jq cộng cut

Container production thường không có sẵn Python, nhưng gần như luôn có jq, hoặc có thể cài bằng một lệnh apt-get install jq. Thứ jq mang lại là một thất bại ồn ào:

$ jq . bench/minified-json-error-position/broken.json
jq: parse error: Expected separator between values at line 1, column 23619
$ echo $?
5

Mã thoát 5 nghĩa là đầu vào không hợp lệ, nên dưới set -e nó sẽ dừng script giống như grep trả về 1. Đó là một tính năng.

jq không có cờ để in ra các byte gây lỗi, nên hãy đưa vị trí cho cut:

# Note -c (characters), not -b (bytes)
$ 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",

Khi dấu phẩy đã trở lại, jq có thể làm thứ nó thực sự giỏi:

$ 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

5. Cách ba: không có runtime, hoặc dữ liệu không được chạm vào đĩa — dán vào trình duyệt

Hai cách đầu cần có thứ gì đó được cài và quyền ghi tệp. Nếu bạn đang dùng một laptop trần, hoặc payload đầy email khách hàng và địa chỉ IP mà "tôi đã dán nó vào một trang web nào đó" là câu bạn không muốn có trong báo cáo sự cố, thì lựa chọn thứ ba phù hợp.

Dán toàn bộ chuỗi vào trình phân tích JSON trực tuyến và nó đặt dấu mũ trên ký tự gây lỗi cùng nhãn dòng, cột; lợi thế của công cụ cục bộ là 4 MB đó không bao giờ rời khỏi máy và không bao giờ rơi vào log của người khác.

Hai lưu ý:

  1. Chọn công cụ nói rõ rằng nó xử lý mọi thứ trong trình duyệt, đừng chọn công cụ POST nội dung của bạn lên backend;
  2. Dán chịu được tệp rất lớn, nhưng vượt vài chục megabyte thì chính tab sẽ ngốn bộ nhớ — lúc đó hãy quay lại cách một.

6. Độ lệch ký tự không phải độ lệch byte: cạm bẫy thứ hai với dữ liệu CJK

Cái này cắn cả hai cách khi payload trộn tiếng Trung/tiếng Anh.

JSONDecodeError.pos là một độ lệch ký tự (đếm theo code point Unicode), vì nó là kết quả của việc phân tích một str. cut -c cắt theo ký tự và khớp với nó. Nhưng head -c, tail -c và dd cắt theo byte, và dưới UTF-8 một sơ suất là lệch hết.

Một mẫu tiếng Trung làm rõ điều đó (5 đơn hàng, 148 ký tự, 222 byte):

>>> raw = open('bench/minified-json-error-position/cn.json', encoding='utf-8').read()
>>> raw[25:35]                                    # characters 25-35
'城市":"杭州"},'
>>> open('bench/minified-json-error-position/cn.json','rb').read()[25:35]      # bytes 25-35
b'DD00000","'

Cùng một độ lệch: góc nhìn ký tự rơi đúng vào tên trường, góc nhìn byte rơi vào đuôi của một mã đơn hàng. Một ký tự CJK chiếm 3 byte dưới UTF-8, và độ trôi tích lũy theo vị trí.

Quy tắc: sau khi đọc e.pos, hãy cắt bằng cut -c hoặc slice của Python, không bao giờ bằng head -c / dd bs=1 skip=N. Nếu bạn chỉ có công cụ byte, hãy chuyển đổi trước: len(raw[:error.pos].encode('utf-8')).

7. Cú pháp đã sửa, dữ liệu vẫn sai: ba cạm bẫy im lặng

Đây chính là lý do gỡ lỗi không thể dừng ở "nó đã phân tích xong". Mỗi trường hợp sau hoặc không bao giờ ném lỗi, hoặc ném rất lâu sau khi giá trị đã được dùng.

Cạm bẫy 1: khóa trùng — giá trị sau thắng, không ai phàn nàn

Mục 4 của RFC 8259 rõ ràng: các tên trong một object NÊN là duy nhất, và khi không duy nhất, hành vi là không thể đoán trước — hầu hết bản triển khai giữ lần xuất hiện cuối, số khác báo lỗi. Cả ba công cụ đo ở đây đều giữ cái cuối:

Input   {"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}

Không một cảnh báo nào. Nếu trường bị ghi đè là amount_cents, bạn chỉ phát hiện khi việc đối soát lệch ba xu.

Cạm bẫy 2: NaN và Infinity — Python chấp nhận, Node thì không

Mục 6 của RFC 8259 nói thẳng rằng literal số không chứa NaN và không chứa Infinity. json.loads của Python chấp nhận cả hai theo mặc định, vì float của chính nó có những giá trị đó:

>>> json.loads('{"discount":NaN}')
{'discount': nan}
>>> json.dumps(json.loads('{"discount":NaN}'))
'{"discount": NaN}'    # written straight back out

Đưa đầu ra đó cho Node và bạn nhận được:

SyntaxError: Unexpected token 'N', "{"discount":NaN}" is not valid JSON

Nói cách khác: dữ liệu xấu qua được Python và phát nổ ở bên tiêu thụ hạ nguồn.

Cạm bẫy 3: số nguyên vượt 2^53 âm thầm mất độ chính xác

Một tài liệu, hai giá trị khác nhau:

JSON source   {"order_no":12345678901234567890}
Python        12345678901234567890          <- kept as-is
Node          12345678901234567000          <- last three digits flattened

Lý do trong một dòng: Number.MAX_SAFE_INTEGER của JavaScript là 9007199254740991 (2^53-1). MDN chỉ ra cụ thể rằng reviver chạy sau khi giá trị đã được phân tích thành một số JavaScript, nên độ chính xác mất đi trước reviver — không thể khôi phục ở phía giải tuần tự. Cách sửa duy nhất là bên sản xuất phải tuần tự hóa các ID lớn dưới dạng chuỗi.

So sánh ba cạm bẫy:

Cạm bẫy Có gì ném lỗi không? Ai phát hiện trước Cách chặn
Khóa trùng Không Đối soát hạ nguồn Kiểm tra tường minh qua object_pairs_hook
NaN / Infinity Node ném, Python im lặng Bên tiêu thụ hạ nguồn Từ chối ngay ở biên bằng parse_constant
Số nguyên vượt 2^53 Không Mã thực hiện kiểm tra bằng nhau Gửi chúng dưới dạng chuỗi

Bắt hai cạm bẫy đầu trong một lượt

# 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')

Chi phí, nói một cách trung thực: trên cùng mẫu 2,011,084 ký tự, phiên bản nghiêm ngặt này đạt 35.2-36.2 ms so với 9.4-9.9 ms của phiên bản dễ dãi, chậm hơn khoảng 3.7 lần (3.6 đến 3.8 lần qua năm vòng, mỗi vòng năm lần chạy, lấy trung vị mỗi vòng) — object_pairs_hook chạy một Counter cho mỗi object. Hãy để nó ngoài đường đi chính của CI và chỉ bật khi một lần giao hàng trông đáng ngờ.

8. Danh sách kiểm tra

Sau khi sửa tệp, thật dễ muốn định dạng đẹp để dễ đọc, nhưng cùng mẫu đó đo được 2,011,084 ký tự khi nén (separators=(',',':')) và 2,869,100 ký tự với indent=2 — nhiều hơn 43%. Trọng lượng đó đi vào lịch sử Git, bộ đệm CI và truyền tải mạng, nên hãy giữ bản đẹp ở cục bộ và ép nó trở lại một dòng trước khi phát hành.

  • [ ] Tìm (char N) / position N trước; bỏ qua line 1 column N;
  • [ ] Python dùng e.pos, jq đi cặp với cut -c — cùng độ lệch ký tự, nên đừng bao giờ hòa giải column của jq với chúng;
  • [ ] Với tài liệu CJK, cắt theo ký tự (cut -c, slice Python), không bao giờ dùng head -c / dd;
  • [ ] Script trong container nên coi mã thoát 5 của jq là "đầu vào không hợp lệ";
  • [ ] Kiểm tra lại dữ liệu đáng ngờ bằng object_pairs_hook + parse_constant và chấp nhận chi phí khoảng 3.7 lần;
  • [ ] Gửi mọi ID vượt 2^53 dưới dạng chuỗi; đừng mong reviver cứu được;
  • [ ] Đầu ra định dạng đẹp (đo được +43%) giữ ở cục bộ: dùng /minify để nén lại một dòng trước khi phát hành hoặc commit; vượt vài MB thì chuyển sang streaming, được trình bày trong Phân tích, hiển thị và streaming JSON lớn.

Câu hỏi thường gặp

Vì sao cả ba công cụ đều báo một vị trí khác nhau?

Vì chúng đếm những thứ khác nhau. char N của Python và position N của Node đều nghĩa là "nơi trình phân tích dừng lại" — trên mẫu này cả hai đều là 23610, nên chúng khớp nhau. jq báo nơi token gây lỗi bắt đầu, ở đây là 23619, xa hơn chín đơn vị. Không ai tính sai và không cần hiệu chỉnh; chỉ so sánh các con số trong cùng một công cụ.

Tôi nên dùng cut -c hay head -c?

Hãy dùng cut -c. JSONDecodeError.pos là độ lệch đếm theo ký tự Unicode, và cut -c cắt theo ký tự, nên hai bên khớp nhau. head -c, tail -c và dd cắt theo byte, sẽ trôi trên các tài liệu UTF-8 chứa văn bản CJK và thậm chí có thể chặt đôi một ký tự. Trên mẫu tiếng Trung, cùng một độ lệch cho ra 城市":"杭州"}, từ góc nhìn ký tự và DD00000"," từ góc nhìn byte.

Xử lý khóa trùng thực sự không được bảo vệ ở mọi nơi sao?

Đúng vậy. Mục 4 của RFC 8259 chỉ nói các tên NÊN là duy nhất và nêu rằng hành vi là không thể đoán trước khi chúng không duy nhất. Python, Node và jq đều được đo ở đây áp dụng quy tắc cái cuối thắng mà không phát ra bất kỳ cảnh báo nào. Để bắt nó cần object_pairs_hook của riêng bạn, với chi phí khoảng 3.7 lần thời gian phân tích.

Vì sao Python đọc NaN ổn còn Node thì ném lỗi?

Mục 6 của RFC 8259 không cho phép NaN và Infinity làm literal số. json.loads của Python chấp nhận chúng theo mặc định vì float của chính Python có những giá trị đó, nên dữ liệu xấu qua được Python và được ghi thẳng trở ra. JSON.parse của Node tuân theo đặc tả một cách nghiêm ngặt và ném SyntaxError trên đầu ra đó. Thêm parse_constant tại điểm vào sẽ từ chối nó ngay lập tức.

Số đơn hàng 20 chữ số có luôn mất độ chính xác khi qua JSON không?

Chỉ khi bên tiêu thụ phân tích chúng thành số thực độ chính xác kép IEEE 754. int của Python có độ chính xác tùy ý và giữ được 12345678901234567890; Number.MAX_SAFE_INTEGER của JavaScript là 9007199254740991, nên cùng trường đó trở thành 12345678901234567000. MDN ghi rõ rằng reviver chạy sau khi phân tích, nên không thể khôi phục gì ở hạ nguồn — cách sửa đáng tin duy nhất là bên sản xuất phát ra chuỗi.