Hỏi "cái vali này nặng bao nhiêu" tưởng đơn giản, cho tới khi bạn nhận ra có ba câu trả lời khác nhau: cân cả vali lẫn không khí bên trong, cân chỉ đồ thật, hay cân đồ đã xếp gọn sẵn sàng gửi đi. Với một cái vali thường thì ba con số gần bằng nhau. Nhưng với một cái vali bơm phồng — vỏ khai 20 lít mà chỉ đựng một chiếc áo — ba con số chênh nhau cả chục lần, và ai tin nhầm con số đầu sẽ tưởng mình mang cả tủ quần áo. Kho lưu trữ trace của Jaeger đúng là cái vali bơm phồng đó, và bài này suýt nữa tôi báo cáo nó nặng gấp 432 lần sự thật.

Bốn phần trước đo trace từ phía ứng dụng: cái nó chỉ ra, cái nó tốn, cái gãy khi mất ngữ cảnh, và cách lấy mẫu. Phần này đo phía sau: hai hệ lưu trữ trace thật, cùng một tập 20 000 trace, đo dung lượng và tốc độ truy vấn. Kết quả so sánh thì rõ ràng — nhưng thứ tôi học được nhiều nhất lại là bốn lần cả hai hệ nói dối tôi theo bốn kiểu khác nhau. (Đo bằng Jaeger 1.62 và Tempo 2.6.1 thật trong container, không có phần cứng đặc biệt.)

Bảng số liệu

Jaeger 1.62 với lưu trữ Badger, Tempo 2.6.1 với lưu trữ local. Cùng một bộ sinh dữ liệu nạp 20 000 trace, 80 000 span vào từng hệ qua OTLP/HTTP.

Nạp vào:

Jaeger 42 254 span/giây
Tempo 42 816 span/giây

Gần như bằng nhau.

Dung lượng — và đây là chỗ phải cẩn thận:

Cách đo Jaeger
du -sb trên cả thư mục 2 193 MB
Số block thực sự chiếm 68,50 MB
Chỉ dữ liệu đã ổn định (.sst) 16,48 MB

Ba con số cho cùng một câu hỏi, chênh nhau 133 lần. Tempo, đo tương ứng: 5,05 MB (parquet 4,95 MB + bloom filter 0,10 MB). So dữ liệu đã ổn định với nhau: 16,48 so với 5,05 MB — Tempo nhỏ hơn 3,3 lần.

Truy vấn (trung vị 3 lần, mỗi lần 5 lượt):

Câu hỏi Jaeger Tempo Kết quả
Lấy một trace theo trace_id 0 ms 5 ms 4 span / 1 879 byte
Tìm theo dịch vụ, giới hạn 20 24 ms 3 ms 20 trace cả hai
Tìm theo thuộc tính status_code=500 2 ms 3 ms 20 trace cả hai

Đánh đổi rõ, và con số suýt lừa tôi

Đánh đổi rất rõ: Tempo nhỏ hơn 3,3 lần và tìm theo dịch vụ nhanh hơn 8 lần; Jaeger lấy theo trace_id nhanh hơn. Điều đó khớp với thiết kế — Jaeger dùng kho khoá-giá trị nên tra theo khoá là thao tác rẻ nhất của nó; Tempo lưu cột trong parquet nên quét theo thuộc tính là thứ nó làm tốt.

Nhưng con số đáng nhớ nhất không nằm trong bảng so sánh. du -sb trên thư mục Jaeger cho 2 193 MB. Con số đó sai không phải một chút mà sai 133 lần, vì Badger cấp phát trước một file value log khai báo 2 GB — và file đó là file thưa, thực sự chiếm đúng 4 KB trên đĩa. Nếu tôi tin con số đầu tiên mình lấy được, bài này sẽ kết luận Jaeger tốn 432 lần Tempo. Đúng cái vali bơm phồng.

Đây là lần thứ ba trong sê-ri cùng một cái bẫy. Loki với thư mục WAL, Prometheus với chunk chưa kịp ghi, và giờ Badger với file cấp phát trước. Ba hệ khác nhau, ba cơ chế khác nhau, cùng một kết luận: du trên thư mục dữ liệu không phải là phép đo dung lượng.

Vì sao ba con số lệch nhau

Badger là kho khoá-giá trị kiểu LSM. Nó tách dữ liệu thành hai phần: khoá và chỉ mục nằm trong file .sst, còn giá trị nằm trong file .vlog. File value log được tạo sẵn ở kích thước tối đa ngay lúc khởi động để tránh phải mở rộng file khi ghi. Hệ thống tệp hiện đại xử lý chuyện đó bằng file thưa: file khai báo 2 GB nhưng chỉ cấp block khi có dữ liệu thật. du -sb đọc kích thước khai báo. du không có cờ đó đếm số block thực cấp. Với file thưa, hai con số chênh nhau tuỳ ý.

Còn 52 MB memtable trong con số 68,50 MB: đó là bộ đệm ghi trong bộ nhớ được ánh xạ ra file, và nó sẽ được nén xuống .sst khi đầy. Nó là chi phí tức thời, không phải chi phí lưu trữ dài hạn — cùng bản chất với WAL của Loki.

Về phía truy vấn, Tempo lưu mỗi block thành một file parquet có bloom filter đi kèm. Tìm theo thuộc tính nghĩa là đọc đúng cột cần và bỏ qua phần còn lại, nên 3 ms cho 20 kết quả. Jaeger phải tra chỉ mục rồi lấy từng trace theo khoá, và với truy vấn "20 trace gần nhất của dịch vụ" thì đó là 20 lần tra — 24 ms.

Bốn lần "không có dữ liệu", bốn nguyên nhân khác nhau

Đây là phần tôi bỏ nhiều thời gian nhất, và là phần hữu ích nhất cho ai sắp dựng Tempo. Bốn lần liên tiếp Tempo trả về rỗng, mỗi lần một nguyên nhân khác, và cả bốn đều biểu hiện y hệt nhau.

1. max_traces_per_user mặc định là 10 000. Tôi nạp 20 000 trace, Tempo im lặng từ chối khoảng một nửa và chỉ ghi LIVE_TRACES_EXCEEDED vào log. Bên gửi nhận về HTTP 200. Cách phát hiện: dung lượng Tempo là 2,61 MB — bằng đúng một nửa con số 5,05 MB sau khi sửa.

2. blocklist_poll mặc định là 5 phút. Block đã ghi xuống đĩa — tôi kiểm được bằng tempo_ingester_blocks_flushed_total = 1 — nhưng bộ truy vấn chỉ quét lại danh sách block mỗi 5 phút. Trong khoảng đó, mọi truy vấn đều trả rỗng dù dữ liệu nằm ngay trên đĩa.

3. query_backend_after mặc định là 15 phút. Tempo chỉ tìm trong kho lưu trữ với dữ liệu cũ hơn 15 phút; mới hơn thì nó tìm trong ingester. Tôi vừa khởi động lại Tempo nên ingester rỗng, còn kho thì "chưa đủ tuổi" — dữ liệu rơi vào đúng khoảng trống giữa hai nơi.

4. Và một lỗi của chính tôi: tôi đặt hai tham số trên vào querier.search thay vì query_frontend.search. Tempo từ chối khởi động với thông báo field query_backend_after not found in type querier.SearchConfig, chỉ đúng dòng sai.

Lỗi thứ tư là lỗi duy nhất ồn ào, và vì thế là lỗi duy nhất vô hại — tôi sửa nó trong hai phút. Ba lỗi đầu, mỗi lỗi tốn một lượt đo đầy đủ để loại trừ.

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

  • Đo dung lượng bằng du không có cờ -b, và luôn liệt kê từng file trước khi tin con số tổng. Ba lần trong sê-ri này, con số tổng đã sai theo ba cách khác nhau.
  • Chọn theo cách bạn hay tra cứu. Nếu quy trình của bạn là "copy trace_id từ log rồi dán vào ô tìm kiếm", Jaeger nhanh hơn. Nếu là "tìm mọi trace chậm của dịch vụ X trong giờ qua", Tempo nhanh hơn tám lần và tốn ít đĩa hơn ba lần.
  • Khi dựng Tempo và không thấy dữ liệu, hãy kiểm theo đúng thứ tự trên. Ba tham số mặc định đó là ba nguyên nhân phổ biến nhất, và cả ba đều không sinh ra lỗi nào.
  • Đọc log của hệ lưu trữ sau khi nạp lần đầu. LIVE_TRACES_EXCEEDED nằm ở đó suốt, tôi chỉ không nhìn.

Cùng một cái bẫy "kích thước khai báo ≠ chỗ thật chiếm", đổi ngăn xếp

Chuyện "công cụ báo dung lượng theo kích thước khai báo của file, còn đĩa thật cấp theo block, và hai con số lệch nhau với file thưa hoặc file cấp-trước" không riêng gì Badger — nó là cái bẫy đo dung lượng chung của mọi hệ ghi kiểu LSM/pre-allocate.

Trong thế giới Java, đúng bài học này cắn người dùng Cassandra/Kafka (cả hai chạy trên JVM và dùng lưu trữ dạng log-structured): Cassandra có SSTable + commitlog, Kafka có segment file cấp trước log.segment.bytes. Một du -sb trên thư mục data thấy các segment khai báo đầy kích thước dù chưa ghi hết, giống hệt vlog của Badger. Chuẩn là đọc metric của chính broker (kafka.log.Size) chứ không tin du.

Trong Go — ngôn ngữ viết cả Jaeger, Tempo, Badger — file thưa và mmap là idiom cực phổ biến (os.Truncate để cấp trước, rồi mmap). Chính vì thế các dev Go quen với việc du --apparent-size (tức -b) nói dối, và công cụ giám sát dung lượng trong hệ sinh thái này thường đọc block thật (stat field st_blocks) — đúng cái cột thứ hai trong script cuối bài.

Còn ở chính cơ sở dữ liệu, đây là lý do mọi RDBMS phơi ra hàm dung lượng nội tại thay vì để bạn du: PostgreSQL có pg_total_relation_size(), MySQL có information_schema.TABLES.DATA_LENGTH. Chúng đếm dữ liệu thật engine quản, tránh cả phần file tablespace cấp trước lẫn khoảng trống chưa dùng. Ai đo dung lượng CSDL bằng du trên thư mục data đều sẽ vấp đúng cái lệch này.

Mẫu số chung: kích thước một file khai báo và số block nó thật sự chiếm là hai con số khác nhau — file thưa, file cấp-trước, và mmap làm chúng lệch tuỳ ý — nên du -sb/apparent-size không phải phép đo dung lượng. Muốn con số đúng, phải đo block thật cấp (du không -b, hay st_blocks) hoặc hỏi chính engine metric dung lượng nội tại của nó. Nên câu hỏi đúng trước mỗi con số dung lượng không phải "thư mục này bao nhiêu byte" mà là "con số này đếm chỗ khai báo hay chỗ thật chiếm, và nó có gộp cả buffer tạm thời (WAL, memtable, segment cấp-trước) vào không".

Muốn tự bắt một file thưa trong kho của mình, chạy:

# Ba cach do cung mot thu muc - chay tren bat ky kho du lieu nao cua ban
D=/var/lib/docker            # doi thanh thu muc du lieu can do

echo "khai bao (du -sb)   : $(du -sb  $D 2>/dev/null | cut -f1 | numfmt --to=iec)"
echo "thuc chiem (du -s)  : $(du -s   $D 2>/dev/null | cut -f1 | numfmt --from-unit=1024 --to=iec)"
echo
echo "nam file lon nhat, khai bao so voi thuc chiem:"
find $D -type f -printf '%s %b %p\n' 2>/dev/null | sort -rn | head -5 | \
  while read khai block ten; do
    printf "  %10s  %10s  %s\n" \
      "$(numfmt --to=iec $khai)" "$(numfmt --from-unit=512 --to=iec $block)" "$(basename $ten)"
  done

Nếu hai cột đầu của một dòng nào đó chênh nhau nhiều lần, bạn vừa tìm thấy một file thưa — và mọi con số dung lượng bạn từng báo cáo về thư mục này đều cần tính lại.