产品搜索自动补全系统设计

1. 总览

用户在搜索框输入“ao th”,希望在不到100ms内看到“áo thun nam”、“áo thun cotton”这样的建议。本文通过一个完整的案例研究来展示整个设计过程:需求、数据模型、API、读写流程,再到热key、一致性和突发流量这些难点。

读完本文后,你可以用Spring Boot、Redis和Kafka构建一个自动补全服务。你也会明白为什么每项设计决策都是这样做的。

2. 需求

2.1. 功能需求

  • 用户至少输入2个字符。系统返回最多10条建议,按热度排序。
  • 无论有无声调都能匹配:“ao thun”应该匹配“Áo thun”。
  • 建议来自两个来源:用户搜索过的关键词和商品名称、品牌、分类。
  • 管理员可以封禁敏感关键词。被封禁的词应该从建议中消失。

2.2. 规模和约束

  • 约200万条建议和500万件商品。
  • 正常流量5,000请求/秒。闪购时达到30,000请求/秒。
  • 服务器端p99延迟不超过50ms。
  • 可接受的数据新鲜度为1–2分钟。这是最终一致性:一个刚热起来的关键词可能还没立即出现,这是可以接受的。

最后这条约束非常重要。它允许我们完全分离写路径(更新分数)和读路径(返回建议)。

3. 总体架构

Client ──(防抖150ms)──> API Gateway ──> Autocomplete Service ──> Caffeine (本地)
                                               │                      │ 未命中
                                               │                      v
                                               │               Redis: ac:p:{prefix}
Search Service ──(search-events)──> Kafka ──> Aggregator ──(flush 30s)──> Redis
Catalog Service ──(product-events)──┘

核心思想是预计算每个前缀的前K个。每个前缀比如“ao”、“ao t”、“ao th”都有自己的Redis sorted set。这个集合最多保存50条分数最高的建议。读取时,你只需要一条ZREVRANGE命令,时间复杂度是O(log N + K)。无需扫描,无需计算。

内存估算:200万条建议,前缀限制20个字符。合并重复前缀后约1000万个key。每个key保持50个元素约40字节,总共约20GB包括开销。这个数字能放入一个3分片带副本的Redis Cluster。

4. 数据模型

原始数据存在PostgreSQL中。Redis只是供读的索引,任何时候都可以重建。

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;

Redis有三种key:

ac:score            ZSET  member=display_text, score=全局总分
ac:p:{prefix}       ZSET  member=display_text, score=总分(最多50个元素)
ac:blocked          SET   被封禁的display_text

5. 越南语规范化和前缀生成

这是最容易出错的地方。你必须去掉声调、转换为小写、合并空格,以及特殊处理“đ”字符,因为Normalizer无法正确分离它。

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 " 与 "ao" 在trim()后相同
        }
        return result;
    }
}

Form.NFD 将“á”分离为“a”和一个结合符号。正则表达式\p{M}随后删除标记部分。prefixes函数跳过以空格结尾的前缀。原因是:用户的查询已经被trim()过了,所以key ac:p:ao 永远不会被读取。

核心行为的测试:

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");
    }
}

使用输入"ao thun"、minLen = 2和maxLen = 5,长度为2到5的子串是"ao"、"ao "、"ao t"、"ao th"。元素"ao "被排除因为它以空格结尾。

注意: 这种方法只匹配从开头开始的词。要让“thun”也能建议出“áo thun”,你需要从每个词开始位置生成前缀:从“thun nam”、“nam”开始……代价是内存增加约3–4倍。在启用前务必先测试。

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)将limit限制在1–10范围内,所以客户端无法请求10,000个元素。一个示例请求:

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

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

客户端应该防抖约150ms,并在用户继续输入时取消旧请求。仅这一步就能减少60–70%的请求量。

7. 读路径

添加必要的依赖:

<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>

读服务使用两层缓存:JVM内的Caffeine和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);
    }
}

值得注意的地方:

  • Caffeine的Cache.get(key, loader)对每个key来说是原子操作。如果1,000个线程同时询问前缀“ao”而缓存为空,只有一个线程调用Redis。其他线程等待结果。这是请求合并技术,防止缓存雪崩。
  • 长于20个字符的查询将使用前20个字符的key。随后的filter(startsWith)步骤再进行精确过滤。
  • 过滤使用已规范化的query,不是key。这样结果总是与用户输入的字符串匹配。

8. 写路径:聚合分数和更新索引

每当用户搜索时,Search Service会向search-events主题发送一个事件。如果每个事件都导致二十多条ZADD命令,Redis会立即过载。因此我们先在内存中聚合分数。

@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能在多个线程同时递增时承受高并发,竞争比AtomicLong要少。每30秒,同一个短语只产生一次写操作,即使它在这段时间内被搜索了10,000次。别忘了在配置类中添加@EnableScheduling。

注意: 有个小的竞态条件。事件可能正好在remove和sum()之间到达,落入一个新的LongAdder,将在下一轮flush时处理。如果实例在flush前崩溃,你会丢失最多30秒的分数。对于建议排名问题,这个误差是可接受的。但对于金融交易则不是。

Indexer更新全局分数,然后将总分数推送到每个前缀:

@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;
        });
    }
}

每行做什么:

  • ZINCRBY ac:score原子地累加分数并返回新总分。应用程序端没有读–修改–写的步骤。
  • ZADD ... GT只在新分数大于现有分数时写入。假设两个实例flush同一个短语,较慢的实例带着旧总分到达。旧总分将不会覆盖新总分。因为分数只增不减,GT足以解决竞争而不需要锁。
  • ZREMRANGEBYRANK key 0 -51删除排名低的元素,保留分数最高的50个。我们保留50而不是10,以便过滤被封禁的词和长查询。
  • 管道在单个往返中发送约40条命令。在Redis Cluster上管道工作得很好,因为每条命令只涉及一个key。

注意: 不要将这些命令捆绑到一个跨越多个key的Lua脚本。ac:p:*这些key位于不同的slot,脚本会报CROSSSLOT错误。你可以使用hash tag将它们强制到同一个slot,但这样所有流量都会聚集到一个分片。

Catalog Service通过product-events主题处理类似的逻辑。当新商品被创建时,商品名称被添加到初始分数较低的列表中。务必使用productId作为Kafka key,以确保同一商品的事件始终按正确顺序到达。

9. 难点和解决方案

9.1. 热key和突发流量

当闪购“iPhone”开始时,数万人同时输入“ip”。Key ac:p:ip变成热key,一个Redis分片承载所有流量。系统分层应对:

  1. 客户端: 防抖、最小长度2个字符、取消旧请求。
  2. Caffeine: 在每个实例中缓存30秒。短前缀占流量的大部分,所以命中率通常超过90%。20个实例下,Redis每30秒对一个前缀最多收到约20个请求。
  3. 合并: 如第7节所述,相同key的多个请求只产生一次Redis调用。
  4. Redis副本: 启用从副本读(Lettuce中的ReadFrom.REPLICA_PREFERRED)来分散读负载。
  5. 降级: 如果Redis超时超过30ms,返回空列表或事先缓存的全局热门短语。不要让搜索框整页卡住。

9.2. 封禁关键词时的一致性

被封禁的词必须在几秒内消失,而不是等待30秒的缓存TTL。做法分两步:

  • 管理员封禁关键词。系统在DB中设置blocked = true,添加到ac:blocked,并发布keyword-blocked事件。
  • 每个实例监听这个事件。实例更新内存中的Set<String> blocklist,然后调用local.invalidateAll()。读取时的filter步骤会排除blocklist中的项。
  • 后台job运行ZREM删除该词的前缀和ac:score中的项,来清理垃圾。

读时过滤确保立即生效。清理job只是为了节省内存。

9.3. 重建索引

Redis丢失数据,或者你改变了规范化规则?那时你从suggestion表重建,写入新的命名空间,比如ac:v2:p:*。之后改变读配置指向v2并删除v1。这是索引的蓝绿部署:用户永远不会看到不完整的索引。

定期地,你应该同步ac:score回到suggestion.score列。这列是需要时重建的源。

10. 何时不应该用这种方法

  • 需要模糊搜索(Fuzzy): 用户输入错误“ao thn”仍想看到“áo thun”。前缀顶K做不到。用Elasticsearch/OpenSearch的completion suggester或search_as_you_type。
  • 个性化建议: 每个人一个不同的列表。按前缀的key是全局的,所以你需要在后面添加按用户的重排层。
  • 小目录(数万商品、每秒数百请求):一个在normalized列上带text_pattern_ops索引的PostgreSQL查询LIKE 'ao th%'足够了。别为这个规模架构Kafka和Redis Cluster。

11. 结论

你刚完成了一个能承受数万次请求/秒的自动补全系统的设计。要点归结为四个决策:

  • 预计算每个前缀的前K个结果,让读路径只需一条ZREVRANGE命令。
  • 通过Kafka分离写路径并按批次聚合分数,防止Redis被写命令淹没。
  • 使用ZINCRBY和ZADD GT处理竞争而不需要锁。
  • 用Caffeine加请求合并来阻止热key。

接受分数排名的最终一致性,但对需要立即生效的部分(如blocklist)严格把控,是大多数电商平台的合理平衡。在此基础上,当产品真正需要时,你可以添加模糊搜索或个性化。

system-designrediskafkaspring-bootautocomplete