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)
# syntax=docker/dockerfile:1# ---- build stage: compile with Maven inside a JDK image -------------------FROMmaven:3.9-eclipse-temurin-21ASbuildWORKDIR/srcCOPYpom.xml.
COPYsrc./src
# the cache mount keeps ~/.m2 between builds: dependencies are downloaded onceRUN--mount=type=cache,target=/root/.m2mvn-B-qpackage-DskipTests
# ---- runtime stage: only a JRE and the jar --------------------------------FROMeclipse-temurin:21-jreWORKDIR/appRUNuseradd--system--uid10001app
COPY--from=build/src/target/app.jarapp.jar
USERappEXPOSE8083# use up to 75% of the container's memory limit for the heapENTRYPOINT["java","-XX:MaxRAMPercentage=75","-jar","app.jar"]
<?xml version="1.0" encoding="UTF-8"?><projectxmlns="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>BoxOfficeinventory:seatsandholds,stronglyconsistent</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>
server:port:8083spring: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:inventorymvc:problemdetails:enabled:trueinventory:locking:${INVENTORY_LOCKING:pessimistic}# naive | pessimistic | optimistichold-ttl:${INVENTORY_HOLD_TTL:PT10M}# a hold expires after 10 minutesmax-seats-per-hold:6datasource:primary:# writes and read-your-writes readsjdbc-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:primaryreplica:# @Transactional(readOnly = true) readsjdbc-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:replicaread-only:truemanagement:endpoints:web:exposure:include:health, info, metricsendpoint:health:probes:enabled:true
-- 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.CREATETABLEinventory.seats(idBIGINTGENERATEDBYDEFAULTASIDENTITYPRIMARYKEY,event_idBIGINTNOTNULL,sectionVARCHAR(10)NOTNULL,seat_rowINTEGERNOTNULL,seat_numberINTEGERNOTNULL,priceNUMERIC(10,2)NOTNULL,statusVARCHAR(10)NOTNULLDEFAULT'AVAILABLE'CHECK(statusIN('AVAILABLE','HELD','SOLD')),hold_idUUID,versionINTEGERNOTNULLDEFAULT0,-- optimistic lockingUNIQUE(event_id,section,seat_row,seat_number)-- also the index for "seats of an event");CREATETABLEinventory.holds(idUUIDPRIMARYKEY,event_idBIGINTNOTNULL,user_idVARCHAR(64)NOTNULL,statusVARCHAR(10)NOTNULLCHECK(statusIN('ACTIVE','CONFIRMED','RELEASED','EXPIRED')),expires_atTIMESTAMPTZNOTNULL,created_atTIMESTAMPTZNOTNULLDEFAULTnow());-- which seats each hold asked for: lets us AUDIT double bookings afterwardsCREATETABLEinventory.hold_seats(hold_idUUIDNOTNULLREFERENCESinventory.holds(id),seat_idBIGINTNOTNULLREFERENCESinventory.seats(id),PRIMARYKEY(hold_id,seat_id));
-- Event 1 (the flash sale): 30 sections x 40 rows x 50 seats = 60,000 seats.INSERTINTOinventory.seats(event_id,section,seat_row,seat_number,price)SELECT1,'S'||lpad(s::text,2,'0'),r,n,250+(s%5)*100FROMgenerate_series(1,30)ASs,generate_series(1,40)ASr,generate_series(1,50)ASnORDERBYs,r,n;-- Events 2..100: 5 sections x 10 rows x 10 seats = 500 seats each.INSERTINTOinventory.seats(event_id,section,seat_row,seat_number,price)SELECTe,'S'||lpad(s::text,2,'0'),r,n,80+s*20FROMgenerate_series(2,100)ASe,generate_series(1,5)ASs,generate_series(1,10)ASr,generate_series(1,10)ASnORDERBYe,s,r,n;ANALYZEinventory.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.
packageboxoffice.inventory;importorg.springframework.boot.SpringApplication;importorg.springframework.boot.autoconfigure.SpringBootApplication;importorg.springframework.scheduling.annotation.EnableScheduling;@SpringBootApplication@EnableScheduling// the job that expires old holdspublicclassInventoryApplication{publicstaticvoidmain(String[]args){SpringApplication.run(InventoryApplication.class,args);}}
packageboxoffice.inventory;importjava.time.Duration;importjava.time.OffsetDateTime;importjava.time.ZoneOffset;importjava.util.List;importjava.util.UUID;importorg.slf4j.Logger;importorg.slf4j.LoggerFactory;importorg.springframework.beans.factory.annotation.Value;importorg.springframework.http.HttpStatus;importorg.springframework.jdbc.core.simple.JdbcClient;importorg.springframework.scheduling.annotation.Scheduled;importorg.springframework.stereotype.Service;importorg.springframework.transaction.annotation.Transactional;importorg.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. */@ServicepublicclassHoldService{privatestaticfinalLoggerlog=LoggerFactory.getLogger(HoldService.class);privaterecordSeat(longid,Stringstatus,intversion){}privatefinalJdbcClientjdbc;privatefinalStringlocking;privatefinalDurationholdTtl;privatefinalintmaxSeats;publicHoldService(JdbcClientjdbc,@Value("${inventory.locking}")Stringlocking,@Value("${inventory.hold-ttl}")DurationholdTtl,@Value("${inventory.max-seats-per-hold}")intmaxSeats){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 primarypublicHoldOuthold(StringuserId,HoldInin){varseatIds=in.seatIds().stream().distinct().sorted().toList();if(seatIds.size()>maxSeats){thrownewResponseStatusException(HttpStatus.BAD_REQUEST,"at most "+maxSeats+" seats per hold");}varholdId=UUID.randomUUID();varexpiresAt=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(varseatId:seatIds){jdbc.sql("INSERT INTO inventory.hold_seats (hold_id, seat_id) VALUES (:holdId, :seatId)").param("holdId",holdId).param("seatId",seatId).update();}returnnewHoldOut(holdId,in.eventId(),"ACTIVE",expiresAt,seatIds);}// ---- strategy 1: check-then-act, no lock (WRONG on purpose) ---------------privatevoidholdNaive(longeventId,List<Long>seatIds,UUIDholdId){varseats=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 -----------------------privatevoidholdPessimistic(longeventId,List<Long>seatIds,UUIDholdId){// 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.varseats=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 ---------------privatevoidholdOptimistic(longeventId,List<Long>seatIds,UUIDholdId){varseats=read(eventId,seatIds,"");// no lockrequireAvailable(seats,seatIds);for(varseat:seats){intchanged=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)thrownewResponseStatusException(HttpStatus.CONFLICT,"seat "+seat.id()+" was just taken");}}}privateList<Seat>read(longeventId,List<Long>seatIds,Stringsuffix){returnjdbc.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)->newSeat(rs.getLong("id"),rs.getString("status"),rs.getInt("version"))).list();}privatestaticvoidrequireAvailable(List<Seat>seats,List<Long>seatIds){if(seats.size()!=seatIds.size()){thrownewResponseStatusException(HttpStatus.BAD_REQUEST,"some seats do not belong to this event");}if(seats.stream().anyMatch(s->!"AVAILABLE".equals(s.status()))){thrownewResponseStatusException(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 itpublicHoldOutfind(StringuserId,UUIDholdId){varhold=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)->newHoldOut(rs.getObject("id",UUID.class),rs.getLong("event_id"),rs.getString("status"),rs.getObject("expires_at",OffsetDateTime.class),List.of())).optional().orElseThrow(()->newResponseStatusException(HttpStatus.NOT_FOUND,"hold not found"));varseatIds=jdbc.sql("SELECT seat_id FROM inventory.hold_seats WHERE hold_id = :id ORDER BY seat_id").param("id",holdId).query(Long.class).list();returnnewHoldOut(hold.id(),hold.eventId(),hold.status(),hold.expiresAt(),seatIds);}/** Payment succeeded (lab 7 calls this): seats become SOLD. */@TransactionalpublicHoldOutconfirm(StringuserId,UUIDholdId){intchanged=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){thrownewResponseStatusException(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();returnfind(userId,holdId);}@Transactionalpublicvoidrelease(StringuserId,UUIDholdId){intchanged=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){thrownewResponseStatusException(HttpStatus.NOT_FOUND,"no active hold "+holdId);}freeSeatsOf(holdId);}privatevoidfreeSeatsOf(UUIDholdId){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)@TransactionalpublicvoidexpireHolds(){intfreed=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):
packageboxoffice.inventory;importjava.util.List;importorg.springframework.jdbc.core.simple.JdbcClient;importorg.springframework.stereotype.Service;importorg.springframework.transaction.annotation.Transactional;/** Read-only queries: they run on the REPLICA (readOnly = true), so they may be slightly stale. */@ServicepublicclassSeatQueries{privatefinalJdbcClientjdbc;publicSeatQueries(JdbcClientjdbc){this.jdbc=jdbc;}@Transactional(readOnly=true)publicList<SectionAvailability>availability(longeventId){returnjdbc.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)->newSectionAvailability(rs.getString("section"),rs.getLong("available"),rs.getLong("held"),rs.getLong("sold"))).list();}@Transactional(readOnly=true)publicList<SeatOut>seats(longeventId,Stringsection){returnjdbc.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)->newSeatOut(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. */@TransactionalpubliclongdoubleBookedSeats(){returnjdbc.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();}}
packageboxoffice.inventory;importjakarta.validation.Valid;importjava.net.URI;importjava.util.List;importjava.util.Map;importjava.util.UUID;importorg.springframework.http.HttpStatus;importorg.springframework.http.ResponseEntity;importorg.springframework.web.bind.annotation.DeleteMapping;importorg.springframework.web.bind.annotation.GetMapping;importorg.springframework.web.bind.annotation.PathVariable;importorg.springframework.web.bind.annotation.PostMapping;importorg.springframework.web.bind.annotation.RequestBody;importorg.springframework.web.bind.annotation.RequestHeader;importorg.springframework.web.bind.annotation.RequestMapping;importorg.springframework.web.bind.annotation.RequestParam;importorg.springframework.web.bind.annotation.ResponseStatus;importorg.springframework.web.bind.annotation.RestController;@RestController@RequestMapping("/inventory")publicclassInventoryController{privatefinalSeatQueriesqueries;privatefinalHoldServiceholds;publicInventoryController(SeatQueriesqueries,HoldServiceholds){this.queries=queries;this.holds=holds;}// ---- open (GET, through the gateway without a token), served by the replica ----@GetMapping("/events/{eventId}/availability")publicList<SectionAvailability>availability(@PathVariablelongeventId){returnqueries.availability(eventId);}@GetMapping("/events/{eventId}/seats")publicList<SeatOut>seats(@PathVariablelongeventId,@RequestParamStringsection){returnqueries.seats(eventId,section);}// ---- protected: the gateway sets X-User-Id from the JWT ----@PostMapping("/holds")publicResponseEntity<HoldOut>hold(@RequestHeader("X-User-Id")StringuserId,@Valid@RequestBodyHoldInin){varout=holds.hold(userId,in);returnResponseEntity.created(URI.create("/inventory/holds/"+out.id())).body(out);}@GetMapping("/holds/{id}")publicHoldOutfind(@RequestHeader("X-User-Id")StringuserId,@PathVariableUUIDid){returnholds.find(userId,id);}@PostMapping("/holds/{id}/confirm")publicHoldOutconfirm(@RequestHeader("X-User-Id")StringuserId,@PathVariableUUIDid){returnholds.confirm(userId,id);}@DeleteMapping("/holds/{id}")@ResponseStatus(HttpStatus.NO_CONTENT)publicvoidrelease(@RequestHeader("X-User-Id")StringuserId,@PathVariableUUIDid){holds.release(userId,id);}/** The invariant check used after a flash-sale test: must be {"doubleBookedSeats": 0}. */@GetMapping("/audit/double-bookings")publicMap<String,Long>audit(){returnMap.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):
server:port:8080spring:application:name:gatewaycloud:gateway:server:webflux:httpclient:connect-timeout:1000# ms to open a connection to a serviceresponse-timeout:5s# never wait forever for a servicedefault-filters:# retry idempotent GETs once on another instance when a connection fails-name:Retryargs:retries:1methods:GETseries:SERVER_ERRORexceptions:java.io.IOException, java.util.concurrent.TimeoutExceptionroutes:-id:authuri:${AUTH_URL:http://auth:8081}predicates:-Path=/auth/**-id:cataloguri:lb://catalog# "lb": pick one instance of "catalog" per requestpredicates:-Path=/catalog/**-id:inventoryuri:${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:8082loadbalancer:configurations:health-check# skip instances that are not readyhealth-check:interval:5spath:default:/actuator/health/readiness# this instance, not db/redis that every replica sharessecurity:oauth2:resourceserver:jwt:jwk-set-uri:${AUTH_JWKS_URI:http://auth:8081/auth/.well-known/jwks.json}jwt:issuer:${JWT_ISSUER:boxoffice-auth}audience:boxofficemanagement:endpoints:web:exposure:include:health, info, metrics, gatewayendpoint:health:probes:enabled:truegateway:access:read-only# GET /actuator/gateway/routes lists the routes
packageboxoffice.gateway;importjava.util.List;importorg.springframework.beans.factory.annotation.Value;importorg.springframework.context.annotation.Bean;importorg.springframework.context.annotation.Configuration;importorg.springframework.http.HttpMethod;importorg.springframework.security.config.Customizer;importorg.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;importorg.springframework.security.config.web.server.ServerHttpSecurity;importorg.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;importorg.springframework.security.oauth2.jwt.JwtClaimValidator;importorg.springframework.security.oauth2.jwt.JwtValidators;importorg.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder;importorg.springframework.security.oauth2.jwt.ReactiveJwtDecoder;importorg.springframework.security.web.server.SecurityWebFilterChain;@Configuration@EnableWebFluxSecuritypublicclassSecurityConfig{/** Which routes are open, which need a valid token, which need the admin scope. */@BeanSecurityWebFilterChainsecurity(ServerHttpSecurityhttp){returnhttp.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. */@BeanReactiveJwtDecoderjwtDecoder(@Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}")StringjwksUri,@Value("${jwt.issuer}")Stringissuer,@Value("${jwt.audience}")Stringaudience){vardecoder=NimbusReactiveJwtDecoder.withJwkSetUri(jwksUri).build();decoder.setJwtValidator(newDelegatingOAuth2TokenValidator<>(JwtValidators.createDefaultWithIssuer(issuer),// exp, nbf, issnewJwtClaimValidator<List<String>>("aud",aud->aud!=null&&aud.contains(audience))));returndecoder;}}
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-XPOSTlocalhost/auth/register-H'Content-Type: application/json'\-d'{"name":"Ana","email":"ana@example.com","password":"secret-123"}';echoexportTOKEN=$(curl-s-XPOSTlocalhost/auth/login-H'Content-Type: application/json'\-d'{"email":"ana@example.com","password":"secret-123"}'|jq-r.accessToken)exportADMIN=$(curl-s-XPOSTlocalhost/auth/login-H'Content-Type: application/json'\-d'{"email":"admin@boxoffice.dev","password":"boxoffice-admin"}'|jq-r.accessToken)curl-slocalhost/inventory/events/1/availability|jq-c'.[0]'HOLD=$(curl-s-XPOSTlocalhost/inventory/holds-H"Authorization: Bearer $TOKEN"\-H'Content-Type: application/json'-d'{"eventId":1,"seatIds":[1,2]}')echo"$HOLD"exportHOLD_ID=$(echo"$HOLD"|jq-r.id)# the same seat for somebody else, no token, somebody else's holdcurl-s-o/dev/null-w"%{http_code}\n"-XPOSTlocalhost/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"-XPOSTlocalhost/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 againcurl-s-XPOSTlocalhost/inventory/holds/$HOLD_ID/confirm-H"Authorization: Bearer $TOKEN"|jq-c'{status, seatIds}'curl-slocalhost/inventory/events/1/availability|jq-c'.[0]'curl-slocalhost/inventory/audit/double-bookings-H"Authorization: Bearer $TOKEN"
Você deve ver (de uma execução deste lab; ids e horários diferem):
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.
// Shared helpers for the lab 6 scripts: create fans and log them in.importhttpfrom'k6/http';import{check}from'k6';exportconstBASE_URL=__ENV.BASE_URL||'http://localhost';constjson={headers:{'Content-Type':'application/json'}};/** Registers `count` fans and returns one JWT per fan (runs in setup(); BCrypt makes it take a few seconds). */exportfunctionloginFans(count){construn=Date.now();consttokens=[];for(leti=0;i<count;i++){constemail=`fan-${run}-${i}@boxoffice.dev`;http.post(`${BASE_URL}/auth/register`,JSON.stringify({name:`Fan ${i}`,email,password:'secret-123'}),json);constres=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'));}returntokens;}exportfunctionauthHeaders(token){return{headers:{'Content-Type':'application/json',Authorization:`Bearer ${token}`}};}
// 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.jsimporthttpfrom'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 failurehttp.setResponseCallback(http.expectedStatuses(200,201,204,409));constheld=newCounter('holds_created');constconflicts=newCounter('holds_conflict');exportconstoptions={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'],},};exportfunctionsetup(){consttokens=loginFans(50);constseats=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}exportdefaultfunction(data){consttoken=data.tokens[__VU%data.tokens.length];consti=Math.floor(Math.random()*(data.seatIds.length-1));constbody=JSON.stringify({eventId:1,seatIds:[data.seatIds[i],data.seatIds[i+1]]});constres=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 salehttp.del(`${BASE_URL}/inventory/holds/${res.json('id')}`,null,{...authHeaders(token),tags:{name:'release'}});}}elseif(res.status===409){conflicts.add(1);sleep(0.2);}}// after the test: the invariant. Anything other than 0 is a double booking.exportfunctionteardown(data){constaudit=http.get(`${BASE_URL}/inventory/audit/double-bookings`,authHeaders(data.tokens[0]));console.log(`AUDIT double-booked seats: ${audit.json('doubleBookedSeats')}`);}
#!/usr/bin/env bash# Lab 6: put every seat back on sale and forget all holds (run before each flash-sale test).set-euopipefail
dockercomposeexec-Tdbpsql-q-U"${DB_USER:-boxoffice}"-d"${DB_NAME:-boxoffice}"-vON_ERROR_STOP=1<<'SQL'BEGIN;DELETE FROM inventory.hold_seats;DELETE FROM inventory.holds;UPDATE inventory.seats SET status = 'AVAILABLE', hold_id = NULL, version = 0WHERE status <> 'AVAILABLE' OR hold_id IS NOT NULL;COMMIT;SQLecho"inventory reset: all seats AVAILABLE"
Rode-o uma vez por estratégia, reiniciando os assentos a cada vez:
mkdir-presults
formodeinnaivepessimisticoptimistic;doINVENTORY_LOCKING=$modedockercomposeup-dinventory
sleep25# wait until inventory is healthy againbashscripts/reset-inventory.sh
k6runload/flash-sale.js2>&1|teeresults/lab6-flash-$mode.txt|grep-E"AUDIT|holds_.*:|\{ name:hold"done
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.