Bỏ qua đến nội dung
Quay lại

Thiết kế Dịch vụ Gợi ý Tìm kiếm

Đã đăng:  at  10:00 SA

1. Bài toán và phạm vi

[SOURCE FACT] Ô tìm kiếm cần trả gợi ý ngay khi người dùng còn đang nhập. Với tiền tố như phot, dịch vụ trả về danh sách ngắn, có thứ tự, chẳng hạn “photo printer”, “photo editor” và “photography”. Web, mobile và client hỗ trợ giọng nói đều dùng cùng dịch vụ này.

Luồng request phải phục vụ khách truy cập ẩn danh, khách hàng đã đăng nhập và các job ingestion nội bộ. Dịch vụ cần hỗ trợ:

[SOURCE FACT] SLO hướng tới người dùng là p99 dưới 50 ms tại biên dịch vụ, không tính mạng client. Core prefix path có mục tiêu availability 99.99% theo tháng. Kết quả cũ nhưng an toàn tốt hơn lỗi. Gợi ý không được làm lộ query riêng tư, blocked term hoặc dữ liệu của tenant khác. Click hoặc search event có thể mất trong thời gian ngắn, nhưng request ingestion đã được chấp nhận phải idempotent và cuối cùng được lập index.

[ANALYSIS] Prefix lookup tự nó không phải phần khó nhất. Vấn đề là kết hợp nhiều nguồn dữ liệu mà không để dependency tùy chọn kéo dài latency của critical path. Vì vậy serving index được tối ưu riêng cho đọc, event ingestion được tách riêng, còn personalization và trending là các input có giới hạn và có thể suy giảm có kiểm soát.

2. Giả định về capacity

[ASSUMPTIONS] Đây là các input để lập kế hoạch, không phải số đo từ một hệ thống production cụ thể. Trước khi cam kết capacity, cần thay chúng bằng telemetry production.

[ANALYSIS] Các giả định này dẫn tới một serving index tối ưu cho đọc và nằm trong memory, một event pipeline có thể scale độc lập, cùng fan-out có giới hạn. Hot path không nên query relational database một lần cho mỗi keystroke.

3. API contract

[PROPOSED DESIGN] Public endpoint:

GET /v1/suggestions?q=phot&limit=8&category=all&locale=en-US
Authorization: Bearer <token>       # optional for personalization
X-Request-Id: 7f2c...
{
  "query": "phot",
  "suggestions": [
    {"text": "photo printer", "category": "product", "score": 0.94},
    {"text": "photo editor", "category": "app", "score": 0.89}
  ],
  "complete": true,
  "sources": ["global", "trending"],
  "index_version": "2026-08-15T09:59:40Z",
  "request_id": "7f2c..."
}

Giới hạn limit ở 10. Trước khi lookup, chuẩn hóa query bằng Unicode normalization, case folding và giới hạn độ dài. Trả 200 với complete: false khi một source tùy chọn timeout. Trả 400 cho locale hoặc dạng query không hợp lệ, chỉ trả 401 khi một tính năng được yêu cầu rõ ràng cần identity đã xác thực, và trả 429 khi caller vượt quota. Nếu core index không khả dụng, trả cached result khi có; nếu không, trả 503 kèm Retry-After.

Ingestion dùng endpoint riêng:

POST /v1/query-events
Idempotency-Key: 6b5d6b9e-...
Authorization: Bearer <token>
Content-Type: application/json

{"query":"photo printer","selected_suggestion":"photo printer","locale":"en-US","occurred_at":"2026-08-15T03:00:02Z"}

Trả 202 Accepted sau khi durable queue nhận event. Phạm vi của idempotency key là tenant và endpoint; giữ key trong 48 giờ. Client không được retry 4xx, ngoại trừ 429; có thể retry khi mất response bằng cùng key. Server cấp producer timestamp. Không dùng các field của event làm nguồn xác thực authorization hoặc tenant identity.

4. Dữ liệu nguồn và serving representation

[SOURCE FACT] Metadata relational là source of truth vì ownership của phrase, moderation và versioned publication cần constraint và transaction.

CREATE TABLE phrases (
  tenant_id       BIGINT NOT NULL,
  phrase_id       BIGINT NOT NULL,
  normalized_text  TEXT NOT NULL,
  locale           TEXT NOT NULL,
  category         TEXT NOT NULL,
  status           TEXT NOT NULL,
  base_score       DOUBLE PRECISION NOT NULL,
  updated_at       TIMESTAMPTZ NOT NULL,
  PRIMARY KEY (tenant_id, phrase_id),
  UNIQUE (tenant_id, locale, normalized_text)
);

CREATE INDEX phrases_lookup
  ON phrases (tenant_id, locale, status, normalized_text);

CREATE TABLE query_events (
  tenant_id       BIGINT NOT NULL,
  event_id        UUID NOT NULL,
  idempotency_key TEXT NOT NULL,
  user_id         BIGINT,
  normalized_text TEXT NOT NULL,
  category        TEXT,
  occurred_at     TIMESTAMPTZ NOT NULL,
  PRIMARY KEY (tenant_id, event_id),
  UNIQUE (tenant_id, idempotency_key)
);

CREATE INDEX events_time ON query_events (tenant_id, occurred_at);

Unique constraint của phrase ngăn catalog entry trùng trong cùng tenant và locale. phrases_lookup phục vụ tool moderation và rebuild, không phục vụ hot suggestion path. (tenant_id, event_id) cung cấp durable deduplication; time index phục vụ windowed aggregation. Partition các bảng event lớn theo ngày để retention 30 ngày có thể thực hiện bằng cách drop partition thay vì xóa một tỷ row.

[PROPOSED DESIGN] Phục vụ từ snapshot FST/trie immutable được memory-map. Mỗi terminal lưu phrase ID, category, base score và các tham chiếu compact tới ranking feature. Giữ một danh sách riêng theo user, key bằng (tenant_id, user_id, locale), với TTL ngắn.

Partition event stream theo hash(tenant_id, normalized_text). Normalized phrase là ordering key cho window aggregation xác định. Tenant-specific salt có thể ngăn pattern placement dễ đoán giữa các tenant; salt không được làm suy yếu tenant isolation hoặc authorization.

5. Read path

[PROPOSED DESIGN] Request handler thực hiện một chuỗi có giới hạn:

  1. Authenticate khi request yêu cầu personalization hoặc feature phụ thuộc identity, sau đó validate tenant, locale và dạng query.
  2. Chuẩn hóa query và kiểm tra response cache nhỏ; cache key phải gồm tenant, locale, category và prefix đã chuẩn hóa.
  3. Đọc local global index để lấy prefix match. Một side index chịu typo có thể cung cấp candidate cho các biến thể edit-distance-one phổ biến.
  4. Fetch per-user list và trending candidate song song, mỗi nguồn có timeout ngắn và result set bị giới hạn.
  5. Loại candidate bị block, private, trùng hoặc thuộc tenant khác trước khi rank.
  6. Merge candidate bằng category, base score, trend, recency và personalization feature, sau đó trả tối đa số lượng được yêu cầu.

Response ghi rõ source nào đã đóng góp. Nếu fetch tùy chọn thất bại, trả global result với complete: false; client không phải suy đoán completeness từ một list rỗng. Nếu core index thất bại, dùng cache fallback có giới hạn. Circuit breaker (cơ chế ngắt mạch) ngăn gọi lặp tới dependency không lành mạnh; backpressure (kiểm soát áp lực ngược) giới hạn công việc khi traffic vượt khả năng xử lý.

6. Ingestion và index build

[PROPOSED DESIGN] Ingestion endpoint validate tenant đã xác thực và payload, xử lý idempotency rồi append event vào durable queue. Consumer cập nhật aggregate và ghi relational event store bất đồng bộ. 202 hoàn tất nghĩa là queue đã nhận request, không có nghĩa phrase đã xuất hiện trong serving index.

Consumer phải chịu được duplicate và delivery không đúng thứ tự. Vì vậy aggregate update cần idempotent key và chính sách event-time rõ ràng. Mất một click có thể làm ranking chậm cập nhật; retry của client không được tạo ra event accepted thứ hai.

Build snapshot FST/trie mới từ relational metadata đã được duyệt và các feature đã tính. Validate snapshot trước khi publish, sau đó publish version một cách atomic. Serving process có thể giữ snapshot cũ trong lúc load snapshot mới. index_version trong response giúp đối chiếu hành vi với snapshot cụ thể.

7. Ranking, privacy và moderation

[ANALYSIS] Ranking cần minh bạch về nguồn của feature. Global popularity và editorial score là tín hiệu cấp catalog. Trending lấy từ aggregate gần đây và cần smoothing hoặc cap để một event nhiễu không chi phối. Personalization có phạm vi tenant và user, nên là thành phần tùy chọn trên latency-critical path.

[PROPOSED DESIGN] Áp dụng privacy và moderation filter trước khi trả candidate, không chỉ trong offline indexing. Không đưa raw private query vào global index dùng chung. Blocked term phải bị loại khỏi cả serving snapshot và fallback cache. Cache key và dữ liệu theo user phải có tenant scope; cache hit vẫn không thay thế bước authorization.

8. Failure handling và vận hành

[PROPOSED DESIGN] Đặt timeout độc lập cho cache, global index, personalization và trending dependency. Aggregate timeout phải chừa thời gian serialize và gửi response trong mục tiêu p99 50 ms. Chỉ retry operation có thể retry an toàn, đồng thời giới hạn retry budget để tránh retry storm.

Theo dõi latency theo source và theo result completeness, cùng cache hit rate, index version, queue lag, consumer failure, event bị reject, deduplication conflict và số candidate bị moderation filter. Alerting cần phân biệt core index unavailable với optional source degraded.

Dùng rate limit và quota theo tenant. Bảo vệ event endpoint bằng authentication, payload validation, giới hạn durable queue và backpressure. Redact query text khỏi log thông thường, trừ khi cần theo privacy policy đã được phê duyệt.

9. Trade-off

10. Tóm tắt

[ANALYSIS] Thiết kế giữ interactive path nhỏ: chuẩn hóa prefix, đọc immutable index cục bộ, merge các source tùy chọn có giới hạn, filter an toàn rồi trả partial result khi source tùy chọn lỗi. Ingestion, aggregation và index publication chạy bất đồng bộ, scale độc lập. Các ranh giới vận hành được xác định rõ: durable queue xác định ingestion đã được nhận, immutable snapshot xác định dữ liệu đang được phục vụ, còn tenant-aware filter được áp dụng trước khi result đến client.


Chia sẻ bài viết này trên:

Bài trước
Thiết kế hệ thống chat thời gian thực bền vững
Bài tiếp theo
Thiết kế dịch vụ rút ngắn URL cho lưu lượng chuyển hướng lớn