Câu hỏi thường gặp về hiệu năng và bảo mật: chậm, giật và dữ liệu không đáng tin
Kết luận: Tại sao một tệp JSON vài chục MB không mở được, sao chép sâu tốn kém đến mức nào, làm sao ngăn ô nhiễm __proto__, và XSS lọt vào từ đâu — mười kết luận sơ cứu xếp theo triệu chứng.

Tóm lại: hãy chẩn đoán theo triệu chứng trước — phân tích, kết xuất hay bộ nhớ — rồi làm theo thứ tự sửa: giảm lượng dữ liệu, dừng sao chép sâu, đưa phân tích vào Worker kèm cuộn ảo, và dùng dạng luồng cho mọi thứ trên 50 MB. Với đầu vào không đáng tin, hãy giới hạn kích thước và độ sâu lồng nhau trước khi lọc __proto__, constructor và prototype.
"JSON chậm" sai trong chín trên mười trường hợp; thứ thực sự chậm thường là kết xuất, sao chép sâu, hoặc thứ tự thao tác sai.
1. Tại sao nó chậm
Phân tích một tệp JSON lớn rất chậm. Tôi tối ưu thế nào?
Hãy xác định nút cổ chai trước, bàn đến tối ưu sau. Các giai đoạn chênh nhau vài bậc độ lớn:
| Giai đoạn | Thời gian ước lượng cho 10 MB dữ liệu | Tại sao nó là nút cổ chai |
|---|---|---|
| Truyền qua mạng | Tùy băng thông | JSON không nén thường nén được còn khoảng 1/5 |
JSON.parse |
20–80 ms | Hiện thực native, vốn đã nhanh |
| Duyệt đệ quy / sao chép sâu | 200–2000 ms | Xử lý từng nút bằng JS thuần |
| Kết xuất DOM (dạng cây) | Vài giây, dễ đóng băng | Số nút bùng nổ |
Vì thế thứ tự tối ưu nên là:
- Giảm lượng dữ liệu: để API phân trang, chỉ trả về những trường cần thiết, và bật gzip/brotli;
- Tránh sao chép sâu: xem phần tiếp theo; đây là chi phí ẩn phổ biến nhất;
- Kết xuất theo yêu cầu: trong dạng xem cây, chỉ kết xuất các cấp đang mở rộng thay vì sinh hàng chục nghìn nút DOM cùng lúc;
- Dạng luồng: đừng phân tích tất cả cùng lúc khi dữ liệu vượt quá 10 MB.
Nếu nút cổ chai thực sự là JSON.parse, điều đó nghĩa là dữ liệu đã vài chục MB trở lên, và cách đúng là dùng trình phân tích dạng luồng:
import { parser } from 'stream-json';
import { streamArray } from 'stream-json/streamers/StreamArray';
// Handle array elements one by one; memory usage is independent of the total size
const pipeline = fs.createReadStream('big.json')
.pipe(parser())
.pipe(streamArray());
pipeline.on('data', ({ value }) => {
processItem(value);
});
Một JSON vài chục MB làm trình duyệt sập ngay khi mở. Giờ sao?
Trước hết hãy xác nhận nó "sập" hay "đóng băng". Hai trường hợp cần cách xử lý hoàn toàn khác nhau:
- Đóng băng: tab hiện "Page unresponsive" và có thể phục hồi sau vài chục giây. Nguyên nhân là các tác vụ dài làm nghẽn luồng chính;
- Sập: tab biến thành "Aw, Snap", thường là cạn bộ nhớ (OOM).
Dù thế nào, đừng xử lý nó trên luồng chính. Hãy đưa việc phân tích vào Web Worker:
// worker.js
self.onmessage = (event) => {
const parsed = JSON.parse(event.data);
self.postMessage(parsed);
};
Worker giải quyết "giao diện không đóng băng", không giải quyết trần bộ nhớ. Một tab trình duyệt thường có 1–4 GB khả dụng; một JSON 200 MB được phân tích thành object có thể chiếm 1 GB trở lên (mỗi cặp khóa-giá trị đều mang chi phí object), và cộng thêm kết xuất thì dễ chạm giới hạn.
Giải pháp thực sự là không bao giờ tải tất cả:
| Kích thước dữ liệu | Cách làm |
|---|---|
| < 5 MB | Phân tích trực tiếp bằng JSON.parse, chú ý khâu kết xuất |
| 5–50 MB | Phân tích trong Worker + cuộn ảo |
| > 50 MB | Phân tích dạng luồng, chỉ giữ các trường cần dùng |
| > 500 MB | Đổi định dạng (Parquet / SQLite / JSON Lines chia nhỏ) |
JSON.parse(JSON.stringify(obj)) có phải sao chép sâu không?
Nó là sao chép sâu, nhưng nó "âm thầm thay đổi dữ liệu" ở một số chỗ, nên không phù hợp làm công cụ sao chép tổng quát.
const source = {
date: new Date(),
fn: () => {},
undef: undefined,
nan: NaN,
map: new Map(),
nested: { a: 1 },
};
JSON.parse(JSON.stringify(source));
// {
// date: "2026-10-30T03:00:00.000Z", // Date becomes a string
// nan: null, // NaN becomes null
// nested: { a: 1 } // only this level is correct
// }
// fn and undef disappear, map becomes {}
Danh sách vấn đề:
| Kiểu | Kết quả |
|---|---|
Date |
Biến thành chuỗi ISO |
NaN / Infinity |
Biến thành null |
undefined / hàm / Symbol |
Biến mất trong object, biến thành null trong mảng |
Map / Set |
Biến thành {} |
BigInt |
Ném TypeError |
| Tham chiếu vòng | Ném TypeError |
Trình duyệt hiện đại có structuredClone, hỗ trợ Date, Map, Set và tham chiếu vòng nhưng không hỗ trợ hàm:
const copy = structuredClone(source);
Nếu chỉ là gộp cấu hình nông, toán tử spread là lựa chọn rẻ nhất: { ...config, timeout: 3000 }.
Tại sao mở rộng dạng xem cây lại giật thế?
Vì mọi nút đều là một phần tử DOM, và khi số nút lên đến hàng chục nghìn, chi phí dàn trang của trình duyệt bùng nổ.
Một JSON 10 MB có thể chứa 50k–100k nút lá. Ngay cả khi chỉ kết xuất vùng nhìn thấy, việc dựng DOM một lần tốn hàng trăm mili giây đến vài giây, và mỗi lần cuộn sau đó lại kích hoạt reflow.
Cách đúng:
- Cuộn ảo: chỉ kết xuất vài chục dòng trong khung nhìn và tái sử dụng nút DOM khi cuộn;
- Thu gọn mặc định: chỉ mở rộng hai cấp đầu và kết xuất các cấp sâu hơn theo yêu cầu;
- Giới hạn quy mô kết xuất: trên 10k nút, hãy hiện "Dữ liệu quá lớn, dùng tìm kiếm để định vị" thay vì ép kết xuất.
Đây cũng là lý do trình biên tập trên trang này suy giảm mượt mà với tệp lớn — không phải vì về mặt kỹ thuật nó không thể kết xuất, mà vì kết xuất sẽ không dùng được.
2. Tại sao nó bị tấn công
__proto__ injection hoạt động thế nào?
Ô nhiễm prototype. Kẻ tấn công dựng JSON có khóa __proto__; nếu mã của bạn gộp nó vào một object mà không lọc,
nó ghi đè Object.prototype và ảnh hưởng đến mọi object trong runtime.
const payload = JSON.parse('{"__proto__": {"isAdmin": true}}');
// Dangerous code: recursive merge without checking key names
function merge(target, source) {
for (const key in source) {
if (typeof source[key] === 'object') {
target[key] = target[key] || {};
merge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
merge({}, payload);
({}).isAdmin; // true — every object has been polluted
Sự thật then chốt: bản thân JSON.parse không làm ô nhiễm prototype; {"__proto__": ...} được phân tích thành một thuộc tính riêng bình thường.
Điều nguy hiểm là việc gộp, gán, Object.assign, lodash.merge và các thao tác tương tự sau khi phân tích.
Ba lớp phòng thủ:
- Lọc tên khóa: bỏ qua
__proto__,constructorvàprototypekhi gộp; - Dùng
Object.create(null)làm vật chứa để không có chuỗi prototype để ô nhiễm; - Dùng
Mapđể lưu các cặp khóa-giá trị không đáng tin.
function safeMerge(target, source) {
for (const key of Object.keys(source)) {
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
continue;
}
const value = source[key];
if (value && typeof value === 'object' && !Array.isArray(value)) {
target[key] = safeMerge(target[key] ?? {}, value);
} else {
target[key] = value;
}
}
return target;
}
Cũng lưu ý for...in duyệt cả thuộc tính kế thừa; Object.keys() an toàn hơn.
Cần chú ý gì khi phân tích JSON không đáng tin?
Hãy làm theo thứ tự này — chi phí thấp, lợi ích cao:
- Giới hạn kích thước: từ chối ngay mọi thứ vượt ngưỡng, không đọc nó vào bộ nhớ trước;
- Giới hạn độ sâu: một JSON lồng hàng trăm cấp có thể làm tràn stack của trình phân tích đệ quy;
- Giới hạn số khóa: ngăn tấn công va chạm băm và khuếch đại bộ nhớ;
- Lọc khóa nguy hiểm:
__proto__,constructor,prototype; - Kiểm chứng cấu trúc: cho nó qua một Schema để xác nhận kiểu và trường bắt buộc;
- Đừng tin
Content-Type: kiểu được khai báo và nội dung thực tế có thể không khớp.
Kiểm tra độ sâu rẻ; bạn có thể lọc thô bằng regex trước khi phân tích, hoặc duyệt sau khi phân tích:
function measureDepth(value, depth = 0) {
if (value === null || typeof value !== 'object') {
return depth;
}
let maxDepth = depth;
for (const key of Object.keys(value)) {
maxDepth = Math.max(maxDepth, measureDepth(value[key], depth + 1));
}
return maxDepth;
}
Nhưng đoạn mã này bản thân là đệ quy và vẫn có thể tràn stack với dữ liệu cố tình lồng quá sâu. Cách an toàn là áp đặt giới hạn ngay khi phân tích,
dùng trình phân tích hỗ trợ tùy chọn độ sâu (một số thư viện cung cấp tham số maxDepth).
XSS qua JSON xảy ra thế nào?
Bản thân JSON không phải HTML và không thực thi. XSS xảy ra tại điểm "một giá trị JSON được nối vào HTML".
Ba đường phổ biến:
| Đường | Ví dụ | Phòng thủ |
|---|---|---|
| Nối trong template phía máy chủ | res.send('<div>' + data.name + '</div>') |
Escape HTML khi xuất ra |
innerHTML phía frontend |
el.innerHTML = data.html |
Dùng textContent hoặc làm sạch |
| Nhúng script nội tuyến | <script>var d = ${JSON.stringify(data)}</script> |
Escape <, >, & |
Đường thứ ba là tinh vi nhất. JSON.stringify không escape <, nên dữ liệu chứa </script><script>alert(1)</script>
có thể đóng thẻ script sớm:
const data = { name: '</script><script>alert(1)</script>' };
// Output: <script>var d = {"name":"</script><script>alert(1)</script>"}</script>
Cách sửa là chuyển các ký tự nguy hiểm thành escape Unicode:
function safeJsonForHtml(data) {
return JSON.stringify(data)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026')
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029');
}
Có nên giới hạn độ sâu lồng nhau không?
Có. Đây là một trong những biện pháp phòng thủ có tỉ lệ lợi ích/chi phí tốt nhất.
Hậu quả của việc không giới hạn: hàm đệ quy tràn stack, JSON.parse sập trên một số hiện thực khi độ sâu quá lớn,
và việc tuần tự hóa cũng làm tràn stack theo cách tương tự. Một [[[[...]]]] 1 KB là đủ để hạ một trình phân tích không được bảo vệ.
Ngưỡng hợp lý tùy nghiệp vụ:
| Loại dữ liệu | Giới hạn đề xuất |
|---|---|
| Object nghiệp vụ thông thường | 10–20 cấp |
| Cấu trúc cây (sơ đồ tổ chức, thư mục) | 50–100 cấp |
| API gateway chung | 20 cấp |
JSON hijacking là gì?
Một lỗ hổng lịch sử: trình duyệt thời đầu cho phép <script src> khác nguồn tải và thực thi JSON, và kẻ tấn công đánh cắp dữ liệu bằng cách định nghĩa lại
hàm dựng Array hoặc Object.prototype.__defineSetter__.
Các biện pháp bảo vệ hiện đại đã khá hoàn chỉnh:
- Trình duyệt không còn cho phép chiếm đoạt array literal bằng
__defineSetter__; Content-Type: application/jsonđúng sẽ ngăn việc thực thi script;X-Content-Type-Options: nosniffngăn dò MIME.
Hệ thống cũ vẫn còn rủi ro tồn dư. Lời khuyên thực tế: dùng object ở cấp cao nhất, không dùng mảng trần.
Một lớp bọc như {"data": [...]} giữ cho phản hồi không phải một câu lệnh JavaScript hợp lệ ({ được coi là khối mã),
chặn hijacking ở cấp cú pháp.
Ghi log JSON có những rủi ro gì?
Ba rủi ro:
- Thông tin nhạy cảm bị ghi xuống đĩa: số điện thoại, số CMND, token và mật khẩu dạng rõ rơi vào hệ thống log, nơi thường có quyền hạn lỏng hơn và thời gian lưu lâu hơn;
- Log injection: một giá trị JSON chứa xuống dòng và tiền tố log giả sẽ tách một bản ghi thành nhiều bản ghi và làm ô nhiễm dấu vết kiểm toán;
- Kích thước bùng nổ: ghi cả một phản hồi 10 MB vào log sẽ làm đầy hệ thống log nhanh chóng.
Cách làm:
// Redact and truncate before logging
function safeLog(data) {
const cloned = structuredClone(data);
for (const key of ['password', 'token', 'idCard', 'phone']) {
if (key in cloned) cloned[key] = '***';
}
const text = JSON.stringify(cloned);
return text.length > 2000 ? text.slice(0, 2000) + '...(truncated)' : text;
}
Hệ thống log có cấu trúc (như pino) tự escape xuống dòng, nhưng che dữ liệu và cắt bớt vẫn là việc của bạn.
3. Bảng tra nhanh
| Triệu chứng | Kiểm tra trước | Cách sửa |
|---|---|---|
| Phân tích chậm | Lượng dữ liệu, có liên quan đến sao chép sâu không | Chia lô/dạng luồng, bỏ JSON.parse(JSON.stringify()) |
| Trang đóng băng | Tác vụ dài trên luồng chính, số nút được kết xuất | Worker + cuộn ảo |
| Tab sập | Mức sử dụng bộ nhớ | Đừng tải tất cả; chuyển sang dạng luồng |
| Sao chép sâu làm mất dữ liệu | Date, NaN, Map |
Dùng structuredClone |
| Object đột nhiên có thêm thuộc tính | Ô nhiễm prototype | Lọc __proto__, dùng Map |
| Một script tự bật lên | Chuỗi được nối vào HTML | Escape <, dùng textContent |
| Tràn stack | Độ sâu lồng nhau | Giới hạn 10–20 cấp |
| Log đầy lên | Ghi cả phản hồi | Cắt bớt + che dữ liệu |