Loại bỏ SQL ‘LIKE’: Xây dựng tính năng Search-as-you-type 50ms với Meilisearch

Database tutorial - IT technology blog
Database tutorial - IT technology blog

Tiếng chuông báo động lúc 2 giờ sáng: Khi ‘LIKE %query%’ làm sập hệ thống

Điện thoại của tôi bắt đầu reo inh ỏi lúc 2 giờ sáng. Tỷ lệ chuyển đổi của khách hàng thương mại điện tử của chúng tôi đang sụt giảm nghiêm trọng, và các bản log đều chỉ đích danh một thủ phạm: thanh tìm kiếm. Người dùng đang nhập tên sản phẩm, nhưng cơ sở dữ liệu không thể đáp ứng kịp. Thời gian phản hồi trung bình? 3,5 giây. Trên môi trường web hiện đại, ba giây là cả một đời người. Người dùng mong đợi kết quả ngay khi ngón tay họ vừa chạm vào phím—một tiêu chuẩn được gọi là ‘Search-as-you-type’ (Tìm kiếm ngay khi nhập).

Kiểm tra backend đã làm rõ vấn đề. Một lập trình viên trước đó đã triển khai tính năng tìm kiếm bằng các toán tử LIKE chuẩn của SQL trên ba bảng. Cách này hoạt động ổn với 1.000 sản phẩm. Tuy nhiên, danh mục hàng hóa đã tăng lên 500.000 mặt hàng với nhiều biến thể khác nhau. Cơ sở dữ liệu đang phải thực hiện quét toàn bộ bảng (full table scan) cho mỗi lần nhấn phím. Việc thêm ILIKE để không phân biệt chữ hoa chữ thường chỉ càng đẩy mức sử dụng CPU lên cao hơn, buộc chúng tôi phải giải mã bí ẩn ‘truy vấn chậm’ này ngay lập tức.

Tại sao cơ sở dữ liệu quan hệ thất bại trong việc tìm kiếm

Số lượng dòng dữ liệu không phải là kẻ thù duy nhất ở đây. Các cơ sở dữ liệu quan hệ như PostgreSQL hay MySQL ưu tiên tuân thủ ACID và các mối quan hệ phức tạp. Chúng không được xây dựng để xếp hạng văn bản tốc độ cao. Khi bạn cố ép một trải nghiệm tìm kiếm hiện đại vào SQL, bạn sẽ gặp phải ba nút thắt cổ chai chính khiến các truy vấn phân tích đang ‘giết chết’ hệ thống Production của bạn:

  • Không có khả năng chịu lỗi chính tả: Nếu người dùng nhập “ipone” thay vì “iphone”, LIKE sẽ không trả về kết quả nào. Bạn mất một đơn hàng chỉ vì một chữ cái bị thiếu.
  • Xếp hạng độ liên quan kém: SQL không hiểu một cách tự nhiên kết quả nào là “tốt hơn”. Nó chỉ biết chuỗi đó có tồn tại hay không. Nó không dễ dàng ưu tiên kết quả khớp ở tiêu đề so với kết quả khớp ở phần mô tả.
  • Quá tải khi tìm kiếm theo bộ lọc (Faceted Search): Việc tính toán số lượng cho các danh mục, thương hiệu và khoảng giá (facets) đòi hỏi các truy vấn GROUP BY phức tạp. Những truy vấn này chậm dần theo cấp số nhân khi tập dữ liệu của bạn lớn lên.

Các ứng cử viên: Elasticsearch vs. Algolia vs. Meilisearch

Tôi đã đánh giá ba lựa chọn để giải quyết sự cố này:

  1. Elasticsearch: Một cái tên sừng sỏ. Nó cực kỳ mạnh mẽ nhưng cực kỳ ngốn tài nguyên. Nó yêu cầu lượng RAM đáng kể—thường cần 4GB heap chỉ để khởi động—và cấu hình JVM phức tạp. Đối với dự án này, nó là quá mức cần thiết.
  2. Algolia: Một sản phẩm SaaS cao cấp. Nó nhanh chớp nhoáng và không cần bảo trì. Tuy nhiên, chi phí tăng rất mạnh theo lưu lượng tìm kiếm. Khách hàng cũng cần lưu trữ dữ liệu tại chỗ (on-premise) để tuân thủ nghiêmặt các quy định về quyền riêng tư.
  3. Meilisearch: Một engine mã nguồn mở viết bằng Rust, được thiết kế đặc biệt cho tìm kiếm phía người dùng cuối. Nó nhẹ, hỗ trợ chịu lỗi chính tả theo mặc định và xử lý tìm kiếm theo bộ lọc một cách tự nhiên.

Tôi đã chọn Meilisearch. Nó mang lại tỷ lệ hiệu năng trên cấu hình tốt nhất trong khi vẫn giữ cho hạ tầng của chúng tôi tinh gọn thay vì cố gắng tinh chỉnh tìm kiếm PostgreSQL hiệu năng cao một cách thủ công.

Triển khai: Chuyển từ SQL sang Meilisearch

1. Khởi động Engine

Tôi đã triển khai Meilisearch thông qua Docker. Đây là cách đáng tin cậy nhất để chạy một instance production mà không lo lắng về xung đột thư viện cục bộ.

docker run -it --rm \
  -p 7700:7700 \
  -v $(pwd)/meili_data:/meili_data \
  getmeili/meilisearch:latest \
  meilisearch --master-key="MY_SECRET_MASTER_KEY"

2. Chuyển đổi và nhập dữ liệu

Meilisearch dựa trên cấu trúc JSON. Dữ liệu của khách hàng bị kẹt trong một file xuất CSV cũ kỹ và khổng lồ. Để tránh việc phải viết một script Python dùng một lần, tôi đã sử dụng toolcraft.app/vi/tools/data/csv-to-json để chuyển đổi file. Vì nó chạy ngay trên trình duyệt, dữ liệu không bao giờ rời khỏi máy của tôi. Việc này đã tiết kiệm khoảng 20 phút dọn dẹp bằng regex.

Với file products.json đã sẵn sàng, tôi đẩy dữ liệu vào index bằng cURL:

curl -X POST 'http://localhost:7700/indexes/products/documents' \
  -H 'Content-Type: application/json' \
  -H 'Authorization: Bearer MY_SECRET_MASTER_KEY' \
  --data-binary @products.json

3. Cấu hình bộ lọc (Facets) và sắp xếp

Tìm kiếm theo bộ lọc cho phép người dùng lọc theo thương hiệu hoặc đánh giá. Trong Meilisearch, bạn phải xác định rõ ràng các thuộc tính này để giữ cho index được tối ưu hóa. Đây là bước bắt buộc để có hiệu suất lọc cao.

curl -X PATCH 'http://localhost:7700/indexes/products/settings' \
  -H 'Authorization: Bearer MY_SECRET_MASTER_KEY' \
  -H 'Content-Type: application/json' \
  --data-binary '{
    "filterableAttributes": ["category", "brand", "rating"],
    "sortableAttributes": ["price", "rating"]
  }'

4. Frontend: Vòng lặp phản hồi tức thì

Để đạt được cảm giác “tức thì”, tôi đã sử dụng SDK meilisearch-js. Frontend hiện giờ bỏ qua API chính và truy vấn trực tiếp vào Meilisearch. Vì engine này được tối ưu hóa cho các tác vụ đọc nặng, nó có thể xử lý hàng trăm yêu cầu đồng thời với độ trễ dưới 50ms.

import { MeiliSearch } from 'meilisearch'

const client = new MeiliSearch({
  host: 'http://localhost:7700',
  apiKey: 'SEARCH_ONLY_PUBLIC_KEY', // Luôn sử dụng key giới hạn quyền cho phía client!
})

const index = client.index('products')

async function searchProducts(query) {
  const results = await index.search(query, {
    facets: ['category', 'brand'],
    attributesToHighlight: ['title'],
  });
  return results;
}

Kết quả: Thời gian phản hồi 12ms

Kết quả đến ngay lập tức. Sau khi triển khai, độ trễ tìm kiếm trung bình giảm từ 3.500ms xuống chỉ còn 12ms. Trải nghiệm tìm kiếm trở nên mượt mà. Ngay cả khi người dùng nhập “samung”, engine vẫn xác định đúng là “Samsung” nhờ thuật toán khoảng cách Levenshtein được tích hợp sẵn.

Các bộ lọc ở thanh bên cũng nhận được sự cải thiện đáng kể. Trước đây, việc nhấp vào một danh mục yêu cầu các lệnh SQL join nặng nề. Giờ đây, các bộ lọc đó cập nhật ngay tức khắc. Bằng cách giảm tải việc tìm kiếm khỏi cơ sở dữ liệu chính, chúng tôi đã thấy mức sử dụng CPU của DB tổng thể giảm 40%. Điều này đã ổn định toàn bộ môi trường ứng dụng, một kết quả rõ rệt khi chúng tôi tiến hành giám sát Database với Prometheus & Grafana.

Bài học rút ra

Đừng ép cơ sở dữ liệu chính của bạn phải làm mọi thứ. SQL rất xuất sắc cho các giao dịch và tính toàn vẹn dữ liệu, nhưng các công cụ tìm kiếm chuyên dụng được xây dựng để tối ưu tốc độ và độ liên quan. Nếu tính năng tìm kiếm của bạn có cảm giác chậm chạp, đừng chỉ thêm một index khác vào bảng SQL. Hãy chuyển sang một công cụ chuyên dụng như Meilisearch. Nó đã thay đổi hoàn toàn trải nghiệm người dùng của chúng tôi và quan trọng hơn là giữ cho chuông báo động im lặng lúc 2 giờ sáng.

Share: