2026-08-18

Project case study

SeatFlow · 影院选座票务系统

以 Redis 为座位状态权威源、覆盖高并发锁座、超时释放、支付幂等与对账收敛的经典 Java 后端项目。

Java 21Spring BootRedisRocketMQMySQLVue 3

一句话概括:一千个人抢同一场电影的一百个座位——系统要保证不超卖、不漏还、不乱账,并且能拿出证据。

SeatFlow 是一个影院选座票务系统:用户浏览场次、在座位图上选座、下单、用内置的模拟网关完成支付;运营侧管理影片、影厅座位模板与场次排期,并查看订单与销售统计。项目围绕一个经典的高并发命题展开:把「并发正确性」从口号变成可验证的工程——Redis 上的 Lua 原子脚本负责同一瞬间的座位裁决,数据库唯一索引是最后防线,RocketMQ 延迟消息负责超时释放,定时对账把两层存储的漂移收敛回一致。

一、项目概况

票务系统的核心难点不是功能数量,而是三个正确性命题:

  1. 不超卖:同一场次同一座位,最多产生一个有效订单;
  2. 不漏还:订单十五分钟未支付,座位必须自动回到可售,且恰好释放一次;
  3. 不乱账:支付回调可能重复、迟到、乱序;订单状态、支付状态与座位状态三者最终一致。

围绕这三个命题,项目刻意排除了与主题无关的范围:不做微服务拆分、不做分库分表、不做优惠券与会员、不接真实支付渠道、鉴权用轻量 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

相关文章

全部文章 →