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ê:
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)
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)));
}
}
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);
}
}
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) {
}
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);
}
}
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):
boxoffice:
waiting-room:
admit-per-second: ${ADMIT_PER_SECOND:20}
admission-ttl: 5m
Experimente
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 ativosem 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.