Thiết kế hệ thống tìm kiếm sản phẩm với autocomplete

1. Tổng quan

Gõ “ao th” vào ô tìm kiếm, người dùng muốn thấy ngay “áo thun nam”, “áo thun cotton” trong chưa tới 100ms. Bài này đi qua một case study đầy đủ: yêu cầu, mô hình dữ liệu, API, luồng ghi và đọc, rồi tới các điểm khó như hot key, nhất quán và tải đột biến.

Đọc xong, bạn có thể dựng một dịch vụ autocomplete bằng Spring Boot, Redis và Kafka. Bạn cũng sẽ biết vì sao từng quyết định thiết kế được chọn.

2. Yêu cầu

2.1. Yêu cầu chức năng

  • Người dùng gõ tối thiểu 2 ký tự. Hệ thống trả về tối đa 10 gợi ý, xếp theo độ phổ biến.
  • Gõ có dấu hay không dấu đều khớp: “ao thun” phải khớp với “Áo thun”.
  • Gợi ý lấy từ hai nguồn: từ khoá người dùng hay tìm, và tên sản phẩm, thương hiệu, danh mục.
  • Admin có thể chặn từ khoá nhạy cảm. Từ bị chặn phải biến mất khỏi gợi ý.

2.2. Quy mô và ràng buộc

  • Khoảng 2 triệu cụm gợi ý và 5 triệu sản phẩm.
  • Lưu lượng bình thường 5.000 request/giây. Lúc flash sale lên 30.000 request/giây.
  • p99 latency phía server dưới 50ms.
  • Độ tươi chấp nhận được là 1–2 phút. Đây là eventual consistency: một từ khoá vừa hot có thể chưa xuất hiện ngay, và điều đó chấp nhận được.

Ràng buộc cuối rất quan trọng. Nó cho phép tách hẳn luồng ghi (cập nhật điểm) khỏi luồng đọc (trả gợi ý).

3. Kiến trúc tổng thể

Client ──(debounce 150ms)──> API Gateway ──> Autocomplete Service ──> Caffeine (local)
                                                     │                      │ miss
                                                     │                      v
                                                     │               Redis: ac:p:{prefix}
Search Service ──(search-events)──> Kafka ──> Aggregator ──(flush 30s)──> Redis
Catalog Service ──(product-events)──┘

Ý tưởng chính là tính trước top-K cho từng prefix. Mỗi prefix như “ao”, “ao t”, “ao th” có một Redis sorted set riêng. Set này chỉ giữ tối đa 50 cụm có điểm cao nhất. Khi đọc, bạn chỉ cần một lệnh ZREVRANGE có độ phức tạp O(log N + K). Không cần quét, không cần tính toán.

Ước lượng bộ nhớ: 2 triệu cụm, giới hạn prefix 20 ký tự. Sau khi gộp các prefix trùng còn khoảng 10 triệu key. Mỗi key giữ 50 phần tử khoảng 40 byte, tổng cộng cỡ 20GB kể cả overhead. Con số này vừa với một Redis Cluster 3 shard có replica.

4. Mô hình dữ liệu

Dữ liệu gốc nằm trong PostgreSQL. Redis chỉ là index phục vụ đọc và có thể dựng lại bất cứ lúc nào.

CREATE TABLE suggestion (
    id            BIGSERIAL PRIMARY KEY,
    display_text  VARCHAR(200) NOT NULL,          -- "Áo thun nam cotton"
    normalized    VARCHAR(200) NOT NULL UNIQUE,   -- "ao thun nam cotton"
    source        VARCHAR(20)  NOT NULL,          -- QUERY | PRODUCT | BRAND
    score         BIGINT       NOT NULL DEFAULT 0,
    blocked       BOOLEAN      NOT NULL DEFAULT FALSE,
    updated_at    TIMESTAMPTZ  NOT NULL DEFAULT now()
);

CREATE INDEX idx_suggestion_score ON suggestion (score DESC) WHERE blocked = FALSE;

Phía Redis có ba loại key:

ac:score            ZSET  member=display_text, score=tổng điểm toàn cục
ac:p:{prefix}       ZSET  member=display_text, score=tổng điểm (tối đa 50 phần tử)
ac:blocked          SET   các display_text bị chặn

5. Chuẩn hoá tiếng Việt và sinh prefix

Đây là phần dễ sai nhất. Bạn phải bỏ dấu, chuyển về chữ thường, gộp khoảng trắng, và xử lý riêng chữ “đ” vì Normalizer không tách được nó.

public final class TextNormalizer {

    private static final Pattern MARKS = Pattern.compile("\\p{M}+");
    private static final Pattern SPACES = Pattern.compile("\\s+");

    private TextNormalizer() {}

    public static String normalize(String input) {
        if (input == null) return "";
        String s = Normalizer.normalize(input, Normalizer.Form.NFD);
        s = MARKS.matcher(s).replaceAll("")
                 .replace('đ', 'd').replace('Đ', 'D')
                 .toLowerCase(Locale.ROOT);
        return SPACES.matcher(s).replaceAll(" ").trim();
    }

    public static List<String> prefixes(String normalized, int minLen, int maxLen) {
        List<String> result = new ArrayList<>();
        int end = Math.min(normalized.length(), maxLen);
        for (int i = minLen; i <= end; i++) {
            String p = normalized.substring(0, i);
            if (!p.endsWith(" ")) result.add(p); // "ao " trùng với "ao" sau khi trim
        }
        return result;
    }
}

Form.NFD tách “á” thành “a” và một dấu kết hợp. Regex \p{M} sau đó xoá phần dấu. Hàm prefixes bỏ qua các prefix kết thúc bằng khoảng trắng. Lý do: query của người dùng đã được trim(), nên key ac:p:ao sẽ không bao giờ được đọc.

Test cho hành vi chính:

class TextNormalizerTest {

    @Test
    void shouldRemoveVietnameseDiacritics() {
        assertThat(TextNormalizer.normalize("  Áo Thun ĐỎ   nam "))
            .isEqualTo("ao thun do nam");
    }

    @Test
    void shouldGeneratePrefixesWithoutTrailingSpace() {
        assertThat(TextNormalizer.prefixes("ao thun", 2, 5))
            .containsExactly("ao", "ao t", "ao th");
    }
}

Với input "ao thun", minLen = 2 và maxLen = 5, các substring độ dài 2 đến 5 là "ao", "ao ", "ao t", "ao th". Phần tử "ao " bị loại vì kết thúc bằng khoảng trắng.

Lưu ý: Cách này chỉ khớp từ đầu cụm. Muốn “thun” cũng gợi ý ra “áo thun”, bạn sinh thêm prefix từ mỗi vị trí bắt đầu từ: “thun nam”, “nam”… Đổi lại, bộ nhớ tăng khoảng 3–4 lần. Hãy đo trước khi bật.

6. API

@RestController
@RequestMapping("/api/v1/autocomplete")
public class AutocompleteController {

    private final AutocompleteService service;

    public AutocompleteController(AutocompleteService service) {
        this.service = service;
    }

    public record SuggestResponse(String query, List<String> suggestions) {}

    @GetMapping
    public SuggestResponse suggest(@RequestParam("q") String q,
                                   @RequestParam(defaultValue = "10") int limit) {
        int safeLimit = Math.clamp(limit, 1, 10);
        return new SuggestResponse(q, service.suggest(q, safeLimit));
    }
}

Math.clamp (Java 21) giới hạn limit trong khoảng 1–10, nên client không thể xin 10.000 phần tử. Một request mẫu:

GET /api/v1/autocomplete?q=ao%20th&limit=3

{"query":"ao th","suggestions":["Áo thun nam","Áo thun cotton","Áo thun oversize"]}

Phía client nên debounce khoảng 150ms và huỷ request cũ khi người dùng gõ tiếp. Chỉ riêng việc này đã giảm 60–70% lượng request.

7. Luồng đọc

Thêm các dependency cần thiết:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>

Dịch vụ đọc dùng hai tầng cache: Caffeine trong JVM và Redis.

@Service
public class AutocompleteService {

    static final int MAX_PREFIX = 20;
    private final StringRedisTemplate redis;
    private final Cache<String, List<String>> local = Caffeine.newBuilder()
            .maximumSize(200_000)
            .expireAfterWrite(Duration.ofSeconds(30))
            .build();

    public AutocompleteService(StringRedisTemplate redis) {
        this.redis = redis;
    }

    public List<String> suggest(String rawQuery, int limit) {
        String query = TextNormalizer.normalize(rawQuery);
        if (query.length() < 2) return List.of();
        String key = query.length() > MAX_PREFIX ? query.substring(0, MAX_PREFIX) : query;

        return local.get(key, this::loadFromRedis).stream()
                .filter(s -> TextNormalizer.normalize(s).startsWith(query))
                .limit(limit)
                .toList();
    }

    private List<String> loadFromRedis(String prefix) {
        Set<String> top = redis.opsForZSet().reverseRange("ac:p:" + prefix, 0, 49);
        return top == null ? List.of() : List.copyOf(top);
    }
}

Các điểm đáng chú ý:

  • Cache.get(key, loader) của Caffeine là thao tác atomic theo từng key. Nếu 1.000 thread cùng hỏi prefix “ao” lúc cache trống, chỉ một thread gọi Redis. Các thread còn lại chờ kết quả. Đây là kỹ thuật request coalescing, chống được cache stampede.
  • Query dài hơn 20 ký tự sẽ dùng key của 20 ký tự đầu. Bước filter(startsWith) sau đó lọc lại chính xác.
  • Bước lọc dùng query đã chuẩn hoá, không dùng key. Nhờ vậy kết quả luôn khớp với đúng chuỗi người dùng gõ.

8. Luồng ghi: gom điểm và cập nhật index

Mỗi lần người dùng tìm kiếm, Search Service bắn một event vào topic search-events. Nếu mỗi event lại gây hai chục lệnh ZADD, Redis sẽ quá tải ngay. Vì vậy ta gom điểm trong bộ nhớ trước.

@Component
public class SearchEventAggregator {

    private final ConcurrentHashMap<String, LongAdder> pending = new ConcurrentHashMap<>();
    private final AutocompleteIndexer indexer;

    public SearchEventAggregator(AutocompleteIndexer indexer) {
        this.indexer = indexer;
    }

    @KafkaListener(topics = "search-events", groupId = "autocomplete-aggregator")
    public void onSearch(String displayQuery) {
        pending.computeIfAbsent(displayQuery.trim(), k -> new LongAdder()).increment();
    }

    @Scheduled(fixedDelay = 30_000)
    public void flush() {
        for (String phrase : List.copyOf(pending.keySet())) {
            LongAdder adder = pending.remove(phrase);
            if (adder != null) indexer.addScore(phrase, adder.sum());
        }
    }
}

LongAdder chịu được nhiều thread tăng đếm cùng lúc mà ít tranh chấp hơn AtomicLong. Cứ 30 giây, mỗi cụm chỉ sinh một lần ghi, dù nó được tìm 10.000 lần trong khoảng đó. Nhớ thêm @EnableScheduling vào lớp cấu hình.

Lưu ý: Có một khe hở nhỏ. Event có thể tới đúng lúc giữa remove và sum(), rơi vào một LongAdder mới, và sẽ được flush ở lượt sau. Nếu instance chết trước khi flush, bạn mất tối đa 30 giây điểm. Với bài toán xếp hạng gợi ý, sai số này chấp nhận được. Với bài toán tiền bạc thì không.

Indexer cập nhật điểm toàn cục, rồi đẩy tổng điểm vào từng prefix:

@Component
public class AutocompleteIndexer {

    static final int KEEP_PER_PREFIX = 50;
    private final StringRedisTemplate redis;

    public AutocompleteIndexer(StringRedisTemplate redis) {
        this.redis = redis;
    }

    public void addScore(String phrase, long delta) {
        Double total = redis.opsForZSet().incrementScore("ac:score", phrase, delta);
        if (total == null) return;
        byte[] member = phrase.getBytes(StandardCharsets.UTF_8);
        List<String> prefixes = TextNormalizer.prefixes(
                TextNormalizer.normalize(phrase), 2, AutocompleteService.MAX_PREFIX);

        redis.executePipelined((RedisCallback<Object>) conn -> {
            for (String p : prefixes) {
                byte[] key = ("ac:p:" + p).getBytes(StandardCharsets.UTF_8);
                conn.zSetCommands().zAdd(key, total, member, RedisZSetCommands.ZAddArgs.empty().gt());
                conn.zSetCommands().zRemRange(key, 0, -(KEEP_PER_PREFIX + 1));
            }
            return null;
        });
    }
}

Từng dòng làm gì:

  • ZINCRBY ac:score cộng dồn điểm một cách atomic và trả về tổng mới. Không có bước đọc–sửa–ghi nào ở phía ứng dụng.
  • ZADD ... GT chỉ ghi khi điểm mới lớn hơn điểm hiện có. Giả sử hai instance flush cùng một cụm, instance chậm hơn mang tổng cũ tới sau. Tổng cũ đó sẽ không ghi đè được tổng mới. Vì điểm chỉ tăng, GT đủ để giải quyết tranh chấp mà không cần lock.
  • ZREMRANGEBYRANK key 0 -51 xoá các phần tử có rank thấp, giữ lại 50 phần tử điểm cao nhất. Ta giữ 50 thay vì 10 để còn chỗ lọc từ bị chặn và lọc query dài.
  • Pipeline gửi khoảng 40 lệnh trong một round-trip. Pipeline hoạt động tốt trên Redis Cluster vì mỗi lệnh chỉ chạm một key.

Lưu ý: Đừng gom các lệnh này vào một Lua script nhiều key trên Redis Cluster. Các key ac:p:* nằm ở nhiều slot khác nhau và script sẽ báo lỗi CROSSSLOT. Bạn có thể ép chúng về cùng slot bằng hash tag, nhưng khi đó toàn bộ tải dồn vào một shard.

Catalog Service xử lý tương tự qua topic product-events. Khi sản phẩm mới được tạo, tên sản phẩm được thêm vào với điểm khởi tạo nhỏ. Hãy dùng productId làm Kafka key để các event của cùng một sản phẩm luôn tới theo đúng thứ tự.

9. Các điểm khó và cách giải

9.1. Hot key và tải đột biến

Khi flash sale “iPhone” bắt đầu, hàng chục nghìn người gõ “ip” cùng lúc. Key ac:p:ip thành hot key và một shard Redis gánh toàn bộ. Hệ thống giải quyết theo từng lớp:

  1. Client: debounce, độ dài tối thiểu 2 ký tự, huỷ request cũ.
  2. Caffeine: cache 30 giây trong mỗi instance. Prefix ngắn chiếm phần lớn traffic, nên tỷ lệ hit thường trên 90%. Với 20 instance, Redis chỉ nhận tối đa khoảng 20 request mỗi 30 giây cho một prefix.
  3. Coalescing: như mục 7 đã nói, nhiều request trùng key chỉ tạo một lần gọi Redis.
  4. Redis replica: bật đọc từ replica (ReadFrom.REPLICA_PREFERRED trong Lettuce) để chia tải đọc.
  5. Degrade: nếu Redis timeout quá 30ms, trả về danh sách rỗng hoặc top cụm hot toàn cục được cache sẵn. Đừng để ô tìm kiếm treo cả trang.

9.2. Nhất quán khi chặn từ khoá

Từ bị chặn phải biến mất trong vài giây, chứ không phải 30 giây theo TTL của cache. Cách làm gồm hai bước:

  • Admin chặn từ khoá. Hệ thống ghi blocked = true vào DB, thêm vào ac:blocked, và phát event keyword-blocked.
  • Mỗi instance lắng nghe event này. Instance cập nhật một Set<String> blocklist trong bộ nhớ, rồi gọi local.invalidateAll(). Bước filter khi đọc loại các phần tử thuộc blocklist.
  • Một job nền chạy ZREM trên các prefix của từ đó và xoá khỏi ac:score để dọn rác.

Bộ lọc lúc đọc đảm bảo đúng ngay lập tức. Job dọn dẹp chỉ để tiết kiệm bộ nhớ.

9.3. Dựng lại index

Redis mất dữ liệu, hoặc bạn đổi quy tắc chuẩn hoá? Lúc đó bạn dựng lại từ bảng suggestion và ghi vào namespace mới, ví dụ ac:v2:p:*. Sau đó đổi cấu hình đọc sang v2 và xoá v1. Đây là kiểu blue-green cho index: không có lúc nào người dùng thấy index nửa vời.

Định kỳ, bạn cũng nên đồng bộ ac:score về cột suggestion.score. Cột này là nguồn để dựng lại khi cần.

10. Khi nào KHÔNG nên dùng cách này

  • Cần tìm kiếm mờ (fuzzy): người dùng gõ sai “ao thn” vẫn muốn ra “áo thun”. Top-K theo prefix không làm được. Hãy dùng Elasticsearch/OpenSearch với completion suggester hoặc search_as_you_type.
  • Gợi ý cá nhân hoá: mỗi người một danh sách khác nhau. Key theo prefix là toàn cục, nên bạn cần thêm một tầng re-rank theo user phía sau.
  • Catalog nhỏ (vài chục nghìn sản phẩm, vài trăm request/giây): một truy vấn PostgreSQL với LIKE 'ao th%' trên cột normalized có index text_pattern_ops là đủ. Đừng dựng Kafka và Redis Cluster cho bài toán này.

11. Kết luận

Bạn vừa đi qua thiết kế một hệ thống autocomplete chịu được hàng chục nghìn request/giây. Điểm mấu chốt nằm ở bốn quyết định:

  • Tính trước top-K cho từng prefix, để luồng đọc chỉ còn một lệnh ZREVRANGE.
  • Tách luồng ghi qua Kafka và gom điểm theo batch, để Redis không bị dội lệnh ghi.
  • Dùng ZINCRBY và ZADD GT để xử lý tranh chấp mà không cần lock.
  • Chắn hot key bằng Caffeine kèm request coalescing.

Chấp nhận eventual consistency cho điểm xếp hạng, nhưng kiểm soát chặt phần cần đúng ngay, như blocklist, là cách cân bằng hợp lý cho đa số sàn thương mại điện tử. Từ nền này, bạn có thể bổ sung fuzzy search hoặc cá nhân hoá khi sản phẩm thực sự cần.

system-designrediskafkaspring-bootautocomplete