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 query normalizado, no key. 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 remove y sum(), cayendo en un nuevo LongAdder, 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:score suma 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 ... GT solo 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, GT resuelve contenciones sin bloqueos.
  • ZREMRANGEBYRANK key 0 -51 elimina 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 error CROSSSLOT. 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:

  1. Cliente: debounce, longitud mínima 2 caracteres, cancelar solicitudes antiguas.
  2. 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.
  3. Coalescencia: como se mencionó en la sección 7, múltiples solicitudes con la misma clave generan solo una llamada a Redis.
  4. Replica de Redis: habilita lectura desde replicas (ReadFrom.REPLICA_PREFERRED en Lettuce) para distribuir la carga de lectura.
  5. 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 = true en la BD, la añade a ac:blocked, y emite un evento keyword-blocked.
  • Cada instancia escucha este evento. Actualiza un Set<String> blocklist en memoria, luego llama a local.invalidateAll(). El paso filter al leer excluye elementos en la blocklist.
  • Un trabajo en segundo plano ejecuta ZREM en los prefijos de esa palabra y la elimina de ac:score para 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 suggester o search_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 columna normalized con índice text_pattern_ops es 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 ZINCRBY y ZADD GT para 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.

system-designrediskafkaspring-bootautocomplete