ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于SSM的电影院订票选座系统:表设计、事务与并发控制实战

基于SSM的电影院订票选座系统:表设计、事务与并发控制实战 简介这是一份基于SSM框架构建的电影院订票选座系统完整项目资料面向JavaWeb方向的在校学生、毕业设计者以及需要快速搭建票务类系统的开发者。资源围绕选座购票这一核心流程实现了影片信息展示、在线选座、订单生成与支付、后台排片和座位管理等模块并兼顾了并发订票时的数据一致性及登录校验、通信加密等安全设计。压缩包共1230个文件大小54.96MB文件类型覆盖Java后端源码含Mapper、Service、Controller分层、Vue前端页面、wxml/wxss小程序页面与样式、SQL数据库脚本以及XML、YML等配置另含有doc说明文档、mp4运行演示、bat环境脚本、png/jpg界面预览图等目录分层清晰方便按模块检索。目前已有19人浏览学习。资料提供完整项目源码与数据库初始化脚本附带部署说明、构建命令和演示视频可帮助读者快速复现系统追踪前后端交互流程理解SSM整合、座位状态更新与订单事务处理等关键逻辑对同类课程设计或企业级JavaWeb开发有直接参考价值。1. 电影院订票选座系统为什么值得你亲手搭一遍周末黄金场次一开售座位图上的红色区域就在几秒内蔓延用户反复提交订单却拿不到想要的座位——电影院订票选座系统的核心难点从来不是 CRUD而是“同一个座位在并发下只能被一个人锁定”。基于 SSM 框架实现这套系统是 Java Web 入门者能接触到的最贴近真实业务的项目之一它同时涉及数据库表设计、事务边界、并发控制、前后端联调比单纯做后台管理系统的收获大得多。这套方案适合三类人准备毕业设计的学生需要一个业务闭环完整、能讲清楚技术亮点的项目Java 后端初学者想搞明白 SSM 三件套在一个真实系统里是如何协作的以及小规模影院或点映活动的运营者需要一个轻量、可私有化部署的订票后台。顺着“设计及实现”这个标题我会把架构、表结构、核心代码、并发坑点和部署验证一条线讲透。2. SSM 架构拆解订票选座系统里三个框架各管哪一段2.1 SSM 在电影院选座场景中的分工逻辑Spring、SpringMVC、MyBatis 这三个框架在订票系统里各司其职理解它们的边界比会配置重要得多。MyBatis 负责最底层的数据持久化——座位状态查询、订单写入、座位锁定更新这些 SQL 都写在 Mapper 层SpringMVC 负责 HTTP 层的请求路由和参数绑定用户点击座位、提交订单、查询场次这些请求由 Controller 接收Spring 则是粘合剂管理 Service 层的对象生命周期和事务边界。在选座这个具体场景里三者的协作顺序非常典型。用户请求到达 Controller 后SpringMVC 把请求参数绑定成一个 VO 对象Controller 调用 Service 层的方法Spring 的事务代理在这里生效——如果方法里后续的座位更新失败整个事务会回滚Service 调用 Mapper 接口MyBatis 通过动态代理执行 XML 里写好的 SQL把 seat 表的 status 字段从 0 改成 1。这三层缺一环选座系统都会出问题没有事务座位改了但订单没建成没有合理的 SQL高并发下同一座位被卖出两次没有 Controller 的参数校验前端传一个负数座位号也能打到数据库。这类系统里我一般建议包结构按 controller、service、mapper、entity、vo 分层。实体类只做字段映射VO 用来接收前端参数和返回结果不要把两者混用——订票场景里前端提交的是一个座位 ID 列表而后端返回的是订单号和总价两个数据结构差异很大硬用一个类会让代码越写越乱。2.2 支撑选座的数据库设计五张核心表和两个关键决策选座系统的数据库设计有两个关键决策点直接决定后续代码写起来顺不顺手。第一个决策是座位表怎么关联场次。常见做法是 seat 表不单独存在而是构建一张 session_seat 关联表把所有座位在某个场次下的状态存进去。原因很简单同一个影厅的同一个座位在不同场次下的状态是独立的——上午场没人坐晚上黄金场已经被锁定了。如果把座位状态存在 seat 表里场次之间会互相污染。第二个决策是订单表如何处理多个座位。一张订单对应多个座位这是典型的一对多关系。常见做法是拆成 order 主表和 order_seat 子表主表存订单号、用户 ID、总价、状态子表存订单号、场次 ID、座位 ID、单价。这样设计的好处是取消订单时只需要把子表里对应座位的状态回滚不需要解析字符串。下面是精简后的建表语句省略了冗余字段保留了核心逻辑-- 影片表 CREATE TABLE film ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, duration INT NOT NULL COMMENT 时长(分钟), price DECIMAL(8,2) NOT NULL COMMENT 基础票价 ); -- 影厅表 CREATE TABLE hall ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, row_count INT NOT NULL COMMENT 排数, col_count INT NOT NULL COMMENT 列数 ); -- 场次表 CREATE TABLE session ( id INT PRIMARY KEY AUTO_INCREMENT, film_id INT NOT NULL, hall_id INT NOT NULL, start_time DATETIME NOT NULL, FOREIGN KEY (film_id) REFERENCES film(id), FOREIGN KEY (hall_id) REFERENCES hall(id) ); -- 场次座位表每个座位在场次下的实时状态 CREATE TABLE session_seat ( id INT PRIMARY KEY AUTO_INCREMENT, session_id INT NOT NULL, seat_row INT NOT NULL COMMENT 第几排, seat_col INT NOT NULL COMMENT 第几列, status TINYINT NOT NULL DEFAULT 0 COMMENT 0可售 1锁定 2已售, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, FOREIGN KEY (session_id) REFERENCES session(id), UNIQUE KEY uk_session_seat (session_id, seat_row, seat_col) ); -- 订单主表 CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, user_id INT NOT NULL, session_id INT NOT NULL, total_amount DECIMAL(8,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已取消, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no) ); -- 订单座位子表 CREATE TABLE order_seat ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, session_seat_id INT NOT NULL, price DECIMAL(8,2) NOT NULL, FOREIGN KEY (order_id) REFERENCES orders(id), FOREIGN KEY (session_seat_id) REFERENCES session_seat(id) );这里值得注意的一个细节是 session_seat 表加了 version 字段这个字段是后面处理并发锁座位的关键。version 字段配合带条件更新使用具体写法在第四章讲并发时会展示。另一个细节是 orders 表里 order_no 设置了唯一索引——用户重复点击提交按钮时同样的订单号写不进去这比在代码里做判断要可靠得多。只建表不足以支撑业务初始化数据也是必做的一步。系统启动时或后台新增场次时要遍历影厅的 row_count 和 col_count为当前场次生成全部座位记录SQL 可以用存储过程也可以用 Java 代码循环插入。这里我建议用代码生成而不是手动写 SQL因为插入条数等于排数乘以列数常见的影厅规模在 8 排、10 列左右手动拼 INSERT 语句很容易出错。初始化完成后还需要为 session_seat 表生成一个座位图的坐标范围缓存供前端绘制座位图使用。2.3 SSM 整合配置文件连接池、扫描路径和事务管理器表结构确定后下一步是搭 SSM 的运行环境。整合配置容易踩的坑集中在三处包的扫描路径不一致、事务管理器没生效、MyBatis 的 mapper 映射文件位置没配对。下面这段是 applicationContext.xml 中关于扫描和事务的配置配合 spring-mvc.xml 里的注解驱动即可跑通。!-- 开启注解驱动识别 Controller Service Repository 等注解 -- context:component-scan base-packagecom.cinema / !-- 配置事务管理器 -- bean idtransactionManager classorg.springframework.jdbc.datasource.DataSourceTransactionManager property namedataSource refdataSource / /bean !-- 启用基于注解的事务管理 -- tx:annotation-driven transaction-managertransactionManager / !-- 配置 MyBatis SqlSessionFactory加载 Mapper XML 映射文件 -- bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource / property namemapperLocations valueclasspath:mapper/*.xml / /bean !-- 扫描 Mapper 接口生成代理对象 -- bean classorg.mybatis.spring.mapper.MapperScannerConfigurer property namebasePackage valuecom.cinema.mapper / property namesqlSessionFactoryBeanName valuesqlSessionFactory / /bean这组配置里最容易被忽略的坑是 component-scan 的 base-package 与 MapperScannerConfigurer 的 basePackage 必须覆盖到实际代码所在的包不能为了图省事扫描一个过大的父包否则会引发 Bean 定义冲突。事务管理器依赖 dataSource所以 dataSource 的定义必须在这个文件里先出现推荐使用 Druid 或 HikariCP 连接池并为它设置连接池大小和超时时间。MyBatis 的 mapperLocations 指向 classpath:mapper/*.xml意味着 Mapper 接口的 XML 文件必须放在 resources/mapper 目录下。事务边界的使用同样有讲究在订票系统中锁定座位、生成订单这两个操作必须放在同一个事务里。当你在 Service 方法上标注Transactional后Spring 会为这个方法生成一个代理对象任何异常都会触发事务回滚。但需要注意Transactional只对 public 方法生效并且同一个类内部方法间的自调用不会经过代理事务不会生效——这个问题在真实项目中是排查了很长时间才发现的“黑匣子”。正确做法是把锁座位和创建订单放到不同的 Service 类中通过注入的 Bean 去调用或者将这两个操作封装在同一个 Service 方法里并确保方法为 public 且被外部调用。3. 核心链路实现从座位图渲染到订单落库3.1 后端选座接口的代码实现与状态流转选座接口是整个后端最核心的业务逻辑它要做的事可以拆成四步接收前端传来的座位 ID 列表校验这些座位在当前场次是否为可售状态将全部选中座位一次性更新为锁定状态并生成订单最后返回订单号。这四步必须在一个事务里完成否则一旦后续步骤失败前面锁定的座位就会残留。下面是选座 Service 层的核心代码包括座位校验和锁定的 Mapper SQL以及事务控制的部分实现。Service public class SeatServiceImpl implements SeatService { Autowired private SessionSeatMapper sessionSeatMapper; Autowired private OrderMapper orderMapper; Transactional(rollbackFor Exception.class) Override public OrderVO selectSeats(SeatSelectRequest req) { // 1. 查询当前场次的全部座位状态 ListSessionSeat seats sessionSeatMapper.findBySessionId(req.getSessionId()); MapLong, SessionSeat seatMap seats.stream() .collect(Collectors.toMap(SessionSeat::getId, Function.identity())); // 2. 校验每个座位是否可售 ListLong seatIds req.getSeatIds(); for (Long seatId : seatIds) { SessionSeat seat seatMap.get(seatId); if (seat null || seat.getStatus() ! 0) { throw new BusinessException(座位 seatId 已不可选); } } // 3. 乐观锁批量更新座位状态 从可售改为锁定 int updated sessionSeatMapper.lockSeats(req.getSessionId(), seatIds); if (updated ! seatIds.size()) { throw new BusinessException(座位已被他人锁定请刷新后重试); } // 4. 生成订单并写入订单座位关系 Order order new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(req.getUserId()); order.setSessionId(req.getSessionId()); order.setStatus(0); // 待支付 order.setTotalAmount(calculateAmount(seatIds, req.getSessionId())); orderMapper.insert(order); for (Long seatId : seatIds) { orderMapper.insertOrderSeat(order.getId(), seatId); } return buildVO(order, seatIds); } }这段代码逻辑上比较清晰但有两个地方容易让人栽跟头。第一步把全部座位一次性查出再在内存里校验好处是避免了对数据库的多次查询代价是如果一场次座位数很大比如上千个座位内存占用会偏大但对于电影院这种百级座位的场景是合理的没必要为了面面俱到做成分页查询。第三步的lockSeats方法在并发下是否能真正拦住超卖不取决于 Java 代码写得如何而取决于对应的 Mapper SQL 有没有用到WHERE status 0这个条件。订单号我习惯用时间戳加随机数的组合来生成形如20250101120000123同时在数据库上加了唯一索引。这样即使生成逻辑出现并发问题数据库也能挡住重复订单形成“最后一道后悔药”。计算总金额时同样要注意不能信任前端传过来的总金额要在后端根据座位 ID 重新从库里查影片价格计算这是所有涉及支付的业务应该具备的基本防护意识。3.2 SeatMapper 的 XML 映射与乐观锁 SQL 写法选座功能的成败压倒性地取决于 Mapper XML 里lockSeats这条 SQL。如果不带状态条件两个并发请求会同时把同一个座位从 0 改成 1这就是超卖的技术根源带上了status 0条件后数据库的行锁会让第二个请求的更新等待第一个请求提交后再执行。!-- 批量锁定座位乐观锁方式 -- update idlockSeats parameterTypemap UPDATE session_seat SET status 1, version version 1 WHERE session_id #{sessionId} AND id IN foreach collectionseatIds itemid open( separator, close) #{id} /foreach AND status 0 /update !-- 查询场次座位列表 -- select idfindBySessionId resultTypecom.cinema.entity.SessionSeat SELECT id, session_id, seat_row, seat_col, status, version FROM session_seat WHERE session_id #{sessionId} ORDER BY seat_row, seat_col /selectlockSeats的返回值是一个 int表示数据库实际更新的行数。如果这条 SQL 在并发下只更新了 0 行Java 代码里updated ! seatIds.size()的条件会让整个事务抛出异常并回滚订单不会生成用户的选座请求会失败并要求重试。这里建议保留 status 判断而不要只依赖 version因为 status 0 的语义更直接地表达了“座位必须是可售状态才能被锁定”version 字段更像是后备保险用来防止某些绕过状态判断的极端情况。foreach批量的写法在这里比 for 循环单条更新要好因为一条 UPDATE 语句只占用一次网络往返对一个订单里通常 1 到 5 个座位的量级来说足够完成。超过 10 个座位的订单在电影院场景并不常见没必要做成临时表关联更新那样复杂度会上升而没有收益。3.3 前端座位图与后端状态的同步机制前端的技术选型可以是 Vue 或原生 JavaScript也可以用 Thymeleaf 服务端渲染核心在于座位图必须和后端状态保持一致。这里说一下常用做法不限定具体框架。页面加载时前端调用/session/{id}/seats接口获取场次座位状态列表接口返回一个二维数组或带坐标的对象数组。前端根据每个座位的状态渲染成不同颜色灰色代表已售或已锁定白色代表可售黄色代表当前用户选中。用户点击座位时前端先把座位标记为选中状态提交订单时再把选中的座位 ID 列表发给后端——不要在用户点击的瞬间就请求后端锁座位那样会产生大量无意义的请求也不要把锁座位的动作推迟到支付环节否则用户在支付前座位可能已经被别人抢走。关于状态同步的另一个细节订单支付完成后前端需要主动刷新座位图把锁定状态改成已售订单取消或超时未支付时后端要定时释放过期的锁定座位。我通常用数据库的定时任务或 Spring Task每 15 分钟扫描一次 status 1 且创建时间超过 30 分钟的订单回滚座位状态。这个释放策略虽然不够精细但实现成本低而且对用户来说感受是合理的——超过支付时限座位自动释放和主流平台的规则一致。4. 并发是选座系统的试金石四大典型踩坑记录与解决方案4.1 现象同一个座位被两个用户同时下单成功超卖问题频发这是选座系统上线初期最容易暴露的问题。两个用户同时看中 5 排 7 座分别提交订单系统都返回了下单成功。原因出在“先查后写”的处理模式上两个请求同时查出座位状态为 0然后其中一个把状态改成 1另一个也把状态改成 1update 语句没有加任何约束条件。解决这一问题的核心在两条线同时起作用。第一层是数据库条件约束就是第三章里lockSeats的WHERE status 0条件——数据库的行锁会保证并发场景下只有第一个请求能更新成功第二个更新的实际行数为 0第二层是事务的隔离级别默认的 READ_COMMITTED 级别下当第一个请求未提交时第二个请求的更新会等待行锁释放提交后再执行此时它发现状态已经是 1更新行数为 0。这两层机制叠在一起从根上杜绝了超卖。注意 Java 代码里的updated ! seatIds.size()判断在这里并非可有可无它是把“数据库更新失败”转化为“用户可见的业务异常”的关键一环。4.2 现象页面显示座位可售点击后提示已被锁定这个现象常被误以为是并发问题但实际多是缓存与数据一致性造成的。前端或后端引入了 Redis 缓存座位状态但缓存更新时机不对导致页面显示可售、数据库里实际已锁定。另一个常见原因是用户长时间停留在选座页面期间座位被他人锁定但页面没有轮询刷新。解决方式有两种任选其一。不做缓存每次请求直接查数据库对于电影院这种读多写少但总量不大的场景完全够用如果要上缓存就一定要设置合理的过期时间并且在下单锁座成功时主动失效对应场次的座位缓存不要只依赖默认的缓存淘汰策略。5 排 7 座这个位置的库存只存在一个场次里这种热点覆盖范围很小主动失效的成本几乎可以忽略。4.3 现象Transactional 加了注解但事务没有回滚这是 SSM 项目中最经典的“黑匣子”问题。异常抛出后座位依然被锁定订单没有生成数据处于半完成状态。排查发现事务根本没生效。原因通常是两个一是Transactional加在了非 public 方法上Spring 不会为它创建事务代理二是同类内部方法自调用比如selectSeats方法里直接调用了同一个类里的lockSeat方法绕过了代理对象事务管理形同虚设。解决方式很简单但需要养成习惯事务注解只加在 public 方法上且方法必须是被外部 Bean 调用的。如果代码里有事务方法嵌套要拆分到不同的 Service 类中通过注入的方式调用。另外建议在Transactional里显式指定rollbackFor Exception.class因为 Spring 默认只对 RuntimeException 回滚自定义的业务异常继承自 Exception 时不会触发回滚这是很多项目里“事务吞掉异常”的直接原因。遇到这种问题先用日志在方法入口和出口各打一条记录确认事务代理是否真的进入了方法。4.4 现象并发压测时数据库连接被耗尽用 JMeter 模拟 500 个并发用户抢票运行一分钟后数据库连接池报出无可用连接的异常。根源在于连接池大小配置不合理或者某个查询 SQL 没有走到索引导致连接被长时间占用。选座场景的查询模式集中在session_id上所以session_seat表必须建session_id的索引order_seat表必须建order_id的索引。连接池参数方面HikariCP 推荐初始值 10、最大值 20超过 20 个并发时数据库连接操作会进入队列等待。还有一个性价比很高的做法是打开 SQL 慢查询日志把超过 200ms 的查询抓出来单独优化。电影院选座系统不是高并发互联网应用不要在连接池和缓存上堆太多资源先把 SQL 的索引用好把事务的粒度控制好性能瓶颈基本就解决了。5. 本地跑通项目与联调部署步骤和常见异常排查5.1 初始化数据库和导入依赖的完整步骤从拿到一份 SSM 电影院订票选座项目源码到能在本地跑起来核心步骤是导入 SQL 脚本、配置数据库连接、启动 Maven 依赖下载、部署到 Tomcat。这个流程每一步都可能出问题下面按操作顺序列出步骤和对应的参数说明。创建数据库并执行项目附带的cinema.sql脚本。执行前先检查 MySQL 版本脚本里如果含DATETIME DEFAULT CURRENT_TIMESTAMP语法需要 MySQL 5.6.5 以上版本支持。建库时统一使用 utf8mb4 字符集。修改jdbc.properties文件里的数据库地址、用户名、密码。这里最常见的错误是连接 URL 没加characterEncodingutf8useSSLfalse导致插入中文数据乱码或连接报错。连接池参数可以先用initialSize5、maxActive20的保守配置。在 IDEA 或 Eclipse 中导入 Maven 项目等待依赖下载完成。SSM 项目依赖较多第一次下载可能需要几分钟出现下载失败时配置阿里云镜像可以解决问题。配置 Tomcat 的 Deployment将项目 artifact 部署到 Tomcat运行环境选择 JDK 1.8 或对应版本。启动后访问/login或/index页面验证前端是否正常渲染。启动过程中如果报ClassNotFound或NoClassDefFoundError基本可以断定是依赖缺失或 jar 包冲突优先检查 pom.xml 里 spring-webmvc、mybatis-spring、mysql-connector 的版本是否匹配。SSM 的经典版本组合有很多种选一套自己能讲清楚的即可不要盲目追求新版本因为新版本往往需要额外的配置项。5.2 三个最常遇到的启动异常404、500、空指针启动成功但访问页面 404通常是 SpringMVC 的 URL 映射配错了。前端请求的路径和后端 Controller 的RequestMapping不一致或者 web.xml 里 DispatcherServlet 的url-pattern配置为*.do而访问路径没有带.do。排查方式很简单看 Tomcat 控制台是否输出了“Mapping”相关的日志再用浏览器的开发者工具看请求的实际 URL 拼装。500 错误多数是 MyBatis Mapper XML 没被加载。注意mapperLocations的路径要和实际文件位置一致文件后缀必须是.xml。还有一个隐蔽问题MyBatis 3.4 以上版本对接口方法返回值类型要求更严格findBySessionId如果返回ListXML 里resultType不能写成实体类的字符串要写全限定名或用别名否则会报TypeException。空指针在登录和获取用户信息时出现得最多。代码里从 session 取当前用户对象时做了强转但 session 过期后对象是 null。建议所有从 session 取用户的代码统一封装成一个方法内部判空并抛出业务异常而不是返回 null 让上层继续处理。经验是项目中凡是可能出现 null 的地方都要在代码层面明确“是允许 null 还是不允许 null”。声明的越清楚排查空指针时越容易定位。6. 进阶技巧把订票系统从“能跑”提升到“上线可用”如果只是把 CRUD 写出来交差这个项目练不到多少东西。我建议在基础功能上再做三件事为热点场次加一层轻量缓存、为提交订单接口做幂等控制、把支付后的座位状态做一个对账任务。热点场次的缓存适合放在 Rediskey 设计为session:seats:{sessionId}value 存座位状态 JSON。缓存策略是“先更新数据库再删除缓存”而不是先删缓存再更新数据库——显然后者会导致缓存与数据库短暂不一致。由于选座页面只在用户刚进入或点击场次时读取一次缓存命中率会很高但要注意任何座位状态变更成功后必须立即删除该场次的缓存否则用户看到的座位图可能滞后一步。订单幂等控制是另一个实战价值很高的技巧。前端提交订单按钮在弱网环境下会触发用户重复点击后端如果不对同一个请求做幂等会产生两个相同的订单。常见做法是前端生成一个 uuid 作为请求唯一标识后端在缓存或数据库里记录这个 id第一次提交时正常处理第二次提交时直接返回第一次的结果。接口幂等实现不复杂但对支付的正确性意义重大。座位对账任务用来处理“状态不一致”。比如已支付的订单没有座位记录或座位处于锁定状态但对应订单已经取消。我通常写一个定时任务每 5 分钟扫描一次所有字段可疑的记录输出到日志表里人工复核。这一个任务在真实项目里价值不亚于选座功能本身。这个项目刚开始做的时候我也经历过页面 404、事务不生效、数据库插入乱码每一个问题都是一行行日志翻出来的。走通一遍之后你会对 SSM 项目的运行机制有全局认识再回头看那些“框架都一样、业务不同”的说法就能明白架构选型的关键在于清楚每个框架的边界在哪里。这个方向值得深入但更重要的是把并发、事务、缓存这几件事吃透它们在任何语言和框架下都是通用的。希望帮到你。本文还有配套的精品资源点击获取
返回列表