☰
SSM框架实战:电影院订票系统架构设计与并发座位控制
2026/10/15 12:49:03 网站建设 项目流程

简介:一套基于Spring+SpringMVC+Mybatis开发的电影院订票系统前后台项目,面向JavaWeb学习者、课程设计与毕业设计人群。系统按前台用户端与后台管理端划分,前台包含登录注册、首页浏览、影厅查阅、影片预览、档期查询、订票与影评查询等功能;后台则提供用户、影厅、影片、档期计划、订票、影评及系统管理等完整管理模块,结合MySQL 5.7与Tomcat 9.0即可运行。压缩包内共1156个文件,大小约17.77MB,其中html/css/js负责前端页面与交互,jsp用于动态视图,java/class为后端业务与控制逻辑,jar包含依赖库,另附sql数据库脚本,便于快速导入环境。已有1532人学习/下载,适合需要参考SSM整合项目分层结构、接口实现或二次开发的人群,整体目录清晰,可直接部署学习。

1. 为什么要用 SSM 去做电影院订票系统:这不是老技术,这是务实选型

这两年新项目都在往 Spring Boot 上搬,但 Spring + SpringMVC + MyBatis 这套经典组合在中小型后台管理系统里仍然大量存在,电影院订票系统就是其中一个典型场景。它的核心诉求很直接:前台要支撑用户选座、下单、支付的流畅操作,后台要管电影排片、场次座位、订单核销,业务规则集中在座位状态流转和订单状态机上。我做过两个类似的票务系统,可以负责任地说,SSM 做这类系统非但不落伍,反而能让开发人员把精力花在业务逻辑而不是框架花活上。

这套系统适合三类人:刚入行想通过完整项目把 SSM 串联起来的同学,公司还在维护老项目需要快速上手的技术人员,以及想把订票业务跑通做毕业设计或产品原型的开发者。我在实践里的习惯是:Spring 管对象生命周期和事务,SpringMVC 管请求路由,MyBatis 管数据访问,三者各司其职,出了问题能快速定位。接下来我会从架构设计、表结构、核心代码到踩坑记录,完整还原一个可运行的电影院订票系统前后台的开发过程,参数和边界条件我都会讲清楚。

2. 系统架构与数据模型设计:先把表结构定好,后面少返工

2.1 前后台分离的模块划分与 Spring 容器配置:谁负责什么,先划清界线

电影院订票系统的功能边界比一般电商系统更清晰,因为它多了一个「座位图」这个强交互元素。前台面向 C 端用户,核心模块是:用户注册登录、电影浏览、场次选择、在线选座、订单提交、支付回调处理。后台面向运营人员,核心模块是:电影信息管理、影院影厅管理、排片管理、订单查询与退款、每日对账。我习惯把项目拆成四个 Maven 模块:ssm-cinema-common放通用工具和常量,ssm-cinema-dao放 MyBatis 的 mapper 接口和 XML,ssm-cinema-service放业务逻辑,ssm-cinema-web放 Controller、拦截器和视图层。

先看依赖配置,这是整个项目的地基。我一般会固定 Spring 版本为 5.x 而不是盲目追新,因为 MyBatis-Spring 的兼容性在 5.x 下最稳:

<!-- pom.xml 核心依赖片段 --> <properties> <spring.version>5.3.29</spring.version> <mybatis.version>3.5.13</mybatis.version> <mybatis-spring.version>2.1.1</mybatis-spring.version> </properties> <dependencies> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-jdbc</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>${mybatis.version}</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>${mybatis-spring.version}</version> </dependency> </dependencies>

为什么不用 Spring Boot 那套自动配置?因为这个项目场景里,事务管理、连接池参数、MyBatis 插件都需要精细控制,XML 配置虽然啰嗦但每一条都看得见。spring-dao.xml里最关键的是事务管理器配置,我推荐用DataSourceTransactionManager配合注解驱动,业务方法上直接加@Transactional即可,比 AOP 切面配置更直观。

spring-web.xml里要额外注意静态资源放行,否则前端页面引用的 CSS、JS、图片全会被拦截器拦住:

<mvc:resources mapping="/static/**" location="/static/"/> <!-- 配置视图解析器:Controller 返回逻辑视图名,自动拼接前缀后缀 --> <bean class="org.springframework.web.servlet.view.InternalResourceViewResolver"> <property name="prefix" value="/WEB-INF/views/"/> <property name="suffix" value=".jsp"/> </bean>

2.2 数据库表设计:电影、场次、订单、座位的状态流转是核心

电影院订票系统的表设计我踩过不少坑,最深刻的一条是:座位一定不要冗余存储在订单表里,而是要通过「场次座位表」单独维护。下面是核心表结构,我直接给出我在项目中验证过的设计:

电影表t_film:film_id、film_name、duration(片长,分钟)、release_date、status(1 上映中 0 下架)。注意片长直接影响排片时间计算,很多新手漏掉这个字段,导致排片重叠。

影厅表t_hall:hall_id、hall_name、row_count、col_count。座位总数由行列计算得出,不要单独存 total_count,否则加了座位后两个字段容易不一致。

场次表t_session:session_id、film_id、hall_id、start_time、end_time、price、status。end_time 不建议手填,由start_time + film.duration + 20分钟(散场清理时间)自动计算。

场次座位表t_session_seat:seat_id、session_id、row_num、col_num、status。status 分三态:0 可选、1 已锁定、2 已售出。这张表是并发控制的关键点。

订单表t_order:order_id、order_no(唯一业务流水号)、user_id、session_id、total_amount、status、create_time、pay_time。订单状态我用 TINYINT 存:0 待支付、1 已支付、2 已取消、3 已退款、4 已完成。

订单座位关联表t_order_seat:id、order_id、seat_id。为什么要单独关联表?因为一个订单可以买多张票,而且后续退款时需要精确知道退的是哪几个座位。

这张表结构有四个设计要点需要特别注意:第一,t_session_seat在每次排片创建时批量生成,比如一个 8 排 10 列的影厅,一场次就是 80 条记录;第二,所有金额字段用 DECIMAL(10,2),绝不用 FLOAT;第三,订单号用「年月日时分秒 + 用户ID后四位 + 随机四位」生成,并在数据库加唯一索引;第四,t_order_seat和t_session_seat通过 seat_id 关联,其他情况下订单表不直接碰座位信息。

海量并发的场景下,这套表结构还有一个隐藏优势:更新座位状态时只需要操作t_session_seat的 status 字段,不需要动订单表,锁粒度能控到行级。如果后续要做读写分离,这套表的设计天然支持从库查询、主库更新的分工。

3. 搭建可运行的前后台骨架:从 Maven 工程到登录拦截器

3.1 用 Maven 创建聚合工程与 MyBatis 核心配置:每一步都能直接抄

我习惯按「聚合工程 + 分模块」的方式组织项目,在 IDEA 里先建一个空的 pom 作为父模块,然后依次创建子模块。注意每个子模块的 pom 里要声明父模块引用,否则依赖版本会乱掉。实际动手时最容易出错的地方是漏掉 Maven 编译插件配置,导致 JSP 页面编译不过去或资源文件没有打进去,我一般会在ssm-cinema-web的 pom 里固定加上 maven-compiler-plugin。

MyBatis 的全局配置文件mybatis-config.xml放在ssm-cinema-dao的 resources 下,有几个参数是必调的:

<configuration> <settings> <!-- 驼峰映射:数据库列 account_name 自动映射到属性 accountName --> <setting name="mapUnderscoreToCamelCase" value="true"/> <!-- 开启日志,开发期看 SQL 执行情况 --> <setting name="logImpl" value="STDOUT_LOGGING"/> <!-- 查询超时时间,防止慢 SQL 拖垮连接池 --> <setting name="defaultStatementTimeout" value="5"/> </settings> <typeAliases> <!-- 实体类别名:com.cinema.entity.Film 直接用 Film 引用 --> <package name="com.cinema.entity"/> </typeAliases> </configuration>

mapUnderscoreToCamelCase 这个开关必须开,否则每次查询都要写 resultMap 做字段映射,非常啰嗦。typeAliases 用包扫描替代逐个声明,后续在 XML 里写 parameterType 和 resultType 时直接写类名就可以,省去全限定名。

Spring 整合 MyBatis 时用MapperScannerConfigurer自动扫描 Mapper 接口,不用手写每个 Mapper 的代理类。但要注意它和 Spring 容器初始化顺序的问题,sqlSessionFactoryBean必须声明在先,否则启动时报「Property 'sqlSessionFactory' is required」:

<bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="configLocation" value="classpath:mybatis-config.xml"/> <property name="mapperLocations" value="classpath:mapper/*.xml"/> </bean> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.cinema.dao"/> <!-- 不指定 sqlSessionFactoryBeanName 也没问题,自动装配 --> </bean>

3.2 用户登录拦截器与 ThreadLocal 封装:手动搭权限控制的办法

订票系统的前台页面允许游客浏览电影列表,但选座、下单、支付必须登录。后台更是要全部拦截,运营人员未登录能看到页面就是事故。我们用 SpringMVC 的 HandlerInterceptor 来做,整体思路是在拦截器里用 HandlerMapping 的 excludePathPatterns 排除登录接口和静态资源。

我踩过一个坑:SpringMVC 拦截器拦截的是 Handler 方法,不拦截静态资源。但如果在 web.xml 里把 DispatcherServlet 的 url-pattern 配成了/(会覆盖容器默认的静态资源处理器),静态资源就会全部 404。所以我坚持在 spring-web.xml 里配置<mvc:resources>来兜底。登录态用 Cookie 存 sessionId,Redis 暂不考虑,单体项目用 Session 够用:

// 用户登录状态拦截器核心代码 public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行跨域预检请求和静态资源路径 if ("OPTIONS".equalsIgnoreCase(request.getMethod())) return true; // 从 Session 中获取登录用户 User user = (User) request.getSession().getAttribute("loginUser"); if (user == null) { // 请求头是 AJAX 就返回 JSON,否则重定向到登录页 if ("XMLHttpRequest".equals(request.getHeader("X-Requested-With"))) { response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"msg\":\"未登录\"}"); } else { response.sendRedirect(request.getContextPath() + "/login"); } return false; } // 通过 ThreadLocal 封装用户信息,Service 层直接 UserContext.get() UserContext.set(user); return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContext.remove(); // 必须清理,防止线程池复用导致数据串号 } }

这里有个关键点:UserContext用 ThreadLocal 存用户,但afterCompletion里必须 remove。因为 Tomcat 的工作线程是池化复用的,如果当前请求结束后不清理,下一个请求可能读到上一个用户的登录态,这种 bug 排查起来非常玄学,属于线上才暴露的脏数据问题。Controller 里取当前用户时调用UserContext.get()就行,不用每个方法都从 Session 里拿参数传递,代码清爽很多。

4. 前后台核心功能实现:从电影管理到支付回调的完整链路

4.1 后台排片管理与座位图生成:MyBatis 的批量插入不要用 foreach

后台运营的第一步是维护电影信息和排片。电影表的 CRUD 比较常规,这里重点说排片。新增一个场次时,要做三个动作:插入场次记录、计算结束时间、批量生成场次座位。批量生成座位这里,新手最常见的做法是在 Java 里 for 循环调用单条 insert,场次 80 个座位就执行 80 次 SQL,性能差且事务时间长。我的做法是 MyBatis 批量插入:

<!-- 批量生成场次座位:注意 separator 和 foreach 的用法 --> <insert id="batchInsertSessionSeats" parameterType="java.util.List"> INSERT INTO t_session_seat (session_id, row_num, col_num, status) VALUES <foreach collection="list" item="item" separator=","> (#{item.sessionId}, #{item.rowNum}, #{item.colNum}, 0) </foreach> </insert>

逻辑说明:foreach拼接多组 VALUES,一次 SQL 完成 80 条插入,比循环单插快一个数量级。参数说明:collection="list"对应 Java 方法参数List\<SessionSeat\>,item是循环体引用名。这里有个隐藏坑:MySQL 对单条 SQL 的包大小默认是 4MB,如果影厅很大(比如 20 排 30 列就是 600 条),一条 SQL 可能超包大小,我在代码里加了分批逻辑,每批 500 条调用一次 mapper 方法。实际编码里,一次性生成好 list 之后,用Lists.partition或者手写 subList 分批处理即可。

生成座位图数据后,前端选座页需要知道哪些座位可选、哪些被锁定。查询接口按 sessionId 查出所有座位状态,返回给前端一个二维数组。我在 mapper 里用SELECT row_num, col_num, status FROM t_session_seat WHERE session_id = #{sessionId},然后 Java 里组装成List<List<SeatVO>>。

4.2 前台在线选座与并发扣锁:乐观锁与悲观锁的取舍

选座是订票系统并发压力最大的接口。用户点一个座位时,前端先发送锁定请求,后端要保证同一时刻两个用户不能锁同一个座。我在早期版本里用的是悲观锁SELECT ... FOR UPDATE,压测下来发现一个问题:单个场次的座位锁集中在同一行记录上时,并发能力上不去,而且被锁定的座位在长事务里会拖垮整个库。

后来我改成了乐观锁方案,核心是更新时带条件判断:

// 锁定座位:乐观锁实现,更新条件带上旧状态 @Override @Transactional public boolean lockSeat(Long sessionId, Integer rowNum, Integer colNum, Long userId) { // 1. 查座位是否存在且可选 SessionSeat seat = sessionSeatMapper.selectBySessionAndPosition(sessionId, rowNum, colNum); if (seat == null || seat.getStatus() != 0) return false; // 2. 更新状态:只有当前状态为 0(可选)时才更新为 1(锁定) int rows = sessionSeatMapper.updateStatusByCondition( seat.getSeatId(), seat.getStatus(), // 旧状态作为条件 1, // 新状态:锁定 userId, // 记录锁定人,超时释放用 new Date() ); // rows 为 1 说明并发下只有当前线程抢到了锁;为 0 说明已被别人锁定 return rows > 0; }
<!-- 乐观锁更新座位状态:where 条件带上旧状态,天然防并发 --> <update id="updateStatusByCondition"> UPDATE t_session_seat SET status = #{newStatus}, lock_user_id = #{userId}, lock_time = #{lockTime} WHERE seat_id = #{seatId} AND status = #{oldStatus} </update>

逻辑说明:这个更新的巧妙之处在于WHERE子句里的status = #{oldStatus},两个并发请求同时读到 status = 0,但数据库行锁保证只有一个请求的 UPDATE 能成功,另一个受影响行数为 0,返回 false 告知前端「手慢了」。参数说明:oldStatus必须是从前一步查询带出来的值,不能重新查一次,否则可能拿到的还是 0,导致并发控制失效。这种方案的好处是锁时间极短,UPDATE 执行完就释放锁,不占用长事务,非常适合高并发选座场景。

锁定时长我设置为 15 分钟,超过后定时任务把 status 从 1 回滚为 0,同时把 lock_user_id 置空。回滚时条件必须是lock_user_id = #{userId},防止把顺手把别人刚锁的座释放了。这个定时任务我用 Spring 的@Scheduled注解,fixedDelay 设为 30 秒,扫一次超时锁定记录。

4.3 订单状态机与支付回调处理:幂等性设计到底怎么落地

订单提交的流程是:用户点「确认支付」→ 后端创建订单 + 锁定座位转已售 + 生成支付参数 → 前端跳转支付页 → 支付平台异步回调 → 后端更新订单状态。这里面最容易出问题的环节是回调处理的幂等性。支付平台的回调因为网络原因会重试多次,如果后端不做好幂等,同一笔订单可能被重复更新。

我踩过一次教训:回调里先查订单状态,发现是「已支付」就直接返回成功,但当时没加锁,两个并发回调同时进来都查到「待支付」,然后同时更新状态,结果订单状态倒是没坏,但订单支付流水表插了两条记录,对账时过不去。后来我改成了「数据库唯一约束 + 状态条件更新」双重保险:

@Override @Transactional public boolean handlePayCallback(String orderNo, String tradeNo, BigDecimal amount) { // 1. 按订单号查询订单,不存在或金额对不上返回 false Order order = orderMapper.selectByOrderNo(orderNo); if (order == null || order.getTotalAmount().compareTo(amount) != 0) { return false; } // 2. 关键一步:只有状态为 0(待支付)才更新为 1(已支付) // 两个并发回调,只有第一个能成功,第二个受影响行数为 0 int rows = orderMapper.updateStatusByOrderNoAndOldStatus( orderNo, 0, 1, tradeNo, new Date() ); if (rows > 0) { // 3. 更新成功才插入支付流水,流水表对 order_no 有唯一索引 paymentMapper.insertPayment(orderNo, tradeNo, amount); return true; } // 受影响行数为 0,说明已被处理过,直接返回成功(幂等) return true; }

这段代码的核心是updateStatusByOrderNoAndOldStatus,它的 SQL 是UPDATE t_order SET status = #{newStatus} WHERE order_no = #{orderNo} AND status = #{oldStatus}。两个并发请求执行同一句 UPDATE,行锁会阻塞第二个请求直到第一个提交,提交后第二个再执行时,status已经不是 0 了,受影响行数为 0。我把它放在事务里,保证流水插入和状态更新原子性。

这里要特别说明一个理念:幂等不靠「查询判断」,靠「条件更新 + 唯一约束」。查询判断是窗口期不可控的,条件更新是数据库层面保证的。t_payment表对order_no加唯一索引,即使极端情况下两条回调都进入插入语句,第二条会抛 DuplicateKeyException,我捕获这个异常后也返回成功——因为这意味着之前已经处理过了。

订单取消和退款,我用的状态枚举是:0 待支付、1 已支付、2 已取消、3 已退款、4 已完成。取消操作只允许待支付状态的订单操作,已支付订单走退款流程,退款调用原路退回接口后异步更新状态。整个状态机的规则只有一个:状态转换必须带旧状态条件,不允许无条件 UPDATE。

5. 电影院订票系统避坑指南:排片校验、事务失效和日期边界

5.1 排片时间重叠排查:不要把校验全压在后端接口上

现象:新增排片时,一部电影成功保存了,但前台场次列表里出现两个场次在同一影厅同一时间段的记录,其中一个还是「幽灵场次」。

原因:我在排片接口里只校验了同一影厅、同一天有没有冲突场次,但漏了「跨天」场景——比如 22:30 的场次,电影时长 120 分钟,结束时间是次日 00:30,而当天 23:00 又排了一场,两场时间其实重叠了。更隐蔽的是,数据库里存的 start_time 和 end_time 是 DATETIME 类型,直接用字符串比较在边界条件下坑很多。

解决:校验 SQL 用时间区间重叠判断。MySQL 里区间重叠的条件是start_time < newEndTime AND end_time > newStartTime,只要查出来有记录就说明重叠。另外我在插入前强制用 Java 计算 end_time(start_time.plusMinutes(duration + 20)),绝不信任前端传过来的 end_time。这个校验同时放在 Service 层和数据库层,数据库层用索引保障性能。

5.2 MyBatis 映射字段为 null 的坑:if test 判空竟然失效

现象:查询场次列表时,部分场次的价格显示为 null,但数据库里明明有值。排查发现 SQL 执行正常,结果集也有值,就是 Java 属性接不到。

原因:我在t_session表里用了别名price,在 Java 实体类Session里对应属性也是price,但 MyBatis 默认开启的驼峰映射在这里失效了。进一步定位发现,实体类里该字段类型是BigDecimal,而数据库该列是DECIMAL(10,2),这本身没问题——真正的问题出在 SQL 的SELECT s.price AS p FROM t_session s,别名改成了p,MyBatis 能识别 resultType 自动映射,但别名必须能匹配到属性名或开启驼峰转换。我用AS price后问题就消失了。

解决:统一规范——SQL 里列名要么不加别名,要么别名跟 Java 属性保持一致,避免花式缩写。这条规则我写进了团队开发规范。另一个相关坑是:if test="price != null"在 MyBatis 动态 SQL 里,对 BigDecimal 判空没问题,但对 Integer 类型,如果值为 0,!= null判断不通过。因为 MyBatis 的 OGNL 表达式里,Integer 的 0 会被解析为 false。

5.3 Service 层 this 方法调用导致 @Transactional 失效

现象:新增订单接口里,createOrder方法调用了同类里的私有方法deductSeat,测试时发现调用deductSeat改了座位状态,但后续代码抛异常后座位状态没有回滚。

原因:Spring 的@Transactional基于 AOP 动态代理实现。当外部调用createOrder方法时,进入的是代理对象,事务生效;但在方法内部this.deductSeat()调用的是当前对象的方法,绕过了代理对象,deductSeat上的@Transactional直接失效。这是 SSM 项目里最普遍的翻车现场,网上搜「事务不回滚」一大半是这个原因。

解决:把deductSeat逻辑直接在createOrder方法体里展开,或者把座位扣减放入独立的 Service 类中通过注入调用。我通常会把涉及多个表更新的逻辑放在同一个@Transactional方法里,不拆多个内部方法。如果确实要拆,就新建一个SeatService注入进来,通过另一个代理对象调用。另外@Transactional默认只回滚 RuntimeException,受检异常(如Exception)不会自动回滚,需要显式声明@Transactional(rollbackFor = Exception.class)。

5.4 日期时间边界:票根打印出来的场次时间差 8 小时

现象:部署到服务器后,后台查订单时发现支付时间比实际时间慢了 8 小时,用户收到的短信通知时间也是错的。

原因:服务器时区设置成了 UTC,而 MySQL 的 JDBC 连接串里的serverTimezone=GMT%2B8没设置。更隐蔽的是,代码里new Date()返回的是当前时区的毫秒数,存入数据库时 MySQL 会根据连接时区做转换,两边时区不一致就会错位。我在本地开发时因为电脑时区是北京时区,问题没暴露,一到云服务器就翻车。

解决:统一三个层面的时区:一是 MySQL 连接串加serverTimezone=Asia/Shanghai&useLegacyDatetimeCode=false,二是部署环境的JAVA_OPTS里加-Duser.timezone=GMT+08:00,三是数据库表字段用 DATETIME 而不是 TIMESTAMP(TIMESTAMP 会跟数据库时区转换)。这样三层一致后,时间不会再有偏差。还有一个伴生坑:查询「当日场次」用WHERE start_time >= '2025-01-13 00:00:00' AND start_time < '2025-01-14 00:00:00',别用BETWEEN,因为 BETWEEN 是闭区间,会把 23:59:59 之后的临界数据卷进来。

5.5 前端选座点击穿透:快速点击两个座位导致重复提交

现象:用户在选座页快速点击相邻两个座位,前端只弹出一个订单,但后台出现两条订单记录,其中一个座位被锁定了两次。

原因:前端点击事件的防抖没做好,两个 click 事件几乎同时发出请求,后端两个请求都进入lockSeat方法。乐观锁确实拦住了同一座位的并发,但两个不同座位的请求都成功了,订单创建逻辑却只消费了第一个请求的座位列表。

解决:前端层面给选座按钮加 500ms 的防抖,点击后立即置灰,防止快速连点。后端层面再加一道:createOrder方法里优先查当前用户是否有「待支付」状态的订单,有就直接返回原单号,不让重复建单。两道防线配合后,绝不会有重复订单。

6. 性能优化与压测验证:让订票系统扛住高峰期流量

开发完功能只是第一步,电影院订票系统的高峰流量集中在黄金档场次开售的前几分钟,瞬时并发可能上千。针对这套 SSM 架构,我从性能角度做了三件事,这套方法在项目里经过线上验证,效果直接可见。

第一件是给 MyBatis 查询加二级缓存。t_film(电影列表)这种变化极少的数据,开启二级缓存收益最高:

<mapper namespace="com.cinema.dao.FilmMapper"> <cache eviction="LRU" flushInterval="60000" size="1024" readOnly="true"/> <select id="selectHotFilms" resultType="Film" useCache="true"> SELECT film_id, film_name, duration FROM t_film WHERE status = 1 </select> </mapper>

参数说明:eviction="LRU"表示缓存满了按最近最少使用淘汰,flushInterval是 60 秒刷新一次,size是缓存对象数量上限。注意readOnly="true"很关键——如果缓存对象被代码修改,会影响下次读到脏数据;读多写少的数据直接设置只读最安全。t_session_seat这类高频更新数据绝对不开二级缓存,否则座位状态改了缓存不失效,前台看到的就是「座位明明锁了却还能点」。

第二件是数据源参数调优。我用的是阿里巴巴 Druid 连接池,核心参数这么配:initialSize=5、minIdle=5、maxActive=50、maxWait=60000。压测时发现maxActive如果设太小,高峰期连接被占满,后续请求全部阻塞在获取连接上,表现为接口 RT 突然飙升到 5 秒以上。经测试 50 个连接在这个业务量下足够,每个连接被打满时 CPU 先到瓶颈。另外连接池的testWhileIdle和testOnBorrow一定要开,不然数据库主动断开空闲连接后,池里还留着死连接,第一次查询直接报 CommunicationsException。

第三件是读写分离的预留方案。t_order和t_session_seat的写入都在事务里,但前台查询场次列表、电影列表压力最大。我把这些查询接口的数据源切到只读库,主库专心处理写事务。具体做法是在 Spring 配置里配置两个 DataSource,用 AbstractRoutingDataSource 做动态切换,方法上通过自定义注解标记走哪个库。

压测验证我用的工具是 JMeter,重点看两个指标:一是锁座接口的 TPS 和错误率,二是订单支付的最终一致性。压测方案是 200 线程并发锁同一个场次的座位,预期结果是每个座位最多被成功锁定一次,TPS 不低于 50。实测压测完检查t_session_seat表,如果出现同一 seat_id 被两个用户锁定(lock_user_id 不同),说明锁逻辑有漏洞,需要回到updateStatusByCondition检查 where 条件是否带了旧状态。

另一个验证点是订单状态和支付流水的对账。我写了一个校验脚本,压测结束后跑一遍:查所有「已支付」订单是否都有对应流水,查「待支付」订单是否超时未释放座位。这套校验脚本后来我保留下来了,每次上线前都跑一遍,比手动点页面测试靠谱得多。

最后额外提一句部署层面的稳定习惯:SSM 项目的 Tomcat 里,JSP 编译缓存目录要配到临时目录而不是项目目录,否则每次重新部署都报 JSP 编译失败。另外spring-web.xml里开启SpringMVC的异常解析器,统一返回 JSON 错误信息,比默认的 500 页面友好太多。关于这套电影院订票系统,我踩过的坑远不止文中这些,但最值得记住的一条教训是:数据库层面的条件更新是票务系统防并发的最后一道防线,不要把所有希望寄托在代码判断上。希望我的这些经验能让你在搭这套系统的时候少走弯路,尤其是并发扣座和支付回调这两块,做稳了,系统就成功了一大半。

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

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

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

立即咨询