Đây là phần cuối của sê-ri, và nó gói lại mọi thứ đã đo: một request thật gần như không bao giờ chỉ đi một chặng. Nó qua CDN, qua cân bằng tải, vào app, app gọi cache rồi cơ sở dữ liệu rồi vài dịch vụ khác — mỗi chặng ăn một phần ngân sách độ trễ, cái tổng thời gian mà người dùng chịu đựng được. Câu hỏi cuối: các chặng đó cộng lại thế nào, và làm sao giữ tổng trong ngân sách? Tôi dựng một chuỗi chặng thật trong container để đo — và bị chính cái máy chủ tự dựng của mình lừa một vố, đúng như bài học đã theo suốt cả sê-ri.

Ngân sách độ trễ

Ngân sách và các chặng

Một "ngân sách độ trễ" là con số bạn không được vượt: ví dụ "trang phải hiện trong 200 ms". Mỗi chặng trên đường đi tiêu một phần của 200 ms đó, và cách chúng cộng lại phụ thuộc vào việc bạn gọi chúng tuần tự (chặng sau chờ chặng trước xong) hay song song (gọi cùng lúc). Tuần tự thì tổng là phép cộng mọi chặng; song song thì tổng chỉ bằng chặng chậm nhất. Khác biệt đó quyết định bạn có nằm trong ngân sách hay không.

Tôi dựng một app gọi 4 backend, mỗi chặng có RTT nền ~24 ms (netem), rồi đo tổng độ trễ dưới các kiểu gọi khác nhau.

Đo: tuần tự cộng dồn, song song lấy chặng chậm nhất

Bốn chặng nhanh (backend trả lời ngay):

Cách gọi Tổng độ trễ
Tuần tự (lần lượt) 261 ms
Song song (cùng lúc) 63 ms

Gọi tuần tự, độ trễ cộng dồn: 261 ms cho 4 chặng (mỗi chặng tốn khoảng 2 RTT — sẽ nói ở phần sau vì sao). Gọi song song, cả 4 chặng chồng lên nhau nên tổng chỉ ~63 ms — bằng khoảng một chặng. Song song hoá 4 lời gọi độc lập cắt độ trễ đi hơn 4 lần.

Ba chặng nhanh + một chặng chậm (một backend ngủ thêm 100 ms):

Cách gọi Tổng độ trễ
Tuần tự 368 ms
Song song 172 ms

Đây là chỗ đắt giá. Gọi tuần tự, chặng chậm cộng trọn 100 ms của nó vào tổng → 368 ms. Gọi song song, tổng nhảy lên 172 ms — và con số đó bằng đúng chặng chậm nhất, không phải tổng các chặng. Ba chặng nhanh xong trong nháy mắt rồi ngồi chờ đúng cái chặng chậm. Khi bạn fan-out và chờ tất cả, độ trễ của bạn là độ trễ của kẻ chậm nhất — chính là lý do trễ đuôi p99 của một chặng lại trở thành trễ điển hình của cả request.

Một lần tôi đo hớ: máy chủ của tôi làm nhiễu phép đo

Ở đây có một con số tôi suýt hiểu sai, và nó là bài học tổng kết đúng nghĩa của cả sê-ri. Khi đo lần đầu, mỗi chặng ra ~63 ms dù RTT chỉ 24 ms. Phản xạ của tôi là gán cho hiện tượng: "ừ thì mạng nội bộ cũng có overhead, chắc là chồng chất TCP". Tôi suýt viết con số đó vào bài như một sự thật về "độ trễ mỗi chặng".

May là con số quá lạ nên tôi truy tới cùng thay vì chấp nhận. Thủ phạm không phải mạng, mà là chính cái máy chủ tôi tự dựng để đo. Server dùng http.server của Python, và nó ghi phần header rồi phần thân của phản hồi thành hai lần ghi riêng. Với Nagle bật, mẩu thứ hai bị giữ lại chờ ACK của mẩu đầu, mà client thì trì hoãn ACK — đúng cú kẹt Nagle cộng delayed-ACK ~40 msbài về Nagle đã đo. Mỗi phản hồi vì thế cõng thêm ~40 ms không phải của mạng, mà của cấu hình server. Tắt Nagle trên server (đúng một dòng disable_nagle_algorithm), độ trễ keep-alive tụt từ 253 xuống 163 ms.

Đây là bài học xuyên suốt và có lẽ là quan trọng nhất của cả sê-ri: cái công cụ, cái máy chủ, cái môi trường bạn tự dựng lên để đo cũng là một biến số. Suốt 45 phần, phép đo đã nhiều lần bị chính thứ tôi dựng làm sai lệch: một cổng bị chia sẻ nhầm, một ip_forward thừa kế từ Docker, một aioquic thuần Python chậm hơn kernel, và giờ là một http.server kẹt Nagle. Mỗi lần, cám dỗ là đổ cho hiện tượng đang đo. Mỗi lần, cách đúng là dừng lại soi cái công cụ trước. Một con số lạ thường không phải khám phá — nó thường là công cụ đang nói dối, và việc của người đo là bắt nó thú nhận.

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên là song song hoá các chặng độc lập. Nếu app của bạn gọi ba dịch vụ mà kết quả của cái này không cần cho cái kia, đừng gọi chúng lần lượt — gọi cùng lúc, và tổng độ trễ sụp từ tổng-các-chặng xuống chặng-chậm-nhất (261 xuống 63 ms trong phép đo). Đây là một trong những tối ưu có đòn bẩy lớn nhất và hay bị bỏ qua nhất, vì code viết tuần tự đọc tự nhiên hơn. Chỉ những chặng phụ thuộc nhau (cần kết quả A để gọi B) mới buộc phải tuần tự.

Hệ quả thứ hai là khi fan-out, hãy ám ảnh với chặng chậm nhất, không phải chặng trung bình. Vì tổng song song bằng kẻ chậm nhất, một chặng có p99 tệ sẽ kéo tụt cả request mỗi khi nó xui — dù trung bình của nó đẹp. Đây là lý do các hệ lớn cắt đuôi bằng request dự phòng (hedged requests), đặt hạn chót cho từng lời gọi con, và theo dõi p99 của từng phụ thuộc. Và đừng quên thuế bắt tay: trong phép đo, mỗi chặng mở kết nối mới tốn ~2 RTT (bắt tay rồi mới gọi); tái dùng kết nối bằng keep-alive và bể kết nối kéo mỗi chặng về gần 1 RTT (163 so với 268 ms cho 4 lời gọi). Ngân sách độ trễ được tiêu cả ở số chặng, độ song song, chặng chậm nhất, việc có tái dùng kết nối hay không.

Hệ quả thứ ba — lời chốt cho cả sê-ri — là một thái độ hơn một kỹ thuật: đo, đừng đoán; và khi đo, nghi ngờ cả công cụ đo. Con số mang theo: độ trễ tuần tự cộng dồn (4 chặng = 261 ms) nên hãy song song hoá (còn 63 ms); tổng song song bằng chặng chậm nhất (một chặng +100 ms đẩy tổng lên 172 ms, không phải tổng); và mỗi kết nối mới cõng thêm thuế bắt tay nên hãy tái dùng. Bốn mươi lăm phần này chỉ có một phương pháp lặp đi lặp lại: dựng nó lên, đo nó thật, và khi con số làm bạn ngạc nhiên, hãy tin phép đo hơn niềm tin cũ — sau khi đã chắc chắn chính cái thước không cong. Mạng thôi là hộp đen khi bạn chịu mở nó ra mà nhìn.

Thử ba mươi giây

Mở DevTools tab Network trên một trang bất kỳ, xem cột Waterfall — nó chính là ngân sách độ trễ của trang đó vẽ ra: mỗi thanh là một chặng (một request tài nguyên), và bạn thấy ngay cái nào chạy song song (các thanh chồng thời gian lên nhau) và cái nào bị xếp tuần tự (thanh này chờ thanh kia xong mới bắt đầu). Tìm những chuỗi tuần tự dài — mỗi cái là một cơ hội song song hoá — và tìm cái thanh dài nhất kéo dài tổng thời gian, đó là chặng chậm nhất đáng tối ưu. Toàn bộ sê-ri này, rốt cuộc, chỉ để bạn nhìn cái biểu đồ đó và hiểu từng mili giây trong nó đến từ đâu. Cảm ơn bạn đã đọc tới đây.