
前几天有个同学问我Spring Boot 方向的课设/毕设到底选什么题目才既不烂大街又能把该练的技术都练到。我第一个想到的就是基于 Java 的在线电影购票系统再加一个管理端可视化看板。这个题目覆盖面很广——前端交互、后端接口、库存并发、订单状态机、数据统计报表全都能碰到尤其是一旦把可视化落进去整个项目的完成度和观感会上一个档次。我自己完整做过一版从前期的角色拆解、表结构设计到购买流程里的锁座和防超卖再到最后的 ECharts 大屏看板整个链路都跑通了。这篇就按我实际开发的顺序把这套系统的设计思路、核心代码取舍、坑点与可视化实现细节全部记录下来。不需要你照着抄每一行代码但每一步为什么这么做、哪些地方最容易翻车我会讲清楚。1. 从买票到卖票先把需求边界和角色拆明白在做任何代码之前先要把这个系统到底服务谁、要解决什么事搞清楚。很多初学者一上来就建表写接口结果做到一半发现有的模块不知道往哪放有的业务流程前后对不上。我建议先把需求拆成两端来看。1.1 用户端看起来简单实际上链条很长用户端的核心是选一部电影 → 看场次 → 选座位 → 下单 → 支付 → 拿到电子票。这中间有一堆隐藏细节电影列表按热门、评分、上映日期排序还要支持关键词搜索和类型筛选。场次列表一部电影在不同影厅、不同时间点会有多个场次用户需要一眼看出哪个场次还有余票。选座这是最容易被低估的环节。影厅的座位要按二维坐标展示已经卖掉的座位要置灰用户选中后座位要临时锁定防止别人抢。订单状态已创建、待支付、已支付、已取消、已退票。每一种状态之间怎么流转要在编码之前画清楚。支付和出票真实项目中会对接微信/支付宝课程设计阶段一般用模拟支付搞定但出票的数据结构要提前预留。1.2 管理端排片是纽带数据看板是亮点管理端比用户端更考验数据组织能力。常见模块包括用户管理、电影信息维护海报、简介、时长、影厅管理座位行列数、场次管理把电影挂到某个影厅的某个时间段、订单管理查看、退款操作。这里面最容易出问题的是排片逻辑——同一个影厅在同一个时间段只能有一场电影如果场次时间重叠必须拦截。我记得自己在这个校验上吃过亏最开始只校验了同一影厅、同一天的简单时间冲突后来发现跨天场次比如深夜场到凌晨才散场会漏判。解决方案是存开场时间的同时存散场时间冲突条件要写成 startTime 目标散场时间 且 endTime 目标开场时间。可视化在这里的价值是给管理员一个实时数据面板展示今日票房、近期票房趋势、热门电影排行、影厅上座率等。这不仅是功能亮点也逼着你把聚合查询和图表联动的能力练出来。我个人习惯把系统边界列成一张表开工前先确认哪些做、哪些不做模块用户端管理端本期做/不做账号体系注册/登录管理员登录做电影信息列表/详情增删改/上下架做场次按电影查场次排片/冲突校验做选座购票锁座/下单/支付订单查询/退款做可视化用户小程序/网页图表运营数据看板做会员/优惠券讨论过讨论过不做二期再做边界清晰的另一个好处是答辩或者复盘的时候你能明确说出这个系统解决了什么问题、哪些场景主动砍掉了这比堆功能更能体现业务理解。2. 技术选型的三个关键决策为什么不盲追最新版本技术栈这个东西网上方案多到爆炸但真正到落地时会有很多隐性约束。我这套项目最终采用的组合是Spring Boot 2.7 MyBatis-Plus MySQL 8.0 Redis Vue 2 ECharts。下面说几个关键决策的理由。2.1 Spring Boot 版本稳定大于尝鲜用 Spring Boot 2.7 而不是 3.x不是说 3.x 不好而是考虑到生态兼容性。Spring Boot 3 基于 Spring 6 和 Jakarta EE很多老项目的配置方式、第三方 starter 都要跟着变。对于一个以课设/毕设/中小项目为场景的系统2.7 版本有海量踩坑资料遇到问题随便一搜就有答案这是巨大的隐性优势。如果非要问什么时候选 3.x我的建议是除非你要在简历里特别强调用了 Spring Boot 3 的新特性否则 2.7 就够了。技术选型不是越新越好而是越不容易卡壳越好。项目能跑通、能讲清楚比版本号好看重要得多。2.2 MyBatis-Plus 的价值少写 60% 的 CRUD 代码选 MyBatis-Plus 其实没什么争议。它内置了单表 CRUD 的通用方法分页插件也很好用。对一个字段比较多的电影/场次/订单表来说省下的重复代码量是肉眼可见的。但要注意一点MyBatis-Plus 的 Service 层内置方法虽然方便多表关联和复杂统计还是要自己写 SQL。尤其是后面可视化看板里的按日聚合、按电影维度统计MP 的 QueryWrapper 很难优雅实现直接上自定义 SQL 反而更清爽。2.3 可视化这个关键词怎么落地后端聚合 前端图表可视化在这个项目里不是一句口号。我把它拆成了三个具体目标用户端座位选择的可视化用 CSS Grid 渲染影厅座位布局已售、锁定、可选三种状态用不同颜色区分。管理端订单/排期的表格可视化说白了就是各种状态筛选和统计汇总表格 筛选条件就能覆盖。运营数据看板按日票房折线图、电影票房排行柱状图、类型占比饼图用 ECharts 实现。要让图表有数据可渲染后端就要提供聚合接口比如GET /admin/statistics/boxOffice?days7返回近七天的数据点数组。前端拿到之后直接塞给 ECharts 的 series 就行。这里还有个容易翻车的地方ECharts 是一个体积不小的前端库不同图表的配置写法差异很大。我建议不要妄图背下所有配置项而是把折线图、柱状图、饼图各写一个基础配置模板后面改数据源就行。可视化这件事的难点不在于图表本身多炫酷而是指标口径统一、数据准确、刷新及时。3. 数据库设计余票字段、座位表与唯一索引才是关键在线购票系统的数据库设计比一般的管理系统复杂在库存和座位状态这两个动态数据上。我最终设计出了这些核心表用户表、电影表、影厅表、场次表、座位表、订单表、订单明细表、系统管理表。重点说三个设计决策。3.1 场次表余票到底是一张表还是一个字段一开始很容易陷入余票要实时算的误区——每次查看余票都去 count 一下订单表里该场次的已售座位数。如果用户量小倒没事但一旦并发上来每次查票都要全表聚合性能很快就会崩。我的做法是在场次表里直接维护remainingSeats和soldSeats两个冗余字段每次下单扣减、取消订单回补。这里确实存在数据一致性问题但它的处理方式不是放在数据库 salary 层而是放在事务 乐观锁这一层来解决第四章会展开。3.2 座位可视化影厅的二维布局要落库用户端座位图要按行列展示数据库里必须有座位维度。常见设计是每个影厅预生成一张座位表每条记录包含排数(row)、列数(col)、区域类型(普通/情侣座等)、状态(可用/不可用/维修中)。这样前端渲染时拿到一个座位集合按 row/col 映射到网格坐标上就非常自然。我当时是建场的动作里做了一次初始化根据影厅的行列数批量插入 seat 记录。注意这里的可用跟未售出是两个概念可用表示座位本身没被固定禁用比如安全通道位置未售出是订单维度判断的。不要搞混。3.3 唯一索引防止一票多卖的最后防线防超卖不能只靠应用层锁数据库的兜底一定要做。我在订单表上建了一个唯一索引(session_id, seat_id)。这个索引的意思是同一个场次的同一个座位只能有一条有效订单记录。这个索引在并发场景下非常关键。就算应用层某个锁没生效数据库也会拒绝插入重复的座位订单抛出 DuplicateKeyException。这不是什么高深技术但真的是一个低成本、高收益的设计。很多看似复杂的并发问题一个唯一索引就能兜住底。数据库表结构定好了后面写代码才有底气。我自己做这个项目时在表设计上花了大概两天期间还反复调整了几次。建议你在建表前先思考清楚一个完整购票流程会在哪些表上发生什么样的读写这样就不会出现边开发边改表的情况了。4. 核心购票流程锁座、下单与防超卖的实操方案在线购票系统和普通商城购物最大的区别在于同一个座位不能被两个人同时买走。这一节是整个项目的灵魂也是面试或者答辩时最容易出彩的地方。我会把完整的购票链路和几种并发控制方案讲透。4.1 完整链路从点击座位到支付成功用户点击座位后系统要做的事有校验这个座位在当前场次是否可售把座位标记为锁定状态临时占用一般给 15 分钟支付超时时间创建一条待支付订单用户支付成功后订单状态改成已支付座位标记为已售如果超时或用户取消释放座位回补余票。这里最关键的是第二步和第三步要放在同一个事务里执行。如果锁了座位但订单没创建成功座位就是死锁状态如果订单创建了但座位没锁就可能超卖。我一开始写的代码把两个操作拆成了两次请求结果出了一个很诡异的问题用户连续点击同一个座位两次产生了两个待支付订单。后来才发现是前端重复提交改了之后在锁座接口里加了幂等性校验同一用户同一场次同一座位上已经有待支付订单时直接返回重复订单不再新建。4.2 防超卖乐观锁、悲观锁与唯一索引的三层保险在一次实际压测中我用 JMeter 模拟同一个座位的 20 个并发请求不加任何措施时成功率高达 80%也就是有 16 个请求同时创建了订单。这说明应用层的 if 判断在并发下根本不靠谱必须用数据库给到的锁机制。我最终用的是乐观锁 唯一索引双保险。乐观锁的实现思路是-- 锁定座位前先查座位状态执行时带上条件 UPDATE seat SET status LOCKED WHERE seat_id #{seatId} AND status AVAILABLE这条 UPDATE 的返回值是受影响行数。如果等于 1说明锁座成功如果等于 0说明座位状态已经变了直接抛业务异常该座位已被其他人选中。配合订单表上的唯一索引就算极端情况下两个请求都执行了 UPDATE 且都返回了 1理论上概率极低数据库层面也会让第二个插入订单的操作失败。我总结的防超卖三层防线是层级方案作用应用层先查状态再更新过滤明显不可卖情况数据库层UPDATE 带条件乐观锁保证座位状态变更原子性数据约束层唯一索引 (session_id, seat_id)兜底一票多卖悲观锁的方案SELECT ... FOR UPDATE我也写过但那个方案在流量大一点的时候非常容易被行锁拖垮。座位粒度很细还好如果是场次粒度锁那就直接把整场观众的性能都拖下来了。所以常规做法还是优先乐观锁。4.3 订单超时与座位释放定时任务还是延迟队列锁定状态不能永远存在否则用户不付款座位就永远被占着。我设计的是 15 分钟支付超时超时后自动取消订单、释放座位。最简单的实现是 Spring 的定时任务每隔一分钟扫一次订单表把待支付且创建时间早于 15 分钟前的订单改为取消同时释放对应座位。这个方案在小项目里完全够用实现成本极低。但要注意一个问题订单取消和座位释放必须是同一事务的一部分否则可能出现订单取消了座位却还是锁定状态。我在这里踩过一次坑定时任务里只改了订单状态没有调用座位释放的方法导致一批座位直到系统重启前都买不了。如果你的项目想更进阶一点可以引入 RocketMQ 的延迟消息或者 Redis 的过期监听的 Key 失效回调。但要权衡复杂度个人建议先把定时任务跑通后续再考虑消息队列。5. 可视化看板的实现从 SQL 聚合到 ECharts 图表的全链路可视化是整个项目最容易出彩的部分也是标题里可视化三个字的落点。做这一步需要后端提供聚合粒度正确、口径统一的数据前端再把数据变成图表。下面讲一下我的具体实现。5.1 运营指标拆解看板要展示哪几个图做商业可视化第一件事不是写代码而是确定指标。我当时和同学讨论了很久最后定下四个核心图近七日票房趋势折线图横轴是日期纵轴是票房金额一眼看出整体走势和峰值日。电影票房排行 Top10柱状图按电影的累计票房从高到低排序方便判断哪些片子是主要营收来源。电影类型分布饼图按类型聚合票房或场次数看观众的口味偏好。影厅上座率表格/仪表盘每个影厅的场均上座率辅助排片决策。这四个指标不是随便拍的它们分别对应了时间维度、电影维度、类型维度、影厅维度刚好把一个购票系统最核心的运营数据覆盖全了。5.2 后端聚合接口让 SQL 承担统计工作后端不能把原始订单数据全量返回给前端去算数据量大了就卡死应该直接在数据库层做聚合。这里我举一个典型例子——按日期统计票房先确认核心的价格字段在订单表里。SELECT DATE_FORMAT(create_time, %Y-%m-%d) AS day, SUM(order_amount) AS total_amount FROM t_order WHERE status PAID AND create_time #{startDate} AND create_time #{endDate} GROUP BY DATE_FORMAT(create_time, %Y-%m-%d) ORDER BY day注意几个细节状态过滤只有已支付的订单才算票房待支付和已取消的都不算。日期格式化MySQL 的 DATE_FORMAT 函数可以精确到天。我用的 create_time 是 datetime 类型如果直接 GROUP BY create_time会按秒分组那就完全错了。时区问题如果 DBA 在数据库层设置了 UTC 时区而业务用的是北京时间统计结果可能会差 8 小时。我后来在 JDBC 连接串里强制设置了serverTimezoneAsia/Shanghai并且在建表时用 datetime 类型配合统一写入规范才彻底解决。类似地电影排行接口就是GROUP BY film_id类型分布就是先关联上电影表再GROUP BY category。这些 SQL 写起来都不复杂关键是把业务口径提前定好——比如上座率 已售座位数 / 总座位数不要等前端看到数据才来跟你扯皮。5.3 前端图表联动Vue ECharts 的封装方式后端接口保证数据之后前端就轻松很多了。我用 Vue 2 ECharts 5代码组织上做了一个统计面板组件里面维护了几个图表 ref每个图表单独封装配置函数。我写 ECharts 的习惯是一个图表就一个 initChart 方法图表配置项全部在 method 里写清楚数据源由接口响应动态设置。项目里不需要把每个图都写成公共组件但如果多个页面都用到了同类折线图那抽个公共组件更划算。前端实现的一点心得刚开始我直接把 ECharts 的配置写在每个组件的 created 里导致页面切换再回来时图一直报container not initialized。后来统一在 mounted 里初始化beforeDestroy 里调用chart.dispose()问题就消失了。图表组件的一个常见坑是容器宽高为 0ECharts 会渲染空白。解决办法是外层容器设好固定高度或者用 window.resize 后再调一次 chart.resize()。6. 实测踩过的坑和最后的扩展建议这一节主要讲那些做完才明白的问题。如果你打算自己动手写一版这些内容能帮你少走大量弯路。6.1 事务失效的超隐蔽原因方法自调用我在释放座位的定时任务里一直以为this调用的订单取消方法是带事务的结果发现确实没生效。因为 Spring 事务基于 AOP 生成的代理对象只有通过外部代理调用方法时事务切面才会拦截。同一个类内部this.method()的自调用直接绕过了代理事务自然就失效了。排查方式很简单写一条日志看 SQL 语句是否在一个事务连接里执行。修复方式是把事务方法移到另一个 Service 类里由定时任务调用外部 Bean。6.2 Long 主键传到前端丢精度订单表如果用了雪花 ID 这种 Long 类型传给前端时会发生精度丢失。尤其是座位编号、订单号超过 JS 的 Number.MAX_SAFE_INTEGER 之后500多位的数字会变成科学计数法或者四舍五入导致前端拿到的 ID 跟后端对不上。我踩过这个坑之后统一在配置里加了 Long 转 String 的序列化处理器。这个坑基本属于必现问题如果你是用 MyBatis-Plus 的 ID_WORKER 策略提前处理掉否则后面排查起来非常痛苦。6.3 后续扩展建议从能跑到好用如果你做的东西想再往前推进我建议从三个方向扩展引入 Redis 缓存把场次余票和电影列表的读接口做缓存减少数据库压力。具体做法是写操作时先更新数据库再删除缓存避免幻读。引入消息队列订单创建之后发一条 MQ 消息给出票服务异步出票提高峰值响应能力。真实票务系统基本是这个套路。做推荐系统雏形用最简单的物品协同过滤根据用户历史观影记录推荐电影加到首页做猜你喜欢板块。这个方向很有讲述空间写在简历上也很能抓眼球。做完整个项目再回头看这个系统难的不是 Spring Boot 本身而是怎么把业务状态管理和并发控制理清楚。票务系统是一个典型的业务逻辑比技术框架更容易翻车的案例如果你也打算做同类型项目一定要把订单状态、座位状态、支付状态的关系图先画出来再开始建表和写代码。这段时间我在调试订单超时释放座位功能的时候还把定时任务的 cron 表达式写错过把每分钟执行写成了每分钟执行。这种低级错误虽然很快就被发现了但特别提醒大家写完定时任务一定要先看日志确认执行频率是否符合预期不要想当然地以为配了注解就生效了。总的来讲从需求到上线这套系统没有哪个部分是真正的技术难点但很多细节叠加在一起把每一步做扎实了就能产出一个完成度相当高的项目作品。