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

Ảnh chụp đoạn mã nền tối reconnect mất bao nhiêu trùng bao nhiêu ai nhớ hộ với Go 1.23 gorilla websocket 1.5.3 server dist-lab phát 200 event mỗi giây client go-lab ReadDeadline 1 s rút cáp 3 s bằng iptables DROP, chuyện gì xảy ra trong 3 giây đứt gồm t bằng 4 s DROP server vẫn WriteMessage 200 lần mỗi giây vào kết nối cũ thành công byte nằm trong send buffer kernel, t bằng 5 s client im 1 s ReadDeadline Close nối lại mỗi 100 ms SYN bị DROP chưa được, t bằng 7 s gỡ DROP client nối được kết nối cũ nhận RST server mới biết nó chết, ba lớp mất event trong buffer kernel kết nối cũ event phát khi client đang offline event phát khi đang bắt tay lại, ba mức giao thức ứng dụng cùng một stream gồm L0 ngây thơ hello sub server đẩy từ lúc nối không seq không nhớ gì đa số code đầu tiên, L1 resume hello resume lastSeq mỗi event mang seq server giữ ring buffer N event dùng chung nối lại gửi tiếp từ lastSeq cộng 1 nếu đã bị ghi đè báo GAP kèm seq hiện tại, L2 session hello session id lastAcked server giữ unacked riêng cho từng session client ack mỗi 50 event nối lại resend mọi event lớn hơn lastAcked có thể trùng client phải dedup theo seq, mã lõi của L1 server và cách client đếm gồm ms ok bằng ring.from lastSeq cộng 1 ok bằng false nếu lastSeq cộng 1 cũ hơn phần tử cũ nhất còn trong ring, if ok for m range ms c.send m else c.send gapMarker seqNow, client seen seq tăng trùng bằng seen lớn hơn 1 sai thứ tự bằng seq nhỏ hơn maxSeen và chưa thấy MẤT bằng max trừ min cộng 1 trừ số duy nhất, đo gì 15 s mỗi mức cắt 3 s ở giây 4 span seq đã thấy nhận duy nhất MẤT TRÙNG sai thứ tự offline deadline tới nối lại, server written của từng kết nối kể cả kết nối chết so với client thật sự nhận replayed gaps resent unacked còn lại, L1 chạy hai lần ring 1000 đủ cho 3 s nhân 200 và ring 256 không đủ để thấy GAP trông thế nào

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

Ảnh chụp output thật nền tối L0 mất 615 L1 mất 0 L2 mất 0 trùng 10 với 200 event mỗi giây 15 s DROP 3 s ở giây 4 client đo bằng seq trong payload server đếm written mỗi kết nối, A bốn lần chạy cùng kịch bản gồm L0 ngây thơ span seq 3000 duy nhất 2385 MẤT 615 TRÙNG 0 sai thứ tự 0 offline 2076 ms dải mất 964 tới 1578 không ai biết, L1 ring 1000 span 2998 duy nhất 2998 mất 0 trùng 0 offline 3085 ms replayed 818, L1 ring 256 span 2999 duy nhất 2180 mất 819 trùng 0 offline 3092 ms gaps_sent 1 GAP 964 tới 1782 client biết, L2 ack mỗi 50 span 2999 duy nhất 2999 mất 0 trùng 10 offline 3085 ms resent 827 unacked còn 142, 615 xấp xỉ 3,07 s nhân 200 đúng bằng thời gian đứt L1 ring 256 mất nhiều hơn L0 819 vì ring bị ghi đè sạch báo gap tới seq hiện tại, B server ghi thành công bao nhiêu vào kết nối đã chết gồm L0 conn 1 written 1222 client nhận qua conn 1 811 biến mất trong kernel 411 event khoảng 2 s, L1 ring 1000 1206 811 395 event nhưng replay 818 bù lại hết, L2 1206 810 396 event resend 827 bù lại dư 10 trùng, server chỉ thấy conn 1 chết khi RST về sau khi gỡ DROP trong 3 s nó tưởng mình đang phát bình thường, C giá phải trả cho không mất gồm L1 ring 1000 nhân 100 B bằng 100 KB cho mọi client đủ cho ring chia rate bằng 5 s đứt 10000 client offline cùng lúc vẫn 100 KB nhưng ring là một stream chung mỗi client một stream riêng thì thành L2, L2 unacked mỗi session xấp xỉ ack interval cộng outage nhân rate nhân 100 B bằng 827 nhân 100 B xấp xỉ 83 KB mỗi client lúc đứt 3 s 10000 client đứt cùng lúc mất điện Wi-Fi văn phòng bằng 830 MB chưa kể session của client không bao giờ quay lại, trùng 10 bằng số event nhận sau ack cuối mà chưa kịp ack client phải dedup ack dày hơn thì trùng ít hơn ack tốn hơn, nói thẳng offline 2 tới 3 s ở đây bằng 1 s ReadDeadline cộng chờ gỡ DROP mạng thật có thêm DNS TLS backoff sai thứ tự bằng 0 ở mọi mức vì một kết nối TCP là một hàng mất thứ tự chỉ xuất hiện khi có hai kết nối cùng sống phần 9 nhiều máy đây không phải bằng chứng là WebSocket luôn đúng thứ tự

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ề

  1. 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.
  2. "Đã 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ỗ.
  3. 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

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.