..

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:

  1. SYNC ROOM INFO — Đồng bộ danh sách phòng
  2. 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

SetMap — 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

  • **await trong vòng for là dấu hiệu nguy hiểm số 1.** Mỗi khi thấy pattern for ... 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.
  • **SetMap là 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: false là combo thần thánh của Sequelize.** Một câu INSERT ... ON DUPLICATE KEY UPDATE thay cho hàng ngàn câu riêng lẻ. hooks: false bỏ lifecycle hooks khi không cần. Chỉ cần khai báo sẵn mảng updateOnDuplicate fields ở đầu file cho gọn.
  • **GROUP BY với CASE WHEN thay cho N lần COUNT.** 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ờ.