Pular para conteúdo

4. Sala de Espera

Às 10:00, 2 milhões de fãs apertam "Comprar" para 60 mil assentos. O Kafka protege notification da rajada, mas a rajada atinge primeiro o caminho síncrono: reservas de assentos e pedidos, apoiados em um inventário fortemente consistente que não escala para 2 milhões de requisições por segundo. A resposta usual de sites de ingressos e de lançamentos de tênis é uma sala de espera virtual: uma fila na frente do sistema que deixa os fãs entrarem no ritmo que o sistema consegue atender, em ordem de chegada, e diz a todos os outros a sua posição com honestidade.

flowchart LR
    fans["2 M de fãs"] -->|POST join| wr["sala de espera (FIFO no Redis)"]
    wr -->|"posição, ETA"| fans
    adm["admissor (a cada 1 s)"] -->|"ZPOPMIN N"| wr
    adm -->|"ticket admitido, TTL 5 min"| redis[(Redis)]
    fans -->|"POST /orders + X-Admission-Ticket"| order[order]
    order -->|"ticket admitido?"| redis

Isso é nivelamento de carga por fila (queue-based load leveling): as chegadas vêm em rajadas, o atendimento é constante, e a fila guarda a diferença. A Lei de Little dá a estimativa honesta que o fã vê:

\[ \text{espera} \approx \frac{\text{posição na fila}}{\text{taxa de admissão}} \qquad \text{ex.: } \frac{30\,000}{500\ \text{fãs/s}} = 60\ \text{s} \]

Projeto

Decisão Escolha aqui Por quê
Armazenamento da fila um sorted set do Redis por show, score = sequência de chegada entrada e posição em O(log n), retirada atômica dos N primeiros (ZPOPMIN)
Justiça sequência vinda do INCR, não do relógio do cliente o relógio dos clientes mente; quem decide a ordem é o servidor
Admissão a cada segundo, retirar admit-per-second tickets e marcá-los como admitidos com TTL a taxa é o botão de controle; o TTL libera a capacidade de quem desiste
Barreira POST /orders exige um X-Admission-Ticket admitido enquanto a venda está aberta sem fiscalização, robôs furam a fila
Posição o cliente consulta GET /tickets/{id} periodicamente simples; em escala, push por SSE/WebSocket ou long polling

Onde fica a barreira

A barreira precisa cobrir todos os passos caros da venda (reservas de assentos no inventory e pedidos); por isso, em produção, ela vive na borda: um filtro no gateway (lab 4) ou um produto de sala de espera de CDN. Este lab a aplica em order para ficar pequeno; movê-la para o gateway é uma boa extensão.

Código (no serviço order)

services/order/src/main/java/boxoffice/order/waitingroom/WaitingRoom.java
package boxoffice.order.waitingroom;

import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Component
public class WaitingRoom {

    private static final String OPEN_SHOWS = "wr:open";

    private final StringRedisTemplate redis;
    private final WaitingRoomProperties props;

    public WaitingRoom(StringRedisTemplate redis, WaitingRoomProperties props) {
        this.redis = redis;
        this.props = props;
    }

    private static String queue(String showId) { return "wr:" + showId + ":queue"; }
    private static String seq(String showId) { return "wr:" + showId + ":seq"; }
    private static String admitted(String showId, String ticket) { return "wr:" + showId + ":admitted:" + ticket; }

    public void open(String showId) { redis.opsForSet().add(OPEN_SHOWS, showId); }

    public void close(String showId) { redis.opsForSet().remove(OPEN_SHOWS, showId); }

    public boolean isOpen(String showId) {
        return Boolean.TRUE.equals(redis.opsForSet().isMember(OPEN_SHOWS, showId));
    }

    public Ticket join(String showId) {
        String ticket = UUID.randomUUID().toString();
        Long arrival = redis.opsForValue().increment(seq(showId)); // the server decides the order
        redis.opsForZSet().add(queue(showId), ticket, arrival);
        return status(showId, ticket);
    }

    public Ticket status(String showId, String ticket) {
        // queue first: the admitter moves a ticket from the queue to "admitted" in one atomic step
        Long rank = redis.opsForZSet().rank(queue(showId), ticket);
        if (rank != null) {
            long position = rank + 1;
            long eta = (long) Math.ceil((double) position / props.admitPerSecond()); // Little's Law
            return Ticket.waiting(ticket, position, eta);
        }
        String key = admitted(showId, ticket);
        if (Boolean.TRUE.equals(redis.hasKey(key))) {
            return Ticket.admitted(ticket, redis.getExpire(key, TimeUnit.SECONDS));
        }
        return Ticket.unknown(ticket); // never joined, or admission expired
    }

    /** ZPOPMIN + SET in one Lua script: a polling fan never sees a ticket that is neither queued nor admitted. */
    private static final RedisScript<Long> POP_AND_ADMIT = RedisScript.of("""
            local popped = redis.call('ZPOPMIN', KEYS[1], ARGV[1])
            for i = 1, #popped, 2 do
                redis.call('SET', ARGV[2] .. popped[i], '1', 'EX', ARGV[3])
            end
            return #popped / 2
            """, Long.class);

    /** Runs on ONE instance only (see pitfalls): N replicas would admit N times the rate. */
    @Scheduled(fixedRate = 1000)
    public void admit() {
        Set<String> shows = redis.opsForSet().members(OPEN_SHOWS);
        if (shows == null) {
            return;
        }
        for (String showId : shows) {
            redis.execute(POP_AND_ADMIT, List.of(queue(showId)), String.valueOf(props.admitPerSecond()),
                    admitted(showId, ""), String.valueOf(props.admissionTtl().toSeconds()));
        }
    }

    public boolean mayEnter(String showId, String ticket) {
        if (!isOpen(showId)) {
            return true; // no on-sale running for this show: no gate
        }
        return ticket != null && Boolean.TRUE.equals(redis.hasKey(admitted(showId, ticket)));
    }
}
services/order/src/main/java/boxoffice/order/waitingroom/Ticket.java
package boxoffice.order.waitingroom;

import com.fasterxml.jackson.annotation.JsonInclude;

@JsonInclude(JsonInclude.Include.NON_NULL)
public record Ticket(String ticketId, String status, Long position, Long etaSeconds, Long expiresInSeconds) {

    static Ticket waiting(String id, long position, long eta) {
        return new Ticket(id, "WAITING", position, eta, null);
    }

    static Ticket admitted(String id, Long expiresIn) {
        return new Ticket(id, "ADMITTED", null, null, expiresIn);
    }

    static Ticket unknown(String id) {
        return new Ticket(id, "UNKNOWN", null, null, null);
    }
}
services/order/src/main/java/boxoffice/order/waitingroom/WaitingRoomProperties.java
package boxoffice.order.waitingroom;

import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;

@ConfigurationProperties(prefix = "boxoffice.waiting-room")
public record WaitingRoomProperties(
        @DefaultValue("20") int admitPerSecond,
        @DefaultValue("5m") Duration admissionTtl) {
}
services/order/src/main/java/boxoffice/order/waitingroom/WaitingRoomController.java
package boxoffice.order.waitingroom;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/orders/waiting-room/{showId}")
public class WaitingRoomController {

    private final WaitingRoom room;

    public WaitingRoomController(WaitingRoom room) {
        this.room = room;
    }

    /** Operator endpoints: in production, protect them (admin role at the gateway). */
    @PostMapping("/open")
    public ResponseEntity<Void> open(@PathVariable String showId) {
        room.open(showId);
        return ResponseEntity.noContent().build();
    }

    @PostMapping("/close")
    public ResponseEntity<Void> close(@PathVariable String showId) {
        room.close(showId);
        return ResponseEntity.noContent().build();
    }

    @PostMapping("/join")
    public ResponseEntity<Ticket> join(@PathVariable String showId) {
        return ResponseEntity.status(HttpStatus.ACCEPTED).body(room.join(showId));
    }

    @GetMapping("/tickets/{ticketId}")
    public ResponseEntity<Ticket> ticket(@PathVariable String showId, @PathVariable String ticketId) {
        Ticket t = room.status(showId, ticketId);
        return "UNKNOWN".equals(t.status()) ? ResponseEntity.status(HttpStatus.NOT_FOUND).body(t)
                                            : ResponseEntity.ok(t);
    }
}
services/order/src/main/java/boxoffice/order/OrderController.java
package boxoffice.order;

import boxoffice.order.waitingroom.WaitingRoom;
import jakarta.validation.Valid;
import java.net.URI;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;

@RestController
@RequestMapping("/orders")
public class OrderController {

    private final OrderService orders;
    private final WaitingRoom waitingRoom;

    public OrderController(OrderService orders, WaitingRoom waitingRoom) {
        this.orders = orders;
        this.waitingRoom = waitingRoom;
    }

    @PostMapping
    public ResponseEntity<OrderView> place(
            @Valid @RequestBody OrderRequest req,
            @RequestHeader(name = "X-Admission-Ticket", required = false) String ticket) {
        if (!waitingRoom.mayEnter(req.showId(), ticket)) {
            throw new ResponseStatusException(HttpStatus.FORBIDDEN,
                    "on-sale in progress: join /orders/waiting-room/" + req.showId() + "/join first");
        }
        OrderService.PlaceResult result = orders.place(req);
        if (!result.created()) {
            return ResponseEntity.ok(result.order());
        }
        return ResponseEntity.created(URI.create("/orders/" + result.order().id())).body(result.order());
    }

    @GetMapping("/{id}")
    public OrderView get(@PathVariable UUID id) {
        return orders.find(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
    }
}

As propriedades da sala de espera já estão no services/order/src/main/resources/application.yaml completo do passo 2; confira que o seu tem este bloco (adicione-o se escreveu o arquivo à mão):

services/order/src/main/resources/application.yaml (additions)
boxoffice:
  waiting-room:
    admit-per-second: ${ADMIT_PER_SECOND:20}
    admission-ttl: 5m

Experimente

docker compose up -d --build --wait ordercurl -s -X POST localhost:8084/orders/waiting-room/show-42/open -o /dev/null -w '%{http_code}\n'204curl -s -X POST localhost:8084/orders/waiting-room/show-42/join{"ticketId":"0c6f3b8e-0f2a-4a8e-9d55-1b7c5b3c9e21","status":"WAITING","position":1,"etaSeconds":1}curl -s localhost:8084/orders/waiting-room/show-42/tickets/0c6f3b8e-0f2a-4a8e-9d55-1b7c5b3c9e21{"ticketId":"0c6f3b8e-0f2a-4a8e-9d55-1b7c5b3c9e21","status":"ADMITTED","expiresInSeconds":298}curl -s -X POST localhost:8084/orders -H 'Content-Type: application/json' \ -d '{"showId":"show-42","holdId":"hold-0100","customerEmail":"caio@example.com","amount":350.00}' \ -o /dev/null -w '%{http_code}\n'403curl -s -X POST localhost:8084/orders -H 'Content-Type: application/json' \ -H 'X-Admission-Ticket: 0c6f3b8e-0f2a-4a8e-9d55-1b7c5b3c9e21' \ -d '{"showId":"show-42","holdId":"hold-0100","customerEmail":"caio@example.com","amount":350.00}' \ -o /dev/null -w '%{http_code}\n'201

Espie a estrutura de dados enquanto um pico do k6 roda (passo 5):

docker compose exec redis redis-cli ZCARD wr:show-42:queue
docker compose exec redis redis-cli ZRANGE wr:show-42:queue 0 4 WITHSCORES

Armadilhas

  • O admissor precisa rodar uma vez só. Com 3 réplicas de order, três admissores retiram 3 × N por segundo. Rode-o em uma única instância (um worker dedicado), eleja um líder ou use uma trava distribuída (por exemplo, ShedLock) — veja a discussão sobre agendadores.
  • Admitido ≠ comprou. O TTL devolve vagas abandonadas; um admissor mais esperto admite capacidade − compradores ativos em vez de um N fixo.
  • A própria sala de espera precisa escalar. Entrar e consultar são operações baratas no Redis, mas 2 M de fãs consultando a cada segundo são 2 M de requisições por segundo: consulte com menos frequência quando a posição está longe, sirva a página de espera pela CDN e mantenha a fila em um Redis próprio.
  • Robôs entram muitas vezes. Limite as entradas por cliente e exija um desafio (WAF) antes de entrar.

Próximo: Meça