Diseño de tracking de pedidos y ubicación de repartidores en tiempo real
1. Resumen general
Cuando ordenas comida o contratas un envío, el cliente quiere saber en qué estado está el pedido y dónde está el repartidor en el mapa. Este artículo diseña un sistema así desde los requisitos hasta el código. Pasamos por el modelo de datos, API, flujos de procesamiento, y luego los tres puntos difíciles: contención cuando se reclama un pedido, consistencia de datos y picos de carga.
Al terminar, tendrás una arquitectura de referencia y fragmentos de código Spring Boot 3.x para comenzar la implementación.
2. Requisitos
2.1. Requisitos funcionales
- El repartidor envía su ubicación GPS periódicamente desde la app.
- El cliente ve el estado del pedido y la ubicación del repartidor casi en tiempo real, con una latencia menor a 3 segundos.
- El repartidor reclama un pedido. Cada pedido se asigna a exactamente un repartidor.
- El estado del pedido sigue un ciclo de vida:
PENDING → ASSIGNED → PICKED_UP → DELIVERING → DELIVERED. Desde algunos estados se puede cambiar aCANCELLED. - Se guarda el historial de cambios de estado para conciliación y resolución de reclamaciones.
2.2. Estimación de escala
| Métrica | Valor |
|---|---|
| Repartidores online en hora punta | 50.000 |
| Período de envío de ubicación | 4 segundos |
| Escrituras de ubicación | ~12.500 msg/s |
| Clientes viendo la pantalla de seguimiento | ~200.000 conexiones |
| Nuevos pedidos en hora punta | ~300 pedidos/s |
2.3. Restricciones
- La ubicación es datos temporales. Perder algunos puntos es aceptable, pero no se debe mostrar una ubicación anterior sobrescribiendo una más reciente.
- El estado del pedido es datos críticos. No se puede perder ni debe estar en orden incorrecto.
- La red móvil es inestable, así que la app reintentará y enviará en lotes.
De aquí sale una decisión clave: separar dos flujos. El flujo de ubicación necesita alto rendimiento y acepta pérdida de datos. El flujo de estado necesita transacciones y durabilidad.
3. Arquitectura general
App repartidor ──HTTP batch──> Location API ──> Kafka [shipper-locations]
│
▼
Location Consumer ──> Redis (última ubicación, GEO)
│
└──> Redis Pub/Sub [order:{id}:loc]
│
App cliente <──────── SSE ──────── Tracking Gateway <────────────┘
App repartidor ──HTTP──> Order Service ──> PostgreSQL (orders, order_events, outbox)
│
└──> Outbox relay ──> Kafka [order-events]
Cada componente tiene un propósito:
- Kafka absorbe el pico de escrituras de ubicación y mantiene el orden por repartidor, usando
shipperIdcomo clave. - Redis solo guarda la ubicación más reciente de cada repartidor. El sistema no escribe cada punto GPS en la base de datos relacional.
- PostgreSQL es la fuente de verdad para el estado del pedido.
- SSE empuja datos en una dirección desde el servidor hacia el cliente. SSE es más simple que WebSocket y suficiente para este caso de uso.
4. Modelo de datos
4.1. PostgreSQL para pedidos
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL,
shipper_id BIGINT,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
version INT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE order_events (
id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES orders(id),
from_status VARCHAR(20),
to_status VARCHAR(20) NOT NULL,
actor_id BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
aggregate_id BIGINT NOT NULL,
payload JSONB NOT NULL,
published BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_outbox_unpublished ON outbox(id) WHERE published = false;
La columna version sirve para optimistic locking. La tabla order_events es un historial de solo adición, no se modifica. La tabla outbox asegura que los eventos se publiquen a Kafka sin pérdida. La sección 7 lo explica en detalle.
4.2. Redis para ubicaciones
shipper:loc:{shipperId} HASH {lat, lng, ts} TTL 120s
shippers:geo:{city} GEO member = shipperId
shipper:active-order:{shipperId} STRING orderId
TTL de 120 segundos ayuda a que un repartidor sin conexión desaparezca automáticamente del sistema. No necesitas un job de limpieza separado. La clave GEO se separa por ciudad, así que el comando GEOSEARCH para encontrar repartidores cercanos no tiene que escanear toda la nación.
5. API
POST /api/v1/shippers/{shipperId}/locations body: [ {lat,lng,ts}, ... ] -> 202
POST /api/v1/orders/{orderId}/claim -> 200 | 409
POST /api/v1/orders/{orderId}/transitions body: {to: "PICKED_UP"} -> 200 | 409
GET /api/v1/orders/{orderId} -> estado + ubicación actual
GET /api/v1/orders/{orderId}/track -> text/event-stream
El API de ubicación devuelve 202 Accepted porque solo empuja datos a Kafka y responde inmediatamente. Este API acepta datos en lotes, para que la app pueda agrupar puntos atrapados durante la pérdida de conexión y enviar todo de una vez.
6. Flujo de procesamiento de ubicaciones
Necesitas las siguientes dependencias:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<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>
Configura el serializador JSON para Kafka:
spring:
kafka:
bootstrap-servers: localhost:9092
producer:
value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
acks: 1
consumer:
group-id: location-consumer
value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
properties:
spring.json.trusted.packages: "com.nonla.tracking"
El valor acks: 1 es una decisión deliberada. Perder algunos puntos GPS cuando el broker líder muere es aceptable, a cambio de menor latencia de escritura comparado con acks: all.
6.1. Recibir ubicaciones
public record LocationUpdate(long shipperId, double lat, double lng, long ts) {}
@RestController
@RequestMapping("/api/v1/shippers")
public class LocationController {
private final KafkaTemplate<String, LocationUpdate> kafka;
public LocationController(KafkaTemplate<String, LocationUpdate> kafka) {
this.kafka = kafka;
}
@PostMapping("/{shipperId}/locations")
public ResponseEntity<Void> push(@PathVariable long shipperId,
@RequestBody List<LocationUpdate> batch) {
batch.stream()
.filter(u -> u.shipperId() == shipperId)
.forEach(u -> kafka.send("shipper-locations", String.valueOf(shipperId), u));
return ResponseEntity.accepted().build();
}
}
Dos cosas a tener en cuenta:
- La clave es
shipperId. Todos los puntos del mismo repartidor caen en la misma partición, así que el consumer los procesa en el orden exacto del envío. - El filtro
filterdescarta puntos con unshipperIddiferente al del path. En la práctica, deberías obtenershipperIddel JWT en lugar de confiar en el body.
6.2. Guardar la ubicación más reciente, descartar puntos antiguos
El orden en la partición no es suficiente. La app puede reenviar un lote antiguo después de recuperar la conexión, y ese lote puede llegar después de un punto más reciente. Por lo tanto, el consumer debe comparar ts y solo escribir si el punto es más nuevo. La comparación y la escritura deben ser atómicas, así que usamos un script Lua:
@Component
public class LocationConsumer {
private static final RedisScript<Long> UPSERT_IF_NEWER = RedisScript.of("""
local cur = redis.call('HGET', KEYS[1], 'ts')
if cur and tonumber(cur) >= tonumber(ARGV[3]) then return 0 end
redis.call('HSET', KEYS[1], 'lat', ARGV[1], 'lng', ARGV[2], 'ts', ARGV[3])
redis.call('EXPIRE', KEYS[1], 120)
return 1
""", Long.class);
private final StringRedisTemplate redis;
public LocationConsumer(StringRedisTemplate redis) {
this.redis = redis;
}
@KafkaListener(topics = "shipper-locations", concurrency = "6")
public void onLocation(LocationUpdate u) {
Long updated = redis.execute(UPSERT_IF_NEWER,
List.of("shipper:loc:" + u.shipperId()),
String.valueOf(u.lat()), String.valueOf(u.lng()), String.valueOf(u.ts()));
if (updated == null || updated == 0) return; // punto antiguo, descartar
redis.opsForGeo().add("shippers:geo:hcm", new Point(u.lng(), u.lat()),
String.valueOf(u.shipperId()));
String orderId = redis.opsForValue().get("shipper:active-order:" + u.shipperId());
if (orderId != null) {
redis.convertAndSend("order:" + orderId + ":loc",
"{\"lat\":%s,\"lng\":%s,\"ts\":%d}".formatted(u.lat(), u.lng(), u.ts()));
}
}
}
El flujo de procesamiento es así:
- El script devuelve
0si el punto es más antiguo que o igual al punto almacenado. En ese caso, el consumer se detiene inmediatamente sin actualizar GEO ni empujar al cliente. - El comando
GEOADDrecibe parámetros en el orden longitud primero, latitud después. Este es un error muy común. - El sistema solo publica cuando el repartidor tiene un pedido activo. Un repartidor sin pedido no genera tráfico fan-out.
Nota: El script anterior solo toca una clave, así que funciona en Redis Cluster. Si quieres incluir
GEOADDen el script, todas las claves deben estar en el mismo slot. En ese caso, usa hash tags como{hcm}:loc:42y{hcm}:geo.
6.3. Empujar al cliente con SSE
@RestController
public class TrackingController implements MessageListener {
private final Map<String, Set<SseEmitter>> subscribers = new ConcurrentHashMap<>();
@GetMapping(value = "/api/v1/orders/{orderId}/track",
produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter track(@PathVariable String orderId) {
SseEmitter emitter = new SseEmitter(Duration.ofMinutes(30).toMillis());
Set<SseEmitter> set = subscribers.computeIfAbsent(orderId, k -> ConcurrentHashMap.newKeySet());
set.add(emitter);
Runnable cleanup = () -> set.remove(emitter);
emitter.onCompletion(cleanup);
emitter.onTimeout(cleanup);
emitter.onError(e -> cleanup.run());
return emitter;
}
@Override
public void onMessage(Message message, byte[] pattern) {
String channel = new String(message.getChannel()); // order:123:loc
String orderId = channel.split(":")[1];
String body = new String(message.getBody());
subscribers.getOrDefault(orderId, Set.of()).forEach(e -> {
try {
e.send(SseEmitter.event().name("location").data(body));
} catch (IOException ex) {
e.completeWithError(ex);
}
});
}
}
Registras un listener con patrón order:*:loc:
@Bean
RedisMessageListenerContainer container(RedisConnectionFactory cf, TrackingController listener) {
var c = new RedisMessageListenerContainer();
c.setConnectionFactory(cf);
c.addMessageListener(listener, new PatternTopic("order:*:loc"));
return c;
}
El cliente recibe eventos como estos:
event:location
data:{"lat":10.7769,"lng":106.7009,"ts":1760342400000}
El gateway es stateless, así que puedes escalar horizontalmente cuantas instancias quieras. La instancia que mantiene la conexión SSE del cliente recibirá el mensaje a través de Pub/Sub y lo empujará hacia abajo. Con 200.000 conexiones, deberías habilitar virtual threads (spring.threads.virtual.enabled=true) o cambiar a WebFlux, para que cada conexión no tenga que retener un thread de plataforma.
7. Puntos difíciles y cómo resolverlos
7.1. Contención: dos repartidores reclaman el mismo pedido
Un nuevo pedido se muestra a varios repartidores cercanos. Dos personas pueden hacer clic en “Reclamar” al mismo tiempo. La manera incorrecta es leer el estado, verificar PENDING, y luego escribir, porque hay un hueco entre la lectura y la escritura. La manera correcta es usar UPDATE con condición:
@Service
public class OrderClaimService {
private final JdbcTemplate jdbc;
public OrderClaimService(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Transactional
public boolean claim(long orderId, long shipperId) {
int rows = jdbc.update("""
UPDATE orders
SET shipper_id = ?, status = 'ASSIGNED', version = version + 1, updated_at = now()
WHERE id = ? AND status = 'PENDING'
""", shipperId, orderId);
if (rows == 0) return false; // alguien más ya reclamó
jdbc.update("INSERT INTO order_events(order_id, from_status, to_status, actor_id) VALUES (?, 'PENDING', 'ASSIGNED', ?)",
orderId, shipperId);
jdbc.update("INSERT INTO outbox(aggregate_id, payload) VALUES (?, ?::jsonb)",
orderId, "{\"orderId\":%d,\"status\":\"ASSIGNED\",\"shipperId\":%d}".formatted(orderId, shipperId));
return true;
}
}
PostgreSQL bloquea la fila mientras ejecuta UPDATE. El segundo repartidor debe esperar, luego reevalúa la condición status = 'PENDING', ve que ya no coincide y obtiene rows == 0. El controller devuelve 409 Conflict, y la app muestra un mensaje “El pedido ya fue reclamado”. No necesitas un distributed lock.
7.2. Consistencia: máquina de estados y outbox
Toda transición de estado debe ser válida. Codificas las reglas de transición directamente en un enum:
public enum OrderStatus {
PENDING, ASSIGNED, PICKED_UP, DELIVERING, DELIVERED, CANCELLED;
public boolean canTransitionTo(OrderStatus next) {
return switch (this) {
case PENDING -> next == ASSIGNED || next == CANCELLED;
case ASSIGNED -> next == PICKED_UP || next == CANCELLED;
case PICKED_UP -> next == DELIVERING;
case DELIVERING -> next == DELIVERED;
case DELIVERED, CANCELLED -> false;
};
}
}
Pruebas para el comportamiento principal:
class OrderStatusTest {
@Test
void shouldAllowHappyPath() {
assertThat(OrderStatus.PENDING.canTransitionTo(OrderStatus.ASSIGNED)).isTrue();
assertThat(OrderStatus.DELIVERING.canTransitionTo(OrderStatus.DELIVERED)).isTrue();
}
@Test
void shouldRejectCancelAfterPickup() {
assertThat(OrderStatus.PICKED_UP.canTransitionTo(OrderStatus.CANCELLED)).isFalse();
}
@Test
void terminalStatesAreFinal() {
assertThat(OrderStatus.values())
.noneMatch(OrderStatus.DELIVERED::canTransitionTo)
.noneMatch(OrderStatus.CANCELLED::canTransitionTo);
}
}
Cuando cambias el estado, usas la condición WHERE id = ? AND version = ?. Si no se actualiza ninguna fila, significa que alguien más cambió el pedido antes. En ese caso devuelves 409 y dejas que el cliente recargue.
El patrón outbox resuelve el problema “escribir en DB con éxito pero fallar al enviar a Kafka”. El evento se escribe en la tabla outbox en la misma transacción que orders. Un relay periódico lee filas no publicadas y las empuja a Kafka:
@Scheduled(fixedDelay = 500)
@Transactional
public void relay() {
var rows = jdbc.queryForList(
"SELECT id, aggregate_id, payload::text AS payload FROM outbox WHERE published = false ORDER BY id LIMIT 200 FOR UPDATE SKIP LOCKED");
for (var r : rows) {
kafka.send("order-events", r.get("aggregate_id").toString(), (String) r.get("payload")).join();
jdbc.update("UPDATE outbox SET published = true WHERE id = ?", r.get("id"));
}
}
La cláusula FOR UPDATE SKIP LOCKED permite ejecutar múltiples relays en paralelo sin procesar la misma fila dos veces. El relay asegura at-least-once: si muere después de enviar pero antes de marcar como published, el evento se reenviará. Por lo tanto, el consumer de order-events debe ser idempotente. Por ejemplo, el consumer ignora eventos con version menor o igual a la version ya procesada.
El consumer de order-events también es donde estableces o eliminas la clave shipper:active-order:{shipperId}. Gracias a eso, el flujo de ubicación sabe a qué pedido empujar.
7.3. Picos de carga
En la hora del almuerzo, lluvias fuertes o días de venta, la carga puede triplicarse o quintuplicarse en solo algunos minutos. Tienes varias formas de manejar esto:
- Kafka como buffer. Si el consumer es lento, el lag aumenta, pero el API sigue respondiendo rápido. Aumenta el número de particiones (por ejemplo 48) con anticipación, porque aumentarlas mientras se ejecuta reorganizará el orden por clave.
- Descartar puntos muy antiguos. Cuando el lag es grande, un punto GPS de hace 30 segundos no tiene valor para mostrarlo. El consumer puede descartarlo inmediatamente:
if (System.currentTimeMillis() - u.ts() > 30_000) return;. - Período de envío adaptativo. El servidor devuelve un header sugiriendo, por ejemplo,
X-Next-Interval: 8, para que la app extienda el período cuando el sistema está sobrecargado. Un repartidor parado envía con menos frecuencia. - Agrupar en el cliente. La app agrupa 2–3 puntos en una solicitud. El número de solicitudes HTTP disminuye mucho, mientras que el número de mensajes Kafka se mantiene igual.
- Rate limit por repartidor. Limitas a cada repartidor a máximo 1 solicitud/segundo para bloquear apps buggy o clientes falsificados spamming.
- Separar recursos. El flujo de ubicación corre en un cluster Redis separado y un grupo de instancias separado. Si este flujo se cae, la reclamación de pedidos y la actualización de estado todavía funcionan.
8. Errores comunes y cuándo no hacer esto
- Escribir cada punto GPS en PostgreSQL. 12.500 inserts/s más indexación rápidamente ahogará la base de datos. Si necesitas guardar la ruta para auditoría, haz que otro consumer escriba en lotes en un almacén más apropiado como TimescaleDB u object storage, con una frecuencia menor.
- Usar un distributed lock (Redisson) para reclamar pedidos. Esto es más complejo y propenso a errores que UPDATE con condición en la fuente de verdad misma.
- Confiar en el orden de llegada de solicitudes. La red móvil no garantiza orden. Siempre debes comparar
ts, y para el estado comparaversion. - Redis Pub/Sub para datos críticos. Pub/Sub es fire-and-forget. El suscriptor que pierde conexión pierde el mensaje. Esto está bien para ubicaciones, pero no es apropiado para eventos de estado. Los eventos de estado deben ir a través de Kafka. Cuando el cliente se reconecta a SSE, llama a
GET /orders/{id}para obtener una snapshot más reciente.
Nota: Si tu sistema solo tiene unos pocos cientos de repartidores, esta arquitectura es excesiva. Un servicio único escribiendo ubicaciones en Redis, combinado con polling del cliente cada 5 segundos, es suficiente. Añade Kafka y SSE gateway cuando los números reales te fuerzen a hacerlo.
9. Conclusión
Este artículo diseñó un sistema de tracking de pedidos y ubicación del repartidor siguiendo el principio central: separar datos temporales de datos críticos.
- La ubicación va a través de Kafka hacia Redis. El sistema acepta pérdida, pero bloquea sobrescribir un punto antiguo usando un script Lua que compara timestamps.
- El estado del pedido reside en PostgreSQL. La contención se resuelve con UPDATE condicional, la consistencia se mantiene con máquina de estados y patrón outbox.
- Los picos de carga se absorben con Kafka, combinado con descartar puntos antiguos, período adaptativo y recursos separados.
Desde este marco, puedes expandir con características como calcular ETA, agrupar pedidos por ubicación usando GEOSEARCH, o guardar la ruta para auditoría.