Diseño de una app de taxis: encontrar el conductor más cercano

1. Descripción general

Una aplicación de llamada de taxis tiene dos preguntas fundamentales. ¿Qué conductor está más cerca del pasajero? ¿Cómo lograr que un viaje pertenezca a exactamente un conductor, incluso cuando decenas de conductores presionan “Aceptar” simultáneamente?

Este artículo aborda todo el problema siguiendo la metodología de diseño de sistemas: requisitos, estimación de escala, modelo de datos, API y flujos de procesamiento. Luego se profundiza en los puntos críticos: conflictos, consistencia y picos de carga. Al terminar de leer, podrás construir el núcleo de “encontrar conductor y asignar viaje” usando Spring Boot 3, Redis, Kafka y PostgreSQL.

2. Requisitos

2.1. Requisitos funcionales

  • El conductor envía su ubicación GPS periódicamente mientras está conectado.
  • El pasajero solicita un viaje con punto de recogida y destino.
  • El sistema encuentra los conductores disponibles más cercanos y les envía invitaciones secuencialmente.
  • El conductor acepta o rechaza. Si no responde en 15 segundos, el sistema envía la invitación al siguiente.
  • El pasajero puede seguir el estado del viaje: REQUESTED → ACCEPTED → IN_PROGRESS → COMPLETED (o CANCELLED).

2.2. Escala y restricciones

Supongamos un sistema operando en un país de tamaño medio:

  • En horas pico hay 300.000 conductores conectados, cada uno envía su ubicación cada 4 segundos. Esto suma aproximadamente 75.000 escrituras de ubicación por segundo.
  • Hay alrededor de 1.500 solicitudes de viaje por segundo. En lluvia o a hora de salida del trabajo, esto puede multiplicarse por 5.
  • Encontrar conductor debe completarse en 200 ms (p99).
  • Un viaje pertenece exactamente a un conductor. Un conductor tiene como máximo un viaje activo. Esta es una restricción que no puede violarse.

De estas cifras surge una observación importante: la ubicación del conductor es datos de escritura muy frecuente, con ciclo de vida corto y tolerancia a pérdida (perder un punto GPS no importa). Por el contrario, los datos de viaje se escriben mucho menos pero requieren consistencia absoluta. Por eso estos dos tipos de datos deben estar en almacenes diferentes.

3. Modelo de datos

Ubicación del conductor se almacena en Redis. Viajes se almacenan en 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 conductor tiene como máximo un viaje activo
CREATE UNIQUE INDEX ux_rides_driver_active
    ON rides (driver_id)
    WHERE status IN ('ACCEPTED', 'IN_PROGRESS');

El índice único parcial al final es la “red de seguridad” final. Si la capa de aplicación tiene un bug e intenta asignar dos viajes al mismo conductor, la base de datos rechazará la segunda escritura.

En Redis se usan tres tipos de clave:

drivers:available            Conjunto GEO: driverId -> (lng, lat), solo conductores disponibles
driver:heartbeat:{driverId}  String, TTL 30s, marca que el conductor sigue conectado
driver:lock:{driverId}       String, TTL 15s, valor = rideId siendo ofrecido

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}

El endpoint de actualización de ubicación maneja mucha carga. En producción, esto típicamente se hace por WebSocket o gRPC stream para evitar abrir una conexión HTTP cada 4 segundos. Para mantener el ejemplo simple, este artículo usa REST.

5. Guardar y buscar ubicación del conductor con Redis GEO

Añade la dependencia:

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

5.1. Actualizar ubicación

@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 guarda las coordenadas como geohash en un sorted set. Cada comando tiene complejidad O(log N), así que un nodo Redis puede manejar decenas de miles de comandos por segundo sin problemas.

El heartbeat es necesario porque los miembros en un conjunto GEO no tienen TTL individual. Si el conductor pierde conexión o cierra la app, las coordenadas antiguas permanecen en el conjunto. Una clave de heartbeat que expira después de 30 segundos nos ayuda a filtrar estos conductores “fantasma”.

Nota: Redis GEO espera parámetros en orden (longitud, latitud). Invertir el orden es un error muy común. Si lo haces, los conductores en Ciudad Ho Chi Minh se “moverán” a la Antártida.

5.2. Encontrar el conductor más cercano

public List<String> findNearby(double lng, double lat, double radiusKm, int limit) {
    var args = RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs()
            .includeDistance()
            .sortAscending()
            .limit(limit * 2L); // obtener de más para compensar conductores 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 devuelve conductores dentro del radio especificado, ordenados del más cercano al más lejano. Luego un MGET único verifica el heartbeat de todos los candidatos, usando solo un viaje redondo adicional. Para un pasajero en el Distrito 1 con radio de 3 km, el resultado podría verse así:

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

La distancia aquí es en línea recta. En producción, deberías re-clasificar los top-N candidatos usando ETA real del servicio de mapas. La razón: un conductor a 500 m pero al otro lado del río puede llegar más lentamente que uno a 1 km de distancia.

6. Asignar viaje y manejar conflictos

Hay dos tipos de conflictos a evitar:

  1. Dos viajes invitan al mismo conductor. Dos pasajeros están cerca y solicitan viajes simultáneamente; ambos flujos de matching eligen a d-7.
  2. Dos conductores aceptan el mismo viaje. Esto sucede cuando envías invitaciones en paralelo a múltiples conductores para reducir el tiempo de espera.

6.1. Bloquear conductor en Redis mientras se invita

@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 es una operación atómica. Solo un hilo de matching puede tomar el bloqueo de cada conductor, y se libera automáticamente después de 15 segundos, exactamente el tiempo de espera de la respuesta del conductor. Gracias a este TTL, el sistema automáticamente “pasa al siguiente” cuando el conductor no responde sin necesidad de limpieza.

El script Lua para liberar bloqueo solo elimina la clave si el valor coincide con el rideId. Si usas DEL directo, un viaje A cuyo bloqueo expiró podría eliminar accidentalmente el bloqueo que el viaje B acaba de obtener.

6.2. Confirmar viaje con actualización condicional

El bloqueo de Redis es solo una optimización para evitar invitaciones duplicadas. La fuente de verdad sigue siendo la base de datos:

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

La condición status = 'REQUESTED' convierte el UPDATE en un compare-and-set. Cuando dos conductores aceptan simultáneamente, la base de datos serializa las dos escrituras en el mismo row. El primer comando devuelve 1. El segundo ve que el estado ya es ACCEPTED, así que devuelve 0. Este enfoque no necesita SELECT ... FOR UPDATE ni una columna de versión separada.

El servicio que maneja la aceptación de viaje une los pasos:

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

El orden aquí es deliberado: escribir en base de datos primero, actualizar Redis después. Si el paso markBusy falla, el conductor sigue en la lista de disponibles unos segundos más. El peor caso es que el conductor reciba una invitación adicional, y cuando intente aceptar, el índice de único parcial bloqueará la asignación del segundo viaje. Hacerlo al revés es más riesgoso: eliminar del conductor de Redis y luego fallar en escribir la base de datos haría que el conductor “desapareciera” del sistema.

7. Procesamiento asincrónico con Kafka

Encontrar conductor, enviar invitación y esperar 15 segundos es un proceso largo. No deberías mantener la solicitud HTTP del pasajero durante todo ese tiempo. El API POST /api/rides solo guarda el viaje y devuelve 202 Accepted inmediatamente. La parte de asignación corre en un consumidor 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;

    // constructor omitido por brevedad

    @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()); // notificación push / WebSocket
                return;
            }
        }
        if (event.attempt() < 5) {
            // nadie disponible: reintentar después
            kafka.send("ride-requested-retry", event.cityId(),
                    new RideRequested(event.rideId(), event.cityId(), event.lng(), event.lat(), event.attempt() + 1));
        }
    }
}

El consumidor itera los candidatos del más cercano al más lejano. Bloquea el primer conductor disponible e invita al conductor. Si nadie está disponible, el evento se envía al topic de reintentos, con un límite de intentos.

La clave del mensaje es cityId. Así todos los viajes en una ciudad caen en el mismo grupo de particiones, y el caché local del consumidor (si existe) logra una tasa de aciertos más alta.

Hay un problema menor: guardar el viaje en PostgreSQL y luego enviar a Kafka son dos operaciones separadas. Si la aplicación se bloquea entre medias, el viaje quedará “atascado” en estado REQUESTED para siempre. La solución correcta es el patrón transactional outbox. Escribes el evento en una tabla outbox dentro de la misma transacción que la tabla rides, luego dejas que un proceso separado (o Debezium) lea la tabla outbox y envíe a Kafka.

Nota: Kafka garantiza at-least-once, así que el consumidor podría recibir un evento dos veces. La lógica de matching debe ser idempotente. Antes de enviar la invitación, verifica que el viaje sigue en estado REQUESTED. La actualización condicional de la sección 6.2 ya es segura contra duplicados.

8. Manejo de picos de carga

Cuando llueve a las 5 de la tarde, tanto el volumen de solicitudes como el número de conductores conectados se disparan. Hay cuatro formas principales de manejar esto.

  • Particionar datos de ubicación por región. Donde tener una sola clave drivers:available para todo el país, divídela por ciudad o celda geohash, por ejemplo drivers:available:hcm. Cada clave vive en un shard del Redis Cluster, así la carga de escritura se distribuye. Cuando un pasajero está cerca del límite de dos celdas, consultas también las celdas adyacentes.
  • Reducir frecuencia de envío de ubicación. Un conductor parado o llevando un pasajero no necesita enviar ubicación cada 4 segundos. Solo cuando se mueve más de 50 m o cuando han pasado 15 segundos. Esta optimización por sí sola puede reducir 40–60% del volumen de escritura.
  • Backpressure en lugar de colapso. Kafka actúa como amortiguador. Cuando el volumen de solicitudes crece 5 veces, la latencia de asignación aumenta unos segundos, pero ningún viaje se pierde. Escala el número de consumidores según el lag del consumidor, con un límite superior igual al número de particiones.
  • Precio dinámico (surge pricing). Esta es una solución a nivel de producto, no técnica. Precios más altos reducen la demanda y atraen más conductores, rebalanceando el sistema.

Además, implementa rate limiting en el API de solicitud de viajes por customerId. Cuando la app muestra “buscando taxi” demasiado tiempo, los pasajeros tienden a pulsar solicitar repetidamente, creando así una tanda de carga duplicada.

9. Prueba de conflictos al aceptar viaje

El comportamiento más importante a probar es: cuando múltiples conductores aceptan el mismo viaje, solo uno gana. Esta prueba usa H2 en memoria a través de @JdbcTest. El archivo src/test/resources/schema.sql contiene la tabla rides como en la sección 3, omitiendo el índice parcial ya que H2 no lo soporta.

@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 threads virtuales llaman a assignDriver en el mismo viaje. Sin importar el orden de ejecución, solo una orden UPDATE coincide con la condición status = 'REQUESTED'.

Notarás que la prueba omitió la anotación @Transactional predeterminada de @JdbcTest del cálculo. La razón: los nuevos threads no ejecutan en la misma transacción que el método de prueba, y el INSERT sigue siendo visible para ellos porque los datos ya fueron commiteados bajo la configuración de auto-commit. Si obtienes winners = 0, añade @Transactional(propagation = Propagation.NOT_SUPPORTED) a la clase de prueba.

Con lógica GEO, usa Testcontainers ejecutando Redis real en lugar de mocks. El cálculo de distancia y ordenamiento son cosas que los mocks no pueden verificar.

10. Conclusión

Este artículo ha esbozado el núcleo de una aplicación de llamada de taxis. El punto clave es separar datos según su naturaleza:

  • Ubicación del conductor se escribe frecuentemente y tolera pérdida, así que va en Redis GEO acompañado de heartbeat para filtrar conductores desconectados.
  • Viaje requiere consistencia, así que va en PostgreSQL. La actualización condicional actúa como compare-and-set, e índice único parcial es la red de seguridad final.
  • Bloqueo de Redis con TTL previene invitaciones duplicadas al conductor y maneja timeout automáticamente. Aún así, el bloqueo es solo una optimización, no la fuente de verdad.
  • Kafka desacopla el flujo de asignación de la solicitud del pasajero, absorbe picos de carga y permite reintentos. El patrón outbox garantiza que no se pierdan eventos.

Cuando expandes después, considera: clasificar candidatos por ETA real en lugar de distancia en línea recta, matching por lotes cada pocos segundos para optimizar globalmente, y viajes compartidos (pooling). Todo se construye sobre la base presentada aquí.

system-designrediskafkaspring-bootcase-study