..

Câu Chuyện Về Phòng Số 1525

Phần 1: Mở Đầu

Có hai loại bug trên đời: loại mà bạn tìm ra ngay, và loại mà bạn nhìn thẳng vào mặt nó mà vẫn không thấy.

Bug này thuộc loại thứ ba — loại mà hệ thống nhìn thẳng vào dữ liệu mà vẫn không thấy.

Phòng 1525 tồn tại. Task của nó nằm trong danh sách, status NEW, rõ ràng như ban ngày. Nhưng gõ “1525” vào ô tìm kiếm — hệ thống trả về 0 kết quả. Gõ “2223” — ra ngay. Gõ “Lầu 28” — ra ngay. Gõ lại “1525” — im lặng.

Cái đáng sợ nhất không phải là hệ thống trả sai. Cái đáng sợ nhất là nó trả sai một cách chọn lọc. Phòng này tìm được, phòng kia thì không. Cùng API, cùng logic, cùng filter — chỉ khác số phòng. Kiểu bug này khiến bạn ngồi nhìn code 30 phút mà không biết nghi ngờ chỗ nào, vì mọi thứ trông đều… đúng.

Cho đến khi bạn nhận ra: dữ liệu nằm ở một nơi, nhưng hệ thống đi tìm ở nơi khác.


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

Hệ thống lưu thông tin phòng như thế nào?

Trong service quản lý task này, thông tin phòng (room) của một task được lưu ở hai nơi khác nhau:

Nơi 1: Ngay trên bảng chính task

Mỗi bản ghi task có các cột:

  • areaName — tên phòng/khu vực, ví dụ “1525” hoặc “Lầu 32”
  • areaId — mã UUID của phòng
  • roomNumber — số phòng

Đây là cách “cũ”, đơn giản: một task — một phòng.

Nơi 2: Bảng con task_areas

Một bảng riêng, liên kết với task qua taskId. Mỗi dòng là một phòng/khu vực. Cách này hỗ trợ nhiều phòng cho một task — ví dụ task “Lau tầng 3” ảnh hưởng đến cả phòng 301, 302, 303.

Dữ liệu được tạo ra sao?

Khi tạo task, model có một đoạn logic fallback như sau:

dataInput.areaId = dataInput.areaId || dataInput.roomId
dataInput.areaName = dataInput.areaName || dataInput.roomNumber

Dịch nôm na: “Nếu client gửi roomNumber mà không gửi areaName, thì lấy roomNumber làm areaName.”

Nghe hợp lý. Nhưng vấn đề là: khi client gửi dữ liệu bằng areas array (cách mới, hỗ trợ nhiều phòng), thì roomNumberareaName đều không được gửi. Kết quả:

  • task.areaName = NULL
  • task.roomNumber = NULL
  • Bảng task_areas có bản ghi với areaName = "Lầu 32" — nhưng không ai hỏi nó cả

Tìm kiếm hoạt động ra sao?

Khi user gõ “1525” vào ô search, API nhận tham số s=1525 và tạo một mệnh đề OR:

preCondition.$or = [
  {code: s},                              // mã task
  {tmCode: s},                            // số task
  {name: {$like: `%${s}%`}},              // tên task
  {guestName: {$like: `%${s}%`}},         // tên khách
  {areaName: {$like: `%${s}%`}},          // tên phòng — chỉ trên bảng task
  {roomNumber: {$like: `%${s}%`}},        // số phòng — chỉ trên bảng task
  {assigneedName: {$like: `%${s}%`}},     // người xử lý
  {note: {$like: `%${s}%`}},              // ghi chú
  // ...
]

Nhìn qua thì đầy đủ. Nhưng tất cả đều tìm trên bảng task. Không ai hỏi bảng task_areas.

Nên nếu task.areaName là NULL (vì dữ liệu chỉ nằm ở task_areas), thì LIKE '%1525%' so với NULL = không bao giờ match.

Tại sao một số phòng lại tìm được?

Cách client gửi dữ liệu task.areaName task_areas Tìm được?
Gửi roomNumber: "1525" trực tiếp "1525" Không có Tìm được
Gửi areas: [{areaName: "Lầu 32"}] NULL "Lầu 32" Không
Gửi cả hai "Lầu 32" "Lầu 32" Tìm được

Phòng 2223 tìm được vì task của nó được tạo với roomNumber trực tiếp — dữ liệu nằm trên bảng task. Phòng 1525 không tìm được vì task của nó được tạo với areas array — dữ liệu chỉ nằm ở task_areas.

Điều hài hước hơn

Endpoint đếm số lượng (/states — hiển thị số trên tab badge “NEW: 5”) thì lại subquery để tìm trong task_areas. Nên tab badge hiển thị đúng: “5 task NEW”. Nhưng khi user bấm vào tab để xem danh sách, endpoint lấy danh sách (/getBatch) lại không có subquery đó.

Kết quả: badge nói “5”, nhưng chỉ hiển thị 3. User nghĩ app bị lỗi. Và đúng là app bị lỗi thật.


Phần 3: Giải Pháp

Fix 1: Thêm subquery vào tất cả endpoint tìm kiếm

Đây là fix chính. Thêm một correlated subquery (truy vấn con liên kết) vào mệnh đề OR:

if (scopes && scopes.indexOf('areas') !== -1) {
  const escapedS = SqlString.escape(`%${s}%`);
  preCondition.$or.push(sequelize.literal(`
    (SELECT taskAreas.id
     FROM task_areas AS taskAreas
     WHERE taskAreas.status = true
       AND taskAreas.isDeleted = false
       AND taskAreas.taskId = Task.id
       AND taskAreas.areaName LIKE ${escapedS})
  `));
}

Giải thích từng dòng:

  • scopes.indexOf('areas') !== -1 — Chỉ thêm subquery khi client yêu cầu scope areas. App mobile luôn gửi scopes=service,staff,room,countItem,areas, nên điều kiện này luôn đúng trong thực tế. Nhưng nếu có client khác không cần, ta không làm chậm query của họ.

  • SqlString.escape(...) — Chống SQL injection. Biến user input thành chuỗi an toàn trước khi nhúng vào SQL.

  • sequelize.literal(...) — Nói với ORM: “Đây là SQL thô, đừng có parse nó thành tên cột.” Cần thiết vì ta đang query một bảng khác (task_areas) không nằm trong model chính.

  • Subquery: Với mỗi dòng task ở outer query, MySQL chạy subquery này để kiểm tra: “Có bản ghi nào trong task_areas chưa bị xoá, đang active, thuộc task này, và có areaName khớp với từ khoá không?” Nếu có, trả về id (truthy), mệnh đề OR pass.

Áp dụng cho 3 endpoint thiếu:

  • Staff getBatch (danh sách task — endpoint chính từ bug report)
  • Staff getMyTask (task của tôi)
  • Admin getMyTask (task của tôi — phía admin)

Fix 2: Sửa LIKE wildcard bị thiếu

Endpoint states đã có subquery, nhưng dùng SqlString.escape(\${s}`)thay vìSqlString.escape(`%${s}%`). Thiếu dấu %nghĩa làLIKE ‘1525’` — exact match, không phải partial match. Như bạn gõ tìm kiếm trên Google mà phải gõ đúng từng chữ cái, không được thiếu.

- const escapedS = SqlString.escape(`${s}`);
+ const escapedS = SqlString.escape(`%${s}%`);

Phiên bản admin đã dùng %${s}% từ đầu. Chỉ phiên bản staff bị sai.

Fix 3: Đồng bộ dữ liệu hai chiều khi tạo/cập nhật task

Đây là fix “phòng ngừa tương lai”. Thay vì chỉ sửa search, ta đảm bảo dữ liệu luôn nhất quán ở cả hai nơi:

Chiều A — areas array -> bảng task:

if (dataInput.areas && dataInput.areas.length > 0 && !taskInstance.areaName) {
  taskInstance.areaName = dataInput.areas[0].areaName;
  taskInstance.areaId = taskInstance.areaId || dataInput.areas[0].areaId;
  taskInstance.areaType = taskInstance.areaType || dataInput.areas[0].areaType || 'room';
  await taskInstance.save({...options, tracking: false});
}

Nếu client gửi areasareaName trên bảng task vẫn NULL, lấy tên của area đầu tiên gán vào. tracking: false để không ghi audit — đây là sync nội bộ, không phải thao tác của user.

Chiều B — bảng task -> task_areas:

if (!dataInput.areas && (dataInput.roomId || dataInput.areaId)) {
  dataInput.areas = [{
    areaId: taskInstance.areaId || dataInput.roomId,
    areaName: taskInstance.areaName || dataInput.roomNumber,
    areaType: taskInstance.areaType || 'room'
  }];
}

Nếu client gửi roomNumber mà không gửi areas, tạo một areas array “ảo” từ dữ liệu trên bảng task. Array này sẽ được xử lý bởi hàm saveAreasList ở phía dưới, tự động tạo bản ghi trong task_areas.

Logic này được áp dụng cho cả createupdate.

Ưu điểm

  • Fix search ngay lập tức cho dữ liệu cũ (subquery tìm trong task_areas)
  • Ngăn dữ liệu mới bị lệch (đồng bộ hai chiều)
  • Không cần migration database hay backfill dữ liệu cũ
  • Performance tốt: EXPLAIN cho thấy subquery chỉ scan 1 dòng nhờ index idx_isDeleted_status_taskId

Nhược điểm / Trade-off

  • Correlated subquery chạy mỗi dòng của outer query — với dataset lớn có thể chậm hơn JOIN. Nhưng thực tế, outer query đã filter theo hotelId + state + createdAt, nên số dòng rất nhỏ.
  • Dữ liệu cũ vẫn lệch (task.areaName = NULL) — chỉ là subquery “bù” khi search. Muốn clean hoàn toàn phải chạy script backfill.
  • Logic đồng bộ nằm ở route handler, không phải model — nếu có endpoint khác tạo task mà không đi qua đây, vẫn có thể bị lệch.

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

Môn Cơ Sở Dữ Liệu (DBMS)

Correlated subquery là kiến thức kinh điển trong môn DBMS. Hồi đi học, thầy dạy:

“Correlated subquery là subquery tham chiếu đến bảng của outer query. Nó chạy một lần cho mỗi dòng của outer query.”

Hồi đó tôi nghĩ: “Vậy thì chậm lắm, ai mà dùng?” Hoá ra thực tế MySQL optimizer khá thông minh. Khi có index phù hợp (trong trường hợp này là idx_isDeleted_status_taskId), mỗi lần chạy subquery chỉ là một index seek — O(log n), không phải full table scan.

Bài học: correlated subquery không xấu. Nó xấu khi không có index. Giống như dao không nguy hiểm, người cầm dao mới nguy hiểm.

Môn Kiến Trúc Phần Mềm

Vấn đề gốc của bug này là data duplication without synchronization — dữ liệu được lưu ở hai nơi nhưng không có cơ chế đồng bộ. Đây là lỗi vi phạm nguyên tắc Single Source of Truth (SSOT).

Trong lý thuyết, bạn nên có một nơi duy nhất lưu thông tin phòng. Nhưng thực tế, bảng task cần areaName để search nhanh (không cần JOIN), và task_areas cần tồn tại để hỗ trợ nhiều phòng. Nên ta chấp nhận duplication, nhưng phải đồng bộ.

Giải pháp của chúng ta là bidirectional sync — tương tự như cách Git merge hoạt động: thay đổi ở nhánh nào thì đồng bộ sang nhánh kia.


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

  • Dữ liệu lưu hai nơi mà không đồng bộ = bug chờ nổ. Nếu bạn có cột areaName trên bảng chính VÀ bảng con, bạn PHẢI đảm bảo chúng luôn khớp nhau. Không có ngoại lệ.

  • Kiểm tra tất cả endpoint, không chỉ endpoint bị report. Bug report chỉ nói về getBatch, nhưng getMyTask cũng bị. Và states thì có subquery nhưng thiếu wildcard %. Mỗi endpoint là một “lối vào” khác nhau của cùng một logic — thiếu một cái là mất nhất quán.

  • LIKE không có % cũng như tìm kiếm mà phải gõ đúng 100% — vô nghĩa. LIKE '1525' tương đương = '1525'. Phải là LIKE '%1525%' mới là “tìm kiếm”.

  • Subquery không chậm nếu có index. Đừng sợ correlated subquery. Chạy EXPLAIN trước khi kết luận. Trong case này, MySQL scan đúng 1 dòng nhờ compound index.

  • Fix search chỉ là “băng dán” — fix data mới là “chữa bệnh”. Subquery giúp tìm dữ liệu cũ bị lệch. Nhưng đồng bộ hai chiều khi create/update mới ngăn vấn đề tái diễn. Luôn fix cả hai.