Projetando um sistema de busca de produtos com autocomplete

1. Visão geral

Digitar “ao th” na caixa de busca, o usuário quer ver logo “áo thun nam”, “áo thun cotton” em menos de 100ms. Este artigo passa por um estudo de caso completo: requisitos, modelo de dados, API, fluxo de escrita e leitura, depois chegando aos pontos difíceis como hot key, consistência e picos de tráfego.

Depois de ler, você pode construir um serviço de autocomplete com Spring Boot, Redis e Kafka. Você também entenderá por que cada decisão de design foi escolhida.

2. Requisitos

2.1. Requisitos funcionais

  • O usuário digita no mínimo 2 caracteres. O sistema retorna no máximo 10 sugestões, ordenadas por popularidade.
  • Tanto com diacríticos quanto sem: “ao thun” deve corresponder a “Áo thun”.
  • As sugestões vêm de duas fontes: palavras-chave que os usuários pesquisam e nomes de produtos, marcas, categorias.
  • O administrador pode bloquear palavras-chave sensíveis. Termos bloqueados devem desaparecer das sugestões.

2.2. Escala e restrições

  • Cerca de 2 milhões de termos de sugestão e 5 milhões de produtos.
  • Tráfego normal de 5.000 requisições/segundo. Durante flash sale, sobe para 30.000 requisições/segundo.
  • p99 latency no servidor abaixo de 50ms.
  • A frescura aceitável é de 1–2 minutos. Isso é eventual consistency: uma palavra-chave recém-quente pode não aparecer imediatamente, e está tudo bem.

Esta última restrição é muito importante. Ela permite separar completamente o fluxo de escrita (atualização de pontos) do fluxo de leitura (retorno de sugestões).

3. Arquitetura geral

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)──┘

A ideia principal é pré-calcular top-K para cada prefixo. Cada prefixo como “ao”, “ao t”, “ao th” tem seu próprio Redis sorted set. Este conjunto mantém no máximo 50 termos com pontuações mais altas. Ao ler, você precisa apenas de um comando ZREVRANGE com complexidade O(log N + K). Sem varredura, sem cálculos.

Estimativa de memória: 2 milhões de termos, limite de prefixo de 20 caracteres. Após mesclar prefixos duplicados, cerca de 10 milhões de chaves. Cada chave mantém 50 elementos de aproximadamente 40 bytes, totalizando cerca de 20GB incluindo overhead. Este número cabe em um Redis Cluster de 3 shards com réplica.

4. Modelo de dados

Os dados brutos estão no PostgreSQL. Redis é apenas um índice para leitura e pode ser reconstruído a qualquer 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;

Redis tem três tipos de chave:

ac:score            ZSET  member=display_text, score=pontuação total global
ac:p:{prefix}       ZSET  member=display_text, score=pontuação total (máx. 50 elementos)
ac:blocked          SET   display_text bloqueados

5. Normalização de vietnamita e geração de prefixo

Esta é a parte mais propensa a erros. Você precisa remover diacríticos, converter para minúsculas, mesclar espaços em branco e lidar especificamente com a letra “đ” porque Normalizer não consegue separar.

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 com "ao" depois de trim
        }
        return result;
    }
}

Form.NFD separa “á” em “a” e um diacrítico combinante. Regex \p{M} então remove a parte do diacrítico. A função prefixes ignora prefixos que terminam com espaço em branco. Motivo: a consulta do usuário já foi trim()ada, então a chave ac:p:ao nunca será lida.

Teste para o comportamento 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");
    }
}

Com input "ao thun", minLen = 2 e maxLen = 5, as substrings de comprimento 2 a 5 são "ao", "ao ", "ao t", "ao th". O elemento "ao " é descartado porque termina com espaço em branco.

Observação: Esta abordagem apenas corresponde desde o início do termo. Se você quiser que “thun” também sugira “áo thun”, precisa gerar prefixos adicionais a partir de cada posição de início: “thun nam”, “nam”… Em troca, a memória aumenta cerca de 3–4 vezes. Meça antes de ativar.

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) restringe limit entre 1–10, portanto o cliente não pode solicitar 10.000 elementos. Um exemplo de requisição:

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

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

O lado do cliente deve fazer debounce de cerca de 150ms e cancelar a requisição anterior quando o usuário digita novamente. Apenas isso já reduz 60–70% do volume de requisições.

7. Fluxo de leitura

Adicione as dependências necessárias:

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

O serviço de leitura usa dois níveis de cache: Caffeine dentro da JVM e 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);
    }
}

Pontos a observar:

  • Cache.get(key, loader) do Caffeine é uma operação atômica por chave. Se 1.000 threads perguntarem pelo prefixo “ao” ao mesmo tempo quando o cache está vazio, apenas um thread chama Redis. Os demais threads aguardam o resultado. Esta é a técnica de request coalescing, que previne cache stampede.
  • Consultas mais longas que 20 caracteres usarão a chave dos primeiros 20 caracteres. A etapa filter(startsWith) filtra novamente com precisão.
  • A etapa de filtro usa query já normalizado, não key. Dessa forma, o resultado sempre corresponde exatamente à string que o usuário digitou.

8. Fluxo de escrita: agregação de pontuação e atualização de índice

Cada vez que um usuário faz uma busca, o Search Service publica um evento no tópico search-events. Se cada evento resultasse em vinte comandos ZADD, Redis seria sobrecarregado imediatamente. Portanto, agregamos as pontuações em memória primeiro.

@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 suporta muitos threads incrementando o contador simultaneamente com menos contenção que AtomicLong. A cada 30 segundos, cada termo gera apenas uma escrita, mesmo que seja pesquisado 10.000 vezes nesse período. Lembre-se de adicionar @EnableScheduling à sua classe de configuração.

Observação: Há uma pequena brecha. Um evento pode chegar exatamente entre remove e sum(), caindo em um novo LongAdder e sendo processado no flush seguinte. Se a instância cair antes do flush, você perde no máximo 30 segundos de pontuação. Para um problema de ranking de sugestões, essa margem de erro é aceitável. Para problemas de dinheiro, não é.

O indexer cria uma index atualizado, depois incrementa a pontuação para cada prefixo:

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

O que cada linha faz:

  • ZINCRBY ac:score incrementa a pontuação atomicamente e retorna a nova soma. Não há operação read-modify-write no lado da aplicação.
  • ZADD ... GT escreve apenas se a nova pontuação for maior que a existente. Suponha que duas instâncias façam flush do mesmo termo, com a instância mais lenta chegando depois. A soma antiga não conseguirá sobrescrever a nova. Como a pontuação apenas aumenta, GT é suficiente para resolver contenção sem precisar de lock.
  • ZREMRANGEBYRANK key 0 -51 remove elementos com rank baixo, mantendo os 50 elementos com pontuações mais altas. Mantemos 50 em vez de 10 para ter espaço para filtrar termos bloqueados e filtrar consultas longas.
  • Pipeline envia cerca de 40 comandos em uma única viagem de ida e volta. Pipeline funciona bem em Redis Cluster porque cada comando toca apenas uma chave.

Observação: Não combine esses comandos em um script Lua com múltiplas chaves no Redis Cluster. As chaves ac:p:* estão em slots diferentes e o script retornará um erro CROSSSLOT. Você pode forçá-las ao mesmo slot usando hash tags, mas então toda a carga se concentra em um único shard.

Catalog Service processa de forma semelhante através do tópico product-events. Quando um novo produto é criado, seu nome é adicionado com uma pontuação inicial baixa. Use productId como chave Kafka para garantir que os eventos do mesmo produto sempre cheguem na ordem correta.

9. Pontos difíceis e soluções

9.1. Hot key e picos de tráfego

Quando flash sale de “iPhone” começa, dezenas de milhares de pessoas digitam “ip” ao mesmo tempo. A chave ac:p:ip se torna um hot key e um shard Redis fica sobrecarregado. O sistema resolve camada por camada:

  1. Cliente: debounce, comprimento mínimo de 2 caracteres, cancele requisições antigas.
  2. Caffeine: cache 30 segundos em cada instância. Prefixos curtos representam a maior parte do tráfego, então a taxa de hit geralmente é acima de 90%. Com 20 instâncias, Redis recebe no máximo cerca de 20 requisições a cada 30 segundos para um prefixo.
  3. Coalescing: como mencionado na seção 7, múltiplas requisições com a mesma chave criam apenas uma chamada a Redis.
  4. Redis replica: habilite leitura a partir de réplica (ReadFrom.REPLICA_PREFERRED no Lettuce) para distribuir a carga de leitura.
  5. Degrade: se Redis expirar timeout após 30ms, retorne uma lista vazia ou os termos hot globais pré-cacheados. Não deixe a caixa de busca congelada na página.

9.2. Consistência ao bloquear palavras-chave

Termos bloqueados devem desaparecer em poucos segundos, não em 30 segundos conforme o TTL do cache. A abordagem tem dois passos:

  • O administrador bloqueia uma palavra-chave. O sistema escreve blocked = true no BD, adiciona a ac:blocked, e publica um evento keyword-blocked.
  • Cada instância escuta este evento. A instância atualiza um Set<String> blocklist em memória, depois chama local.invalidateAll(). A etapa filter ao ler remove elementos na blocklist.
  • Um job em background executa ZREM nos prefixos do termo e o remove de ac:score para limpeza.

O filtro ao ler garante precisão imediata. O job de limpeza é apenas para poupar memória.

9.3. Reconstruindo o índice

Redis perdeu dados ou você mudou as regras de normalização? Nesse caso, você reconstrói a partir da tabela suggestion e escreve em um novo namespace, por exemplo ac:v2:p:*. Depois alterne a configuração de leitura para v2 e delete v1. Esta é uma estratégia blue-green para o índice: nunca há um momento em que o usuário veja um índice parcial.

Periodicamente, você também deve sincronizar ac:score de volta à coluna suggestion.score. Esta coluna é a fonte para reconstrução quando necessário.

10. Quando NÃO usar esta abordagem

  • Precisa de busca fuzzy: usuário digita “ao thn” errado e ainda quer “áo thun”. Top-K por prefixo não consegue fazer isso. Use Elasticsearch/OpenSearch com completion suggester ou search_as_you_type.
  • Sugestões personalizadas: cada pessoa tem uma lista diferente. As chaves por prefixo são globais, portanto você precisa adicionar uma camada de re-ranking por usuário depois.
  • Catálogo pequeno (algumas dezenas de milhares de produtos, algumas centenas de requisições/segundo): uma consulta PostgreSQL com LIKE 'ao th%' na coluna normalized com índice text_pattern_ops é suficiente. Não construa Kafka e Redis Cluster para este problema.

11. Conclusão

Você acabou de passar por projetar um sistema de autocomplete que suporta dezenas de milhares de requisições/segundo. Os pontos-chave residem em quatro decisões:

  • Pré-calcule top-K para cada prefixo, para que o fluxo de leitura seja apenas um comando ZREVRANGE.
  • Separe o fluxo de escrita através de Kafka e agregue pontuações em lotes para que Redis não seja bombardeado por comandos de escrita.
  • Use ZINCRBY e ZADD GT para lidar com contenção sem precisar de lock.
  • Bloqueie hot keys com Caffeine junto com request coalescing.

Aceitar eventual consistency para pontuações de ranking, mas manter controle rígido sobre partes que precisam estar corretas imediatamente, como blocklists, é um equilíbrio razoável para a maioria das plataformas de e-commerce. A partir dessa base, você pode adicionar busca fuzzy ou personalização quando o produto realmente precisar.

system-designrediskafkaspring-bootautocomplete