Project case study
SeatFlow · 影院选座票务系统
以 Redis 为座位状态权威源、覆盖高并发锁座、超时释放、支付幂等与对账收敛的经典 Java 后端项目。
一句话概括:一千个人抢同一场电影的一百个座位——系统要保证不超卖、不漏还、不乱账,并且能拿出证据。
SeatFlow 是一个影院选座票务系统:用户浏览场次、在座位图上选座、下单、用内置的模拟网关完成支付;运营侧管理影片、影厅座位模板与场次排期,并查看订单与销售统计。项目围绕一个经典的高并发命题展开:把「并发正确性」从口号变成可验证的工程——Redis 上的 Lua 原子脚本负责同一瞬间的座位裁决,数据库唯一索引是最后防线,RocketMQ 延迟消息负责超时释放,定时对账把两层存储的漂移收敛回一致。
一、项目概况
票务系统的核心难点不是功能数量,而是三个正确性命题:
- 不超卖:同一场次同一座位,最多产生一个有效订单;
- 不漏还:订单十五分钟未支付,座位必须自动回到可售,且恰好释放一次;
- 不乱账:支付回调可能重复、迟到、乱序;订单状态、支付状态与座位状态三者最终一致。
围绕这三个命题,项目刻意排除了与主题无关的范围:不做微服务拆分、不做分库分表、不做优惠券与会员、不接真实支付渠道、鉴权用轻量 JWT 而不是 Spring Security 全套。取舍原则只有一条:所有进入范围的技术点,都必须能讲清楚它解决哪个正确性问题、代价是什么。
二、总体架构
flowchart LR
FE[Vue 3 前端] -->|REST| WEB[seatflow-web]
WEB --> SVC[seatflow-service]
SVC --> DAO[seatflow-dao]
DAO --> DB[(MySQL 8)]
SVC --> REDIS[(Redis 7)]
SVC <-->|延迟消息 / 消费| MQ[(RocketMQ 5)]
SVC --> PROM[Prometheus 指标]
后端是 Maven 多模块单应用(web → service → dao → common 单向依赖),MySQL 通过 Flyway 管理十张表的迁移;Redis 承担四类职责:座位状态(Hash,值为 0/1/2)、限购计数、限流令牌桶与目录缓存;RocketMQ 承载订单超时消息;Micrometer 把锁座成功率、订单创建、回调结果、对账修复量暴露为 Prometheus 指标;TraceId 从 HTTP 入口透传到消息属性再到消费者,贯穿整条链路。前端是 Vue 3 + Element Plus 的七个页面:用户端的登录、场次、选座、订单,管理端的影片、影厅/场次、订单统计。
三、核心链路
一次下单的端到端流程:
sequenceDiagram
participant U as 用户
participant W as 后端
participant R as Redis(Lua)
participant D as MySQL
participant Q as RocketMQ
U->>W: 提交 showId + 座位
W->>R: 令牌桶限流 + 原子预占(检查/锁定/计数)
R-->>W: 成功或失败座位列表
W->>D: 写入订单与座位占用(唯一索引兜底)
W->>Q: 提交后发送 15 分钟延迟消息
Q-->>W: 到期投递(未到期则重投剩余时长)
W->>R: 释放座位 / 置为已售
座位图上四种状态一目了然:绿色可售、黄色锁定(他人待支付)、红色已售、蓝色已选:

支付走内置模拟网关,与真实渠道共用同一条 HMAC 验签与幂等链路:回调以 callbackId 唯一键、支付单/订单状态机 CAS、座位 1→2 幂等写三层挡重复,重复回调不早退、顺带自愈 Redis 漂移。超时释放由 RocketMQ 延迟消息驱动:十五分钟按 10 分钟 + 5 分钟两级投递,未到期自动重投;支付、超时、用户取消三方同时发生时,由条件更新保证只有一方成功。对账任务每五分钟一轮,修复五类 Redis/MySQL 不一致:超时未取消、已支付座位非已售、座位状态丢失重建、锁定孤儿、已售孤儿。
四、用户端与管理端
用户端订单页同时呈现两种状态——待支付(可继续支付或取消)与已支付:

管理端提供订单查询与当日统计:今日订单数、今日营收与锁座成功率(来自 Micrometer 计数器),下图是真实运行时的数据,包含压力测试产生的订单:

五、压测与验收
- 测试:
./mvnw verify全绿(12 个单元测试 + 102 个集成测试,使用 Testcontainers 起真实 MySQL/Redis);其中并发用例以 1000 个调用者验证「抢 1 座恰 1 成功、抢 100 座恰 100 成功」,断言精确计数与数据库无重复占用; - 端到端冒烟:脚本真实走通「注册 → 选座 → 下单 → 模拟支付 → 订单已支付」与「下单不支付 → 10 秒后自动取消并释放座位」;
- JMeter 混合场景:450 浏览线程 + 50 下单线程,共 5505 个请求、351.7 QPS、下单接口 P99 43ms、错误率 0.00%;
- 超卖校验:压测后执行重复占用查询(
GROUP BY show_id, seat_id HAVING COUNT(*) > 1),返回 0 行。
压测为单机环境(应用与压测同机),数据仅用于相对比较与功能验证,不作为容量结论。
六、已知限制与设计取舍
- 对账与在途下单存在极小竞态窗口:对账按「数据库此刻无占用」释放锁定座位,而下单是「Redis 先锁、数据库后提交」,两者之间存在毫秒级窗口,可能把在途座位短暂显示为可售。数据库唯一索引保证不会重复售出,后续对账会把已支付座位修复为永久态;为保住下单路径的并发度,未给下单增加额外锁,风险接受。
- 订单支付时间以支付单为准:订单表的
paid_at不做写入,统计查询关联支付单的paid_at;订单表字段预留给后续退款与对账扩展。 - 退款未实现:已取消订单收到支付成功回调时只记录差异指标与告警日志,自动退款规划为后续迭代。
- 可观测性留白:Grafana 容器已随 compose 启动但未预置面板(指标已由
/actuator/prometheus暴露),慢 SQL 日志未接入;两者都在后续计划内。 - 本地演示的安全姿态:
/actuator/**在内网演示环境不做鉴权,生产部署需要限制访问;JWT 与 HMAC 密钥必须通过环境变量注入 32 字节以上的值。
Articles
相关文章
座位是怎么锁住的
从 Lua 原子预占到数据库唯一索引,拆解高并发选座的正确性来源、失败回滚与并发验证。
订单超时之后
RocketMQ 延迟消息的分级重投、re-arm 策略与取消链路上的状态机竞态。
回调为什么必须幂等
从验签、唯一键到状态机与自愈,拆解支付回调链路上的三层幂等。
当 Redis 和 MySQL 各执一词
五类不一致的发现与修复、批量与锁的边界,以及一处被接受的风险。
电影可以缓存,座位不行
二级缓存、逻辑过期与 SETNX 重建,以及哪些数据永远不该进缓存。