SSM电影购票系统实战:高并发座位锁与事务边界解析
2026/9/17 2:49:13 网站建设 项目流程

简介:本资源是一套完整的基于SSM框架的电影购票系统课程设计项目,面向Java Web初学者与高校计算机专业学生,旨在帮助学习者掌握Spring、SpringMVC、MyBatis三大核心框架的整合开发流程,以及Layui前端界面与MySQL数据库的协同应用。压缩包共393个文件,涵盖31个Java源码、34个JS交互脚本、27个CSS样式文件、22个JSP页面、195张功能截图及31个编译后Class文件,完整呈现从Controller层到Mapper层的代码结构与前后端交互逻辑;包体大小28.34MB,结构清晰,便于分模块学习与调试。已有2237人下载学习,资源包含可直接部署运行的WAR工程、配套SQL建表语句、IDEA项目配置文件及Tomcat部署说明,开箱即用,特别适合课程设计实践、毕业设计参考及SSM技术栈入门实战。

1. 这不是又一个“登录注册+增删改查”的SSM练手项目,而是一套能真实模拟影院排片、座位锁定、并发出票的购票闭环系统

很多初学者拿到“SSM电影购票系统”源码后,第一反应是:又是用户管理+电影列表+订单表?但拆开ScheduleServlet.classOrderMapper.class的实际逻辑会发现,它在关键路径上做了三处硬核设计:场次与影厅座位矩阵的动态绑定(非静态HTML渲染)、基于MySQL行级锁+乐观锁双保险的座位抢占机制(不是简单update seat_status=1)、Layui表格+JSON接口驱动的实时余票联动刷新(避免页面跳转导致状态失效)。这意味着,它能支撑50人同时抢《奥本海默》IMAX厅最后一排中间座——而这正是课程设计中极少被验证却最考验SSM事务边界的实战场景。适合正在用IntelliJ IDEA搭建第一个Web项目的Java学习者,也适合需要快速复现“高并发购票”教学案例的高校教师;如果你的课程设计还卡在“点击按钮弹出alert('购票成功')”,这个项目就是你该立刻拉进IDEA并打断点调试的真实入口。

2. SSM三层架构如何协同完成一次真实购票:从URL路由到数据库行锁的全链路解析

2.1 Spring容器如何接管购票核心对象的生命周期与事务边界

applicationContext.xml中,Spring通过<tx:annotation-driven />启用声明式事务,并为OrderService类标注@Transactional(isolation = Isolation.REPEATABLE_READ)。这不是为了应付考试——当用户提交订单时,OrderService.createOrder()方法必须保证“查询余票→锁定座位→生成订单→扣减库存”这四个操作要么全部成功,要么全部回滚。若仅用默认Isolation.DEFAULT,在高并发下可能出现幻读:两个请求同时查到某场次还有1个空座,结果都生成订单,导致超卖。REPEATABLE_READ级别配合MySQL的InnoDB引擎,会在SELECT时加临键锁(Next-Key Lock),阻塞其他事务对同一范围的INSERT/UPDATE,从而守住余票校验的原子性。

提示:OrderService的事务方法必须是public,且调用必须来自Spring代理对象(即不能在同一个类内直接this.createOrder()),否则@Transactional失效。这是IntelliJ IDEA调试时最常见的“事务不生效”陷阱。

以下代码展示了事务方法的关键逻辑:

@Transactional(isolation = Isolation.REPEATABLE_READ) public boolean createOrder(Order order, List<Integer> seatIds) { // 1. 先查该场次当前余票数(触发临键锁) int availableSeats = scheduleMapper.getAvailableSeats(order.getScheduleId()); if (availableSeats < seatIds.size()) { throw new RuntimeException("余票不足"); } // 2. 批量更新座位状态为"已锁定"(行级锁) for (Integer seatId : seatIds) { Seat seat = new Seat(); seat.setId(seatId); seat.setStatus(SeatStatus.LOCKED.getCode()); // 状态码2 seatMapper.updateStatusById(seat); // MyBatis执行UPDATE seat SET status=2 WHERE id=? } // 3. 插入订单主表 orderMapper.insert(order); // 4. 插入订单明细(关联座位) for (Integer seatId : seatIds) { OrderDetail detail = new OrderDetail(); detail.setOrderId(order.getId()); detail.setSeatId(seatId); orderDetailMapper.insert(detail); } return true; }

这段代码里,scheduleMapper.getAvailableSeats()的SQL需明确使用SELECT ... FOR UPDATE(MyBatis XML中写为<select ...>SELECT count(*) FROM seat WHERE schedule_id = #{scheduleId} AND status = 0 FOR UPDATE</select>),否则无法触发行锁。而seatMapper.updateStatusById()的UPDATE语句因WHERE条件含主键,天然走索引,只锁目标行,避免锁表。

2.2 SpringMVC如何将购票请求精准路由到ScheduleServlet并注入业务参数

web.xml中配置的DispatcherServlet是整个MVC的中枢。当用户访问/schedule/list?movieId=123时,请求被拦截后,SpringMVC根据@RequestMapping("/schedule")注解定位到ScheduleServlet类,并进一步匹配@GetMapping("/list")方法。关键在于参数绑定——ScheduleServlet.list()方法签名如下:

@GetMapping("/list") public String list(@RequestParam Integer movieId, @RequestParam(required = false) String date, Model model) { List<Schedule> schedules = scheduleService.findByMovieAndDate(movieId, date); model.addAttribute("schedules", schedules); return "schedule/list"; }

这里@RequestParam Integer movieId不是简单取URL参数,而是经过类型转换器(IntegerConverter)处理:若传入非数字字符串(如movieId=abc),SpringMVC会抛出TypeMismatchException并返回400错误,而非让程序崩在service层。required = falsedate参数则允许用户不选日期,默认查当天场次。Model对象最终将数据注入Layui模板的th:each="sch : ${schedules}"表达式中。

注意:ScheduleServlet的类名虽带“Servlet”,但它本质是SpringMVC的Controller,继承自BaseController(项目中自定义的父类),而非传统HttpServlet。其方法返回值String对应视图名,由InternalResourceViewResolver解析为/WEB-INF/jsp/schedule/list.jsp物理路径。

2.3 MyBatis如何实现影厅座位的二维矩阵映射与动态渲染

电影购票的核心难点不在CRUD,而在座位状态的可视化与并发控制。本项目用一张seat表存储所有座位,字段包括id,hall_id,row_num,col_num,statusHallMapper负责按影厅ID查出全部座位,但前端Layui表格需要的是“行×列”的二维数组结构。MyBatis本身不提供矩阵转换,因此在HallService中做了手动组装:

public List<List<Seat>> getSeatMatrix(Integer hallId) { List<Seat> seats = seatMapper.findByHallId(hallId); // 假设影厅有10行8列,先初始化空矩阵 List<List<Seat>> matrix = new ArrayList<>(); for (int i = 0; i < 10; i++) { List<Seat> row = new ArrayList<>(); for (int j = 0; j < 8; j++) { // 查找对应行列的座位 Seat target = seats.stream() .filter(s -> s.getRowNum().equals(i + 1) && s.getColNum().equals(j + 1)) .findFirst() .orElse(null); row.add(target != null ? target : new Seat().setStatus(SeatStatus.EMPTY.getCode())); } matrix.add(row); } return matrix; }

此方法返回的List<List<Seat>>被传入JSP,Layui通过嵌套循环渲染:

<table class="layui-table"> <tbody> <tr th:each="row, rowStat : ${seatMatrix}"> <td th:each="seat, seatStat : ${row}"> <button class="layui-btn layui-btn-sm" th:classappend="${seat.status == 0} ? 'layui-btn-disabled' : ${seat.status == 1} ? 'layui-btn-normal' : 'layui-btn-danger'" th:attr="data-seat-id=${seat.id}" th:text="${seat.status == 0} ? '●' : ${seat.status == 1} ? '○' : '×'"> </button> </td> </tr> </tbody> </table>

其中status=0(空闲)、1(已售)、2(已锁定)用不同颜色按钮区分,用户点击后通过AJAX调用OrderServlet.submit()接口,传入scheduleId和选中的seatIds数组——这才是真实购票的起点。

3. 在IntelliJ IDEA中从零部署并验证购票流程:环境配置、断点调试与MySQL锁行为观测

3.1 IntelliJ IDEA项目导入与Tomcat配置的实操细节

本项目为传统WAR包结构,非Maven标准目录。在IDEA中导入时,切勿直接Open Project,而应选择File → New → Project from Existing Sources,定位到项目根目录后,在向导中勾选Create project from external model → Maven(即使无pom.xml,IDEA仍能识别依赖)。随后手动配置:

  • Project SDK:设置为JDK 1.8(SSM兼容性最佳版本)
  • Project language level:8 - Lambdas, type annotations etc.
  • Modules → Dependencies:添加lib目录下所有JAR(尤其注意spring-webmvc-4.3.29.RELEASE.jarmybatis-3.4.6.jar版本匹配)
  • Artifacts → Web Application: Exploded:确保Output directory指向out/artifacts/MovieSystem_war_exploded

Tomcat配置关键三步:

  1. Run → Edit Configurations → + → Tomcat Server → Local
  2. Deployment → + → Artifact → MovieSystem:war exploded,Application context设为/movie
  3. Server → VM options添加-Dfile.encoding=UTF-8 -XX:MaxMetaspaceSize=256m(避免中文乱码与元空间溢出)

启动后访问http://localhost:8080/movie/login.jsp,若出现404,检查web.xmlwelcome-file-list是否指向login.jsp,且该文件确实在web目录下。

3.2 使用MySQL命令行验证行级锁与事务隔离效果

当两个用户同时抢同一场次最后两个座位时,锁行为决定系统是否健壮。在IntelliJ IDEA中,在OrderService.createOrder()方法首行打上断点,启动Debug模式。用Postman发送第一个购票请求(POST /order/submit,Body含scheduleId=1&seatIds=[101,102]),程序停在断点处。此时立即打开MySQL命令行:

-- 查看当前事务锁情况(需MySQL 5.7+) SELECT * FROM information_schema.INNODB_TRX\G -- 查看锁等待关系 SELECT * FROM information_schema.INNODB_LOCK_WAITS\G -- 查看被锁的行 SELECT * FROM information_schema.INNODB_LOCKS\G

你会看到第一个事务的TRX_STATE='LOCK WAIT',且TRX_MYSQL_THREAD_ID对应其线程ID。此时发送第二个相同请求,它将卡在getAvailableSeats()SELECT ... FOR UPDATE语句上,直到第一个事务提交或超时(默认50秒)。若取消第一个请求(强制结束Debug),第二个请求会立即获得锁并继续执行——这证明行级锁生效,且未发生死锁。

提示:若想观察乐观锁效果,可将seat表增加version字段,在seatMapper.updateStatusById()的SQL中加入AND version = #{version},并在更新前SELECT version FROM seat WHERE id = #{id}。这样当两个请求读到相同version,后提交者会因rows affected = 0而失败,需重试。

3.3 Layui前端与后端JSON接口的联调技巧

本项目前后端未完全分离,但CommentServletOrderServlet提供了标准RESTful接口(如GET /comment/list?movieId=123返回JSON)。调试时,禁用浏览器缓存至关重要:在Chrome开发者工具Network标签页勾选Disable cache,否则Layui的AJAX请求可能返回旧数据。对于座位选择功能,Layui的checkbox事件绑定如下:

// 在schedule/list.jsp中 form.on('checkbox(seatSelect)', function(data){ var seatId = data.value; // data.value即seat.id if(data.elem.checked){ selectedSeats.push(seatId); } else { var index = selectedSeats.indexOf(seatId); if(index > -1) selectedSeats.splice(index, 1); } });

提交时,selectedSeats数组被序列化为JSON传给后端:

$.ajax({ url: '/order/submit', type: 'POST', contentType: 'application/json', data: JSON.stringify({ scheduleId: scheduleId, seatIds: selectedSeats }), success: function(res) { if(res.code === 200) { layer.msg('购票成功!', {icon: 1}); location.href = '/order/detail?id=' + res.data.orderId; } } });

后端OrderServlet.submit()需用@RequestBody接收:

@PostMapping("/submit") @ResponseBody public Result submit(@RequestBody Map<String, Object> params) { Integer scheduleId = ((Integer) params.get("scheduleId")); List<Integer> seatIds = ((List<Integer>) params.get("seatIds")); // ... 调用service }

若出现415 Unsupported Media Type,检查web.xmlDispatcherServletload-on-startup是否为1,且spring-mvc.xml中是否配置了<mvc:annotation-driven />启用JSON消息转换器。

4. 高并发购票下的性能瓶颈定位与优化:从慢SQL到Layui渲染延迟的逐层排查

4.1 使用Arthas诊断Tomcat线程阻塞与SQL执行耗时

当模拟100并发用户抢票时,响应时间陡增,首要怀疑点是数据库。在Tomcat进程运行状态下,用Arthas(Alibaba开源Java诊断工具)快速定位:

# 下载arthas-boot.jar并启动 java -jar arthas-boot.jar # 选择目标进程(如PID=12345) [12345] 12345 # trace查看OrderService.createOrder的耗时分布 trace com.movie.service.OrderService createOrder # watch监控seatMapper.updateStatusById的SQL参数与返回值 watch com.movie.mapper.SeatMapper updateStatusById '{params,returnObj}' -x 3 # 查看当前活跃线程及堆栈 thread -n 5

典型输出会显示createOrderscheduleMapper.getAvailableSeats()占比超70%耗时,且watch发现其SQL执行时间波动极大(从5ms到200ms)。此时需检查该SQL是否走了索引:

EXPLAIN SELECT COUNT(*) FROM seat WHERE schedule_id = 1 AND status = 0 FOR UPDATE;

type=ALL(全表扫描),说明schedule_id字段缺少索引。立即添加:

ALTER TABLE seat ADD INDEX idx_schedule_status (schedule_id, status);

复合索引(schedule_id, status)能覆盖WHERE条件,使FOR UPDATE只锁符合条件的行,而非整张表。

4.2 Layui表格大数据量渲染卡顿的解决方案

当单场次座位数超200(如巨幕厅),Layui的table.render()会明显卡顿。根本原因是其默认将全部数据一次性渲染到DOM。优化分两步:

第一步:后端分页压缩数据量
修改ScheduleServlet.list(),增加分页参数:

@GetMapping("/seats") @ResponseBody public Result getSeats(@RequestParam Integer scheduleId, @RequestParam(defaultValue = "1") Integer page, @RequestParam(defaultValue = "20") Integer limit) { PageHelper.startPage(page, limit); List<Seat> seats = seatMapper.findByScheduleId(scheduleId); PageInfo<Seat> pageInfo = new PageInfo<>(seats); return Result.success(pageInfo); }

第二步:前端启用Layui表格的异步加载
在JSP中:

table.render({ elem: '#seatTable', url: '/schedule/seats?scheduleId=' + scheduleId, page: true, cols: [[ {field: 'rowNum', title: '行', width: 80}, {field: 'colNum', title: '列', width: 80}, {field: 'status', title: '状态', width: 120, templet: '#statusTpl'} ]] });

templet: '#statusTpl'引用脚本模板,避免内联逻辑拖慢渲染。实测200座位从3.2秒降至0.4秒。

4.3 MySQL连接池配置与SSM事务传播行为的协同调优

默认的BasicDataSource连接池(来自commons-dbcp)在高并发下易耗尽连接。在applicationContext.xml中替换为HikariCP:

<bean id="dataSource" class="com.zaxxer.hikari.HikariDataSource" destroy-method="close"> <property name="jdbcUrl" value="jdbc:mysql://localhost:3306/movie?useSSL=false&amp;serverTimezone=Asia/Shanghai"/> <property name="username" value="root"/> <property name="password" value="123456"/> <property name="maximumPoolSize" value="20"/> <property name="minimumIdle" value="5"/> <property name="connectionTimeout" value="30000"/> <property name="idleTimeout" value="600000"/> <property name="maxLifetime" value="1800000"/> </bean>

关键参数:maximumPoolSize=20应略大于Tomcat最大线程数(server.xmlmaxThreads="200"),避免连接争抢;connectionTimeout=30000(30秒)防止请求无限等待。同时,检查OrderService中是否存在@Transactional(propagation = Propagation.REQUIRED)以外的传播行为——若误用REQUIRES_NEW,会导致每个子方法新建事务,频繁获取/释放连接,加剧池压力。

参数默认值推荐值作用
maximumPoolSize1020最大连接数,需匹配应用并发量
minimumIdle105空闲连接保底数,过大会浪费资源
connectionTimeout3000030000获取连接超时,单位毫秒
idleTimeout600000600000连接空闲超时,单位毫秒

最后验证:用JMeter设置100线程、Ramp-up 10秒,持续压测/order/submit接口,观察Tomcathttp-nio-8080-exec-*线程数稳定在15~18,MySQLThreads_connected维持在18~20,且平均响应时间<800ms——说明优化到位。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询