↓Chuyển đến nội dung chính
  1. Bài viết/

Bài 06: Chuyển pha — vì sao hệ thống quá tải đóng băng thay vì chậm dần đều

Khi quá tải, hệ thống không chậm dần đều mà rơi thẳng xuống, rồi kẹt lại ngay cả khi tải đã giảm. Bài này giải thích vì sao, và chỉ ra dấu hiệu báo trước đủ sớm để kịp xử lý.

I. Tóm tắt #

Trực giác nói: tải tăng 10% thì hệ thống chậm thêm cỡ 10%. Hệ thống thật không như vậy. Khi quá tải, nó đổi trạng thái đột ngột như nước đông thành đá, và để lại ba dấu vết đo được. Một: lượng việc làm xong có ích không giảm dần mà rơi thẳng đứng. Hai: sau khi kẹt, hệ làm được ít hơn cả lúc ngay trước khi kẹt, và ở lì đó dù tải đã giảm. Ba: trước cú rơi, độ trễ dao động mạnh dần trong khi mức trung bình vẫn yên, nên cú rơi thấy được từ trước. Hai công cụ giải thích chuyện này là vật lý chuyển pha và lý thuyết hàng đợi. Hệ quả thực tế: hãy tính sức chứa tại chỗ đường cong độ trễ bắt đầu bẻ ngoặt thay vì dựa vào con số tối đa từng đo. Bạn cần đặt cảnh báo lên độ dao động thay vì chỉ nhìn mức trung bình; và khi hệ đã kẹt cứng, hãy cắt tải thật sâu thay vì tỉa bớt từng chút một.

II. Điều mà cách nghĩ “tải tăng bao nhiêu, chậm bấy nhiêu” bỏ sót #

Có một loại lỗi người ta mất ba ngày để tìm và mười phút để sửa. Hệ thống vẫn chạy, không gì báo đỏ, chỉ có người dùng phải chờ 5 giây mỗi lần bấm. Tôi mở bài bằng nó vì nó chỉ đúng chỗ trực giác về tải bị sai.

Một service chạy êm nhiều tháng với connection pool 10 kết nối1. Service là một phần mềm chạy trên server, lo một việc cụ thể như xử lý thanh toán. Connection pool là nhóm kết nối database mở sẵn dùng chung, như phòng giao dịch có 10 quầy: khách thứ 11 phải đứng chờ dù phòng còn bao nhiêu ghế trống.

Rồi nhu cầu leo tới 200 request cùng lúc. Request là một lần app gửi yêu cầu tới server, như một lần bấm “Đặt món”. 190 request thừa không tính toán gì, chỉ đứng chờ quầy, nên CPU vẫn vừa phải, bộ nhớ đi ngang, không cảnh báo nào kêu. Trong khi đó p95 vọt lên 5 giây và đứng luôn ở đó.

Độ trễ là thời gian từ lúc bấm tới lúc có kết quả. p95 là mức độ trễ mà 95% request nhanh hơn: cứ 100 lần bấm thì 5 lần chờ lâu hơn con số này. Theo mọi đồng hồ đo tài nguyên, đây là một service khỏe mạnh, chỉ có một vấn đề nhỏ là người dùng phải chờ 5 giây. Nút thắt là giới hạn cứng 10 việc cùng lúc, thứ CPU hay bộ nhớ không phản ánh.

Cái sai nằm ở trực giác tuyến tính: phản ứng của hệ thay đổi đều theo tải, tức lượng request đổ vào mỗi giây. Hỏi tôi “server chạy 80% CPU thì còn dư bao nhiêu”, phản xạ đầu tiên của tôi vẫn là “20%”. Theo trực giác đó, tải tăng 10% thì chậm thêm cỡ 10%, và giảm tải là hết. Ngưỡng cảnh báo nào đặt trên độ trễ trung bình hay tỉ lệ lỗi cũng ngầm tin như vậy.

Ai đi xe giờ tan tầm ở TP.HCM đều biết điều đó sai. Đường đông dần, xe vẫn chạy; rồi một xe phanh gấp là cả đoạn đứng lại, dù phía trước chẳng có tai nạn hay đèn đỏ. Lượng xe đổ về đã vơi mà đoạn kẹt chưa tan, vì xe đã dừng hẳn cần thời gian mới lăn bánh lại. Tôi dùng con đường này làm ví dụ xuyên suốt, vì dòng xe và dòng request kẹt theo những cơ chế rất giống nhau.

Quan sát thứ hai lớn hơn nhiều. Tháng 10/1986, đường truyền giữa Lawrence Berkeley và UC Berkeley, cách nhau chừng 370 mét qua ba router, tụt từ 32 kbps xuống khoảng 40 bps (Jacobson & Karels, 1988). Router là thiết bị chuyển tiếp dữ liệu giữa các mạng; kbps là nghìn bit mỗi giây, cùng họ với Mbps trên gói cước Internet nhà bạn. Tải đầu vào tăng tính bằng phần trăm, còn dữ liệu tới nơi sụt gần một nghìn lần, dù không sợi cáp nào đứt. Mạng đứng nguyên như vậy cho tới khi chính cách truyền dữ liệu được sửa.

Hai dấu hiệu này đi cùng nhau: đầu ra nhảy bậc thay vì trượt dần, và trạng thái tệ tự duy trì sau khi nguyên nhân đã qua. Vật lý gọi cặp dấu hiệu đó là chuyển pha đột ngột. Chuyển pha là lúc một hệ đổi hẳn trạng thái khi một điều kiện bên ngoài vượt ngưỡng, như nước thành đá dưới 0°C. Với phần mềm, đó là lúc service chuyển từ “chậm” sang “đứng hình”, và cách nghĩ tuyến tính không có chỗ cho bước nhảy ấy. Vì sao hệ nhảy bậc, và vì sao không tự nhảy về, là việc của Mục III.

III. Hai công cụ: vật lý chuyển pha và lý thuyết hàng đợi #

Hai kiểu đổi trạng thái #

  • Liên tục. Hệ xấu đi từ từ, như con đường đông dần lúc chiều. Không có khoảnh khắc nào để chỉ tay, và bạn có thời gian phản ứng.
  • Gián đoạn. Như nước đóng băng: trên 0°C là nước, dưới 0°C là đá. Hệ có thể đứng ở trạng thái sai một lúc: nước tinh khiết lạnh dưới 0°C vẫn lỏng, tới khi một hạt bụi rơi vào thì cả khối đông lại. Và quay lại không dễ như đi tới.

Trên đường, đó là lúc xe dày tới mức một cú phanh đủ làm cả đoạn đứng; khi đã đứng, bớt vài xe cũng chưa chạy lại được.

Vật lý hình dung chuyện này bằng một hòn bi trên mặt đất gồ ghề2. Mỗi chỗ trũng là một trạng thái hệ có thể nằm yên. Có một thung lũng thì hòn bi trượt dần khi điều kiện đổi: đổi trạng thái liên tục. Có hai thung lũng ngăn bởi một quả đồi thì hòn bi phải nhảy sang bên kia: đổi trạng thái gián đoạn, và quả đồi là lý do hệ kẹt lại ở thung lũng sai.

Hãy mang hình ảnh này theo suốt bài. “Sập rồi không tự đứng dậy” nghĩa là hệ đã rơi xuống thung lũng thứ hai, và giảm tải một chút không đẩy nó qua lại được quả đồi. Với một service, đó là lúc request đã về mức bình thường mà người dùng vẫn chờ mãi.

Vì sao 20% năng lực cuối cùng đắt nhất #

Công cụ thứ hai là lý thuyết hàng đợi: phần toán học mô tả chuyện xếp hàng chờ phục vụ, từ quầy ngân hàng tới server. Kết quả quen thuộc nhất của nó, từ Kleinrock (1975), là độ trễ tăng theo kiểu chia cho phần năng lực còn trống.

Giả sử một quán cơm trưa nhận đơn qua app giao đồ ăn, bếp nấu được 60 suất mỗi giờ. Đơn tới 30 suất mỗi giờ thì bếp bận một nửa, và theo công thức hàng đợi đơn giản nhất, mỗi đơn nằm trong bếp trung bình 2 phút, kể cả chờ lẫn nấu. Đơn tới 57 suất mỗi giờ thì bếp bận 95%, và con số đó là 20 phút. Bếp thật hay nấu gộp nên số sẽ khác, nhưng đường cong vẫn dựng đứng khi bếp gần kín việc.

Tỉ lệ bận đó gọi là utilization: lượng việc đổ vào chia cho lượng việc hệ làm được. Phần còn trống gọi là headroom, khoảng dư để đỡ những lúc việc dồn tới.

Trên giấy, bếp bận 95% là bếp làm việc rất hiệu quả. Với người đang chờ cơm, đó là 20 phút cho một suất nấu mất 1 phút.

Một server xử lý 1.000 request mỗi giây có cùng hình dạng: độ trễ trung bình 2 mili-giây (ms) ở utilization 50%, 5 ms ở 80%, 20 ms ở 95%. Bước từ 80% lên 95% chỉ bằng nửa bước từ 50% lên 80%, nhưng làm độ trễ tăng 15 ms thay vì 3 ms. Quanh 80%, mỗi 1% utilization đắt gấp khoảng sáu lần quanh 50%, nên “còn 20% headroom” không có nghĩa là còn 20% an toàn.

Đào sâu: công thức độ trễ của hàng đợi đơn giản nhất

Kleinrock gọi mô hình này là M/M/1: request tới ngẫu nhiên, thời gian xử lý ngẫu nhiên, một chỗ xử lý.

$$ W = \frac{1}{\mu\,(1 - \rho)}, \qquad \frac{dW}{d\rho} = \frac{1}{\mu\,(1-\rho)^2} $$

Đọc bằng lời: độ trễ bằng thời gian xử lý một request chia cho phần năng lực còn trống. Độ dốc chia cho bình phương phần còn trống, nên càng gần kín việc, mỗi phần tải thêm càng đắt.

  • $\lambda$, $\mu$: số request tới, và số request xử lý được, mỗi giây.
  • $\rho = \lambda/\mu$: utilization, từ 0 đến 1.
  • $W$: độ trễ trung bình, gồm cả chờ lẫn xử lý, tính bằng giây.

Thay số với $\mu = 1000$. Ở $\rho = 0{,}5$: $W = 1/(1000 \times 0{,}5)$ giây = 2 ms, độ dốc 4 ms cho mỗi đơn vị $\rho$. Ở $\rho = 0{,}8$: $W$ = 5 ms, độ dốc 25 ms, gấp 6,25 lần. Ở $\rho = 0{,}95$: $W$ = 20 ms, gấp 20 lần thời gian xử lý.

Mô hình này còn hiền: việc nào rồi cũng xong, chỉ muộn hơn. Hệ thật không được vậy, vì hàng chờ có giới hạn, người dùng có giới hạn kiên nhẫn, và càng tranh nhau tài nguyên càng tốn công điều phối. Ba thứ đó biến đường cong trơn thành cú rơi của lượng việc có ích. Nói gọn: đường cong hàng đợi tả đoạn tiến tới chuyển pha, hai thung lũng tả chuyện xảy ra tại đó.

Một giới hạn cần nói trước #

Chuyển pha đúng nghĩa toán học chỉ có ở hệ có vô số phần tử3. Một cụm 200 máy, hay 18.000 máy của Roblox, vẫn hữu hạn: đường cong là một khúc cua rất dốc, mượt hơn lý thuyết, chứ không phải bước nhảy tuyệt đối. Vì vậy, tôi dùng “chuyển pha” như một mô hình gần đúng. Nó được kiểm chứng qua ba dự đoán: hai trạng thái cùng tồn tại ở một mức tải, hệ làm được ít việc hơn sau khi kẹt, và luôn có tín hiệu báo trước. Mục IV chỉ ra chúng nằm ở đâu trong hệ thống thật.

IV. Từ vật lý sang hệ thống của bạn: bảng quy đổi #

Để quy đổi, tôi cần hai thước đo tách bạch. Throughput là số request server xử lý xong mỗi giây, kể cả request không còn ai chờ. Goodput chỉ đếm request được trả lời kịp và còn có ích. Lúc khỏe, hai con số gần bằng nhau; lúc kẹt, chúng tách xa nhau, và cả bài xoay quanh khoảng cách đó.

Vật lý cho ba khái niệm mà lý thuyết hàng đợi cổ điển không có: hai trạng thái ổn định cùng tồn tại ở một mức tải, một cú kích hoạt nhỏ đủ đẩy hệ sang trạng thái kia, và capacity drop. Capacity drop là hiện tượng sau khi kẹt, hệ thoát việc được ít hơn cả lúc ngay trước khi kẹt. Trên đường, xe ra khỏi đoạn kẹt thưa hơn xe chạy thoáng lúc trước, vì xe đứng yên cần thêm thời gian để lăn bánh.

Khái niệm thứ tư là critical slowing down: càng gần điểm chuyển pha, hệ càng chậm hồi lại sau mỗi cú xóc. Nó cho ta nhìn thấy cú rơi từ trước.

Khái niệm vật lýTrên đường giờ tan tầmTrong hệ thống phần mềm
Núm vặn từ bên ngoàiLượng xe đổ vàoTải: request mỗi giây, mức chiếm dụng CPU hay connection pool
Con số cho biết hệ ở trạng thái nàoTỉ lệ xe đi qua trên xe đổ vàoGoodput chia cho tải: gần 1 khi khỏe, gần 0 khi kẹt (mức đầy hàng chờ đi theo chiều ngược)
Đổi trạng thái gián đoạnCả đoạn đứng lại gần như tức thìVách đá goodput: việc có ích rơi thẳng đứng, độ trễ vượt mức người dùng chịu chờ
Đứng ở trạng thái sai (nước dưới 0°C vẫn lỏng)Xe dày mà vẫn chạy nhanhChạy “có vẻ ổn” trên mức tải bền vững: throughput ở đỉnh, hết dư để hồi sau cú xóc
Cú kích hoạt (hạt bụi rơi vào nước đó)Một xe phanh gấpServer dừng để dọn bộ nhớ, dữ liệu nạp sẵn bị xóa, một máy chậm, một lần nạp lại cấu hình
Capacity dropXe ra khỏi đoạn kẹt thưa hơnGoodput sau cú rơi thấp hơn throughput trước cú rơi, vì công điều phối ăn mất năng lực
Critical slowing downMỗi cú phanh để lại đợt dồn lâu tanĐộ trễ dao động mạnh dần và “ì” dần: sức hồi đang cạn, số đo thấy trước

Vì sao cú rơi nằm sẵn trong cấu trúc #

Lập luận chỉ cần hai thứ mà hệ thống nào cũng có — giới hạn thời gian chờ và một tài nguyên dùng chung — và đi qua bốn bước.

1. Hàng chờ dài ra. Khi lượng request tới sát năng lực xử lý, hàng chờ và độ trễ leo theo đường cong ở Mục III. Chưa có gì hỏng; hệ chỉ đang chậm dần.

2. Timeout đảo ngược mọi thứ. Timeout là thời gian tối đa phía gọi chịu chờ trước khi bỏ cuộc. Khi độ trễ vượt timeout, người dùng đã bỏ đi nhưng server không hề biết. Nó vẫn bốc request hết hạn ra xử lý, tốn trọn vẹn tài nguyên, rồi gửi kết quả vào một kết nối đã đóng. Tôi gọi phần công sức đó là việc chết: công sức bỏ ra cho những request không còn ai chờ.

Ở quán cơm giả định, khách chờ 40 phút rồi hủy, nhưng bếp không được báo nên vẫn nấu, đóng hộp, rồi bỏ đi. Mỗi suất như vậy đẩy đơn thật phía sau chờ lâu hơn, và thêm khách hủy. Bếp vẫn ra đủ 60 suất mỗi giờ, chỉ là ngày càng nhiều suất đi thẳng vào thùng rác. Ví dụ này lệch ở một điểm: app thật thường báo hủy cho quán, còn server thường không được báo gì. Con đường cũng không tả được bước này, vì xe kẹt không bỏ cuộc giữa đường.

Đây là chỗ goodput tách khỏi throughput: goodput xấp xỉ bằng lượng request nhận vào nhân với tỉ lệ được trả lời trước khi phía gọi bỏ đi. Giả sử server xử lý 1.000 request mỗi giây nhưng chỉ 30% còn người chờ: throughput là 1.000, goodput là 300. Dashboard CPU không phân biệt được hai con số này, vì server bận 100% trong cả hai trường hợp.

3. Năng lực bị đốt vào việc điều phối. Thread là một dòng việc chạy song song trong server. Lock là cơ chế chỉ cho một thread sửa một bản ghi tại một thời điểm; thread khác phải chờ tới lượt.

Hình dung 128 thread cùng muốn sửa một bản ghi. Một thread giữ lock 1 ms, còn 127 thread kia liên tục kiểm tra tới lượt chưa, đốt CPU mà không làm ra gì. Năng lực thật tụt đúng lúc nhu cầu cao nhất, như thêm đầu bếp vào gian bếp chỉ có một bếp lửa: quá một số người, món ra còn chậm hơn.

Gunther (2007) viết hiện tượng này thành Universal Scalability Law. Đây là công thức ước lượng throughput theo số thread chạy song song, trong đó chi phí điều phối tăng theo bình phương số thread. Vượt một ngưỡng, thêm thread làm throughput giảm: với tham số giả định ở khối Đào sâu dưới đây, đi từ 32 lên 128 thread làm throughput giảm khoảng 40%.

Đào sâu: vì sao thêm thread có thể làm hệ chậm đi
$$ X(N) = \frac{\gamma N}{1 + \sigma(N-1) + \kappa N(N-1)} $$

Đọc bằng lời: tử số là throughput lý tưởng nếu các thread không phải chờ nhau. Mẫu số là phần hao hụt: một phần tăng theo số thread (chờ tới lượt dùng tài nguyên chung), một phần tăng theo bình phương số thread (mọi cặp thread phải giữ dữ liệu khớp nhau). Khi $N$ đủ lớn, phần bình phương thắng, và throughput đi xuống.

  • $N$: số thread, hoặc số core, làm song song. $X(N)$: throughput, request/giây.
  • $\gamma$: throughput của một thread làm một mình.
  • $\sigma$: tỉ lệ phần việc buộc phải làm lần lượt, từ 0 đến 1.
  • $\kappa$: chi phí giữ dữ liệu khớp nhau cho từng cặp thread.

Thay số với tham số giả định $\gamma = 100$, $\sigma = 0{,}05$, $\kappa = 0{,}001$: $X(32) \approx 903$ request/giây, gần đỉnh; $X(128) \approx 542$ request/giây. Gấp bốn lần số thread, throughput giảm khoảng 40%.

4. Vòng lặp tự nuôi. Năng lực thật giảm thì utilization thật tăng, ngay cả khi lượng request từ bên ngoài đứng yên. Bận hơn thì hàng chờ dài hơn, nhiều request hết hạn hơn, nhiều việc chết và tranh giành hơn. Hệ trượt xuống thung lũng thứ hai và ở lại đó, không cần thêm gì để giữ.

Lập luận này không cần retry. Retry là việc phía gọi tự gửi lại request khi không nhận được trả lời; nó chồng thêm một vòng khuếch đại, nhưng đó là chủ đề của một bài sau. Vách đá tồn tại cả khi không có retry. Câu hỏi còn lại: hệ rơi khi nào, và làm sao kéo nó lên?

V. Khi nào hệ rơi, và vì sao giảm tải chưa đủ để nó hồi #

Ba giới hạn tạo ra cú rơi #

  • Sức chứa buffer. Buffer là vùng nhớ chứa tạm request đang chờ, và nó không chứa được một hàng dài vô tận.
  • Timeout. Mỗi request có hạn sử dụng.
  • Chi phí tranh giành. Dữ liệu dùng chung làm công điều phối tăng nhanh hơn số thread.

Vì ba giới hạn này, trong một khoảng tải gần điểm tới hạn, cùng một mức tải có thể ứng với hai trạng thái. Điểm tới hạn là mức tải mà quanh đó hệ có thể rơi từ trạng thái này sang trạng thái kia. Một là chạy thoáng, goodput cao, còn giữ từ lúc nhẹ việc; hai là kẹt, goodput thấp, bị việc chết giữ tại chỗ. Hệ ở trạng thái nào phụ thuộc vào lịch sử của nó, không chỉ vào tải hiện tại.

Trên đường cũng vậy: cùng một số xe, đoạn đường vẫn thoáng nếu trước đó xe chạy đều, và vẫn kẹt nếu trước đó đã có khối kẹt. Đó là hai thung lũng ở Mục III, và là lời giải cho câu quen thuộc trong báo cáo sự cố: “tải đã về bình thường mà hệ vẫn không hồi”.

Vùng đông mà vẫn chạy #

Kerner, người dựng lý thuyết giao thông từ dữ liệu đường cao tốc ở Đức, thêm một điều chỉnh4: giữa “chạy thoáng” và “kẹt cứng” còn trạng thái thứ ba, xe dày, lúc nhanh lúc chậm, nhưng vẫn đưa được nhiều xe đi qua. Ai đi đường giờ tan tầm đều quen trạng thái này. Tôi gọi nó là vùng đông mà vẫn chạy.

Server cũng có trạng thái đó. Một máy chạy 90% CPU không chết ngay; nó có thể chạy hàng giờ trong vùng tranh giành nặng, độ trễ dao động rộng nhưng goodput vẫn giữ.

Điều này quan trọng với monitoring. Monitoring là hệ thống theo dõi số đo của server và gửi cảnh báo khi có số vượt ngưỡng. Coi mọi lần bước vào vùng đông mà vẫn chạy là sập thì sẽ gọi người trực dậy giữa đêm vì một trạng thái có thể kéo dài vô hại hàng giờ. Thứ đáng báo động là lúc hệ rời khỏi vùng này để rơi xuống trạng thái kẹt, như sơ đồ dưới.

stateDiagram-v2 state "Chạy thoáng" as ChayThoang state "Đông mà vẫn chạy" as DongMaVanChay state "Kẹt cứng" as KetCung [*] --> ChayThoang : Tải thấp, request được xử lý ngay ChayThoang --> DongMaVanChay : Utilization leo vào vùng tranh giành DongMaVanChay --> ChayThoang : Cắt bớt tải hoặc thêm năng lực kịp lúc DongMaVanChay --> KetCung : Cú kích hoạt nhỏ (một máy chậm, một lần dừng dọn bộ nhớ) KetCung --> ChayThoang : Cắt tải sâu dưới sức chứa, rồi mở lại từng bậc

Hai sự cố thật #

NSFNET, tháng 10/1986. Quan sát thứ hai ở Mục II thuộc đợt sự cố này: vách đá goodput đầu tiên được ghi nhận ở quy mô cả mạng. Router quá tải bỏ bớt gói dữ liệu, máy gửi gửi lại mà không giảm tốc, nên ngày càng nhiều băng thông chở những gói sẽ bị bỏ lần nữa: việc chết đang chạy trên dây.

TCP là giao thức nền để truyền dữ liệu tin cậy trên Internet. Congestion avoidance là cơ chế Jacobson thêm vào TCP sau đó: thấy mất gói thì máy gửi tự giảm tốc, nhờ vậy tổng lượng dữ liệu gửi vào mạng được giữ dưới điểm tới hạn.

Roblox, tháng 10/2021. Sự cố 73 giờ này là một bài minh họa cho chuyện kẹt ở thung lũng sai. Trung tâm là Consul, hệ thống các service của Roblox dùng để tìm nhau và đọc cấu hình chung5. Cú kích hoạt là streaming, một tính năng mới bật của Consul. Dưới tải đọc ghi lớn, tính năng này dồn tranh giành vào một điểm duy nhất và đẩy lớp lưu trữ bên dưới vào chế độ dọn dẹp bất thường.

Độ trễ ghi p50 nhảy từ dưới 300 ms lên 2 giây, một cú nhảy chứ không phải trôi dần, và các hệ phụ thuộc vào Consul lần lượt timeout. p50 là mức độ trễ ở giữa: một nửa số lần ghi nhanh hơn nó.

Phần đáng học nhất là lần cứu hộ không thành: đội kỹ sư thay cả cụm Consul bằng máy 128 core với ổ NVMe tốc độ cao, và hệ vẫn kẹt. Core là một nhân xử lý; 128 core chạy được 128 việc song song. Thấy một cụm máy kẹt, phản xạ đầu tiên của tôi cũng là đi tìm máy to hơn.

Phản xạ đó hỏng vì đúng lý do ở bước 3 của Mục IV. Thứ trói hệ là chi phí tranh giành chứ không phải thiếu sức tính, nên thêm core chỉ làm phần chi phí tăng theo bình phương lớn thêm. Hệ chỉ hồi khi đội kỹ sư tắt streaming, rồi cho người dùng vào lại từ 0 theo từng bậc nhỏ qua DNS. DNS là hệ thống chỉ đường, quyết định người dùng được đưa tới server nào.

Trạng thái kẹt phải thoát bằng cách hạ tải thật thấp; lối thoát không mua được bằng phần cứng. Hai sự cố DynamoDB 2015 và Slack tháng 1/2021 nhắc ở bài trước cũng nằm trên sơ đồ này. Mục VI kiểm tra ba dự đoán bằng mô phỏng.

VI. Ba mô phỏng cho ba luận điểm #

Mỗi mô phỏng kiểm tra một luận điểm; script đầy đủ nằm trong thư mục assets của series.

Mô phỏng A — đoạn tiến tới là đường cong dựng đứng. Mô phỏng tính công thức ở Mục III cho server 1.000 request mỗi giây, ra đúng ba mốc 2, 5 và 20 ms. Cách đọc: trục ngang là utilization; trục dọc, có nhãn waiting time, là độ trễ trung bình theo thang log, mỗi vạch lớn gấp 10 lần vạch dưới. Đường cong lên thoai thoải tới 80% rồi dựng đứng sau 90%.

M/M/1 simulation: mean waiting time diverges as utilization approaches 1

Mô phỏng B — capacity drop mà không cần retry. Đây là con đường giờ tan tầm viết thành code: vòng đường 500 ô với các xe chạy theo luật Nagel–Schreckenberg. Ở mỗi bước, xe tăng tốc nếu phía trước trống, giảm tốc nếu sắp chạm xe trước, và thỉnh thoảng phanh ngẫu nhiên. Barlović thêm luật “khởi động chậm”: xe đã dừng hẳn cần thêm thời gian mới lăn bánh. Mỗi mật độ xe chạy từ hai điểm xuất phát: xe rải đều, và cả đoàn dồn thành một khối kẹt.

Mô phỏng này không có dòng retry nào, không có timeout nào, và cũng không có server nào. Chỉ có xe, và một luật: đã dừng thì khởi động chậm.

Mật độ là tỉ lệ ô có xe: 0,16 là cứ 100 ô có 16 xe. Lưu lượng là số xe qua một điểm mỗi bước: 0,79 là cứ 100 bước có 79 xe qua. Xuất phát rải đều cho ra trạng thái chạy thoáng, đứng vững tới mật độ 0,16 với lưu lượng đỉnh 0,79. Xuất phát từ khối kẹt cho ra trạng thái kẹt, lưu lượng đi ngang quanh 0,21, tức ít hơn 73% ở cùng mật độ. Từ mật độ 0,07 đến 0,16, hai trạng thái cùng tồn tại: hai thung lũng ở Mục III.

Trong khoảng đó, trạng thái chạy thoáng là metastable. Metastable là trạng thái chỉ đứng vững chừng nào chưa có cú xóc đủ lớn; một cú phanh không may là đủ đẩy nó xuống trạng thái kẹt. Con số 73% thuộc về mô hình này: mô hình cho thấy khoảng hụt có tồn tại, còn độ lớn thì tùy hệ. Cách đọc: trục ngang là mật độ, trục dọc là lưu lượng; đường cam là chạy thoáng, đường đen đứt nét là kẹt, khoảng hở giữa hai đường ở mật độ 0,16 là capacity drop.

Nagel-Schreckenberg simulation with slow-to-start rule: the fundamental diagram splits into a metastable high-flow branch and a jam outflow branch, a 73% capacity drop

Không tham số nào được chỉnh để ra kết quả đó; riêng luật “dừng thì nhanh, chạy lại thì chậm” đã đủ. Trong server, luật ấy là cache bị xóa phải nạp lại, connection pool cạn phải mở lại kết nối, bộ nhớ đầy dữ liệu cũ. Cache là vùng giữ sẵn dữ liệu hay dùng để khỏi hỏi database mỗi lần; cache trống thì mọi yêu cầu lại đổ xuống database.

Mô phỏng C — tín hiệu báo trước tới sớm hơn cú rơi. Một hàng chờ chịu tải tăng chậm từ utilization 50% lên 99% qua 300.000 bước. Theo hiện tượng critical slowing down, càng gần điểm chuyển pha, mỗi cú xóc càng lâu tan; trên đường, đó là lúc dòng xe bắt đầu lúc nhanh lúc chậm trước khi đứng hẳn. Mô phỏng đo độ dài hàng chờ, con số lên xuống cùng nhịp với độ trễ, qua hai thống kê:

  • Variance đo độ dài hàng chờ nhảy lên xuống quanh mức trung bình mạnh tới đâu.
  • Autocorrelation đo giá trị lúc này giống giá trị ngay trước bao nhiêu. Gần 1 nghĩa là hệ “ì”: cú xóc cũ chưa tan thì cú mới đã tới.

Variance là 3,2 ở utilization 60%, 11 ở 75%, 56 ở 90% và 707 ở 98%; autocorrelation ở bốn mốc đó là 0,87, 0,95, 0,985 và 0,999. Con số đáng giá nhất nằm ở mốc 75%: variance đã gấp hơn ba lần mức ở 60%. Trong khi đó, độ dài hàng chờ trung bình mới nhích từ 1,2 lên 2,6 request, vẫn nằm sát đáy biểu đồ. Trung bình chỉ đáng lo sau khoảng 95%, rất lâu sau khi hai thống kê kia rời vùng an toàn. Khoảng cách đó là thời gian bạn được tặng thêm, nếu monitoring nhìn đúng chỗ.

Critical slowing down đã được dùng làm tín hiệu báo trước trong sinh thái học và khí hậu học (Scheffer và cộng sự, 2009), và mới bắt đầu được thử với phần mềm. Cách đọc biểu đồ: ba tầng chung trục ngang utilization; tầng trên là hàng chờ trung bình, tầng giữa là variance theo thang log, tầng dưới là autocorrelation. Hai tầng dưới leo đều từ 60%, còn tầng trên chỉ bật mạnh trong vùng tô màu sau 95%.

Critical slowing down simulation: rolling variance and lag-1 autocorrelation of queue length rise well before mean latency explodes

Đào sâu: vì sao variance tăng trước, và code mô phỏng

Gần điểm chuyển pha, độ lệch của hệ ở bước sau xấp xỉ bằng $\alpha$ lần độ lệch ở bước trước, cộng một cú xóc ngẫu nhiên mới. Từ đó:

$$ \alpha = e^{-r\,\Delta t} \to 1, \qquad \sigma^2 = \frac{\mathbb{E}[\epsilon^2]}{1-\alpha^2} \to \infty $$

Đọc bằng lời: khi tốc độ hồi phục tụt về 0, hệ số “ì” tiến về 1, mẫu số tiến về 0, nên variance tăng vọt dù các cú xóc vẫn to như cũ.

  • $r$: tốc độ hệ tự kéo về cân bằng; tiến về 0 gần chuyển pha. $\Delta t$: một bước đo.
  • $\alpha$: autocorrelation giữa hai lần đo liên tiếp.
  • $\epsilon$: cú xóc mỗi bước; $\mathbb{E}[\epsilon^2]$ là độ lớn trung bình của nó.
  • $\sigma^2$: variance của độ trễ hoặc độ dài hàng chờ.

Thay số: ở $\alpha = 0{,}95$, mẫu số là 0,0975, variance gấp khoảng 10 lần độ lớn cú xóc, và một độ lệch cần khoảng 14 bước để tan một nửa. Ở $\alpha = 0{,}999$, mẫu số là 0,002, variance gấp khoảng 500 lần, và cần khoảng 690 bước.

Mô phỏng C là một hàng chờ chứ không đúng công thức trên, nên số đi cùng chiều nhưng không khớp từng chữ. Code dưới đây là lõi của script: dựng hàng chờ, tăng tải dần, rồi in trung bình, variance và autocorrelation trên 10.000 bước gần nhất ở bốn mốc tải.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import numpy as np

rng = np.random.default_rng(7)
T = 300_000                          # số bước mô phỏng
rho = np.linspace(0.50, 0.99, T)     # tải tăng chậm từ 50% lên 99%

# Hàng chờ rời rạc: mỗi bước có vài request tới và vài request được xử lý xong
arrivals = rng.poisson(rho)          # trung bình rho request tới mỗi bước
services = rng.poisson(1.0, size=T)  # trung bình 1 request được xử lý mỗi bước
q = np.zeros(T, dtype=np.int64)
cur = 0
for t in range(T):
    cur = max(0, cur + arrivals[t] - services[t])
    q[t] = cur

def lag1_autocorr(w):
    w = w - w.mean()
    return (w[1:] * w[:-1]).sum() / (w * w).sum()

WIN = 10_000                         # cửa sổ trượt: 10.000 bước gần nhất
for target in (0.6, 0.75, 0.9, 0.98):
    i = int(np.argmin(abs(rho - target)))
    w = q[i - WIN : i].astype(float)
    print(f"rho={target}: mean={w.mean():.1f}  variance={w.var():.1f}  "
          f"autocorr={lag1_autocorr(w):.3f}")

Kết quả in ra, làm tròn, khớp đúng các con số ở mạch chính.

VII. Ba việc nên làm, và cái giá của từng việc #

Một phản biện chính đáng cần trả lời trước. Capacity planning là việc tính trước cần bao nhiêu máy cho lượng tải dự kiến, và cùng lý thuyết hàng đợi, nó đã là một ngành trưởng thành. Mang vật lý vào thì được thêm gì? Tôi thấy có ba thứ:

  • Một cơ chế giải thích phần goodput bị hụt sau quá tải; mô hình hàng đợi tĩnh không có tham số nào cho capacity drop.
  • Một tín hiệu đi trước: variance và autocorrelation tăng trước, còn độ trễ trung bình tăng sau.
  • Ranh giới giữa gián đoạn và liên tục, thứ tách hệ xuống cấp từ từ, nhìn thấy được, khỏi hệ đứng hình không báo trước.

Ở nơi ba thứ đó không thêm được gì (service không lưu dữ liệu giữa hai lần gọi, timeout rộng rãi, không có dữ liệu dùng chung), capacity planning cổ điển là đủ, và nên cất bộ từ vựng nặng hơn đi.

  1. Tính sức chứa tại điểm gãy, không phải tại đỉnh load test. Load test là bài thử bơm tải giả vào hệ để đo xem nó chịu được bao nhiêu. Con số cao nhất trong load test là đỉnh của trạng thái metastable, điểm vận hành mong manh nhất mà hệ có. Tôi hiểu sức hút của con số đó, vì nó đẹp nhất để ghi vào kế hoạch. Chỉ có điều lên kế hoạch theo “90% của đỉnh” là xếp cả cụm máy đứng ngay mép vách.

    Hãy neo sức chứa tại điểm gãy. Điểm gãy là chỗ đường cong độ trễ bắt đầu bẻ ngoặt và variance bắt đầu tăng; trong các mô phỏng ở đây, nó nằm quanh utilization 70–75%. Mọi thứ phía trên là thời gian đi vay, và nên được ghi vào kế hoạch đúng như vậy. Câu nên hỏi đội kỹ thuật: con số sức chứa trong kế hoạch đo ở điểm gãy hay ở đỉnh load test?

  2. Đặt cảnh báo lên độ dao động, không chỉ lên trung bình. Thêm variance và autocorrelation của độ trễ hoặc độ dài hàng chờ, tính trên một khoảng ngắn gần nhất, vào bộ cảnh báo, hiệu chỉnh theo mức nền của từng service. Vùng đông mà vẫn chạy cũng dao động mạnh mà vẫn sống được, nên tín hiệu cần nhìn là autocorrelation đang tiến về 1 và variance đang dốc lên, không phải một mức tuyệt đối.

    DAGOR, hệ thống chống quá tải của WeChat, là bằng chứng từ hệ thống thật. Nó quyết định nhận hay từ chối request ngay từ cửa vào dựa trên thời gian chờ trung bình trong hàng đợi. Ngưỡng cắt được đặt ở 20 ms, đúng tại điểm gãy, trong khi timeout là 500 ms. Vượt ngưỡng, nó bỏ ngay 50% request ưu tiên thấp nhất tại service đầu tiên đón request. Dưới tải gấp 10 lần dịp Tết Nguyên đán trên 3.000 service nhỏ, cách này giữ tỉ lệ thành công cao gấp 1,5 đến 2 lần so với khi không bật chống quá tải.

    Câu nên hỏi đội kỹ thuật: cảnh báo hiện tại có nhìn vào độ dao động của độ trễ, hay chỉ nhìn trung bình?

  3. Đã kẹt thì cắt tải thật sâu, rồi mở lại từng bậc. Ở trạng thái kẹt, việc gọt 10–20% tải chỉ giữ hệ đúng chỗ capacity drop đang níu giữ nó: tải giảm, nhưng năng lực xử lý cũng đã tụt theo. Muốn thoát khỏi trạng thái kẹt, bạn phải cắt lượng request nhận vào xuống thật sâu dưới sức chứa bình thường — giảm 60–80%, hoặc ngắt về 0 như cách Roblox từng làm. Giữ mức cắt đó đủ lâu cho hàng chờ cạn sạch và tranh chấp tài nguyên tan biến, rồi mới mở tải lại từng bậc. Ở quán cơm, đó là bấm tạm ngưng nhận đơn trên app cho tới khi bếp nấu xong số đơn còn người chờ.

    Việc này cần một kill switch dựng sẵn và một runbook đã diễn tập, không phải thứ ứng biến giữa sự cố. Kill switch là công tắc cắt tải bằng một thao tác. Runbook là tài liệu hướng dẫn từng bước xử lý một loại sự cố. Câu nên hỏi đội kỹ thuật: nếu hệ kẹt, ai được bấm công tắc, và lần diễn tập gần nhất là khi nào?

Biện phápĐược gìTrả giá gì
Tính sức chứa theo điểm gãyCụm máy không đứng trên trạng thái metastable; đợt tải tăng đột ngột rơi vào vùng đông mà vẫn chạyTốn nhiều máy hơn cho mỗi đơn vị tải; phải đo lại điểm gãy khi kiểu tải thay đổi
Cảnh báo theo varianceThêm hàng phút cảnh báo nhờ tín hiệu đi trước; chuông kêu trước khi trung bình nhúc nhíchTốn công đo mức nền cho từng service; ngưỡng tuyệt đối sẽ báo nhầm mỗi lần hệ vào vùng đông mà vẫn chạy
Công tắc cắt tải sâuLối thoát đáng tin duy nhất khỏi trạng thái kẹt; biến cái bẫy 73 giờ thành một vòng cắt tải rồi hồi phụcChủ động từ chối phần lớn người dùng thật; cần phân lớp ưu tiên chính xác và một lộ trình mở lại đã được thử

VIII. Tài liệu tham khảo #

  • Barlović, R., Santen, L., Schadschneider, A., & Schreckenberg, M. (1998). Metastable states in cellular automata for traffic flow.
  • Goldenfeld, N. (1992). Lectures on Phase Transitions and the Renormalization Group. Addison-Wesley.
  • Gunther, N. (2007). Guerrilla Capacity Planning. Springer.
  • Jacobson, V., & Karels, M. (1988). Congestion Avoidance and Control.
  • Kerner, B. S. (2004). The Physics of Traffic. Springer.
  • Kleinrock, L. (1975). Queueing Systems, Volume 1: Theory. Wiley.
  • Landau, L. D., & Lifshitz, E. M. (1980). Statistical Physics, Part 1 (3rd ed.). Pergamon Press.
  • Nagel, K., & Schreckenberg, M. (1992). A cellular automaton model for freeway traffic.
  • Roblox Engineering (2022). Roblox Return to Service 10/28–10/31 2021.
  • Scheffer, M., Bascompte, J., Brock, W. A., et al. (2009). Early-warning signals for critical transitions.
  • Stumpf, M. P. H., & Porter, M. A. (2012). Critical Truths About Power Laws. Science, 335(6069), 665–666.
  • Yang, C. N., & Lee, T. D. (1952). Statistical Theory of Equations of State and Phase Transitions. I. Theory of Condensation. Physical Review, 87(3), 404–409.
  • Zhou, H., et al. (2018). Overload Control for Scaling WeChat Microservices. ACM Symposium on Cloud Computing (SoCC).

  1. Cụ thể: HikariCP, thư viện quản lý connection pool phổ biến của Java, cấu hình maximumPoolSize=10. ↩︎

  2. Landau (1937) viết hình ảnh này thành toán: “độ cao” là năng lượng tự do, viết thành đa thức theo tham số trật tự, con số cho biết hệ ở pha nào. Hệ số bậc bốn dương thì chuyển pha liên tục. Hệ số đó âm, cùng số hạng bậc sáu giữ đa thức có đáy, thì trong một khoảng điều kiện có cùng lúc thung lũng ở 0 và thung lũng khác 0: đó là chuyển pha đột ngột, còn gọi là chuyển pha bậc nhất. Xem Landau & Lifshitz (1980). ↩︎

  3. Goldenfeld (1992); Yang và Lee (1952). Stumpf và Porter (2012) thêm một cảnh báo: đừng vội kết luận mọi hệ tuân theo cùng một quy luật từ dữ liệu hữu hạn, nhiều nhiễu. ↩︎

  4. Kerner (2004) gọi trạng thái này là synchronized flow, trong lý thuyết ba pha dựng từ dữ liệu cao tốc Đức, và dùng nó để phê phán bức tranh hai trạng thái là quá gọn. ↩︎

  5. Theo báo cáo của Roblox (2022): streaming dồn tranh giành vào một Go channel duy nhất, tức một kênh chuyển dữ liệu giữa các luồng trong ngôn ngữ Go, và đẩy BoltDB, cơ sở dữ liệu nhúng trong Consul, vào chế độ dọn dẹp bất thường. Các hệ phụ thuộc bị timeout theo gồm Vault và Nomad. ↩︎