Gestaltung eines Produktsuchsystems mit Autocomplete
1. Überblick
Gibst du “ao th” in das Suchfeld ein, möchte der Benutzer sofort Vorschläge wie “áo thun nam”, “áo thun cotton” in weniger als 100ms sehen. Dieser Artikel durchläuft eine vollständige Case Study: Anforderungen, Datenmodell, API, Schreib- und Leseprozesse, dann zu schwierigen Punkten wie Hot Keys, Konsistenz und Lastspitzen.
Nach dem Lesen kannst du einen Autocomplete-Dienst mit Spring Boot, Redis und Kafka aufbauen. Du wirst auch verstehen, warum jede Designentscheidung getroffen wurde.
2. Anforderungen
2.1. Funktionale Anforderungen
- Benutzer müssen mindestens 2 Zeichen eingeben. Das System gibt maximal 10 Vorschläge zurück, sortiert nach Popularität.
- Eingaben mit oder ohne diakritische Zeichen passen beide: “ao thun” muss “Áo thun” entsprechen.
- Vorschläge stammen aus zwei Quellen: Suchbegriffe, die Benutzer eingeben, und Produktnamen, Marken, Kategorien.
- Admins können sensible Suchbegriffe blockieren. Blockierte Begriffe müssen aus den Vorschlägen verschwinden.
2.2. Skalierung und Constraints
- Etwa 2 Millionen Vorschlagsbegriffe und 5 Millionen Produkte.
- Normaler Durchsatz: 5.000 Anfragen/Sekunde. Bei Flash Sales bis zu 30.000 Anfragen/Sekunde.
- Server-seitige p99-Latenz unter 50ms.
- Akzeptable Frische: 1–2 Minuten. Dies ist Eventual Consistency: ein gerade populär gewordener Begriff möglicherweise nicht sofort angezeigt werden, und das ist akzeptabel.
Diese letzte Einschränkung ist sehr wichtig. Sie ermöglicht es, den Schreibprozess vollständig vom Leseprozess zu trennen.
3. Gesamtarchitektur
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)──┘
Die Hauptidee ist top-K für jedes Präfix vorab zu berechnen. Jedes Präfix wie “ao”, “ao t”, “ao th” hat ein eigenes Redis Sorted Set. Das Set hält maximal die 50 Begriffe mit den höchsten Punkten. Beim Lesen benötigst du nur einen ZREVRANGE Befehl mit O(log N + K) Komplexität. Kein Scanning, keine Berechnungen erforderlich.
Speicherabschätzung: 2 Millionen Begriffe mit Präfix-Grenzwert von 20 Zeichen. Nach dem Zusammenführen doppelter Präfixe bleiben etwa 10 Millionen Keys. Jeder Key hält 50 Elemente à ca. 40 Bytes, insgesamt etwa 20GB inklusive Overhead. Diese Zahl passt in einen Redis Cluster mit 3 Shards und Replicas.
4. Datenmodell
Rohdaten sind in PostgreSQL gespeichert. Redis ist nur ein Index für Lesezugriffe und kann jederzeit neu aufgebaut werden.
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 hat drei Arten von Keys:
ac:score ZSET member=display_text, score=Gesamtpunktzahl global
ac:p:{prefix} ZSET member=display_text, score=Gesamtpunktzahl (max 50 Elemente)
ac:blocked SET blockierte display_text
5. Vietnamesische Normalisierung und Präfix-Generierung
Dies ist der fehleranfälligste Teil. Du musst Diakritika entfernen, zu Kleinbuchstaben konvertieren, Leerzeichen zusammenfassen und “đ” separat verarbeiten, weil Normalizer es nicht trennt.
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 " ist identisch mit "ao" nach trim
}
return result;
}
}
Form.NFD trennt “á” in “a” und ein kombinierendes Zeichen. Regex \p{M} entfernt dann die Zeichen. Die prefixes-Funktion überspringt Präfixe, die mit Leerzeichen enden. Grund: die Abfrage des Benutzers wurde bereits trim(), daher wird der Schlüssel ac:p:ao niemals gelesen.
Test für das Hauptverhalten:
class TextNormalizerTest {
@Test
void sollteVietnamesischeDiakritikEntfernen() {
assertThat(TextNormalizer.normalize(" Áo Thun ĐỎ nam "))
.isEqualTo("ao thun do nam");
}
@Test
void solltepraefix eOhneAbschliessendesLeerzeichenGenerieren() {
assertThat(TextNormalizer.prefixes("ao thun", 2, 5))
.containsExactly("ao", "ao t", "ao th");
}
}
Mit der Eingabe "ao thun", minLen = 2 und maxLen = 5 sind die Substrings der Länge 2 bis 5 "ao", "ao ", "ao t", "ao th". Das Element "ao " wird ausgeschlossen, da es mit einem Leerzeichen endet.
Hinweis: Diese Methode passt nur vom Anfang des Begriffs. Um auch “thun” als Vorschlag für “áo thun” zu haben, musst du zusätzliche Präfixe von jeder Startposition generieren: “thun nam”, “nam”… Im Gegenzug steigt der Speicherverbrauch um etwa das 3–4-fache. Messe vorher, bevor du es aktivierst.
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) begrenzt limit im Bereich 1–10, daher kann der Client nicht 10.000 Elemente anfordern. Eine Beispielanfrage:
GET /api/v1/autocomplete?q=ao%20th&limit=3
{"query":"ao th","suggestions":["Áo thun nam","Áo thun cotton","Áo thun oversize"]}
Der Client sollte etwa 150ms debounce und alte Anfragen abbrechen, wenn der Benutzer weitereingeben. Allein das reduziert 60–70% des Anfragevolumens.
7. Leseprozess
Erforderliche Dependencies hinzufügen:
<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>
Der Lesedienst verwendet zwei Cache-Ebenen: Caffeine im JVM und 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);
}
}
Wichtige Punkte:
Cache.get(key, loader)in Caffeine ist eine atomare Operation pro Schlüssel. Wenn 1.000 Threads den Präfix “ao” abfragen, wenn der Cache leer ist, nur ein Thread fragt Redis ab. Die anderen Threads warten auf das Ergebnis. Dies ist Request Coalescing, das gegen Cache Stampede schützt.- Abfragen länger als 20 Zeichen verwenden den Schlüssel der ersten 20 Zeichen. Der
filter(startsWith)Schritt filtert dann genau. - Der Filterschritt verwendet die normalisierte
query, nichtkey. So passen die Ergebnisse immer zur exakten Eingabe des Benutzers.
8. Schreibprozess: Score-Aggregation und Index-Aktualisierung
Jedes Mal, wenn ein Benutzer sucht, wirft der Search Service ein Event in das Topic search-events. Wenn jedes Event zu zwei Dutzend ZADD Befehlen führt, wird Redis sofort überlastet. Daher aggregieren wir die Scores zunächst im Speicher.
@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 verträgt viele Threads, die gleichzeitig erhöhen, besser als AtomicLong. Alle 30 Sekunden wird jeder Begriff nur einmal geschrieben, auch wenn er 10.000 Mal in diesem Zeitraum gesucht wird. Vergiss nicht, @EnableScheduling zur Konfigurationsklasse hinzuzufügen.
Hinweis: Es gibt eine kleine Lücke. Ein Event könnte genau zwischen
removeundsum()ankommen, würde in einen neuenLongAdderfallen und würde beim nächsten Flush verarbeitet. Wenn die Instanz stirbt, bevor flush, verlierst du bis zu 30 Sekunden an Scores. Für ein Ranking-Problem von Vorschlägen ist dieser Fehler akzeptabel. Für ein Geldproblem ist es nicht.
Der Indexer aktualisiert die globale Punktzahl und pusht die Gesamtpunktzahl dann in jedes Präfix:
@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;
});
}
}
Was jede Zeile tut:
ZINCRBY ac:scoreerhöht Scores atomar und gibt die neue Summe zurück. Kein Read-Modify-Write auf Anwendungsseite.ZADD ... GTschreibt nur, wenn der neue Score größer ist. Angenommen, zwei Instanzen flushen denselben Begriff, die langsamere Instanz trägt die ältere Summe später hin. Diese ältere Summe kann die neue nicht überschreiben. Da Scores nur steigen, istGTausreichend, um Konflikte ohne Sperren zu lösen.ZREMRANGEBYRANK key 0 -51entfernt Elemente mit niedrigem Rang, behält die 50 Elemente mit den höchsten Scores. Wir behalten 50 statt 10, um Platz für Filterpflichtwörter und lange Abfragen zu haben.- Pipeline sendet etwa 40 Befehle in einer Round-Trip. Pipeline funktioniert gut auf Redis Cluster, da jeder Befehl nur einen Key berührt.
Hinweis: Packe diese Befehle nicht in ein Lua-Skript mit mehreren Keys auf Redis Cluster. Die Keys
ac:p:*liegen auf verschiedenen Slots, das Skript meldetCROSSSLOTFehler. Du kannst sie mit Hash-Tags auf denselben Slot zwingen, aber dann konzentriert sich die ganze Last auf einen Shard.
Der Catalog Service funktioniert ähnlich über das Topic product-events. Wenn ein neues Produkt erstellt wird, wird der Produktname mit einem niedrigen Anfangsscore hinzugefügt. Verwende productId als Kafka-Schlüssel, damit Events desselben Produkts immer in der richtigen Reihenfolge ankommen.
9. Schwierigkeiten und Lösungen
9.1. Hot Keys und Lastspitzen
Wenn der Flash Sale “iPhone” beginnt, tippen Zehntausende gleichzeitig “ip”. Der Schlüssel ac:p:ip wird zum Hot Key, ein Redis Shard trägt die ganze Last. Das System löst es schichtweise:
- Client: debounce, Mindestlänge 2 Zeichen, alte Anfragen abbrechen.
- Caffeine: Cache 30 Sekunden in jeder Instanz. Kurze Präfixe machen den Großteil des Traffics aus, daher ist die Hit-Rate normalerweise über 90%. Mit 20 Instanzen erhält Redis maximal etwa 20 Anfragen pro 30 Sekunden für ein Präfix.
- Coalescing: Wie in Abschnitt 7 erwähnt, mehrere Anfragen zum selben Key erstellen nur einen Redis-Aufruf.
- Redis Replicas: Lese-Replicas aktivieren (
ReadFrom.REPLICA_PREFERREDin Lettuce) um die Lesevast zu verteilen. - Degrade: Wenn Redis länger als 30ms antwortet, gib eine leere Liste zurück oder die globalen heißen Begriffe, die bereits gecacht sind. Lass das Suchfeld nicht die ganze Seite einfrieren.
9.2. Konsistenz beim Blockieren von Begriffen
Ein blockierter Begriff muss innerhalb von Sekunden verschwinden, nicht 30 Sekunden wie bei der Cache-TTL. Die Lösung erfolgt in zwei Schritten:
- Admin blockiert einen Begriff. Das System schreibt
blocked = truein die DB, fügt es zuac:blockedhinzu und baut ein Eventkeyword-blocked. - Jede Instanz hört auf dieses Event. Die Instanz aktualisiert ein
Set<String>Blocklist im Speicher, und ruft dannlocal.invalidateAll()auf. DerfilterSchritt beim Lesen entfernt blockierte Elemente. - Ein Hintgrund-Job führt
ZREMfür die Präfixe aus und löscht ausac:scorezur Cleanup.
Der Filter beim Lesen gewährleistet sofortige Korrektheit. Der Cleanup-Job spart nur Speicher.
9.3. Index-Neubau
Redis verliert Daten, oder du änderst die Normalisierungsregeln? Dann erstellst du es neu aus der suggestion Tabelle und schreibst in einen neuen Namespace, z.B. ac:v2:p:*. Dann wechselt die Read-Konfiguration zu v2 und löschst v1. Das ist ein Blue-Green-Deployment für den Index: es gibt keinen Moment, in dem Benutzer einen unvollständigen Index sehen.
Regelmäßig solltest du ac:score auch mit der Spalte suggestion.score synchronisieren. Diese Spalte ist die Quelle für Neuerstellungen bei Bedarf.
10. Wann du diesen Ansatz NICHT verwenden solltest
- Benötigst du Fuzzy Search: Der Benutzer tippt “ao thn” falsch ein und möchte “áo thun” zurück. Top-K nach Präfix kann das nicht. Verwende Elasticsearch/OpenSearch mit
completion suggesterodersearch_as_you_type. - Personalisierte Vorschläge: Jeder hat eine andere Liste. Keys nach Präfix sind global, daher brauchst du eine zusätzliche Re-Ranking-Schicht pro Benutzer danach.
- Kleiner Katalog (einige Zehntausend Produkte, ein paar hundert Anfragen/Sekunde): Eine PostgreSQL-Abfrage mit
LIKE 'ao th%'in dernormalizedSpalte mit Indextext_pattern_opsist ausreichend. Baue kein Kafka und Redis Cluster dafür auf.
11. Fazit
Du hast gerade ein Autocomplete-System entworfen, das zehntausende Anfragen pro Sekunde verträgt. Die Kernpunkte liegen in vier Entscheidungen:
- Top-K für jedes Präfix vorab berechnen, damit der Leseprozess nur einen
ZREVRANGEBefehl ist. - Schreibprozess über Kafka trennen und Scores in Batches aggregieren, damit Redis nicht mit Schreibbefehlen überlastet wird.
ZINCRBYundZADD GTverwenden, um Konflikte ohne Sperren zu behandeln.- Hot Keys mit Caffeine und Request Coalescing blockieren.
Eventual Consistency für Ranking-Scores akzeptieren, aber eng kontrollen, was sofort korrekt sein muss, wie Blocklists, ist ein vernünftiger Kompromiss für die meisten E-Commerce-Plattformen. Von dieser Grundlage aus kannst du Fuzzy Search oder Personalisierung hinzufügen, wenn das Produkt es wirklich benötigt.