JSON Toolkit

Sổ ghi bộ nhớ của JSON: chuỗi, object và mảng lớn thật sự tốn bao nhiêu

Kết luận: Một tệp JSON 10MB có thể thành 30-50MB trong heap V8. Hãy hiểu chuỗi, object và số thật sự tốn bao nhiêu, cách lấy đường cơ sở bằng process.memoryUsage, và khi nào truyền luồng giúp ích.

Nâng caoĐọc khoảng 11 phútXuất bản 2026-10-31Cập nhật 2026-10-31
Sổ ghi bộ nhớ JSON: chi phí heap của chuỗi, object và mảng trong V8

Kết luận: trước hết hãy lấy đường cơ sở bằng process.memoryUsage, ghi riêng phần tăng của văn bản và của đồ thị đối tượng, rồi loại bỏ một bản sao đầy đủ trước khi thử bất cứ điều gì khác — không JSON.parse(JSON.stringify(x)), không bọc tài liệu thô trong hệ phản ứng. Tiếp theo hãy cắt các tham chiếu sống lâu, và cân nhắc truyền luồng sau cùng: đường bộ nhớ phẳng của nó chỉ đáng đánh đổi truy cập ngẫu nhiên khi tệp không vừa hoặc bạn chỉ cần vài trường.

Một tài liệu JSON 10MB có thể thành 30MB đến 50MB trong heap sau JSON.parse, và đỉnh tạm thời còn cao hơn nữa. Không làm phép tính này, "tối ưu hóa" chỉ là phỏng đoán.

1. Văn bản và đồ thị đối tượng là hai sổ ghi khác nhau

Trên đĩa và trong thân yêu cầu, JSON là một chuỗi byte. Khi vào bộ nhớ, nó thành hai thứ: một chuỗi, và một đồ thị đối tượng dựng từ object, mảng, số và boolean. Giữ riêng hai sổ ghi đó là cách duy nhất để giải thích vì sao một tệp 10MB có thể khiến tiến trình tiêu tốn 80MB.

Hình dạng dữ liệu Đồ thị đối tượng so với kích thước văn bản Chi phí chi phối
Mảng phẳng gồm các object (khoảng 8 trường mỗi object) Khoảng 2,5-4 lần Một header object và các ô thuộc tính cho mỗi phần tử
Cấu hình lồng sâu Khoảng 3-5 lần Nhiều object nhỏ, mỗi object một header riêng
Mảng gồm các chuỗi rất dài Khoảng 1,2-2 lần Header chuỗi cộng dữ liệu ký tự
Số lớn có phần thập phân Khoảng 2-3 lần Một HeapNumber cho mỗi giá trị
Tài liệu có nhiều khóa lặp lại Tỉ lệ với số khóa Băm khóa và thiết lập thuộc tính khi phân tích

Các hệ số này là tham chiếu theo bậc độ lớn, phụ thuộc phiên bản V8, nén con trỏ, số trường và nội dung chuỗi. Bảng chỉ nói một điều: đồ thị đối tượng được định cỡ theo "số nút x chi phí mỗi nút", không theo số byte trong văn bản.

2. Chuỗi: khoản mục bị đánh giá thấp nhất

Một chuỗi V8 không phải mảng ký tự trần. Nó mang một header object (con trỏ map, trường hash và độ dài), tốn khoảng một chục byte trên bản build 64-bit điển hình có nén con trỏ, trước cả ký tự nào. Các ký tự được lưu theo một trong hai cách:

  • Chuỗi một byte cho ký tự trong dải Latin-1: 1 byte mỗi ký tự;
  • Chuỗi hai byte khi nội dung gồm ký tự CJK, emoji hoặc bất cứ thứ gì ngoài Latin-1: 2 byte mỗi ký tự.

Một ký tự Trung Quốc chiếm 3 byte trong tệp UTF-8 nhưng 2 byte mỗi ký tự trong chuỗi hai byte, cộng header. Kích thước văn bản và kích thước heap không phải cùng một phép đo, và chúng không phải lúc nào cũng đi cùng hướng.

Ba chi tiết ảnh hưởng trực tiếp đến công việc với JSON:

  1. Slice giữ cha còn sống. V8 biểu diễn các chuỗi con dài hơn dưới dạng SlicedString, lưu một tham chiếu tới cha cộng offset và độ dài. Lưu 200 ký tự được cắt từ một nhật ký 80MB và toàn bộ 80MB vẫn còn chạm được cho tới khi slice đó được giải phóng.
  2. Nối chuỗi tạo ConsString. Việc += lặp lại trên chuỗi lớn dựng nên một cây mảnh chỉ được làm phẳng thành bộ nhớ liền mạch khi ký tự thật sự được đọc. Nối mười nghìn lần rồi đọc một lần sẽ tạo một đợt cấp phát lớn đúng lúc đó.
  3. Giá trị lặp lại không tự động được chia sẻ. Mười nghìn bản ghi chứa "status": "paid" thường có mười nghìn đối tượng chuỗi độc lập; trình phân tích không trỏ chúng vào một hằng. Để tiết kiệm chi phí đó, bạn phải tự ánh xạ các chuỗi thường gặp sang enum hoặc số.

Khi cần giữ một đoạn trích ngắn trong thời gian dài, hãy biến nó thành chuỗi độc lập và giải phóng bản gốc:

// Bad: the slice references the whole document, so logText cannot be collected
const summary = logText.slice(0, 200);

// Good: create an independent string, then release logText
const summaryCopy = Buffer.from(summary, 'utf8').toString('utf8');

3. Object: mỗi khóa chiếm một ô

Một object thuần chứa ít nhất ba thứ: con trỏ tới lớp ẩn (map), con trỏ tới kho thuộc tính và con trỏ tới kho phần tử. Với nén con trỏ, mỗi con trỏ là 4 byte, và header object trên heap cộng thêm, nên ngay cả một object rỗng cũng tốn khoảng mười sáu đến hơn hai mươi byte. Trên bản build 64-bit không nén con trỏ, hãy nhân đôi.

Thuộc tính có tên rơi vào một trong hai chỗ:

  • Thuộc tính trong object: các ô bố trí ngay sau header object, cho truy cập nhanh nhất;
  • Một PropertyArray: khi các ô trong object cạn, thuộc tính chuyển sang một mảng riêng và object giữ con trỏ tới đó.

Mỗi ô là 4 hoặc 8 byte, và chuỗi khóa được tính riêng. Đó là lý do tên trường dài trở thành chi phí thật qua hàng trăm nghìn bản ghi, chứ không chỉ là vấn đề thẩm mỹ.

Tin tốt là lớp ẩn được chia sẻ: một triệu object có cùng khóa theo cùng thứ tự chia sẻ một map thay vì sao chép thông tin cấu trúc. Tin xấu là các mẫu sau phá vỡ sự chia sẻ đó hoặc đẩy object sang một biểu diễn tốn kém hơn:

  • Một số bản ghi có trường remark và số khác thì không, tạo ra hai hình dạng và thêm một lần kiểm tra hình dạng ở mỗi lần truy cập;
  • Thứ tự chèn khác nhau có thể tạo ra hình dạng khác nhau;
  • Số lượng thuộc tính rất lớn (vượt khoảng một nghìn) hoặc thao tác delete thường xuyên đẩy object sang chế độ từ điển, nơi mỗi thuộc tính còn mang một mục bảng băm;
  • Object.create(null) ở chế độ từ điển ngay từ khi sinh ra. Nó là bảng tra tốt và là lựa chọn kém cho một bản ghi có lược đồ cố định được truy cập trong vòng lặp nóng.

Một hiểu lầm phổ biến cần được sửa: JSON cho phép khóa trùng lặp, và {"a":1,"a":2} là một tài liệu hợp lệ với kết quả a === 2. Object cuối cùng chỉ giữ một giá trị, nhưng trình phân tích vẫn quét và gán mọi lần xuất hiện. Khóa trùng lặp không tiết kiệm bộ nhớ; chúng chỉ tốn thời gian phân tích.

4. Mảng và số: loại phần tử quyết định độ rộng

V8 theo dõi một số "loại phần tử" cho mảng, và mỗi loại có độ rộng khác nhau cho mỗi ô:

Nội dung mảng Loại phần tử Chi phí ước tính mỗi ô
Số nguyên nhỏ (trong phạm vi Smi) PACKED_SMI_ELEMENTS 4 byte
Số thập phân hoặc số nguyên rất lớn PACKED_DOUBLE_ELEMENTS 8 byte
Chuỗi hoặc object PACKED_ELEMENTS Con trỏ 4 byte cộng bản thân giá trị
Mảng có lỗ do delete HOLEY_* Như trên, cộng kiểm tra lỗ
Mảng rất thưa DICTIONARY_ELEMENTS Mục băm, thường 20+ byte

Hai quy tắc đáng ghi nhớ:

Thứ nhất, loại phần tử chỉ có thể xuống cấp. Một mảng bắt đầu bằng số nguyên nhỏ trở thành mảng double ngay khi bạn chèn 0.5, và thành mảng phần tử tổng quát khi bạn chèn một chuỗi. Nó không bao giờ tự phục hồi; bạn phải dựng một mảng mới.

Thứ hai, một số JSON rơi vào đâu phụ thuộc giá trị của nó. Số nguyên trong phạm vi Smi (khoảng cộng trừ 1,07 tỷ) được lưu trực tiếp dưới dạng Smi và chiếm một ô con trỏ. Số có phần thập phân hoặc số mũ, hoặc giá trị ngoài phạm vi đó, cấp phát một HeapNumber khoảng 16 byte, với con trỏ tới nó được lưu trong mảng. Vậy nên [1, 2, 3] và [0.1, 0.2, 0.3] không chỉ khác giá trị mà khác cả "một ô 4 byte" so với "một ô 8 byte cộng một object trên heap."

Đây cũng là gốc của vấn đề độ chính xác với ID số nguyên lớn: vượt phạm vi số nguyên an toàn, số JSON được xử lý như double và các chữ số bậc thấp đơn giản biến mất. Mảng JSON đến dưới dạng packed và không có lỗ, nên đừng delete phần tử sau khi phân tích; làm vậy đẩy cả mảng về một biểu diễn tốn kém hơn.

5. Đỉnh khi phân tích và tuần tự hóa: thời điểm tiến trình thật sự chết

Bộ nhớ ổn định là một sổ ghi; đỉnh là sổ khác. Trong khi JSON.parse(text) chạy, tiến trình giữ ba thứ cùng lúc:

  1. Chuỗi đầu vào text;
  2. Đồ thị đối tượng đang được dựng và chưa hoàn chỉnh;
  3. Các cấu trúc tạm thời của chính trình phân tích.

Đỉnh khi phân tích xấp xỉ "văn bản + đồ thị đối tượng + chi phí tạm thời." Sau khi phân tích, nếu text vẫn còn bị tham chiếu bởi một biến, một closure, một bộ đệm cấp module hoặc một store phản ứng, bộ nhớ của văn bản đó không bao giờ được trả lại.

Tuần tự hóa cũng có đỉnh. JSON.stringify(data) giữ toàn bộ đồ thị đối tượng trong khi dựng chuỗi đầu ra, với các bộ đệm nối chuỗi ở giữa. Khi đầu ra tiến gần giới hạn độ dài chuỗi của V8 (khoảng 500 triệu ký tự), nó ném RangeError; lỗi này thường xuất hiện trong các script kết xuất toàn bộ cơ sở dữ liệu thành một tài liệu JSON.

Mẫu phổ biến tốn kém nhất dùng tuần tự hóa như một bản sao sâu:

// Peak is roughly: original object graph + temporary text + new object graph
const safeCopy = JSON.parse(JSON.stringify(bigData));

Chỉ một dòng đó lưu cùng dữ liệu ba lần. Với công việc chỉ đọc, hãy truyền tham chiếu. Khi thật sự cần cô lập, dùng structuredClone và chấp nhận rằng nó cũng dựng lại toàn bộ đồ thị đối tượng.

6. Đo bằng process.memoryUsage

Trong Node, process.memoryUsage() trả về năm con số cùng lúc: rss (bộ nhớ vật lý tiến trình dùng), heapTotal, heapUsed (heap JS đã dùng), external (bộ nhớ do các object C++ giữ), và arrayBuffers (bộ nhớ ArrayBuffer và Buffer). Khi nghiên cứu việc xử lý JSON, heapUsed là thấu kính đúng cho đồ thị đối tượng và rss cho bạn biết toàn bộ tiến trình có đang mất kiểm soát không.

Một script đo cần ba kỷ luật: gọi global.gc() trước, lấy mẫu theo giai đoạn, và thay đổi một biến mỗi lần. Script này ghi riêng phần tăng của "đọc văn bản" và "phân tích thành object":

// Run: node --expose-gc measure-json-memory.mjs
import { readFileSync } from 'node:fs';

global.gc();
const baseline = process.memoryUsage().heapUsed;

const text = readFileSync('big.json', 'utf8');
const afterRead = process.memoryUsage().heapUsed;

const data = JSON.parse(text);
const afterParse = process.memoryUsage().heapUsed;

console.log({
  textOnHeap: afterRead - baseline,
  graphOnHeap: afterParse - afterRead,
  parsePeak: afterParse - baseline,
});

Một vài cạm bẫy:

  • --expose-gc phải được bật rõ ràng; nếu không global.gc không tồn tại;
  • heapUsed chỉ bao phủ heap JS. Các Buffer đọc tệp nằm dưới external và arrayBuffers, nên đừng dùng một con số để giải thích mọi thứ;
  • Đừng console.log một object lớn giữa các lần lấy mẫu; nó giữ một tham chiếu tạm thời còn sống;
  • Lần chạy đầu bao gồm khởi động JIT và biên dịch lười. Hãy chạy ít nhất ba vòng và lấy trung vị;
  • Để so sánh trong trình duyệt, hãy chụp heap snapshot trong bảng Memory của DevTools và đọc Retained Size thay vì nhìn chằm chằm vào hình răng cưa trong Allocation Timeline.

Không có đường cơ sở này, những tuyên bố như "chuyển sang truyền luồng sẽ sửa được" hay "một bộ nhớ đệm sẽ làm nó nhanh hơn" chỉ là cảm giác.

7. Truyền luồng mua đỉnh bộ nhớ, không mua tổng thời gian

Sự đánh đổi trong phân tích dạng luồng rất rõ ràng: truy cập ngẫu nhiên để đổi lấy bộ nhớ không còn tỉ lệ với kích thước tệp.

Cách tiếp cận Đỉnh bộ nhớ Truy cập ngẫu nhiên Phù hợp nhất với
JSON.parse toàn bộ Văn bản cộng đồ thị đối tượng hoàn chỉnh Có Dưới khoảng 100MB và cần phần lớn trường
Trình phân tích SAX như stream-json Một khối cộng phần tử hiện tại Không Tệp khổng lồ, hoặc chỉ cần vài trường
JSON Lines theo từng dòng Object cho một dòng Không Dữ liệu vốn là một lô bản ghi
Chỉ mục hoặc cơ sở dữ liệu bên ngoài Không nạp gì hoàn chỉnh Có Truy vấn lặp lại trên cùng dữ liệu
import { createReadStream } from 'node:fs';
import { parser } from 'stream-json';
import { pick } from 'stream-json/filters/Pick';
import { streamArray } from 'stream-json/streamers/StreamArray';

const pipeline = createReadStream('huge.json')
  .pipe(parser())
  .pipe(pick({ filter: 'items' }))
  .pipe(streamArray());

for await (const { value } of pipeline) {
  handleItem(value);
}

Đoạn mã này không bao giờ dựng cây đầy đủ, nên đường bộ nhớ giữ phẳng. Cái giá cũng có thật: bạn không thể quay lại lấy một trường đã bỏ qua, tổng hợp toàn cục trở nên khó, phục hồi lỗi là trách nhiệm của bạn, và một phép quét từng ký tự bằng JS thuần thường chậm hơn JSON.parse native một bậc độ lớn.

Quyết định không phải là "tệp có vượt 10MB không." Hãy hỏi hai câu: Tôi có cần phần lớn các trường sau khi phân tích không? Tôi có sống được với truy cập tuần tự không? Nếu cả hai câu trả lời là có, phân tích toàn bộ là đúng và công sức của bạn thuộc về việc giảm bản sao và kết xuất. Nếu một trong hai câu trả lời là không, hãy cân nhắc truyền luồng. Để có chiến lược phân tầng đầy đủ, xem Phân tích, kết xuất và truyền luồng JSON lớn.

8. Ít bản sao hơn, giải phóng sớm hơn

Trong công việc với bộ nhớ, loại bỏ một bản sao đầy đủ thường đáng hơn việc đổi trình phân tích:

  • Truyền tham chiếu cho công việc chỉ đọc. Đừng sao chép sâu "để an toàn", đặc biệt đừng dùng JSON.parse(JSON.stringify(x));
  • Đừng bọc tài liệu thô trong một hệ phản ứng. reactive của Vue tạo proxy đệ quy cho toàn bộ đồ thị đối tượng, và lớp proxy có chi phí bộ nhớ riêng;
  • Tính và đệm riêng dữ liệu dẫn xuất. Nếu một danh sách chỉ cần id, title và status, đừng đưa cả bản ghi vào store;
  • Chuẩn hóa các chuỗi tần suất cao. Ánh xạ các giá trị như "paid" và "pending" sang enum số để tiết kiệm cả đối tượng chuỗi lẫn chi phí so sánh;
  • Cắt các tham chiếu sống lâu. Đặt null cho object lớn khi xong, xóa bộ đệm, và hủy đăng ký callback để closure không mang chúng sang trang kế tiếp;
  • Tính đến chi phí sao chép của Worker. postMessage mặc định dùng sao chép có cấu trúc; gửi một đồ thị đối tượng 50MB về luồng chính dựng nên bản sao thứ hai. Hãy trả về tóm tắt bất cứ khi nào có thể.

Nửa còn lại của sổ kích thước nằm trong Đừng nén JSON sai thứ tự: byte trên đĩa và byte trên đường truyền là một tài khoản khác với đồ thị đối tượng trong heap, và không có một tỉ lệ phần trăm nào giải thích được cả hai. Về cách trình biên tập và công cụ dòng lệnh hành xử với bộ nhớ, xem Trình biên tập, công cụ dòng lệnh và tự động hóa.

9. Danh sách kiểm tra

Triệu chứng Nguyên nhân nhiều khả năng hơn Hành động đầu tiên
Một tệp 10MB đẩy tiến trình lên 50MB Đồ thị đối tượng và văn bản đều còn sống Đo theo giai đoạn; kiểm tra xem văn bản có bị giữ lại không
OOM lúc phân tích, bình thường ở trạng thái ổn định Đỉnh khi phân tích chồng lên dữ liệu cũ Giải phóng object cũ trước khi phân tích tệp mới
Bộ nhớ tăng chậm và không bao giờ giảm Một slice, bộ đệm hoặc closure giữ tham chiếu Chụp heap snapshot và sắp xếp theo kích thước giữ lại
Mảng lớn hơn kỳ vọng Một số thập phân làm xuống cấp loại phần tử Thống nhất kiểu số; tránh delete
Thêm bộ nhớ đệm làm mọi thứ chậm hơn Khóa và giá trị tiêu tốn bộ nhớ, tăng áp lực GC Giới hạn bộ nhớ đệm và chỉ lưu tóm tắt

Tại sao bộ nhớ tăng ba hoặc bốn lần sau JSON.parse?

Vì văn bản chỉ là đầu vào; sản phẩm của phân tích là một đồ thị đối tượng. Mỗi object có một header, mỗi thuộc tính có một ô, và mỗi chuỗi có header riêng. Với đủ số nút, tổng chi phí vượt số byte của văn bản. Hệ số phụ thuộc hình dạng dữ liệu, và mảng object phẳng khác rất nhiều so với tài liệu lồng sâu.

Tệp JSON 10MB nên được phân tích toàn bộ hay truyền luồng?

Hãy xét mục đích trước. Nếu bạn cần truy cập ngẫu nhiên, cần phần lớn các trường, và tệp vừa bộ nhớ, dùng JSON.parse toàn bộ. Nếu bạn chỉ cần vài trường, hoặc tệp không vừa, hãy truyền luồng. 10MB không phải ranh giới; truyền luồng thường trở thành bắt buộc ở đâu đó trên 100MB.

Tại sao process.memoryUsage().heapUsed khác với giám sát hệ thống?

heapUsed chỉ bao phủ phần đã dùng của heap JS, loại trừ external, arrayBuffers và code space. Giám sát hệ thống báo rss, một phép đo khác. Hãy theo dõi heapUsed và rss cùng nhau; khoảng cách lớn chỉ ra Buffer và object native.

JSON.parse(JSON.stringify(x)) có phải bản sao sâu an toàn không?

Không. Nó giữ đồ thị gốc, văn bản tạm thời và đồ thị mới cùng lúc, nên đỉnh xấp xỉ ba lần trạng thái ổn định, và nó làm mất undefined, hàm và ngữ nghĩa Date. Hãy truyền tham chiếu cho công việc chỉ đọc; khi cần cô lập, dùng structuredClone và chấp nhận cùng chi phí dựng lại.