LiveCommerce
A flash-sale platform for live broadcasts: 1,000 purchase attempts for 100 items, zero oversells
What this project proves
- Correctness: 1,000 attempts for 100 items, repeated three times, with 0 oversells, 0 payment/sales mismatches and 0 HTTP errors; five invariants checked after every run
- Fault tolerance: stopping Kafka or the payment database for real lost 0 events and charged nobody twice (chaos tests)
- Measurement-driven speed-ups: sell-out 1 min 27 s → about 23 s, order creation p95 5.72 s → 2.68 s; the connection-pool hypothesis was rejected by measurement
- Closing a production business risk with evidence: one account could lock up all stock with unpaid orders; a partial unique index fixed it (from 15 → 0 to 15 → 5)
- Edge protection: rate limits on login, sign-up and orders, with the client IP taken from the trusted proxy chain so a forged X-Forwarded-For cannot dodge the 429 (verified in production)
- Operations: SHA-tagged deploys, health-gated rollback, a two-way drill in production, and a JWT key that keeps tokens valid across restarts
- Role
- Solo: architecture, seven services, frontend, tests, load testing, deployment and operations (127 commits)
- Period
- 2026.09 – 2026.10
- Stack
- Kotlin
- Spring Boot
- Spring Cloud Gateway
- Spring Data JPA
- PostgreSQL
- Apache Kafka
- WebSocket (STOMP)
- Flyway
- Toss Payments
- OpenTelemetry
- Jaeger
- Next.js
- TypeScript
- Tailwind CSS
- JUnit 5
- k6
- Docker
- Caddy
Key numbers
| Item | Value | Type | Evidence |
|---|---|---|---|
| Oversells (100 items, 1,000 attempts, all 3 runs) | 0 | Measured | View evidence |
| Sold = charged | 100 / 100 | Measured | View evidence |
| Order creation p95 | 2.68 s | Measured | View evidence |
| Payment submission p95 | 0.63 s | Measured | View evidence |
| Sell-out time | 23 s | Measured | View evidence |
| Stock update delivery (500 viewers) | 99.2 % | Measured | View evidence |
| Viewer-count messages reduced | 98 % | Measured | View evidence |
| Chaos tests: lost events / double charges | 0 | Measured | View evidence |
The problem
A limited sale during a live broadcast packs orders into a few seconds, and stock, payment and delivery live in different services. In a flash sale, selling out is normal, so charging first turns every sell-out into a refund and a PG fee, and one stopped service can lose events or double-charge.
Architecture
api-gateway (Spring Cloud Gateway) verifies JWTs, rewrites identity headers, and handles rate limiting and the WebSocket proxy. The Saga orchestrator in order-service holds stock for ten minutes before taking payment (AWAITING_STOCK → AWAITING_PAYMENT → PAYMENT_CONFIRMING → CONFIRMING_STOCK → COMPLETED, with a five-minute payment window). inventory (hold, confirm, release, expiry sweep), payment (Toss confirm and cancel with an Idempotency-Key), live (STOMP chat, live stock, viewer count), auth (RS256 JWT, JWKS, refresh token rotation) and delivery (shipping, HMAC courier webhooks) each own a PostgreSQL database and read one another's data only through APIs or Kafka events. Events leave through each service's transactional outbox together with a traceparent, so the whole Saga stays one Jaeger trace across Kafka.
Data model
Key constraints and indexes
- database per service: PostgreSQL 5개, 서비스 간 FK 없음, order_id / member_id UUID와 Kafka 이벤트로만 연결 (Flyway 마이그레이션 21개)
- orders.uq_orders_one_open_per_member_product: 열린 상태 partial UNIQUE (V5)
- orders.version (@Version), 인덱스 (status, updated_at), partial payment_deadline WHERE status='AWAITING_PAYMENT'
- product_stock.quantity_available CHECK (>= 0): 코드가 틀려도 DB가 초과 판매를 막음, unit_price는 서버 측
- inventory_reservations.order_id UNIQUE: 중복 이벤트가 재고를 두 번 줄이지 않음, partial index expires_at WHERE status='HELD'
- payments.order_id UNIQUE: 주문 하나에 결제 하나
- tracking_events.event_id UNIQUE: 택배 웹훅 재전송도 한 번만 적용
- refresh_tokens.token_hash UNIQUE, family_id 인덱스: 재사용된 토큰은 가족 전체를 폐기
- outbox_events.trace_parent (W3C), partial index created_at WHERE status='PENDING'
Key design decisions
Hold stock first, then charge (hold-then-pay)
- Context
- Selling out is normal in a flash sale, so charging first turns every sell-out into a refund and a PG fee.
- Decision
- Hold stock for ten minutes, open a five-minute payment window, and confirm the hold once payment is confirmed.
- Trade-off
- Stock stays locked for ten minutes, which made a one-open-order-per-buyer rule necessary.
Orchestration, not choreography
- Context
- The state machine, recovery and admin retries need one home.
- Decision
- order-service coordinates the Saga, and a recovery scheduler resumes stuck steps every five seconds.
- Trade-off
- order-service becomes the central point.
Transactional outbox, PUBLISHED only after the broker acks
- Context
- Events must survive Kafka being down.
- Decision
- State changes and events are written in one transaction; the relay sends in parallel and marks only the leading run of acknowledged events as PUBLISHED.
- Trade-off
- Duplicates can happen, so every consumer is idempotent.
Toss called outside the transaction, result written with the outbox
- Context
- A slow external API must not hold a database connection.
- Decision
- With TransactionTemplate, Toss confirmation is called outside, and the result plus outbox event are written in one transaction.
- Trade-off
- If the result is not recorded, the recovery scheduler retries with the same Idempotency-Key.
Reject sold-out early with a lock-free scalar read
- Context
- Even after sell-out every request queued on FOR UPDATE, giving a p95 of 5.8 s.
- Decision
- Read one number before locking and reject at zero. The entity is not read first, because Hibernate's first-level cache would return a stale copy at the next FOR UPDATE and open the door to oversells.
- Trade-off
- A buyer may see sold-out while stock is being returned, but oversells remain impossible.
One open order: a partial unique index
- Context
- One account could lock up all stock without paying, and the guarantee had to hold under concurrent requests too.
- Decision
- A partial unique index on (member_id, product_id) for open statuses (V5); a violation returns 409 OPEN_ORDER_EXISTS with the existing orderId.
- Trade-off
- Buyers must pay or cancel the old order before a new one, and the UI takes them back to it.
Rate limits in gateway memory, orders keyed by user
- Context
- Keeping it simple without Redis, and in Korea many buyers share one CGNAT IP.
- Decision
- Login 10/min and sign-up 5/hour by IP, orders 20/min by user; the IP is the right-most public address in the XFF chain.
- Trade-off
- It is only accurate with one gateway, and per-IP login limits do not stop attacks on one account from many IPs.
JWT key in .env, kid as an RFC 7638 thumbprint
- Context
- Tokens must stay valid across restarts and deploys and match the gateway's JWKS cache.
- Decision
- JWT_SIGNING_KEY is loaded from the server's .env (chmod 600) and the same key always yields the same kid; without the key, compose refuses to start.
- Trade-off
- Rotating the key briefly rejects older tokens.
Before and after
- What changed
- Four causes found and fixed: requests waiting on FOR UPDATE even after sell-out → a scalar read before the lock; one partition and one consumer per topic (stock-hold p95 17.8 s) → six partitions and six threads (keyed by orderId); an outbox relay sending one event at a time every 500 ms → parallel batches every 100 ms; the viewer count sent to all 500 viewers on every join (O(n²)) → at most once per second and only on change. Payment submission p95 0.71 → 0.63 s, stock hold p95 17.77 → 4.80 s, completion p95 33.55 → 11.04 s, viewer-count messages 454,583 → 8,151. The 'before' latencies are truncated because k6 stopped waiting for slow orders after 15–20 s, so the real gap is larger.
Incident write-up
- Symptom
- In production on 2026-10-09, lc-hoard-check.sh created unpaid orders back to back from one test account: 2×201 and stock 15 → 0. A single user could shut everyone else out of the broadcast's whole stock for the ten-minute hold.
- Root cause
- The Saga opened a separate hold for every POST /api/orders, and no rule anywhere said one buyer, one open order.
- Fix
- Migration V5: a partial unique index on (member_id, product_id) for the open statuses (AWAITING_STOCK, AWAITING_PAYMENT, PAYMENT_CONFIRMING, CONFIRMING_STOCK). The controller checks first; under a race, the DataIntegrityViolationException is identified by the index name and answered with 409 OPEN_ORDER_EXISTS plus the existing orderId, and the frontend resumes that order.
- Prevention
- On a local Postgres, the second of two concurrent transactions waited 1.5 s and was rejected; a production re-check gave 1×201 + 409 with stock stopping at 5. An integration test asserts exactly one 201 out of ten concurrent requests, and k6 flash-sale.js now gives each VU its own user (USERS ≥ VUS is checked).
Tests and quality
- Tests
- 46
- Coverage
- —
- CI
- —
- Test tools
- JUnit 5 (통합 24, 카오스 3, gateway 단위 14, SigningKeys 5), 실행 중인 전체 스택에 실제 HTTP, k6 3종
Deployment and operations
- Deployment
- docker-compose.prod.yml on an iwinv VPS: 16 containers (7 Spring services, Next.js, 5 PostgreSQL, Kafka KRaft, Jaeger, Caddy edge). No container publishes a port; only edge joins the external proxy network, sending /api/* and /ws/* to the gateway and everything else to the frontend. JVMs run as USER app (non-root) with per-service memory limits (Spring 512 MB, PostgreSQL 256 MB, Kafka 768 MB). PAYMENTS_FAKE_ENABLED=false. deploy.sh: conflict-marker check → git pull --ff-only → build 8 SHA-tagged images → up -d → check every service's /actuator/health and the frontend from inside edge, 60 × 3 s → on success record APP_TAG and .deploy-history. Measured on 2026-10-10: deploy 2 min 38 s (116 s build), about 27 s of downtime.
- Backups
- The server-wide cron backup (daily at 03:00, kept 7 days) shows all five livecommerce PostgreSQL databases dumped in its log; a restore test has not been run yet.
- Rollback
- rollback.sh <sha> checks that all 8 images exist, then runs up -d --no-deps --force-recreate without building. Flyway migrations only go forward (V5 only adds an index, so the old code still runs). In the production drill a rollback took 48 s (about 36 s down) and rolling forward again 39 s (about 26 s).
- Monitoring
- lc-diagnose.sh checks in one command container state, restarts/OOM, memory, 24-hour ERROR count, public endpoint latency, orders by status, orders stuck over 15 minutes, outbox PENDING, stock, database sizes and Kafka lag. On 2026-10-09: 9 days of uptime, 0 restarts, 0 OOM, 0 ERRORs, Kafka lag 0, PENDING 0. Full Saga traces in Jaeger (OpenTelemetry, 20% sampling).
Screenshots
Limits and retrospective
Current limits
- Load numbers come from one laptop (±25% variance) and are not production throughput; next: a separate load machine and a production-like environment
- flash-sale.js has not been re-measured since V5, so the before/after table uses the old script; next: re-measure a median of three with the new script
- live-service keeps viewers and sessions in memory, so it runs as one instance and viewers reconnect on restart; next: Redis pub/sub or a broker relay
- The rate limiter lives in gateway memory, so more gateways multiply the limit and a restart resets it; login limits are per IP and do not stop credential stuffing from many IPs. Next: a Redis limiter and per-account limits
- The JWT key lives in env, not KMS, with no rotation; next: rotation with two keys in the JWKS and a secret manager
- Deploys cause about 26–36 s of downtime (single instance, no rolling or blue-green), during which the gateway returns 500 instead of 503, so clients cannot tell they should retry
- outbox_events and refresh_tokens are never pruned and grow over time; next: a retention job for PUBLISHED and expired rows
- The 8 GB VPS is shared with other projects and uses 813 MB of swap, so latency can rise under memory pressure
- No CI (GitHub Actions billing), alerting, backup restore drill or frontend automated tests yet
- Payments use Toss test keys and the broadcast video is simulated
Key code
order-service/…/saga/OrderSagaOrchestrator.ktSaga state machine: hold → payment → confirm, compensation, OPEN_ORDER_EXISTS
order-service/…/saga/SagaRecoveryScheduler.ktRecovers steps stuck over 20 s and orders past their payment deadline every 5 s
order-service/…/outbox/OutboxRelay.ktParallel sends, PUBLISHED only for the acknowledged leading run, stop at the first failure
inventory-service/…/service/InventoryService.ktreserve / confirm / release / expire, lock-free sold-out rejection before FOR UPDATE
payment-service/…/gateway/TossPaymentGateway.ktToss confirm/cancel: timeouts, Idempotency-Key, mapping declines
api-gateway/…/filter/IdentityRelayFilter.ktStrips client-sent identity headers and rewrites them from the JWT
api-gateway/…/ratelimit/RateLimitFilter.kt, ClientAddress.ktRate limiting and a trusted IP from the XFF chain
auth-service/…/config/SigningKeys.ktLoads the key from env, thumbprint kid
delivery-service/…/webhook/WebhookSignature.ktHMAC signature, timestamp freshness, apply each event_id once
order-service/…/V5__one_open_order_per_member_product.sqlOne-open-order partial unique index
load-test/k6/flash-sale.js200-VU flash sale with five invariants
Current status and next steps
Three runs of 1,000 purchase attempts for 100 items (200 VUs) each ended with 0 oversells, 0 mismatches between payments and units sold, and 0 HTTP errors, with five invariants checked through the admin API after every run. Measuring the bottlenecks cut the sell-out time from 1 min 27 s to about 23 s, and a connection-pool hypothesis was rejected by measurement. In production I closed a hole where one account could hold the entire stock (15 → 0) with two unpaid orders, using a partial unique index (15 → 5); fixed a JWT key problem where, after an auth restart, the same token got different answers from different services (401/200) (0eeaf2c); and added limits to login and ordering, which had none (085ded0). I also tracked down an outbox transaction holding locks for minutes when the broker was unreachable (881afda), consumers missing new partitions after a partition increase, a k6 invariant that showed 101 sold because a hold from a previous run expired, and 14 failing integration tests caused by another project's container occupying port 8080.