---
title: "Thiết kế ứng dụng realtime và offline-first - WebSocket, SSE, đồng bộ dữ liệu và CRDT"
description: "Khóa học về thiết kế app realtime như chat, livestream và ghi chú sửa được khi offline, xoay quanh câu hỏi: tin nhắn bị mất, trùng hay trễ ở chặng nào. Bạn sẽ chọn được polling, SSE hay WebSocket, lấy lại tin đã lỡ mà không bị trùng, chạy nhiều gateway, giữ gateway ổn định ở phòng đông người và gộp bản sửa lúc offline."
canonical: "https://200lab.io/courses/thiet-ke-ung-dung-realtime-va-offline-first-websocket-sse-dong-bo-du-lieu-va-crdt"
type: "course"
course_type: "course"
learning_type: "free_form"
published: "2026-10-13T08:03:34Z"
students: 0
---

## Khóa học này mang lại gì cho bạn

App chat nhóm mà bạn xây cùng agent đã có tin nhắn, chấm online, dòng “đang gõ…” và số tin chưa đọc, chạy cả trên trình duyệt lẫn app điện thoại. Người dùng muốn mọi thứ đó chạy realtime, tức dữ liệu mới hiện trên màn hình trong khoảng dưới một giây mà không phải tải lại trang. Muốn vậy, server giữ sẵn connection tới từng thiết bị, tức kết nối mà trình duyệt hay app điện thoại mở ra với server rồi giữ để gửi dữ liệu qua lại.

Khi app có thêm nhiều người dùng, có người mở lại app sau khi wifi chập chờn thì thấy thiếu vài tin của nhóm, có tin vừa gửi hiện hai lần, còn agent đề xuất “chuyển hết sang WebSocket là xong”. Trước khi làm theo đề xuất đó, bạn cần biết tin đang mất hay trùng ở chặng nào, và cần hỏi agent những số liệu nào.

- **Chọn cách đưa tin mới tới trình duyệt** — Bạn chọn giữa polling (client hỏi server theo chu kỳ cố định, ví dụ mỗi **2 giây**, xem có dữ liệu mới không), long-polling, SSE và WebSocket theo hướng đi của dữ liệu và tần suất cập nhật. Bạn cũng biết khi nào polling vẫn đủ dùng và app chưa cần gateway. Gateway là thành phần giữ connection luôn mở với client và đẩy dữ liệu mới xuống client qua connection đó.
- **Giữ connection luôn mở trên một gateway** — Heartbeat là việc gateway gửi ping định kỳ, chờ client trả pong, và đóng connection khi pong không về trong hạn chờ. Bạn dùng heartbeat để đóng connection không còn client ở đầu kia, và để chấm online tự tắt khi người dùng không còn connection nào. Khi deploy, bạn cho gateway đóng dần connection thay vì làm mọi client cùng reconnect trong cùng một giây.
- **Lấy lại tin đã lỡ mà không hiện trùng** — Bạn giải thích được vì sao reconnect thành công mà client vẫn thiếu tin hoặc hiện tin hai lần. Bạn cho mỗi kênh đánh số thứ tự cho từng tin trong kênh, để client báo được đã nhận tới tin số mấy. Mỗi tin còn mang ID do client sinh, nên lần gửi lại không tạo thêm dòng mới trong database.
- **Chạy nhiều gateway mà tin vẫn tới mọi thiết bị** — Bạn lưu tin vào database trước rồi mới chuyển tin sang các gateway khác, để khi tin bị lỡ ở chặng chuyển giữa các gateway, client vẫn tự bù được phần thiếu. Server quyết định khi nào đẩy tin qua connection đang mở, khi nào gửi push notification, và tính số tin chưa đọc mà không phải ghi thêm gì cho từng người nhận mỗi khi có tin mới.
- **Giữ gateway chạy ổn khi kênh hay phòng có hàng nghìn người** — Bạn giới hạn phần dữ liệu gateway giữ cho từng connection để vài client đọc chậm không chiếm hết bộ nhớ, chỉ gửi nội dung đầy đủ của tin cho người đang mở kênh, và gom lượt thả tim lại để bình luận không phải xếp sau hàng trăm lượt tim.
- **Cho app đọc và sửa được dữ liệu khi offline** — Offline-first là yêu cầu app vẫn đọc và sửa được dữ liệu khi không có network, rồi đồng bộ khi có network trở lại. Bạn giữ thao tác của người dùng trên thiết bị kèm ID sinh ở client. Để gộp bản sửa của nhiều thiết bị, bạn chọn được giữa LWW, OT và CRDT, và biết cách nào âm thầm bỏ bản sửa của một thiết bị, cách nào giữ lại mọi thao tác.
- **Hỏi agent đúng số liệu** — Trước khi làm theo một đề xuất về realtime, bạn hỏi agent xem bao nhiêu phần trăm request polling trả về rỗng, gateway đang giữ bao nhiêu connection không còn trả lời heartbeat, client thiếu và hiện trùng bao nhiêu tin sau mỗi lần reconnect, và bao nhiêu bản sửa bị mất khi gộp dữ liệu của các thiết bị.

Ví dụ xuyên suốt khóa học là app chat nhóm chạy trên Postgres, cùng phòng livestream bán hàng và trình soạn ghi chú dùng chung. Khóa học không phụ thuộc vào nền tảng realtime hay thư viện CRDT nào: code minh họa là vài đoạn TypeScript và SQL ngắn, còn frame giao thức đầy đủ, schema, cấu hình heartbeat và kịch bản lỗi nằm trong demo kit để agent của bạn chạy.

## Khóa học cung cấp những nền tảng nào

Mọi bài trong khóa đều quay về một câu hỏi: tin mới đi từ người gửi tới từng màn hình đang mở qua những chặng nào, và khi một chặng bị đứt, chạy chậm hay gửi trùng tin thì tin nào mất, tin nào trùng, tin nào tới trễ? Sáu nhóm bài dưới đây đi theo bậc thang scale của app chat, tức các nấc kiến trúc mà app trải qua khi lớn dần.

Bậc thang bắt đầu ở khoảng **100 người** online, khi trình duyệt hỏi tin mới theo chu kỳ và server chỉ chạy backend cùng Postgres. Backend là phần code chạy trên server, nhận request của trình duyệt hay điện thoại rồi đọc, ghi database. Nấc cuối không đến từ số người online mà từ nhu cầu sửa dữ liệu khi không có network, nên app chỉ có vài trăm người dùng cũng có thể phải lên nấc này.

Tài liệu [Gateway](https://docs.discord.com/developers/events/gateway) của Discord cho thấy mỗi event mang số thứ tự (sequence) để sau khi connection đứt, client lấy lại được phần event đã lỡ (resume). Bài viết [Real-time Messaging](https://slack.engineering/real-time-messaging/) của Slack mô tả cách Slack tách server giữ WebSocket khỏi server giữ lịch sử gần đây của kênh. Bài viết [Local-first software](https://www.inkandswitch.com/essay/local-first/) của Ink & Switch giải thích vì sao app để dữ liệu nằm sẵn trên thiết bị và dùng CRDT để gộp bản sửa.

### 1. Polling, SSE hay WebSocket, và giữ connection luôn mở trên một gateway

Endpoint lấy tin mới nhận **6.000 request mỗi giây** khi có **12.000 người** online, và **95%** response trả về danh sách rỗng. Bài so sánh polling, long-polling, SSE và WebSocket trên năm tiêu chí đo được, rồi chọn theo hướng đi của dữ liệu.

Dữ liệu đi cả hai chiều liên tục thì dùng WebSocket; dữ liệu chỉ đi từ server xuống thì SSE (server-sent events) là đủ, và trình duyệt tự reconnect kèm `Last-Event-ID`. App chỉ có vài trăm người online, tin thưa, thì polling vẫn là lựa chọn hợp lý.

Frame close là gói dữ liệu client gửi để báo đóng connection. Client mất network đột ngột thì không kịp gửi frame này, nên gateway vẫn giữ connection đó cùng phần bộ nhớ dành cho nó. Connection không còn client ở đầu kia như vậy gọi là connection ma. Bài cho gateway gửi ping mỗi **25 giây** và đóng connection không trả pong trong **10 giây**, nên connection ma bị đóng chậm nhất **35 giây** sau lần pong cuối.

Chu kỳ ping phải ngắn hơn idle timeout **60 giây** của load balancer để connection đang im lặng không bị cắt. Drain là cách gateway ngừng nhận connection mới rồi đóng dần connection đang mở. Khi deploy, gateway drain trong khoảng **30 giây** thay vì đóng tất cả cùng lúc.

Truy vấn bảng `users` trả về **640 người** có `is_online = true` dù không còn thiết bị nào giữ connection. Gateway chỉ bật cờ này khi connection mở và chỉ tắt khi nhận được frame close. Khi gateway khởi động lại, các connection cũ mất theo process cũ mà không có frame close nào được xử lý, nên cờ kẹt ở true.

Presence là trạng thái đang online mà người khác thấy. Bài coi presence là soft state: trạng thái tự hết hạn sau TTL **60 giây** nếu không được làm mới.

Debounce là chờ trạng thái giữ nguyên trong một khoảng thời gian rồi mới báo. Gateway chờ như vậy **10 giây** rồi mới báo offline, để chấm online không nhấp nháy mỗi lần điện thoại đổi network. Dòng “đang gõ…” tự ẩn mà không cần event “ngừng gõ”, và app hiện “không rõ” khi gateway không đọc được nơi giữ presence.

### 2. Reconnect mà không thiếu tin, gửi lại mà không trùng tin

Reconnect chỉ mở lại connection. Muốn lấy lại phần tin đã lỡ, client còn phải resume, tức báo gateway mình đã nhận tới đâu để gateway phát bù phần còn thiếu. Sequence là số thứ tự của từng tin trong kênh. Bài cho transaction ghi tin cấp sequence ngay trên dòng của kênh trong bảng `channels`.

Cursor của client là sequence của tin cuối cùng mà client đã nhận và xử lý xong, khi trước đó không thiếu tin nào. Sau mỗi lần reconnect, client gửi cursor của từng kênh cho gateway. Retention là phần tin gần nhất server còn giữ để phát bù. Khoảng thiếu dài hơn retention thì client resync bằng snapshot, tức bản chụp các tin gần nhất của kênh.

Ngay trước bài về cursor là bài nền lấy từ khóa về hệ thống event-driven. Bài đó cho thấy vì sao không lấy `id` tự tăng của bảng `messages` làm cursor: transaction commit muộn để lại khoảng trống mà cursor có thể vượt qua và bỏ sót tin.

Ack là phản hồi báo tin đã lưu. Tin hiện hai lần thường bắt đầu từ lúc server đã lưu tin nhưng ack không về tới client. Bài cho client sinh `client_msg_id` một lần cho mỗi tin và giữ nguyên ở mọi lần gửi lại. Bảng `messages` có constraint `UNIQUE (sender_id, client_msg_id)`, còn gateway chỉ trả ack sau khi commit. Trạng thái “Đã gửi”, “Đã nhận”, “Đã xem” của tin chỉ được tiến lên, không lùi lại.

Nhóm bài này khép lại bằng bài tình huống kể lại lần chạy thử trước khi phát hành. Proxy giả lập lỗi network đóng connection của laptop rồi chặn network **5 giây**, đúng lúc mọi người trong nhóm chat đang nhắn. Laptop thiếu **3 tin**, **2 tin** của laptop hiện hai lần trên thiết bị của người khác, và chấm online của bạn tắt rồi sáng lại. Agent đề xuất tải lại **200 tin** gần nhất sau mỗi lần reconnect, nhưng cách đó không xử lý được tin trùng.

Sau khi sửa bằng cursor, `client_msg_id` và debounce cho presence, tin thiếu và tin trùng đều về **0**, và gateway chỉ phát bù đúng **3 tin** gửi trong lúc đứt. Tradeoff là gateway đọc thêm database ở mỗi lần resume, và người thật sự tắt app hiện offline chậm hơn **10 giây**.

### 3. Chạy nhiều gateway mà tin vẫn tới mọi thiết bị

Từ khi có thêm `gateway-2`, agent đếm được **49%** tin không tới người nhận đang online qua connection, vì mỗi gateway chỉ đẩy tin cho connection mà chính nó giữ. Nhóm này mở bằng bài nền lấy từ khóa về hệ thống event-driven. Bài đó giải thích queue, pub/sub và event log khác nhau thế nào ở cách giao message cho consumer, và message còn hay mất khi consumer offline.

Pub/sub ở đây là thành phần chuyển tin từ gateway này sang các gateway khác. Bài cho gateway lưu tin vào Postgres trước để có sequence, rồi mới publish lên topic của kênh. Topic là tên mà gateway publish và subscribe trên pub/sub. Pub/sub không giữ lại tin nào, nên khi tin bị lỡ ở chặng này, client tự yêu cầu gateway phát bù theo cursor.

Ở kênh đang không có tin mới, client không có tin kế tiếp nào để thấy mình đang thiếu tin. Vì vậy gateway gửi sequence mới nhất của từng kênh (`last_seq`) xuống client ngay sau mỗi lần subscribe lại pub/sub, chẳng hạn sau khi gateway khởi động lại. Gateway còn gửi `last_seq` theo chu kỳ, phòng khi gateway khác đã commit tin rồi crash trước khi kịp publish.

Push notification là thông báo mà server nhờ service push của hệ điều hành điện thoại hiện lên, kể cả khi app không mở. Bài cho mọi gateway cùng ghi vào connection registry chung trên Redis. Registry này cho biết connection của từng người nằm ở gateway nào, kèm lần cuối người dùng thao tác trên mỗi thiết bị. Server tra registry trước khi gửi push, và người có thao tác trong **60 giây** gần nhất chỉ nhận tin qua connection.

Người bị xóa khỏi kênh trong lúc connection vẫn mở cũng thôi nhận tin của kênh, kể cả khi event báo việc đó bị lỡ, vì gateway đối chiếu lại quyền theo chu kỳ bằng cách đọc database chính. Nếu không đọc được quyền của người dùng nào, gateway tạm dừng đẩy tin qua connection của người đó.

Nếu mỗi tin mới làm tăng số tin chưa đọc của từng người nhận thêm một, kênh **2.000** thành viên tốn **1.999** lệnh ghi cho mỗi tin. Bài cho server giữ số tin của kênh (`msg_count`) trên dòng của kênh, và giữ mốc đã đọc tới của từng người, rồi tính số tin chưa đọc bằng hiệu hai số.

Badge là số nhỏ báo còn bao nhiêu tin chưa đọc. Mốc đã đọc của mỗi người chỉ được tăng và được gửi tới mọi thiết bị của người đó, nên khi người dùng đọc trên laptop, badge trên điện thoại cũng giảm theo, chỉ muộn hơn vài giây. Nếu thiết bị lỡ event báo mốc đã đọc mới, badge trên thiết bị đó vẫn hiện số cũ cho tới khi thiết bị tải lại badge từ server.

Bài tình huống cuối nhóm gồm hai sự cố trong cùng buổi chiều: Redis khởi động lại làm tin bị lỡ ở chặng pub/sub, rồi lần deploy `gateway-1` làm **9.000** client cùng reconnect trong vòng một giây. Hiện tượng thứ hai gọi là reconnect storm: rất nhiều client cùng reconnect trong cùng một khoảng thời gian ngắn. Truy vấn resume chờ connection tới database gần **6 giây** rồi trả về lỗi, và client thử lại ngay.

Agent hay đề xuất chuyển sang pub/sub có lưu tin, nhưng client có connection đứt vẫn phải tự lấy phần thiếu bằng cursor, còn truy vấn resume không đi qua pub/sub. Bài cho gateway drain trong **30 giây**, cho client chờ một khoảng thời gian ngẫu nhiên trước cả lần reconnect đầu, và giới hạn số lần resume mỗi giây. Số lần reconnect cao nhất trong một giây giảm từ khoảng **9.000** xuống khoảng **300**, đổi lại client của gateway đang được deploy phải chờ thêm vài giây.

### 4. Giữ gateway chạy ổn khi kênh hay phòng có hàng nghìn người

Bài đầu tiên của nhóm này là bài nền lấy từ khóa về hệ thống chịu tải cao. Bài đó cho thấy hàng chờ trong RAM phình to khi việc đổ về service nhanh hơn tốc độ service xử lý được. Trong app chat, `gateway-2` giữ **1,6 GB** dữ liệu chờ gửi cho **180 connection**, phần lớn là điện thoại bắt sóng yếu. Dữ liệu chờ gửi là phần gateway đã nhận lệnh gửi cho từng connection nhưng chưa đẩy ra network được.

Bài cho gateway kiểm tra ngưỡng **1 MB** trước khi đưa frame vào hàng chờ của connection, rồi xử lý theo loại event. Tin nhắn không được mất, nên gateway đóng connection để client resume lại bằng cursor; presence chỉ cần giá trị mới nhất, còn lượt thả tim được cộng dồn. Dữ liệu chờ gửi của các connection đọc chậm còn tối đa khoảng **180 MB**.

Kênh thông báo có khoảng **9.000 người** online trên `gateway-3`, và mỗi tin mất khoảng **2,3 giây** mới tới connection cuối cùng. Trong lúc đó, tin ở mọi kênh khác trên cùng gateway cũng đứng lại, vì vòng gửi do agent viết dựng lại payload cho từng người nhận trên cùng event loop.

Bài cho gateway encode payload một lần, chỉ gửi nội dung đầy đủ cho người đang mở kênh (người khác chỉ được báo là kênh có tin mới), và chia connection thành từng nhóm để nhường event loop cho kênh khác. Thời gian gateway làm việc trong vòng gửi còn khoảng **180 ms** thay vì gần **2,3 giây**. Độ trễ tới người nhận cuối cùng vẫn phải đo riêng. Đổi lại, người không mở kênh chỉ thấy nội dung tin khi mở kênh.

Ở bài tình huống cuối nhóm, phòng livestream bán hàng có **30.000** người xem chia đều trên **3** gateway. Thêm gateway thứ tư chỉ làm mỗi gateway giữ ít người xem hơn, còn mỗi người xem vẫn nhận số frame như cũ. Bài cho gateway gom event theo từng loại ở bước gửi tới người xem. Batching là gom event lại rồi gửi một lần mỗi **250 ms**. Bình luận dùng batching, còn mỗi frame tim mang tổng số tim mới nhất của phòng.

Người xem đọc chậm có lỡ vài frame tim thì frame sau vẫn mang đúng tổng. Gateway crash thì khác: tổng có thể thiếu phần lượt tim mà gateway đó đang giữ. Mỗi người xem còn nhận khoảng **6,5** frame mỗi giây, và tradeoff là bình luận trễ thêm khoảng một chu kỳ gom.

### 5. Client cũng là replica của dữ liệu khi app phải dùng được offline

Người dùng báo ghi chú tạo lúc offline hiện thành hai bản, và dữ liệu cho thấy **6%** ghi chú tạo khi offline bị nhân đôi trên server. Trình soạn ghi chú do agent viết giữ thao tác trong RAM của app và gửi lại nguyên request khi có network. Vì vậy, thao tác server đã ghi rồi vẫn bị gửi thêm lần nữa, còn thao tác của những lần app bị tắt trước khi có network thì mất hẳn.

Bài coi client cũng là replica của dữ liệu, tức bản dữ liệu nằm trên từng thiết bị. Op là thao tác sửa của người dùng, ví dụ gõ thêm vài chữ vào ghi chú. Mỗi op mang `op_id` sinh ở client và nằm trong hàng đợi op trên bộ nhớ lưu trữ của thiết bị tới khi có ack. Khi có network trở lại, client lấy thay đổi của server trước rồi mới gửi op đang chờ.

Hai bài nền tiếp theo lấy từ khóa về dữ liệu phân tán, với ví dụ về sàn thương mại điện tử. LWW (last-write-wins) là cách giữ bản được ghi sau cùng. Bài đầu cho thấy LWW âm thầm ghi đè bản sửa địa chỉ giao hàng. CRDT là kiểu dữ liệu mà bản trên từng thiết bị tự gộp về cùng một giá trị. Bài thứ hai cho thấy counter CRDT đếm lượt dùng mã giảm giá không sót lượt nào, mà mã vẫn bị dùng vượt giới hạn.

Hai bài này đứng ở nhóm bài về ghi chú dùng chung, vì phần gộp văn bản cần đúng hai phần kiến thức nền đó: LWW làm mất bản sửa mà không ai được báo, còn CRDT tự gộp được nhưng không giữ được invariant nghiệp vụ. Invariant nghiệp vụ là điều kiện mà nghiệp vụ luôn yêu cầu phải đúng, như mã giảm giá không được dùng quá giới hạn.

### 6. OT, sequence CRDT và ký tự đã xóa trong ghi chú dùng chung

Agent so sánh các ghi chú họp trên hai laptop và thấy **14** trong **90** ghi chú không khớp nhau, dù mỗi laptop đã nhận đủ op của laptop kia. Bài thu nhỏ ví dụ lại thành chuỗi “ABC” trên hai máy. Nếu hai op được tạo cùng lúc mà vẫn giữ nguyên vị trí tính bằng số khi tới máy kia, thì hai máy sẽ cho ra hai nội dung khác nhau.

Với OT (operational transformation), server xếp thứ tự các op và biến đổi vị trí của op được tạo trên bản cũ. Sequence CRDT thì gắn ID riêng cho từng ký tự để op trỏ tới ID thay cho vị trí. App có server trung tâm và người dùng chủ yếu sửa khi online thì dùng OT vẫn ổn; app cho sửa offline lâu thì sequence CRDT hợp hơn, dù mọi ký tự tốn thêm dữ liệu.

Ghi chú dự án chỉ còn ba trang chữ nhưng dữ liệu đồng bộ nặng **gần 5 MB**, và điện thoại mất **6 giây** mới mở xong. Phần lớn dung lượng là tombstone, tức dấu giữ lại cho từng ký tự đã xóa để thiết bị khác vẫn biết ký tự đó đã bị xóa.

Agent đề xuất bỏ hẳn ký tự đã xóa quá **7 ngày**, và lần thử trên staging làm đoạn đã xóa hiện lại khi laptop có network trở lại sau hai tuần offline. Quy tắc dọn ID và tombstone mà bài đưa ra là chính sách riêng do team tự viết cho app, không phải cách dọn chung cho mọi CRDT.

Theo quy tắc đó, server đồng bộ bỏ nội dung chữ của ký tự đã xóa từ sớm, nhưng chỉ bỏ hẳn ID khi mọi replica mà server còn theo dõi đã xử lý xong lệnh xóa đó, và mọi op mà các replica này tạo trước lúc nhận lệnh xóa đều đã về tới server.

Replica offline quá **30 ngày** bị bỏ khỏi danh sách theo dõi, và khi quay lại phải tải snapshot. Bài cũng nói về lịch sử phiên bản và undo của từng người khi nhiều người cùng sửa một ghi chú.

Bài tình huống cuối khóa bắt đầu khi laptop báo “Đã đồng bộ” mà ghi chú chỉ còn bản đồng nghiệp sửa trên điện thoại, vì server đồng bộ chỉ so sánh hai giá trị `updated_at` rồi giữ một bản. Trên bộ dữ liệu mẫu, gộp thao tác bằng CRDT giữ lại đủ thao tác của laptop, và app đánh dấu **6** đoạn mà cả hai thiết bị cùng sửa để người dùng xem lại.

Phần đánh dấu đó bạn phải tự viết trong app, vì thư viện CRDT gộp cả hai lần sửa mà không báo đoạn nào bị sửa chồng. Tradeoff của CRDT là dung lượng lưu trữ lớn hơn vì ID và tombstone, bù lại thiết bị chỉ gửi phần thay đổi thay vì cả ghi chú.

## Vì sao 200Lab tạo ra khóa học này

Agent dựng xong gateway WebSocket, bảng `messages`, chấm online hay trình soạn ghi chú chỉ trong vài giờ, và khi bạn thử cùng vài người trong team trên wifi văn phòng, app chạy ổn. Lỗi chỉ lộ ra khi người dùng thật đổi network giữa chừng, khi app chạy từ hai gateway trở lên, khi một phòng có hàng nghìn người, hay khi có người sửa ghi chú suốt chuyến bay.

Lúc sự cố xảy ra, agent thường đề xuất cách xử lý dùng cho mọi trường hợp, như tải lại cả lịch sử sau mỗi lần reconnect, chuyển sang pub/sub có lưu tin hay thêm gateway. Cách nào cũng bắt team vận hành thêm thành phần hoặc dồn thêm tải lên database, trong khi chặng đang hỏng có thể vẫn nằm nguyên đó.

Còn bạn là người phải trả lời ticket “tin mình gửi hiện hai lần”. Bạn cũng phải giải thích vì sao người dùng bị lỡ tin của nhóm mà chính họ không hề hay biết, và nghe người dùng kể họ đã mất trọn phần ghi chú viết suốt chuyến bay.

Những hệ thống chat lớn như Slack hay Discord cũng phải giải quyết đúng những chuyện này. Năm 2017, Slack kể về reconnect storm khi cả văn phòng mất network rồi cùng reconnect, và mỗi lần như vậy client tải lại gần như toàn bộ dữ liệu cần để khởi động app.

Trong sự cố năm 2022 của Slack, client đã retry với backoff kèm jitter (chờ lâu dần giữa các lần thử, cộng thêm một khoảng thời gian ngẫu nhiên) mà vẫn góp thêm tải khi tầng database đang quá tải. Còn Discord chỉ gửi nội dung đầy đủ của tin trong nhóm chat lớn cho người đang mở nhóm đó, vì khoảng **90%** connection đang online gắn với nhóm chat lớn thuộc về người không mở nhóm lên xem.

Với mỗi sự cố, khóa học giúp bạn lần ngược về chặng trên đường đi của tin và lựa chọn thiết kế đã gây ra nó, chẳng hạn client có gửi cursor khi reconnect không, gateway lưu tin trước hay publish trước, hay server gộp bản sửa bằng cách chỉ giữ một bản. Biết lỗi nằm ở chặng nào rồi, bạn sửa đúng chặng đó và giải thích được với team vì sao chọn cách sửa ấy, cùng tradeoff nào team phải chấp nhận.

Ngược lại, số đo cũng cho bạn lý do để tiếp tục dùng polling với một Postgres, hoặc chỉ chạy một gateway. Điều kiện là người dùng còn chấp nhận tin tới trễ vài giây, một gateway vẫn giữ nổi số connection hiện có, vài giây gián đoạn mỗi lần deploy vẫn chấp nhận được, và chưa ai cần sửa dữ liệu khi offline. Lúc đó, team chưa phải vận hành Redis, nhiều gateway hay thư viện CRDT.

## Những vấn đề thường gặp khi app có tính năng realtime

Khi tin bị thiếu, bị trùng hay tới trễ, team thường vá ngay chỗ vừa thấy hỏng, thêm gateway hoặc làm theo đề xuất chung của agent. Các triệu chứng dưới đây cho thấy những cách đó dễ sửa nhầm chỗ, bởi nguyên nhân nằm ở chặng cụ thể trên đường đi của tin, như lúc connection đứt, lúc tin sang gateway khác hay lúc gộp bản sửa của thiết bị vừa có network trở lại.

- Gateway giữ **31.000 connection** trong khi chỉ khoảng **18.000 người** online, và bộ nhớ lên **2,9 GB** tới mức tối nào team cũng phải khởi động lại — client mất network không kịp gửi frame close, mà gateway lại không có heartbeat, nên connection ma (connection không còn client ở đầu kia) nằm lại và giữ bộ nhớ cùng file descriptor.
- Reconnect thành công, vậy mà **3.300** trong **410.000** tin của hôm qua không hiện trên ít nhất một thiết bị đang mở kênh — client không báo gateway mình đã nhận tới tin nào, nên gateway chỉ đẩy những tin tới sau khi connection mới mở.
- Khoảng **1,9%** tin gửi từ điện thoại có thêm bản sao trong kênh, cách bản gốc dưới **10 giây** — server đã lưu tin nhưng ack không về, client gửi lại với ID mới, và database không có constraint nào nhận ra lần gửi lại.
- Redis khởi động lại trong **8 giây**, và **1.240 tin** đã lưu đủ trong database vẫn không tới người nhận đang online — pub/sub không giữ lại tin, còn client ở kênh không có tin mới không biết mình đang thiếu cho tới khi có tin kế tiếp.
- App đã chạy hai gateway, mà **48%** push vẫn đi tới người đang dùng app trên thiết bị khác — gateway chỉ tra danh sách connection của chính nó trước khi gửi push, nên không thấy laptop đang có connection ở gateway còn lại.
- Postgres nhận **3.800** lệnh ghi mỗi giây, **70%** trong số đó là `UPDATE` số tin chưa đọc, trong khi app chỉ nhận khoảng **40** tin mỗi giây — mỗi tin mới làm app tăng số tin chưa đọc của từng người nhận thêm một, nên số lệnh ghi tăng theo số thành viên của kênh.
- Bình luận livestream tới trễ **9 giây** dù phòng đã chia đều cho ba gateway — mỗi người xem nhận ít nhất **740** frame mỗi giây, và bình luận xếp sau hàng trăm frame tim trong hàng chờ của connection người xem đó.
- Laptop báo “Đã đồng bộ” sau **3 tiếng** sửa ghi chú lúc offline, nhưng mọi phần sửa của laptop đều biến mất — server đồng bộ chỉ so sánh hai giá trị `updated_at` rồi giữ bản của điện thoại, và không báo cho ai biết.

Mỗi triệu chứng trên có một bài lần ngược từ số đo về đúng chặng gây lỗi, kèm câu cần hỏi lại agent trước khi chọn cách sửa.

## Bạn sẽ học theo cách nào

- **Bắt đầu từ tín hiệu thật** — Mỗi bài mở bằng thứ bạn tự thấy được trên app của mình, như ticket của người dùng, dashboard của gateway hay Postgres, cảnh báo bộ nhớ, log của client, lần chạy thử hay kết quả của lần đối chiếu do agent chạy.
- **Ví dụ thu nhỏ trước, nghiệp vụ sau** — Hai trình duyệt, một kênh và vài tin nhắn, hay chuỗi “ABC” trên hai máy, đủ để thấy cơ chế trước khi bài quay lại app chat, phòng livestream hay ghi chú dùng chung. Bảng theo dõi sequence và cursor qua từng mốc thời gian, cùng sơ đồ ngắn gọn vẽ thứ tự các bước giữa client, gateway và database, giúp bạn thấy tin đi qua thành phần nào.
- **Chọn phương án bằng số liệu** — Bạn quyết định cách đưa tin xuống trình duyệt, nhịp heartbeat, ngưỡng dữ liệu chờ gửi hay cách gộp bản sửa dựa trên số liệu của ví dụ, biết rõ tradeoff của từng lựa chọn và nấc nào của bậc thang thì chưa cần tới nó.
- **Quiz yêu cầu bạn dự đoán** — Nhiều bài cơ chế có quiz ngắn để bạn trả lời trước khi xem đáp án, như connection ma bị đóng chậm nhất sau bao lâu khi ping mỗi **25 giây** và chờ pong **10 giây**, hay client nhận lại những tin nào khi cursor đang là **43**.
- **Demo kit chạy bằng Docker** — Bốn bài tình huống và bài chọn giữa polling, SSE và WebSocket có demo kit, mỗi kit là file Markdown để coding agent của bạn dựng hệ thống trên máy, chạy cùng một kịch bản trước và sau khi sửa rồi so sánh số đo. Cơ chế của client như reconnect, cursor và hàng đợi op nằm trong client TypeScript chạy bằng dòng lệnh, còn proxy giả lập lỗi network nằm sẵn trong kit. Bạn không bắt buộc phải chạy kit.

## Khóa học dành cho ai

1. **Junior developer hoặc vibe coder** — Bạn đang xây app có chat, chấm online hay ghi chú dùng chung trên Postgres, phần lớn code do AI agent viết, và đã gặp hoặc sắp gặp chuyện thiếu tin sau khi wifi chập chờn, tin hiện hai lần hay phần sửa lúc offline biến mất; bạn cần biết vì sao app hỏng như vậy, chọn phương án nào và hỏi agent câu gì.
2. **Developer vừa nhận được đề xuất kiến trúc realtime** — Agent hoặc team vừa đề xuất chuyển hết sang WebSocket, thêm gateway thứ hai cùng Redis hay dùng thư viện CRDT, và bạn cần số liệu để quyết định trước khi đồng ý.
3. **Developer đang làm tính năng sửa dữ liệu khi offline** — Trình soạn ghi chú hay app điện thoại của bạn cho người dùng sửa khi không có network, và bạn cần biết thao tác chờ gửi nằm ở đâu, gộp bản sửa của nhiều thiết bị thế nào và bản sửa nào có thể bị bỏ.

## Những điều cần lưu ý

- **Kiến thức nền cần có** — Bạn chỉ cần từng làm app nhận request và ghi vào Postgres trong transaction, từng gọi API từ trình duyệt bằng `fetch`, kể cả khi code do agent viết, và đọc được TypeScript, SQL cùng dòng lỗi trong log; không cần từng vận hành Kubernetes hay load balancer.
- **Nên học thêm các khóa System Design liên quan** — Năm bài nền lấy từ ba khóa “Thiết kế hệ thống event-driven — message queue, pub/sub, saga, CQRS và event sourcing”, “Thiết kế hệ thống chịu tải cao — rate limiting, load shedding và circuit breaker” và “Thiết kế dữ liệu phân tán — consistency, quorum, leader election và failover”, mỗi bài đặt ngay trước bài cần nó. Khóa “Xử lý timeout, retry và crash — idempotency, outbox, lease và reconciliation” không phải điều kiện để học khóa này. Cách chống trùng bằng `client_msg_id` và `op_id`, cùng cách dùng jitter để rải reconnect, đều được giải thích ngay trong khóa này. Khóa kia giúp bạn nắm phần nền chung của các cơ chế đó, như idempotency key, kiểu giao at-least-once và retry, mà ở đây chỉ được nhắc một câu.
- **Không hướng dẫn cấu hình một nền tảng realtime cụ thể** — Gateway, sequence theo kênh và hàng đợi op được viết tay trên Postgres và Redis, demo kit chỉ dùng thư viện CRDT để so sánh với cách gộp tự viết, còn frame giao thức, schema và cấu hình nằm trong kit cho agent; đổi lại, bạn hiểu cơ chế đủ rõ để biết agent và thư viện đang quyết định thay bạn những gì.
- **Số liệu trong ví dụ do bài tự đặt** — Số người online, số connection hay số frame mỗi giây trong bài được chọn để các phép tính khớp nhau, không phải số đo từ production của một công ty nào; khi áp dụng, bạn nhờ agent đo trên hệ thống của mình, như số tin client thiếu sau mỗi lần reconnect hay dữ liệu chờ gửi của mỗi client đọc chậm, rồi đưa vào cùng cách tính.

## Bắt đầu từ đâu

Bài đầu tiên mở bằng bốn tình huống của app chat vừa có **8.000 người** online, cùng đề xuất của agent: chuyển hết sang WebSocket là xong. Khi lần theo đường đi của tin nhắn từ người gửi tới từng thiết bị của người nhận, bạn sẽ thấy WebSocket chỉ đổi chặng cuối, còn nguyên nhân của bốn tình huống nằm ở bốn chặng khác. Bài đứng đầu vì nó cho bạn ba câu hỏi cần hỏi agent trước mọi lần đổi kiến trúc.

## Chương trình

- Chuyển app chat sang WebSocket là xong, hay cần phải làm thêm gì nữa? (miễn phí)
- Từ 100 tới 100.000 người online, app chat cần thêm gì ở mỗi nấc? (miễn phí)

### Từ polling sang một gateway: tin nhắn không mất, không trùng

- 95% request polling trả về rỗng, nên dùng SSE hay WebSocket? (miễn phí) · kit cho agent
- Gateway giữ 31.000 connection dù chỉ có 18.000 người online · quiz
- Chấm online vẫn sáng dù người dùng đã tắt app từ hôm qua · quiz
- Đọc bảng event theo id khiến consumer bỏ sót event commit muộn · quiz · kit cho agent
- Vì sao reconnect thành công mà vẫn thiếu tin nhắn? · quiz
- Client gửi lại khi network chập chờn nên tin nhắn hiện hai lần · quiz
- Tình huống: mất network 5 giây giữa lúc cả nhóm đang nhắn tin · kit cho agent

### Chạy nhiều gateway mà tin nhắn vẫn tới mọi thiết bị

- Hai service đọc chung một queue thì mỗi service chỉ nhận một phần message (miễn phí) · quiz · kit cho agent
- Thêm gateway thứ hai thì một nửa tin nhắn không tới người nhận · quiz
- Đang đọc trên laptop mà điện thoại vẫn nhận push cho từng tin · quiz
- Vì sao đếm tin chưa đọc lại tốn lệnh ghi hơn cả lưu tin nhắn? · quiz
- Tình huống: pub/sub restart 8 giây làm 1.240 tin nhắn không tới · kit cho agent

### Gateway phải làm gì khi kênh hay phòng có hàng nghìn người?

- Hàng chờ trong RAM phình to khi service nhận việc nhanh hơn tốc độ xử lý
- Vì sao vài trăm client đọc chậm chiếm hết bộ nhớ của gateway? · quiz
- Tin gửi vào kênh 12.000 người mất hơn 2 giây mới tới người cuối · quiz
- Tình huống: bình luận livestream trễ 9 giây khi phòng quá đông · kit cho agent

### Sửa dữ liệu khi offline rồi gộp bản sửa từ nhiều thiết bị

- Ghi chú tạo lúc offline hiện thành hai bản trên server · quiz
- Last-write-wins âm thầm ghi đè bản sửa địa chỉ giao hàng · quiz
- Mã giảm giá vượt giới hạn dù counter CRDT không đếm sót lượt nào · quiz
- Hai người cùng sửa một đoạn thì mỗi thiết bị hiện một nội dung · quiz
- Ghi chú chỉ dài ba trang nhưng dữ liệu đồng bộ nặng gần 5 MB · quiz
- Tình huống: sửa ghi chú 3 tiếng lúc offline, đồng bộ xong mất hết · kit cho agent
