JSON Toolkit

ดีบัก JSON ที่ถูกบีบอัด: จากบรรทัด 1 คอลัมน์ 23611 ถึงไบต์ที่พังจริง

สรุปสำคัญ: ไฟล์ JSON ขนาด 4 MB ที่มีบรรทัดเดียวรายงานว่าอยู่บรรทัด 1 คอลัมน์ 23611 ซึ่งไม่บอกอะไรเลย คู่มือนี้ใช้ตัวอย่างที่วัดในเครื่องอธิบายว่าทำไม Python, Node และ jq รายงานเลขสามค่าต่างกัน character offset พาคุณไปถึงไบต์ที่พังได้อย่างไร และมีกับดักสามข้อใดที่ทำข้อมูลเสียหายเงียบ ๆ หลังไวยากรณ์ถูกแก้แล้ว

ขั้นสูงอ่านประมาณ 7 นาทีเผยแพร่ 2026-11-01อัปเดต 2026-11-01
ภาพหน้าปกการหาตำแหน่ง error ใน JSON ที่ถูกบีบอัด

สรุปสั้น: เลขบรรทัดและคอลัมน์มีความหมายเฉพาะในเอกสารหลายบรรทัด เมื่อ JSON ถูกบีบอัดเหลือบรรทัดเดียว error ทุกตัวจึงอยู่บนบรรทัด 1 — ตัวเลขที่คุณต้องการไม่ใช่บรรทัด แต่คือดัชนีของอักขระ

เอกสาร JSON ที่ถูกบีบอัดมีเพียงบรรทัดเดียว คู่บรรทัด/คอลัมน์ในข้อความ error จึงบีบตำแหน่งสี่ล้านไบต์ให้เหลือประโยคไร้ประโยชน์ประโยคเดียว ทางออกคือข้ามบรรทัด/คอลัมน์ไปทั้งหมดแล้วอ่าน character offset แทน — (char 23610) เรียบ ๆ ท้ายข้อความคือตัวเลขที่ช่วยคุณได้จริง

ตัวเลขทุกตัวด้านล่างวัดในเครื่องนี้ ตัวอย่างคือเอกสารบรรทัดเดียวบรรจุเหตุการณ์เรียกเก็บเงิน 13,000 รายการ รวม 2,011,084 ตัวอักษร โดยลบเครื่องหมายจุลภาคหนึ่งตัวออกจากหลัง event_id ของเหตุการณ์ที่ #154 อย่างจงใจ สภาพแวดล้อมคือ Python 3.11.1, Node 22.13.1 และ jq 1.7

1. ข้อเท็จจริงก่อน: ตัวแยกวิเคราะห์สามตัว สามตัวเลข

ไฟล์ที่พังไฟล์เดียวกัน ป้อนให้เครื่องมือสามตัว:

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

เทียบกันข้าง ๆ:

เครื่องมือ ค่าที่รายงาน หน่วย นับจาก ทำไมบรรทัด/คอลัมน์ถึงใช้ไม่ได้
Python JSONDecodeError.pos 23610 Character offset ต้นบัฟเฟอร์ ตัวเดียวที่เชื่อถือได้
Python lineno / colno 1 / 23611 บรรทัดและคอลัมน์ ต้นบรรทัด มีบรรทัดเดียว จึงไม่บอกอะไร
Node position 23610 Character offset ต้นบัฟเฟอร์ เชื่อถือได้ แม้ข้อความจะซ่อนฟิลด์นี้ไว้
jq column 23619 Character offset ต้น token ที่มีปัญหา ต่างจาก Python อยู่ 9

ข้อสรุปตรง ๆ: char N ของ Python กับ position N ของ Node เป็นเลขเดียวกันและตรงกัน ส่วน jq ใช้ระบบของตัวเอง — อย่าเสียเวลาไปปรับให้ตรงกัน

เลขสามค่าต่างกันไม่ใช่เพราะใครคำนวณผิด แต่เพราะนับคนละอย่าง Python และ Node รายงานว่าตัวแยกวิเคราะห์หยุดที่ไหน; jq รายงานว่า token ที่แยกวิเคราะห์ไม่สำเร็จเริ่มที่ไหน ระหว่างสองจุดมีอักขระที่ถูกอ่านไปแล้วจำนวนหนึ่งคั่นอยู่ — ในตัวอย่างนี้คือ 9 ตัว

2. ทำไม "บรรทัด 1 คอลัมน์ N" ถึงไร้ความหมายกับไฟล์แบบนี้

เลขบรรทัดเป็นค่าที่ได้จากการคำนวณ ซึ่งสมมติว่า "ผ่านมาไม่นานตั้งแต่ขึ้นบรรทัดใหม่ครั้งล่าสุด" เอกสารที่ถูกบีบอัดมี \n เพียงตัวเดียว มักอยู่ท้ายไฟล์ ดังนั้น:

  • lineno เป็น 1 เสมอ;
  • colno เสื่อมลงเป็น "ผ่านต้นบรรทัดนั้นมาแล้วกี่ตัวอักษร";
  • และบรรทัดนั้นกว้างถึงสี่ล้านตัวอักษร

กระโดดไปคอลัมน์ 23611 ในเอดิเตอร์ก็ไม่เห็นอะไรที่มีประโยชน์ ยิ่งกว่านั้นเครื่องมืออย่าง less และ vim คลานช้ามากบนบรรทัดที่ยาวมาก การขยับเคอร์เซอร์ด้วยปุ่มลูกศรจะเผาเวลายี่สิบนาที

พลิกความคิด: offset คือค่าพื้นฐาน และบรรทัด/คอลัมน์คำนวณจากมัน ให้ใช้ค่าพื้นฐาน

3. วิธีที่หนึ่ง: ใน Python ใช้ e.pos และเพิกเฉย lineno

JSONDecodeError พาเลขสามตัวมา และมีเพียง pos ที่ควรอ่าน lineno และ colno เป็นค่าที่สืบเนื่องมาและยุบเหลือศูนย์บนเอกสารบรรทัดเดียว

# 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

4. วิธีที่สอง: ในคอนเทนเนอร์ไม่มี 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

exit code 5 หมายถึงอินพุตไม่ถูกต้อง ดังนั้นภายใต้ set -e สคริปต์จะถูกยกเลิกเหมือนกับ grep ที่คืนค่า 1 นั่นคือคุณสมบัติที่ดี

jq ไม่มีแฟล็กสำหรับพิมพ์ไบต์ที่มีปัญหา จึงต้องส่งตำแหน่งให้ 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",

เมื่อเติมเครื่องหมายจุลภาคกลับแล้ว 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

5. วิธีที่สาม: ไม่มี runtime หรือข้อมูลห้ามแตะดิสก์ — วางในเบราว์เซอร์

สองวิธีแรกต้องมีอะไรติดตั้งไว้และมีสิทธิ์เขียนไฟล์ ถ้าคุณอยู่บนแล็ปท็อปเปล่า ๆ หรือ payload เต็มไปด้วยอีเมลและ IP ของลูกค้า และ "ผมวางมันลงในเว็บไซต์แห่งหนึ่ง" คือประโยคที่คุณไม่อยากเห็นในรายงานเหตุการณ์ ทางเลือกที่สามจึงเข้ากว่า

วางสตริงทั้งหมดลงใน ตัวแยกวิเคราะห์ JSON ออนไลน์ แล้วมันจะวางเครื่องหมาย ^ ที่อักขระที่มีปัญหาและระบุบรรทัดและคอลัมน์; ข้อดีของเครื่องมือในเครื่องคือ 4 MB นั้นไม่เคยออกจากเครื่องและไม่ไปโผล่ใน log ของคนอื่น

ข้อควรระวังสองข้อ:

  1. เลือกเครื่องมือที่ระบุชัดว่าประมวลผลทั้งหมดในเบราว์เซอร์ ไม่ใช่ตัวที่ POST เนื้อหาของคุณไปยังแบ็กเอนด์;
  2. การวางรองรับไฟล์ใหญ่มากได้ แต่เมื่อเกินหลายสิบ MB แท็บเองก็กินหน่วยความจำ — ถึงจุดนั้นให้กลับไปใช้วิธีที่หนึ่ง

6. Character offset ไม่ใช่ byte offset: กับดักที่สองกับข้อมูล CJK

ข้อนี้กัดทั้งสองวิธีเมื่อ payload ผสมจีนกับอังกฤษ

JSONDecodeError.pos เป็น character offset (นับเป็น Unicode code point) เพราะเกิดจากการแยกวิเคราะห์ 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]                                    # characters 25-35
'城市":"杭州"},'
>>> open('bench/minified-json-error-position/cn.json','rb').read()[25:35]      # bytes 25-35
b'DD00000","'

offset เดียวกัน: มุมมองตัวอักษรตกที่ชื่อฟิลด์ มุมมองไบต์ตกที่ท้ายรหัสออเดอร์ อักขระ CJK หนึ่งตัวกิน 3 ไบต์ภายใต้ UTF-8 และความคลาดเคลื่อนสะสมตามตำแหน่ง

กฎ: หลังอ่าน e.pos ให้ตัดด้วย cut -c หรือ slice ของ Python ห้ามใช้ head -c / dd bs=1 skip=N ถ้ามีแต่เครื่องมือระดับไบต์ ให้แปลงก่อน: len(raw[:error.pos].encode('utf-8'))

7. ไวยากรณ์แก้แล้ว แต่ข้อมูลยังผิด: กับดักเงียบสามข้อ

นี่คือเหตุผลที่การดีบักหยุดแค่ "มันแยกวิเคราะห์ผ่าน" ไม่ได้ แต่ละข้อด้านล่างไม่เคย throw เลย หรือ throw หลังจากค่านั้นถูกใช้ไปนานแล้ว

กับดัก 1: คีย์ซ้ำ — ตัวสุดท้ายชนะ ไม่มีใครบ่น

RFC 8259 หมวด 4 ชัดเจน: ชื่อภายใน object SHOULD ไม่ซ้ำกัน และเมื่อซ้ำ พฤติกรรมคาดเดาไม่ได้ — การนำไปใช้งานส่วนใหญ่เก็บตัวสุดท้าย ตัวอื่น throw ทั้งสามเครื่องมือที่วัดนี้เก็บตัวสุดท้ายทั้งหมด:

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}

ไม่มีคำเตือนสักคำ ถ้าฟิลด์ที่ถูกเขียนทับคือ amount_cents คุณจะรู้ก็ตอนกระทบยอดคลาดกันสามเซนต์

กับดัก 2: NaN และ Infinity — Python รับ Node ไม่รับ

RFC 8259 หมวด 6 ระบุตรง ๆ ว่าค่าคงที่ของตัวเลขไม่มี NaN และไม่มี Infinity json.loads ของ Python รับทั้งคู่โดยค่าเริ่มต้น เพราะ float ของ Python เองมีค่าเหล่านี้:

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

ส่งเอาต์พุตนั้นให้ Node จะได้:

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

พูดอีกอย่าง: ข้อมูลเสียผ่าน Python ได้สบาย แล้วไประเบิดที่ผู้บริโภคปลายน้ำ

กับดัก 3: จำนวนเต็มที่เกิน 2^53 สูญเสียความแม่นยำอย่างเงียบ ๆ

เอกสารเดียว สองค่าที่ต่างกัน:

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

เหตุผลในหนึ่งบรรทัด: Number.MAX_SAFE_INTEGER ของ JavaScript คือ 9007199254740991 (2^53-1) MDN ระบุชัดว่า reviver ทำงาน หลังจาก ค่าถูกแยกวิเคราะห์เป็น number ของ JavaScript แล้ว ความแม่นยำจึงหายไปก่อนถึง reviver — กู้คืนฝั่งดีซีเรียลไลซ์ไม่ได้ ทางแก้เดียวคือผู้ผลิตต้องทำให้ ID ขนาดใหญ่เป็นสตริง

กับดักทั้งสามเมื่อเทียบกัน:

กับดัก มีอะไร throw ไหม ใครรู้ก่อน วิธีสกัดกั้น
คีย์ซ้ำ ไม่ การกระทบยอดปลายน้ำ ตรวจชัดเจนด้วย object_pairs_hook
NaN / Infinity Node throw, 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 ตัวอักษรเดียวกัน เวอร์ชันเข้มงวดนี้ใช้เวลา 35.2-36.2 ms เทียบกับ 9.4-9.9 ms ของเวอร์ชันผ่อนปรน ช้ากว่าประมาณ 3.7 เท่า (3.6x ถึง 3.8x จากการทดสอบห้ารอบ รอบละห้าครั้ง ใช้ค่ามัธยฐานแต่ละรอบ) — เพราะ object_pairs_hook รัน Counter ให้ทุก object ให้เก็บมันออกจากเส้นทางปกติของ CI และเปิดใช้เฉพาะเมื่อข้อมูลที่ส่งมาดูไม่น่าไว้วางใจ

8. รายการตรวจสอบ

หลังแก้ไฟล์แล้วก็อยากจัดรูปแบบให้อ่านง่าย แตตัวอย่างเดิมวัดได้ 2,011,084 ตัวอักษรเมื่อบีบอัด (separators=(',',':')) และ 2,869,100 ตัวอักษรที่ indent=2 — มากกว่า 43% น้ำหนักนั้นไหลเข้า Git history แคชของ CI และการรับส่งผ่านเครือข่าย ดังนั้นให้เก็บเวอร์ชันจัดรูปแบบไว้ในเครื่องและบีบกลับเหลือบรรทัดเดียวก่อนส่งมอบ

  • [ ] มองหา (char N) / position N ก่อน; เพิกเฉย line 1 column N;
  • [ ] Python ใช้ e.pos, jq ใช้คู่กับ cut -c — character offset เดียวกัน จึงห้ามเทียบ column ของ jq กับค่าเหล่านี้;
  • [ ] เอกสาร CJK ให้ตัดตามตัวอักษร (cut -c, slice ของ Python) ห้าม head -c / dd;
  • [ ] สคริปต์ในคอนเทนเนอร์ควรถือว่า exit code 5 ของ jq คือ "อินพุตไม่ถูกต้อง";
  • [ ] ตรวจข้อมูลที่น่าสงสัยซ้ำด้วย object_pairs_hook + parse_constant และยอมรับต้นทุนราว 3.7 เท่า;
  • [ ] ส่ง ID ใดที่เกิน 2^53 เป็นสตริง; อย่าหวังให้ reviver ช่วยกู้;
  • [ ] เอาต์พุตที่จัดรูปแบบแล้ว (วัดได้ +43%) ให้เก็บไว้ในเครื่อง: ใช้ /minify บีบกลับเหลือบรรทัดเดียวก่อนส่งมอบหรือคอมมิต; เมื่อเกินหลาย MB ให้เปลี่ยนไปใช้สตรีมมิ่ง ซึ่งกล่าวถึงใน การแยกวิเคราะห์ การเรนเดอร์ และสตรีมมิ่ง JSON ขนาดใหญ่

คำถามที่พบบ่อย

ทำไมเครื่องมือทั้งสามรายงานตำแหน่งต่างกัน

เพราะนับคนละอย่าง char N ของ Python กับ position N ของ Node ต่างหมายถึง "ที่ที่ตัวแยกวิเคราะห์หยุด" — บนตัวอย่างนี้ทั้งคู่เป็น 23610 จึงตรงกัน jq รายงานว่า token ที่มีปัญหาเริ่มที่ไหน ในที่นี้คือ 23619 ไกลออกไปอีกเก้า ไม่มีใครคำนวณผิดและไม่ต้องแก้ค่าใด ๆ ให้เทียบตัวเลขภายในเครื่องมือเดียวกันเท่านั้น

ควรใช้ cut -c หรือ head -c

ใช้ cut -c JSONDecodeError.pos เป็น offset ที่นับเป็นอักขระ Unicode และ cut -c ตัดตามตัวอักษร สองอย่างจึงตรงกัน head -c, tail -c และ dd ตัดตามไบต์ ซึ่งเลื่อนบนเอกสาร UTF-8 ที่มีข้อความ CJK และอาจตัดอักขระขาดครึ่งได้ บนตัวอย่างภาษาจีน offset เดียวกันให้ 城市":"杭州"}, จากมุมมองตัวอักษร และ DD00000"," จากมุมมองไบต์

การจัดการคีย์ซ้ำไร้การป้องกันจริง ๆ ทุกที่หรือไม่

จริง RFC 8259 หมวด 4 บอกเพียงว่าชื่อ SHOULD ไม่ซ้ำกัน และระบุว่าพฤติกรรมคาดเดาไม่ได้เมื่อซ้ำ Python, Node และ jq ที่วัดทั้งหมดใช้หลักตัวสุดท้ายชนะโดยไม่แจ้งคำเตือน การจับได้ต้องใช้ object_pairs_hook ของคุณเอง ด้วยต้นทุนเวลาการแยกวิเคราะห์ราว 3.7 เท่า

ทำไม Python อ่าน NaN ได้ปกติ แต่ Node throw

RFC 8259 หมวด 6 ไม่อนุญาต NaN และ Infinity เป็นค่าคงที่ของตัวเลข json.loads ของ Python รับโดยค่าเริ่มต้นเพราะ float ของ Python มีค่าเหล่านี้ ข้อมูลเสียจึงผ่าน Python และถูกเขียนกลับออกมาตรง ๆ JSON.parse ของ Node ทำตามมาตรฐานอย่างเคร่งครัดและ throw SyntaxError กับเอาต์พุตนั้น การเพิ่ม parse_constant ที่จุดเข้าใช้งานจะปฏิเสธทันที

เลข order 20 หลักสูญเสียความแม่นยำผ่าน JSON เสมอหรือไม่

เฉพาะเมื่อผู้บริโภคแยกวิเคราะห์เป็น IEEE 754 double int ของ Python มีความแม่นยำไม่จำกัดและเก็บ 12345678901234567890 ไว้ได้ ขณะที่ Number.MAX_SAFE_INTEGER ของ JavaScript คือ 9007199254740991 ฟิลด์เดียวกันจึงกลายเป็น 12345678901234567000 MDN ระบุชัดว่า reviver ทำงานหลังการแยกวิเคราะห์ จึงกู้คืนอะไรฝั่งปลายทางไม่ได้ ทางแก้ที่เชื่อถือได้เดียวคือผู้ผลิตต้องส่งออกเป็นสตริง