Thiết kế hệ thống chat 1-1 và nhóm: lưu tin nhắn và trạng thái đã đọc
1. Tổng quan
Chat trông đơn giản: gửi một dòng chữ rồi hiển thị nó. Nhưng khi có hàng triệu người dùng, bạn phải trả lời những câu hỏi khó hơn. Làm sao giữ đúng thứ tự tin nhắn? Làm sao để gửi lại không tạo tin trùng? Làm sao biết ai đã đọc tới đâu mà không ghi hàng tỷ bản ghi “đã xem”?
Bài này đi qua một thiết kế hoàn chỉnh cho chat 1-1 và chat nhóm. Thiết kế gồm yêu cầu, mô hình dữ liệu, API, luồng xử lý và các điểm khó. Đọc xong, bạn sẽ dựng được phần lõi lưu tin nhắn và trạng thái đã đọc bằng Spring Boot 3, PostgreSQL và Kafka.
2. Yêu cầu
2.1. Chức năng
- Chat 1-1 và chat nhóm (tối đa 1.000 thành viên).
- Gửi tin văn bản. Tin hiển thị theo đúng thứ tự trong một cuộc hội thoại.
- Xem lịch sử, cuộn ngược theo trang.
- Danh sách hội thoại (inbox) kèm số tin chưa đọc.
- Trạng thái đã đọc: chat 1-1 hiện “Đã xem”, chat nhóm hiện “Đã xem bởi N người”.
2.2. Quy mô và ràng buộc
- 10 triệu DAU. Trung bình mỗi người gửi 40 tin/ngày, tức 400 triệu tin/ngày, khoảng 4.600 tin/giây. Giờ cao điểm có thể lên khoảng 30.000 tin/giây.
- Không được mất tin đã được xác nhận gửi thành công.
- Thứ tự chỉ cần đúng trong một hội thoại. Không cần thứ tự toàn cục.
- Client có thể gửi lại do mạng chập chờn. Server phải idempotent.
- Sự kiện “đã đọc” nhiều hơn sự kiện gửi tin nhiều lần, vì mỗi lần mở app đều phát sinh.
Ràng buộc thứ ba là chìa khoá của cả thiết kế. Chỉ cần thứ tự cục bộ, nên mọi thứ có thể phân mảnh theo conversation_id.
3. Mô hình dữ liệu
Ý tưởng trung tâm: mỗi hội thoại có một số thứ tự tăng dần (seq). Tin nhắn thứ N trong hội thoại có seq = N. Trạng thái đã đọc của một thành viên chỉ là một con số, last_read_seq. Nó là một watermark, không phải danh sách từng tin đã đọc.
CREATE TABLE conversation (
id BIGINT PRIMARY KEY,
type VARCHAR(10) NOT NULL, -- DIRECT | GROUP
last_seq BIGINT NOT NULL DEFAULT 0,
last_message_at TIMESTAMPTZ
);
CREATE TABLE conversation_member (
conversation_id BIGINT NOT NULL REFERENCES conversation(id),
user_id BIGINT NOT NULL,
last_read_seq BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (conversation_id, user_id)
);
CREATE INDEX idx_member_user ON conversation_member(user_id);
CREATE TABLE message (
conversation_id BIGINT NOT NULL,
seq BIGINT NOT NULL,
sender_id BIGINT NOT NULL,
client_msg_id UUID NOT NULL,
body TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (conversation_id, seq),
UNIQUE (conversation_id, client_msg_id)
);
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
msg_key VARCHAR(64) NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Các điểm đáng chú ý:
- Khoá chính
(conversation_id, seq)giúp đọc lịch sử chỉ cần quét một dải liên tục của index. Khoá này cũng là shard key tự nhiên khi bạn phân mảnh. UNIQUE (conversation_id, client_msg_id)là lớp chặn tin trùng ở tầng database.- Số tin chưa đọc là
last_seq - last_read_seq. Phép tính này không cầnCOUNT(*). - “Đã xem bởi” trong nhóm được tính bằng số thành viên có
last_read_seq >= seq. Bạn không cần bảngmessage_receiptvới hàng tỷ dòng. - Với chat 1-1, thêm bảng
direct_pair(user_low, user_high, conversation_id)có ràng buộc unique. Hai người mở chat cùng lúc sẽ không tạo ra hai hội thoại.
Lưu ý: Watermark giả định người dùng đọc tuần tự, tức đọc tin N thì coi như đã đọc mọi tin trước đó. Giả định này đúng với gần như mọi ứng dụng chat. Nếu bạn cần biết chính xác từng tin cụ thể đã được mở (ví dụ tin nhắn tự huỷ), hãy lưu thêm bản ghi riêng cho loại tin đó. Đừng áp dụng cho mọi tin.
4. API
| Method | Endpoint | Mô tả |
|---|---|---|
POST |
/conversations/{id}/messages |
Gửi tin. Body gồm clientMsgId và body |
GET |
/conversations/{id}/messages?beforeSeq=&limit= |
Lịch sử, phân trang theo seq |
POST |
/conversations/{id}/read |
Body { "seq": 120 } |
GET |
/inbox?limit= |
Danh sách hội thoại và số tin chưa đọc |
WS |
/ws |
Server đẩy sự kiện message.new và read.updated |
Gửi tin đi qua REST thay vì WebSocket. Cách này cho bạn retry, timeout và idempotency theo kiểu HTTP quen thuộc. WebSocket chỉ dùng để đẩy xuống client.
5. Luồng gửi tin
Thư viện cần dùng:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
Toàn bộ thao tác gửi tin nằm trong một transaction:
public record SentMessage(long conversationId, long seq, UUID clientMsgId) {}
@Service
public class MessageService {
private final JdbcTemplate jdbc;
public MessageService(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Transactional
public SentMessage send(long conversationId, long senderId, UUID clientMsgId, String body) {
// 1. Khoá dòng conversation: mọi lần gửi vào cùng hội thoại được xếp hàng
Long lastSeq = jdbc.queryForObject(
"SELECT last_seq FROM conversation WHERE id = ? FOR UPDATE",
Long.class, conversationId);
// 2. Idempotency: client gửi lại thì trả về kết quả cũ
List<Long> existing = jdbc.queryForList(
"SELECT seq FROM message WHERE conversation_id = ? AND client_msg_id = ?",
Long.class, conversationId, clientMsgId);
if (!existing.isEmpty()) {
return new SentMessage(conversationId, existing.getFirst(), clientMsgId);
}
// 3. Cấp seq mới, ghi tin và cập nhật hội thoại
long seq = lastSeq + 1;
jdbc.update("""
INSERT INTO message(conversation_id, seq, sender_id, client_msg_id, body)
VALUES (?, ?, ?, ?, ?)""", conversationId, seq, senderId, clientMsgId, body);
jdbc.update("UPDATE conversation SET last_seq = ?, last_message_at = now() WHERE id = ?",
seq, conversationId);
// 4. Người gửi hiển nhiên đã đọc tin của chính mình
jdbc.update("""
UPDATE conversation_member SET last_read_seq = GREATEST(last_read_seq, ?)
WHERE conversation_id = ? AND user_id = ?""", seq, conversationId, senderId);
// 5. Outbox: sự kiện được ghi cùng transaction, không bao giờ lệch với dữ liệu
jdbc.update("INSERT INTO outbox(msg_key, payload) VALUES (?, ?::jsonb)",
String.valueOf(conversationId),
"""
{"type":"message.new","conversationId":%d,"seq":%d,"senderId":%d}"""
.formatted(conversationId, seq, senderId));
return new SentMessage(conversationId, seq, clientMsgId);
}
}
Giải thích từng bước:
- Bước 1 dùng
SELECT ... FOR UPDATE. Hai request gửi vào cùng hội thoại sẽ chạy tuần tự, nên seq không bao giờ trùng hay nhảy cóc. Các hội thoại khác nhau không chặn nhau. - Bước 2 kiểm tra trùng sau khi đã giữ lock. Vì vậy hai lần retry đồng thời của cùng
clientMsgIdcũng không lọt qua được. Nếu kiểm tra trước khi lock, cả hai request có thể cùng thấy “chưa có”. Request thứ hai khi đó sẽ dínhDuplicateKeyException. - Bước 3 tăng seq liên tục, không có khoảng trống. Nhờ đó client có thể tự phát hiện tin bị thiếu (mục 7.3).
- Bước 5 áp dụng transactional outbox. Nếu bạn gửi thẳng lên Kafka trong transaction, có thể xảy ra trường hợp Kafka đã nhận nhưng DB rollback, hoặc ngược lại.
Gọi send hai lần với cùng clientMsgId:
send(1, 10, a1b2..., "Chào nhóm") -> SentMessage[conversationId=1, seq=42, ...]
send(1, 10, a1b2..., "Chào nhóm") -> SentMessage[conversationId=1, seq=42, ...] // không tạo tin mới
Một relay riêng đọc bảng outbox theo id, đẩy lên topic chat.events với key là conversationId, rồi xoá các dòng đã gửi. Bạn cũng có thể dùng Debezium để làm việc này. Key theo hội thoại đảm bảo mọi sự kiện của cùng hội thoại vào cùng partition và giữ thứ tự.
Phía consumer đẩy sự kiện xuống các gateway WebSocket:
@Component
public class FanoutListener {
private final MemberRepository members; // trả về danh sách userId của hội thoại
private final GatewayRegistry gateways; // userId -> các session đang online
public FanoutListener(MemberRepository members, GatewayRegistry gateways) {
this.members = members;
this.gateways = gateways;
}
@KafkaListener(topics = "chat.events", groupId = "fanout")
public void onEvent(ConsumerRecord<String, String> record) {
long conversationId = Long.parseLong(record.key());
for (long userId : members.findUserIds(conversationId)) {
gateways.pushIfOnline(userId, record.value());
}
}
}
Người dùng offline không nhận được push, và điều đó không sao. Khi kết nối lại, client gọi /inbox và tự tải các tin có seq lớn hơn seq cuối cùng nó đang giữ.
6. Trạng thái đã đọc
Đánh dấu đã đọc chỉ cần một câu UPDATE đơn điệu:
@Service
public class ReadStateService {
private final JdbcTemplate jdbc;
public ReadStateService(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
public boolean markRead(long conversationId, long userId, long seq) {
int updated = jdbc.update("""
UPDATE conversation_member m
SET last_read_seq = LEAST(?, c.last_seq)
FROM conversation c
WHERE c.id = m.conversation_id
AND m.conversation_id = ? AND m.user_id = ?
AND m.last_read_seq < LEAST(?, c.last_seq)""",
seq, conversationId, userId, seq);
return updated == 1; // true nếu watermark thực sự tiến lên
}
public long unreadCount(long conversationId, long userId) {
return jdbc.queryForObject("""
SELECT c.last_seq - m.last_read_seq
FROM conversation c JOIN conversation_member m ON m.conversation_id = c.id
WHERE c.id = ? AND m.user_id = ?""", Long.class, conversationId, userId);
}
}
Câu UPDATE giải quyết ba vấn đề cùng lúc:
- Tranh chấp: điện thoại gửi
seq=50, laptop gửiseq=45đến sau. Điều kiệnlast_read_seq < ...khiến request cũ không kéo watermark lùi lại. Bạn không cần lock hay version. - Dữ liệu rác:
LEAST(?, c.last_seq)chặn client gửi seq vượt quá tin thực tế. - Giảm tải: khi
updated == 0, watermark không đổi nên không phát sự kiệnread.updated. Fanout vô ích được cắt bớt.
Chỉ khi markRead trả về true, service mới ghi một sự kiện read.updated vào outbox, tương tự mục 5.
7. Các điểm khó và cách giải
7.1. Hội thoại “nóng” và tranh chấp lock
FOR UPDATE tuần tự hoá việc gửi tin trong một hội thoại. Với chat 1-1 hoặc nhóm nhỏ, chi phí này không đáng kể. Nhóm 1.000 người đang sôi nổi có thể lên vài chục tin/giây vào cùng một dòng. Mỗi transaction chỉ giữ lock vài mili-giây, nên mức này vẫn chịu được.
Nếu một hội thoại vượt quá vài trăm tin/giây (kiểu kênh broadcast), hãy tách bộ cấp seq ra Redis bằng INCR chat:seq:{conversationId}. Đổi lại, bạn phải chấp nhận seq có khoảng trống khi ghi DB thất bại. Client cần phân biệt “khoảng trống thật” với “tin chưa tới”.
Lưu ý: Đừng dùng
created_athay ID toàn cục (Snowflake) để sắp xếp tin trong hội thoại. Đồng hồ giữa các server lệch nhau vài mili-giây cũng đủ đảo thứ tự hai tin gửi sát nhau.
7.2. Nhất quán giữa DB, Kafka và client
Chuỗi đảm bảo của thiết kế này:
- DB là nguồn sự thật. Tin chỉ được xác nhận cho client sau khi commit.
- Outbox đảm bảo at-least-once lên Kafka. Consumer có thể nhận trùng.
- Client khử trùng theo
(conversationId, seq). Tin nào đã có thì bỏ qua.
7.3. Phát hiện tin bị thiếu
Seq liên tục nên client tự phát hiện được lỗ hổng. Giả sử client đang có seq=40 và nhận push seq=43. Nó biết thiếu tin 41 và 42, nên gọi GET /messages?afterSeq=40. Bạn không cần cơ chế ACK phức tạp từ server cho từng tin.
7.4. Tải đột biến
- Fanout nhóm lớn: một tin vào nhóm 1.000 người tạo ra 1.000 lần push. Với nhóm trên ngưỡng (ví dụ 200 thành viên), chỉ đẩy tới người đang online, tra trong Redis set
online:{conversationId}. Payload chỉ chứa thông báo nhẹ{conversationId, seq}, client tự kéo nội dung về khi cần. - Bão “đã đọc”: sau sự cố mạng, hàng triệu client kết nối lại và cùng gửi read receipt. Client nên debounce khoảng 1–2 giây và chỉ gửi seq lớn nhất. Server có thể gộp tiếp trong Redis, chỉ giữ giá trị max theo key
{conversationId}:{userId}, rồi flush xuống DB theo lô. - Kafka: partition theo
conversationIdđể giữ thứ tự. Khi tải tăng, bạn chỉ cần tăng số consumer, tối đa bằng số partition. - Lưu trữ: 400 triệu tin/ngày, mỗi tin khoảng 300 byte, tương đương khoảng 120 GB/ngày. Hãy partition bảng
messagetheoconversation_id(hash) và chuyển tin cũ sang kho rẻ hơn như Cassandra hoặc object storage. Truy vấn lịch sử luôn kèmconversation_id, nên mô hình nào cũng hợp.
8. Kiểm thử hành vi chính
Hai hành vi quan trọng nhất cần test: gửi lại không tạo tin trùng, và watermark không bao giờ lùi. Bạn nên test với PostgreSQL thật qua Testcontainers vì logic phụ thuộc vào FOR UPDATE và GREATEST/LEAST.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
Đặt DDL ở mục 3 vào src/main/resources/schema.sql và bật spring.sql.init.mode=always.
@SpringBootTest(properties = {
"spring.sql.init.mode=always",
"spring.kafka.listener.auto-startup=false"
})
@Testcontainers
class ChatServiceTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired MessageService messages;
@Autowired ReadStateService reads;
@Autowired JdbcTemplate jdbc;
@BeforeEach
void setUp() {
jdbc.execute("TRUNCATE message, outbox, conversation_member, conversation");
jdbc.update("INSERT INTO conversation(id, type) VALUES (1, 'GROUP')");
jdbc.update("INSERT INTO conversation_member(conversation_id, user_id) VALUES (1, 10), (1, 20)");
}
@Test
void resendingSameClientMessageIdDoesNotCreateDuplicate() {
UUID clientMsgId = UUID.randomUUID();
SentMessage first = messages.send(1, 10, clientMsgId, "Chào nhóm");
SentMessage retry = messages.send(1, 10, clientMsgId, "Chào nhóm");
assertThat(retry.seq()).isEqualTo(first.seq()).isEqualTo(1L);
assertThat(jdbc.queryForObject("SELECT count(*) FROM message", Long.class)).isEqualTo(1L);
}
@Test
void readWatermarkNeverMovesBackward() {
for (int i = 0; i < 5; i++) {
messages.send(1, 10, UUID.randomUUID(), "tin " + i);
}
assertThat(reads.markRead(1, 20, 4)).isTrue();
assertThat(reads.markRead(1, 20, 2)).isFalse(); // request cũ đến muộn
assertThat(reads.markRead(1, 20, 99)).isTrue(); // bị chặn về last_seq = 5
assertThat(reads.unreadCount(1, 20)).isZero();
assertThat(reads.unreadCount(1, 10)).isZero(); // người gửi tự động đã đọc
}
}
Test thứ hai kiểm tra ba tình huống. Watermark tiến lên 4. Request seq=2 đến muộn bị bỏ qua và trả về false. Seq vượt quá thực tế bị giới hạn về 5, nên số tin chưa đọc bằng 0. Thuộc tính spring.kafka.listener.auto-startup=false giữ cho test không cố kết nối tới Kafka.
9. Kết luận
Toàn bộ thiết kế xoay quanh một quyết định: mỗi hội thoại có một seq liên tục. Từ seq, bạn có thứ tự tin nhắn, phân trang lịch sử, phát hiện tin thiếu và số tin chưa đọc chỉ bằng một phép trừ. Trạng thái đã đọc là một watermark đơn điệu, cập nhật bằng một câu UPDATE có điều kiện. Cách này loại bỏ được cả tranh chấp lẫn bảng receipt khổng lồ.
Phần còn lại là kỹ thuật quen thuộc. clientMsgId cộng ràng buộc unique cho idempotency. Transactional outbox với Kafka key theo hội thoại cho nhất quán và thứ tự sự kiện. Debounce, chỉ đẩy tới người đang online và pull khi kết nối lại giúp hệ thống sống sót qua tải đột biến. Hãy bắt đầu với PostgreSQL và FOR UPDATE. Chỉ tách bộ cấp seq ra Redis hoặc chuyển kho lưu trữ khi số liệu thực tế cho thấy bạn thật sự cần.