Ba yêu cầu quyết định thiết kế: tin nhắn không mất, thứ tự trong một hội thoại phải ổn định, và người offline vẫn nhận đủ khi mở app.
Data model — khoá chính đặt đúng là xong nửa bài:
conversations(id, type, created_at) -- type: direct | group
members(conversation_id, user_id, joined_at, last_read_seq)
messages(conversation_id, seq bigint, id, sender_id, body, created_at,
primary key (conversation_id, seq))seq là số tăng dần trong phạm vi một hội thoại, do server cấp. Không dùng timestamp của client làm thứ tự — đồng hồ máy khách lệch nhau. Truy vấn lịch sử trở thành phân trang theo seq, rất rẻ.
Luồng gửi: client gửi kèm clientMsgId (UUID) → server cấp seq, ghi DB, rồi phát qua pub/sub tới các node đang giữ kết nối của thành viên. clientMsgId duy nhất làm cho gửi lại khi mất mạng không tạo tin trùng.
Offline: client lưu lastSeq đã có; khi kết nối lại gọi GET /conversations/{id}/messages?after={lastSeq} để lấp khoảng trống, rồi mới nghe realtime. Đây là cơ chế đơn giản hơn nhiều so với hàng đợi riêng cho từng người.
Đã đọc: không tạo một bản ghi cho mỗi cặp (tin nhắn, người đọc) — nhóm 100 người sẽ tạo 100 dòng cho một tin. Lưu một con trỏ last_read_seq mỗi thành viên; số tin chưa đọc = max_seq - last_read_seq.
Điểm nghẽn:
- Nhóm lớn: fan-out một tin ra 500 kết nối. Fan-out chạy bất đồng bộ, đọc danh sách thành viên từ cache.
- Push notification cho người không online — tách hẳn khỏi đường realtime, đi qua queue.
Đánh đổi: seq theo hội thoại buộc phải cấp tuần tự (một điểm ghi cho mỗi hội thoại), đổi lại được thứ tự tuyệt đối và phân trang đơn giản. Với chat, thứ tự sai là lỗi người dùng nhìn thấy ngay, nên đánh đổi này gần như luôn đáng.