0

Elasticsearch Bài học 46: Best Practices Tối ưu Tốc độ Đọc (Search Performance) cho hệ thống E-commerce

Ở Bài 45, chúng ta đã biến cụm Elasticsearch thành một "cỗ máy nuốt Log" khổng lồ bằng cách hy sinh một số yếu tố để tối đa hóa tốc độ Ghi (Indexing).

Nhưng đối với hệ thống E-commerce như Hasaki, bài toán hoàn toàn ngược lại. Một sản phẩm có thể chỉ được Ghi (cập nhật giá/tồn kho) 1 lần mỗi ngày, nhưng lại bị Đọc (tìm kiếm, lọc) tới 10.000 lần. Khách hàng sẽ rời bỏ giỏ hàng nếu trang web load chậm hơn 2 giây. Ở mặt trận này, nguyên lý tối thượng là: Sẵn sàng bắt CPU làm việc cực nhọc lúc Ghi, để giải phóng tối đa sức mạnh lúc Đọc.

Dưới đây là 5 chiến thuật thiết kế giúp API tìm kiếm của bạn luôn đạt tốc độ dưới 50 mili-giây.

1. Tính toán trước mọi thứ lúc Ghi (Pre-indexing Data)

Một lỗi rất phổ biến của các Backend Developer khi chuyển từ SQL sang là lạm dụng script trong lúc tìm kiếm.

Giả sử Hasaki đang có chương trình giảm giá, điểm xếp hạng sản phẩm được tính dựa trên công thức: Điểm = Điểm BM25 + (Giá gốc - Giá khuyến mãi). Nếu bạn dùng script_score (Bài 34) để tính phép trừ (Giá gốc - Giá khuyến mãi) cho 1 triệu sản phẩm ngay tại thời điểm khách hàng bấm Search, CPU của Elasticsearch sẽ bị vắt kiệt.

Giải pháp: Hãy mang logic tính toán đó về API Backend (Golang/PHP). Khi người dùng lưu sản phẩm ở CMS, code Backend sẽ tính sẵn con số discount_amount = price - sale_price và lưu hẳn con số tuyệt đối này thành một trường vật lý mới trong Elasticsearch ("discount_amount": 50000). Khi khách hàng Search, bạn chỉ cần dùng field_value_factor đọc thẳng trường số này ra. Tốc độ sẽ tăng lên hàng trăm lần vì không phải biên dịch và chạy Script on-the-fly.

2. Tránh xa kiểu dữ liệu nested và join

Trong các hệ thống RDBMS, việc thiết kế quan hệ Cha-Con (Parent-Child) là hiển nhiên. Elasticsearch cũng cung cấp kiểu dữ liệu join và nested (Bài 18) để mô phỏng lại quan hệ này (ví dụ: Product -> Variants).

Tuy nhiên, việc thực thi các câu lệnh nested query hay has_child chậm hơn hàng chục lần so với việc truy vấn trên một Document phẳng.

Giải pháp: Trải phẳng dữ liệu (Denormalization) Thay vì lưu 1 Document Cha chứa mảng 10 Document Con, hãy tạo ra 10 Document riêng biệt, mỗi Document chứa cả thông tin của Cha lẫn thông tin của Con. Việc lặp lại dữ liệu (Data Duplication) sẽ làm tốn thêm dung lượng ổ cứng, nhưng ổ cứng thì vô cùng rẻ, còn tốc độ CPU (để tính toán JOIN phân tán) thì đắt đỏ vô cùng.

3. Ép nén Segment bằng Force Merge (Dành cho dữ liệu tĩnh)

Như đã học ở Bài 9, dữ liệu trong Elasticsearch nằm rải rác ở hàng ngàn Segment (các Inverted Index mini) do quá trình Refresh tạo ra. Khi khách hàng tìm kiếm, hệ thống phải lùng sục qua tất cả các Segment này rồi mới tổng hợp lại, gây suy giảm hiệu năng.

Khác với hệ thống Log (ghi liên tục 24/7), catalog sản phẩm của E-commerce thường rất tĩnh. Hầu hết các đợt cập nhật giá hay import hàng loạt đều diễn ra vào ban đêm.

Giải pháp: Vào lúc 3h sáng (sau khi cronjob đồng bộ dữ liệu từ MySQL sang Elasticsearch chạy xong), hãy kích hoạt lệnh Force Merge để ép Elasticsearch gộp hàng ngàn Segment vụn vặt đó thành đúng 1 Segment duy nhất.

JSON

POST products/_forcemerge?max_num_segments=1

(Lưu ý: Lệnh này ngốn Disk I/O cực kỳ kinh khủng, tuyệt đối KHÔNG chạy vào giờ hành chính hoặc lúc có traffic khách hàng cao). Kết quả sáng hôm sau, tốc độ Full-text Search của toàn bộ hệ thống sẽ mượt mà và nhanh hơn hẳn vì Lucene chỉ cần tra cứu trên 1 file Index duy nhất.

4. Tối đa hóa Node Query Cache (Chia để trị)

Ở Bài 27, chúng ta đã biết sức mạnh của khối filter là nó tự động Cache dải bit nhị phân vào RAM. Tuy nhiên, Cache này chỉ hiệu quả khi câu query của bạn có khả năng tái sử dụng cao.

Giả sử khách hàng tìm kiếm: "Áo thun Cetaphil dưới 150k". Nếu bạn viết query thế này:

JSON

"filter": {
  "range": {
    "price": { "lte": 150000 }
  }
}

Lệnh này sẽ được Cache. Nhưng ngày mai khách hàng tìm "Áo thun dưới 149k", Cache bị trượt (Miss). Mỗi khách nhập một mức giá khác nhau, Cache của bạn sẽ bị tràn bộ nhớ toàn các điều kiện rác không bao giờ được dùng lại.

Giải pháp: Nhóm giá thành các khoảng tĩnh trên UI. Thay vì để khách tự gõ số tiền lẻ, hãy thiết kế UI dạng Tickbox (Dưới 100k, 100k-300k, 300k-500k). Khi đó, lượng câu lệnh Filter gửi xuống Elasticsearch sẽ cực kỳ giới hạn và lặp lại liên tục. Tỷ lệ Hit Cache sẽ chạm mốc 99%.

5. Tăng cường Replica Shards để Scale luồng Đọc

Primary Shard dùng để chia nhỏ dữ liệu (Scale Dung lượng Ghi), còn Replica Shard dùng để nhân bản dữ liệu (Scale Dung lượng Đọc).

Nếu Hasaki chuẩn bị có Mega Sale 11/11, lượng Traffic tìm kiếm dự kiến tăng gấp 5 lần ngày thường. Thay vì phải cầu nguyện hệ thống trụ vững, bạn chỉ cần gõ đúng 1 dòng lệnh API để thêm Replica.

JSON

PUT products/_settings
{
  "index": {
    "number_of_replicas": 3 // Tăng từ 1 lên 3
  }
}

Ngay lập tức, Master Node sẽ copy dữ liệu sang các Data Node khác. Thay vì chỉ có 2 Node phục vụ search, giờ đây bạn có 4 Node (1 Primary + 3 Replica) cùng đứng ra trả lời các request tìm kiếm của khách hàng dưới cơ chế Round-robin. Sau khi Mega Sale kết thúc, bạn có thể chỉnh con số này về 1 để giải phóng RAM và ổ cứng.

Cấu hình chuẩn xác giữa luồng Đọc (E-commerce) và luồng Ghi (Logs) sẽ giúp hệ thống của bạn vắt kiệt được từng đồng chi phí phần cứng. Tuy nhiên, dù phần mềm có tối ưu đến đâu, nếu bạn cấp phát một con Server 2GB RAM cho Elasticsearch, nó vẫn sẽ sập. Ở bài tiếp theo, chúng ta sẽ học cách tính toán dung lượng RAM, CPU và Shard hợp lý trước khi xin sếp ngân sách mua Server.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí