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
queryjá normalizado, nãokey. 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
removeesum(), caindo em um novoLongAddere 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:scoreincrementa a pontuação atomicamente e retorna a nova soma. Não há operação read-modify-write no lado da aplicação.ZADD ... GTescreve 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 -51remove 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 erroCROSSSLOT. 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:
- Cliente: debounce, comprimento mínimo de 2 caracteres, cancele requisições antigas.
- 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.
- Coalescing: como mencionado na seção 7, múltiplas requisições com a mesma chave criam apenas uma chamada a Redis.
- Redis replica: habilite leitura a partir de réplica (
ReadFrom.REPLICA_PREFERREDno Lettuce) para distribuir a carga de leitura. - 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 = trueno BD, adiciona aac:blocked, e publica um eventokeyword-blocked. - Cada instância escuta este evento. A instância atualiza um
Set<String>blocklist em memória, depois chamalocal.invalidateAll(). A etapafilterao ler remove elementos na blocklist. - Um job em background executa
ZREMnos prefixos do termo e o remove deac:scorepara 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 suggesterousearch_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 colunanormalizedcom índicetext_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
ZINCRBYeZADD GTpara 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.