Design de app de caronas: encontrar motorista próximo e matching

1. Visão Geral

Um aplicativo de compartilhamento de caronas tem duas questões fundamentais. Qual motorista está próximo ao passageiro? Como garantir que uma corrida pertence a apenas um motorista, mesmo quando dezenas de motoristas pressionam “Aceitar”?

Este artigo aborda o problema completo no estilo de design de sistemas: requisitos, estimativas de escala, modelo de dados, API e fluxo de processamento. Depois, concentra-se nos pontos difíceis: condições de corrida, consistência e picos de carga. Após a leitura, você será capaz de implementar a parte central de “encontrar motoristas e fazer matching de corridas” usando Spring Boot 3, Redis, Kafka e PostgreSQL.

2. Requisitos

2.1. Requisitos Funcionais

  • Motoristas enviam localização GPS periodicamente enquanto estão online.
  • Passageiros solicitam uma corrida com ponto de partida e destino.
  • O sistema encontra os motoristas livres mais próximos e envia convites sequencialmente para cada um.
  • Motoristas aceitam ou recusam. Se um motorista não responder em 15 segundos, o sistema passa o convite para o próximo.
  • Passageiros acompanham o status da corrida: REQUESTED → ACCEPTED → IN_PROGRESS → COMPLETED (ou CANCELLED).

2.2. Escala e Restrições

Suponha que o sistema opera em um país de tamanho médio:

  • No horário de pico, há 300.000 motoristas online, cada um enviando localização a cada 4 segundos. Isso significa cerca de 75.000 atualizações de localização por segundo.
  • Há cerca de 1.500 solicitações de corrida por segundo. Em dias chuvosos ou na hora de pico, esse número pode aumentar 5 vezes.
  • A busca de motoristas deve ser concluída em 200 ms (p99).
  • Uma corrida pertence a exatamente um motorista. Um motorista pode ter no máximo uma corrida ativa. Essa é uma restrição inviolável.

A partir desses números, chegamos a uma observação importante. Localização do motorista é um dado com altíssimo volume de escrita, com vida curta e que tolera perdas: perder um ponto GPS não é problema. Por outro lado, dados de corrida têm volume menor de escrita, mas precisam de consistência absoluta. Portanto, esses dois tipos de dados devem residir em armazenamentos diferentes.

3. Modelo de Dados

A localização do motorista é armazenada em Redis. As corridas são armazenadas em PostgreSQL:

CREATE TABLE rides (
    id           BIGSERIAL PRIMARY KEY,
    customer_id  VARCHAR(64)  NOT NULL,
    driver_id    VARCHAR(64),
    pickup_lat   DOUBLE PRECISION NOT NULL,
    pickup_lng   DOUBLE PRECISION NOT NULL,
    status       VARCHAR(20)  NOT NULL DEFAULT 'REQUESTED',
    created_at   TIMESTAMP    NOT NULL DEFAULT now(),
    accepted_at  TIMESTAMP
);

-- Cada motorista pode ter no máximo uma corrida ativa
CREATE UNIQUE INDEX ux_rides_driver_active
    ON rides (driver_id)
    WHERE status IN ('ACCEPTED', 'IN_PROGRESS');

O índice único parcial ao final é uma “rede de segurança” final. Suponha que a camada de aplicação tenha um bug e tente atribuir duas corridas ao mesmo motorista. Neste caso, o banco de dados rejeitará o segundo comando de escrita.

No lado do Redis, usamos três tipos de chave:

drivers:available            GEO set: driverId -> (lng, lat), contém apenas motoristas livres
driver:heartbeat:{driverId}  String, TTL 30s, marca motorista como ainda online
driver:lock:{driverId}       String, TTL 15s, valor = rideId sendo oferecida

4. API

POST /api/drivers/{driverId}/location   { "lat": 10.7769, "lng": 106.7009 }
POST /api/rides                         { "customerId": "c-42", "pickupLat": ..., "pickupLng": ... }
POST /api/rides/{rideId}/accept         { "driverId": "d-7" }
GET  /api/rides/{rideId}

O endpoint de atualização de localização tem um volume muito grande. Na prática, essa parte geralmente passa por WebSocket ou stream gRPC para não abrir uma conexão HTTP a cada 4 segundos. Para manter o exemplo simples, este artigo ainda usa REST.

5. Armazenar e Buscar Localização de Motoristas com Redis GEO

Adicione a dependência:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

5.1. Atualizar Localização

@Service
public class DriverLocationService {

    static final String AVAILABLE_KEY = "drivers:available";
    private final StringRedisTemplate redis;

    public DriverLocationService(StringRedisTemplate redis) {
        this.redis = redis;
    }

    public void updateLocation(String driverId, double lng, double lat) {
        redis.opsForGeo().add(AVAILABLE_KEY, new Point(lng, lat), driverId);
        redis.opsForValue().set("driver:heartbeat:" + driverId, "1", Duration.ofSeconds(30));
    }

    public void markBusy(String driverId) {
        redis.opsForGeo().remove(AVAILABLE_KEY, driverId);
    }
}

GEOADD armazena coordenadas como geohash em um sorted set. Cada comando tem complexidade O(log N), então um nó Redis pode lidar com dezenas de milhares de comandos por segundo sem dificuldade.

A parte do heartbeat existe porque members em um GEO set não têm TTL individual. Se um motorista perder sinal ou desligar o app, a coordenada antiga ainda estará no set. A chave de heartbeat expirando após 30 segundos nos ajuda a filtrar esses motoristas ‘fantasma’.

Nota: Redis GEO aceita parâmetros na ordem (longitude, latitude). Inverter a ordem é um erro muito comum. Com a inversão, motoristas em TP.HCM se ‘mudariam’ para o Pólo Sul.

5.2. Encontrar Motoristas Próximos

public List<String> findNearby(double lng, double lat, double radiusKm, int limit) {
    var args = RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs()
            .includeDistance()
            .sortAscending()
            .limit(limit * 2L); // buscar extras para compensar motoristas que serão filtrados

    GeoResults<RedisGeoCommands.GeoLocation<String>> results = redis.opsForGeo().search(
            AVAILABLE_KEY,
            GeoReference.fromCoordinate(lng, lat),
            new Distance(radiusKm, Metrics.KILOMETERS),
            args);

    List<String> candidates = results.getContent().stream()
            .map(r -> r.getContent().getName())
            .toList();

    List<String> alive = redis.opsForValue().multiGet(
            candidates.stream().map(id -> "driver:heartbeat:" + id).toList());

    List<String> online = new ArrayList<>();
    for (int i = 0; i < candidates.size() && online.size() < limit; i++) {
        if (alive.get(i) != null) {
            online.add(candidates.get(i));
        }
    }
    return online;
}

GEOSEARCH retorna os motoristas dentro do raio especificado, ordenados do mais próximo para o mais distante. Depois, um único comando MGET verifica o heartbeat de todos os candidatos, custando apenas um round-trip adicional. Para um passageiro no Distrito 1 com raio de 3 km, o resultado pode parecer assim:

[d-7 (0.4 km), d-19 (0.9 km), d-3 (1.6 km)]

A distância aqui é em linha reta. Na prática, você deve reordenar os top-N candidatos usando ETA real de um serviço de mapas. Motivo: um motorista a 500 m, mas do outro lado do rio, pode levar mais tempo que um motorista a 1 km.

6. Matching de Corridas e Tratamento de Condições de Corrida

Existem dois tipos de condições de corrida que precisam ser evitadas:

  1. Duas corridas convidam o mesmo motorista. Dois passageiros próximos solicitam caronas simultaneamente, e ambos fluxos de matching escolhem d-7.
  2. Dois motoristas aceitam a mesma corrida. Isso ocorre quando você envia convites em paralelo para vários motoristas para reduzir o tempo de espera.

6.1. Bloquear Motorista com Redis Durante o Convite

@Service
public class DriverLockService {

    private static final RedisScript<Long> RELEASE = RedisScript.of("""
            if redis.call('GET', KEYS[1]) == ARGV[1] then
                return redis.call('DEL', KEYS[1])
            end
            return 0
            """, Long.class);

    private final StringRedisTemplate redis;

    public DriverLockService(StringRedisTemplate redis) {
        this.redis = redis;
    }

    public boolean tryLock(String driverId, long rideId) {
        return Boolean.TRUE.equals(redis.opsForValue()
                .setIfAbsent("driver:lock:" + driverId, String.valueOf(rideId), Duration.ofSeconds(15)));
    }

    public void release(String driverId, long rideId) {
        redis.execute(RELEASE, List.of("driver:lock:" + driverId), String.valueOf(rideId));
    }
}

SET NX EX é uma operação atômica. Apenas um fluxo de matching obtém o lock para cada motorista, e o lock expira automaticamente após 15 segundos, exatamente o tempo de espera da resposta. Graças a este TTL, o sistema ‘passa adiante’ automaticamente quando um motorista não responde, sem necessidade de tarefas de limpeza.

O script Lua para liberar o lock apenas deleta a chave se o valor corresponder ao rideId. Se você usar DEL simples, a corrida A, após expiração do lock, pode acidentalmente deletar o lock que a corrida B acabou de obter.

6.2. Confirmar Corrida com Atualização Condicional

O lock Redis é apenas um mecanismo de otimização para evitar convites duplicados. O banco de dados continua sendo a fonte da verdade:

@Repository
public class RideRepository {

    private final JdbcTemplate jdbc;

    public RideRepository(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    public boolean assignDriver(long rideId, String driverId) {
        int updated = jdbc.update("""
                UPDATE rides
                SET status = 'ACCEPTED', driver_id = ?, accepted_at = CURRENT_TIMESTAMP
                WHERE id = ? AND status = 'REQUESTED'
                """, driverId, rideId);
        return updated == 1;
    }
}

A condição status = 'REQUESTED' transforma o comando UPDATE em uma operação compare-and-set. Quando dois motoristas pressionam aceitar simultaneamente, o banco de dados serializa dois comandos de escrita na mesma linha. O primeiro comando retorna 1. O segundo comando vê que o status já é ACCEPTED e retorna 0. Esta abordagem não requer SELECT ... FOR UPDATE nem uma coluna de versão separada.

O serviço que trata a aceitação da corrida monta as etapas:

@Transactional
public AcceptResult accept(long rideId, String driverId) {
    if (!rideRepository.assignDriver(rideId, driverId)) {
        return AcceptResult.ALREADY_TAKEN;
    }
    locationService.markBusy(driverId);
    lockService.release(driverId, rideId);
    return AcceptResult.ACCEPTED;
}

A ordem aqui é intencional: escrever no database antes, atualizar Redis depois. Se a etapa markBusy falhar, o motorista ainda estará na lista de livres por alguns segundos adicionais. A pior consequência é que o motorista receba outro convite, mas então o índice único parcial impedirá a atribuição de uma segunda corrida. Fazer o oposto é mais arriscado: deletar o motorista do Redis e depois falhar ao escrever no database o fará ‘desaparecer’ do sistema.

7. Processamento Assíncrono com Kafka

Encontrar um motorista, enviar um convite e esperar 15 segundos é um processo longo. Não deve manter a requisição HTTP do passageiro durante esse tempo todo. A API POST /api/rides apenas salva a corrida e retorna 202 Accepted imediatamente. O matching é executado em um consumer separado.

<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>
public record RideRequested(long rideId, String cityId, double lng, double lat, int attempt) {}

@Component
public class MatchingConsumer {

    private final DriverLocationService locations;
    private final DriverLockService locks;
    private final OfferSender offerSender;
    private final KafkaTemplate<String, RideRequested> kafka;

    // construtor omitido para brevidade

    @KafkaListener(topics = "ride-requested", groupId = "matching")
    public void onRideRequested(RideRequested event) {
        for (String driverId : locations.findNearby(event.lng(), event.lat(), 3, 10)) {
            if (locks.tryLock(driverId, event.rideId())) {
                offerSender.send(driverId, event.rideId()); // notificação push / WebSocket
                return;
            }
        }
        if (event.attempt() < 5) {
            // ninguém disponível: recoloque para tentar novamente
            kafka.send("ride-requested-retry", event.cityId(),
                    new RideRequested(event.rideId(), event.cityId(), event.lng(), event.lat(), event.attempt() + 1));
        }
    }
}

O consumer itera pelos candidatos do mais próximo para o mais distante. Ele bloqueia o primeiro motorista disponível e envia um convite para esse motorista. Se ninguém estiver disponível, o evento é movido para o tópico de retry, com limite de tentativas.

A chave da mensagem é cityId. Assim, todas as corridas em uma cidade caem na mesma partição, e o cache local de motoristas do consumer (se houver) atinge uma taxa de acerto mais alta.

Há um pequeno problema: salvar a corrida em PostgreSQL e depois enviar para Kafka são duas operações separadas. Se o aplicativo cair no meio do caminho, a corrida ficará “presa” no estado REQUESTED para sempre. A solução correta é o transactional outbox. Você escreve o evento na tabela outbox na mesma transação da tabela rides, e depois deixa um processo separado (ou Debezium) ler a tabela outbox e enviá-lo para Kafka.

Nota: Kafka garante at-least-once, então o consumer pode receber um evento duas vezes. A lógica de matching deve ser idempotente. Antes de enviar um convite, verifique se a corrida ainda está no estado REQUESTED. A atualização condicional na seção 6.2 já é segura contra duplicatas.

8. Lidar com Picos de Tráfego

Quando chove às 17h, tanto o volume de solicitações quanto o número de motoristas online aumentam drasticamente. Existem quatro estratégias principais de tratamento.

  • Shard os dados de localização por região. Em vez de usar uma chave drivers:available para todo o país, divida por cidade ou célula geohash, por exemplo drivers:available:hcm. Cada chave reside em um shard do Redis Cluster, distribuindo uniformemente a carga de escrita. Quando um passageiro está próximo à fronteira entre duas células, você consulta as células vizinhas também.
  • Reduzir a frequência de envio de localização. Um motorista parado ou carregando um passageiro não precisa enviar localização a cada 4 segundos. Envie apenas quando eles se moverem mais de 50 m ou se 15 segundos tiverem passado. Essa otimização sozinha pode reduzir 40-60% do volume de escrita.
  • Backpressure em vez de falha. Kafka funciona como um buffer. Quando o volume de solicitações aumenta 5 vezes, a latência de matching aumenta alguns segundos, mas nenhuma corrida é perdida. Você escala o número de consumers com base no lag do consumer, com limite superior sendo o número de partições.
  • Preços dinâmicos (surge pricing). Essa é uma solução no nível de produto, não técnico. Preços mais altos reduzem a demanda e atraem mais motoristas online, ajudando a reequilibrar o sistema.

Além disso, você deve rate limit a API de solicitação de corrida por customerId. Quando o app mostra ‘procurando carro’ por muito tempo, os passageiros tendem a pressionar solicitar novamente repetidamente, criando um pico de carga artificial.

9. Testando Condições de Corrida na Aceitação de Corrida

O comportamento mais importante a testar é: se vários motoristas aceitarem a mesma corrida, apenas um vence. O teste abaixo usa H2 em memória via @JdbcTest. O arquivo src/test/resources/schema.sql contém a tabela rides como na seção 3, sem o índice parcial pois H2 não o suporta.

@JdbcTest
@Import(RideRepository.class)
class RideRepositoryTest {

    @Autowired
    RideRepository rideRepository;

    @Autowired
    JdbcTemplate jdbc;

    @Test
    void givenConcurrentAccepts_whenAssignDriver_thenOnlyOneDriverWins() throws Exception {
        jdbc.update("INSERT INTO rides (id, customer_id, pickup_lat, pickup_lng, status) "
                + "VALUES (1, 'c-42', 10.77, 106.70, 'REQUESTED')");

        List<String> drivers = List.of("d-1", "d-2", "d-3", "d-4", "d-5");
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<Boolean>> results = drivers.stream()
                    .map(d -> executor.submit(() -> rideRepository.assignDriver(1L, d)))
                    .toList();

            long winners = 0;
            for (Future<Boolean> f : results) {
                if (f.get()) winners++;
            }
            assertThat(winners).isEqualTo(1);
        }

        String status = jdbc.queryForObject("SELECT status FROM rides WHERE id = 1", String.class);
        assertThat(status).isEqualTo("ACCEPTED");
    }
}

Cinco virtual threads chamam assignDriver na mesma corrida. Independentemente da ordem de execução, apenas um comando UPDATE satisfaz a condição status = 'REQUESTED'.

Você pode notar que o teste removeu o @Transactional padrão de @JdbcTest da equação. Motivo: as novas threads não executam na mesma transação do método de teste, e o comando INSERT ainda é visível para eles porque os dados já foram commitados com auto-commit. Se você receber o resultado winners = 0, adicione @Transactional(propagation = Propagation.NOT_SUPPORTED) à classe de teste.

Para lógica GEO, use Testcontainers rodando Redis real em vez de mock. Cálculos de distância e ordenação são coisas que mock não pode verificar adequadamente.

10. Conclusão

Este artigo delineou a parte central de um aplicativo de compartilhamento de caronas. O ponto-chave é separar dados por características:

  • Localização do motorista tem alto volume de escrita e tolera perdas, então coloque em Redis GEO, acompanhado de heartbeat para filtrar motoristas desconectados.
  • Corridas requerem consistência, então coloque em PostgreSQL. Atualização condicional funciona como compare-and-set, e o índice único parcial é a rede de segurança final.
  • Lock Redis com TTL impede convites duplicados e trata timeouts automaticamente. No entanto, o lock é apenas um mecanismo de otimização, não a fonte da verdade.
  • Kafka desacopla o fluxo de matching da requisição do passageiro, absorve picos de carga e permite retry. O padrão outbox garante que nenhum evento seja perdido.

Ao expandir, você pode considerar: reordenar candidatos por ETA em vez de distância em linha reta, batch matching a cada alguns segundos para otimização global, e compartilhamento de caronas (pooling). Tudo pode ser construído sobre a base apresentada aqui.

system-designrediskafkaspring-bootestudo-de-caso