Hãy so hai cách sắp một thư viện. Cách thứ nhất: làm một mục lục ghi từng câu trong từng cuốn sách — tra "câu này ở đâu" thì tức khắc, nhưng cái mục lục còn dày hơn cả đống sách. Cách thứ hai: chỉ ghi nhãn kệ — sách khoa học kệ A, tiểu thuyết kệ B — mục lục mỏng dính, nhưng muốn tìm một câu cụ thể thì phải tới đúng kệ rồi lật từng trang. Elasticsearch chọn cách một; Loki chọn cách hai. Bài này tôi đo cho ra cái giá thật của lựa chọn đó — và cái mục lục mỏng dính ấy hoá ra chỉ chiếm 0,42% dữ liệu.

Hai phần trước đo cái giá của việc sinh ra một dòng log. Phần này đo cái giá của việc giữ nó: dựng một Loki thật, đẩy 200.000 dòng log có cấu trúc vào, rồi đo dung lượng nó chiếm và thời gian nó tìm. Con số quan trọng nhất hoá ra không phải tốc độ, mà là tỷ lệ giữa chỉ mục và dữ liệu. (Đo bằng Loki 2.9.8 thật trong container, không có phần cứng đặc biệt.)

Bảng số liệu

Dữ liệu thử: 200.000 dòng log JSON có cấu trúc, mỗi dòng gồm level, service, endpoint, status, duration_ms, trace_id, user_id, msg. Tổng 30,75 MB, trung bình 161 byte mỗi dòng. Loki 2.9.8 chạy một mình trong container, lưu trữ hệ thống tệp, nhãn là service và level — tức 15 luồng.

Nạp vào:

Thời gian đẩy 200.000 dòng 0,63 s
Tốc độ 318.509 dòng/giây

Chiếm chỗ trên đĩa, sau khi ép xả chunk:

Thư mục Dung lượng Là gì
chunks 5,27 MB dữ liệu log đã nén
boltdb-shipper-active 0,13 MB chỉ mục
wal 32,18 MB nhật ký ghi trước, tạm thời
Tổng thư mục 37,60 MB

Tìm kiếm (trung vị 3 lần, truy vấn tức thời trên toàn bộ dữ liệu):

Truy vấn Thời gian Kết quả
Lọc theo nhãn {service="auth"} 27 ms 40 324
Lọc chuỗi ` = "request failed"` 33 ms
Bóc JSON ` json status=500`
Gộp sum by (service) 40 ms 200 000

Cả bốn con số kết quả khớp chính xác với số đếm tôi tính trực tiếp từ tệp nguồn trước khi đẩy vào. Đó là điều kiện tiên quyết: một phép đo tốc độ chỉ có nghĩa khi câu trả lời đúng.

Bốn điều đáng nhớ

Dữ liệu thật chiếm 5,40 MB — chunks cộng chỉ mục. So với 30,75 MB log thô, đó là nén 5,69 lần, hay 28,3 byte mỗi dòng thay vì 161 byte.

Chỉ mục chỉ có 0,13 MB, tức 0,42% dữ liệu thô và 2,5% kích thước chunks. Đây là toàn bộ đặc tính của Loki gói trong một con số: nó chỉ đánh chỉ mục nhãn, không đánh chỉ mục nội dung dòng log. Bạn không trả tiền chỉ mục cho 30 MB văn bản; bạn trả tiền chỉ mục cho 15 luồng. Đúng cái mục lục ghi nhãn kệ.

Đổi lại, tìm theo nội dung phải quét. Lọc theo nhãn mất 27 ms, còn lọc chuỗi mất 33 ms và bóc JSON mất 38 ms — Loki phải giải nén chunk rồi đọc từng dòng. Ở quy mô 5 MB thì chênh lệch nhỏ, và tôi sẽ nói rõ bên dưới vì sao không được suy ra từ đó.

Và thư mục lớn nhất không phải dữ liệu. wal chiếm 32,18 MB, gấp 6,1 lần thư mục chunks. Nếu tôi chỉ chạy du -sb /loki rồi lấy con số 37,60 MB, tôi đã kết luận Loki lưu 30,75 MB log hết 37,60 MB — tức phình ra 1,22 lần, ngược hoàn toàn với sự thật là nén 5,69 lần. Suýt nữa cả bài viết đi sai hướng vì một thư mục tạm.

Vì sao chỉ mục lại nhỏ đến vậy

Loki cố tình không làm cái mà một công cụ tìm kiếm toàn văn làm. Nó dựng chỉ mục chỉ trên tập nhãn — ở đây là các cặp service và level. Với 5 dịch vụ và 3 mức, có đúng 15 luồng, và chỉ mục chỉ cần trả lời "luồng này có chunk nào trong khoảng thời gian nào". Đó là lý do 0,13 MB đủ cho 200.000 dòng.

Nội dung dòng log đi thẳng vào chunk, được nén theo khối. Khi bạn hỏi một câu có lọc chuỗi, Loki dùng nhãn để thu hẹp tập chunk cần đọc, rồi giải nén và quét tuần tự phần còn lại. Truy vấn có nhãn hẹp thì rẻ; truy vấn quét toàn bộ thì đắt theo đúng lượng byte phải giải nén.

Tỷ lệ nén 5,69 lần đến từ chính bản chất log: các dòng rất giống nhau. Cùng tên trường, cùng vài chục giá trị service và endpoint, cùng khuôn {"level":"INFO","service":.... Bộ nén khối ăn rất tốt loại dữ liệu này. Tôi không kiểm tra thuật toán nén cụ thể mà Loki dùng ở cấu hình mặc định, nên chỉ nói tới tỷ lệ đo được chứ không nói tới cơ chế. Còn wal lớn vì nó là nhật ký ghi trước: mọi dòng vừa nhận được ghi ngay ở dạng gần như thô để nếu tiến trình chết thì khôi phục lại được, và nó chỉ được cắt bớt sau các mốc kiểm tra. Nó là chi phí vận hành tức thời, không phải chi phí lưu trữ dài hạn. Trộn hai thứ đó vào một con số là sai.

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

  • Ước lượng dung lượng theo 28,3 byte mỗi dòng, không theo kích thước log thô. Ở 10.000 dòng mỗi giây, đó là 24,4 GB mỗi ngày ở dạng thô nhưng chỉ 4,3 GB thực sự nằm lại.
  • Giữ số nhãn nhỏ. Chỉ mục 0,42% là hệ quả của việc chỉ có 15 luồng. Thêm một nhãn có 50.000 giá trị — user_id chẳng hạn — là nhân số luồng lên và ném đi toàn bộ ưu thế này. Đây là lỗi thường gặp nhất khi dùng Loki, và một phần sau của sê-ri sẽ đo đúng nó.
  • Viết truy vấn có nhãn trước, lọc chuỗi sau. {service="auth"} |= "timeout" đọc ít hơn hẳn {service=~".+"} |= "timeout", vì nhãn quyết định bao nhiêu byte phải giải nén.
  • Khi tính chỗ trống trên đĩa, cộng cả WAL. Nó là 32 MB ở đây và nó có thật — chỉ đừng dùng nó để đánh giá hiệu quả nén.
  • Loki dùng 128,5 MiB RAM trong suốt phép đo này. Đó là con số dễ chịu cho một máy chủ nhỏ, và tôi sẽ so nó với Elasticsearch ở phần sau.

Cùng một đánh đổi "chỉ mục gì thì tốn tiền đó", đổi ngăn xếp

Chuyện "chọn đánh chỉ mục ít (nhãn) thì lưu rẻ nhưng tìm nội dung phải quét, chọn đánh chỉ mục nhiều (toàn văn) thì tìm nhanh nhưng chỉ mục phình" không riêng gì Loki — nó là đánh đổi trung tâm của mọi hệ lưu trữ tìm kiếm.

Trong thế giới Java, Elasticsearch (nền của ELK, hệ log phổ biến nhất giới Java/enterprise) chọn cực đối lập với Loki: nó dựng inverted index cho mọi token trong mọi dòng, nên tìm chuỗi tức khắc không cần quét — nhưng chỉ mục thường lớn bằng hoặc hơn dữ liệu gốc, ngược hẳn con số 0,42% ở trên. Đó chính là lý do Loki ra đời: đổi tốc độ tìm-toàn-văn lấy chi phí lưu rẻ hơn cả chục lần. Chọn cái nào là chọn giữa hoá đơn đĩa và độ trễ tìm kiếm.

Trong Go — ngôn ngữ viết cả Loki lẫn Prometheus — triết lý "chỉ index nhãn, còn lại quét" phản chiếu đúng mô hình của Prometheus với chỉ số, và đó không phải trùng hợp: Loki cố ý mượn kiến trúc "nhãn + chunk" của Prometheus. Bài học "cardinality của nhãn mới là thứ làm nổ chi phí" chuyển nguyên vẹn giữa hai hệ.

Còn ở cơ sở dữ liệu, đây đúng là bài toán chọn index kinh điển: mỗi index bạn thêm làm ghi chậm đi và đĩa phình ra, đổi lấy đọc nhanh hơn cho đúng truy vấn đó. Một B-tree index trên cột phân biệt cao là "index toàn văn" của SQL; không đánh index thì mọi truy vấn cột đó phải seq-scan — đúng cái "phải quét chunk" của Loki. DBA lành nghề đánh index đúng cột hay dùng, chứ không index tất cả, vì cùng lý do Loki chỉ index nhãn.

Mẫu số chung: chỉ mục là một khoản đầu tư — cái gì bạn đánh chỉ mục thì tìm nhanh nhưng phải trả bằng đĩa và tốc độ ghi, cái gì bạn không đánh thì lưu rẻ nhưng tìm phải quét — và không có cấu hình nào tối ưu cả hai đầu. Loki đặt cược rằng bạn hầu như luôn lọc theo nhãn trước (dịch vụ nào, mức nào) rồi mới quét trong tập hẹp đó, nên chỉ cần index nhãn là đủ rẻ mà vẫn nhanh cho truy vấn thực tế. Nên câu hỏi đúng khi chọn hệ log không phải "cái nào tìm nhanh nhất" mà là "truy vấn thật của tôi có luôn kèm một nhãn hẹp không" — nếu có, index-nhãn thắng về chi phí; nếu bạn thật sự cần tìm-toàn-văn tuỳ ý trên mọi dòng, bạn phải trả giá index toàn văn.

Muốn tự dựng thử và tránh đúng cái bẫy đã làm hỏng lần đo đầu của tôi, chạy:

docker run -d --name loki -p 3100:3100 grafana/loki:2.9.8

# doi loki san sang
until curl -sf localhost:3100/ready | grep -q ready; do sleep 1; done

# day 3 dong vao mot luong
NS=$(date +%s000000000)
curl -s -X POST localhost:3100/loki/api/v1/push -H 'Content-Type: application/json' \
  -d "{\"streams\":[{\"stream\":{\"service\":\"api\"},\"values\":[
      [\"$NS\",\"{\\\"status\\\":200,\\\"msg\\\":\\\"ok\\\"}\"],
      [\"$((NS+1000000))\",\"{\\\"status\\\":500,\\\"msg\\\":\\\"request failed\\\"}\"],
      [\"$((NS+2000000))\",\"{\\\"status\\\":500,\\\"msg\\\":\\\"request failed\\\"}\"]]}]}"

# dem: phai ra dung 2
curl -sG localhost:3100/loki/api/v1/query \
  --data-urlencode 'query=sum(count_over_time({service="api"} |= "request failed" [1h]))' \
  | python3 -c 'import sys,json;r=json.load(sys.stdin)["data"]["result"];print("ket qua:",r[0]["value"][1] if r else 0)'

docker rm -f loki

Đẩy vào ba dòng, hai dòng khớp, kết quả phải là 2. Nếu bạn dùng query_range thay cho query ở lệnh cuối và cộng các điểm lại, con số sẽ lớn hơn — và đó chính là cái bẫy đã làm hỏng lần đo đầu tiên của tôi.