Diseño de sistema de búsqueda de productos con autocomplete
1. Descripción general
Escribe “ao th” en el campo de búsqueda, el usuario espera ver “áo thun nam”, “áo thun cotton” en menos de 100ms. Este artículo cubre un caso de estudio completo: requisitos, modelo de datos, API, flujo de escritura y lectura, y hasta puntos difíciles como hot keys, consistencia y picos de carga.
Después de leerlo, podrás construir un servicio de autocomplete con Spring Boot, Redis y Kafka. También sabrás por qué se eligió cada decisión de diseño.
2. Requisitos
2.1. Requisitos funcionales
- El usuario escribe un mínimo de 2 caracteres. El sistema devuelve máximo 10 sugerencias, ordenadas por popularidad.
- Escribir con o sin marcas diacríticas funciona: “ao thun” debe coincidir con “Áo thun”.
- Las sugerencias provienen de dos fuentes: consultas de búsqueda de usuarios y nombres de productos, marcas, categorías.
- Los administradores pueden bloquear palabras clave sensibles. Las palabras bloqueadas deben desaparecer de las sugerencias.
2.2. Escala y restricciones
- Aproximadamente 2 millones de expresiones de sugerencia y 5 millones de productos.
- Tráfico normal de 5.000 solicitudes/segundo. En rebajas flash llega a 30.000 solicitudes/segundo.
- Latencia p99 del servidor por debajo de 50ms.
- Tolerancia de actualización de 1–2 minutos. Esta es consistencia eventual: una palabra clave recién popular podría no aparecer inmediatamente en las sugerencias, y eso es aceptable.
La última restricción es muy importante. Permite separar completamente el flujo de escritura (actualizar puntuaciones) del flujo de lectura (devolver sugerencias).
3. Arquitectura general
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)──┘
La idea principal es precomputar top-K para cada prefijo. Cada prefijo como “ao”, “ao t”, “ao th” tiene su propio sorted set en Redis. El set mantiene como máximo 50 expresiones con las puntuaciones más altas. Al leer, solo necesitas un comando ZREVRANGE con complejidad O(log N + K). Sin escaneos, sin cálculos.
Estimación de memoria: 2 millones de expresiones, límite de prefijo 20 caracteres. Después de consolidar prefijos duplicados quedan aproximadamente 10 millones de claves. Cada clave mantiene 50 elementos de aproximadamente 40 bytes, sumando alrededor de 20GB incluyendo overhead. Esta cantidad cabe en un Redis Cluster de 3 shards con replicas.
4. Modelo de datos
Los datos primarios residen en PostgreSQL. Redis es solo un índice para lectura y se puede reconstruir en cualquier momento.
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;
En Redis hay tres tipos de clave:
ac:score ZSET member=display_text, score=puntuación total global
ac:p:{prefix} ZSET member=display_text, score=puntuación total (máximo 50 elementos)
ac:blocked SET display_text bloqueados
5. Normalización del vietnamita y generación de prefijos
Esta es la parte más propensa a errores. Debes eliminar marcas diacríticas, convertir a minúsculas, consolidar espacios en blanco, y manejar especialmente la letra “đ” porque Normalizer no puede separarla.
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 " coincide con "ao" después de trim
}
return result;
}
}
Form.NFD separa “á” en “a” y una marca combinante. La regex \p{M} elimina después la parte de marcas. La función prefixes ignora prefijos terminados en espacio en blanco. Razón: la consulta del usuario ya fue trim(), así que la clave ac:p:ao nunca será leída.
Prueba del comportamiento principal:
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");
}
}
Con entrada "ao thun", minLen = 2 y maxLen = 5, los substrings de longitud 2 a 5 son "ao", "ao ", "ao t", "ao th". El elemento "ao " se excluye porque termina en espacio en blanco.
Nota: Este enfoque solo coincide con prefijos desde el inicio. Si quieres que “thun” también sugiera “áo thun”, debes generar prefijos adicionales desde cada posición de inicio: “thun nam”, “nam”… En cambio, la memoria aumenta aproximadamente 3–4 veces. Mide primero antes de habilitarlo.
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) limita limit en el rango 1–10, así que el cliente no puede solicitar 10.000 elementos. Una solicitud de ejemplo:
GET /api/v1/autocomplete?q=ao%20th&limit=3
{"query":"ao th","suggestions":["Áo thun nam","Áo thun cotton","Áo thun oversize"]}
El cliente debe hacer debounce alrededor de 150ms y cancelar solicitudes antiguas cuando el usuario sigue escribiendo. Solo esto reduce 60–70% del tráfico de solicitudes.
7. Flujo de lectura
Añade las dependencias necesarias:
<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>
El servicio de lectura usa dos capas de caché: Caffeine dentro de la JVM y 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);
}
}
Puntos a notar:
Cache.get(key, loader)de Caffeine es una operación atómica por clave. Si 1.000 threads consultan el prefijo “ao” con caché vacío, solo un thread llama a Redis. Los otros threads esperan el resultado. Esta es la técnica de coalescencia de solicitudes, que previene cache stampede.- Consultas más largas que 20 caracteres usan la clave de los primeros 20 caracteres. El paso
filter(startsWith)después filtra con precisión. - El filtro usa
querynormalizado, nokey. Así el resultado siempre coincide exactamente con la cadena que escribió el usuario.
8. Flujo de escritura: agregar puntuaciones y actualizar índice
Cada vez que un usuario busca, el Search Service envía un evento al topic search-events. Si cada evento causara veinte comandos ZADD, Redis se sobrecargría inmediatamente. Por eso agregamos puntuaciones en memoria primero.
@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 resiste bien múltiples threads incrementando el contador simultáneamente con menos contención que AtomicLong. Cada 30 segundos, cada expresión genera solo una escritura, aunque se haya buscado 10.000 veces en ese intervalo. Recuerda añadir @EnableScheduling a la clase de configuración.
Nota: Hay un pequeño hueco. Un evento podría llegar justo entre
removeysum(), cayendo en un nuevoLongAdder, y se vaciará en el siguiente período. Si la instancia muere antes de vaciarse, pierdes como máximo 30 segundos de puntuaciones. Para problemas de ranking de sugerencias, este error es aceptable. Para dinero no lo es.
El indexer actualiza la puntuación global, luego la empuja a cada prefijo:
@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;
});
}
}
Lo que hace cada línea:
ZINCRBY ac:scoresuma la puntuación de forma atómica y devuelve la nueva suma. No hay operación leer–modificar–escribir en el lado de la aplicación.ZADD ... GTsolo escribe si la nueva puntuación es mayor que la actual. Supón que dos instancias vacían la misma expresión, la instancia más lenta lleva la suma anterior y llega después. Esa suma anterior no sobreescribirá la suma nueva. Como las puntuaciones solo aumentan,GTresuelve contenciones sin bloqueos.ZREMRANGEBYRANK key 0 -51elimina elementos con rango bajo, manteniendo los 50 elementos con puntuación más alta. Mantenemos 50 en lugar de 10 para dejar espacio filtrar palabras bloqueadas y filtrar consultas largas.- Pipeline envía aproximadamente 40 comandos en un viaje redondo. Pipeline funciona bien en Redis Cluster porque cada comando afecta solo a una clave.
Nota: No agrupes estos comandos en un script Lua de múltiples claves en Redis Cluster. Las claves
ac:p:*están en diferentes slots y el script reportará un errorCROSSSLOT. Puedes forzarlas al mismo slot con hash tags, pero entonces toda la carga se concentra en un shard.
El Catalog Service funciona de forma similar a través del topic product-events. Cuando se crea un nuevo producto, su nombre se añade con una puntuación inicial baja. Usa productId como clave de Kafka para que los eventos del mismo producto siempre lleguen en el orden correcto.
9. Puntos difíciles y soluciones
9.1. Hot keys y picos de carga
Cuando comienza un rebajas flash de “iPhone”, decenas de miles de personas escriben “ip” simultáneamente. La clave ac:p:ip se convierte en hot key y un shard de Redis soporta toda la carga. El sistema resuelve esto capa por capa:
- Cliente: debounce, longitud mínima 2 caracteres, cancelar solicitudes antiguas.
- Caffeine: caché 30 segundos en cada instancia. Los prefijos cortos representan la mayoría del tráfico, así que la tasa de acierto suele ser superior al 90%. Con 20 instancias, Redis recibe como máximo alrededor de 20 solicitudes cada 30 segundos para un prefijo.
- Coalescencia: como se mencionó en la sección 7, múltiples solicitudes con la misma clave generan solo una llamada a Redis.
- Replica de Redis: habilita lectura desde replicas (
ReadFrom.REPLICA_PREFERREDen Lettuce) para distribuir la carga de lectura. - Degradación: si Redis timeout es superior a 30ms, devuelve una lista vacía o el top de expresiones populares globales precacheadas. No dejes que el campo de búsqueda congele toda la página.
9.2. Consistencia al bloquear palabras clave
Las palabras bloqueadas deben desaparecer en segundos, no en 30 segundos según el TTL del caché. El enfoque consta de dos pasos:
- El administrador bloquea una palabra clave. El sistema marca
blocked = trueen la BD, la añade aac:blocked, y emite un eventokeyword-blocked. - Cada instancia escucha este evento. Actualiza un
Set<String>blocklist en memoria, luego llama alocal.invalidateAll(). El pasofilteral leer excluye elementos en la blocklist. - Un trabajo en segundo plano ejecuta
ZREMen los prefijos de esa palabra y la elimina deac:scorepara limpiar basura.
El filtro al leer asegura precisión inmediata. El trabajo de limpieza solo ahorra memoria.
9.3. Reconstruir índice
¿Redis pierde datos, o cambias las reglas de normalización? Entonces reconstruyes desde la tabla suggestion y escribes en un namespace nuevo, como ac:v2:p:*. Luego cambias la configuración de lectura a v2 y borras v1. Este es el tipo de blue-green para índices: nunca hay un momento en que el usuario vea un índice incompleto.
Periódicamente, también debes sincronizar ac:score con la columna suggestion.score. Esta columna es la fuente para reconstruir cuando sea necesario.
10. Cuándo NO usar este enfoque
- Necesita búsqueda difusa (fuzzy): el usuario escribe erróneamente “ao thn” y aún quiere ver “áo thun”. Top-K por prefijo no lo puede hacer. Usa Elasticsearch/OpenSearch con
completion suggesterosearch_as_you_type. - Sugerencias personalizadas: cada persona tiene una lista diferente. Las claves por prefijo son globales, así que necesitas una capa de re-ranking por usuario después.
- Catálogo pequeño (decenas de miles de productos, cientos de solicitudes/segundo): una consulta PostgreSQL con
LIKE 'ao th%'en la columnanormalizedcon índicetext_pattern_opses suficiente. No construyas Kafka y Redis Cluster para este problema.
11. Conclusión
Has recorrido el diseño de un sistema de autocomplete que resiste decenas de miles de solicitudes por segundo. Los cuatro puntos clave residen en:
- Precomputar top-K para cada prefijo, para que el flujo de lectura sea solo un comando
ZREVRANGE. - Separar el flujo de escritura a través de Kafka y agregar puntuaciones por lotes, para que Redis no sea bombardeado con escrituras.
- Usar
ZINCRBYyZADD GTpara manejar contenciones sin bloqueos. - Bloquear hot keys con Caffeine más coalescencia de solicitudes.
Aceptar consistencia eventual para puntuaciones de ranking, pero controlar estrictamente las partes que deben ser precisas, como blocklists, es un equilibrio razonable para la mayoría de plataformas de comercio electrónico. Desde esta base, puedes añadir búsqueda difusa o personalización cuando el producto realmente lo necesite.