c. Réplica e Pool de Conexões
Durante a venda, para cada fã que reserva um assento, dezenas olham o mapa de assentos. Essas leituras não precisam do primário: uma réplica de leitura — uma cópia do banco mantida atualizada pelo streaming do write-ahead log (WAL) do primário — pode atendê-las. Dois custos vêm junto: a cópia fica um pouco atrasada (atraso de replicação), e agora você roteia consultas para dois lugares. Este passo também adiciona o PgBouncer, porque cada réplica de cada serviço abrindo o seu próprio pool de conexões é como o PostgreSQL fica sem conexões.
1. Replicação por streaming
sequenceDiagram
autonumber
participant I as inventory
participant P as db (primário)
participant R as db-replica (hot standby)
Note over R: primeira inicialização: o pg_basebackup copia o primário
I->>P: BEGIN; UPDATE seats ...; COMMIT
P->>P: grava o registro no WAL, flush
P-->>I: COMMIT ok (replicação assíncrona: não espera R)
P->>R: streaming do WAL
R->>R: reaplica (depois de recovery_min_apply_delay)
I->>R: SELECT da disponibilidade (tx readOnly)
R-->>I: resultado, possivelmente alguns ms defasado
O primário precisa de um usuário com permissão para replicar. Este script roda uma vez, quando o volume do primário é criado:
| infra/postgres/10-replication.sh |
|---|
| #!/bin/bash
# Runs once, when the primary's data directory is created (docker-entrypoint-initdb.d).
# Creates the user the replica connects with and allows replication connections.
set -e
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-SQL
CREATE ROLE replicator WITH REPLICATION LOGIN PASSWORD '${REPLICATION_PASSWORD:-replicator}';
-- pg_rewind (step 4) needs it; ALTER SYSTEM keeps it in the data directory, where pg_rewind looks
ALTER SYSTEM SET wal_log_hints = on;
SQL
echo "host replication replicator all scram-sha-256" >> "$PGDATA/pg_hba.conf"
|
A réplica clona o primário na sua primeira inicialização e depois o acompanha:
| infra/postgres/replica-entrypoint.sh |
|---|
| #!/bin/bash
# Starts a streaming replica of "db". The first time, it clones the primary with
# pg_basebackup; -R writes standby.signal + primary_conninfo, so PostgreSQL starts
# as a read-only hot standby that keeps applying the primary's WAL.
set -e
if [ ! -s "$PGDATA/PG_VERSION" ]; then
echo "cloning the primary..."
until pg_basebackup -h db -U replicator -D "$PGDATA" -R -X stream -c fast -P; do
echo "primary not ready yet, retrying in 2 s"; sleep 2
done
chmod 0700 "$PGDATA"
fi
# REPLICA_DELAY (e.g. 2s) makes the replica apply changes late: replication lag on demand;
# cluster_name is the name the primary sees (synchronous_standby_names, step 4)
exec postgres -c hot_standby=on -c recovery_min_apply_delay="${REPLICA_DELAY:-0}" -c cluster_name=db-replica
|
Recrie o volume do banco
O 10-replication.sh só roda num diretório de dados vazio. O seu volume db-data existe desde o lab 1, então recrie-o (o Flyway popula tudo de novo na inicialização): docker compose down -v antes do primeiro up deste lab. Lembre-se de chmod +x infra/postgres/*.sh.
2. PgBouncer
Cada conexão do PostgreSQL é um processo do sistema operacional com a sua própria memória; algumas centenas vão bem, milhares não. Conte os clientes: 3 réplicas do catalog × 10 + auth × 10 + inventory × 20 × 2 pools… e no lab 10 o Kubernetes vai escalar réplicas automaticamente. O PgBouncer aceita muitas conexões de cliente baratas e as multiplexa sobre um pool pequeno de conexões reais.
| infra/pgbouncer/pgbouncer.ini |
|---|
| ; PgBouncer: thousands of client connections, a few dozen real PostgreSQL connections.
[databases]
boxoffice = host=db port=5432 dbname=boxoffice
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
admin_users = boxoffice
; transaction pooling: a server connection is lent for ONE transaction, then returned
pool_mode = transaction
; real connections to PostgreSQL per (database, user) pair
default_pool_size = 20
; client connections PgBouncer accepts (all services and replicas together)
max_client_conn = 1000
; protocol-level prepared statements work in transaction mode (PgBouncer >= 1.21)
max_prepared_statements = 200
ignore_startup_parameters = extra_float_digits,options
|
infra/pgbouncer/userlist.txt"boxoffice" "boxoffice"
| Modo de pool | Uma conexão de servidor é emprestada por… | Quebra |
session | toda a conexão do cliente | nada, mas economiza pouco |
transaction | uma transação | estado de sessão: SET, advisory locks de sessão, LISTEN, tabelas temporárias |
statement | um comando | transações com vários comandos |
O pooling por transação é o que torna o PgBouncer eficaz — e é por isso que o Flyway não passa por ele: as migrações usam locks de nível de sessão. Por isso o inventory tem dois hosts: DB_HOST=pgbouncer para a aplicação e DB_MIGRATION_HOST=db para o Flyway.
3. Compose
| docker-compose.yaml |
|---|
| name: boxoffice
# settings shared by the three catalog replicas (a YAML anchor)
x-catalog: &catalog
build: ./services/catalog
image: boxoffice-catalog
environment:
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${DB_NAME:-boxoffice}
DB_USER: ${DB_USER:-boxoffice}
DB_PASSWORD: ${DB_PASSWORD:-boxoffice}
DB_POOL_SIZE: ${DB_POOL_SIZE:-10}
REDIS_HOST: redis
REDIS_PORT: 6379
CACHE_ENABLED: ${CACHE_ENABLED:-true}
CACHE_TTL_EVENT: ${CACHE_TTL_EVENT:-60s}
CACHE_TTL_LIST: ${CACHE_TTL_LIST:-10s}
CACHE_STAMPEDE_PROTECTION: ${CACHE_STAMPEDE_PROTECTION:-false}
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
deploy:
resources:
limits:
cpus: "1.0"
memory: 768M
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8082/actuator/health/readiness || exit 1"]
interval: 10s
timeout: 3s
start_period: 40s
retries: 3
services:
db:
image: postgres:17
command: ["postgres", "-c", "max_connections=${DB_MAX_CONNECTIONS:-100}"]
environment:
POSTGRES_DB: ${DB_NAME:-boxoffice}
POSTGRES_USER: ${DB_USER:-boxoffice}
POSTGRES_PASSWORD: ${DB_PASSWORD:-boxoffice}
REPLICATION_PASSWORD: ${REPLICATION_PASSWORD:-replicator}
ports:
- "5432:5432"
volumes:
- db-data:/var/lib/postgresql/data
# runs only when the volume is new: creates the "replicator" user
- ./infra/postgres/10-replication.sh:/docker-entrypoint-initdb.d/10-replication.sh:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 3s
retries: 10
# read-only streaming replica of db (hot standby)
db-replica:
image: postgres:17
user: postgres
entrypoint: ["/usr/local/bin/replica-entrypoint.sh"]
environment:
PGPASSWORD: ${REPLICATION_PASSWORD:-replicator} # used by pg_basebackup
REPLICA_DELAY: ${REPLICA_DELAY:-0} # e.g. 2s: artificial lag
ports:
- "5433:5432"
volumes:
- replica-data:/var/lib/postgresql/data
- ./infra/postgres/replica-entrypoint.sh:/usr/local/bin/replica-entrypoint.sh:ro
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "pg_isready -U boxoffice -d boxoffice"]
interval: 5s
timeout: 3s
retries: 20
pgbouncer:
image: edoburu/pgbouncer:v1.26.0-p0
ports:
- "6432:6432"
volumes:
- ./infra/pgbouncer/pgbouncer.ini:/etc/pgbouncer/pgbouncer.ini:ro
- ./infra/pgbouncer/userlist.txt:/etc/pgbouncer/userlist.txt:ro
depends_on:
db:
condition: service_healthy
redis:
image: redis:8-alpine
# a pure cache: bounded memory, evict least-recently-used keys, no persistence
command: ["redis-server", "--maxmemory", "256mb", "--maxmemory-policy", "allkeys-lru",
"--save", "", "--appendonly", "no"]
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
# three identical replicas; no "ports:" — only the edge is reachable from outside
catalog-1:
<<: *catalog
hostname: catalog-1
catalog-2:
<<: *catalog
hostname: catalog-2
catalog-3:
<<: *catalog
hostname: catalog-3
inventory:
build: ./services/inventory
environment:
DB_HOST: ${INVENTORY_DB_HOST:-pgbouncer} # writes go through PgBouncer...
DB_PORT: ${INVENTORY_DB_PORT:-6432}
DB_MIGRATION_HOST: db # ...migrations straight to the primary
DB_REPLICA_HOST: ${DB_REPLICA_HOST:-db-replica}
DB_REPLICA_PORT: 5432
DB_NAME: ${DB_NAME:-boxoffice}
DB_USER: ${DB_USER:-boxoffice}
DB_PASSWORD: ${DB_PASSWORD:-boxoffice}
DB_POOL_SIZE: ${INVENTORY_POOL_SIZE:-20}
INVENTORY_LOCKING: ${INVENTORY_LOCKING:-pessimistic}
INVENTORY_HOLD_TTL: ${INVENTORY_HOLD_TTL:-PT10M}
ports:
- "8083:8083" # published for experiments; clients go through the edge
depends_on:
db:
condition: service_healthy
db-replica:
condition: service_healthy
pgbouncer:
condition: service_started
deploy:
resources:
limits:
cpus: "1.0"
memory: 768M
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8083/actuator/health/readiness || exit 1"]
interval: 10s
timeout: 3s
start_period: 40s
retries: 3
auth:
build: ./services/auth
environment:
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${DB_NAME:-boxoffice}
DB_USER: ${DB_USER:-boxoffice}
DB_PASSWORD: ${DB_PASSWORD:-boxoffice}
JWT_ISSUER: ${JWT_ISSUER:-boxoffice-auth}
JWT_TTL: ${JWT_TTL:-PT1H}
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@boxoffice.dev}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-boxoffice-admin}
depends_on:
db:
condition: service_healthy
deploy:
resources:
limits:
cpus: "1.0"
memory: 768M
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8081/actuator/health/readiness || exit 1"]
interval: 10s
timeout: 3s
start_period: 40s
retries: 3
gateway:
build: ./services/gateway
environment:
AUTH_URL: http://auth:8081
INVENTORY_URL: http://inventory:8083
AUTH_JWKS_URI: http://auth:8081/auth/.well-known/jwks.json
JWT_ISSUER: ${JWT_ISSUER:-boxoffice-auth}
ports:
- "8080:8080" # published for experiments; clients use the edge (port 80)
depends_on:
- auth
- inventory
- catalog-1
- catalog-2
- catalog-3
deploy:
resources:
limits:
cpus: "1.0"
memory: 768M
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/actuator/health/readiness || exit 1"]
interval: 10s
timeout: 3s
start_period: 40s
retries: 3
edge:
image: nginx:1.30-alpine
ports:
- "80:80"
volumes:
- ./infra/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- gateway
volumes:
db-data:
replica-data:
|
chmod +x infra/postgres/*.sh
docker compose down -v
docker compose up --build -d
docker compose exec db psql -U boxoffice -c "SELECT client_addr, state, sent_lsn, replay_lsn FROM pg_stat_replication"
docker compose exec db-replica psql -U boxoffice -c "SELECT pg_is_in_recovery(), now() - pg_last_xact_replay_timestamp() AS lag"
A coluna lag é o tempo desde que a réplica reaplicou a última transação: enquanto o primário está ocioso, ela continua crescendo, mesmo com a réplica totalmente em dia. Compare sent_lsn e replay_lsn no primário para ver a distância real.
docker compose exec db psql -U boxoffice -c "SELECT client_addr, state FROM pg_stat_replication" client_addr | state
-------------+-----------
172.22.0.4 | streamingdocker compose exec db-replica psql -U boxoffice -c "INSERT INTO inventory.holds VALUES (gen_random_uuid(), 1, 'x', 'ACTIVE', now(), now())"ERROR: cannot execute INSERT in a read-only transaction
4. Roteando leituras para a réplica
| services/inventory/src/main/java/boxoffice/inventory/DataSourceConfig.java |
|---|
| package boxoffice.inventory;
import com.zaxxer.hikari.HikariDataSource;
import javax.sql.DataSource;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy;
/**
* Two connection pools — primary and replica — behind one DataSource.
* A transaction marked @Transactional(readOnly = true) gets a replica connection;
* every other transaction gets a primary connection. The choice happens when the
* transaction runs its first statement (that is what "lazy" means here).
*/
@Configuration
public class DataSourceConfig {
@Bean
@ConfigurationProperties("inventory.datasource.primary")
HikariDataSource primaryDataSource() {
return new HikariDataSource();
}
@Bean
@ConfigurationProperties("inventory.datasource.replica")
HikariDataSource replicaDataSource() {
return new HikariDataSource();
}
@Bean
@Primary
DataSource dataSource(HikariDataSource primaryDataSource, HikariDataSource replicaDataSource) {
var routing = new LazyConnectionDataSourceProxy(primaryDataSource);
routing.setReadOnlyDataSource(replicaDataSource); // Spring Framework 6.2+
return routing;
}
}
|
O LazyConnectionDataSourceProxy entrega uma conexão provisória quando uma transação começa e busca a real no primeiro comando — a essa altura o Spring já sabe se a transação é readOnly e escolhe o pool. A regra vira uma anotação:
| Método | Anotação | Vai para | Por quê |
SeatQueries.availability, seats | @Transactional(readOnly = true) | réplica | navegação; alguns ms de defasagem não fazem mal |
HoldService.hold, confirm, release | @Transactional | primário | escritas |
HoldService.find | @Transactional | primário | read-your-writes: o fã acabou de criar esta reserva |
SeatQueries.doubleBookedSeats | @Transactional | primário | uma auditoria não pode estar defasada |
Os dois pools publicam métricas (hikaricp.connections.active{pool=primary|replica}): confira-as enquanto um teste roda, curl -s 'localhost:8083/actuator/metrics/hikaricp.connections.active?tag=pool:replica' | jq.
5. Experimentos
Alívio pela réplica. Rode a carga de leitura contra a réplica, depois aponte o pool "replica" para o primário e rode de novo — sem mudar código:
| load/availability.js |
|---|
| // Lab 6: read traffic on the seat map during the sale. These GETs are readOnly
// transactions, so they run on the replica (or on the primary, if you point
// DB_REPLICA_HOST at it to compare).
// k6 run -e RATE=200 load/availability.js
import http from 'k6/http';
import { check } from 'k6';
import { BASE_URL } from './lib.js';
const RATE = Number(__ENV.RATE || 200);
export const options = {
scenarios: {
readers: {
executor: 'constant-arrival-rate', rate: RATE, timeUnit: '1s', duration: __ENV.DURATION || '1m',
preAllocatedVUs: Math.max(20, RATE), maxVUs: Math.max(200, RATE * 10),
},
},
summaryTrendStats: ['avg', 'med', 'p(95)', 'p(99)', 'max'],
thresholds: {
http_req_failed: ['rate<0.01'],
'http_req_duration{name:availability}': ['p(99)<1000'],
'http_req_duration{name:section}': ['p(99)<1000'],
},
};
export default function () {
if (Math.random() < 0.5) {
const res = http.get(`${BASE_URL}/inventory/events/1/availability`, { tags: { name: 'availability' } });
check(res, { 'availability is 200': (r) => r.status === 200 });
} else {
const section = `S${String(1 + Math.floor(Math.random() * 30)).padStart(2, '0')}`;
const res = http.get(`${BASE_URL}/inventory/events/1/seats?section=${section}`, { tags: { name: 'section' } });
check(res, { 'section is 200': (r) => r.status === 200 });
}
}
|
k6 run -q -e RATE=200 -e DURATION=30s load/availability.js > /dev/null # warm-up: the seat-map queries are still cold
k6 run -e RATE=200 load/availability.js # reads on db-replica
DB_REPLICA_HOST=db docker compose up -d --wait inventory # --wait: until the recreated inventory is healthy
k6 run -q -e RATE=200 -e DURATION=30s load/availability.js > /dev/null # warm-up: a new JVM
k6 run -e RATE=200 load/availability.js # reads on the primary
docker compose up -d --wait inventory # back to the replica
Deixe o docker stats aberto: a CPU passa do db para o db-replica. A latência do mapa de assentos quase não muda — uma réplica não deixa uma consulta mais rápida, ela acrescenta capacidade e impede que as leituras disputem recursos com as escritas da venda.
Read-your-writes. Um fã reserva um assento e recarrega na hora o mapa de assentos, atendido pela réplica:
| load/read-your-writes.js |
|---|
| // Lab 6: hold a seat, then immediately look at the seat map (served by the replica).
// Does the fan see their own seat as HELD? With replication lag, sometimes not.
// REPLICA_DELAY=1s docker compose up -d db-replica (artificial lag)
// bash scripts/reset-inventory.sh && k6 run load/read-your-writes.js
import http from 'k6/http';
import { check } from 'k6';
import { Rate } from 'k6/metrics';
import { BASE_URL, loginFans, authHeaders } from './lib.js';
http.setResponseCallback(http.expectedStatuses(200, 201, 204, 409));
const stale = new Rate('stale_reads');
export const options = {
vus: 10,
iterations: 200,
thresholds: { stale_reads: ['rate>=0'] },
};
export function setup() {
const seats = http.get(`${BASE_URL}/inventory/events/2/seats?section=S01`).json();
return { tokens: loginFans(10), seatIds: seats.map((s) => s.id) };
}
export default function (data) {
const token = data.tokens[__VU % data.tokens.length];
const seatId = data.seatIds[Math.floor(Math.random() * data.seatIds.length)];
const hold = http.post(`${BASE_URL}/inventory/holds`, JSON.stringify({ eventId: 2, seatIds: [seatId] }), authHeaders(token));
if (hold.status !== 201) {
return; // seat already taken: try another one
}
// read-after-write: the seat map comes from the replica
const map = http.get(`${BASE_URL}/inventory/events/2/seats?section=S01`).json();
const seat = map.find((s) => s.id === seatId);
stale.add(seat.status !== 'HELD');
check(seat, { 'my seat shows as HELD': (s) => s.status === 'HELD' });
// and from the primary (GET /holds/{id} is not readOnly): always up to date
const mine = http.get(`${BASE_URL}/inventory/holds/${hold.json('id')}`, authHeaders(token));
check(mine, { 'my hold is visible on the primary': (r) => r.status === 200 });
http.del(`${BASE_URL}/inventory/holds/${hold.json('id')}`, null, authHeaders(token));
}
|
bash scripts/reset-inventory.sh && k6 run load/read-your-writes.js # lag ~ milliseconds
REPLICA_DELAY=1s docker compose up -d db-replica && sleep 10
bash scripts/reset-inventory.sh && k6 run load/read-your-writes.js # lag of 1 second
REPLICA_DELAY=0 docker compose up -d db-replica
Mesmo sem atraso artificial, stale_reads não é zero: numa execução de teste deste lab, ficou em cerca de 6% — a réplica na mesma máquina está só milissegundos atrás, mas a próxima requisição do fã também chega em milissegundos. Com REPLICA_DELAY=1s, ficou em 98%: quase nenhum fã viu a própria reserva no mapa de assentos, embora o GET /inventory/holds/{id} (primário) a mostre. Em produção, o atraso cresce com o volume de escritas, a distância de rede e consultas longas na réplica — exatamente durante uma venda relâmpago.
Saídas: ler as suas próprias escritas recentes do primário (o que o find faz), rotear um usuário para o primário por alguns segundos depois de ele escrever (leituras "sticky"), ou esperar até a réplica ter reaplicado o LSN (log sequence number) da escrita.
PgBouncer. Conte as conexões durante a venda relâmpago, com e sem o pooler:
# during load/flash-sale.js:
docker compose exec db psql -U boxoffice -c "SELECT usename, application_name, count(*) FROM pg_stat_activity GROUP BY 1, 2"
docker compose exec -e PGPASSWORD=boxoffice db psql -h pgbouncer -p 6432 -U boxoffice pgbouncer -c "SHOW POOLS"
# without PgBouncer:
INVENTORY_DB_HOST=db INVENTORY_DB_PORT=5432 docker compose up -d inventory
Numa execução de teste deste lab, o pg_stat_activity mostrou cerca de 60 conexões da aplicação: 30 das três réplicas do catalog, 10 do auth e 20 do PgBouncer — que atendeu todas as conexões de cliente do inventory. O SHOW POOLS mostrou cl_active 20 contra sv_active + sv_idle + sv_used ≤ 20.
Force a barra: reinicie com DB_MAX_CONNECTIONS=80 INVENTORY_POOL_SIZE=40 docker compose up -d. Pelo PgBouncer tudo funciona (o PostgreSQL continua vendo no máximo 20 conexões vindas dele). Direto contra o db (acrescente INVENTORY_DB_HOST=db INVENTORY_DB_PORT=5432 a esse mesmo comando), 30 + 10 + 40 conexões, mais as ociosas que o PgBouncer ainda mantém abertas, passam de 80 (boxoffice é superusuário, então os slots reservados ao superusuário não ajudam): o PostgreSQL recusa toda conexão nova com FATAL: sorry, too many clients already (docker compose logs db). O pool do inventory para de crescer antes de 40 (31 numa execução de teste deste lab), e todo outro cliente novo também é recusado — inclusive o psql e o scripts/reset-inventory.sh. Um pooler transforma "clientes demais" em fila (cl_waiting). Depois, volte ao padrão: docker compose up -d --wait.
Na entrevista
"Adicionar réplicas de leitura" só é resposta para carga com muita leitura que tolera defasagem. Diga quais leituras vão para as réplicas, como você trata o read-your-writes e o que acontece com as escritas — as réplicas não fazem nada por elas; isso exige particionamento (aula 6) ou menos trabalho por escrita.
Próximo: 3. Índices e EXPLAIN.