Hình dung hai cách cất một thư viện. Cách thứ nhất: xếp sách lên kệ theo thứ tự, hết. Muốn tìm một câu trong một cuốn, bạn phải rút cuốn đó ra và lật từng trang — chậm, nhưng kho gọn gàng, cất nhanh, tốn ít chỗ. Cách thứ hai: ngoài xếp sách, bạn còn thuê người đọc mọi cuốn và lập một cuốn sổ tra cứu khổng lồ ghi "từ này xuất hiện ở sách nào, trang nào". Tra một câu ra kết quả tức thì — nhưng cuốn sổ ấy to gần bằng cả thư viện, và bạn phải trả tiền cho người đọc ngay lúc nhập sách. Loki là cách thứ nhất, Elasticsearch là cách thứ hai, và bài này tôi cân đo đúng hai cái giá đó bằng cùng một đống sách.
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ô. Lần này tôi đẩ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". (Các con số là dung lượng và độ trễ đo trong container, không phải perf counter phần cứng.)
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 |
Điều làm tôi yên tâm rằng phép đo không bịp: cả tám 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.
Bốn con số, kể lại bằng lời
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 — đúng bản chất của việc tra một cuốn sổ đã dựng sẵn thay vì lật từng trang.
Đổi lại, 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 tới 4,26 lần. Nói cho thấm: 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 cũng 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 hoàn toàn 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à đây là chỗ tôi thấy đáng bực nhất, vì nó là tiền vứt đi mà chẳng đổi lại gì: mapping mặc định của Elasticsearch phí 29,5% dung lượng. 23,01 MB so với 16,22 MB cho cùng dữ liệu, cùng kết quả truy vấn. 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). Không có đánh đổi nào để bào chữa — mặc định đơn giản là kém hơn ở mọi cột.
Vì sao mỗi hệ tính tiền ở chỗ khác nhau
Hai hệ chọn hai điểm khác nhau trên cùng một đường đánh đổi, và cả hai đều nhất quán với lựa chọn của mình.
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ó chung 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 thì 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 chính 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: 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, endpoint và trace_id, tôi chỉ cần keyword; phần text được dựng, được lưu, và không bao giờ dùng tới. trace_id là trường hợp tệ nhất: 200.000 giá trị ngẫu nhiên duy nhất, bị phân tích thành token rồi nhét 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 bao giờ. 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ừ đó mà ra: 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.
Rút ra cho công việc thực tế thì thế này. Chọn theo câu hỏi bạn hay hỏi, đừng chọn theo hệ nào nghe "mạnh hơn": nếu việc thường ngày là "mở log của dịch vụ X quanh 14h30" thì Loki trả lời thừa nhanh và rẻ hơn nhiều lần; còn nếu việc là dò tìm tự do trên toàn bộ dữ liệu nhiều lần mỗi phút thì 13 lần chênh lệch sẽ tự trả tiền cho nó. Nếu đã chọn Elasticsearch thì hãy khai mapping — đây là việc rẻ nhất trong cả bài: một tệp JSON vài chục dòng đổi lấy 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. Cảnh giác đặc biệt với các trường nhiều giá trị duy nhất như trace_id, request_id, user_id: nếu không tìm toàn văn trên chúng thì khai keyword, và nếu không bao giờ gộp theo chúng thì tắt luôn doc_values. Cuối cùng, quy ra ngân sách cho dễ tranh luận: ở 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ữ rồi hãy bàn tiếp.
Mẫu số chung
Cái đánh đổi "chỉ mục mọi thứ để tra nhanh, hay để dữ liệu thô để cất rẻ" không phải đặc sản của công cụ log — nó là câu hỏi trung tâm của mọi hệ lưu trữ, chỉ đổi lớp áo.
Trong thế giới Java, đây gần như đúng nghĩa đen: Elasticsearch được xây trên Lucene, và chính Lucene là thư viện chỉ mục đảo viết bằng Java. Cái giá 16,22 MB kia là các cấu trúc của Lucene — postings list, doc values, term dictionary — hiện thành file. Bất cứ ai từng chỉnh một chỉ mục Lucene đều biết cùng nút vặn: stored hay không, docValues hay không, analyzed hay keyword — mỗi lựa chọn thêm một lớp cấu trúc và một khoản dung lượng.
Trong Go, Loki chính là ví dụ — nó viết bằng Go, và triết lý "chỉ mục nhãn, còn lại nén và quét" phản chiếu đúng khẩu vị của hệ sinh thái Go: đơn giản, ít cấu trúc phụ, chấp nhận quét tuyến tính khi quét đủ nhanh. Cùng dòng máu đó là các engine tìm kiếm thuần Go như Bleve, nơi bạn lại đối mặt cùng lựa chọn "index field này hay không".
Còn ở cơ sở dữ liệu, đây là bài học kinh điển nhất: một cột không có index thì WHERE status = 500 phải quét toàn bảng (chính là Loki), thêm một B-tree hay GIN index thì tra tức thì nhưng index ngốn thêm dung lượng và làm mọi lần INSERT chậm đi (chính là Elasticsearch). "Index mọi cột cho chắc" là phiên bản DB của mapping động mặc định — nghe an toàn, thực ra là trả tiền cho những lối tra không ai đi.
Mẫu số chung: chỉ mục là dung lượng và công-lúc-ghi bỏ ra trước để mua tốc-độ-lúc-đọc về sau — nên chỉ mục đúng thứ bạn thật sự truy vấn thì lãi, còn chỉ mục mọi thứ 'cho chắc' luôn là lỗ, ở mọi tầng. Vì thế câu hỏi đúng khi thiết kế lưu trữ không phải "hệ nào nhanh nhất" mà là "tôi sẽ tra theo những trường nào, bao nhiêu lần" — trả lời được câu đó thì mới biết nên đứng ở đâu giữa 5,40 MB và 23,01 MB.
Muốn tự thấy cái giá của việc để Elasticsearch tự đoán, chạy thử ngay:
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, thu nhỏ lại vừa đủ để bạn thấy nó bằng mắt.