Vào lúc 2:14 sáng ngày Black Friday, chuông cảnh báo PagerDuty on-call đã đánh thức toàn bộ đội ngũ hạ tầng của chúng tôi. Máy chủ PostgreSQL chính 16-core chạm ngưỡng 100% CPU. PgBouncer đã dùng hết sạch 300 connection trong pool, và độ trễ truy vấn trung vị vọt từ 22ms lên hơn 9 giây.
Thủ phạm lộ diện ngay lập tức trong pg_stat_activity: hàng nghìn khách hàng mua sắm đồng thời gõ các từ khóa như samusng glalxy vào thanh tìm kiếm trên trang web. Mỗi lỗi gõ sai (typo) đều kích hoạt một truy vấn quét wildcard ILIKE '%query%' trên toàn bộ 1,4 triệu dòng dữ liệu. Chúng tôi buộc phải chuyển tải lưu lượng tìm kiếm sang một search engine chuyên dụng trước đợt bùng nổ chiến dịch marketing lúc 8:00 sáng.
Chúng tôi đã triển khai Meilisearch trên Docker, tinh chỉnh typo tolerance cùng bộ lọc faceted search, và thiết lập một pipeline đồng bộ dữ liệu theo kiến trúc hướng sự kiện (event-driven). Khi bình minh ló rạng, độ trễ tìm kiếm đã giảm xuống dưới 6 mili-giây.
Bắt đầu nhanh (5 phút)
Khi hệ thống production đang gặp sự cố nghiêm trọng, các giải pháp đơn giản luôn là lựa chọn tối ưu. Docker cung cấp cho bạn một search node độc lập, sẵn sàng cho môi trường production với bộ lưu trữ bền vững (persistent storage) chỉ trong vòng chưa đầy năm phút.
1. Định nghĩa Service Docker Compose
Tạo một file docker-compose.yml gọn gàng. Cố định phiên bản engine để tránh các sự cố nâng cấp định dạng index ngoài ý muốn, mount một named volume cho các file database, và cung cấp một master key dài 32 ký tự:
version: '3.8'
services:
meilisearch:
image: getmeili/meilisearch:v1.6
container_name: meilisearch_prod
restart: always
ports:
- "7700:7700"
environment:
- MEILI_ENV=production
- MEILI_MASTER_KEY=4a8f9c2d1e0b3a7f8e5d2c1b9a0f3e6d
- MEILI_NO_ANALYTICS=true
volumes:
- meili_data:/meili_data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:7700/health"]
interval: 10s
timeout: 5s
retries: 3
volumes:
meili_data:
driver: local
2. Khởi chạy và Kiểm tra Trạng thái Health
Khởi động container ở chế độ chạy ngầm (background):
docker compose up -d
curl -X GET 'http://localhost:7700/health'
Endpoint phản hồi {"status":"available"} trong vòng hai giây. Dịch vụ đã hoạt động và sẵn sàng nạp dữ liệu danh mục sản phẩm.
Đi sâu vào chi tiết
Các cơ sở dữ liệu quan hệ đòi hỏi khớp chính xác, nếu không sẽ làm quá tải ổ đĩa bằng các tác vụ quét tuần tự toàn bộ bảng (sequential table scan). Meilisearch đi theo hướng ngược lại bằng cách sử dụng prefix tree trong bộ nhớ và tính toán khoảng cách lỗi gõ phím (typo distance). Tuy nhiên, các cấu hình mặc định ban đầu có thể làm giảm sút chất lượng tìm kiếm nếu bạn kinh doanh các sản phẩm có mã model ngắn.
Tinh chỉnh Typo Tolerance
Meilisearch chấp nhận một lỗi gõ sai đối với từ có từ 5 ký tự trở lên, và hai lỗi đối với từ có từ 9 ký tự trở lên. Quy tắc này hoạt động rất tốt với văn bản thông thường, nhưng sẽ hoàn toàn phản tác dụng đối với các mã định danh phần cứng gồm 4 ký tự hoặc mã SKU cần độ chính xác tuyệt đối.
Bạn có thể điều chỉnh các ngưỡng này thông qua endpoint cài đặt index. Cấu hình này đảm bảo các truy vấn như "headphnes" vẫn trả về kết quả mượt mà trong khi các thuật ngữ kỹ thuật như "RTX" hay mã SKU danh mục vẫn được kiểm soát nghiêm ngặt:
curl -X PATCH 'http://localhost:7700/indexes/products/settings/typo-tolerance' \
-H 'Authorization: Bearer 4a8f9c2d1e0b3a7f8e5d2c1b9a0f3e6d' \
-H 'Content-Type: application/json' \
--data-binary '{
"enabled": true,
"minWordSizeForTypos": {
"oneTypo": 4,
"twoTypos": 8
},
"disableOnWords": ["amd", "rtx", "usb"],
"disableOnAttributes": ["sku"]
}'
Hãy đặc biệt chú ý đến disableOnAttributes. Vô hiệu hóa tính năng sửa lỗi gõ sai trên trường sku sẽ ngăn engine trả về các linh kiện không liên quan khi khách hàng nhập sai dù chỉ một chữ số trong số serial.
Cấu hình Faceted Search
Người mua sắm hiếm khi chỉ dừng lại ở việc gõ văn bản tự do. Họ còn lọc sâu hơn theo thương hiệu, tình trạng còn hàng và mức ngân sách. Meilisearch yêu cầu bạn phải khai báo tường minh trước các trường có thể lọc (filterable) và sắp xếp (sortable) để hệ thống tính toán trước các roaring bitmap index:
curl -X PATCH 'http://localhost:7700/indexes/products/settings' \
-H 'Authorization: Bearer 4a8f9c2d1e0b3a7f8e5d2c1b9a0f3e6d' \
-H 'Content-Type: application/json' \
--data-binary '{
"filterableAttributes": [
"category",
"brand",
"in_stock",
"price"
],
"sortableAttributes": [
"price",
"created_at"
]
}'
Sau khi các bitmap được tạo, các truy vấn lọc phức tạp sẽ được xử lý trực tiếp ngay trên cấu trúc index mà không cần quét qua các document thô:
curl -X POST 'http://localhost:7700/indexes/products/search' \
-H 'Authorization: Bearer 4a8f9c2d1e0b3a7f8e5d2c1b9a0f3e6d' \
-H 'Content-Type: application/json' \
--data-binary '{
"q": "headphne",
"filter": "brand = 'Sony' AND price <= 300 AND in_stock = true",
"facets": ["category", "brand"]
}'
Trong các bài kiểm tra của chúng tôi dưới tải 450 request đồng thời mỗi giây, truy vấn này phản hồi chỉ trong 4,2 mili-giây.
Sử dụng nâng cao
Chuyển tính năng tìm kiếm ra khỏi database chính sẽ phát sinh thêm chi phí đồng bộ trạng thái dữ liệu. Nếu một mặt hàng đã bán hết trong PostgreSQL nhưng Meilisearch vẫn hiển thị còn hàng, bạn sẽ phải đối mặt với tình trạng đặt hàng vượt quá tồn kho (backorder) và sự thất vọng từ phía khách hàng.
Đồng bộ ở Cấp ứng dụng qua Transaction Outbox
Tuyệt đối không gọi trực tiếp API tìm kiếm bên ngoài bên trong chu kỳ request-response HTTP chính của ứng dụng. Nếu cụm search bị trễ, các luồng (thread) API sẽ bị nghẽn. Thay vào đó, hãy ghi lại các thay đổi trạng thái vào một bảng outbox trong database hoặc message broker, sau đó để một background worker xử lý cập nhật theo đợt (batch):
import os
import time
from meilisearch import Client
MEILI_URL = os.getenv("MEILI_URL", "http://localhost:7700")
MEILI_KEY = os.getenv("MEILI_KEY", "4a8f9c2d1e0b3a7f8e5d2c1b9a0f3e6d")
client = Client(MEILI_URL, MEILI_KEY)
index = client.index("products")
def sync_product_record(product_data):
"""
Đẩy bản cập nhật sản phẩm đơn lẻ hoặc theo lô lên Meilisearch.
Meilisearch tự động xử lý việc ghi đè document theo 'id'.
"""
task = index.add_documents([{
"id": product_data["id"],
"name": product_data["name"],
"sku": product_data["sku"],
"brand": product_data["brand"],
"category": product_data["category"],
"price": float(product_data["price"]),
"in_stock": product_data["stock_quantity"] > 0,
"updated_at": int(time.time())
}], primary_key="id")
print(f"Đã đưa tác vụ đồng bộ vào hàng đợi, task ID: {task.task_uid}")
# Sự kiện mẫu từ một consumer outbox
new_item = {
"id": 1042,
"name": "Tai nghe chống ồn không dây",
"sku": "WH-1000XM5",
"brand": "Sony",
"category": "Âm thanh",
"price": 299.99,
"stock_quantity": 12
}
sync_product_record(new_item)
Khởi tạo dữ liệu mẫu và Migration
Trong quá trình triển khai khẩn cấp, chúng tôi phải backfill 52.000 bản ghi dữ liệu cũ vào Meilisearch từ các bản dump database thô. Khi chuyển đổi các file kho hàng định dạng CSV đã export sang các chunk JSON để import theo lô, tôi sử dụng toolcraft.app/vi/tools/data/csv-to-json. Quá trình phân tích dữ liệu (parsing) chạy ngay phía client trên trình duyệt, nhờ đó dữ liệu sản phẩm không bao giờ rời khỏi máy trạm làm việc của bạn. Điều này mang lại cho chúng tôi các payload JSON chuẩn chỉnh để nạp thẳng vào Meilisearch mà không cần phải vội vã viết các script phân tích dùng một lần dưới áp lực thời gian.
Kinh nghiệm thực tế
Dưới đây là ba bài học vận hành mà chúng tôi đã đúc kết lại sau khi vượt qua đợt cao điểm Black Friday:
1. Tuyệt đối không để Master Key trong mã nguồn Frontend
MEILI_MASTER_KEY cấp toàn quyền quản trị, bao gồm cả việc xóa index. Đối với các ứng dụng Single-Page Application (SPA) phía client và ứng dụng di động, hãy tạo một API key giới hạn phạm vi quyền (scoped API key) chỉ dành riêng cho các thao tác đọc:
curl -X POST 'http://localhost:7700/keys' \
-H 'Authorization: Bearer 4a8f9c2d1e0b3a7f8e5d2c1b9a0f3e6d' \
-H 'Content-Type: application/json' \
--data-binary '{
"description": "Key công khai chỉ dùng để tìm kiếm",
"actions": ["search"],
"indexes": ["products"],
"expiresAt": null
}'
Lưu token key nhận được vào biến môi trường phía frontend. Ngay cả khi kẻ tấn công kiểm tra các network request từ phía client, họ cũng không thể sửa đổi cấu hình hoặc trích xuất toàn bộ collection dữ liệu thô.
2. Tái tạo Index Không Gián đoạn (Zero-Downtime) với Kỹ thuật Index Swapping
Việc thay đổi quy tắc tokenizer hoặc các stop word buộc Meilisearch phải re-index lại toàn bộ tài liệu. Tái lập chỉ mục trực tiếp trên index đang hoạt động sẽ khiến độ trễ truy vấn tăng đột biến. Giải pháp an toàn hơn là tạo một index phụ chạy ngầm, kiểm tra tính toàn vẹn rồi thực hiện hoán đổi nguyên tử (atomic swap):
# Bước 1: Nạp các bản ghi vào products_v2
# Bước 2: Hoán đổi nguyên tử các alias mà không làm gián đoạn các truy vấn tìm kiếm đang hoạt động
curl -X POST 'http://localhost:7700/swap-indexes' \
-H 'Authorization: Bearer 4a8f9c2d1e0b3a7f8e5d2c1b9a0f3e6d' \
-H 'Content-Type: application/json' \
--data-binary '[
{
"indexes": ["products", "products_v2"]
}
]'
Quá trình swap diễn ra trong chưa đầy 10 mili-giây. Các request đang xử lý dở dang (in-flight requests) sẽ hoàn tất trên dữ liệu cũ, trong khi tất cả các request tiếp theo sẽ lập tức chuyển sang products_v2.
3. Giám sát Memory Mapping của LMDB
Meilisearch lưu trữ dữ liệu index trong LMDB – cơ chế phụ thuộc rất lớn vào page cache bộ nhớ ảo của Linux thay vì các bộ cấp phát bộ nhớ nội bộ tùy chỉnh. Trong Docker, hãy phân bổ giới hạn bộ nhớ ít nhất gấp 2 lần kích thước tập dữ liệu thô của bạn. Với file database 3,2 GB, chúng tôi đã đặt giới hạn mem_limit: 8g. Bất kỳ mức phân bổ nào thấp hơn con số này đều có nguy cơ kích hoạt OOM kill khi tiến trình lập chỉ mục ngầm chạy trùng với thời điểm lưu lượng tìm kiếm tăng đột biến.

