0

Kiến trúc đồng bộ tin nhắn Local-first SQLite và hàng đợi BullMQ cho ứng dụng quản lý đa tài khoản realtime

Khi xây dựng một hệ thống nhắn tin hợp nhất (Unified Inbox) cho phép vận hành đồng thời hàng chục tài khoản chat trên cùng một màn hình — chạy xuyên suốt trên Mobile (React Native / Expo), Desktop (Electron) và Web — bài toán khó nhất không nằm ở giao diện UI mà ở hai điểm nghẽn cốt lõi: độ trễ đồng bộ dữ liệu cục bộ (Local-first sync) và kiểm soát tốc độ gửi tin hàng loạt (Rate-limiting & Pacing) để không làm đứt gãy kết nối realtime.

Dưới đây là cách đội ngũ mình thiết kế kiến trúc giải quyết trọn vẹn hai bài toán này.

1. Đồng bộ hội thoại Local-first đa nền tảng với SQLite

Nếu mọi thao tác chuyển đổi tài khoản, cuộn lịch sử tin nhắn hay tìm kiếm hội thoại đều phụ thuộc vào REST API từ server, độ trễ mạng sẽ phá hỏng trải nghiệm người dùng ngay khi số lượng hội thoại vượt mốc vài chục nghìn dòng. Giải pháp tối ưu là đưa toàn bộ trạng thái hội thoại về Local-first SQLite ngay trên thiết bị:

  • Mobile (iOS / Android): Sử dụng expo-sqlite truy xuất trực tiếp file database vật lý với WAL mode (PRAGMA journal_mode = WAL;) giúp đọc và ghi bất đồng bộ không khóa luồng UI.
  • Desktop (macOS / Windows): Chạy SQLite trực tiếp trong tiến trình Electron để tận dụng tốc độ I/O ổ cứng SSD, tải hàng nghìn cuộc hội thoại tức thì ngay cả khi rớt mạng tạm thời.
  • Web Browser: Sử dụng sql.js (WebAssembly SQLite in-memory) kết hợp đồng bộ delta qua WebSocket, giúp giữ nguyên cấu trúc truy vấn SQL mà không cần viết lại tầng lưu trữ bằng IndexedDB thuần.

Khi có sự kiện tin nhắn mới đẩy về qua WebSocket, client chỉ thực hiện UPSERT theo lô (batch transaction) vào bảng messages và conversations, sau đó phát tín hiệu reactive cập nhật đúng vùng danh sách đang hiển thị.

2. Tách lớp SqlDriver dùng chung Schema & Migration với node:sqlite

Một rủi ro phổ biến khi duy trì 3 nền tảng client là lệch schema hoặc lệch logic truy vấn giữa Mobile, Desktop và Web. Để loại bỏ hoàn toàn tình trạng này, chúng mình trừu tượng hóa một giao diện SqlDriver tối giản chỉ gồm các phương thức cốt lõi (exec, run, all, transaction):

export interface SqlDriver {
  exec(sql: string): Promise<void>;
  run(sql: string, params?: unknown[]): Promise<{ changes: number }>;
  all<T>(sql: string, params?: unknown[]): Promise<T[]>;
  transaction<T>(fn: () => Promise<T>): Promise<T>;
}

Toàn bộ hệ thống migration, câu lệnh UPSERT hội thoại, gắn thẻ phân loại và lọc tìm kiếm đều nằm trong một package core duy nhất, chỉ phụ thuộc vào interface SqlDriver. Nhờ đó:

  • Trên Mobile, Desktop và Web, mỗi nền tảng chỉ cần viết một adapter mỏng (~50 dòng code).
  • Trong môi trường Automated Test (CI), chúng mình gắn trực tiếp adapter node:sqlite (có sẵn từ Node.js 22+) để kiểm thử hàng trăm kịch bản đồng bộ delta, xung đột thứ tự tin nhắn (out-of-order events) và migration mà không cần giả lập trình duyệt hay thiết bị di động.

3. Điều tiết gửi tin hàng loạt (Broadcast Pacing & Cooldown) bằng BullMQ + Redis

Với tính năng chăm sóc khách hàng và gửi thông báo hàng loạt trên nhiều tài khoản, nếu bắn request đồng loạt bằng Promise.all, hệ thống rất dễ chạm ngưỡng giới hạn tần suất (rate-limit) hoặc gây nghẽn kết nối socket của từng phiên đăng nhập.

Thay vì dùng cronjob quét database truyền thống, chúng mình tổ chức hàng đợi phân tán bằng BullMQ + Redis theo mô hình Per-Account Paced Queue:

  1. Tách luồng theo từng tài khoản: Mỗi tài khoản hoạt động có trạng thái pacing độc lập, đảm bảo tài khoản A đang gửi chiến dịch lớn không làm chậm tin nhắn phản hồi trực tiếp của nhân viên trên tài khoản B.
  2. Jitter Delay & Cooldown tự động: Mỗi job gửi tin tiếp theo được tính toán độ trễ giãn cách ngẫu nhiên (delay = baseInterval + randomJitter). Sau mỗi cụm tin nhắn định trước, worker tự động chuyển sang chu kỳ nghỉ ngắn (cooldown window) trước khi tiếp tục.
  3. Idempotency & Tự phục hồi: Mỗi tác vụ gắn jobId duy nhất theo cặp (campaignId, recipientId). Nếu worker restart giữa chừng, BullMQ đảm bảo không bao giờ gửi trùng tin nhắn cho cùng một khách hàng.

Tổng kết

Sự kết hợp giữa Local-first SQLite ở phía client (giúp phản hồi giao diện dưới 16ms) và BullMQ + Redis ở phía server (điều phối tải ổn định, chịu lỗi cao) giúp hệ thống vận hành mượt mà ngay cả khi đội ngũ sale/CSKH xử lý song song hàng chục tài khoản cùng lúc.

Kiến trúc trên hiện đang được đội ngũ mình áp dụng thực tế cho ZaChat — Nền tảng quản lý đa tài khoản Zalo & CRM bán hàng (hỗ trợ bản Web, iOS, Android và ứng dụng Desktop macOS/Windows). Rất mong nhận được góp ý và trao đổi thêm từ anh em cùng làm hệ thống realtime messaging!


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí