Sync 2000 Phòng Từ 15 Phút Xuống 4 Giây
Phần 1: Mở Đầu — Tôi Có Thể Nấu Một Nồi Mì Trong Lúc Đợi Sync
Thứ hai. 9 giờ sáng. Tôi bấm nút “Sync Room” cho một khách sạn 2000 phòng.
Rồi tôi đi pha cà phê.
Rồi tôi uống cà phê.
Rồi tôi đi vệ sinh.
Rồi tôi quay lại.
Task vẫn đang chạy.
Tôi mở Slack, rep vài tin nhắn. Đọc một bài blog trên Medium. Like vài cái tweet. Nhìn lại dashboard.
Task. Vẫn. Đang. Chạy.
Lúc đó tôi bắt đầu hoang mang thật sự. Tôi mở log lên:
[syncData] SYNC ROOM INFO started...
Dòng log đó xuất hiện cách đây… 12 phút. Và vẫn chưa có dòng “completed” nào cả.
Tôi ngồi đó, nhìn cái progress bar tưởng tượng trong đầu, và tự hỏi: “Mình đã làm gì sai với cuộc đời để phải ngồi đây xem 2000 cái phòng được đồng bộ từng-cái-một như người ta đếm cừu để ngủ?”
15 phút sau, task hoàn thành.
15 phút. Để copy 2000 dòng dữ liệu từ service này sang service kia. Tôi có thể nấu xong một nồi mì tôm, ăn hết, rửa bát, và vẫn kịp quay lại xem kết quả.
Và điều đáng sợ nhất? Tôi phải fix cái code ấy trong 2hrs.
Phần 2: Vấn Đề Và Bối Cảnh
Hệ thống làm gì?
Trong hệ thống của chúng tôi, thông tin phòng (tên phòng, loại phòng, tầng, khu…) được quản lý ở service A. Nhưng trạng thái phòng (phòng trống, phòng bẩn, phòng đang dọn…) lại được quản lý ở service B.
Mỗi khi có thay đổi, service B cần đồng bộ danh sách phòng từ service A về. Cái background task này chạy trong 1 file duy nhất: sync-data.js. Nó có 2 step:
- SYNC ROOM INFO — Đồng bộ danh sách phòng
- SYNC ROOM TYPE — Đồng bộ danh sách loại phòng (kèm đếm số phòng mỗi loại)
Nghe đơn giản. Hai step. Một file. Nhưng bên trong là một thảm hoạ performance mà tôi mất đúng 15 phút mới phát hiện ra — theo nghĩa đen.
Step 1 — SYNC ROOM INFO: Tàn phá database 4000 lần
Quy trình cũ:
1. Gọi API lấy phòng, 200 phòng/trang, TỪNG TRANG MỘT
2. Xoá phòng không còn tồn tại
3. Với MỖI phòng trong 2000 phòng:
- Query 1: findOne kiểm tra đã có chưa?
- Query 2: Có -> update. Chưa -> create.
Vấn đề 1: Fetch tuần tự
while (pageIndex <= totalPages) {
pageIndex += 1;
const result = await getRooms({ limit: 200, pageIndex });
rooms.push(...result.data.items);
}
10 trang. Mỗi trang đợi 400ms. Tổng: 4 giây. Giống đi siêu thị kiểu ông bà: mua 1 quả trứng -> chạy về nhà bỏ tủ lạnh -> chạy lại siêu thị -> mua 1 cọng hành -> chạy về… 10 lần.
Vấn đề 2: N+1 Query — Ông kẹ của mọi developer
for (let item of rooms) {
let checkData = await ProRoomStatus.findOne({
attributes: ['id'],
where: { id: item.id }
})
if (!checkData) {
await ProRoomStatus.create(params)
} else {
await ProRoomStatus.update(params, { where: { id: item.id } })
}
}
2000 phòng x 2 query = 4000 queries. Mỗi query 3-5ms. Tổng: 20-40 giây chỉ riêng bước này.
Nhưng khoan. Còn step 2.
Step 2 — SYNC ROOM TYPE: Đếm cừu để ngủ
Code cũ cần biết mỗi loại phòng có bao nhiêu phòng active, bao nhiêu out-of-service, out-of-order, pseudo… Và nó làm thế này:
for (let item of items) {
let [
totalActiveRoomsOfRoomType,
totalRoomsOfRoomType,
totalOutOfService,
totalOutOfOrder,
totalPseudo
] = await ProRoomType.countRoomsForRoomType({
roomTypeId: item.id,
propertyId,
organizationId
})
// ... rồi lại findOne + create/update TỪNG CÁI
}
50 loại phòng = 50 câu COUNT(*). Mỗi câu phải scan bảng room_status. Cộng thêm 50 câu findOne + 50 câu create/update = 150 queries nữa.
Tổng thiệt hại
| Bước | Cách cũ | Queries | Thời gian |
|---|---|---|---|
| Fetch phòng | Tuần tự 10 trang | 10 API calls | ~4s |
| Upsert 2000 phòng | findOne + create/update từng cái | ~4000 | ~40-90s |
| Count room type | countRoomsForRoomType từng loại | ~50 | ~2-5s |
| Upsert 50 room type | findOne + create/update từng cái | ~150 | ~3-5s |
| Tổng | ~4200 queries | Lên tới 15 phút |
15 phút vì còn cộng thêm: network latency tích luỹ, connection pool chờ đợi, database bận phục vụ 4000 query nhỏ lẻ nên chậm cho cả hệ thống, và đôi khi timeout rồi retry.
Phần 3: Giải Pháp
Tất cả nằm trong 1 file, 1 commit. Không thay đổi schema, không thay đổi API. Chỉ thay đổi cách gọi.
Fix 1: Parallel Fetch — Đi siêu thị 1 lần
// Trang đầu: biết được tổng
const firstPage = await getRooms({ limit: PAGE_SIZE, pageIndex: 0 });
const total = firstPage.data.total;
const totalPages = Math.ceil(total / PAGE_SIZE);
// Tất cả trang còn lại: gọi CÙNG LÚC
if (totalPages > 1) {
const pagePromises = [];
for (let i = 1; i < totalPages; i++) {
pagePromises.push(getRooms({ limit: PAGE_SIZE, pageIndex: i }));
}
const results = await Promise.all(pagePromises);
for (const r of results) rooms.push(...r.data.items);
}
Trước: while loop, đợi từng trang -> 10 x 400ms = 4000ms
Sau: Promise.all, gọi 9 trang cùng lúc -> 400ms + 400ms = ~800ms
Tưởng tượng bạn là shipper. Thay vì chạy 10 chuyến giao hàng, bạn gọi thêm 9 đứa bạn và đi cùng lúc. Tổng thời gian = 1 chuyến.
Fix 2: Bulk Upsert — Từ 4000 queries xuống ~22 queries
Đây là fix chính. Ba bước thay vì 2000 vòng lặp:
Bước 1 — Hỏi database đúng 1 lần: “ID nào đã tồn tại?”
const existingRows = await ProRoomStatus.findAll({
attributes: ['id'],
where: { id: { [Op.in]: ids } },
raw: true
});
const existingIds = new Set(existingRows.map(r => r.id));
1 query. 2000 ID. Bỏ vào Set để tra cứu O(1).
Bước 2 — Phân loại trong memory (0 query)
const toCreate = [];
const toUpdate = [];
for (const item of activeRooms) {
let params = ProRoomStatus.formatRoomForUpdate({ item, createdBy: userId })
if (!existingIds.has(item.id)) {
params = ProRoomStatus.defaultRoomStatus(params)
params.createdBy = userId
params.id = item.id
toCreate.push(params)
} else {
toUpdate.push(params)
}
}
Set.has() là O(1). Duyệt 2000 phòng vẫn chỉ là O(n). Không tốn 1 query nào.
Bước 3 — Ghi hàng loạt theo chunk 100
// INSERT hàng loạt
for (let i = 0; i < toCreate.length; i += BULK_CHUNK) {
await ProRoomStatus.bulkCreate(
toCreate.slice(i, i + BULK_CHUNK),
{ hooks: false }
);
}
// UPDATE hàng loạt
for (let i = 0; i < toUpdate.length; i += BULK_CHUNK) {
await ProRoomStatus.bulkCreate(
toUpdate.slice(i, i + BULK_CHUNK),
{ updateOnDuplicate: ROOM_UPDATE_FIELDS, hooks: false }
);
}
Mỗi bulkCreate tạo ra 1 câu SQL thay cho 100 câu:
INSERT INTO pro_room_status (id, roomNumber, roomTypeCode, ...)
VALUES ('uuid1', '101', 'DLX', ...), ('uuid2', '102', 'SUP', ...), ...
ON DUPLICATE KEY UPDATE
roomNumber = VALUES(roomNumber),
roomTypeCode = VALUES(roomTypeCode), ...
2000 phòng / 100 = 20 câu thay vì 4000 câu.
hooks: false — bỏ qua Sequelize lifecycle hooks (beforeCreate, afterCreate…) vì sync data không cần trigger side effects. Tiết kiệm overhead đáng kể.
ROOM_UPDATE_FIELDS được khai báo sẵn ở đầu file — chỉ update đúng những field cần thiết, không ghi đè toàn bộ row.
Trước: ~4000 queries, 40-90 giây Sau: ~22 queries, 2-3 giây
Fix 3: GROUP BY thay vì COUNT từng cái
Đây là fix cho step SYNC ROOM TYPE. Thay vì đếm phòng cho từng loại riêng biệt:
// CŨ: 50 loại phòng = 50 câu COUNT
for (let item of items) {
let [active, total, oos, ooo, pseudo] =
await ProRoomType.countRoomsForRoomType({ roomTypeId: item.id })
}
Gom thành 1 câu GROUP BY:
const countResults = await ProRoomStatus.findAll({
attributes: [
'roomTypeId',
[Sequelize.fn('SUM', Sequelize.literal(
"CASE WHEN status = 1 THEN 1 ELSE 0 END"
)), 'totalActive'],
[Sequelize.fn('COUNT', Sequelize.literal('*')), 'totalRooms'],
[Sequelize.fn('SUM', Sequelize.literal(
"CASE WHEN status = 1 AND roomStatus = 'OUT_OF_SERVICE' THEN 1 ELSE 0 END"
)), 'totalOOS'],
[Sequelize.fn('SUM', Sequelize.literal(
"CASE WHEN status = 1 AND roomStatus = 'OUT_OF_ORDER' THEN 1 ELSE 0 END"
)), 'totalOOO'],
[Sequelize.fn('SUM', Sequelize.literal(
"CASE WHEN status = 1 AND isPseudo = 1 THEN 1 ELSE 0 END"
)), 'totalPseudo'],
],
where: { organizationId, propertyId, isDeleted: 0 },
group: ['roomTypeId'],
raw: true
});
const countMap = new Map(countResults.map(r => [r.roomTypeId, r]));
Database scan bảng 1 lần duy nhất, trả về kết quả gom nhóm cho tất cả room type. Kết quả bỏ vào Map để tra cứu O(1):
const counts = countMap.get(item.id) || {};
item.numberOfRoom = parseInt(counts.totalActive) || 0
item.numberOfOutOfService = parseInt(counts.totalOOS) || 0
// ...
Trước: 50 câu SELECT COUNT(*), mỗi câu scan cả bảng
Sau: 1 câu SELECT ... GROUP BY, scan 1 lần
Đây chính là bài toán kinh điển: thay vì hỏi “lớp này có bao nhiêu học sinh nam?” 50 lần cho 50 lớp, bạn hỏi 1 lần “cho tôi bảng thống kê số học sinh nam theo từng lớp”. Cô giáo chỉ cần đếm 1 lần.
Fix 4: Room Type cũng Bulk Upsert
Pattern y hệt Fix 2, áp dụng cho room type:
const existingIds = new Set(existingRows.map(r => r.id));
const toCreate = [];
const toUpdate = [];
for (const item of items) {
if (item.isDeleted !== 0) continue;
// ... gán counts từ countMap ...
if (!existingIds.has(item.id)) {
toCreate.push(item)
} else {
toUpdate.push(item)
}
}
// Bulk create + bulk update (updateOnDuplicate)
Thêm một cải tiến nhỏ: thay vì gọi destroy trong vòng for cho từng room type bị xoá, gom tất cả ID cần xoá rồi destroy 1 lần:
const deletedRoomTypeIds = items.filter(i => i.isDeleted !== 0).map(i => i.id);
if (deletedRoomTypeIds.length > 0) {
await ProRoomType.destroy({ where: { id: { [Op.in]: deletedRoomTypeIds } } })
}
Tổng kết sau optimize
| Bước | Queries cũ | Queries mới | Nhanh hơn |
|---|---|---|---|
| Fetch phòng | 10 API tuần tự | 1 + 9 song song | ~5x |
| Upsert 2000 phòng | ~4000 | ~22 | ~180x |
| Count room type | ~50 | 1 | ~50x |
| Upsert 50 room type | ~150 | ~3 | ~50x |
| Tổng | ~4200 | ~36 | ~120x |
Thời gian: 15 phút -> 4-5 giây.
Phần 4: Kiến Thức Đại Học Ứng Dụng
Môn Cấu Trúc Dữ Liệu và Giải Thuật
Set và Map — hai cấu trúc dữ liệu cơ bản nhất — hoá ra lại là vũ khí chính trong bài toán này.
Code cũ kiểm tra từng phòng tồn tại bằng cách hỏi database:
findOne({ where: { id: X } }) -> 1 network round-trip, ~5ms
Làm 2000 lần -> 10,000ms
Code mới lấy hết ID về, bỏ vào Set:
Set.has(X) -> O(1), ~0.001ms
Làm 2000 lần -> 0.002ms
Sự khác biệt giữa “15 phút” và “4 giây” đôi khi chỉ là việc chuyển từ “hỏi database từng cái” sang “hỏi 1 lần rồi tra trong memory”.
Hồi đi học, tôi nghĩ Big-O là lý thuyết xa vời, chỉ dùng để thi. Giờ tôi hiểu: Big-O là sự khác biệt giữa “user bấm nút và thấy kết quả” với “user bấm nút, đi pha trà, nấu mì, ăn mì, rửa bát, và vẫn chưa thấy kết quả”.
Môn Cơ Sở Dữ Liệu
GROUP BY với CASE WHEN — kiến thức SQL năm 2 đại học. Hồi đó tôi chỉ nhớ nó để thi rồi quên. 10 năm sau, nó giúp tôi thay 50 query bằng 1 query.
INSERT ... ON DUPLICATE KEY UPDATE — câu lệnh MySQL mà sách giáo khoa chắc có nhắc nhưng tôi chắc chắn không đọc tới phần đó. Nó cho phép INSERT hàng loạt, và nếu trùng primary key thì tự động UPDATE. Sequelize wrap lại bằng bulkCreate({ updateOnDuplicate }). Một câu thay cho hàng trăm.
Sách giáo khoa nói đúng. Chỉ là tôi mất 10 năm để tin.
Phần 5: Bài Học Rút Ra
**awaittrong vòngforlà dấu hiệu nguy hiểm số 1.** Mỗi khi thấy patternfor ... await Model.findOne/create/update, hãy dừng lại. 90% trường hợp có thể batch được. Code nhìn sạch sẽ, chạy đúng, nhưng chậm không tưởng — N+1 là loại bug nguy hiểm nhất vì nó chỉ lộ ra khi data lớn.**SetvàMaplà bạn thân.** Chuyển 1 mảng ID thành Set rồi dùng.has()— chỉ 1 dòng code mà tiết kiệm hàng ngàn query. Đừng bao giờ check tồn tại bằng cách gọi database trong vòng lặp khi bạn có thể lấy hết về memory trước.**bulkCreate+updateOnDuplicate+hooks: falselà combo thần thánh của Sequelize.** Một câuINSERT ... ON DUPLICATE KEY UPDATEthay cho hàng ngàn câu riêng lẻ.hooks: falsebỏ lifecycle hooks khi không cần. Chỉ cần khai báo sẵn mảngupdateOnDuplicatefields ở đầu file cho gọn.**GROUP BYvớiCASE WHENthay cho N lầnCOUNT.** Bất cứ khi nào bạn đếm cùng một bảng nhiều lần với filter khác nhau, hãy gom thành 1 câu GROUP BY. Database scan bảng 1 lần thay vì N lần.- 15 phút là quá đủ để người ta mất kiên nhẫn. Thực tế, 3 giây là ngưỡng người dùng bắt đầu cảm thấy “chậm”. 15 phút là ngưỡng họ bắt đầu nghĩ “cái này hỏng rồi” và bấm F5. Optimize không chỉ là kỹ thuật — nó là sự tôn trọng thời gian của người khác. Và đôi khi chỉ cần 1 file, 1 commit, 2 giờ.