Phần 6 dựng 10.000 kết nối và để chúng ngồi yên. Phần này gửi cho chúng một thứ: cùng một thông điệp tới tất cả, việc mà mọi hệ thống realtime đều làm, từ bảng giá tới thông báo tới phòng chat lớn. Cách viết hiển nhiên là một vòng for qua danh sách client. Câu hỏi là client đứng cuối danh sách nhận sau bao lâu, con số đó đổi thế nào khi chia việc ra nhiều goroutine, khi dựng khung một lần bằng PreparedMessage, khi mỗi client có goroutine ghi riêng, và chuyện gì xảy ra với 9.999 client còn lại khi có một client không đọc lẫn vào. Mọi độ trễ trong bài đo ở phía client, bằng timestamp server nhúng trong payload, không phải thời gian hub chạy xong.

Năm cách phát và một client chậm

Ảnh chụp đoạn mã nền tối fan-out một thông điệp tới 10000 client ai nhận cuối với Go 1.23 gorilla websocket 1.5.3 server dist-lab 10 vCPU 10000 client go-lab 40 vòng nhân 1 KB cách 500 ms, đo cái gì độ trễ tới từng client không phải thời gian hub chạy xong gồm server ghi vào 8 byte đầu payload UnixNano lúc bắt đầu vòng 4 byte kế số vòng, client nhận lat bằng now trừ ts hai container chung kernel chung đồng hồ, mỗi vòng p50 p99 max qua 10000 client client cuối nhận sau bao lâu bằng max gộp 40 vòng bằng 400000 mẫu, server đo riêng hub ms từ lúc bắt đầu tới khi ghi xếp hàng xong cho client cuối CPU tiêu mỗi vòng, năm cách phát gồm seq một goroutine for c range clients c.WriteMessage msg client cuối chờ 9999 lần ghi, seqprep như trên nhưng websocket.NewPreparedMessage một lần WritePreparedMessage cho từng client khung header cộng payload dựng một lần thay vì copy vào writeBuf của từng conn, shard 10 worker bằng GOMAXPROCS client i thuộc worker i mod 10 WaitGroup song song không goroutine dài hạn, shardprep shard cộng PreparedMessage, chan mỗi client một goroutine ghi cộng chan 64 hub chỉ select case ch nhận msg default drop, rồi lẫn vào một client chậm client 10001 rcvbuf 2 KB không bao giờ đọc server đặt SO_SNDBUF 4 KB cho nó tắt autotuning kẹt sau khoảng 10 vòng, mọi cách ghi đều có SetWriteDeadline 1 s ghi kẹt 1 s rồi lỗi remove, câu hỏi 1 s kẹt đó rơi vào ai seq cả vòng shard 1 phần 10 số client chan chỉ goroutine của client đó, đo gì A 5 cách không client chậm p50 p99 max của 400000 mẫu client cuối trung vị và xấu nhất hub ms CPU mỗi vòng RSS, B seq shard chan với 1 client chậm vòng nào kẹt client cuối chờ bao lâu p99 của 10000 client khoẻ đổi thế nào

Hình 1: Độ trễ đo ở client bằng timestamp trong payload, năm cách phát, và cách dựng một client chậm sao cho nó thật sự kẹt.

Độ trễ đúng nghĩa là lúc client nhận trừ lúc hub bắt đầu vòng phát. Server ghi UnixNano vào 8 byte đầu payload và số vòng vào 4 byte kế; client, chạy trong container khác nhưng chung kernel và chung đồng hồ, tính hiệu khi nhận. Mỗi vòng cho 10.000 mẫu; 40 vòng cho 400.000 mẫu mỗi cách; giá trị lớn nhất mỗi vòng là client cuối. Server đo thêm hub ms, thời gian từ lúc bắt đầu tới khi ghi hoặc xếp hàng xong cho client cuối, và CPU tiêu mỗi vòng.

Năm cách phát. seq: một goroutine, vòng for gọi WriteMessage cho từng client; client cuối chờ 9.999 lần ghi trước nó. seqprep: như trên nhưng dựng khung một lần bằng NewPreparedMessage rồi WritePreparedMessage cho từng client, bỏ bước copy payload vào bộ đệm ghi của mỗi kết nối. shard: 10 worker goroutine bằng GOMAXPROCS, client thứ i thuộc worker i mod 10, WaitGroup chờ xong. shardprep: shard cộng PreparedMessage. chan: mỗi client một goroutine ghi và channel 64 chỗ, hub chỉ select với default bỏ qua, chính là chiến lược drop của phần 5.

Client chậm là client thứ 10.001: đặt bộ đệm nhận 2 KB, gửi chữ "slow", rồi không bao giờ đọc. Hai bài học khi dựng nó nằm ở phần đo. Mọi cách ghi đều đặt SetWriteDeadline 1 giây, nên khi kẹt, lời gọi ghi chờ đúng 1 giây rồi lỗi và kết nối bị gỡ. Câu hỏi của phần B là một giây đó rơi vào ai.

Đo thật: client cuối, PreparedMessage, và một giây kẹt rơi vào ai

Ảnh chụp output thật nền tối client cuối seq 91 ms shard 31 ms chan 31 ms với 10000 client 40 vòng nhân 1 KB bằng 400000 mẫu mỗi cách độ trễ đo ở client bằng timestamp trong payload, A không có client chậm gồm seq hub ms trung vị 90,8 max 115 CPU mỗi vòng 90 ms p50 51,9 ms p99 98,9 ms max 115,3 ms client cuối 90,9 trên 115,3 ms RSS 240 MB 10003 goroutine, seqprep 88,5 trên 97 90 ms 49,2 90,9 97,3 88,4 trên 97,3 240 MB, shard nhân 10 30,2 trên 45 140 ms 19,6 42,7 46,7 30,8 trên 46,7 239 MB, shardprep 30,4 trên 41 140 ms 18,3 45,1 314,9 30,8 trên 314,9 239 MB, chan 18,1 trên 48 170 ms 22,1 69,7 75,5 31,4 trên 75,5 292 MB 20003 goroutine, hub của chan chỉ là xếp hàng xong ghi thật do 10000 goroutine làm shardprep có đúng 1 vòng trên 40 trễ 315 ms, PreparedMessage khung dựng một lần nhưng p50 chỉ bớt 2 tới 3 ms ở 1 KB chi phí là syscall write không phải copy, B lẫn vào 1 client không đọc server SO_SNDBUF 4 KB cho nó write deadline 1 s gồm seq vòng kẹt 24 hub 1105 ms p99 tất cả 1065,7 ms max 1105 ms cả 10000 client trễ 1 s ở vòng đó p99 toàn bài hỏng, shard nhân 10 vòng 24 hub 1029 ms p99 40,6 ms max 1029 ms khoảng 1000 client cùng shard trễ 1 s 9000 còn lại không biết, chan không kẹt hub 28 ms p99 45,3 ms max 48 ms không ai goroutine của nó kẹt 1 s rồi remove im lặng, sau đó removed 1 ở cả ba kết nối chậm bị đóng các vòng còn lại trở về bình thường, C lần chạy đầu không thấy gì vì kernel nuốt hộ client chậm rcvbuf 2 KB nhưng server để autotuning 40 vòng nhân 1 KB bằng 40 KB chưa đủ làm send buffer đầy removed 0 không kẹt phần 5 đo kernel nuốt tới 2,5 MB phải đặt SO_SNDBUF 4 KB phía server để chuyện kẹt xảy ra trong 40 vòng, lần trước nữa client chậm bị GC đóng fd vì không ai giữ tham chiếu websocket.Conn server thấy EOF removed 1 giả, nói thẳng 10 vCPU mạng Docker cùng máy nên ms tuyệt đối chỉ để so giữa các cách CPU mỗi vòng của shard chan cao hơn seq 140 tới 170 vs 90 ms vì song song hoá có phí điểm chính không đổi theo máy client cuối của seq chờ đủ 9999 lần ghi một client kẹt kéo theo đúng những ai chung goroutine ghi với nó

Hình 2: Độ trễ 400.000 mẫu mỗi cách, client cuối theo từng cách, và một giây kẹt của client chậm rơi vào 10.000, 1.000 hay 0 client.

cách client cuối (trung vị) p99 CPU/vòng RSS, goroutine
seq 90,9 ms 98,9 ms 90 ms 240 MB, 10.003
seqprep 88,4 ms 90,9 ms 90 ms 240 MB, 10.003
shard ×10 30,8 ms 42,7 ms 140 ms 239 MB, 10.003
shardprep 30,8 ms 45,1 ms (1 vòng 315 ms) 140 ms 239 MB, 10.003
chan 31,4 ms 69,7 ms 170 ms 292 MB, 20.003
với 1 client không đọc vòng kẹt p99 toàn bài ai trễ 1 s
seq 1.105 ms 1.065,7 ms cả 10.000
shard ×10 1.029 ms 40,6 ms ~1.000
chan không 45,3 ms không ai

Ghi tuần tự: client cuối chờ 91 ms, đúng bằng 9.999 lần ghi trước nó. Hub ghi xong cho 10.000 client sau 90,8 ms trung vị, tức khoảng 9 µs mỗi lần WriteMessage 1 KB trên 10 vCPU. Phía client, p50 là 51,9 ms (client ở giữa danh sách), p99 98,9 ms, và client cuối trung vị 90,9 ms, trùng khít hub. Độ trễ của bạn là hàm của vị trí trong danh sách, và thứ tự duyệt map trong Go là ngẫu nhiên nên mỗi vòng một client khác chịu phần cuối. Với 100.000 client, con số này là gần một giây cho người cuối, trước khi mạng kịp làm gì.

Chia 10 worker: 31 ms, gần đúng chia cho số lõi, nhưng CPU mỗi vòng tăng từ 90 lên 140 ms. shard với 10 goroutine bằng GOMAXPROCS: hub 30,2 ms, client cuối 30,8 ms, p99 42,7 ms. Không phải chia đúng 10 vì 10.000 lần write() tranh nhau lõi với 10.000 goroutine đọc đang chờ và với kernel đang chuyển 10 MB qua mạng ảo. CPU mỗi vòng cao hơn 55% so với tuần tự: song song hoá trả bằng chuyển ngữ cảnh và cache. Đổi 50 ms CPU lấy 60 ms độ trễ cho người cuối là đổi có lãi với realtime; với một job nền thì không.

PreparedMessage chỉ bớt 2 đến 3 ms, vì ở 1 KB chi phí là syscall, không phải copy. seqprep so với seq: client cuối 88,4 thay vì 90,9 ms, p99 90,9 thay vì 98,9; shardprep so với shard không khác trong sai số, và còn dính một vòng 315 ms trong 40 vòng, nhiều khả năng là một lần GC hoặc lịch của hệ điều hành. PreparedMessage dựng khung một lần và tiết kiệm một copy 1 KB cho mỗi client, nhưng mỗi client vẫn cần một write() riêng, và syscall đó mới là 9 µs. PreparedMessage có nghĩa khi bật permessage-deflate (nén một lần thay vì 10.000 lần) hoặc payload lớn; với 1 KB không nén, nó là tối ưu đúng chỗ sai.

Kênh riêng cho mỗi client: cùng 31 ms, thêm 10.000 goroutine và 52 MB, và p99 xấu hơn. chan: hub "xong" sau 18 ms vì chỉ xếp hàng, nhưng client cuối vẫn 31,4 ms vì 10.000 goroutine ghi tranh nhau 10 lõi; p99 69,7 ms, tệ hơn shard, vì lịch 10.000 goroutine kém đều hơn 10 worker duyệt tuần tự. RSS 292 MB so với 240, goroutine 20.003 so với 10.003. Đó là giá của sự cô lập, và phần B cho thấy nó mua được gì.

Một client không đọc: tuần tự làm cả 10.000 client trễ 1,1 giây; shard làm 1.000; kênh riêng không ai. Ở vòng 24, send buffer của client chậm đầy. seq: WriteMessage cho nó chặn đủ 1 giây deadline, hub của vòng đó 1.105 ms, và vì mọi client xếp sau nó trong vòng lặp đều chờ, p99 toàn bài nhảy từ 99 lên 1.066 ms, tức hơn 1% tổng số mẫu, cả một vòng, trễ hơn một giây. shard: chỉ worker chứa client đó kẹt 1.029 ms, khoảng 1.000 client cùng shard trễ, 9.000 còn lại không biết gì, p99 toàn bài 40,6 ms. chan: goroutine ghi của riêng nó kẹt rồi lỗi rồi bị gỡ, hub chưa bao giờ quá 28 ms, p99 45,3 ms, không client nào khác thấy gì. Sau đó cả ba đều ghi removed 1 và các vòng còn lại trở lại bình thường. Phạm vi thiệt hại tỉ lệ đúng với số client chung một goroutine ghi với kẻ chậm: 10.000, 1.000, hay 1.

Hai lần chạy đầu không thấy gì, và lý do đáng giá hơn kết quả. Lần đầu, client chậm bị gỡ ngay mà không kẹt: không ai giữ tham chiếu tới *websocket.Conn của nó, GC chạy finalizer của net.Conn và đóng fd, server thấy EOF. Lần hai, giữ tham chiếu rồi, 40 vòng × 1 KB = 40 KB vẫn không đủ làm send buffer đầy, vì autotuning của kernel nới bộ đệm như phần 5 đã đo tới 2,5 MB; không kẹt, không ai bị gì. Phải đặt SO_SNDBUF 4 KB phía server cho riêng kết nối đó, tắt autotuning, kẹt mới xảy ra trong 40 vòng. Trên production, chuyện này không cần ai giúp: một client bị nền hoá 10 phút ở 1 KB/giây là 600 KB, và ở tốc độ của bảng giá thì vài giây.

Đánh đổi cần cân nhắc

Shard là điểm cân bằng tốt nhất trong bài, với điều kiện có deadline. Độ trễ bằng chan, bộ nhớ bằng seq, không goroutine dài hạn, thiệt hại khi có client chậm giới hạn trong một shard và chỉ kéo dài một deadline. Hạ deadline xuống 100 ms thu hẹp thiệt hại thêm 10 lần nhưng đóng nhầm client chỉ hơi chậm; chọn theo p99 của mạng thật. Nếu hệ thống đã có goroutine ghi riêng cho mỗi client vì lý do khác (xếp hàng theo thứ tự, gộp, nén), chan là lựa chọn tự nhiên và trả 5 KB mỗi client cho sự cô lập hoàn toàn.

Không có cách nào thoát khỏi 10.000 lần write(). Mọi cách ở đây đều gọi write() 10.000 lần mỗi vòng; khác nhau chỉ ở bao nhiêu goroutine và ai chờ ai. Giảm số syscall là việc của tầng khác: gộp nhiều thông điệp vào một khung khi tần suất cao (10 thông điệp 100 B thành một khung 1 KB là bớt 90% syscall), hoặc chia client ra nhiều tiến trình và nhiều máy, chủ đề của phần 9 với Redis pub/sub.

Phạm vi đo. 10 vCPU, mạng Docker cùng máy, payload 1 KB, 2 vòng mỗi giây. Độ trễ tuyệt đối sẽ khác trên máy bạn; tỉ lệ giữa các cách và cơ chế thiệt hại thì không. Chưa đo: permessage-deflate (nơi PreparedMessage thật sự có lãi), TLS (mỗi write() thêm mã hoá), và fan-out tới các nhóm con thay vì tất cả.

Ba ý mang về

  1. Với ghi tuần tự, độ trễ của một client là vị trí của nó trong danh sách: client cuối trong 10.000 chờ 91 ms, bằng đúng 9.999 lần write trước nó. Chia theo số lõi đưa con số về 31 ms với giá 50 ms CPU mỗi vòng.
  2. PreparedMessage không phải thuốc cho fan-out thông điệp nhỏ: bớt 2 đến 3 ms trong 91, vì chi phí là syscall chứ không phải copy. Nó có lãi khi nén hoặc payload lớn.
  3. Một client kẹt kéo theo đúng những ai chung goroutine ghi với nó: 10.000 với vòng lặp tuần tự, 1.000 với 10 shard, 0 với kênh riêng. Luôn đặt write deadline, và chọn mức cô lập theo số client bạn chấp nhận cho trễ cùng lúc.

Nguồn

Phần sau đổi góc nhìn sang client: kết nối rớt rồi nối lại, thông điệp nào đã mất, thông điệp nào tới hai lần, và số thứ tự cộng xác nhận giúp được bao nhiêu khi đo với netem cắt mạng thật.