Hình dung bạn ghi lại nhịp tim của một bệnh nhân suốt một tháng, rồi để tiết kiệm giấy, bạn tóm mỗi ngày thành một con số: nhịp trung bình. Trên tờ giấy gọn gàng ấy, ngày bệnh nhân lên cơn đau tim — tim đập vọt lên 180 rồi tụt — trông y hệt mọi ngày khác, vì trung bình cả ngày vẫn quanh 75. Dữ liệu vẫn còn một con số cho ngày đó; cái mất là cái đỉnh, đúng thứ duy nhất đáng xem. Giảm mẫu chỉ số bằng trung bình chính là cách tóm tắt đó, và bài này tôi đo xem nó xoá đi bao nhiêu.

Phần trước đo giá của việc nhìn. Phần này đo giá của việc giữ: một mẫu chiếm bao nhiêu byte sau khi nén, giãn chu kỳ thu thập tiết kiệm được bao nhiêu, và — câu hỏi ít ai đặt — giảm mẫu để tiết kiệm chỗ thì bạn mất đúng cái gì. (Đo bằng ba Prometheus 2.54.1 thật chạy song song, không có phần cứng đặc biệt.)

Bảng số liệu

Ba Prometheus 2.54.1 chạy song song, cùng thu thập một exporter duy nhất, khác nhau đúng một tham số là chu kỳ thu thập. Chạy 180 giây.

Nén, đo trên bản chu kỳ 1 giây (3 881 303 mẫu):

Dung lượng Byte mỗi mẫu
WAL — nhật ký ghi trước, chưa nén 26,84 MB 7,25
Chunk — đã nén 8,00 MB 2,16

Nén được 3,36 lần.

Đổi chu kỳ thu thập — số chuỗi giống hệt nhau ở cả ba:

Chu kỳ Số chuỗi Số mẫu Tổng thư mục So với 15s
1 giây 21 563 3 881 303 34,87 MB 17,3×
5 giây 21 563 754 668 5,55 MB 2,7×
15 giây 21 563 258 168 2,02 MB 1,0×

Giảm mẫu — một chuỗi 3 600 điểm với ba đợt chậm, đỉnh cao nhất 939,3 ms, gộp 60 điểm thành 1:

Cách gộp Đỉnh còn lại Giữ được
Trung bình (avg) 149,9 ms 16%
Nhỏ nhất (min) 13,7 ms 1%
p95 trong cửa sổ 850,8 ms 91%
Lớn nhất (max) 939,3 ms 100%

Một đợt chậm phải kéo dài bao lâu thì avg cửa sổ 60 giây còn thấy:

Độ dài đợt avg So với nền
1 giây 31,5 ms 1,5×
5 giây 84,6 ms 4,0×
10 giây 146,4 ms 6,9×
30 giây 406,7 ms 19,2×

Bốn con số đáng nhớ

Một mẫu chỉ tốn 2,16 byte. Đó là một cặp dấu thời gian và giá trị — về lý thuyết 16 byte — được nén xuống còn hơn một phần bảy. Prometheus không lưu giá trị mà lưu độ lệch so với điểm trước, và với chuỗi thu thập đều thì độ lệch của dấu thời gian gần như luôn bằng nhau nên gần như miễn phí.

Giãn chu kỳ thu thập là đòn bẩy gần như tuyến tính. Từ 1 giây sang 15 giây: số mẫu giảm 15,0 lần, dung lượng giảm 17,3 lần. Vì số chuỗi giống hệt nhau ở cả ba bản, toàn bộ chênh lệch đến từ số mẫu — nghĩa là ở quy mô này chi phí của từng mẫu chi phối, chứ không phải chi phí cố định của từng chuỗi.

Nhưng giảm mẫu thì không miễn phí, và cái giá không nằm ở chỗ người ta nghĩ. Gộp 60 điểm thành một bằng trung bình giữ lại đúng 16% chiều cao đỉnh: một đợt chậm 939 ms hiện ra thành 150 ms. Dữ liệu vẫn còn, biểu đồ vẫn có đường, chỉ là đỉnh đã bị san phẳng và không ai biết. Đúng cái tờ giấy nhịp tim tóm tắt.

Đợt càng ngắn thì trung bình càng mù. Một đợt chậm kéo dài 1 giây trong cửa sổ 60 giây chỉ làm trung bình nhích lên 1,5 lần nền — nằm trong nhiễu. Phải kéo dài ít nhất 30 giây thì nó mới rõ ràng.

Vì sao nén rẻ mà giảm mẫu đắt

Nén của Prometheus dựa trên hai quan sát về dữ liệu chuỗi thời gian: dấu thời gian tăng đều, và giá trị thường thay đổi ít giữa hai mẫu liền nhau. Nó lưu độ lệch của độ lệch cho dấu thời gian — với chu kỳ đều thì con số đó là 0 và chỉ tốn vài bit — và lưu phần khác biệt nhị phân cho giá trị. Kết quả là 2,16 byte cho một mẫu mà biểu diễn thô cần 16 byte. Điều đó cũng giải thích vì sao con số này không phải hằng số: chuỗi càng đều thì càng nén tốt. Một bộ đếm tăng chậm nén cực tốt; một chỉ số nhảy ngẫu nhiên thì tệ hơn nhiều.

Còn chuyện giảm mẫu mất đỉnh là số học đơn giản. Trong cửa sổ 60 giây có 10 giây chậm ở 800 ms và 50 giây bình thường ở 20 ms, trung bình là (50 × 20 + 10 × 800) / 60 ≈ 150 ms. Trung bình làm đúng việc của nó — nó tính giá trị đại diện — và giá trị đại diện của một cửa sổ chủ yếu bình thường thì phải gần mức bình thường.

Đây là cùng một cơ chế đã thấy ở phần 12 khi trung bình che mất đuôi phân bố, chỉ khác là lần này nó che theo trục thời gian thay vì theo trục phân vị. Và nó nguy hiểm hơn, vì đây là phép biến đổi không đảo ngược được: khi dữ liệu độ phân giải cao đã bị xoá, không cách nào lấy lại đỉnh.

Nghĩa là gì trong công việc hằng ngày

  • Ước lượng dung lượng bằng 2,16 byte mỗi mẫu, và nhân ra. 21 563 chuỗi thu thập mỗi 15 giây là 1 438 mẫu mỗi giây, tức 5,18 triệu mỗi giờ và khoảng 256 MB mỗi ngày. Con số đó còn dễ chịu — cho tới khi số chuỗi tăng gấp mười, và lúc đó là 2,5 GB mỗi ngày cho đúng một chỉ số.
  • Giãn chu kỳ trước khi nghĩ tới giảm mẫu. Đổi từ 15 giây sang 30 giây cắt một nửa dung lượng và không mất gì ngoài độ phân giải thời gian — trong khi giảm mẫu bằng trung bình cắt được nhiều hơn nhưng xoá mất thông tin về đỉnh.
  • Nếu giảm mẫu, hãy giữ max song song với avg. Giữ cả avg, max và min cho ra 180 điểm thay vì 3 600 — vẫn giảm 20 lần thay vì 60 lần, và đó là cái giá hợp lý để không mù trước sự cố ngắn. Các hệ lưu trữ dài hạn quen thuộc đều làm đúng như vậy, và giờ bạn biết vì sao.
  • Đừng nhìn WAL rồi tính ngân sách đĩa. WAL là 26,84 MB trong khi dữ liệu thật là 8,00 MB — chênh 3,36 lần. Đây là lần thứ hai trong sê-ri này thư mục ghi-trước suýt làm hỏng một kết luận về dung lượng.
  • Khi đọc biểu đồ dữ liệu cũ, hãy nhớ nó có thể đã bị giảm mẫu. Một biểu đồ sáu tháng trông phẳng lặng không có nghĩa là sáu tháng qua không có sự cố nào — nó có thể nghĩa là mọi sự cố đều ngắn hơn cửa sổ gộp.

Cùng một cái bẫy "tóm tắt xoá mất đỉnh", đổi ngăn xếp

Chuyện "nén thì đảo ngược được và rẻ, còn giảm mẫu bằng trung bình thì không đảo ngược và âm thầm xoá thông tin đuôi" không riêng gì Prometheus — nó là ranh giới chung giữa nén và tóm tắt mất mát.

Trong thế giới Java, đúng bài học này hiện ra khi cấu hình rollup của metric backend. Micrometer đẩy metric lên các hệ như Graphite/Datadog, và ở đó chính sách rollup dài hạn quyết định số phận đuôi: rollup bằng average xoá spike y hệt bài này, nên chuẩn là giữ thêm max (và cả một percentile) cho các chỉ số độ trễ. Ai cấu hình Micrometer với publishPercentiles nhưng rồi để backend rollup bằng avg là tự tay xoá đúng cái mình cất công đo.

Trong Go — ngôn ngữ viết Prometheus — cùng nguyên tắc là lý do các hệ như Thanos/Cortex/Mimir (viết bằng Go) khi downsample luôn lưu song song nhiều hàm gộp (count, sum, min, max) cho mỗi khối, chứ không chỉ một giá trị. Chính vì "avg là phép mất mát không đảo ngược" mà thiết kế storage phải giữ đủ để tái dựng cả trung bình lẫn đỉnh sau này.

Còn ở cơ sở dữ liệu, họ hàng gần nhất là materialized view / continuous aggregate (như TimescaleDB): một continuous aggregate gộp theo giờ bằng avg(latency) là một cái bẫy dung lượng-đổi-độ-chính-xác kinh điển, và cách đúng luôn là thêm max(latency) (và percentile) vào cùng aggregate. Cùng câu chuyện, chỉ ở tầng SQL.

Mẫu số chung: có hai kiểu thu nhỏ dữ liệu — nén (đảo ngược được, chỉ đổi cách lưu, giữ nguyên mọi giá trị) và tóm tắt mất mát (không đảo ngược, thay nhiều điểm bằng một, và cái mất luôn là các cực trị) — và người ta hay nhầm lẫn cái giá của hai kiểu. Nén gần như miễn phí về thông tin; tóm tắt bằng trung bình thì xoá vĩnh viễn đúng cái đỉnh mà giám sát sinh ra để bắt. Nên câu hỏi đúng trước mỗi chính sách lưu trữ dài hạn không phải "cắt được bao nhiêu byte" mà là "sau khi cắt, một sự cố ngắn của quá khứ có còn nhìn thấy được không, hay tôi vừa xoá bằng chứng của chính thứ mình muốn giám sát".

Muốn tự thấy hai biểu đồ sáu-tháng-sau khác nhau ra sao, chạy thử:

docker run --rm python:3.12-slim python - <<'EOF'
import random, statistics
random.seed(5)
lat = [random.lognormvariate(-3.9, 0.25) for _ in range(3600)]   # nen ~20 ms
for s in (600, 1800, 3000):                                      # ba dot cham
    for i in range(s, s+10): lat[i] = random.lognormvariate(-0.25, 0.10)

def gop(f, w=60): return [f(lat[i:i+w]) for i in range(0, len(lat), w)]
dinh = max(lat)
print("dinh that            %8.1f ms" % (dinh*1000))
for ten, f in (("gop bang avg", statistics.fmean), ("gop bang max", max)):
    print("%-20s %8.1f ms   giu duoc %3.0f%%"
          % (ten, max(gop(f))*1000, 100*max(gop(f))/dinh))
EOF

Hai dòng cuối là hai biểu đồ mà bạn sẽ nhìn sáu tháng sau. Một trong hai vẫn còn sự cố; cái kia thì không, và không có gì trên màn hình cho biết nó đã từng có.