Elasticsearch Bài học 45: Best Practices Tối ưu Tốc độ Ghi (Indexing Performance) cho hệ thống Log
Trong hệ thống AFC Metro Line 1, khi các đoàn tàu hoạt động vào giờ cao điểm, lượng log giao dịch quẹt thẻ đổ về Kafka rồi dội thẳng vào Elasticsearch có thể lên tới hàng chục nghìn request mỗi giây.
Nếu giữ nguyên cấu hình mặc định (Out-of-the-box), cụm Elasticsearch của bạn sẽ nhanh chóng bị "nghẽn cổ chai" (Bottleneck) ở tầng Disk I/O và CPU. Các Node sẽ gầm rú, và Kafka Consumer sẽ báo lỗi timeout liên tục.
Để biến Elasticsearch thành một "hố đen" nuốt trọn mọi luồng log khổng lồ mà không hề hấn gì, Backend Engineer cần áp dụng 5 chiến thuật tinh chỉnh hệ thống dưới đây.
1. Kéo giãn thời gian Refresh (Refresh Interval)
Như đã học ở Bài 9, mặc định cứ 1 giây 1 lần, Elasticsearch sẽ gom các Document trong In-memory Buffer để tạo thành một Segment mới (Refresh) nhằm phục vụ tìm kiếm Near Real-Time.
Với hệ thống E-commerce, 1 giây là hợp lý. Nhưng với hệ thống Log, việc tạo ra hàng nghìn Segment nhỏ lẻ mỗi giây sẽ bắt CPU phải liên tục làm nhiệm vụ gộp rác (Segment Merging). Hệ thống log thường chỉ dùng để xem báo cáo hoặc tra cứu sự cố, không ai cần xem một dòng log ngay trong đúng 1 giây nó vừa sinh ra cả.
Giải pháp: Hãy tăng refresh_interval lên 30 giây hoặc 60 giây.
JSON
PUT afc_logs-*/_settings
{
"index": {
"refresh_interval": "30s"
}
}
Việc này giúp Elasticsearch gom được những mẻ batch khổng lồ trên RAM trước khi tạo Segment, giảm tải cực kỳ lớn cho Disk I/O. (Lưu ý: Nếu bạn đang import log lịch sử khối lượng lớn 1 lần duy nhất, hãy set nó thành "-1" để tắt hẳn Refresh, import xong rồi bật lại).
2. Sử dụng ID tự sinh (Auto-generated IDs)
Khi đưa dữ liệu vào Elasticsearch, bạn có 2 cách: Tự truyền ID hoặc để hệ thống tự sinh.
Nếu bạn tự truyền ID (Ví dụ: PUT afc_logs/_doc/TXN-12345), Elasticsearch bắt buộc phải tốn CPU để thực hiện thao tác Kiểm tra sự tồn tại (Lookup): "ID TXN-12345 này đã có trong Index chưa? Nếu có thì Update, chưa có thì Insert". Ở quy mô hàng vạn request/giây, thao tác check này cực kỳ đắt đỏ.
Giải pháp: Với dữ liệu log (mang tính chất Append-only, chỉ thêm mới không bao giờ sửa), tuyệt đối không tự truyền ID. Hãy dùng lệnh POST để Elasticsearch tự sinh chuỗi ID ngẫu nhiên:
JSON
POST afc_logs/_doc
{
"station": "Ben Thanh",
"card_id": "9999",
"action": "CHECK_IN"
}
Bằng cách này, Elasticsearch sẽ bỏ qua bước Lookup và ghi thẳng dữ liệu xuống đĩa (Append) với tốc độ ánh sáng.
3. Tối ưu Translog: Hy sinh một chút an toàn lấy tốc độ
Cũng trong Bài 9, bạn đã biết Translog là file nhật ký ghi nối đuôi để chống mất dữ liệu khi cúp điện.
Mặc định, tham số index.translog.durability được set là request. Nghĩa là CỨ MỖI LẦN có 1 request Bulk hoặc Index chui vào, hệ thống phải thực hiện lệnh fsync để ép đĩa cứng vật lý phải xác nhận đã ghi xong thì mới trả về 200 OK cho API Backend. Thao tác này an toàn tuyệt đối nhưng làm Disk I/O liên tục bị block.
Giải pháp: Đổi sang chế độ async (Bất đồng bộ) cho dữ liệu Log.
JSON
PUT afc_logs-*/_settings
{
"index.translog.durability": "async",
"index.translog.sync_interval": "5s"
}
Lúc này, Elasticsearch sẽ gom các request ghi trên RAM và chỉ gọi fsync xuống đĩa cứng 5 giây 1 lần.
-
Điểm lợi: Tốc độ Indexing (Ghi) tăng vọt gấp nhiều lần.
-
Rủi ro đánh đổi: Nếu server bị rút điện đột ngột hoặc kernel panic, bạn sẽ vĩnh viễn mất tối đa 5 giây dữ liệu log gần nhất. Trong thế giới quản lý Log/Telemetry, sự đánh đổi này là hoàn toàn xứng đáng và được áp dụng ở 99% các tập đoàn lớn.
4. Tinh giản Mapping: Vô hiệu hóa các trường "Vô dụng"
Một JSON log thường chứa rất nhiều trường, nhưng không phải trường nào bạn cũng mang ra để tìm kiếm. Ví dụ: Bạn lưu lại toàn bộ chuỗi JSON raw_payload mà máy quẹt thẻ gửi lên chỉ để sau này đối soát, chứ không bao giờ search chữ "Ben Thanh" trong cái cục raw đó (vì đã có trường station riêng).
Mặc định, Dynamic Mapping sẽ index TOÀN BỘ các trường nó thấy. Điều này làm phình to Inverted Index và tốn CPU.
Giải pháp: Hãy dùng tham số "index": false trong Mapping cho các trường chỉ cần "Lưu" (hiển thị trong _source) mà không cần "Tìm kiếm".
JSON
PUT _index_template/afc_logs_template
{
"index_patterns": ["afc_logs-*"],
"template": {
"mappings": {
"properties": {
"card_id": { "type": "keyword" },
"raw_payload": {
"type": "text",
"index": false // Vẫn lưu trong _source nhưng KHÔNG tạo Inverted Index
}
}
}
}
}
5. Định cỡ Bulk Request hợp lý cho hệ thống mạng
Đây là lời nhắc lại cực kỳ quan trọng từ Bài 13. Hệ thống Log thường được đưa qua Filebeat hoặc Logstash trước khi vào Elasticsearch. Hoặc nếu bạn tự viết Consumer Kafka bằng Golang, hãy chắc chắn bạn đang dùng Bulk API đúng cách.
-
Đừng gửi Bulk có kích thước 1MB (quá nhỏ, gây lãng phí network header).
-
Cũng đừng gửi Bulk 100MB (quá to, làm cạn kiệt RAM của Node điều phối và kích hoạt Garbage Collection).
-
Ngưỡng tối ưu thực nghiệm (Sweet spot): Mỗi request Bulk nên có kích thước vật lý dao động từ 5MB đến 15MB. Nếu log của bạn là các file text nhỏ, con số này tương đương khoảng 3000 đến 5000 Document / 1 request Bulk.
Và hãy đảm bảo Worker của bạn đa luồng (Multi-threading). 1 Worker gửi Bulk 5MB sẽ không bao giờ vắt kiệt được sức mạnh của Elasticsearch. Hãy scale Consumer lên 4 hoặc 8 Worker chạy song song, Elasticsearch sinh ra để xử lý các luồng I/O đồng thời.
Sau khi áp dụng bộ 5 cấu hình trên, cụm Elasticsearch của bạn sẽ chạy êm ru dù hứng chịu hàng vạn request Log mỗi giây.
Tuy nhiên, cấu hình cho hệ thống Log (Thiên về Ghi) lại hoàn toàn đi ngược với cấu hình cho hệ thống E-commerce (Thiên về Đọc). Ở E-commerce, người ta sẵn sàng để CPU làm việc vất vả lúc Ghi nhằm chuẩn bị một bộ Index hoàn hảo nhất cho lúc Đọc.
All rights reserved