WebSocket, thứ tự tin, đã xem, tin khi offlineWebSockets, ordering, read receipts, offline messages
Client giữ WebSocket tới một gateway bất kỳ sau load balancer. Chat Service ghi tin vào DB trước, cấp seq theo hội thoại, rồi tra Redis xem người nhận đang ở gateway nào và publish vào kênh của gateway đó. Bật “B offline” để xem push và bù tin khi kết nối lại, hoặc “Gửi lại sau lỗi mạng” để xem chống trùng.Clients hold a WebSocket to any gateway behind the load balancer. The Chat Service stores the message first, assigns a per-conversation seq, looks up in Redis which gateway holds the recipient and publishes to that gateway's channel. Turn on “B is offline” to see the push and the catch-up on reconnect, or “Resend after a network error” to see deduplication.
1A gửi tin qua WebSocket đang mở tới Gateway 1, kèm client_msg_id do app sinh.A sends over its open WebSocket to Gateway 1, with a client_msg_id generated by the app.
Người gửi và người nhận thường ở hai node khác nhau. Lưu presence:{user} → gateway_id trong Redis; mỗi gateway subscribe kênh gw:{id} của riêng nó. Chat Service đóng vai mediator: client không bao giờ nói chuyện trực tiếp với nhau.Sender and recipient usually sit on different nodes. Keep presence:{user} → gateway_id in Redis; each gateway subscribes to its own gw:{id} channel. The Chat Service acts as the mediator: clients never talk to each other directly.
Timestamp của client hay của nhiều server đều lệch. Cấp seq tăng dần theo conversation dưới khoá dòng (hoặc một writer duy nhất cho mỗi partition Kafka key = conversation_id). Client sắp theo seq và phát hiện lỗ hổng để gọi sync.Client clocks and server clocks both drift. Assign an increasing seq per conversation under a row lock (or a single writer per Kafka partition keyed by conversation_id). Clients sort by seq and detect gaps to trigger a sync.
Ack có thể mất trên đường về. Client giữ client_msg_id cho tới khi nhận ack; server có unique(conversation_id, client_msg_id) và trả lại seq cũ thay vì ghi mới. Client retry với backoff + jitter để cả nghìn máy không cùng kết nối lại một nhịp sau khi gateway khởi động lại.An ack can be lost on the way back. The client keeps its client_msg_id until acked; the server has unique(conversation_id, client_msg_id) and returns the old seq instead of a new row. Clients retry with backoff and jitter so thousands of devices do not reconnect in lockstep after a gateway restart.
Không lưu một dòng cho mỗi tin × mỗi người. Lưu last_delivered_seq và last_read_seq cho mỗi thành viên; mọi tin có seq ≤ giá trị đó là đã nhận / đã xem. Thay đổi được phát cho người gửi như một sự kiện.Do not store one row per message per member. Store last_delivered_seq and last_read_seq per member; every message with seq ≤ that value is delivered or read. Changes are pushed to the sender as events.
Không giữ hàng đợi riêng cho từng người trên gateway. DB là nguồn sự thật; khi kết nối lại, client gửi seq cuối cho từng hội thoại và server trả theo trang. Push qua FCM/APNs chỉ để đánh thức, gộp theo collapse-id.Do not keep a per-user queue on the gateway. The DB is the source of truth; on reconnect the client sends its last seq per conversation and the server pages through the rest. FCM/APNs push only wakes the user and is collapsed by conversation.
Một máy 3G yếu không được làm đầy RAM của gateway. Mỗi socket có hàng gửi giới hạn (ví dụ 256 frame) và write deadline 10 giây; đầy thì đóng socket, client kết nối lại và sync theo seq.A phone on weak 3G must not fill the gateway's memory. Each socket gets a bounded send buffer (say 256 frames) and a 10-second write deadline; when it is full the socket is closed and the client reconnects and syncs by seq.
| BảngTable | Cột chínhKey columns | Ghi chúNotes |
|---|---|---|
message | conversation_id, seq, message_id, client_msg_id, sender_id, body, created_at | PK (conversation_id, seq); unique(conversation_id, client_msg_id). Partition theo conversation_id (Cassandra/ScyllaDB hoặc Postgres hash partition)PK (conversation_id, seq); unique(conversation_id, client_msg_id). Partitioned by conversation_id (Cassandra/ScyllaDB or Postgres hash partitions) |
conversation | id, type, last_seq, updated_at | last_seq được khoá khi cấp seq mớilast_seq is locked while assigning the next seq |
conversation_member | conversation_id, user_id, last_delivered_seq, last_read_seq, muted | Receipt dạng mốc, không phải một dòng mỗi tinReceipts as watermarks, not one row per message |
presence:{user_id} (Redis) | value = gateway_id, TTL 60s | Gateway SET lại mỗi heartbeat 25 giây; hết TTL nghĩa là offlineThe gateway re-sets it on every 25-second heartbeat; an expired key means offline |
device_token | user_id, platform, token, updated_at | Token FCM/APNs; xoá khi nhà cung cấp trả UnregisteredFCM/APNs tokens; deleted when the provider answers Unregistered |
Java: Chat Service ghi tin dưới khoá conversation, chống trùng bằng client_msg_id và chỉ định tuyến sau khi commit. Go: gateway giữ hàng gửi có giới hạn cho mỗi socket, ping định kỳ và gia hạn presence.Java: the Chat Service stores the message under the conversation lock, deduplicates by client_msg_id and routes only after commit. Go: the gateway keeps a bounded send buffer per socket, pings regularly and refreshes presence.
@Service
@RequiredArgsConstructor
public class MessageService {
private final MessageRepository messages;
private final ConversationRepository conversations;
private final PresenceRegistry presence;
private final StringRedisTemplate redis;
private final PushPublisher push;
@Transactional
public Ack send(String senderId, SendCommand cmd) {
long last = conversations.lockLastSeq(cmd.conversationId()); // FOR UPDATE
Optional<Message> dup = messages
.findByConversationIdAndClientMsgId(cmd.conversationId(), cmd.clientMsgId());
if (dup.isPresent()) return Ack.of(dup.get(), true); // retry: same seq
long seq = last + 1;
conversations.updateLastSeq(cmd.conversationId(), seq);
Message m = messages.save(Message.of(cmd, senderId, seq));
afterCommit(() -> route(m));
return Ack.of(m, false);
}
private void route(Message m) {
for (String userId : conversations.memberIds(m.conversationId())) {
if (userId.equals(m.senderId())) continue;
Optional<String> gw = presence.gatewayOf(userId); // GET presence:{user}
if (gw.isPresent()) {
redis.convertAndSend("gw:" + gw.get(), Envelope.json(userId, m));
} else {
push.enqueue(userId, m); // offline: FCM / APNs
}
}
}
private static void afterCommit(Runnable r) {
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override public void afterCommit() { r.run(); }
});
}
}type Client struct {
userID string
conn *websocket.Conn
send chan []byte // bounded: this is the backpressure
}
const (
sendBuffer = 256
writeWait = 10 * time.Second
pingPeriod = 25 * time.Second
presenceTTL = 60 * time.Second
)
// Deliver is called by the subscriber of channel gw:{thisGatewayID}.
func (h *Hub) Deliver(userID string, frame []byte) {
h.mu.RLock()
c, ok := h.clients[userID]
h.mu.RUnlock()
if !ok {
return // user moved away; catch-up by seq covers it
}
select {
case c.send <- frame:
default:
c.conn.Close() // slow consumer: drop it, the client resyncs
}
}
func (c *Client) writePump(ctx context.Context, p Presence, gatewayID string) {
ticker := time.NewTicker(pingPeriod)
defer ticker.Stop()
for {
select {
case frame := <-c.send:
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
if err := c.conn.WriteMessage(websocket.TextMessage, frame); err != nil {
return
}
case <-ticker.C:
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
p.Set(ctx, c.userID, gatewayID, presenceTTL) // SET presence:{u} gw EX 60
case <-ctx.Done():
return
}
}
}| Redis Pub/SubRedis Pub/Sub | KafkaKafka | |
|---|---|---|
| Đảm bảo giaoDelivery guarantee | Fire-and-forget; subscriber đang mất kết nối thì mất tinFire-and-forget; a disconnected subscriber misses messages | Bền, đọc lại theo offsetDurable, replayable by offset |
| Độ trễLatency | Dưới 1 ms trong cùng vùngUnder 1 ms within a region | Vài ms tới chục ms tuỳ linger.ms và acksA few to tens of ms depending on linger.ms and acks |
| Mô hìnhModel | Một kênh gw:{id} mỗi gateway; Redis Cluster 7 nên dùng sharded pub/sub (SPUBLISH)One gw:{id} channel per gateway; on Redis Cluster 7 use sharded pub/sub (SPUBLISH) | Partition theo conversation_id cho thứ tự; gateway đọc topic định tuyến của mìnhPartition by conversation_id for ordering; each gateway reads its routing topic |
| Chọn khiPick it when | DB đã là nguồn sự thật và client bù theo seqThe DB is already the source of truth and clients catch up by seq | Cần xử lý hậu kỳ (push, search, kiểm duyệt) từ cùng luồng tinOther consumers (push, search, moderation) need the same message stream |
Mỗi pattern mở bài học tương ứng trong menu Pattern.Each one opens its lesson in the Patterns menu.
Đồng hồ client sai, đồng hồ giữa các server lệch vài ms tới vài giây, và hai tin có thể cùng millisecond. Seq tăng dần theo conversation, cấp tại một điểm tuần tự, cho thứ tự duy nhất và giúp client phát hiện lỗ hổng. Thứ tự toàn cục giữa các hội thoại không cần.Client clocks are wrong, server clocks drift by milliseconds to seconds, and two messages can share a millisecond. A per-conversation seq assigned at one serial point gives a single order and lets clients detect gaps. A global order across conversations is not needed.
Client thấy socket đóng và kết nối lại qua load balancer với backoff + jitter (1 s, 2 s, 4 s… cộng ngẫu nhiên) để tránh dồn 50k handshake cùng lúc. Presence cũ hết hạn sau TTL. Tin publish vào gw:{id} cũ bị mất nhưng client sync theo seq cuối nên không mất gì.Clients see the socket close and reconnect through the load balancer with backoff and jitter (1s, 2s, 4s… plus randomness) to avoid 50k handshakes at once. Old presence keys expire after the TTL. Messages published to the dead gw:{id} are lost, but clients sync by last seq so nothing is missing.
Không lưu 500 dòng mỗi tin. Mỗi thành viên có last_delivered_seq và last_read_seq; “đã xem bởi 37 người” tính bằng đếm thành viên có last_read_seq ≥ seq. Với nhóm lớn chỉ gửi receipt tổng hợp theo lô vài giây.Not 500 rows per message. Each member has last_delivered_seq and last_read_seq; “seen by 37” is a count of members with last_read_seq ≥ seq. For large groups send aggregated receipts in batches every few seconds.
Đưa việc cấp seq về một writer duy nhất: Kafka partition theo conversation_id với một consumer ghi tuần tự, hoặc INCR conv:{id}:seq trong Redis rồi ghi DB với unique(conversation_id, seq). Đổi lại phải xử lý lỗ hổng seq khi ghi thất bại.Move seq assignment to a single writer: a Kafka partition keyed by conversation_id with one consumer writing in order, or INCR conv:{id}:seq in Redis followed by a DB write with unique(conversation_id, seq). The price is handling seq gaps when a write fails.
Phím 1–4 để chọn đáp án, Enter để kiểm tra.Press 1–4 to choose an answer, Enter to check.
Ôn tập ngắt quãng (spaced repetition): thẻ được lặp lại sau 1 → 3 → 7 → 14 → 30 ngày để củng cố trí nhớ dài hạn.Spaced repetition: cards are repeated after 1 → 3 → 7 → 14 → 30 days to build long-term memory.