Hình dung một chuỗi domino: dịch vụ đổ ở đầu này, và mãi tận cuối hàng, cái domino cuối cùng mới hất chuông trên bàn người trực ban. Ai cũng chăm chăm nhìn cái chuông và tự hỏi "sao nó reo chậm thế", trong khi câu trả lời nằm ở toàn bộ hàng domino ở giữa — mỗi quân có độ trễ riêng, và vài quân thì không ai chỉnh được. Bài này tôi đo từng quân một.
Ba mươi ba phần trước của sê-ri đều đo chi phí: log tốn bao nhiêu byte, trace tốn bao nhiêu CPU, bảng điều khiển tốn bao nhiêu mili-giây. Phần này đo một thứ khác hẳn — thời gian. Cụ thể là khoảng cách giữa hai mốc mà mọi báo cáo sự cố đều phải ghi: lúc dịch vụ bắt đầu hỏng, và lúc người trực ban biết chuyện đó. Khoảng cách ấy hay bị bàn như một chỉ số văn hoá, nhưng phần lớn của nó là số học của đường ống cảnh báo — và số học thì đo được. (Đo trong container dùng một lần, không có phần cứng đặc biệt nào.)
Bảng số liệu
Hệ đo dựng trong container dùng một lần: một dịch vụ giả phơi chỉ số dich_vu_hong bật tắt được qua HTTP, Prometheus thu thập mỗi 15 giây và đánh giá luật mỗi 15 giây, Alertmanager gom nhóm rồi gọi webhook, và một máy nhận webhook ghi đúng khoảnh khắc chuông reo. Phép đo: ghi mốc, bật hỏng, đợi chuông.
for |
group_wait |
Nhanh nhất | Trung vị | Chậm nhất | Biên độ | Số lần |
|---|---|---|---|---|---|---|
0s |
0s |
6,3 s | 14,6 s | 20,0 s | 13,7 s | 8 |
0s |
30s (mặc định) |
49,7 s | 50,3 s | 55,5 s | 5,8 s | 5 |
1m |
30s |
95,4 s | 105,4 s | 107,7 s | 12,3 s | 4 |
2m |
30s |
160,7 s | 162,6 s | 175,1 s | 14,4 s | 3 |
Dòng cuối là cấu hình mà rất nhiều đội đang chạy thật — for: 2m để cảnh báo khỏi kêu vì một cú giật nhất thời, group_wait để nguyên mặc định. Nó cho 2 phút 43 giây, gấp 11,1 lần dòng đầu tiên.
Cái sàn bạn không vặn được
Điều đầu tiên làm tôi dừng lại là dòng đầu bảng — cấu hình cực đoan, for: 0s, group_wait: 0s, vặn hết cỡ về phía nhanh. Vậy mà nó vẫn mất trung vị 14,6 giây, có lần tới 20,0 giây. Không một tuỳ chọn nào trong luật cảnh báo hay trong Alertmanager rút ngắn được khoảng này, vì nó đơn giản không nằm ở đó: nó là scrape_interval cộng evaluation_interval. Sự cố xảy ra ngay sau một lần thu thập thì phải đợi hết 15 giây mới có mẫu, rồi có thể đợi thêm gần 15 giây nữa mới tới lượt đánh giá luật. Đó là một cái sàn cứng, xây sẵn trong kiến trúc.
Ở cấu hình mặc định phổ biến nhất — không đặt for, để group_wait nguyên 30 giây — tổng là 50,3 giây, trong đó 14,6 giây là sàn không đụng tới được. Gần ba phần mười thời gian bạn không có quyền gì với nó.
Điều thứ hai làm tôi yên tâm về phép đo: các núm vặn cộng đúng như khai, sai số nằm gọn trong một ô lưới. Đây là phép kiểm chứng tôi tính trước khi nhìn số:
| Bước | Khai báo thêm | Trung vị thực tế tăng | Lệch |
|---|---|---|---|
thêm group_wait: 30s |
30 s | 35,7 s | +5,7 s |
thêm for: 1m |
60 s | 55,1 s | −4,9 s |
nâng lên for: 2m |
60 s | 57,2 s | −2,8 s |
Cả ba độ lệch đều nhỏ hơn 15 giây, tức nằm gọn trong một ô của lưới đánh giá. Không có chi phí ẩn nào đáng kể; cái bạn khai là cái bạn nhận, cộng thêm phần làm tròn lên lưới.
Vì sao biên độ lớn đến vậy
Đường đi của một cảnh báo có sáu chặng, nhưng chỉ hai chặng nghe lời bạn: for và group_wait. Hai chặng đầu — chờ lần thu thập kế tiếp và chờ lần đánh giá luật kế tiếp, mỗi chặng 0 đến 15 giây — là nguồn của toàn bộ biên độ. Ở dòng đầu bảng, biên độ 13,7 giây gần đúng bằng một scrape_interval: cùng một sự cố, cùng một cấu hình, lặp lại tám lần lại cho ra từ 6,3 đến 20,0 giây, chỉ vì nó rơi vào chỗ khác nhau trên lưới. Nên "thời gian phát hiện" của một sự cố đơn lẻ gần như vô nghĩa: nó là một mẫu rút từ một phân bố rộng bằng cả chu kỳ thu thập. Đừng bao giờ tin một con số phát hiện đơn lẻ.
Cái mà chờ lâu mua lại cho bạn
Bảng trên chỉ có một mặt: for càng lớn thì càng chậm. Nhưng không ai đặt for: 2m vì thích chậm. Để đo mặt kia, tôi cho ba mức for chạy đồng thời trên cùng một Prometheus, cùng một chuỗi dữ liệu — ba luật khác nhau nhìn chung một chỉ số, nên chúng thấy y hệt nhau và khác biệt duy nhất là for.
Kịch bản: bốn sự cố giả (hỏng 30 giây rồi tự khỏi, cách nhau 30 giây), sau đó một sự cố thật (hỏng liên tục hơn ba phút).
| Luật | for |
Kêu vì sự cố giả | Bắt được sự cố thật | Tổng số lần chuông |
|---|---|---|---|---|
Ngay |
0s |
4 lần | có | 5 |
Cho1Phut |
1m |
0 lần | có | 1 |
Cho2Phut |
2m |
0 lần | có | 1 |
Ba lần chạy độc lập cho ra bảng giống hệt nhau, không lệch một con số nào.
Không đặt for thì trong bảy phút bạn nhận năm lần chuông, bốn trong đó là rác — 80% số lần bị đánh thức là cho chuyện tự khỏi trước khi bạn kịp mở laptop. Đó là cái giá thật của việc biết nhanh.
Nhưng con số đáng chú ý hơn nằm ở dòng giữa: for: 1m đã chặn sạch cả bốn sự cố giả, y hệt for: 2m, mà nhanh hơn 57 giây. Vì for không hoạt động theo kiểu càng dài càng sạch — nó chỉ cần dài hơn cơn giật. Cơn giật ở đây kéo dài 30 giây, nên mọi giá trị for vượt qua 30 giây đều cho kết quả như nhau, và mỗi giây đặt thêm quá mốc đó là một giây bạn trả mà không mua được gì. Nên for không phải núm vặn theo cảm tính: hãy nhìn lịch sử của chính chỉ số đó, hỏi cơn giật vô hại dài nhất từng kéo bao lâu, đặt for nhỉnh hơn con số ấy, rồi dừng.
Nghĩa là gì trong thực tế
Chính sách leo thang thường viết kiểu "gọi người thứ nhất, sau 5 phút không ai nhận thì gọi người thứ hai". Con số 5 phút đó được tính từ lúc chuông reo, không phải từ lúc dịch vụ hỏng. Với for: 2m và group_wait mặc định, người thứ hai bị gọi ở phút thứ 7 phút 43 giây kể từ lúc sự cố bắt đầu, chứ không phải phút thứ 5. Nếu chỉ tiêu dịch vụ của bạn nói "khôi phục trong 10 phút" thì gần 80% ngân sách ấy đã tiêu hết trước khi người thứ hai kịp chạm vào bàn phím.
Ba điều rút ra được:
- Đừng hạ
scrape_intervalđể cảnh báo nhanh hơn. Nó tốn lưu trữ trên mọi chỉ số (phần 19 đã đo) mà chỉ mua lại vài giây. Cần nhanh thật thì tách một job chu kỳ ngắn cho vài chỉ số sống còn. - Hạ
group_waitrẻ hơn hạfornhiều. Nó cắt 30 giây mà không đánh đổi gì về nhiễu. - Tính thời gian phát hiện từ mốc của dữ liệu, đừng tính từ lúc chuông reo — chuông đã trễ hằng phút một cách có hệ thống.
Bốn lần đo hỏng, và cái duy nhất tự tố cáo mình
Tôi đo hỏng bốn lần trước khi có bảng ở trên, và ba trong bốn lần cho ra những con số trông hoàn toàn bình thường — đó mới là phần đáng sợ.
Lần một tới lần ba: bốn tiến trình đo chạy chồng lên nhau. Mỗi lần thấy kết quả lạ, tôi khởi động lại phép đo mà quên dừng lần trước. Cả bốn cùng tạo container tên prom và am, cùng xoá của nhau giữa chừng. Kết quả: cấu hình nhanh nhất cho 164,0 giây, còn for: 2m cho 7,9 giây — ngược đời hoàn toàn. Dấu hiệu duy nhất là những dòng Conflict. The container name "/am" is already in use lẫn giữa output, mà tôi đã lướt qua ba lần vì đang mải nghi ngờ Alertmanager. Bài học: khi phép đo cho số vô lý, việc đầu tiên là đếm xem có bao nhiêu bản của nó đang chạy, chứ không phải lao vào sửa cấu hình đối tượng đo.
Lần bốn: thời gian âm. Máy nhận webhook tích luỹ bản ghi suốt cả lần chạy và không bao giờ được xoá, trong khi mỗi cấu hình lại dùng lại đúng tên cảnh báo DichVuHong1..5. Nên cấu hình thứ hai vừa bắt đầu đã tìm thấy ngay bản ghi của cấu hình thứ nhất — sớm hơn cả mốc bắt đầu của nó. Ra −126,1 giây. Đây là lỗi duy nhất trong cả bốn không thể trốn được: một khoảng thời gian âm là chuyện không tồn tại, nên nó tự chỉ vào chính mình. Ba lỗi kia đều nằm trong khoảng hợp lý và hẳn đã lặng lẽ đi thẳng vào bài viết nếu tôi không có một con số cắt ngang để đối chiếu.
Và một lần suýt lọt. Bộ số đầu tiên nhìn có vẻ sạch cho ra ba lần liên tiếp đúng 29,8 giây. Trùng khít ba lần là thứ không nên xảy ra với một đại lượng có biên độ 15 giây. Lý do: mỗi lần đo mất xấp xỉ một bội số của 15 giây, nên mốc bắt đầu của lần sau luôn rơi vào cùng một pha của lưới. Tôi đang lấy mẫu một điểm mà cứ tưởng đang lấy mẫu cả phân bố. Sửa bằng cách chờ ngẫu nhiên 0–15 giây trước mỗi lần đo — và biên độ thật, 13,7 giây, mới chịu hiện ra.
Cùng một cái sàn ẩn, đổi ngăn xếp
Chuyện "có một độ trễ tối thiểu chôn trong kiến trúc mà không tham số nào ở tầng trên chỉnh được" không riêng gì Prometheus — nó là một mẫu lặp ở mọi hệ thống phân tầng.
Trong một ứng dụng Java, người ta có thể vặn đủ mọi timeout ở tầng ứng dụng để giảm độ trễ, rồi vẫn vấp một cái sàn nằm bên dưới: pause của bộ dọn rác. Một cú GC dừng-thế-giới hàng chục mili-giây là thứ không @Timeout nào ở tầng nghiệp vụ rút ngắn được, vì nó không sống ở tầng đó — y như 14,6 giây kia không sống trong luật cảnh báo. Muốn chạm vào nó phải xuống tầng khác hẳn (đổi collector, chỉnh heap).
Trong Go, cái sàn quen thuộc là độ chi tiết của scheduler và timer. Bạn đặt time.Sleep(1*time.Millisecond) nhưng thực tế ngủ lâu hơn, vì runtime chỉ đánh thức goroutine ở những mốc mà nó kiểm timer; giống hệt sự cố phải chờ tới ô lưới đánh giá kế tiếp. Không API nào ở tầng trên xoá được độ hạt đó.
Còn ở cơ sở dữ liệu, cái sàn ẩn kinh điển là chu kỳ flush/checkpoint và độ trễ replica. Bạn có thể tối ưu truy vấn đến từng mili-giây, nhưng một transaction cần bền vững vẫn phải đợi WAL được fsync, và một đọc từ replica vẫn trễ sau primary đúng bằng độ trễ nhân bản — những con số quyết định bởi tầng lưu trữ và cấu hình sao chép, không phải bởi câu SQL của bạn.
Mẫu số chung: độ trễ tổng của một hệ phân tầng luôn có một phần sàn do nhịp của tầng dưới quy định, và mọi núm vặn ở tầng trên chỉ chạm được phần nằm trên cái sàn đó. Muốn biết hệ của mình thật sự nhanh cỡ nào, đừng cộng các timeout bạn đặt — hãy đi tìm cái nhịp cơ sở (chu kỳ thu thập, chu kỳ GC, chu kỳ checkpoint) mà bạn không điều khiển, vì nó mới là con số cuối cùng bạn không thể vượt qua.
Muốn tính cái sàn của chính mình, mở cấu hình Prometheus và Alertmanager ra, cộng ba con số: scrape_interval + for của cảnh báo quan trọng nhất + group_wait, rồi cộng thêm một scrape_interval nữa cho chặng đánh giá luật. Đó là thời gian tối thiểu trước khi có người biết hệ thống của bạn đang hỏng — đem so nó với con số phút trong chính sách leo thang, và với chỉ tiêu khôi phục bạn đã hứa với ai đó.