JSON Text Sequences (RFC 7464): JSON dạng luồng phân tách bằng RS
Kết luận: RFC 7464 dùng ký tự phân tách 0x1E để truyền luồng các văn bản JSON mà bạn có thể đọc và ghi cùng lúc. Bài viết trình bày quy tắc, khác biệt so với NDJSON, và cách hiện thực tách-rồi-phân-tích.

Kết luận: dùng JSON-seq khi bạn kiểm soát cả hai đầu, cần một kiểu phương tiện đã đăng ký, và muốn bên đọc đồng bộ lại sau khi dữ liệu bị cắt cụt; dùng NDJSON khi dữ liệu chảy vào hệ thống nhật ký, phải đọc được bằng mắt người, hoặc phải làm việc với công cụ sẵn có — đừng bao giờ ép JSON-seq lên một bên chỉ hiểu đầu vào theo dòng.
NDJSON tách bản ghi bằng ký tự xuống dòng, nhưng xuống dòng là quy ước của con người. RFC 7464 ghi ranh giới vào chính dòng byte: một ký tự 0x1E vô hình trước mỗi bản ghi.
1. Vấn đề được giải quyết bằng một byte duy nhất
RFC 7464 được công bố vào tháng 2 năm 2015 với tiêu đề JSON Text Sequences, và nó đăng ký kiểu phương tiện application/json-seq. Nó không thêm kiểu dữ liệu mới. Nó chỉ định nghĩa cách một chuỗi văn bản JSON được sắp xếp trong một dòng byte. Toàn bộ quy tắc gói trong một câu: đặt một RS trước mỗi văn bản JSON và một LF sau nó.
- RS là ký tự phân tách bản ghi ASCII, điểm mã
0x1E, trong Unicode được gọi là INFORMATION SEPARATOR TWO (U+001E); - LF là ký tự xuống dòng,
0x0A.
Hai bản ghi trông như sau ở mức byte:
1E 7B 22 69 64 22 3A 31 7D 0A ← 0x1E {"id":1} + LF
1E 7B 22 69 64 22 3A 32 7D 0A ← 0x1E {"id":2} + LF
RS nằm trước bản ghi, ngược với cách bố trí "ghi nội dung, rồi ghi ký tự phân tách" của NDJSON. Hướng đó thay đổi hành vi phục hồi: bên đọc thấy 0x1E biết rằng bản ghi trước đã kết thúc, nên ngay cả một bản ghi bị cắt cụt giữa lúc ghi cũng không thể làm mờ ranh giới của bản ghi kế tiếp.
Đây không phải thiết kế lý thuyết. RFC nêu rõ trường hợp các bản ghi được nối thêm vào tệp nhật ký rồi bị hệ thống tệp cắt cụt sau khi sập hoặc bị kill, và nói rằng trình phân tích không nên hủy toàn bộ việc đọc vì chuyện đó.
2. Hai bộ quy tắc: bên ghi và bên phân tích
RFC 7464 khác thường ở chỗ cung cấp hai ngữ pháp ABNF, một bộ ràng buộc bên ghi và một bộ nới lỏng cho bên đọc.
| Vai trò | Ngữ pháp | Ý nghĩa |
|---|---|---|
| Bên ghi | *(RS JSON-text LF) |
Một RS trước mỗi văn bản JSON, một LF sau nó |
| Bên phân tích | *(1*RS possible-JSON) |
Cho phép nhiều byte RS liên tiếp, và LF giữa các bản ghi là tùy chọn |
Trong kỹ thuật, có năm quy tắc cứng:
- UTF-8 là bắt buộc; không được dùng UTF-16 và UTF-32;
- Bên phân tích coi "mọi byte không phải RS" là một văn bản JSON khả dĩ và cố phân tích nó; khi thất bại, nó nhảy tới RS kế tiếp và tiếp tục;
- Các byte RS liên tiếp không đại diện cho phần tử rỗng và được bỏ qua;
- Không có dấu kết thúc chuỗi, nên luồng chỉ đơn giản là kết thúc;
- Khi giá trị cấp cao nhất là một số,
true,false, hoặcnull, bản ghi phải kết thúc bằng một byte khoảng trắng, nếu không nó bị coi là có thể bị cắt cụt và bị loại bỏ.
Quy tắc thứ năm là quy tắc người ta hay quên. Đối tượng, mảng và chuỗi tự phân định trong JSON; số thì không. <RS>123<RS> không cho bạn biết nó chứa 123 hoàn chỉnh hay 1234 bị cắt cụt. Vì vậy RFC yêu cầu bên ghi thêm một LF, dùng nó như một byte cảnh báo để phát hiện cắt cụt. Tương tự, <RS>true<RS> có thể ban đầu vốn là trueish.
3. Chọn giữa JSON-seq và NDJSON
Cả hai định dạng đều truyền luồng, nhưng chúng nhắm tới những vấn đề khác nhau:
| Khía cạnh | JSON Text Sequences | NDJSON / JSON Lines |
|---|---|---|
| Vị trí ký tự phân tách | RS (0x1E) trước bản ghi |
LF (0x0A) sau bản ghi |
| Kiểu phương tiện | application/json-seq, đã đăng ký IANA |
Không có đăng ký chính thức, quy ước cộng đồng |
| Khả năng đọc byte | Ký tự điều khiển vô hình, cat không hiện gì |
Văn bản thuần, đọc trực tiếp được |
| Ghi nối thêm đồng thời | Bên đọc có thể đồng bộ lại và bỏ phần đuôi bị cắt cụt | Ghi nối thêm cũng được, dòng lỗi bị bỏ qua |
| Số ở cấp cao nhất | Cần một byte khoảng trắng cuối để đáng tin | Ký tự xuống dòng là ranh giới |
| Ký tự phân tách trong chuỗi | RS chỉ có thể xuất hiện dưới dạng \u001e |
LF phải được viết là \n |
| Công cụ | jq --seq và một vài thư viện |
Gần như mọi công cụ nhật ký và dữ liệu lớn |
| Phần mở rộng tệp | Không được RFC định nghĩa | .jsonl / .ndjson |
Quyết định ngắn gọn: dùng JSON-seq khi bạn kiểm soát cả hai đầu, muốn một kiểu phương tiện chính thức, và cần bên đọc đồng bộ lại sau khi bị cắt cụt; dùng JSON Lines và NDJSON khi dữ liệu chảy vào hệ thống nhật ký, cần con người đọc được, hoặc phải làm việc với công cụ sẵn có.
4. Một trình phân tích: tách theo 0x1E, rồi JSON.parse
Lợi ích cụ thể nhất của RFC 7464 là bạn không cần một trình phân tích JSON dạng luồng. Hãy tách theo byte, rồi gọi JSON.parse trên từng đoạn:
/** ASCII record separator, defined by RFC 7464 as 0x1E */
const RECORD_SEPARATOR = 0x1e;
/**
* Parses a chunk of JSON Text Sequence bytes.
* @param buffer Bytes that have been read so far
* @returns Successfully parsed records and the number of failed records
*/
function parseJsonSequence(buffer: Buffer): { records: unknown[]; badCount: number } {
const records: unknown[] = [];
let badCount = 0;
let recordStart = buffer.indexOf(RECORD_SEPARATOR);
while (recordStart !== -1) {
const nextRecordStart = buffer.indexOf(RECORD_SEPARATOR, recordStart + 1);
const recordEnd = nextRecordStart === -1 ? buffer.length : nextRecordStart;
const rawText = buffer.subarray(recordStart + 1, recordEnd).toString('utf8').trim();
if (rawText !== '') {
try {
records.push(JSON.parse(rawText));
} catch {
badCount += 1;
}
}
recordStart = nextRecordStart;
}
return { records, badCount };
}
Ba lưu ý khi hiện thực:
- Tách ở mức byte là an toàn. Các byte tiếp nối UTF-8 nằm trong dải
0x80–0xBF, nên chúng không bao giờ bằng0x1E. Tách theo 0x1E không thể cắt đôi một ký tự nhiều byte. - Đệm phần đuôi khi truyền luồng. Khi đọc theo khối, đoạn cuối có thể chưa chạm tới RS kế tiếp. Hãy giữ nó trong bộ đệm cho tới khi khối kế tiếp đến; nếu không bạn sẽ phân tích cùng một nửa bản ghi hai lần.
- Đếm thất bại thay vì dừng lại. RFC yêu cầu trình phân tích nhảy tới RS kế tiếp và tiếp tục, nên
badCountlà một chỉ số cảnh báo, không phải ngoại lệ. Bản ghi cuối bị cắt cụt cũng thuộc nhóm đó.
Mức sử dụng bộ nhớ phụ thuộc bản ghi đơn lớn nhất cộng một bộ đệm, không phụ thuộc tổng kích thước tệp. Đó cũng là ý tưởng được thảo luận trong phân tích và truyền luồng JSON lớn.
5. Ký tự điều khiển bên trong chuỗi
JSON yêu cầu mọi ký tự điều khiển từ U+0000 đến U+001F phải được escape bên trong chuỗi, và RS nằm trong dải đó. Vì vậy một chuỗi JSON hợp lệ chỉ có thể chứa năm ký tự \u001e, không bao giờ chứa byte 0x1E thô. Đó là lý do việc tách theo 0x1E luôn an toàn với dữ liệu hợp lệ.
Nguy hiểm nằm ở phía bên ghi:
- Tự dựng văn bản JSON bằng
'\x1e', chèn một ký tự điều khiển thô vào chuỗi; - Dựng JSON bằng template literal thay vì
JSON.stringify, để xuống dòng, ngoặc kép và ký tự điều khiển không được escape; - Sao chép văn bản chứa RS từ terminal hoặc trình biên tập, nơi ký tự điều khiển bị nuốt hoặc bị thay thế.
Mẫu đúng luôn là tạo văn bản trước, rồi gắn ký tự phân tách:
const recordText = JSON.stringify(record);
const line = `${String.fromCharCode(RECORD_SEPARATOR)}${recordText}\n`;
JSON.stringify xuất ký tự điều khiển dưới dạng \u00XX, nên RS thô không bao giờ xuất hiện bên trong bản ghi. Khi bước này đúng, logic tách chỉ phải xử lý byte và không bao giờ cần hiểu cú pháp JSON.
Phần lớn lỗi trong nhóm này lộ ra dưới dạng JSON.parse ném Unexpected token, đặc biệt là trường hợp khó hiểu "ký tự đầu tiên là một byte điều khiển vô hình". Bảng tra thông báo lỗi JSON đi qua từng dòng loại lỗi đó.
6. Truyền tải HTTP và kiểu phương tiện
application/json-seq là kiểu phương tiện đã đăng ký, không có tham số bắt buộc hay tùy chọn, và bộ ký tự cố định là UTF-8. Trên HTTP, ba thực hành quan trọng:
- Gửi
Accept: application/json-seqvà phản hồi vớiContent-Type: application/json-seq; - Để truyền luồng thật sự, máy chủ phải dùng truyền tải chia khối và tắt đệm phản hồi ở các bên trung gian như reverse proxy, CDN và công cụ kiểm thử tải;
- Một client đọc theo khối có thể nhận một khối kết thúc giữa bản ghi, nên trình phân tích phải đệm tới RS kế tiếp trước khi phát ra một bản ghi.
Nếu bên kia chỉ hiểu đầu vào theo dòng, đừng ép JSON-seq lên họ. Nó là chuẩn, nhưng độ phủ hệ sinh thái thấp hơn NDJSON rất nhiều, nên tính tương thích phải được quyết định trong lúc thương lượng giao thức chứ không phải sau khi phân tích lỗi.
7. Dòng lệnh, nhật ký và hỗ trợ công cụ
jq là một trong số ít công cụ hỗ trợ sẵn; cờ --seq của nó tồn tại chính vì RFC 7464:
printf '\x1e{"id":1}\n\x1e{"id":2}\n' | jq --seq -c '.id'
Lệnh này in 1 và 2 trên các dòng riêng. Với endpoint dạng luồng, hãy nhớ tắt đệm của curl:
curl -sN https://api.example.com/events | jq --seq -c '.type'
jq --seq đọc đầu vào RFC 7464, bỏ qua bản ghi lỗi và tiếp tục phân tích. Điều này đặc biệt hữu ích khi theo dõi một tệp nhật ký đang được ghi: bản ghi cuối có thể mới ghi một nửa, và đầu ra sẽ không hiện một đối tượng đuôi giả.
7.1 Hỗ trợ sẵn theo môi trường
| Môi trường | Hỗ trợ sẵn | Ghi chú |
|---|---|---|
| jq | --seq |
Tùy chọn dòng lệnh dễ nhất |
| Node.js | Không | Buffer.indexOf cộng JSON.parse |
| Python | Không | Tách theo b'\x1e' |
| Go | Không | SplitFunc tùy biến cho bufio.Scanner |
| Trình duyệt | Không | fetch cộng ReadableStream, tự tách byte |
7.2 Cạm bẫy thường gặp
Các lỗi tập trung ở phía đọc:
- Đọc bằng
readline, khiến RS vô hình còn nằm ở đầu dòng đầu tiên nênJSON.parseluôn lỗi; - Mở tệp
.json-seqtrong trình biên tập nơi ký tự điều khiển vô hình, nên nó trông như một tài liệu JSON kỳ quái; - Tách bằng
split(/\n/), khiến xuống dòng đã escape trong bản ghi bị lẫn với LF giữa các bản ghi; - Coi RS là ký tự thường trong tài liệu, nên nó biến mất sau khi sao chép dán.
8. Khi nào đáng dùng
Phù hợp tốt:
- Bạn sở hữu cả bên sản xuất lẫn bên tiêu thụ, không có công cụ bên thứ ba ở giữa;
- Dữ liệu là một luồng sự kiện được nối thêm liên tục và tiến trình có thể sập, nên bạn cần phục hồi sau khi bị cắt cụt;
- Giao thức xứng đáng có một kiểu phương tiện chính thức thay vì một quy ước như
.jsonl; - Từng bản ghi có thể là số hoặc boolean ở cấp cao nhất, nơi quy tắc cắt cụt rõ ràng có ý nghĩa.
Phù hợp kém:
- Nhật ký phải vào ELK, Loki hoặc hệ thống khác chỉ hiểu đầu vào theo dòng;
- Người ta cần
cat,grepvàawkdữ liệu trực tiếp; - Bên kia chỉ phân tích được
application/json.
9. Danh sách kiểm tra
- [ ] Bên ghi tạo bản ghi bằng
JSON.stringify, rồi thêm0x1Eở đầu và\nở cuối; - [ ] Bản ghi chứa số, boolean hoặc
nullở cấp cao nhất kết thúc bằng LF; - [ ] Bên đọc tách theo byte thay vì đọc theo dòng;
- [ ] Đọc dạng luồng giữ phần đuôi chưa đóng trong bộ đệm cho tới khi RS kế tiếp đến;
- [ ] Thất bại phân tích được đếm và bỏ qua, không bao giờ làm chết cả chuỗi;
- [ ] Giao diện công khai khai báo
application/json-seqrõ ràng và giữ một phương án dự phòng NDJSON.
FAQ
Tôi có nên dùng JSON-seq cho một endpoint API công khai?
Chỉ khi cả hai đầu có thể thay đổi hoặc định dạng được thương lượng trước. JSON-seq là một chuẩn đã đăng ký với kiểu phương tiện application/json-seq, nhưng độ phủ hệ sinh thái thấp hơn NDJSON rất nhiều, nên ép nó lên client bên thứ ba thường thất bại. Hãy quyết định tính tương thích trong lúc thương lượng giao thức và giữ một phương án dự phòng NDJSON.
Tôi có nên chuyển một pipeline NDJSON sẵn có sang JSON-seq?
Thường là không. NDJSON đã truyền luồng, ghi nối thêm và bỏ qua dòng lỗi, và gần như mọi công cụ nhật ký cùng dữ liệu lớn đều chấp nhận nó. Chỉ chuyển khi bạn cần kiểu phương tiện đã đăng ký IANA hoặc cần việc đồng bộ lại mà một ký tự phân tách đứng trước mang lại, và chỉ khi bên sản xuất lẫn bên tiêu thụ có thể thay đổi cùng nhau.
JSON Text Sequences hay NDJSON phổ biến hơn?
NDJSON. Quy tắc của nó đơn giản hơn, độ phủ công cụ rộng hơn nhiều, và hệ thống nhật ký cùng pipeline dữ liệu lớn thường chấp nhận nó. JSON-seq thắng ở kiểu phương tiện đã đăng ký IANA và ở khả năng đồng bộ lại có được nhờ đặt ký tự phân tách lên trước, phù hợp với giao thức truyền luồng có kiểm soát hơn là trao đổi dữ liệu thông thường.
Tại sao ký tự phân tách được đặt trước bản ghi thay vì sau nó?
Vì bản ghi có thể bị cắt cụt. Với ký tự phân tách đứng trước, bên đọc thấy RS biết rằng một bản ghi mới đã bắt đầu, nên bản ghi trước đó dù hoàn chỉnh hay không cũng không thể làm hỏng bản ghi kế tiếp. Với ký tự phân tách ở cuối, một lần ghi bị ngắt để lại đoạn cuối không có cả dấu kết thúc lẫn dấu bắt đầu kế tiếp, nên chỉ có thể đoán ranh giới.
Điều gì xảy ra nếu 0x1E xuất hiện bên trong một bản ghi?
Một chuỗi JSON hợp lệ phải escape RS thành \u001e, nên 0x1E thô không nên xuất hiện. Nếu nó xuất hiện, văn bản không còn là JSON hợp lệ: tách theo 0x1E sẽ cắt sai chỗ và JSON.parse sẽ lỗi. Cách sửa thuộc về phía bên ghi: hãy dùng JSON.stringify thay vì tự dựng văn bản.
Trình duyệt có phân tích trực tiếp application/json-seq không?
Không. Trình duyệt không có trình phân tích JSON-seq; fetch chỉ giao các byte. Bạn cần dùng response.body.getReader(), tích lũy các khối cho tới 0x1E kế tiếp, và gọi JSON.parse trên từng đoạn. Với payload nhỏ, đọc toàn bộ body thành chuỗi trước rồi tách sau cũng hiệu quả.
Tại sao một số ở cấp cao nhất cần LF ở cuối?
Đối tượng, mảng và chuỗi tự phân định trong JSON, còn số thì không: <RS>123<RS> có thể là 123 hoàn chỉnh hoặc 1234 bị cắt cụt. Vì vậy RFC 7464 yêu cầu một byte khoảng trắng sau mỗi bản ghi, và LF đóng vai trò cảnh báo để phát hiện cắt cụt. Quy tắc tương tự áp dụng cho true, false và null ở cấp cao nhất.