Bối cảnh
Một lượt tìm khách sạn bắn song song sang khoảng 20 nhà cung cấp. Mỗi nhà cung cấp trả về cỡ 2.000 khách sạn, mỗi khách sạn cỡ 250 offer. Nhân lên là 10 triệu offer cho một lượt tìm.
Triệu chứng thì đã quen mặt: worker bị PM2 restart ở mốc max_memory_restart 3G,
và mỗi lần có job lớn thì API cũng chậm theo.
Ba chỗ chết người
1. Bộ nhớ đỉnh tỉ lệ thẳng với hotels × offers. Một job giữ khoảng 500.000
offer dưới dạng object JavaScript, cỡ 1–2GB. Tệ hơn: bước persist chạy
Promise.all trên 4 chunk × 500 khách sạn cùng lúc, không có trần — nhân
đỉnh bộ nhớ lên khoảng 4 lần. Đó chính là lý do chạm mốc 3G.
2. Nén đồng bộ, trọn trong một process.nextTick.
// Bản cũ, rút gọn: 2.000 vòng lặp, không nhả event loop lần nào
for (const hotel of hotels) {
const json = JSON.stringify(hotel) // ~300–800KB mỗi khách sạn
pipeline.hset(key, hotel.id, gzipSync(json)) // ~2–5ms mỗi khách sạn
}JSON.stringify sinh ra khoảng 600MB–1,6GB chuỗi rác mỗi job, còn gzipSync
× 2.000 khách sạn chặn event loop 6–10 giây. Toàn bộ nằm gọn trong một
process.nextTick, nên trong suốt quãng đó process không trả lời được bất cứ
request nào.
3. Đường đọc quét toàn bộ hash, mỗi lần FE poll. Danh sách "giá rẻ nhất"
được lưu thành một field cho mỗi khách sạn, nên muốn lọc theo nhà cung cấp là
phải quét cả hash 40.000 field — khoảng 800.000 phép so khớp trong Redis, mà
Redis thì đơn luồng. Xong Node lại unzipSync + JSON.parse 40.000 blob, thêm
1–3 giây block mỗi lần poll. FE poll liên tục cho tới khi search xong.
Ba thay đổi
Chia lô và nhả event loop. Vòng lặp 2.000 khách sạn thành từng lô 100, giữa
các lô await setImmediate(). Không nhanh hơn về tổng thời gian, nhưng process
không còn "chết lâm sàng" giữa chừng.
Đẩy nén sang threadpool. zlib.gzipSync → promisify(zlib.gzip), chạy trên
libuv threadpool, giới hạn đồng thời bằng p-limit(4). Việc nén rời khỏi event
loop, và cái trần 4 giữ cho bộ nhớ đỉnh không bùng lên như Promise.all không
trần trước đó.
Đổi cấu trúc key thay vì tối ưu vòng quét. Thay vì một field mỗi khách sạn, ghi một field cho mỗi nhà cung cấp — gzip cả mảng 2.000 bản ghi rút gọn, ra khoảng 100–200KB. Cú quét toàn hash biến mất vì không còn gì để quét: muốn nhà cung cấp nào thì đọc đúng field đó.
Redis từ ~1,2GB xuống còn ~150–250MB mỗi lượt tìm.
Phần CPU: 100 triệu phép parse ngày tháng
Cùng đợt audit lòi ra thêm một chỗ. Hàm gộp chính sách huỷ phòng được gọi một
lần cho mỗi offer, và mỗi lần làm khoảng 200 phép parse moment — thành ra cỡ
100 triệu moment op cho một job của một nhà cung cấp, chiếm 70–80% CPU của
worker.
Cách sửa không có gì cao siêu: so sánh trên số thay vì trên đối tượng
moment. Parse mỗi chuỗi đúng một lần, cache lại thành mili-giây, rồi mọi phép
so sánh boundary/segment/gap chạy trên number. moment chỉ còn là fallback cho
input lạ.
Đo được: hàm gộp nhanh hơn 18 lần, hàm enrich nhanh hơn 30 lần, và tiết kiệm khoảng 2–10 giây CPU cho một request search-by-id lớn.
Kiểm chứng
Refactor kiểu này rất dễ "nhanh hơn nhưng sai". Nên trước khi đổi, mình dựng 1.300+ test case parity — cùng input, so đầu ra bản cũ với bản mới. Sau đó chạy E2E trên 762 log production thật lấy từ một nhà bán buôn: 37/38 pass (97,4%).
Một lưu ý về các con số ở trên: ×18 và ×30 là đo được bằng benchmark. Còn "100 triệu op", "600MB–1,6GB chuỗi rác" là ước tính bậc độ lớn suy từ đọc code, không phải số đo. Mình ghi rõ như vậy ngay trong tài liệu audit, vì một con số ước tính bị người sau đọc thành số đo là cách nhanh nhất để mất niềm tin vào cả tài liệu.
Điều rút ra
Cái làm sập server thường không phải thuật toán, mà là chỗ không chịu nhả điều khiển. Không hàm nào ở trên là sai. Chúng chỉ chạy đồng bộ, ở quy mô mà lúc viết chưa ai hình dung tới. Node cho bạn một luồng — mọi thứ ngốn luồng đó quá lâu đều là sự cố đang chờ ngày nổ.
Đổi cấu trúc dữ liệu ăn đứt tối ưu vòng lặp. Mình đã có thể ngồi vi chỉnh cái lệnh quét Lua cho nhanh hơn. Nhưng đổi từ "một field mỗi khách sạn" sang "một field mỗi nhà cung cấp" thì xoá luôn cả cú quét. Câu hỏi đáng hỏi trước tiên không phải "làm sao cho nhanh hơn", mà là "sao lại phải làm việc này?".
max_memory_restart là băng dán, không phải cách sửa. Cái mốc 3G che giấu
vấn đề suốt nhiều tháng: process chết rồi tự dậy, biểu đồ trông vẫn ổn. Cho tới
khi ngồi tính đỉnh bộ nhớ theo hotels × offers × concurrency thì mới rõ con số
đó chưa bao giờ là đủ.
Refactor hiệu năng mà không có test parity thì chỉ là đoán. Phần lâu nhất của việc này không phải viết code, mà là dựng 1.300 test case chứng minh bản mới trả ra đúng cái cũ đã trả. Nhanh hơn mà sai thì tệ hơn chậm.