产品搜索自动补全系统设计
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分片承载所有流量。系统分层应对:
- 客户端: 防抖、最小长度2个字符、取消旧请求。
- Caffeine: 在每个实例中缓存30秒。短前缀占流量的大部分,所以命中率通常超过90%。20个实例下,Redis每30秒对一个前缀最多收到约20个请求。
- 合并: 如第7节所述,相同key的多个请求只产生一次Redis调用。
- Redis副本: 启用从副本读(Lettuce中的
ReadFrom.REPLICA_PREFERRED)来分散读负载。 - 降级: 如果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)严格把控,是大多数电商平台的合理平衡。在此基础上,当产品真正需要时,你可以添加模糊搜索或个性化。