Flash Sale Ticket Booking API
A Go and MySQL ticket-booking API designed to never oversell, even when thousands of people book the same ticket at the same moment. It uses a queue with batch processing, idempotency keys, and backpressure.
Overview
This API is a backend I built to solve one hard problem: selling a limited number of tickets correctly during a flash sale. Thousands of requests can arrive for the same ticket within seconds, and the system must never sell more tickets than the stock, never book twice when a client retries, and stay responsive instead of running out of database connections. It is written in Go using only the standard library net/http (no framework or router), with a single dependency: the MySQL driver. How it works: a booking does not run its own transaction. POST /book first validates the request and inserts a row into a booking queue table, then waits briefly for the result. A background worker drains the queue per ticket, oldest first (FIFO), and processes up to 200 orders in one database transaction. It locks the ticket row, decides every order in memory with a pure function (DecideBatch: enough stock and at most 4 tickets per user means success), then writes one stock UPDATE, one bulk INSERT, and the status updates before committing. If anything fails mid-batch, the whole transaction rolls back and the orders stay pending to be retried. If the result is not ready within the wait window, the API answers 202 with a status URL that the client can poll. Correctness is layered on purpose. Stock is decremented with UPDATE ... WHERE stock >= ? inside a transaction that locks the ticket row, and a CHECK (stock >= 0) constraint in MySQL is the last line of defence. Idempotency keys are issued by the server, bound to one user, and expire after 15 minutes. Repeating a request with the same key replays the same result, and reusing a key with a different payload is rejected with 422. The result per key is final, which keeps the idempotency rules simple. Transactions run at READ COMMITTED so the per-user total sees batches that have just committed. Data model (see the ERD in the gallery): four InnoDB tables. tickets holds the stock with the CHECK (stock >= 0) constraint. bookings stores the confirmed orders, with a real foreign key to tickets and a unique idempotency_key. booking_queue is the work queue: each row carries a status (pending, success, or failed), a fail reason, a payload hash for conflict detection, and a unique idempotency_key, and it is indexed by (ticket_id, status, id), which matches how the worker reads the oldest pending rows of one ticket. idempotency_keys stores the server-issued keys with their owner and expiry. Only bookings.ticket_id is an enforced foreign key. The queue and the keys are linked to tickets and to each other by value (ticket_id and idempotency_key) without constraints, which the ERD shows as dashed lines. Throughput and protection: the queue per ticket is capped relative to its stock, so a spike gets 429 (with Retry-After) instead of exhausting connections, and a quick stock check rejects sold-out or non-existent tickets with 409/404 without touching the queue. An optional per-IP token-bucket rate limiter protects the endpoints, with separate quotas for issuing keys and booking. Several server instances can run side by side: a MySQL advisory lock (GET_LOCK) per ticket keeps one instance on a ticket at a time, with a heartbeat and a short session wait_timeout so a crashed lock holder is evicted within about 30 seconds rather than hours. The lock is only an efficiency measure. Correctness comes from the row lock, so a lost lock never corrupts data. A fairness rule caps each turn at 10 batches and serves the longest-waiting ticket first, so busy tickets cannot starve others. Operations and quality: GET /healthz checks the database and the worker loop (queue length is deliberately not a health criterion, since a legitimate burst would get the instance removed from the load balancer), and GET /metrics exposes Prometheus-format metrics without extra dependencies, including queue depth and the age of the oldest pending order. Shutdown is graceful: in-flight requests are answered before the worker stops. The code follows clean architecture (handler, service, repository, all depending on interfaces in the domain package) and is covered by about 100 test functions, including concurrency tests with the race detector against a real MySQL, advisory-lock failure scenarios, and a load test. GitHub Actions runs format, vet, staticcheck, and the full suite on MySQL 8. An embedded test UI lets you run the booking flow, 10 self-checking scenarios, and a server-side load test from the browser. Measured on a single local machine (MySQL on the same host, 50-connection pool): the earlier one-transaction-per-booking design reached about 180 bookings per second on one ticket. With the queue and batches of 200, 5,000 simultaneous bookings on one ticket were all answered in roughly 1.2 to 1.6 seconds (about 3,000 to 4,200 per second). With 100,000 simultaneous requests for the last remaining ticket, exactly one succeeded, stock ended at 0, and every request was answered within about 5 seconds. Numbers on other hardware will differ. Scope: this is a portfolio and learning project. Booking, idempotency, queueing, and concurrency are complete and tested. Authentication, payment, ticket holds with expiry, cancellations, and event or order-history endpoints are not implemented yet, and user_id is currently taken from the request body.
Impact
Demonstrates a booking backend that stays correct under extreme contention: in local load tests, 100,000 simultaneous requests for the last ticket produced exactly one sale and zero oversell, and batching raised throughput on a single ticket from about 180 to roughly 3,000-4,200 bookings per second.
What I Learned
I learned to treat concurrency as a correctness problem first and a speed problem second: combining row locks, conditional updates, and database constraints, choosing READ COMMITTED deliberately, and measuring before and after batching. I also practiced designing idempotent APIs, backpressure, multi-instance coordination with advisory locks, and clean architecture in Go, and I documented the known limitations honestly instead of hiding them.
Key Features
- Anti-oversell: stock decrement guarded by a row lock, a conditional UPDATE, and a CHECK constraint
- Server-issued idempotency keys bound to a user (15-minute TTL), with payload-conflict detection
- MySQL-backed booking queue, processed in batches of up to 200 per transaction in FIFO order
- Backpressure: bounded queue per ticket and 429 with Retry-After under overload
- Multi-instance safe via per-ticket advisory locks with heartbeat and zombie-holder eviction
- Fair scheduling so busy tickets do not starve others
- Async-friendly responses: 200/409 when ready, or 202 with a polling status URL
- Health check, Prometheus metrics, per-IP rate limiting, and graceful shutdown
- About 100 tests with the race detector, a load test, and a CI pipeline on MySQL 8
- Built-in browser UI for scenarios and server-side load testing