Case study Nâng cao 4 phút đọc

Thiết kế typeahead autocomplete (search khi gõ)

Cách xây search-as-you-type trong .NET: cấu trúc trie, ranking sorted set Redis, debounce phía client, và làm mới qua pipeline streaming.

Mục lục
  1. Khi nào typeahead cần thiết kế riêng?
  2. Số nào nên ngân sách?
  3. Kiến trúc trông thế nào?
  4. Cấu hình .NET 10 với sorted set Redis?
  5. Pipeline tươi giữ ranking gần ra sao?
  6. Đường scale-out hỗ trợ?
  7. Tạo failure mode nào?
  8. Khi nào autocomplete quá liều?
  9. Đi tiếp đâu từ đây?

Search-as-you-type là case study nơi cấu trúc dữ liệu quan trọng: chọn sai cho latency 500 ms, chọn đúng cho 20 ms. Chương này cho hai cài đặt thực dụng - sorted set Redis và service trie tuỳ - và cấu hình .NET cộng pipeline làm mới giữ ranking gần.

Khi nào typeahead cần thiết kế riêng?

Ba tín hiệu.

Latency phải dưới 100 ms. Chậm hơn user gõ vượt gợi ý. Tầng cache bắt buộc; database một mình không đạt latency đó ở scale.

Vocabulary đổi. Tên sản phẩm mới, search trending, thành phố mới đăng - data không tĩnh. Cần pipeline update ranking, không phải build index một lần.

Nhiều term, nhiều prefix. Search xuyên triệu term với triệu prefix. Query 'WHERE name LIKE @prefix%' ngây thơ sập; cấu trúc dữ liệu chuyên biệt có giá trị.

Nếu có vài trăm term tĩnh (danh sách quốc gia, dropdown ngôn ngữ), filter phía client là đúng.

Số nào nên ngân sách?

Search / ngày               50M
Avg keystroke / search      7
Tổng query prefix           50M * 7 = 350M / ngày
Sau debounce client         350M / 7 = 50M (một mỗi pause)
Đỉnh QPS                    50M / 100K * 5 = 2,500
Budget latency              < 100 ms p99 đầu cuối
Kích thước vocabulary       1M term unique
Avg bucket prefix           top 10 completion

Không có debounce client, QPS gấp 7x; debounce là tối ưu phía server lớn nhất. 1M vocabulary vừa thoải mái Redis - sorted set với TTL giữ mới, kèm rate limit bảo vệ endpoint khỏi abuse.

Kiến trúc trông thế nào?

flowchart LR
    Client[Browser] -->|debounce 200ms| App[ASP.NET Core]
    App -->|ZRANGE prefix| Redis[(Redis<br/>sorted set)]
    App --> Result[top K completion]
    SearchLog[Click search] --> Stream[(Kafka / queue event)]
    Stream --> Agg[Aggregator]
    Agg -->|update score| Redis
    Reload[Batch đêm] -->|rebuild đầy đủ| Redis

Đường query là một call Redis. Đường tươi là aggregator streaming update score. Job batch đêm rebuild từ đầu để phục hồi mọi drift.

Cấu hình .NET 10 với sorted set Redis?

Lúc index:

public class AutocompleteIndexer(IConnectionMultiplexer redis)
{
    public async Task IndexTermAsync(string term, double popularity)
    {
        var db = redis.GetDatabase();
        var lower = term.ToLowerInvariant();
        // Mỗi prefix từ độ dài 2 đến len(term), thêm term vào sorted set.
        for (var len = 2; len <= lower.Length; len++)
        {
            var prefix = lower.Substring(0, len);
            await db.SortedSetAddAsync($"ac:{prefix}", term, popularity);
        }
    }
}

Lúc query:

app.MapGet("/autocomplete", async (string q, IConnectionMultiplexer redis) =>
{
    if (string.IsNullOrWhiteSpace(q) || q.Length < 2) return Results.Ok(Array.Empty<string>());
    var db = redis.GetDatabase();
    var key = $"ac:{q.ToLowerInvariant()}";
    var top = await db.SortedSetRangeByRankAsync(key, 0, 9, Order.Descending);
    return Results.Ok(top.Select(v => v.ToString()).ToArray());
})
.RequireRateLimiting("autocomplete");

Ba chi tiết. Fanout prefix lúc index nhân storage theo độ dài term trung bình (~10x). Cho triệu term, đó là ~10M entry sorted set - rẻ trên Redis. Query là một ZRANGE - dưới mili giây. Rate limit thiết yếu vì endpoint autocomplete dễ lạm dụng.

Pipeline tươi giữ ranking gần ra sao?

flowchart LR
    SearchEvent --> K[(Kafka)]
    K --> Counter[Worker đếm]
    Counter --> Window[(Đếm 7d sliding)]
    Window --> Updater[Updater score]
    Updater -->|ZADD hoặc ZINCRBY| Redis
    Reload[Job đêm] --> Redis

Event search stream qua Kafka. Aggregator cửa sổ tính count 7 ngày mỗi term. Updater push score mới sang Redis qua ZADD (ghi đè) hoặc ZINCRBY (tăng dần). Job đêm là phương án an toàn rebuild toàn bộ index từ log; ngay cả mọi worker streaming vỡ, autocomplete đuổi kịp trong 24 giờ.

Đường scale-out hỗ trợ?

Cho vocabulary rất lớn (>10M term), service trie tuỳ trong .NET (server giữ trie trong memory) cắt memory Redis đáng kể.

Tạo failure mode nào?

Khi nào autocomplete quá liều?

Khi input từ tập cố định nhỏ (tên bang, mã ngôn ngữ) - load phía client và filter ở đó. Khi backend search đã trả top-10 dưới 50 ms (index nhỏ), gọi trực tiếp. Service autocomplete chuyên là cho ca search engine quá chậm hay quá đắt để gọi mỗi phím.

Đi tiếp đâu từ đây?

Case study kế tiếp: hệ thanh toán / checkout - ca idempotency, saga, và reliability đều va. Case study khó nhất trong series.

Câu hỏi thường gặp

Trie hay sorted set Redis?
Sorted set mỗi prefix đơn giản hơn và dùng một call Redis mỗi query. Trie tiết kiệm memory hơn khi có triệu term nhưng cần service tuỳ. Cho phần lớn autocomplete sản phẩm (vài trăm nghìn term), sorted-set là đủ và dễ vận hành. Chuyển sang trie khi memory thành vấn đề.
Sao cần debounce phía client?
Không có nó, mỗi phím gửi một request - user gõ 'system' gửi sáu request trong vài trăm ms. Debounce chờ ~200 ms không hoạt động trước khi gửi. Giảm tải server 80% và cải thiện UX cảm nhận vì user thấy một kết quả ổn thay vì ba kết quả nhấp nháy.
Giữ ranking tươi ra sao?
Pipeline streaming. Click search emit event, aggregator tính count ngày và tuần, worker update sorted set Redis. Case study analytics events lo cùng hình - autocomplete là consumer downstream của cùng stream event.
Còn dung sai typo?
Hai tầng. Một, normalise input (lowercase, bỏ dấu) trước lookup. Hai, fallback fuzzy - nếu prefix trả rỗng, query Elasticsearch với Fuzziness AUTO. Chương search lo fuzzy match; kết hợp hai cho UX tốt nhất.