Pular para conteúdo

b. Reservas e Locks

Uma reserva (hold) diz "estes assentos são meus por 10 minutos, enquanto eu pago". O invariante: um assento pertence a no máximo uma reserva viva. Tudo neste passo trata de manter esse invariante verdadeiro quando centenas de fãs clicam nos mesmos assentos no mesmo segundo.

1. Por que "confere, depois atualiza" não basta

O código óbvio lê os assentos, confere se estão AVAILABLE e então os atualiza. Sob concorrência, duas transações podem se intercalar:

sequenceDiagram
    autonumber
    participant A as Fã A (tx 1)
    participant DB as PostgreSQL
    participant B as Fã B (tx 2)
    A->>DB: SELECT status FROM seats WHERE id IN (7,8)
    DB-->>A: AVAILABLE, AVAILABLE
    B->>DB: SELECT status FROM seats WHERE id IN (7,8)
    DB-->>B: AVAILABLE, AVAILABLE
    A->>DB: UPDATE seats SET status='HELD', hold_id=A ...
    B->>DB: UPDATE seats SET status='HELD', hold_id=B ... (espera os locks de linha de A)
    A->>DB: COMMIT
    DB-->>B: o update prossegue — sobrescreve a reserva de A
    B->>DB: COMMIT
    Note over A,B: os dois fãs receberam 201 para os assentos 7 e 8: VENDA DUPLICADA

O isolamento padrão do PostgreSQL, READ COMMITTED, não impede esse lost update: cada comando vê dados confirmados, mas nada amarra a decisão de B (tomada no SELECT) aos dados no momento em que B escreve. Três correções:

Estratégia Como Em conflito Boa quando
Pessimista SELECT … FOR UPDATE trava as linhas até o commit; a segunda transação espera e então vê HELD 409 depois de esperar a contenção é alta (a venda relâmpago: todo mundo quer os mesmos 100 assentos)
Otimista lê sem locks; UPDATE … WHERE version = :v; 0 linhas alteradas → alguém foi mais rápido 409 na hora, a transação faz rollback a contenção é baixa; sem espera, sem filas de locks
Update condicional UPDATE … WHERE id IN (…) AND status = 'AVAILABLE' e confere a contagem de linhas 409 na hora um único comando consegue expressar a verificação (uma variante da otimista)
Isolamento serializável SET TRANSACTION ISOLATION LEVEL SERIALIZABLE o banco aborta uma transação (40001), a aplicação tenta de novo invariantes complexos sobre muitas linhas

2. O serviço inventory

Crie services/inventory (porta 8083, schema inventory, mesmo padrão de Dockerfile com EXPOSE 8083). Ele usa o JdbcClient com SQL puro em vez de JPA: todo lock e todo WHERE ficam visíveis no código, que é o propósito deste lab.

services/inventory/Dockerfile e .dockerignore (iguais aos do catalog, só muda o EXPOSE 8083)
services/inventory/Dockerfile
# syntax=docker/dockerfile:1

# ---- build stage: compile with Maven inside a JDK image -------------------
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /src
COPY pom.xml .
COPY src ./src
# the cache mount keeps ~/.m2 between builds: dependencies are downloaded once
RUN --mount=type=cache,target=/root/.m2 mvn -B -q package -DskipTests

# ---- runtime stage: only a JRE and the jar --------------------------------
FROM eclipse-temurin:21-jre
WORKDIR /app
RUN useradd --system --uid 10001 app
COPY --from=build /src/target/app.jar app.jar
USER app
EXPOSE 8083
# use up to 75% of the container's memory limit for the heap
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "app.jar"]
services/inventory/.dockerignore
target/
.idea/
.vscode/
*.iml
services/inventory/pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.1.1</version>
        <relativePath/>
    </parent>

    <groupId>boxoffice</groupId>
    <artifactId>inventory</artifactId>
    <version>1.0.0</version>
    <name>inventory</name>
    <description>BoxOffice inventory: seats and holds, strongly consistent</description>

    <properties>
        <java.version>21</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webmvc</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <!-- plain SQL with JdbcClient: every lock and every WHERE clause is visible -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
        <!-- schema migrations (Boot 4: the starter brings the auto-configuration) -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-flyway</artifactId>
        </dependency>
        <dependency>
            <groupId>org.flywaydb</groupId>
            <artifactId>flyway-database-postgresql</artifactId>
        </dependency>
        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
            <scope>runtime</scope>
        </dependency>
    </dependencies>

    <build>
        <finalName>app</finalName>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>
services/inventory/src/main/resources/application.yaml
server:
  port: 8083

spring:
  application:
    name: inventory
  # migrations always go straight to the primary database (not through PgBouncer)
  flyway:
    url: jdbc:postgresql://${DB_MIGRATION_HOST:localhost}:5432/${DB_NAME:boxoffice}
    user: ${DB_USER:boxoffice}
    password: ${DB_PASSWORD:boxoffice}
    schemas: inventory
  mvc:
    problemdetails:
      enabled: true

inventory:
  locking: ${INVENTORY_LOCKING:pessimistic}     # naive | pessimistic | optimistic
  hold-ttl: ${INVENTORY_HOLD_TTL:PT10M}         # a hold expires after 10 minutes
  max-seats-per-hold: 6
  datasource:
    primary:                                    # writes and read-your-writes reads
      jdbc-url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:boxoffice}
      username: ${DB_USER:boxoffice}
      password: ${DB_PASSWORD:boxoffice}
      maximum-pool-size: ${DB_POOL_SIZE:10}
      pool-name: primary
    replica:                                    # @Transactional(readOnly = true) reads
      jdbc-url: jdbc:postgresql://${DB_REPLICA_HOST:localhost}:${DB_REPLICA_PORT:5432}/${DB_NAME:boxoffice}
      username: ${DB_USER:boxoffice}
      password: ${DB_PASSWORD:boxoffice}
      maximum-pool-size: ${DB_POOL_SIZE:10}
      pool-name: replica
      read-only: true

management:
  endpoints:
    web:
      exposure:
        include: health, info, metrics
  endpoint:
    health:
      probes:
        enabled: true
services/inventory/src/main/resources/db/migration/V1__create_inventory.sql
-- Seats of every event, and the holds that reserve them for a few minutes.
-- event_id refers to catalog.events, but there is NO foreign key across services.
CREATE TABLE inventory.seats (
    id           BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    event_id     BIGINT        NOT NULL,
    section      VARCHAR(10)   NOT NULL,
    seat_row     INTEGER       NOT NULL,
    seat_number  INTEGER       NOT NULL,
    price        NUMERIC(10,2) NOT NULL,
    status       VARCHAR(10)   NOT NULL DEFAULT 'AVAILABLE'
                 CHECK (status IN ('AVAILABLE', 'HELD', 'SOLD')),
    hold_id      UUID,
    version      INTEGER       NOT NULL DEFAULT 0,          -- optimistic locking
    UNIQUE (event_id, section, seat_row, seat_number)       -- also the index for "seats of an event"
);

CREATE TABLE inventory.holds (
    id          UUID         PRIMARY KEY,
    event_id    BIGINT       NOT NULL,
    user_id     VARCHAR(64)  NOT NULL,
    status      VARCHAR(10)  NOT NULL CHECK (status IN ('ACTIVE', 'CONFIRMED', 'RELEASED', 'EXPIRED')),
    expires_at  TIMESTAMPTZ  NOT NULL,
    created_at  TIMESTAMPTZ  NOT NULL DEFAULT now()
);

-- which seats each hold asked for: lets us AUDIT double bookings afterwards
CREATE TABLE inventory.hold_seats (
    hold_id  UUID   NOT NULL REFERENCES inventory.holds (id),
    seat_id  BIGINT NOT NULL REFERENCES inventory.seats (id),
    PRIMARY KEY (hold_id, seat_id)
);
services/inventory/src/main/resources/db/migration/V2__seed_seats.sql
-- Event 1 (the flash sale): 30 sections x 40 rows x 50 seats = 60,000 seats.
INSERT INTO inventory.seats (event_id, section, seat_row, seat_number, price)
SELECT 1, 'S' || lpad(s::text, 2, '0'), r, n, 250 + (s % 5) * 100
FROM generate_series(1, 30) AS s, generate_series(1, 40) AS r, generate_series(1, 50) AS n
ORDER BY s, r, n;

-- Events 2..100: 5 sections x 10 rows x 10 seats = 500 seats each.
INSERT INTO inventory.seats (event_id, section, seat_row, seat_number, price)
SELECT e, 'S' || lpad(s::text, 2, '0'), r, n, 80 + s * 20
FROM generate_series(2, 100) AS e, generate_series(1, 5) AS s,
     generate_series(1, 10) AS r, generate_series(1, 10) AS n
ORDER BY e, s, r, n;

ANALYZE inventory.seats;

hold_seats registra quais assentos cada reserva pediu. A coluna seats.hold_id pode ser sobrescrita por uma estratégia com bug, mas hold_seats guarda a evidência: a consulta de auditoria conta os assentos que aparecem em mais de uma reserva viva.

Os records e a classe da aplicação:

services/inventory/src/main/java/boxoffice/inventory/InventoryApplication.java
package boxoffice.inventory;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication
@EnableScheduling          // the job that expires old holds
public class InventoryApplication {

    public static void main(String[] args) {
        SpringApplication.run(InventoryApplication.class, args);
    }
}
services/inventory/src/main/java/boxoffice/inventory/HoldIn.java
package boxoffice.inventory;

import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import java.util.List;

public record HoldIn(@NotNull Long eventId, @NotEmpty List<@NotNull Long> seatIds) {
}
services/inventory/src/main/java/boxoffice/inventory/HoldOut.java
package boxoffice.inventory;

import java.time.OffsetDateTime;
import java.util.List;
import java.util.UUID;

public record HoldOut(UUID id, Long eventId, String status, OffsetDateTime expiresAt, List<Long> seatIds) {
}
services/inventory/src/main/java/boxoffice/inventory/SeatOut.java
package boxoffice.inventory;

import java.math.BigDecimal;

public record SeatOut(Long id, String section, int row, int number, BigDecimal price, String status) {
}
services/inventory/src/main/java/boxoffice/inventory/SectionAvailability.java
package boxoffice.inventory;

public record SectionAvailability(String section, long available, long held, long sold) {
}

3. Três estratégias em um serviço

services/inventory/src/main/java/boxoffice/inventory/HoldService.java
package boxoffice.inventory;

import java.time.Duration;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.UUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;

/**
 * Holds: "these seats are mine for 10 minutes". The one rule that must never break:
 * a seat belongs to at most ONE live hold. Three ways to (try to) enforce it are
 * selectable with INVENTORY_LOCKING, so you can measure each one.
 */
@Service
public class HoldService {

    private static final Logger log = LoggerFactory.getLogger(HoldService.class);

    private record Seat(long id, String status, int version) { }

    private final JdbcClient jdbc;
    private final String locking;
    private final Duration holdTtl;
    private final int maxSeats;

    public HoldService(JdbcClient jdbc,
                       @Value("${inventory.locking}") String locking,
                       @Value("${inventory.hold-ttl}") Duration holdTtl,
                       @Value("${inventory.max-seats-per-hold}") int maxSeats) {
        this.jdbc = jdbc;
        this.locking = locking;
        this.holdTtl = holdTtl;
        this.maxSeats = maxSeats;
        log.info("seat locking strategy: {}", locking);
    }

    @Transactional        // READ COMMITTED (PostgreSQL's default), on the primary
    public HoldOut hold(String userId, HoldIn in) {
        var seatIds = in.seatIds().stream().distinct().sorted().toList();
        if (seatIds.size() > maxSeats) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "at most " + maxSeats + " seats per hold");
        }
        var holdId = UUID.randomUUID();
        var expiresAt = OffsetDateTime.now(ZoneOffset.UTC).plus(holdTtl);

        switch (locking) {
            case "naive" -> holdNaive(in.eventId(), seatIds, holdId);
            case "optimistic" -> holdOptimistic(in.eventId(), seatIds, holdId);
            default -> holdPessimistic(in.eventId(), seatIds, holdId);
        }

        jdbc.sql("""
                INSERT INTO inventory.holds (id, event_id, user_id, status, expires_at)
                VALUES (:id, :eventId, :userId, 'ACTIVE', :expiresAt)""")
                .param("id", holdId).param("eventId", in.eventId())
                .param("userId", userId).param("expiresAt", expiresAt)
                .update();
        for (var seatId : seatIds) {
            jdbc.sql("INSERT INTO inventory.hold_seats (hold_id, seat_id) VALUES (:holdId, :seatId)")
                    .param("holdId", holdId).param("seatId", seatId)
                    .update();
        }
        return new HoldOut(holdId, in.eventId(), "ACTIVE", expiresAt, seatIds);
    }

    // ---- strategy 1: check-then-act, no lock (WRONG on purpose) ---------------
    private void holdNaive(long eventId, List<Long> seatIds, UUID holdId) {
        var seats = read(eventId, seatIds, "");
        requireAvailable(seats, seatIds);
        // race window: another transaction may have read the same AVAILABLE seats and
        // be about to run this same UPDATE — both will "win"
        jdbc.sql("UPDATE inventory.seats SET status = 'HELD', hold_id = :holdId WHERE id IN (:ids)")
                .param("holdId", holdId).param("ids", seatIds)
                .update();
    }

    // ---- strategy 2: pessimistic, SELECT ... FOR UPDATE -----------------------
    private void holdPessimistic(long eventId, List<Long> seatIds, UUID holdId) {
        // row locks until commit; a second transaction on the same seats WAITS here.
        // Locking in id order (ORDER BY id) prevents deadlocks between overlapping holds.
        var seats = read(eventId, seatIds, "ORDER BY id FOR UPDATE");
        requireAvailable(seats, seatIds);
        jdbc.sql("UPDATE inventory.seats SET status = 'HELD', hold_id = :holdId, version = version + 1 WHERE id IN (:ids)")
                .param("holdId", holdId).param("ids", seatIds)
                .update();
    }

    // ---- strategy 3: optimistic, compare-and-set on a version column ---------------
    private void holdOptimistic(long eventId, List<Long> seatIds, UUID holdId) {
        var seats = read(eventId, seatIds, "");                      // no lock
        requireAvailable(seats, seatIds);
        for (var seat : seats) {
            int changed = jdbc.sql("""
                    UPDATE inventory.seats
                    SET status = 'HELD', hold_id = :holdId, version = version + 1
                    WHERE id = :id AND version = :version AND status = 'AVAILABLE'""")
                    .param("holdId", holdId).param("id", seat.id()).param("version", seat.version())
                    .update();
            if (changed == 0) {
                // someone changed this seat after we read it: give up (the exception rolls back)
                throw new ResponseStatusException(HttpStatus.CONFLICT, "seat " + seat.id() + " was just taken");
            }
        }
    }

    private List<Seat> read(long eventId, List<Long> seatIds, String suffix) {
        return jdbc.sql("SELECT id, status, version FROM inventory.seats "
                        + "WHERE event_id = :eventId AND id IN (:ids) " + suffix)
                .param("eventId", eventId).param("ids", seatIds)
                .query((rs, n) -> new Seat(rs.getLong("id"), rs.getString("status"), rs.getInt("version")))
                .list();
    }

    private static void requireAvailable(List<Seat> seats, List<Long> seatIds) {
        if (seats.size() != seatIds.size()) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "some seats do not belong to this event");
        }
        if (seats.stream().anyMatch(s -> !"AVAILABLE".equals(s.status()))) {
            throw new ResponseStatusException(HttpStatus.CONFLICT, "some seats are not available");
        }
    }

    // ---- reading, confirming and releasing a hold (always on the primary) -----

    @Transactional        // NOT readOnly: read-your-writes, the user just created it
    public HoldOut find(String userId, UUID holdId) {
        var hold = jdbc.sql("""
                SELECT id, event_id, status, expires_at FROM inventory.holds
                WHERE id = :id AND user_id = :userId""")
                .param("id", holdId).param("userId", userId)
                .query((rs, n) -> new HoldOut(rs.getObject("id", UUID.class), rs.getLong("event_id"),
                        rs.getString("status"), rs.getObject("expires_at", OffsetDateTime.class), List.of()))
                .optional()
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "hold not found"));
        var seatIds = jdbc.sql("SELECT seat_id FROM inventory.hold_seats WHERE hold_id = :id ORDER BY seat_id")
                .param("id", holdId).query(Long.class).list();
        return new HoldOut(hold.id(), hold.eventId(), hold.status(), hold.expiresAt(), seatIds);
    }

    /** Payment succeeded (lab 7 calls this): seats become SOLD. */
    @Transactional
    public HoldOut confirm(String userId, UUID holdId) {
        int changed = jdbc.sql("""
                UPDATE inventory.holds SET status = 'CONFIRMED'
                WHERE id = :id AND user_id = :userId AND status = 'ACTIVE' AND expires_at > now()""")
                .param("id", holdId).param("userId", userId)
                .update();
        if (changed == 0) {
            throw new ResponseStatusException(HttpStatus.CONFLICT, "hold is not active (expired, released or unknown)");
        }
        jdbc.sql("UPDATE inventory.seats SET status = 'SOLD', version = version + 1 WHERE hold_id = :id")
                .param("id", holdId).update();
        return find(userId, holdId);
    }

    @Transactional
    public void release(String userId, UUID holdId) {
        int changed = jdbc.sql("""
                UPDATE inventory.holds SET status = 'RELEASED'
                WHERE id = :id AND user_id = :userId AND status = 'ACTIVE'""")
                .param("id", holdId).param("userId", userId)
                .update();
        if (changed == 0) {
            throw new ResponseStatusException(HttpStatus.NOT_FOUND, "no active hold " + holdId);
        }
        freeSeatsOf(holdId);
    }

    private void freeSeatsOf(UUID holdId) {
        jdbc.sql("""
                UPDATE inventory.seats SET status = 'AVAILABLE', hold_id = NULL, version = version + 1
                WHERE hold_id = :id AND status = 'HELD'""")
                .param("id", holdId).update();
    }

    /**
     * Every 5 s, expire old holds and free their seats. FOR UPDATE SKIP LOCKED lets several
     * inventory replicas run this job at the same time without processing the same hold twice.
     */
    @Scheduled(fixedDelay = 5000)
    @Transactional
    public void expireHolds() {
        int freed = jdbc.sql("""
                WITH expired AS (
                    UPDATE inventory.holds SET status = 'EXPIRED'
                    WHERE id IN (SELECT id FROM inventory.holds
                                 WHERE status = 'ACTIVE' AND expires_at < now()
                                 ORDER BY expires_at
                                 LIMIT 500
                                 FOR UPDATE SKIP LOCKED)
                    RETURNING id)
                UPDATE inventory.seats
                SET status = 'AVAILABLE', hold_id = NULL, version = version + 1
                WHERE status = 'HELD' AND hold_id IN (SELECT id FROM expired)""")
                .update();
        if (freed > 0) {
            log.info("expired holds freed {} seats", freed);
        }
    }
}

Leia estratégia por estratégia:

  • naive: read(…, "") e depois um UPDATE incondicional. O comentário marca a janela da corrida.
  • pessimistic: read(…, "ORDER BY id FOR UPDATE"). A segunda transação bloqueia nos locks de linha; quando a primeira faz commit, o PostgreSQL relê as linhas travadas, a segunda vê HELD e o requireAvailable lança 409. Travar numa ordem fixa (ORDER BY id) importa: o fã A reservando os assentos 7→8 e o fã B reservando 8→7 em ordem oposta entrariam em deadlock.
  • optimistic: sem locks; o UPDATE só tem sucesso se o version ainda for o que lemos. Se outra transação alterou o assento nesse meio-tempo, 0 linhas mudam, lançamos uma exceção e o @Transactional desfaz tudo (inclusive os assentos já atualizados no laço).
  • Toda reserva também confere se todos os assentos pertencem ao evento (400 caso contrário) e limita uma reserva a 6 assentos.
  • find, confirm e release filtram por user_id: um fã não consegue ver nem cancelar a reserva de outra pessoa (isso seria um IDOR, insecure direct object reference — OWASP API #1).
  • expireHolds roda a cada 5 s em cada réplica; FOR UPDATE SKIP LOCKED deixa várias réplicas dividirem o trabalho sem bloquear umas às outras nem processar uma reserva duas vezes — o mesmo truque usado por filas de jobs construídas sobre o PostgreSQL.

As consultas somente leitura ficam numa classe separada, porque vão para a réplica (passo 2):

services/inventory/src/main/java/boxoffice/inventory/SeatQueries.java
package boxoffice.inventory;

import java.util.List;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

/** Read-only queries: they run on the REPLICA (readOnly = true), so they may be slightly stale. */
@Service
public class SeatQueries {

    private final JdbcClient jdbc;

    public SeatQueries(JdbcClient jdbc) {
        this.jdbc = jdbc;
    }

    @Transactional(readOnly = true)
    public List<SectionAvailability> availability(long eventId) {
        return jdbc.sql("""
                SELECT section,
                       count(*) FILTER (WHERE status = 'AVAILABLE') AS available,
                       count(*) FILTER (WHERE status = 'HELD')      AS held,
                       count(*) FILTER (WHERE status = 'SOLD')      AS sold
                FROM inventory.seats
                WHERE event_id = :eventId
                GROUP BY section
                ORDER BY section""")
                .param("eventId", eventId)
                .query((rs, n) -> new SectionAvailability(rs.getString("section"),
                        rs.getLong("available"), rs.getLong("held"), rs.getLong("sold")))
                .list();
    }

    @Transactional(readOnly = true)
    public List<SeatOut> seats(long eventId, String section) {
        return jdbc.sql("""
                SELECT id, section, seat_row, seat_number, price, status
                FROM inventory.seats
                WHERE event_id = :eventId AND section = :section
                ORDER BY seat_row, seat_number""")
                .param("eventId", eventId)
                .param("section", section)
                .query((rs, n) -> new SeatOut(rs.getLong("id"), rs.getString("section"),
                        rs.getInt("seat_row"), rs.getInt("seat_number"),
                        rs.getBigDecimal("price"), rs.getString("status")))
                .list();
    }

    /** Seats that belong to more than one live hold: must always be 0. Reads the PRIMARY. */
    @Transactional
    public long doubleBookedSeats() {
        return jdbc.sql("""
                SELECT count(*) FROM (
                    SELECT hs.seat_id
                    FROM inventory.hold_seats hs
                    JOIN inventory.holds h ON h.id = hs.hold_id
                    WHERE h.status IN ('ACTIVE', 'CONFIRMED')
                    GROUP BY hs.seat_id
                    HAVING count(*) > 1) AS doubles""")
                .query(Long.class)
                .single();
    }
}
services/inventory/src/main/java/boxoffice/inventory/InventoryController.java
package boxoffice.inventory;

import jakarta.validation.Valid;
import java.net.URI;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/inventory")
public class InventoryController {

    private final SeatQueries queries;
    private final HoldService holds;

    public InventoryController(SeatQueries queries, HoldService holds) {
        this.queries = queries;
        this.holds = holds;
    }

    // ---- open (GET, through the gateway without a token), served by the replica ----

    @GetMapping("/events/{eventId}/availability")
    public List<SectionAvailability> availability(@PathVariable long eventId) {
        return queries.availability(eventId);
    }

    @GetMapping("/events/{eventId}/seats")
    public List<SeatOut> seats(@PathVariable long eventId, @RequestParam String section) {
        return queries.seats(eventId, section);
    }

    // ---- protected: the gateway sets X-User-Id from the JWT ----

    @PostMapping("/holds")
    public ResponseEntity<HoldOut> hold(@RequestHeader("X-User-Id") String userId, @Valid @RequestBody HoldIn in) {
        var out = holds.hold(userId, in);
        return ResponseEntity.created(URI.create("/inventory/holds/" + out.id())).body(out);
    }

    @GetMapping("/holds/{id}")
    public HoldOut find(@RequestHeader("X-User-Id") String userId, @PathVariable UUID id) {
        return holds.find(userId, id);
    }

    @PostMapping("/holds/{id}/confirm")
    public HoldOut confirm(@RequestHeader("X-User-Id") String userId, @PathVariable UUID id) {
        return holds.confirm(userId, id);
    }

    @DeleteMapping("/holds/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void release(@RequestHeader("X-User-Id") String userId, @PathVariable UUID id) {
        holds.release(userId, id);
    }

    /** The invariant check used after a flash-sale test: must be {"doubleBookedSeats": 0}. */
    @GetMapping("/audit/double-bookings")
    public Map<String, Long> audit() {
        return Map.of("doubleBookedSeats", queries.doubleBookedSeats());
    }
}

4. Ligue-o ao gateway

Adicione a rota e abra os endpoints de leitura (as reservas continuam protegidas pela regra padrão):

services/gateway/src/main/resources/application.yaml
server:
  port: 8080

spring:
  application:
    name: gateway
  cloud:
    gateway:
      server:
        webflux:
          httpclient:
            connect-timeout: 1000          # ms to open a connection to a service
            response-timeout: 5s           # never wait forever for a service
          default-filters:
            # retry idempotent GETs once on another instance when a connection fails
            - name: Retry
              args:
                retries: 1
                methods: GET
                series: SERVER_ERROR
                exceptions: java.io.IOException, java.util.concurrent.TimeoutException
          routes:
            - id: auth
              uri: ${AUTH_URL:http://auth:8081}
              predicates:
                - Path=/auth/**
            - id: catalog
              uri: lb://catalog              # "lb": pick one instance of "catalog" per request
              predicates:
                - Path=/catalog/**
            - id: inventory
              uri: ${INVENTORY_URL:http://inventory:8083}
              predicates:
                - Path=/inventory/**
    # the instances behind lb://catalog (a static list; Kubernetes replaces it in lab 10)
    discovery:
      client:
        simple:
          instances:
            catalog:
              - uri: http://catalog-1:8082
              - uri: http://catalog-2:8082
              - uri: http://catalog-3:8082
    loadbalancer:
      configurations: health-check         # skip instances that are not ready
      health-check:
        interval: 5s
        path:
          default: /actuator/health/readiness   # this instance, not db/redis that every replica shares
  security:
    oauth2:
      resourceserver:
        jwt:
          jwk-set-uri: ${AUTH_JWKS_URI:http://auth:8081/auth/.well-known/jwks.json}

jwt:
  issuer: ${JWT_ISSUER:boxoffice-auth}
  audience: boxoffice

management:
  endpoints:
    web:
      exposure:
        include: health, info, metrics, gateway
  endpoint:
    health:
      probes:
        enabled: true
    gateway:
      access: read-only      # GET /actuator/gateway/routes lists the routes
services/gateway/src/main/java/boxoffice/gateway/SecurityConfig.java
package boxoffice.gateway;

import java.util.List;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.jwt.JwtClaimValidator;
import org.springframework.security.oauth2.jwt.JwtValidators;
import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder;
import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder;
import org.springframework.security.web.server.SecurityWebFilterChain;

@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {

    /** Which routes are open, which need a valid token, which need the admin scope. */
    @Bean
    SecurityWebFilterChain security(ServerHttpSecurity http) {
        return http
                .csrf(ServerHttpSecurity.CsrfSpec::disable)        // stateless API, no cookies
                .authorizeExchange(ex -> ex
                        // open: getting a token, and the public keys to check one
                        .pathMatchers("/auth/register", "/auth/login", "/auth/.well-known/**").permitAll()
                        // open: browsing the catalog (HEAD is a GET without the body)
                        .pathMatchers(HttpMethod.GET, "/catalog/**").permitAll()
                        .pathMatchers(HttpMethod.HEAD, "/catalog/**").permitAll()
                        // admin only: changing the catalog (scope claim "user admin")
                        .pathMatchers("/catalog/**").hasAuthority("SCOPE_admin")
                        // open: seat maps and availability (lab 6); holds need a token
                        .pathMatchers(HttpMethod.GET, "/inventory/events/**").permitAll()
                        .pathMatchers(HttpMethod.HEAD, "/inventory/events/**").permitAll()
                        // health, metrics, routes: the edge never forwards /actuator from outside
                        .pathMatchers("/actuator/**").permitAll()
                        // everything else (and every future route): any valid token
                        .anyExchange().authenticated())
                .oauth2ResourceServer(oauth -> oauth.jwt(Customizer.withDefaults()))
                .build();
    }

    /** Signature from the JWKS of auth, plus expiry, issuer and audience checks. */
    @Bean
    ReactiveJwtDecoder jwtDecoder(@Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}") String jwksUri,
                                  @Value("${jwt.issuer}") String issuer,
                                  @Value("${jwt.audience}") String audience) {
        var decoder = NimbusReactiveJwtDecoder.withJwkSetUri(jwksUri).build();
        decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
                JwtValidators.createDefaultWithIssuer(issuer),     // exp, nbf, iss
                new JwtClaimValidator<List<String>>("aud", aud -> aud != null && aud.contains(audience))));
        return decoder;
    }
}

O arquivo do Compose com o inventory, a réplica e o PgBouncer está no passo 2; suba tudo depois desse passo, ou agora com o arquivo completo.

5. Confira à mão

Com a stack completa do passo 2 rodando, faça login e percorra uma reserva. Todos os comandos na raiz do repositório:

curl -s -X POST localhost/auth/register -H 'Content-Type: application/json' \
  -d '{"name":"Ana","email":"ana@example.com","password":"secret-123"}'; echo
export TOKEN=$(curl -s -X POST localhost/auth/login -H 'Content-Type: application/json' \
  -d '{"email":"ana@example.com","password":"secret-123"}' | jq -r .accessToken)
export ADMIN=$(curl -s -X POST localhost/auth/login -H 'Content-Type: application/json' \
  -d '{"email":"admin@boxoffice.dev","password":"boxoffice-admin"}' | jq -r .accessToken)

curl -s localhost/inventory/events/1/availability | jq -c '.[0]'
HOLD=$(curl -s -X POST localhost/inventory/holds -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d '{"eventId":1,"seatIds":[1,2]}')
echo "$HOLD"
export HOLD_ID=$(echo "$HOLD" | jq -r .id)

# the same seat for somebody else, no token, somebody else's hold
curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost/inventory/holds -H "Authorization: Bearer $ADMIN" \
  -H 'Content-Type: application/json' -d '{"eventId":1,"seatIds":[2,3]}'
curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost/inventory/holds \
  -H 'Content-Type: application/json' -d '{"eventId":1,"seatIds":[5]}'
curl -s -o /dev/null -w "%{http_code}\n" localhost/inventory/holds/$HOLD_ID -H "Authorization: Bearer $ADMIN"

# pay (lab 7 will do this through order) and look again
curl -s -X POST localhost/inventory/holds/$HOLD_ID/confirm -H "Authorization: Bearer $TOKEN" | jq -c '{status, seatIds}'
curl -s localhost/inventory/events/1/availability | jq -c '.[0]'
curl -s localhost/inventory/audit/double-bookings -H "Authorization: Bearer $TOKEN"

Você deve ver (de uma execução deste lab; ids e horários diferem):

{"id":"0b9d8a6e-…","name":"Ana","email":"ana@example.com","role":"USER"}
{"section":"S01","available":2000,"held":0,"sold":0}
{"id":"c869f791-701d-41dd-b82f-d5a6ba48093e","eventId":1,"status":"ACTIVE","expiresAt":"2026-10-18T17:51:50.567788219Z","seatIds":[1,2]}
409
401
404
{"status":"CONFIRMED","seatIds":[1,2]}
{"section":"S01","available":1998,"held":0,"sold":2}
{"doubleBookedSeats":0}

409: o assento 2 já está reservado. 401: reservas exigem um token. 404: a reserva de outro usuário não existe para você. Rode bash scripts/reset-inventory.sh em seguida para pôr os assentos de volta à venda.

6. O teste da venda relâmpago

load/lib.js
// Shared helpers for the lab 6 scripts: create fans and log them in.
import http from 'k6/http';
import { check } from 'k6';

export const BASE_URL = __ENV.BASE_URL || 'http://localhost';
const json = { headers: { 'Content-Type': 'application/json' } };

/** Registers `count` fans and returns one JWT per fan (runs in setup(); BCrypt makes it take a few seconds). */
export function loginFans(count) {
  const run = Date.now();
  const tokens = [];
  for (let i = 0; i < count; i++) {
    const email = `fan-${run}-${i}@boxoffice.dev`;
    http.post(`${BASE_URL}/auth/register`, JSON.stringify({ name: `Fan ${i}`, email, password: 'secret-123' }), json);
    const res = http.post(`${BASE_URL}/auth/login`, JSON.stringify({ email, password: 'secret-123' }), json);
    check(res, { 'login is 200': (r) => r.status === 200 });
    tokens.push(res.json('accessToken'));
  }
  return tokens;
}

export function authHeaders(token) {
  return { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` } };
}
load/flash-sale.js
// Lab 6: the 10:00 flash sale. 200 fans fight for the first 100 seats of section S01
// of event 1. Each fan tries to hold 2 seats; some change their mind and release.
// Run it once per locking strategy (INVENTORY_LOCKING) and compare.
//   bash scripts/reset-inventory.sh && k6 run load/flash-sale.js
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Counter } from 'k6/metrics';
import { BASE_URL, loginFans, authHeaders } from './lib.js';

// a 409 (seat taken) is an expected answer in a flash sale, not a failure
http.setResponseCallback(http.expectedStatuses(200, 201, 204, 409));

const held = new Counter('holds_created');
const conflicts = new Counter('holds_conflict');

export const options = {
  scenarios: {
    fans: { executor: 'constant-vus', vus: Number(__ENV.VUS || 200), duration: __ENV.DURATION || '30s' },
  },
  summaryTrendStats: ['avg', 'med', 'p(95)', 'p(99)', 'max'],
  thresholds: {
    'http_req_duration{name:hold}': ['p(95)<1000'],
    http_req_failed: ['rate<0.01'],
    holds_created: ['count>0'],
    holds_conflict: ['count>=0'],
  },
};

export function setup() {
  const tokens = loginFans(50);
  const seats = http.get(`${BASE_URL}/inventory/events/1/seats?section=S01`).json();
  return { tokens, seatIds: seats.slice(0, 100).map((s) => s.id) };   // rows 1-2: the best seats
}

export default function (data) {
  const token = data.tokens[__VU % data.tokens.length];
  const i = Math.floor(Math.random() * (data.seatIds.length - 1));
  const body = JSON.stringify({ eventId: 1, seatIds: [data.seatIds[i], data.seatIds[i + 1]] });

  const res = http.post(`${BASE_URL}/inventory/holds`, body, { ...authHeaders(token), tags: { name: 'hold' } });
  check(res, { 'hold answered 201 or 409': (r) => r.status === 201 || r.status === 409 });
  if (res.status === 201) {
    held.add(1);
    sleep(0.5);
    if (Math.random() < 0.7) {                       // 70% give up: the seats go back on sale
      http.del(`${BASE_URL}/inventory/holds/${res.json('id')}`, null, { ...authHeaders(token), tags: { name: 'release' } });
    }
  } else if (res.status === 409) {
    conflicts.add(1);
    sleep(0.2);
  }
}

// after the test: the invariant. Anything other than 0 is a double booking.
export function teardown(data) {
  const audit = http.get(`${BASE_URL}/inventory/audit/double-bookings`, authHeaders(data.tokens[0]));
  console.log(`AUDIT double-booked seats: ${audit.json('doubleBookedSeats')}`);
}
scripts/reset-inventory.sh
#!/usr/bin/env bash
# Lab 6: put every seat back on sale and forget all holds (run before each flash-sale test).
set -euo pipefail
docker compose exec -T db psql -q -U "${DB_USER:-boxoffice}" -d "${DB_NAME:-boxoffice}" -v ON_ERROR_STOP=1 <<'SQL'
BEGIN;
DELETE FROM inventory.hold_seats;
DELETE FROM inventory.holds;
UPDATE inventory.seats SET status = 'AVAILABLE', hold_id = NULL, version = 0
WHERE status <> 'AVAILABLE' OR hold_id IS NOT NULL;
COMMIT;
SQL
echo "inventory reset: all seats AVAILABLE"

Rode-o uma vez por estratégia, reiniciando os assentos a cada vez:

mkdir -p results
for mode in naive pessimistic optimistic; do
  INVENTORY_LOCKING=$mode docker compose up -d inventory
  sleep 25                                   # wait until inventory is healthy again
  bash scripts/reset-inventory.sh
  k6 run load/flash-sale.js 2>&1 | tee results/lab6-flash-$mode.txt | grep -E "AUDIT|holds_.*:|\{ name:hold"
done
INVENTORY_LOCKING=naive ... k6 run load/flash-sale.jstime="…" level=info msg="AUDIT double-booked seats: 9" source=console
holds_conflict.................: 11582 263.28/s
holds_created..................: 162 3.68/s
{ name:hold }................: avg=312.03ms med=208.6ms p(95)=808.96ms p(99)=1.88s
INVENTORY_LOCKING=pessimistic ... k6 run load/flash-sale.jstime="…" level=info msg="AUDIT double-booked seats: 0" source=console
holds_conflict.................: 16184 402.17/s
holds_created..................: 141 3.50/s
{ name:hold }................: avg=171.94ms med=78.37ms p(95)=665.17ms p(99)=1.77s
INVENTORY_LOCKING=optimistic ... k6 run load/flash-sale.jstime="…" level=info msg="AUDIT double-booked seats: 0" source=console
holds_conflict.................: 20028 512.57/s
holds_created..................: 141 3.61/s
{ name:hold }................: avg=93.62ms med=31.02ms p(95)=354.94ms p(99)=1.03s

Esses números vêm de uma execução num notebook ocupado — os seus vão diferir —, mas o que importa é a forma: o naive criou mais reservas do que os assentos permitem e a auditoria encontrou 9 assentos vendidos duas vezes; pessimistic e optimistic sempre terminam com 0. Com só 100 assentos, a maioria das tentativas é 409 (os assentos acabaram), que o script conta como resposta esperada, não como erro. Compare também a latência das requisições hold: as transações pessimistas fazem fila nos locks de linha, as otimistas falham rápido — aqui o optimistic teve o menor p99, porque um fã em conflito descobre "ocupado" sem esperar o lock de ninguém.

Pense primeiro: se o lock pessimista é correto, por que alguém usaria o lock otimista?

Locks custam espera. Quando a contenção é baixa (fãs espalhados por 60.000 assentos, a maioria das reservas toca linhas diferentes), o lock otimista nunca espera e quase nunca entra em conflito, então sai mais barato. Quando a contenção é alta (todo mundo quer as primeiras fileiras), o lock otimista vira uma tempestade de conflitos e retries, enquanto o pessimista serializa as requisições de forma organizada. Meça a sua contenção antes de escolher — e lembre que o design real da venda relâmpago também reduz a contenção antes do banco: uma sala de espera (lab 7) admite os fãs numa taxa que o inventário consegue absorver.

Na entrevista

Para "projete o Ticketmaster", diga explicitamente: "o inventário de assentos é o único lugar que precisa de consistência forte; vou usar um banco relacional com lock por linha (ou updates condicionais) para as reservas, com um TTL em cada reserva, e todo o resto — catálogo, busca, mapas de assentos — pode ser eventualmente consistente e ficar em cache." Depois mencione o problema da linha quente: milhares de fãs nos mesmos 100 assentos se serializam nos locks, então coloque-os numa fila antes do banco.

Próximo: 2. Réplica e pool de conexões.