Bỏ qua tới nội dung
Kiên

Một lượt tìm kiếm khách sạn ghi 1,2GB vào Redis

· 4 phút đọc

1,2GB → ~200MB

Audit đường search của engine khách sạn: vì sao worker cứ bị PM2 restart ở mốc 3GB, vì sao event loop đứng 6–20 giây mỗi job, và ba thay đổi kéo Redis từ 1,2GB xuống còn 150–250MB mỗi lượt tìm.

  • Node.js
  • Redis
  • hiệu năng
  • event loop
Nội dung
  1. Bối cảnh
  2. Ba chỗ chết người
  3. Ba thay đổi
  4. Phần CPU: 100 triệu phép parse ngày tháng
  5. Kiểm chứng
  6. Điều rút ra

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.gzipSyncpromisify(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.