A hold says "these seats are mine for 10 minutes, while I pay". The invariant: a seat belongs to at most one live hold. Everything in this step is about keeping that invariant true when hundreds of fans click the same seats in the same second.
1. Why "check, then update" is not enough
The obvious code reads the seats, checks they are AVAILABLE, then updates them. Under concurrency, two transactions can interleave:
sequenceDiagram
autonumber
participant A as Fan A (tx 1)
participant DB as PostgreSQL
participant B as Fan 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 ... (waits for A's row locks)
A->>DB: COMMIT
DB-->>B: update proceeds β overwrites A's hold
B->>DB: COMMIT
Note over A,B: both fans got a 201 for seats 7 and 8: DOUBLE BOOKING
PostgreSQL's default isolation, READ COMMITTED, does not prevent this lost update: each statement sees committed data, but nothing ties B's decision (made on the SELECT) to the data when B writes. Three fixes:
Strategy
How
On conflict
Good when
Pessimistic
SELECT β¦ FOR UPDATE locks the rows until commit; the second transaction waits, then sees HELD
409 after waiting
contention is high (the flash sale: everybody wants the same 100 seats)
Optimistic
read without locks; UPDATE β¦ WHERE version = :v; 0 rows changed β someone was faster
409 immediately, the transaction rolls back
contention is low; no waiting, no lock queues
Conditional update
UPDATE β¦ WHERE id IN (β¦) AND status = 'AVAILABLE' and check the row count
409 immediately
a single statement can express the check (a variant of optimistic)
Serializable isolation
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE
the database aborts one transaction (40001), the app retries
complex invariants over many rows
2. The inventory service
Create services/inventory (port 8083, schema inventory, same Dockerfile pattern with EXPOSE 8083). It uses JdbcClient with plain SQL instead of JPA: every lock and every WHERE is visible in the code, which is the point of this lab.
services/inventory/Dockerfile and .dockerignore (same as catalog, only EXPOSE 8083 changes)
# 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 records which seats each hold asked for. The seats.hold_id column can be overwritten by a buggy strategy, but hold_seats keeps the evidence: the audit query counts seats that appear in more than one live hold.
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);}}}
Read it strategy by strategy:
naive: read(β¦, "") then an unconditional UPDATE. The comment marks the race window.
pessimistic: read(β¦, "ORDER BY id FOR UPDATE"). The second transaction blocks on the row locks; when the first commits, PostgreSQL re-reads the locked rows, the second sees HELD, and requireAvailable throws 409. Locking in a fixed order (ORDER BY id) matters: fan A holding seats 7β8 and fan B holding 8β7 in opposite order would deadlock.
optimistic: no locks; the UPDATE only succeeds if version is still the one we read. If another transaction changed the seat in between, 0 rows change, we throw, and @Transactional rolls back everything (including seats already updated in the loop).
Every hold also checks that all seats belong to the event (400 otherwise) and limits a hold to 6 seats.
find, confirm and release filter by user_id: a fan cannot see or cancel somebody else's hold (that would be an IDOR, insecure direct object reference β OWASP API #1).
expireHolds runs every 5 s on every replica; FOR UPDATE SKIP LOCKED lets several replicas share the work without blocking each other or processing a hold twice β the same trick used by job queues built on PostgreSQL.
The read-only queries live in a separate class, because they go to the replica (step 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. Wire it into the gateway
Add the route and open the read endpoints (holds stay protected by the default rule):
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;}}
The Compose file with inventory, the replica and PgBouncer is in step 2; bring everything up after that step, or now with the full file.
5. Check it by hand
With the full stack of step 2 running, log in and walk through a hold. All commands from the repository root:
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"
You should see (from a run for this lab; ids and times differ):
409: seat 2 is already held. 401: holds need a token. 404: a hold of another user does not exist for you. Run bash scripts/reset-inventory.sh afterwards to put the seats back on sale.
// 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"
Run it once per strategy, resetting the seats each time:
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
These numbers come from one run on a busy laptop β yours will differ β but the shape is what matters: naive created more holds than the seats allow and the audit found 9 double-booked seats; pessimistic and optimistic always end with 0. With only 100 seats, most attempts are 409 (the seats are gone), which the script counts as an expected answer, not an error. Compare also the latency of the hold requests: pessimistic transactions queue on row locks, optimistic ones fail fast β here optimistic had the lowest p99, because a conflicting fan learns "taken" without waiting for anybody's lock.
Think first: if pessimistic locking is correct, why would anyone use optimistic locking?
Locks cost waiting. When contention is low (fans spread over 60,000 seats, most holds touch different rows), optimistic locking never waits and almost never conflicts, so it is cheaper. When contention is high (everybody wants the first rows), optimistic locking turns into a storm of conflicts and retries, while pessimistic locking serializes the requests neatly. Measure your contention before choosing β and remember that the real flash-sale design also reduces contention before the database: a waiting room (lab 7) admits fans at a rate the inventory can absorb.
In the interview
For "design Ticketmaster", say explicitly: "seat inventory is the one place that needs strong consistency; I will use a relational database with row-level locking (or conditional updates) for holds, with a TTL on each hold, and everything else β catalog, search, seat maps β can be eventually consistent and cached." Then mention the hot-row problem: thousands of fans on the same 100 seats serialize on their locks, so queue them before the database.