Làm rõ quy mô: 5 triệu sản phẩm, vài nghìn shop, traffic đọc gấp hàng trăm lần ghi. Đó là lý do tách đường đọc và đường ghi (CQRS ở mức nhẹ, không cần event sourcing).
Nguồn sự thật là Postgres:
products(id, shop_id, title, price, status, updated_at)
variants(id, product_id, sku, price, stock)
categories(id, parent_id, path) -- path dang '/dien-tu/dien-thoai'Dùng cột path (materialized path) cho cây danh mục — truy vấn "mọi sản phẩm trong nhánh Điện tử" chỉ là một điều kiện path LIKE '/dien-tu%', không đệ quy.
Đường đọc là một chỉ mục tìm kiếm (Elasticsearch/OpenSearch) chứa document đã làm phẳng: title, brand, giá, thuộc tính lọc, điểm bán chạy. Đồng bộ từ DB bằng outbox + worker, không ghi đôi từ code ứng dụng — ghi đôi sẽ lệch ngay khi một bên lỗi.
Ba việc tìm kiếm phải làm được:
- Full-text tiếng Việt: đánh chỉ mục cả bản có dấu và bản bỏ dấu để "dien thoai" khớp "điện thoại".
- Faceted filter: hãng, khoảng giá, đánh giá — dùng aggregation của chỉ mục, không tự đếm trong DB.
- Xếp hạng: trộn độ khớp văn bản với tín hiệu kinh doanh (bán chạy, còn hàng, shop uy tín). Sản phẩm hết hàng nên tụt hạng chứ đừng ẩn — ẩn làm kết quả nhảy loạn.
Điểm nghẽn:
- Trang danh mục là đường nóng nhất → cache kết quả theo tổ hợp (danh mục + bộ lọc + trang) với TTL ngắn; TTL ngắn dễ vận hành hơn invalidation thủ công.
- Tồn kho và giá không đọc từ chỉ mục ở trang chi tiết — đọc từ DB/Redis, vì chỉ mục trễ vài giây.
Đánh đổi: chỉ mục làm dữ liệu nhất quán cuối (sửa tên sản phẩm hiển thị sau vài giây). Chấp nhận được với danh sách, không chấp nhận được với giá lúc đặt hàng — nên giá luôn được kiểm tra lại ở bước tạo đơn.