..

Tối Ưu Export Report Từ N+1 Đến Parallel

Phần 1: Hôm Đó Tôi Nhận Được Cái Ticket Ấy…

Hôm đó là một buổi sáng bình thường. Cà phê chưa kịp nguội, Slack đã nổ cái ping.

“Anh ơi, cái chức năng export báo cáo bị timeout rồi. Khách sạn to họ export là treo luôn, không ra file.”

Tôi gõ lại: “Timeout ở đâu?” — vì câu hỏi đó bao giờ cũng hay hơn câu “lỗi gì?”.

Trả lời: “Ở chỗ export PDF ấy anh. Khách sạn nhỏ thì chạy được. Khách sạn lớn, nhiều phòng, là cứng đơ.”

Tôi thở dài. Không phải vì khó — mà vì tôi linh cảm rằng đây là một trong những bug kiểu: code chạy đúng với 50 dòng dữ liệu, chết với 500 dòng. Loại bug này thường có căn nguyên sâu xa hơn là “server yếu”.

Mở code ra. Đọc. Và đúng như dự đoán — có tới ba vấn đề hiệu năng chồng chất lên nhau, mỗi cái đủ sức làm hệ thống ì ạch, cộng lại thì… timeout là chuyện tất nhiên.


Phần 2: Vấn Đề Và Bối Cảnh

Tội ác thứ nhất: N+1 Query — Đi Siêu Thị Kiểu “Nhớ Gì Mua Nấy”

Bạn có bao giờ đi siêu thị mà cứ ra về lại nhớ quên cái gì đó không? Quay lại mua. Rồi lại quên. Rồi lại quay lại. Mỗi lần đi mất 10 phút. Mua tổng cộng 20 thứ = 200 phút chỉ để đi siêu thị.

Đó là N+1 query — vấn đề cổ điển nhất trong lịch sử backend.

Trong code cũ, hàm xử lý dữ liệu báo cáo làm y chang vậy:

// Code CU: Duyet tung dong du lieu
for (let row of data) {
  const item = row.get({ plain: true })

  // Voi moi dong, lai query DB de lay ten ly do!
  for (const reasonId of item.extraData.reasonId) {
    const reason = await DB.ConfigDiscrepancyReason.findOne({  // <-- DB call trong loop!
      where: { id: reasonId }
    })
    // ... xu ly
  }
}

Giả sử có 300 phòng, mỗi phòng có 3 lý do bất thường — hệ thống sẽ gọi DB đúng 900 lần chỉ để lấy tên mấy cái lý do đó. 900 round-trip tới database. Trong khi toàn bộ dữ liệu đó hoàn toàn có thể lấy trong 1 câu query duy nhất.

Trước:

300 phòng x 3 lý do = 900 DB queries (một chuyến đi siêu thị mỗi lần nhớ ra)

Sau:

// Gom tat ca reasonId lai
const allReasonIds = rows.flatMap(r => r.extraDataObj?.reasonId || [])
const uniqueIds = [...new Set(allReasonIds)]

// 1 query duy nhat cho tat ca
const allReasons = await DB.ConfigDiscrepancyReason.findAll({
  where: { id: uniqueIds }
})

// Build map de lookup O(1)
const reasonMap = {}
for (const r of allReasons) reasonMap[r.id] = r

// Gio trong loop chi tra cuu map, khong con DB call
for (const { item, extraDataObj } of rows) {
  const reasonName = extraDataObj.reasonId.map(id => reasonMap[id]?.description)
  // ...
}

1 chuyến siêu thị với danh sách đầy đủ. Về nhà một lần. Xong.


Tội ác thứ hai: Sequential Await — Làm Việc Kiểu “Một Người Làm, Chín Người Ngồi Chờ”

Trong hàm tạo báo cáo kiểm toán cuối ngày, code cũ làm thế này:

// Code CU: Tung buoc cho nhau
const blockRoomData    = await fetchBlockingRooms(...)      // 800ms
const metaData         = await fetchMetadata()              // 200ms
const propertyDetail   = await fetchPropertyInfo(...)       // 300ms
const discrepancyData  = await fetchDiscrepancyRooms(...)   // 600ms
// Tong: ~1900ms

Tưởng tượng bạn cần: pha cà phê, nướng bánh mì, luộc trứng, và đọc báo buổi sáng. Bạn có thể làm tuần tự — pha cà phê xong mới nướng bánh, nướng xong mới luộc trứng. Hoặc bạn có thể bật lò nướng, bỏ trứng vào nồi, pha cà phê, rồi đọc báo trong khi chờ — tất cả cùng lúc.

Bốn cái fetch kia hoàn toàn độc lập nhau. Chúng không cần kết quả của nhau để chạy. Vậy tại sao lại chờ từng cái một?

Sau:

// Code MOI: Chay song song
const [blockRoomData, discrepancyData, metaData, propertyDetail] = await Promise.all([
  fetchBlockingRooms(...),    // \
  fetchDiscrepancyRooms(...), //  > chay dong thoi
  fetchMetadata(),            // /
  fetchPropertyInfo(...)      // /
])
// Tong: ~800ms (chi bang thoi gian cai lau nhat)

Từ ~1900ms xuống ~800ms. Không cần thêm server, không cần cache — chỉ cần sắp xếp lại thứ tự làm việc.


Tội ác thứ ba: Render PDF Cả Nghìn Phòng Một Lần — Như Ăn Cả Nồi Cơm Một Miếng

Puppeteer — công cụ dùng để render HTML thành PDF — rất mạnh nhưng cũng rất… thèm RAM. Khi bạn bắt nó render một trang HTML có 800 dòng bảng, nó sẽ load toàn bộ DOM vào bộ nhớ, vẽ hết, rồi xuất PDF.

Với khách sạn nhỏ (50 phòng): ngon. Với khách sạn lớn (500+ phòng): Puppeteer khởi động, load DOM, rồi… timeout. Hoặc crash.

Trước:

// Render tat ca 500 phong mot lan
const html = await buildTemplate({ items: allRooms })  // DOM khong lo
const pdf  = await htmlToPdf(html)                     // ... timeout

Sau — chiến lược “chia để trị”:

const CHUNK_SIZE = 150  // 150 phong moi chunk

if (totalRooms <= CHUNK_SIZE) {
  // Export nho: render 1 PDF binh thuong
  return await renderSinglePdf(allRooms)
}

// Export lon: chia thanh nhieu chunk
const chunks = splitIntoChunks(allRooms, CHUNK_SIZE)
const pdfBuffers = []

// Render tung batch (toi da 3 PDF song song de tranh het RAM)
for (let i = 0; i < chunks.length; i += 3) {
  const batch = chunks.slice(i, i + 3)
  const buffers = await Promise.all(batch.map(chunk => renderSinglePdf(chunk)))
  pdfBuffers.push(...buffers)
}

// Gop tat ca PDF lai thanh 1 file
const mergedPdf = await mergePdfs(pdfBuffers)

Thay vì nuốt cả nồi cơm một lúc, giờ ăn từng chén nhỏ — rồi gộp lại thành một bữa hoàn chỉnh.


Tội ác thứ tư (bonus): SQL JOIN Nặng Nề Cho Export Lớn

Ngoài ba vấn đề trên, phần export danh sách phòng tổng quan còn có một vấn đề khác: truy vấn dữ liệu dùng một câu SQL JOIN phức tạp — nối phòng với đặt phòng, với hồ sơ khách, với lịch sử chặn phòng — tất cả trong một lần.

Với dữ liệu nhỏ: câu SQL này chạy nhanh. Với dữ liệu lớn: MySQL optimizer phải làm việc nhiều hơn, index không được tận dụng tối đa, và kết quả là query chậm dần theo cấp số nhân.

Giải pháp: tách query, join trong bộ nhớ.

// Buoc 1: Lay phong (nhanh, khong join)
const rooms = await ProRoomStatus.findAll({ where: condition })
const roomIds = rooms.map(r => r.id)

// Buoc 2: Lay du lieu lien quan song song
const [reservations, blockings, metadata] = await Promise.all([
  fetchReservations({ roomId: { in: roomIds } }),
  fetchBlockings({ roomId: { in: roomIds } }),
  fetchMetadata()
])

// Buoc 3: Join trong bo nho (O(1) lookup)
const reservationMap = groupBy(reservations, 'roomId')
const blockingMap    = keyBy(blockings, 'roomId')

const result = rooms.map(room => ({
  ...room,
  reservations: reservationMap[room.id] || [],
  blocking:     blockingMap[room.id] || null
}))

Giống như thay vì nhờ nhà hàng phục vụ từng món theo thực đơn phức tạp, bạn tự lấy nguyên liệu về và tự bày biện — nhanh hơn, kiểm soát được hơn.


Phần 3: Giải Pháp — Tổng Kết

Tóm gọn lại, đây là những gì đã được thay đổi:

Vấn đề Trước Sau
N+1 query Query DB trong vòng lặp Batch fetch + in-memory hashmap
Sequential fetch Await từng cái một Promise.all() song song
PDF timeout Render tất cả cùng lúc Chunked render + merge
SQL JOIN nặng 1 query JOIN phức tạp Nhiều query đơn + join trong memory

Uu diem (Pros):

  • Toc do cai thien ro ret — khong can them server hay tang tai nguyen
  • Code ro rang hon, moi buoc co log rieng de debug
  • Khong thay doi API hay business logic — chi thay doi cach lay du lieu

Nhuoc diem (Cons):

  • Code phức tạp hơn trước, người đọc lần đầu cần hiểu flow chunking
  • Chunked PDF merge yêu cầu thêm dependency (pdf-lib) — thêm một điểm có thể fail
  • Promise.all() có nghĩa là nếu một trong các fetch fail, tất cả fail — cần xử lý lỗi cẩn thận hơn

Phần 4: Kiến Thức Đại Học Ứng Dụng

Hồi đại học, tôi học môn Cơ Sở Dữ Liệu và môn Giải Thuật. Tôi tưởng hai môn đó chỉ để… thi. Rồi đi làm tôi mới hiểu — mấy ông thầy dạy những thứ đó là có lý do.

Từ môn Cơ Sở Dữ Liệu:

Bài học về N+1 query thực ra nằm ngay trong chương “Tối ưu hóa truy vấn”. Thầy dạy: hãy giảm số lần round-trip đến database. Một câu WHERE id IN (...) bao giờ cũng tốt hơn N câu WHERE id = ? trong vòng lặp. Hồi đó tôi gật đầu rồi… quên. Đến khi gặp bug thật mới nhớ ra.

Từ môn Giải Thuật và Cấu Trúc Dữ Liệu:

HashMap (hay dictionary, object trong JavaScript) có độ phức tạp tra cứu O(1) — hằng số, không phụ thuộc kích thước dữ liệu. Bài học này nằm ở chương 3 cuốn giáo trình mà tôi từng học thuộc để thi rồi quên ngay sau đó. Nhưng ở đây, nó cứu cả cái báo cáo: thay vì tìm kiếm tuyến tính O(n) cho mỗi lý do, dùng map để lookup O(1).

Từ môn Hệ Điều Hành:

Khái niệm concurrency — chạy nhiều tác vụ đồng thời — là nền tảng của Promise.all(). Thay vì một luồng chờ I/O xong mới làm tiếp, Node.js event loop cho phép nhiều I/O operations chạy “đồng thời” (thực ra là xen kẽ nhau, nhưng kết quả thực tế là nhanh hơn nhiều). Hồi học OS tôi hay ngủ gật — nhưng cái khái niệm đó bây giờ tôi dùng mỗi ngày.


Phần 5: Bài Học Rút Ra

  • N+1 query không tự nó hiện ra rõ ràng — nó ẩn trong vòng lặp, chạy đúng với dữ liệu nhỏ, và chỉ lộ mặt khi production scale lên. Hãy luôn hỏi: “Có DB call nào nằm trong loop không?”

  • Sequential await không sai — nhưng có thể sai về hiệu năng. Trước khi viết await A; await B; await C, hãy dừng lại hỏi: A và B có cần nhau không? Nếu không, dùng Promise.all.

  • Puppeteer (và các tool render nặng) cần được “chia phần ăn”. Không có tool nào chạy tốt với dữ liệu vô hạn. Chunking là kỹ thuật quan trọng, và merge sau đó là bài toán hoàn toàn giải được.

  • Join trong SQL tốt khi data vừa phải. Join trong memory tốt hơn khi bạn kiểm soát được kích thước từng query riêng lẻ. Đây không phải quy tắc tuyệt đối — nhưng đáng cân nhắc khi gặp timeout.

  • Và cuối cùng: code chạy được != code chạy đúng với mọi scale. Cái ticket timeout hôm đó nhắc tôi rằng — hiệu năng không phải tính năng optional, nó là một phần của tính đúng đắn.


Giờ thì cái báo cáo chạy ngon. Khách sạn lớn hay nhỏ đều xuất được PDF. Cà phê buổi sáng hôm đó nguội rồi — nhưng ticket đã được đóng.