Diseño de sistema de subastas en línea: bids concurrentes y cierre
1. Descripción general
Un sistema de subastas parece simple: los usuarios ofrecen precios, el precio más alto gana. La dificultad está en dos momentos. Primero, cientos de bids pueden entrar en la misma sesión en pocos segundos. En segundo lugar, el límite de “sesión terminada” debe ser absolutamente claro. Perder un bid o elegir al ganador equivocado son errores monetarios.
Este artículo recorre todo el diseño: requisitos, modelo de datos, API, cómo manejar bids concurrentes con PostgreSQL y Redis, el mecanismo anti-sniping y cómo cerrar sesiones de forma segura cuando ejecutas múltiples instancias. Después de leerlo, podrás construir el núcleo de un sistema de subastas correcto con Spring Boot 3.
2. Requisitos del problema
2.1. Requisitos funcionales
- El vendedor crea una sesión de subasta con un precio inicial, un incremento de precio mínimo y una hora de finalización.
- El comprador realiza un bid. Un bid es válido cuando la sesión está abierta y el precio no es inferior a
precio actual + incremento de precio. - Si hay un bid válido en los últimos 2 minutos, la sesión se extiende 2 minutos más.
- Cuando se acaba el tiempo, el sistema cierra la sesión, determina al ganador y emite eventos para los pasos de pago y notificación.
- Los espectadores reciben el nuevo precio en tiempo real.
2.2. Escala y restricciones
- Alrededor de 100.000 sesiones abiertas simultáneamente. La mayoría de las sesiones están muy “frías”, solo unos pocos bids por hora.
- Algunas sesiones “calientes” (marcas de lujo, flash sales) reciben hasta 2.000 bids/segundo en el último minuto.
- Consistencia fuerte por sesión: no perder bids válidos, no aceptar bids después de la hora de cierre, solo un ganador.
- Los bids deben ser idempotentes: reintentos del cliente por timeout no deben crear dos bids.
- Latencia de bid p99 menor a 200 ms.
En resumen, necesitamos consistencia por sesión. Entre diferentes sesiones no es necesario. Este es el punto clave para escalar: cada sesión es una unidad de contención independiente.
3. Modelo de datos
Usamos PostgreSQL como fuente de verdad. La tabla auction mantiene el estado actual. La tabla bid es un registro de solo append (append-only).
CREATE TABLE auction (
id BIGSERIAL PRIMARY KEY,
seller_id BIGINT NOT NULL,
title VARCHAR(200) NOT NULL,
current_price NUMERIC(14,2) NOT NULL,
min_increment NUMERIC(14,2) NOT NULL,
leader_id BIGINT,
status VARCHAR(10) NOT NULL DEFAULT 'OPEN', -- OPEN | CLOSED
end_time TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_auction_open_end ON auction (end_time) WHERE status = 'OPEN';
CREATE TABLE bid (
id BIGSERIAL PRIMARY KEY,
auction_id BIGINT NOT NULL REFERENCES auction(id),
bidder_id BIGINT NOT NULL,
amount NUMERIC(14,2) NOT NULL,
request_id UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
UNIQUE (auction_id, request_id)
);
Algunos puntos a tener en cuenta:
current_priceyleader_idestán desnormalizados enauction. Gracias a esto, verificar un bid solo requiere leer una fila en lugar de hacerSELECT MAX(amount)en la tablabid.- El índice parcial
idx_auction_open_endcontiene solo las sesiones abiertas. El trabajo de cierre de sesión escaneará este índice muy rápido. UNIQUE (auction_id, request_id)es el mecanismo de idempotencia a nivel de base de datos.
4. API
POST /auctions crear sesión
GET /auctions/{id} ver estado de la sesión
POST /auctions/{id}/bids colocar bid (encabezado Idempotency-Key: <uuid>)
WS /ws/auctions/{id} recibir nuevo precio en tiempo real
Solicitud de bid:
{ "bidderId": 42, "amount": 1250000 }
El resultado tiene tres formas. 201 Created cuando se acepta el bid. 409 Conflict cuando el precio es demasiado bajo o la sesión ya está cerrada. 200 OK con el resultado anterior cuando la solicitud coincide con Idempotency-Key.
5. Colocación de bids concurrentes
5.1. Error clásico: leer y luego escribir
La forma más natural es la forma incorrecta:
@Transactional
public void placeBidNaive(long auctionId, long bidderId, BigDecimal amount) {
Auction a = auctionRepository.findById(auctionId).orElseThrow();
if (amount.compareTo(a.getCurrentPrice().add(a.getMinIncrement())) < 0) {
throw new BidRejectedException("Precio muy bajo");
}
a.setCurrentPrice(amount);
a.setLeaderId(bidderId);
}
Supongamos que el precio actual es 1.000. Dos threads leen el precio de 1.000. El thread A establece 1.100, el thread B establece 1.050. Ambos pasan la verificación. Si B hace commit después, el precio final es 1.050 y el líder es B. El bid más alto de A se sobrescribe. Este es el error de lost update.
5.2. Actualización condicional atómica
La solución más simple es combinar la verificación y la escritura en una única declaración UPDATE. La condición está en WHERE:
public interface AuctionRepository extends JpaRepository<Auction, Long> {
@Modifying
@Query(value = """
UPDATE auction
SET current_price = :amount,
leader_id = :bidderId,
end_time = CASE
WHEN end_time - clock_timestamp() < INTERVAL '2 minutes'
THEN clock_timestamp() + INTERVAL '2 minutes'
ELSE end_time END
WHERE id = :id
AND status = 'OPEN'
AND end_time > clock_timestamp()
AND :amount >= current_price + min_increment
""", nativeQuery = true)
int tryPlaceBid(@Param("id") long id,
@Param("bidderId") long bidderId,
@Param("amount") BigDecimal amount);
}
El mecanismo funciona de la siguiente manera:
- PostgreSQL bloquea la fila
auctioncuando UPDATE. El segundo thread debe esperar a que el primer thread haga commit. - En el nivel
READ COMMITTED, después de ser desbloqueado, PostgreSQL reevalúaWHEREen la versión más reciente de la fila. El bid de 1.050 encuentra el nuevo precio de 1.100, la condición falla, por lo que la declaración devuelve0. - La lógica anti-sniping (bloque
CASE) siempre está en esta declaración. Gracias a esto, la extensión nunca se desincroniza con el bid que la causa. - El tiempo se toma de
clock_timestamp()de la base de datos, no del reloj de cada instancia de aplicación. La base de datos es la única fuente de tiempo.
Nota: No uses
now()aquí. En PostgreSQL,now()devuelve la hora de inicio de la transacción. Una transacción abierta antes de la hora de cierre pero que se ejecuta lentamente aún puede hacer que un bid pase después de la hora de cierre.
El servicio registra el histórico de bids en la misma transacción:
@Service
@RequiredArgsConstructor
public class BidService {
private final AuctionRepository auctionRepository;
private final BidRepository bidRepository;
@Transactional
public Bid placeBid(long auctionId, long bidderId, BigDecimal amount, UUID requestId) {
var existing = bidRepository.findByAuctionIdAndRequestId(auctionId, requestId);
if (existing.isPresent()) {
return existing.get(); // reintento del cliente: devuelve el resultado anterior
}
int updated = auctionRepository.tryPlaceBid(auctionId, bidderId, amount);
if (updated == 0) {
throw new BidRejectedException("Sesión cerrada o precio insuficiente");
}
return bidRepository.save(new Bid(auctionId, bidderId, amount, requestId));
}
}
Si dos reintentos con el mismo requestId se ejecutan en paralelo, ambos pueden pasar el paso findBy.... Luego, la restricción UNIQUE lanzará DataIntegrityViolationException a la segunda transacción. Esta transacción se revierte por completo, incluyendo la parte UPDATE del precio. El controlador captura esta excepción y devuelve el bid existente.
5.3. Pruebas con bids concurrentes
La prueba siguiente dispara 200 bids en paralelo usando un virtual thread en la misma sesión, luego verifica invariantes. Dependencias necesarias:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
@SpringBootTest
@Testcontainers
class BidServiceConcurrencyTest {
@Container @ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
@Autowired BidService bidService;
@Autowired AuctionRepository auctionRepository;
@Autowired BidRepository bidRepository;
@Test
void concurrentBids_keepHighestAcceptedBidAsLeader() throws Exception {
long id = auctionRepository.save(Auction.open("Reloj antiguo",
new BigDecimal("1000"), new BigDecimal("10"),
Instant.now().plus(Duration.ofHours(1)))).getId();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 1; i <= 200; i++) {
long bidder = i;
var amount = new BigDecimal(1000 + i * 10);
executor.submit(() -> {
try {
bidService.placeBid(id, bidder, amount, UUID.randomUUID());
} catch (BidRejectedException ignored) { }
});
}
}
Auction result = auctionRepository.findById(id).orElseThrow();
BigDecimal maxAccepted = bidRepository.findMaxAmount(id);
assertThat(result.getCurrentPrice()).isEqualByComparingTo("3000");
assertThat(result.getLeaderId()).isEqualTo(200L);
assertThat(maxAccepted).isEqualByComparingTo(result.getCurrentPrice());
}
}
El bid de 3.000 (bidder 200) siempre es válido porque no hay ningún bid más alto. Por lo tanto, sin importar el orden de ejecución, debe ser el líder. Si reemplazas tryPlaceBid con la versión ingenua en la sección 5.1, esta prueba fallará intermitentemente. Esa es la señal de una condición de carrera.
6. Subastas “calientes”: trasladando la contención a Redis
La actualización condicional serializa todos los bids en una sola fila. Con una sesión, PostgreSQL generalmente puede manejar algunos cientos hasta aproximadamente mil UPDATEs por segundo. Una sesión que recibe 2.000 bids/segundo creará una cola de bloqueo larga y empujará p99 por encima del umbral.
Para las sesiones marcadas como “calientes”, trasladamos la parte de decisión a Redis. Redis ejecuta el script Lua single-threaded y atómico. Dependencias necesarias:
<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>
@Service
@RequiredArgsConstructor
public class HotBidService {
private static final RedisScript<Long> PLACE_BID = RedisScript.of("""
local a = redis.call('HMGET', KEYS[1], 'price', 'inc', 'end', 'status')
local now = tonumber(ARGV[3])
if a[4] ~= 'OPEN' or now >= tonumber(a[3]) then return -1 end
if tonumber(ARGV[1]) < tonumber(a[1]) + tonumber(a[2]) then return 0 end
local newEnd = tonumber(a[3])
if newEnd - now < 120000 then newEnd = now + 120000 end
redis.call('HSET', KEYS[1], 'price', ARGV[1], 'leader', ARGV[2], 'end', newEnd)
return 1
""", Long.class);
private final StringRedisTemplate redis;
private final KafkaTemplate<String, BidAccepted> kafka;
public boolean placeBid(long auctionId, long bidderId, long amount, UUID requestId) {
long now = Long.parseLong(redis.execute(
(RedisCallback<Long>) c -> c.serverCommands().time()).toString());
Long r = redis.execute(PLACE_BID, List.of("auction:" + auctionId),
String.valueOf(amount), String.valueOf(bidderId), String.valueOf(now));
if (r == null || r != 1L) return false;
kafka.send("bid-accepted", String.valueOf(auctionId),
new BidAccepted(auctionId, bidderId, amount, requestId, now));
return true;
}
}
Explicación rápida:
- El script lee el precio, el incremento, la hora de finalización y el estado, verifica y escribe, todo en un paso atómico. Valores de retorno:
1es aceptado,0es precio bajo,-1es sesión cerrada. - El tiempo se toma del comando
TIMEde Redis para que todas las instancias compartan un reloj. En la práctica, puedes llamar aredis.call('TIME')directamente en el script. Para hacer esto, necesitas habilitar la replicación de efectos de script, que es el predeterminado desde Redis 5. - Los bids aceptados se enviarán a Kafka con key siendo
auctionId. Todos los bids de una sesión caen en la misma partición y mantienen el orden correcto. El consumidor los escribe en PostgreSQL en lotes.
Nota: Ahora Redis se convierte en la fuente de verdad durante toda la sesión. Necesitas habilitar AOF con
appendfsync everysecoalways, ejecutar una réplica, y manejar el caso dondekafka.sendfalla después de que Redis haya escrito (reintentos, o reconciliación periódica entre Redis y PostgreSQL). No apliques este patrón a todas las sesiones. La mayoría de las sesiones frías funcionan bien con actualización condicional y son mucho más simples.
7. Cierre de subastas
7.1. Cierre con declaración atómica
No necesitamos un planificador preciso al milisegundo para cada sesión. El límite real ya está en la condición end_time > clock_timestamp() de la declaración UPDATE de bid. Después de la hora de cierre, ningún bid puede pasar, incluso si el estado sigue siendo OPEN. El trabajo de cierre solo necesita “conciliar” en cuestión de segundos:
@Component
@RequiredArgsConstructor
public class AuctionCloser {
private final JdbcTemplate jdbc;
@Scheduled(fixedDelay = 1000)
@Transactional
public void closeExpired() {
var closed = jdbc.query("""
UPDATE auction SET status = 'CLOSED'
WHERE id IN (SELECT id FROM auction
WHERE status = 'OPEN' AND end_time <= clock_timestamp()
ORDER BY end_time LIMIT 500
FOR UPDATE SKIP LOCKED)
RETURNING id, leader_id, current_price
""", (rs, i) -> new AuctionClosed(rs.getLong("id"),
(Long) rs.getObject("leader_id"), rs.getBigDecimal("current_price")));
for (var e : closed) {
jdbc.update("INSERT INTO outbox(topic, aggregate_id, payload) VALUES (?, ?, ?::jsonb)",
"auction-closed", e.auctionId(), e.toJson());
}
}
}
Esta declaración es segura cuando se ejecuta en múltiples instancias porque:
FOR UPDATE SKIP LOCKEDpermite que las instancias compartan las sesiones. Dos instancias nunca cierran la misma sesión.- Si un bid está manteniendo el bloqueo de fila,
SKIP LOCKEDsalta esa fila. La próxima ejecución lo verificará nuevamente, cuandoend_timepudo haber sido extendido. - La transición de estado
OPEN → CLOSEDocurre solo una vez. Ejecutar nuevamente el trabajo no causa efectos secundarios, es decir, el trabajo es idempotente.
7.2. Emitir eventos mediante Outbox
El evento auction-closed activa el pago y envía un correo electrónico al ganador. Si llamas a kafka.send directamente en la transacción, puedes encontrar dos malas situaciones. Una es que el evento se envió pero la transacción se revirtió. Dos es que se hizo commit pero el envío falló. La tabla outbox se escribe en la misma transacción que el cierre de la sesión. Luego, un relay (polling o Debezium CDC) empuja las filas de outbox a Kafka. El lado del consumidor debe ser idempotente por auctionId, ya que el relay garantiza at-least-once.
8. Carga de ráfaga y distribución en tiempo real
El último minuto de una subasta caliente no solo tiene muchos bids, sino que tiene decenas de miles de espectadores. Algunas técnicas complementarias:
- Límite de velocidad por usuario, por ejemplo máximo 5 bids/segundo con token bucket en Redis. Esta técnica bloquea bots de spam antes de que toquen la capa de contención.
- Rechazo temprano: cache el precio actual en memoria (algunos cientos de milisegundos) y rechaza inmediatamente los bids claramente demasiado bajos antes de llamar a DB o Redis. La caché solo se usa para rechazar, nunca para aceptar.
- Fan-out mediante pub/sub: cuando se acepta un bid, publica en el canal Redis
auction:{id}. Cada nodo de WebSocket se suscribe y empuja a sus clientes. Si los bids son demasiado densos, agrupa actualizaciones cada 100-200 ms. Los espectadores solo necesitan ver el precio más reciente, no cada bid. - Separar lectura y escritura:
GET /auctions/{id}puede leer de una réplica o caché. Solo la ruta de nuevos bids necesita acceso al primario.
9. Errores comunes y cuándo no usar
- Usar el reloj de la aplicación para comparar con
end_time. Las instancias se desincronizarían por unos segundos es normal. Siempre deja que la base de datos o Redis decida la hora. - Usar
@Version(bloqueo optimista) para subastas calientes. Sigue siendo correcto, pero 2.000 bids/segundo generará innumerablesOptimisticLockExceptiony ciclos de reintento. La actualización condicional no necesita reintento porqueWHEREse reevalúa automáticamente. - Usar
SELECT ... FOR UPDATEy luego verificar en Java. Esta forma es correcta pero mantiene el bloqueo más tiempo, ya que debe esperar una ida y vuelta a través de la aplicación. Solo usa esto cuando la lógica de verificación sea demasiado compleja para escribir en SQL. - Bloqueo distribuido (Redisson) alrededor de toda la función de bid. Este enfoque añade un punto de fallo y el riesgo de que el bloqueo expire a medio camino, mientras que la base de datos ya tiene bloqueo de filas.
- Ignorar la idempotencia. Los reintentos del cliente móvil cuando la red falla es algo cotidiano. Sin
request_id, la misma persona podría “pujar contra sí misma”.
10. Conclusión
La clave de un sistema de subastas es considerar cada sesión como una unidad de consistencia independiente. Este artículo ha abordado cada punto difícil de la siguiente manera:
- Bids concurrentes: combinar verificación y escritura en una única declaración
UPDATE ... WHEREatómica, con lógica anti-sniping en la misma declaración. - Hora de finalización: obtener la hora de la base de datos (
clock_timestamp()), no de la aplicación. - Subastas calientes: trasladar la contención a Redis Lua y persistencia a través de Kafka, a costa de una operación más compleja.
- Cierre de sesión: usar
FOR UPDATE SKIP LOCKED, transición de estado idempotente y emisión de eventos a través de Outbox.
Deberías comenzar con la versión PostgreSQL pura. Es suficiente para la mayoría de los casos y fácil de verificar con pruebas concurrentes como en la sección 5.3. Solo cuando las métricas reales muestren que una fila auction es un cuello de botella, traslada esas sesiones a Redis.