Pular para conteúdo

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 | streaming
docker 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.