电影购票系统毕设入门实战:从单体架构到高并发设计的完整路径
1. 先吐槽:为什么我的第一版“购票”一上线就崩了?
去年指导学弟做毕设,80% 的同学把“电影购票”当成“电影展示”:页面一戳、座位一点、订单生成,完事。结果一压测,库存直接变负数,老板(老师)脸都绿了。
痛点总结如下:
- 没有事务,扣库存和写订单各玩各的,中途异常就回不了头
- 并发测试=0,本地 Postman 单线程跑通就算“上线”
- 数据库一张
ticket表存剩余座位,前端点一次UPDATE set count=count-1就完事,连版本号都没有 - 接口不做幂等,用户双击就把同座位买两次,退款流程直接原地爆炸
一句话:功能堆得再花哨,一上并发就露馅。毕设想拿优,先把“卖超”解决掉。
2. 技术选型:别一上来就“分布式”
很多同学被微服务洗脑,非要把购票拆成 7 个服务,结果 4 月答辩,3 月还在调 RPC。新手阶段,单体+清晰模块足够。
| 维度 | Spring Boot | Django |
|---|---|---|
| 学习资料 | Java 系教材遍地 | Python 语法爽,但中文资料偏少 |
| 事务封装 | @Transactional一键声明 | 手动transaction.atomic()易漏 |
| 就业面 | 国内岗位最多 | 岗位偏少,面试常问 Java |
结论:Java 同学无脑 Spring Boot,Python 铁粉再考虑 Django。
数据库:MySQL 8 还是 SQLite?
- SQLite 零配置,IDEA 神器,但写锁库级,并发发一高就排队
- MySQL 8 行锁+MVCC,毕设答辩现场 200 并发压测能扛住
- 云服务器 1 核 2 G 装 MySQL 完全跑得动,别省这一步
Redis 要不要上?
- 纯演示、并发<50,可以不上
- 想提前体验“缓存预热”“分布式锁”,装一个 200 MB 内存占用的 Docker 版 Redis 即可,代码里就两三行注解,收益肉眼可见
3. 核心实现:一条事务里做完“查-锁-减-写”
思路:
- 用户选座→系统生成订单→减库存,三步必须在同一事务,失败整体回滚
- 用乐观锁(version 字段)替代“无脑减 1”,防止并发写覆盖
- 对外接口保证幂等:订单号全局唯一,重复提交直接返回原结果
技术栈:Spring Boot 2.7 + MyBatis-Plus + MySQL 8
3.1 数据库设计(极简版)
CREATE TABLE session ( id BIGINT PRIMARY KEY, film_id BIGINT, start_time DATETIME, total_seats INT, version INT DEFAULT 0 -- 乐观锁 ); CREATE TABLE `order` ( id BIGINT PRIMARY KEY, session_id BIGINT, user_id BIGINT, seats INT, status ENUM('LOCK','PAID','CLOSED'), create_time DATETIME, UNIQUE KEY uk_session_user (session_id, user_id) -- 天然幂等 );3.2 关键代码(含注释)
@Service public class TicketService { @Autowired private SessionMapper sessionMapper; @Autowired private OrderMapper orderMapper; /** * 购票接口:事务+乐观锁+幂等 */ @Transactional(rollbackFor = Exception.class) public String buyTicket(Long sessionId, Long userId, int buySeats) { // 1. 幂等判断:同一会话同一用户已下单直接返回 Order exist = orderMapper.selectOne( new QueryWrapper<Order>() .eq("session_id", sessionId) .eq("user_id", userId)); if (exist != null) { return exist.getId(); // 直接返回旧订单号 } // 2. 带版本号的行锁查询 Session s = sessionMapper.selectById(sessionId); if (s.getTotalSeats() < buySeats) { throw new RuntimeException("余票不足"); } // 3. 乐观锁更新库存 int affected = sessionMapper.update(null, new UpdateWrapper<Session>() .setSql("total_seats = total_seats - " + buySeats) .eq("id", sessionId) .eq("version", s.getVersion())); // 版本一致才更新 if (affected == 0) { throw new RuntimeException("并发冲突,请重试"); } // 4. 写订单 Order o = new Order(); o.setId(UUID.randomUUID().toString()); o.setSessionId(sessionId); o.setUserId(userId); o.setSeats(buySeats); o.setStatus("LOCK"); o.setCreateTime(LocalDateTime.now()); orderMapper.insert(o); return o.getId(); } }要点回顾:
@Transactional保证 2、3、4 步同生共死version字段+UpdateWrapper实现乐观锁,避免超卖- 唯一索引
uk_session_user天然挡住重复下单,幂等不依赖 Token
4. 性能与安全:学生党最容易忽视的三件事
冷启动延迟
Spring Boot 3.x 原生启动 3-4 s,老笔记本跑 Demo 时老师以为死机。解决:- 关闭不必要的
spring-boot-starter-xxx - 在测试环境加
spring.main.lazy-initialization=true
- 关闭不必要的
SQL 注入
MyBatis-Plus 的QueryWrapper已经参数化,但手写 SQL 时一定用#{}而非${},老师抓包演示最尴尬接口幂等
除了唯一索引,前端可加“提交中”遮罩,后端对同一sessionId+userId返回相同订单号,用户体验瞬间高级
5. 生产环境踩坑实录
- 时区:本地
application.yml里server.time-zone=GMT+8,服务器是 UTC,订单时间直接穿越。统一写spring.jackson.time-zone=Asia/Shanghai - 连接数:默认 Hikari 10 个连接,压测 200 并发瞬间打满,报错“connection timeout”。改成 50 并配合连接等待,足够演示
- 日志:Tomcat 疯狂刷
DEBUG,磁盘 2 G 瞬间没。上线前把logging.level.root=INFO - 高并发模拟:别拿 Postman 点 20 次就算“压力测试”。用 ApacheBench 或国产 Go 版
hey工具,50 ms 间隔打 1 分钟,超卖问题立即现形
6. 拓展思考:不 benchmark 消息队列,还能怎么缓解超卖?
- 纯数据库乐观锁已能扛住千级并发,但版本重试会放大 RT。可把重试逻辑放到应用层循环 3 次,或直接用
SELECT ... FOR UPDATE行锁,降低冲突概率 - 将“库存”拆成座位粒度数表,一行一座位,冲突粒度更细,超卖概率指数级下降
- 本地缓存+定时刷新:启动时把余票加载到
ConcurrentHashMap,写操作走 DB,读操作 99% 命中内存,压测数字好看,老师点赞
7. 小结与动手任务
走完上面的代码,你就拥有了一个“能跑、不超卖、可演示”的购票内核。接下来:
- 把剩余接口(支付回调、退票、排片管理)补齐,别留 TODO 给老师挑刺
- 用 JMeter 或
hey打 1 分钟 500 并发,观察是否还有 version 冲突,再调优 - 思考:如果拿掉消息队列,仅用数据库+本地缓存,能否把冲突率压到 <0.1%?动手改代码,把实验数据写进论文,亮点瞬间拉满
毕设不是“完成功能”,而是“证明你能解决问题”。先让座位不再为负,再去画前端海报,老师才会心甘情愿给你优秀。祝你编码愉快,答辩顺利!