..

Notification Chậm Và Cuộc Săn Lùng 4 Kẻ Phạm Tội

Phần 1: Chân Dung Của Một Con Bug Không Có Thông Báo Lỗi

Có một loại bug đặc biệt đáng ghét: không crash, không có error log đỏ chói, không có exception nào nổ ra. Hệ thống vẫn chạy. Dữ liệu vẫn đúng. Chỉ có một vấn đề duy nhất — chậm.

Chậm kiểu gì? Kiểu mà người dùng mở notification panel lên, nhìn màn hình loading, rồi tự hỏi “ủa mình có internet không ta”. Kiểu mà bạn không thể reproduce bằng cách gọi API một lần — phải gọi liên tục, với tải thật, mới thấy rõ.

Loại bug này không để lại dấu vết. Nó chỉ để lại… thời gian.

Và để tìm ra nó, bạn phải làm đúng một thứ: đo.

Đó là lúc tôi bắt đầu cắm timing instrumentation vào khắp nơi, nhìn log chảy ra, và dần dần — từng kẻ phạm tội một — lộ diện.


Phần 2: Vấn Đề Và Bối Cảnh — Bốn Kẻ Phạm Tội

Hệ thống notification hoạt động như sau: khi người dùng mở danh sách thông báo, service sẽ:

  1. Lấy danh sách thông báo từ database
  2. Với mỗi thông báo, “inject” thêm thông tin người gửi và người nhận — bằng cách gọi sang một service khác để lấy tên, email, avatar của từng người

Nghe đơn giản. Nhưng đằng sau sự đơn giản đó là bốn vấn đề chồng chất nhau.


Kẻ thứ nhất: Sort trên cột không có index

Khi query danh sách thông báo, code cũ cho phép client tự chọn cột để sort. Nếu client không chỉ định, hệ thống dùng một cột mặc định — không phải createdAt.

Index trong database — nói đơn giản — giống như mục lục của một cuốn sách. Nếu bạn tìm “chương 5” và có mục lục, bạn lật thẳng đến trang đó. Không có mục lục, bạn phải đọc từ trang 1.

Column createdAt có index. Column được dùng mặc định trước đó thì không chắc. Kết quả: database phải quét toàn bộ bảng để sắp xếp — chậm tuyến tính theo số lượng bản ghi.

// Truoc: sort mac dinh khong ro rang, co the dung cot khong co index
let sort = getSort(requestParams.sort, NotificationModel)

// Sau: ep default sort ve createdAt — co index, nhanh
let sort = getSort(requestParams.sort, NotificationModel, '-createdAt')

Trước: Query mất vài trăm ms, tăng dần theo dữ liệu. Sau: Query nhanh ổn định vì dùng B-tree index.


Kẻ thứ hai: Sequential await — Chạy Tiếp Sức Kiểu Sai

Sau khi lấy được danh sách thông báo, code inject thêm thông tin người gửi và người nhận:

// Code CU: await tung cai mot
enrichNotifications = async (items, scope, options) => {
  let tasks = []
  if (scope.includes("receiver")) {
    tasks.push(await injectUserInfo(items, { idField: 'toUserId', target: 'receiver' }, options))
                // ^--- await o day! tasks nhan vao gia tri da resolved
  }
  if (scope.includes("sender")) {
    tasks.push(await injectUserInfo(items, { idField: 'fromUserId', target: 'sender' }, options))
                // ^--- roi moi bat dau cai nay
  }
  await Promise.all(tasks) // Luc nay tasks la array gia tri, Promise.all khong lam gi ca
}

Cái await đặt nhầm chỗ biến Promise.all phía dưới thành vô dụng. Fetch người nhận xong rồi mới bắt đầu fetch người gửi. Như chạy tiếp sức mà vận động viên thứ hai đứng khởi động cho kỹ, đợi người đầu tiên về đích, bắt tay, ngồi xuống uống nước, rồi mới chạy.

// Code MOI: push Promise (chua resolved) vao list, roi Promise.all chay song song
enrichNotifications = async (items, scope, options) => {
  let tasks = []
  if (scope.includes("receiver")) {
    tasks.push(injectUserInfo(items, { idField: 'toUserId', target: 'receiver' }, options))
                // ^--- khong await, push Promise vao
  }
  if (scope.includes("sender")) {
    tasks.push(injectUserInfo(items, { idField: 'fromUserId', target: 'sender' }, options))
  }
  await Promise.all(tasks) // Bay gio hai cai chay song song that su
}

Hai network call sang service user — trước đây chạy nối tiếp, giờ chạy song song. Thời gian giảm gần một nửa cho bước này.


Kẻ thứ ba: O(n²) Deduplication — Thuật Toán Của Sự Lười Biếng

Trước khi gọi sang service user để lấy thông tin, code cần lọc unique user ID từ danh sách thông báo. Code cũ làm thế này:

// Code CU: tim unique bang indexOf — O(n^2)
let uniqueIds = allIds.filter(function(item, pos) {
  return item && allIds.indexOf(item) === pos
  //            ^--- moi phan tu lai scan toan bo mang tu dau
})

indexOf scan từ đầu mảng mỗi lần gọi. Với mảng 100 phần tử, đây là 100×100 = 10.000 phép so sánh. Với 1.000 phần tử: 1.000.000 phép so sánh.

Sau:

// Set dedup trong O(n)
let uniqueIds = [...new Set(allIds.filter(id => id))]

Set dùng hash table bên trong — thêm và kiểm tra trùng đều O(1). Toàn bộ dedup xong trong một lần duyệt.


Kẻ thứ tư (và nguy hiểm nhất): Zero Cache Trên User Info

Đây là kẻ chủ mưu.

Mỗi lần notification service cần thông tin người dùng, nó gọi API sang service quản lý user. Service đó nhận request, query thẳng database, trả kết quả. Không cache gì cả.

Thông tin người dùng — tên, email, avatar — gần như không bao giờ thay đổi trong vài chục phút. Nhưng mỗi lần có ai đó mở notification panel, hệ thống lại đi hỏi database từ đầu. Với một tổ chức có 50 nhân viên thường xuyên nhận thông báo, cùng giờ cao điểm buổi sáng, đây là hàng trăm DB query cho cùng một tập dữ liệu không thay đổi.

Giải pháp: thêm cache per-user với TTL 30 phút trực tiếp trong service quản lý user.

// Trong service quan ly nguoi dung — endpoint moi co cache layer
const CACHE_TTL = 30 * 60  // 30 phut

// 1. Kiem tra cache cho tung user ID
const cacheResults = await Promise.all(
  requestedIds.map(id => {
    const key = `user:${orgId}:${id}`
    return cache.get(key)
      .then(val => ({ id, val }))
      .catch(() => ({ id, val: null }))
  })
)

// 2. Phan loai: hit vs miss
const hitMap = {}
const missIds = []
for (const { id, val } of cacheResults) {
  if (val) hitMap[id] = val   // Cache hit: khong can DB
  else     missIds.push(id)   // Cache miss: can query DB
}

// 3. Chi query DB voi nhung ID chua co trong cache
let freshItems = []
if (missIds.length > 0) {
  const dbData = await UserModel.getBatch({ where: { id: missIds } })
  freshItems = dbData.items

  // 4. Luu vao cache de lan sau khoi query
  await Promise.all(
    freshItems.map(item =>
      cache.set(`user:${orgId}:${item.id}`, item, CACHE_TTL)
    )
  )
}

// 5. Gop ket qua: tu cache + tu DB, theo dung thu tu request
const result = requestedIds
  .map(id => hitMap[id] || freshMap[id])
  .filter(Boolean)

Sau đó, notification service được cập nhật để gọi endpoint mới có cache thay vì endpoint cũ gọi thẳng DB:

// Truoc: goi API lay thong tin user — query DB moi lan
const url = `${userServiceHost}/api/users?${query}`

// Sau: goi API co cache layer
const url = `${userServiceHost}/api/users/cached?${query}`

Trước: Mỗi request → DB query cho tất cả user ID. Sau: Lần đầu → DB query chỉ cho ID chưa cache. Từ lần hai trở đi trong 30 phút → trả về từ cache ngay lập tức.


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

Bốn thay đổi, mỗi cái nhỏ, nhưng cộng hưởng với nhau:

Vấn đề Trước Sau
Sort không dùng index Full table scan B-tree index lookup
Fetch người gửi + nhận Sequential (cộng dồn) Promise.all song song
Deduplication user ID O(n²) loop lồng nhau O(n) với Set
User info mỗi request đều query DB 0% cache hit Cache 30 phút per-user

Uu diem (Pros):

  • Cải thiện latency thật sự ở nhiều tầng: DB query, network call, in-memory computation
  • Cache được implement ở đúng chỗ — phía service sở hữu dữ liệu, không phải phía consumer
  • Fallback an toàn: nếu cache miss hoặc Redis lỗi, vẫn query DB như bình thường

Nhuoc diem (Cons):

  • Cache TTL 30 phút có nghĩa là nếu user đổi tên hoặc avatar, hệ thống có thể hiển thị thông tin cũ trong tối đa 30 phút
  • Thêm Redis vào code path quan trọng — nếu Redis chậm hoặc down, cần có graceful fallback (đã xử lý bằng .catch(() => ({val: null})))
  • Endpoint cũ và endpoint có cache tồn tại song song — cần deprecate cái cũ khi đã stable

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

Tôi từng ngồi học môn Cấu Trúc Dữ Liệu và Giải Thuật với thái độ của một người đang bị giữ làm con tin. Hash table, B-tree, độ phức tạp O(n) vs O(n²) — tôi thuộc để thi, rồi quên sạch sau kỳ thi hai tuần.

Rồi một ngày tôi nhìn vào cái indexOf trong vòng lặp và nhớ lại.

Về thuật toán O(n²) vs O(n): Môn Giải Thuật dạy đo số bước thực thi khi input tăng. filter + indexOf là nested loop ẩn — mỗi phần tử kích hoạt một lần duyệt toàn bộ mảng. Set dùng hash table, insert và lookup đều O(1) amortized. Thầy giáo hồi đó vẽ đồ thị lên bảng, tôi tưởng chỉ là lý thuyết. Hóa ra là warning sign cho production code.

Về Index trong Database: Môn Cơ Sở Dữ Liệu dạy về B-tree index: tổ chức dữ liệu theo cấu trúc cây cân bằng để tìm kiếm và sắp xếp trong O(log n) thay vì O(n). Khi bạn ORDER BY một cột có index, database duyệt cây thay vì scan bảng. Hồi đó bài tập chỉ có vài trăm dòng — index không có sự khác biệt rõ ràng. Lên production với hàng triệu bản ghi, sự khác biệt là vài giây vs vài chục millisecond.

Về Caching và Locality of Reference: Môn Kiến Trúc Máy TínhHệ Điều Hành đều đề cập đến nguyên lý: dữ liệu được truy cập gần đây có xác suất cao sẽ được truy cập lại. CPU cache, page cache, DNS cache — tất cả đều dựa trên nguyên lý này. Cache user info 30 phút là áp dụng đúng hệ quả đó: ai đó vừa nhận thông báo từ người A thì gần như chắc chắn sẽ nhận thêm thông báo từ người A trong cùng session — temporal locality rất cao.


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

  • Bug “không có lỗi” chỉ bị tìm ra bằng cách đo. Trước khi tối ưu, phải có số liệu. Vài dòng timing instrumentation cho bạn biết chính xác thời gian từng bước — không cần phán đoán.

  • await đặt sai chỗ có thể phá hỏng toàn bộ chiến lược parallel. Promise.all([await a(), await b()]) không song song — nó sequential rồi wrap vào array. Promise.all([a(), b()]) mới là song song thật sự. Một ký tự await sai vị trí, hiệu năng giảm đôi.

  • Cache nên được đặt gần nguồn dữ liệu, không phải gần người dùng. Service sở hữu data biết rõ TTL hợp lý là bao lâu. Consumer không nên tự cache data của service khác — vì khi data thay đổi, consumer không biết để invalidate.

  • O(n²) trong production data là bom hẹn giờ. Code chạy đúng với 20 phần tử, chậm với 200, và “disappear” với 2000. Luôn hỏi: thuật toán này scale thế nào khi input tăng gấp 10?

  • Và bài học quan trọng nhất: hệ thống chậm không phải lúc nào cũng cần server to hơn. Bốn thay đổi trên không thêm một CPU hay RAM nào. Chỉ cần nhìn đúng chỗ.


Kết thúc của câu chuyện: notification panel load nhanh hơn. Không ai chú ý. Đó chính xác là kết quả tốt nhất của một performance fix — người dùng không nhận ra vì không có gì để nhận ra nữa.