Phần 7 phát thông điệp tới 10.000 client đang kết nối. Phần này về khoảnh khắc một client không kết nối: Wi-Fi đổi điểm truy cập, thang máy, điện thoại khoá màn hình, NAT hết hạn. Kết nối rớt, client nối lại sau vài giây, và câu hỏi là những sự kiện phát trong vài giây đó đi đâu. WebSocket tự nó không trả lời: nó chỉ hứa thứ tự trong một kết nối. Mọi thứ liên quan đến mất, trùng và tiếp tục từ chỗ cũ là việc của giao thức ứng dụng bạn viết lên trên. Bài này viết ba mức giao thức đó, cắt mạng thật bằng iptables giữa một stream 200 sự kiện mỗi giây, và đếm từng số thứ tự.
Ba giây đứt và ba mức giao thức

Hình 1: Ba lớp sự kiện bị mất trong một lần đứt mạng, ba mức giao thức ứng dụng, và cách client đếm mất, trùng, sai thứ tự.
Khi cáp bị rút ở giây thứ 4, ba chuyện xảy ra theo thứ tự. Server không biết gì: nó tiếp tục WriteMessage 200 lần mỗi giây vào kết nối cũ và mọi lời gọi đều thành công, byte nằm trong send buffer của kernel như phần 5 đã thấy. Client im lặng một giây thì ReadDeadline nổ, nó đóng kết nối và thử nối lại mỗi 100 ms, nhưng SYN cũng bị DROP. Giây thứ 7 gỡ DROP: client nối được; kết nối cũ nhận RST và lúc đó server mới biết nó đã chết. Sự kiện mất vì thế có ba lớp: những gì đang nằm trong buffer của kết nối cũ, những gì phát khi client offline, và những gì phát trong lúc bắt tay lại.
Ba mức giao thức ứng dụng, cùng một stream. L0 ngây thơ: client gửi "sub", server đẩy từ lúc nối, không số thứ tự, không nhớ gì; đây là cách đa số code đầu tiên được viết. L1 resume: mỗi sự kiện mang số thứ tự, server giữ một ring buffer N sự kiện dùng chung cho mọi client; khi nối lại client gửi lastSeq, server gửi tiếp từ lastSeq + 1 nếu còn trong ring, còn không thì gửi một gap marker kèm số thứ tự hiện tại để client biết mình thiếu đoạn nào. L2 session: server giữ danh sách sự kiện chưa được ack riêng cho từng session; client ack mỗi 50 sự kiện; khi nối lại với cùng session id, server gửi lại mọi thứ sau lastAcked, nên những sự kiện client đã nhận nhưng chưa kịp ack sẽ tới hai lần, và client phải loại trùng theo số thứ tự.
Client đếm bằng một map seen[seq]: trùng là seen > 1, sai thứ tự là số thứ tự nhỏ hơn lớn nhất đã thấy mà chưa từng thấy, mất là độ rộng khoảng số thứ tự trừ số duy nhất. Server đếm written của từng kết nối, kể cả kết nối đã chết, để so với số client thật sự nhận. Mỗi mức chạy 15 giây, cắt 3 giây ở giây thứ 4; L1 chạy hai lần với ring 1.000 (đủ cho 3 giây × 200) và ring 256 (không đủ).
Đo thật: 615, 0, 819, 0 cộng 10

Hình 2: Mất, trùng, sai thứ tự theo từng mức; số sự kiện server "ghi thành công" vào kết nối đã chết; và giá bộ nhớ của hai mức không mất.
| mức | nhận duy nhất / span | mất | trùng | server |
|---|---|---|---|---|
| L0 ngây thơ | 2.385 / 3.000 | 615 | 0 | không biết gì |
| L1 ring 1.000 | 2.998 / 2.998 | 0 | 0 | replayed 818 |
| L1 ring 256 | 2.180 / 2.999 | 819 | 0 | gap 964..1782, client biết |
| L2 ack/50 | 2.999 / 2.999 | 0 | 10 | resent 827, còn 142 chưa ack |
| conn 1 (L0): server written / client nhận | 1.222 / 811 | 411 trong kernel |
Ngây thơ: mất 615 sự kiện, đúng 3,07 giây × 200, và không ai biết. Client nhận 811 sự kiện qua kết nối đầu, nối lại sau 2.076 ms kể từ lúc deadline nổ, rồi nhận tiếp từ số thứ tự 1.579. Dải 964 đến 1.578 không bao giờ tới. Không trùng, không sai thứ tự, không lỗi nào ở hai phía: client thấy stream "tiếp tục", server thấy một client "mới". Đây là kiểu mất tệ nhất vì nó im lặng: bảng giá hiển thị giá cũ, phòng chat thiếu 600 tin, dashboard lệch mà không có chỉ báo nào. Mất bao nhiêu phụ thuộc đúng vào thời gian đứt, không phụ thuộc vào code.
Server ghi thành công 1.222 sự kiện vào kết nối đã chết, client chỉ nhận 811. Phần B là con số đáng nhớ nhất của bài. Trong 3 giây đứt, WriteMessage trên kết nối cũ trả nil 411 lần liên tiếp; byte nằm trong send buffer kernel và không bao giờ đi đâu. Server chỉ biết kết nối chết khi RST về sau khi gỡ DROP, tức là sau khi client đã nối lại bằng kết nối mới. Hệ quả cho thiết kế: "đã ghi" không phải "đã nhận". Bất kỳ cơ chế nào tính "đã gửi" tại thời điểm WriteMessage trả về đều sai trong đúng tình huống cần nó nhất. Chỉ có ack từ client mới là bằng chứng.
Số thứ tự cộng ring buffer: mất 0, trùng 0, với 100 KB cho tất cả client. L1 với ring 1.000: client nối lại, gửi resume 963, server trả 818 sự kiện từ ring rồi tiếp tục stream; 2.998 sự kiện duy nhất trên khoảng 2.998, không trùng vì server gửi đúng từ lastSeq + 1. Ring 1.000 × 100 byte là 100 KB dùng chung cho mọi client, và nó chịu được ring/rate = 5 giây đứt. Giá rẻ như vậy vì ring là một stream chung: mọi client xem cùng dữ liệu. Nếu mỗi client có stream riêng (hộp thư, phòng riêng), ring chung không còn áp dụng và bạn rơi vào L2.
Ring quá nhỏ mất nhiều hơn ngây thơ, nhưng nói thật. Ring 256 với 3 giây × 200 = 600 sự kiện đứt: lastSeq + 1 = 964 đã bị ghi đè, server gửi gap marker kèm số thứ tự hiện tại 1.782, client ghi nhận thiếu 964 đến 1.782, tức 819 sự kiện, nhiều hơn 615 của L0 vì mất luôn phần ring đã trôi qua và cả đoạn đang bắt tay. Nhưng khác biệt về chất: client biết mình thiếu đoạn nào, và có thể gọi một API REST để lấp hoặc hiển thị "đang đồng bộ lại". Mất có báo tốt hơn mất im lặng, kể cả khi số lớn hơn.
Session cộng ack: mất 0, trùng 10, và 83 KB cho mỗi client đang đứt. L2: client ack mỗi 50 sự kiện; khi nối lại gửi lastAcked, server resend 827 sự kiện sau mốc đó. Trong 827 có 10 sự kiện client đã nhận trước khi đứt nhưng chưa tới mốc ack kế, nên chúng tới hai lần và client loại trùng theo số thứ tự. Cuối bài server vẫn giữ 142 sự kiện chưa ack cho session này. Giá: unacked mỗi session ≈ (khoảng ack + thời gian đứt × tốc độ) × cỡ sự kiện = 827 × 100 byte ≈ 83 KB. Với 10.000 client đứt cùng lúc, kiểu mất điện Wi-Fi cả văn phòng, đó là 830 MB trên server, chưa kể session của những client không bao giờ quay lại, thứ bắt buộc phải có thời hạn sống.
Sai thứ tự bằng 0 ở cả bốn lần, và đó không phải bằng chứng cho điều bạn nghĩ. Trong một kết nối TCP, byte đến theo đúng thứ tự gửi, và client chỉ có một kết nối sống tại mỗi thời điểm. Sai thứ tự xuất hiện khi có hai kết nối cùng sống (client nối lại trước khi kết nối cũ chết hẳn và server gửi vào cả hai), hoặc khi stream đến từ nhiều server qua Redis pub/sub, chủ đề của phần 9. Số thứ tự trong L1 và L2 là thứ duy nhất phát hiện được cả hai trường hợp đó.
Đánh đổi cần cân nhắc
Chọn mức theo câu hỏi "mất một sự kiện thì sao". Trạng thái mới nhất thay thế được (giá, vị trí, số người online): L0 cộng một snapshot đầy đủ khi nối lại là đủ, không cần ring. Dòng sự kiện mà mỗi cái đều có nghĩa nhưng xem chung (bảng điện tử, log sự kiện công khai): L1 với ring tính từ thời gian đứt chấp nhận được nhân tốc độ. Dữ liệu riêng từng người mà không được mất (tin nhắn, lệnh, thông báo giao dịch): L2, với ack, dedup, và thời hạn sống cho session.
Ack là chi phí liên tục để mua sự chắc chắn lúc đứt. Ack mỗi 50 sự kiện cho 10 trùng và tốn 1/50 số thông điệp đi lên; ack mỗi 10 cho ít trùng hơn và tốn gấp 5. Và ack chỉ có nghĩa nếu client dedup đúng: seen[seq] trong bài là một map lớn dần; thực tế dùng cửa sổ trượt hoặc chỉ giữ maxSeq nếu stream đúng thứ tự.
Phạm vi đo. Một client, một server, đứt 3 giây sạch bằng DROP. Chưa đo: đứt "một nửa" (chỉ mất một chiều) nơi client vẫn gửi ack được nhưng không nhận; reconnect bão của 10.000 client cùng lúc với backoff và jitter; và chi phí lưu ring hoặc unacked ra ngoài tiến trình để sống qua lần khởi động lại server, chủ đề liên quan tới phần 9.
Ba ý mang về
- Không có số thứ tự thì mất là im lặng: 615 sự kiện biến mất, không lỗi, không chỉ báo. Mỗi sự kiện phải mang số thứ tự trước khi bàn tới bất kỳ cơ chế nào khác.
- "Đã ghi" không phải "đã nhận": server ghi thành công 411 sự kiện vào kết nối đã chết trong 3 giây và chỉ biết khi RST về. Chỉ ack từ client mới là bằng chứng, và chỉ ack mới cho phép resend đúng chỗ.
- Ring chung rẻ, session riêng đắt, cả hai đều phải tính từ thời gian đứt: 100 KB cho mọi client với ring 1.000, 83 KB cho mỗi client với session khi đứt 3 giây. Ring quá nhỏ thì báo gap thay vì im lặng; session thì phải có thời hạn sống.
Nguồn
- RFC 6455 — mục 1.5 Design Philosophy và 7 Closing the Connection: https://www.rfc-editor.org/rfc/rfc6455#section-7
- Socket.IO — Connection state recovery (ring buffer + offset, cùng ý với L1): https://socket.io/docs/v4/connection-state-recovery
- MQTT 5.0 — QoS 1 và Session Expiry, mô hình unacked theo session (L2): https://docs.oasis-open.org/mqtt/mqtt/v5.0/mqtt.html
- Go
net—Conn.SetReadDeadline: https://pkg.go.dev/net#Conn - gorilla/websocket —
Conn.ReadMessage,WriteMessage: https://pkg.go.dev/github.com/gorilla/websocket#Conn
Phần sau đưa stream này lên hai server: client nối vào máy A hay máy B đều phải nhận cùng sự kiện, qua Redis pub/sub. Độ trễ thêm bao nhiêu, thứ tự còn giữ được không, và điều gì xảy ra với số thứ tự khi có hai nguồn phát.