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_price y leader_id están desnormalizados en auction. Gracias a esto, verificar un bid solo requiere leer una fila en lugar de hacer SELECT MAX(amount) en la tabla bid.
  • El índice parcial idx_auction_open_end contiene 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 auction cuando 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úa WHERE en 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 devuelve 0.
  • 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: 1 es aceptado, 0 es precio bajo, -1 es sesión cerrada.
  • El tiempo se toma del comando TIME de Redis para que todas las instancias compartan un reloj. En la práctica, puedes llamar a redis.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 everysec o always, ejecutar una réplica, y manejar el caso donde kafka.send falla 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 LOCKED permite que las instancias compartan las sesiones. Dos instancias nunca cierran la misma sesión.
  • Si un bid está manteniendo el bloqueo de fila, SKIP LOCKED salta esa fila. La próxima ejecución lo verificará nuevamente, cuando end_time pudo haber sido extendido.
  • La transición de estado OPEN → CLOSED ocurre 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á innumerables OptimisticLockException y ciclos de reintento. La actualización condicional no necesita reintento porque WHERE se reevalúa automáticamente.
  • Usar SELECT ... FOR UPDATE y 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 ... WHERE ató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.

system-designconcurrencyspring-bootpostgresqlredis