Phần trước đo Loki một mình: 200.000 dòng log chiếm 5,40 MB, chỉ mục chỉ 0,42% dữ liệu thô. Phần này đẩy đúng tệp đó vào Elasticsearch và đo lại từng con số — cùng dữ liệu, cùng bốn câu hỏi, cùng cách đối chiếu kết quả. Câu hỏi không phải "cái nào tốt hơn", mà "mỗi cái tính tiền ở đâu".

Elasticsearch và Loki trên cùng 200.000 dòng log

Bảng số liệu

Cùng một tệp 30,75 MB, 200.000 dòng log JSON. Elasticsearch 8.15.3, một mảnh, không bản sao. Tôi nạp hai lần vào hai chỉ mục: một để mapping động mặc định, một khai mapping tường minh (keyword cho service/level/endpoint/trace_id, short cho status, half_float cho duration_ms, text cho msg).

Loki ES tinh chỉnh ES mặc định
Chỗ trên đĩa 5,40 MB 16,22 MB 23,01 MB
So với 30,75 MB thô nén 5,69× nén 1,90× nén 1,34×
So với Loki 1,00× 3,00× 4,26×
Nạp 200.000 dòng 0,63 s 1,78 s 3,18 s
Dòng mỗi giây 318 509 112 137 62 837
Lọc theo trường/nhãn 27 ms 2 ms 7 ms
Lọc chuỗi trong nội dung 33 ms 3 ms 3 ms
Lọc số status=500 38 ms 2 ms 2 ms
Gộp theo service 40 ms 2 ms 3 ms

Cả tám phép truy vấn của cả hai hệ đều trả về chính xác số đếm tôi tính trực tiếp từ tệp nguồn: 40 324 / 20 028 / 6 082 / 200 000.

Điều đáng nhớ

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

Elasticsearch trả lời nhanh gấp khoảng 13 lần. Loki mất 27–40 ms cho bốn câu hỏi, Elasticsearch mất 2–3 ms cho cùng bốn câu. Chênh lệch đều đặn, không phụ thuộc loại truy vấn.

Loki chiếm ít hơn 3 lần. 5,40 MB so với 16,22 MB — và so với mapping mặc định thì ít hơn 4,26 lần. Chỉ riêng phần chỉ mục của Elasticsearch đã lớn hơn toàn bộ những gì Loki để lại trên đĩa.

Loki nạp nhanh hơn 2,8 lần so với ES tinh chỉnh và 5,1 lần so với ES mặc định. Điều này hợp lý: Loki chỉ nén và ghi, còn Elasticsearch phải dựng chỉ mục đảo cho từng trường của từng bản ghi ngay lúc nạp.

Và mapping mặc định của Elasticsearch là tiền vứt đi. 23,01 MB so với 16,22 MB cho cùng dữ liệu, cùng kết quả truy vấn — lãng phí 29,5%. Nó còn làm việc nạp chậm đi 1,8 lần và làm truy vấn lọc theo trường chậm hơn 3,5 lần (7 ms so với 2 ms).

Vì sao

Hai hệ chọn hai chỗ khác nhau trên cùng một đường đánh đổi, và cả hai đều nhất quán với chính lựa chọn đó.

Loki chỉ dựng chỉ mục trên nhãn. Nội dung dòng log nằm trong chunk nén, và mọi câu hỏi về nội dung đều phải giải nén rồi quét. Vì thế chỉ mục bé tí (0,13 MB), nạp rất nhanh, và mọi truy vấn có cùng một sàn thời gian: thời gian giải nén phần dữ liệu mà nhãn không loại bỏ được.

Elasticsearch dựng chỉ mục đảo cho mọi trường. Sau khi nạp xong, câu hỏi "có bao nhiêu bản ghi status = 500" không cần đọc bản ghi nào — nó tra một danh sách đã dựng sẵn. Đó là lý do 2 ms, và cũng là lý do 16,22 MB: bạn đang lưu cả dữ liệu lẫn mọi cách để tìm nó.

Còn 29,5% lãng phí của mapping mặc định đến từ mapping động: khi gặp một trường chuỗi lạ, Elasticsearch tạo cho nó hai kiểu — text để tìm toàn văn và một trường con .keyword để lọc và gộp chính xác. Với service, level, endpointtrace_id, tôi chỉ cần keyword; phần text được dựng, được lưu, và không bao giờ dùng. trace_id là trường hợp tệ nhất: 200.000 giá trị ngẫu nhiên duy nhất, được phân tích thành token rồi đưa vào chỉ mục toàn văn để phục vụ một loại truy vấn mà không ai chạy.

Việc lọc theo trường trên chỉ mục mặc định chậm hơn (7 ms so với 2 ms) cũng từ đó: nó phải làm việc trên service.keyword trong một chỉ mục lớn hơn và phân mảnh hơn.

Nghĩa là gì trong thực tế

  • Chọn theo câu hỏi bạn hay hỏi, không theo hệ nào "mạnh hơn". Nếu công việc thường ngày là "mở log của dịch vụ X quanh 14h30", Loki trả lời thừa nhanh và rẻ hơn nhiều lần. Nếu công việc là dò tìm tự do trên toàn bộ dữ liệu nhiều lần mỗi phút, 13 lần chênh lệch sẽ tự trả tiền cho nó.
  • Nếu đã chọn Elasticsearch, hãy khai mapping. Đây là việc rẻ nhất trong bài: một tệp JSON vài chục dòng, đổi lại 29,5% dung lượng, 1,8 lần tốc độ nạp, và truy vấn lọc nhanh hơn 3,5 lần. Không có đánh đổi nào ở đây cả — mapping mặc định đơn giản là kém hơn ở mọi cột.
  • Cảnh giác đặc biệt với các trường có nhiều giá trị duy nhất. trace_id, request_id, user_id — nếu bạn không tìm toàn văn trên chúng thì hãy khai keyword, và nếu không bao giờ gộp theo chúng thì tắt luôn doc_values.
  • Quy đổi ra ngân sách. Ở 10.000 dòng mỗi giây, một ngày là 24,4 GB thô. Loki giữ lại khoảng 4,3 GB, ES tinh chỉnh khoảng 12,9 GB, ES mặc định khoảng 18,3 GB. Nhân với số ngày lưu trữ trước khi tranh luận tiếp.

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

Con số bộ nhớ không nói điều bạn tưởng, và tôi phải nói rõ. Trong lúc đo, Loki dùng 129 MiB còn Elasticsearch dùng 1 746 MiB — chênh 13,5 lần. Nhưng tôi là người đặt heap của Elasticsearch bằng 1 GB qua ES_JAVA_OPTS, và JVM chiếm chỗ đó ngay từ đầu bất kể có dùng hay không. Vậy con số đó phản ánh cấu hình của tôi, không phản ánh nhu cầu thật của Elasticsearch ở quy mô 200.000 dòng. Nói "Elasticsearch cần gấp 13 lần bộ nhớ" là đọc sai phép đo của chính mình. Điều tôi có thể nói: Loki chạy tốt mà không cần tôi cấu hình bộ nhớ gì cả, còn Elasticsearch buộc tôi phải quyết định một con số heap trước khi nó khởi động.

200.000 dòng là quá nhỏ để kết luận về tốc độ. Toàn bộ dữ liệu của cả hai hệ nằm gọn trong bộ đệm trang; không có lần đọc đĩa thật nào. Khoảng cách 13 lần là khoảng cách giữa "tra chỉ mục trong RAM" và "giải nén 5 MB trong RAM". Ở quy mô hàng chục hay hàng trăm gigabyte, cả hai vế đều đổi, và tôi không có dữ liệu để nói đổi theo hướng nào.

Tỷ lệ nén phụ thuộc dữ liệu. Log của tôi do máy sinh, rất đều. Log thật có thông điệp lỗi tự do và dấu vết ngăn xếp — Loki sẽ nén kém hơn, còn Elasticsearch sẽ phình thêm vì có nhiều token duy nhất hơn. Tôi ngờ khoảng cách sẽ giãn ra chứ không thu hẹp, nhưng đó là phỏng đoán chứ không phải phép đo.

Một lần đo hỏng, và cách nó lộ ra. Lần chạy Elasticsearch đầu tiên chết ngay ở bước tạo chỉ mục với thông báo cannot create index with name [logs-mapping-mac-dinh], because it matches with template [logs] that creates data streams only. Elasticsearch 8 cài sẵn một mẫu cho các chỉ mục tên logs-*, và mẫu đó chỉ cho phép tạo qua data stream. Tôi vô tình đặt tên chỉ mục khớp mẫu ấy. Chữa bằng cách đổi tiền tố thành bench-.

Điều đáng nói là lỗi này ồn ào nên vô hại — nó dừng hẳn phép đo với một thông báo nói đúng nguyên nhân. Loại lỗi đáng sợ là loại im lặng, như chuyện cửa sổ chồng lấn ở phần trước cho ra 602.552 dòng từ 200.000 dòng đầu vào mà không có lỗi nào. Đó là lý do tôi giữ nguyên thói quen tính trước số đối chiếu từ tệp nguồn: nó bắt được đúng loại lỗi mà hệ thống không bao giờ báo.

Thử ba mươi giây

docker run -d --name es -p 9200:9200 \
  -e discovery.type=single-node -e xpack.security.enabled=false \
  -e "ES_JAVA_OPTS=-Xms1g -Xmx1g" \
  docker.elastic.co/elasticsearch/elasticsearch:8.15.3

until curl -sf localhost:9200/_cluster/health >/dev/null; do sleep 2; done

# cung mot ban ghi, hai chi muc: mac dinh va khai kieu keyword
curl -s -X PUT localhost:9200/bench-mac-dinh >/dev/null
curl -s -X PUT localhost:9200/bench-tinh-chinh -H 'Content-Type: application/json' \
  -d '{"mappings":{"properties":{"trace_id":{"type":"keyword"}}}}' >/dev/null

for idx in bench-mac-dinh bench-tinh-chinh; do
  for i in $(seq 1 20000); do
    echo '{"index":{}}'
    echo "{\"trace_id\":\"$(printf '%016x' $RANDOM$RANDOM)\"}"
  done | curl -s -X POST "localhost:9200/$idx/_bulk" \
       -H 'Content-Type: application/x-ndjson' --data-binary @- >/dev/null
  curl -s -X POST "localhost:9200/$idx/_forcemerge?max_num_segments=1" >/dev/null
done
sleep 3
curl -s 'localhost:9200/_cat/indices?v&h=index,docs.count,pri.store.size'
docker rm -f es

Chỉ một trường, chỉ 20.000 bản ghi, và khác biệt duy nhất là một dòng khai kiểu. Nhìn cột dung lượng: đó là cái giá của việc để Elasticsearch tự đoán.