a. Visão geral
Neste lab
- Construa o
inventory, o coração fortemente consistente do BoxOffice: assentos e reservas (holds), em que um assento nunca pode pertencer a dois fãs. - Compare três estratégias de concorrência numa venda relâmpago — check-then-act ingênuo, lock pessimista (
SELECT … FOR UPDATE) e lock otimista (coluna de versão) — e prove quais vendem o mesmo assento duas vezes2. - Adicione uma réplica de leitura com replicação por streaming, roteie para ela as transações somente leitura e observe o atraso de replicação quebrando o read-your-writes.
- Ponha o PgBouncer entre os serviços e o PostgreSQL, e use
EXPLAIN ANALYZE+ índices para corrigir a listagem lenta do catálogo. - Torne a replicação síncrona e pague por isso, derrube o primário com uma reserva em andamento, faça o failover à mão, provoque um split brain e traga o antigo primário de volta com o
pg_rewind. - Meça cada mudança: conflitos, vendas duplicadas, p99, atraso, conexões, tempo de consulta, escritas perdidas.
A venda relâmpago é o momento para o qual o BoxOffice existe: às 10:00, 60.000 assentos e dois milhões de fãs. O catálogo pôde ir para o cache porque alguns segundos de defasagem não fazem mal. Os assentos são o oposto: vender o mesmo assento duas vezes é o único bug que uma empresa de ingressos não pode se permitir. Este lab trata da camada de dados sob pressão1 — primeiro a corretude (transações e locks), depois a escala (réplicas, pooling, índices), depois a sobrevivência (failover) — e dos trade-offs entre elas.
Este lab constrói o capítulo 6 da história da disciplina, o banco de dados do qual todo serviço stateless agora depende. Ele implementa as decisões da aula 6: um assento, uma reserva com locks ou uma verificação de versão, uma réplica de leitura e o seu atraso, pool de conexões com o PgBouncer, índices lidos com EXPLAIN e um failover, com as suas escritas perdidas e o split brain.
Antes de começar
| Tempo | 5–6 horas (quatro partes: faça uma por encontro, se precisar) |
| Você precisa de | as ferramentas do lab 0; Docker com 8 GB (onze contêineres, seis JVMs); portas 80, 8080, 8083, 5432, 5433, 6432 e 6379 livres |
| Diretório | todo comando roda na raiz do seu repositório boxoffice/ |
docker compose version # Docker Compose v2.x (or newer)
java -version # 21 or newer
mvn -v # Apache Maven 3.9+
k6 version # k6 v1.0+
uv --version # uv (runs the Python helper scripts)
Estado inicial — o fim do lab 5:
📁 boxoffice/
├── docker-compose.yaml # db, redis, catalog-1..3, auth, gateway, edge
├── infra/nginx/nginx.conf
├── 📁 load/ ... scripts/ ... results/ ...
└── 📁 services/auth/ services/catalog/ (with the cache) services/gateway/
Recupere o atraso
Pulou ou quebrou o lab 5? Baixe boxoffice-after-lab-5.zip, descompacte e cd boxoffice. Este lab adiciona services/inventory, infra/postgres/*, infra/pgbouncer/*, a migração do catalog V3__index_events.sql, quatro arquivos do k6 e scripts/reset-inventory.sh e infra/postgres/rejoin-as-replica.sh (passo 4), e substitui o docker-compose.yaml e o application.yaml e o SecurityConfig.java do gateway; todos aparecem completos nas próximas páginas e estão em code/. Ele também precisa de um volume de banco novo (docker compose down -v, explicado no passo 2).
Slides
Clique nos slides e use ← → para navegar; F abre em tela cheia e P abre a visão do apresentador, com as notas. Abrir os slides em uma nova aba
Conexão com a teoria
- Aula 6, Bancos de dados: transações e isolamento (anomalias,
SELECT … FOR UPDATEpara reservas de assentos), replicação (líder–seguidor, atraso, failover), índices e B-trees. - Aula 3, Consenso e quóruns: quem decide o líder depois de um failover, e o fencing.
- Aula 3, Modelos de consistência: read-your-writes.
- Estudo de caso: Venda de ingressos (a venda relâmpago do BoxOffice).
- Catálogo de componentes: Banco SQL.
Antes de medir: o playground
Experimente: o cenário db-replicas tem um primário e réplicas de leitura, com leituras e escritas separadas. Rode-o e depois aumente a fração de escritas: o que acontece com o primário enquanto as réplicas ficam ociosas? Aumente o atraso de replicação e observe o indicador de "leituras desatualizadas". Anote com que tipo de tráfego as réplicas ajudam — e com qual elas não ajudam em nada.
Depois olhe a venda inteira em flash-sale: a sala de espera, o inventário em SQL, o pagamento. O lab 7 constrói a sala de espera; hoje você constrói a caixa do inventário.
O que você vai construir
flowchart LR
k6([fãs do k6]) --> edge[edge] --> gw[gateway]
gw -->|"/catalog/**"| cat["catalog ×3"]
gw -->|"/inventory/**"| inv["inventory :8083"]
inv -->|"escritas, reservas<br/>(tx de leitura e escrita)"| pgb["pgbouncer :6432"]
pgb --> db[("db (primário)")]
inv -->|"mapas de assentos, disponibilidade<br/>(tx readOnly)"| rep[("db-replica")]
db -->|"streaming do WAL"| rep
cat --> db
cat --> redis[(redis)] | Método | Caminho | Auth | Banco | Resposta |
|---|---|---|---|---|
GET | /inventory/events/{eventId}/availability | aberto | réplica | assentos por setor: disponíveis, reservados, vendidos |
GET | /inventory/events/{eventId}/seats?section=S01 | aberto | réplica | o mapa de assentos de um setor |
POST | /inventory/holds {eventId, seatIds} | token | primário | 201 reserva (10 min), 409 se algum assento estiver ocupado |
GET | /inventory/holds/{id} | token (dono) | primário | a reserva |
POST | /inventory/holds/{id}/confirm | token (dono) | primário | os assentos viram SOLD (chamado pelo order no lab 7) |
DELETE | /inventory/holds/{id} | token (dono) | primário | libera os assentos |
GET | /inventory/audit/double-bookings | token | primário | {"doubleBookedSeats": 0} — o invariante |
O evento 1 tem 60.000 assentos (30 setores × 40 fileiras × 50 assentos); os eventos 2–100 têm 500 assentos cada.
Passos
-
O serviço
inventory, três estratégias de lock, o teste da venda relâmpago e a auditoria de vendas duplicadas. -
Replicação por streaming, roteamento de transações
readOnly, atraso de replicação e read-your-writes, PgBouncer. -
Leia um plano de consulta, acrescente os índices certos à listagem do catálogo, meça antes e depois.
-
Replicação síncrona e o seu custo, uma queda com uma reserva em andamento, failover movendo um nome, split brain,
pg_rewind.
Meça
Cada passo tem o seu próprio experimento; reúna os resultados em results/lab6.md:
# Lab 6 — data at scale
## 1. Flash sale: 200 fans, 100 seats, 30 s (load/flash-sale.js)
| INVENTORY_LOCKING | holds 201 | holds 409 | p50 hold (ms) | p99 hold (ms) | double-booked seats (audit) |
|---|---:|---:|---:|---:|---:|
| naive | | | | | |
| pessimistic | | | | | |
| optimistic | | | | | |
## 2. Reads: availability + seat maps at 200 req/s (load/availability.js)
| Reads served by | p50 (ms) | p99 (ms) | CPU db (%) | CPU db-replica (%) |
|---|---:|---:|---:|---:|
| primary (DB_REPLICA_HOST=db) | | | | |
| replica | | | | |
## 3. Read-your-writes (load/read-your-writes.js)
| REPLICA_DELAY | stale_reads (%) |
|---|---:|
| 0 | |
| 1s | |
## 4. Connections
| Setup | connections in pg_stat_activity during the flash sale |
|---|---:|
| inventory → db directly (INVENTORY_DB_HOST=db, PORT=5432) | |
| inventory → pgbouncer | |
PgBouncer SHOW POOLS: cl_active ____ , sv_active ____ , cl_waiting ____
## 5. Catalog listing before/after the indexes (EXPLAIN ANALYZE + sweep)
| | Execution time (ms) | Plan (main node) | Capacity with p99 < 300 ms (req/s) |
|---|---:|---|---:|
| before (V2) | | | |
| after (V3) | | | |
## 6. Failover (step 4)
| synchronous_commit (REPLICA_DELAY=1s) | stale_reads (%) | iteration_duration avg |
|---|---:|---:|
| on | | |
| remote_apply | | |
Replica stopped, synchronous: hold HTTP status ____ , seat 10 afterwards ____
Crash: Ana's hold after the failover (HTTP status) ____ ; seconds until holds worked again ____
Split brain: seat 20 on db ____ , on db-replica ____
## Conclusions
- Which locking strategy would you ship for the flash sale, and why?
- Which reads may go to the replica, and which must not?
- Cache (lab 5) or index (lab 6) for the listing — or both? Justify with your numbers.
- Should holds be replicated synchronously? Give the RPO and the cost of each choice.
Se os seus números forem diferentes
Os resultados de corretude precisam bater em qualquer máquina: o naive termina com vendas duplicadas (quantas varia de execução para execução — 9 e 11 em execuções deste lab; se der 0, rode de novo ou aumente VUS), pessimistic e optimistic sempre terminam com 0. Latências, porcentagens de leituras desatualizadas e contagens de conexões dependem da sua máquina; compare-as apenas entre as suas próprias execuções.
Solução de problemas
| Sintoma | Causa | Correção |
|---|---|---|
db-replica termina com permission denied no replica-entrypoint.sh | o script não é executável | chmod +x infra/postgres/*.sh e docker compose up -d db-replica |
db-replica registra no pg_hba.conf entry for replication connection | o volume do primário é anterior a este lab, então o 10-replication.sh nunca rodou | docker compose down -v e docker compose up --build -d (o Flyway popula tudo de novo) |
db-replica sai: max_connections ... must be at least the primary's value | DB_MAX_CONNECTIONS no primário está acima de 100 | mantenha ≤ 100, ou aumente também na réplica |
o inventory registra password authentication failed via PgBouncer | userlist.txt não bate com DB_PASSWORD | mantenha os dois como boxoffice (ou atualize os dois) |
prepared statement "S_1" already exists | um PgBouncer sem max_prepared_statements (imagem mais antiga ou ini editado) | use edoburu/pgbouncer:v1.26.0-p0 e o ini desta página |
503/404 em /inventory/** pelo edge | o gateway não foi reconstruído com a nova rota | docker compose up --build -d gateway; depois, curl -s localhost:8080/actuator/gateway/routes \| jq -r '.[].route_id' precisa listar inventory |
reservas devolvem 401 | sem token (as reservas são protegidas) | faça login e passe -H "Authorization: Bearer $TOKEN" |
ALTER SYSTEM SET synchronous_standby_names falha com syntax error at or near "-" | o nome com hífen não está entre aspas | 'FIRST 1 ("db-replica")': aspas duplas em volta do nome, simples em volta do valor |
escritas travam, pg_stat_activity mostra wait_event = SyncRep | a replicação síncrona está ligada e a réplica caiu | suba a réplica, ou ALTER SYSTEM RESET synchronous_standby_names + SELECT pg_reload_conf() |
depois de docker compose start db-replica, o db está rodando de novo | o db-replica depende do db, então o start sobe ele também | pare o db de novo antes de promover; use docker compose up -d --no-deps db-replica |
pg_rewind: target server needs to use either data checksums or "wal_log_hints = on" | o wal_log_hints estava desligado quando o antigo primário rodava (seção 1 pulada) | o antigo primário não consegue voltar; recomece o cluster com docker compose down -v e docker compose up --build -d (o Flyway popula tudo de novo) e refaça o passo 4 a partir da seção 1 |
escritas falham com cannot execute INSERT in a read-only transaction depois do failover | db resolve para o standby que voltou e para o novo primário | termine a volta ao primário original |
db-replica fica unhealthy depois de um clone novo, e o log diz waiting for checkpoint | um replica-entrypoint.sh antigo, sem -c fast | atualize o script e rode docker compose up -d --force-recreate db-replica |
contêineres mortos (exit 137), inicialização muito lenta | memória insuficiente para onze contêineres | dê 8 GB ao Docker, feche outros programas, ou pare catalog-2/catalog-3 durante os experimentos do inventory |
Boas práticas neste lab
- Decida no banco, não na aplicação —
SELECT … FOR UPDATEcomORDER BY id, ou um compare-and-set de versão, noHoldService.java; veja Transações — o bug da venda duplicada. - Mande para a réplica as leituras que podem estar desatualizadas, e todo o resto para o primário —
@Transactional(readOnly = true)roteado peloDataSourceConfig.java; veja Replicação — o atraso e as suas anomalias. - Faça pool de conexões na frente do PostgreSQL, e migre contornando o pooler —
pgbouncer.iniem modo transação,DB_MIGRATION_HOSTpara o Flyway noapplication.yamldoinventory; veja Replicação (pool de conexões). - Acrescente um índice para uma consulta medida, conferido com
EXPLAIN ANALYZE—V3__index_events.sql; veja Modelos de dados e engines de armazenamento (lendo oEXPLAIN). - Saiba o que um failover perde, e isole o antigo primário — a replicação assíncrona perdeu uma reserva que tinha respondido
201; opg_rewind(rejoin-as-replica.sh) é o único jeito de o antigo primário voltar; veja Replicação — failover. - Audite o invariante depois de todo teste —
SeatQueries.doubleBookedSeatsatrás de/inventory/audit/double-bookings; veja Transações — reservas de assentos.
Limpeza
REPLICA_DELAY=0 docker compose up -d db-replica # if you left the artificial lag on
INVENTORY_LOCKING=pessimistic docker compose up -d inventory
bash scripts/reset-inventory.sh # all seats back on sale
docker compose down # keep the volumes for lab 7
Guarde tudo: o lab 7 acrescenta o order e o Kafka a esta stack e confirma as reservas pelo inventory.
Entrega
-
services/inventoryrodando atrás do gateway; o gateway roteia/inventory/**(GETs em/inventory/events/**abertos, reservas protegidas). - A tabela da venda relâmpago com as três estratégias, incluindo o resultado da auditoria de cada uma: pelo menos uma execução do
naivecom vendas duplicadas > 0, epessimistic/optimisticcom 0. -
db-replicaem streaming (opg_stat_replicationa mostra), leituras roteadas para ela e o experimento de read-your-writes com e semREPLICA_DELAY. - PgBouncer na frente do primário, com as contagens de conexões.
-
EXPLAIN ANALYZEda listagem do catálogo antes e depois doV3__index_events.sql, e o sweep depois. - O failover do passo 4: a tabela da replicação síncrona, a reserva perdida, o split brain, e a stack de volta com o
dbcomo primário e odb-replicatransmitindo. -
results/lab6.mdcompleto com as quatro conclusões. - Repositório com a tag
lab6; quiz respondido.
Estado final
📁 boxoffice/
├── docker-compose.yaml # replaced: + db-replica (5433), + pgbouncer (6432), + inventory (8083)
├── infra/postgres/10-replication.sh infra/postgres/replica-entrypoint.sh # new
├── infra/postgres/rejoin-as-replica.sh # new: pg_rewind after a failover
├── infra/pgbouncer/pgbouncer.ini infra/pgbouncer/userlist.txt # new
├── load/lib.js load/flash-sale.js load/availability.js load/read-your-writes.js # new
├── scripts/reset-inventory.sh # new
├── 📁 services/inventory/ # new: seats, holds, three locking strategies, replica routing
├── services/catalog/src/main/resources/db/migration/V3__index_events.sql # new
├── services/gateway/src/main/resources/application.yaml # replaced: + /inventory/** route
├── services/gateway/src/main/java/boxoffice/gateway/SecurityConfig.java # replaced: GET /inventory/events/** open
└── results/lab6.md
Próximo: Lab 7 — Assíncrono, em que o order confirma as reservas por meio de eventos.
-
Martin Kleppmann e Chris Riccomini, Designing Data-Intensive Applications, 2ª ed., O'Reilly — capítulos sobre transações e replicação. ↩
-
Documentação do PostgreSQL, Explicit Locking, Log-Shipping Standby Servers e Using EXPLAIN. ↩