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.

Loki: dung lượng lưu và tốc độ tìm

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à servicelevel — 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.

Điều đáng nhớ

Nói lại theo cách khác:

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.

Đổ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.

Vì sao

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 servicelevel. 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ị serviceendpoint, 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 thực tế

  • Ướ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.

Chỗ tôi không kết luận được

200.000 dòng là quá nhỏ để nói gì về tốc độ tìm. Toàn bộ 5,27 MB chunks nằm gọn trong bộ đệm trang; không có lần đọc đĩa thật nào trong bốn truy vấn. Đó là lý do lọc nhãn (27 ms) và quét toàn bộ (40 ms) chỉ chênh 1,5 lần — ở quy mô hàng chục gigabyte, khoảng cách đó sẽ giãn ra theo lượng byte phải giải nén, và tôi không có dữ liệu để nói giãn bao nhiêu. Đừng ngoại suy bảng thời gian này.

Tỷ lệ nén phụ thuộc dữ liệu của bạn. Log của tôi do máy sinh, rất đều, chỉ có vài chục giá trị khác nhau cho phần lớn trường. Log thật có thông điệp lỗi tự do, dấu vết ngăn xếp, mã định danh ngẫu nhiên — nén kém hơn. Con số 5,69 lần là cận trên, không phải một hằng số.

Tôi đo Loki chạy một mình trong một tiến trình. Cụm thật có nhiều thành phần tách rời, có bộ nén nền, có lưu trữ đối tượng thay vì hệ thống tệp. Cả dung lượng lẫn độ trễ đều sẽ khác.

Hai lần đo hỏng, và cách chúng lộ ra.

Lần đầu, tôi đo bằng query_range với bước 60 giây trên cửa sổ 3 giờ. Kết quả: truy vấn gộp trả về 602.552 dòng trong khi tôi chỉ đẩy vào 200.000 — gấp đúng 3,01 lần. Không cần biết Loki hoạt động ra sao cũng thấy con số đó không thể đúng: không thể lấy ra nhiều hơn số đã bỏ vào. Nguyên nhân là mỗi bước 60 giây tính lại trên một cửa sổ 3 giờ, nên các cửa sổ chồng lấn và cùng một dòng bị đếm ở nhiều điểm; tôi lại cộng tất cả các điểm. Chữa bằng cách dùng truy vấn tức thời — một điểm duy nhất, không cửa sổ nào chồng nhau. Sau khi sửa, cả bốn con số khớp chính xác số đếm từ tệp nguồn.

Lần thứ hai là chuyện WAL đã kể ở trên. Cách phát hiện: tôi liệt kê từng thư mục con thay vì lấy một con số tổng, và thấy chunks chỉ có 5,27 MB trong khi tổng là 37,60 MB. Nếu tôi tin con số tổng, bài này đã kết luận ngược 180 độ.

Bài học chung của cả hai: luôn có một con số bên ngoài để đối chiếu. Với truy vấn, đó là số dòng đã đẩy vào. Với dung lượng, đó là việc phân rã theo thư mục. Không có mốc đối chiếu thì không có cách nào biết phép đo đang nói dối.

Thử ba mươi giâ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.