☰
Java Web实战:基于SpringBoot与SSM的影院购票系统设计与并发锁座实现
2026/10/13 10:17:12 网站建设 项目流程

这标题一眼看上去很像传统的“课程设计/毕业设计”命名法,里面提到的 Java、SpringBoot、SSM、影院购票管理系统,基本就是一套很典型的 Java Web 全栈练手项目。我自己当年做类似项目时,最大的感受是:这类系统功能看起来“就那回事”,但真正从零搭到能演示、能答辩、敢把源码给别人去跑,坑一点都不少。这篇文章就把我从需求拆解、数据库设计、并发锁票到最后整理交付物(LW、调试文档、讲解)的完整过程写出来,尤其是并发控座那段,值得反复看。

1. 从题目到系统:先想清楚影院购票到底要解决什么

很多同学拿到“影院购票管理系统”这个题目,第一反应是:不就是电影列表加一个购买按钮吗?真上手做才发现,业务链路比想象中长得多。如果连“用户从进入系统到拿到票”这条主流程都没想清楚,后面写代码就是在给自己挖坑。

1.1 核心用户流:这道题考的不是增删改查,是流程闭环

一个影院购票系统,最基本的角色有两类:普通用户和管理员。

普通用户视角下,这条链路是完整的:

  1. 注册/登录,不然订单没法归属到人。
  2. 浏览正在热映的电影,按上映时间、评分、价格筛选。
  3. 选择某部电影后,进入排片列表,这里必须展示放映厅、场次时间、语言版本(原声/国语)。
  4. 选定场次后进入选座页,看到这个厅的座位布局,已售座位要置灰不可选。
  5. 选多个座位,生成订单,进入“支付”环节——真实项目接支付宝/微信,但大多数课程设计用模拟支付。
  6. 支付成功后,座位变成已售状态,用户可以查看订单详情,里面有取票码或二维码。
  7. 如果要退票,需要在开场前一定时间内申请,后台审核后释放座位、退款。

管理员视角则是反过来的管理链:

  • 管理电影信息(上映、下架)。
  • 管理影厅与座位布局。
  • 维护场次排片:一个厅一天可以排很多场,每场对应一个电影。
  • 处理订单,查看销售统计。

请注意第4步和第7步,这是整个系统的灵魂——座位状态和订单状态是绑在一起的。你的数据库设计、事务控制、并发策略全部围着这两步转。

1.2 功能分级:别什么都往上加,先保住主流程

我见过不少同学一上来就规划“会员积分”“优惠券”“弹幕评论”“电影推荐算法”,结果数据库建了二十张表,页面写了十几个,最后主流程跑不通,答辩现场尴尬到脚趾抠地。

做这类系统,正确姿势是功能分级:

优先级功能模块理由
P0(必须)登录注册、电影浏览、场次选择、选座锁座、订单生成、模拟支付、退票释放座位、后台排片管理没有这些,题目本身就不成立
P1(尽量)座位状态实时刷新、订单超时未支付自动取消、简单的销售统计图表能明显提升系统完成度,成本可控
P2(加分项)优惠券、会员等级、评论、影片预告片播放有时间再做,没时间坚决砍掉

我最终交付的版本,只做了 P0 加部分 P1。答辩时老师关心的根本不是功能多不多,而是“你这个座位是怎么防止两个人同时买到的”这种业务难点。你把核心链路做扎实,比堆一堆花哨页面有用得多。

2. 别再纠结SpringBoot和SSM是不是二选一

标题里同时出现 SpringBoot 和 SSM,很多人第一反应是:这俩不是一个东西吗?为什么并列?其实这恰好是 Java 后端学习路线里最经典的一段演进史。

2.1 SSM到SpringBoot:不是替代,是封装

SSM 指的是 Spring + SpringMVC + MyBatis 三个框架的组合。在 SpringBoot 出现之前,你要用这三者搭一个 Web 项目,得写一大堆 XML 配置:数据源配置、事务管理器配置、MyBatis 的 SqlSessionFactory 配置、SpringMVC 的视图解析器配置、组件扫描配置……每一样都得手动维护,稍不注意版本冲突就启动失败。

SpringBoot 做的事情,是“约定优于配置”。它把 Spring 全家桶的常用配置做了自动装配,你只需要在 Maven 依赖里引入 spring-boot-starter-web、spring-boot-starter-jdbc、mybatis-spring-boot-starter,启动类写一个 @SpringBootApplication,就能把 SSM 的底子全部跑起来。

所以你在这个项目里看到的 SpringBoot 和 SSM,根本不是竞争关系。我们的技术方案就是:用 SpringBoot 做项目骨架,内部依旧沿用 SpringMVC 的 Controller/Servlet 模型做 Web 层,用 MyBatis 做持久层。换句话说,SpringBoot 是壳,SSM 是核。很多公司里跑的老项目,以及市面上大量的 Java 课程设计,都是这么个结构。

2.2 项目分层:按这个结构拆,后面不会想骂人

我见过把 Controller、Service、Mapper 全写在一个类里的“大泥球”代码。当时能跑,但后面加一个退票功能,改一个字段,要找半天。合理的目录结构应该长这样:

src/main/java/com/cinema/ ├── controller/ # 表现层:接收请求、参数校验、返回JSON │ ├── UserController.java │ ├── FilmController.java │ ├── SessionController.java │ ├── OrderController.java │ └── AdminController.java ├── service/ # 业务层:事务边界在这里控制 │ ├── OrderService.java │ └── SeatLockService.java ├── mapper/ # 持久层:MyBatis的Mapper接口 │ ├── FilmMapper.java │ ├── SessionMapper.java │ ├── SeatMapper.java │ └── OrderMapper.java ├── entity/ # 数据库实体类 ├── dto/ # 前后端交互的数据传输对象 ├── common/ # 统一返回体、异常处理、工具类 └── config/ # 跨域配置、拦截器配置等

Controller 里不要写业务逻辑,只负责接收参数和返回结果;事务注解加在 Service 实现类上;Mapper 只做 SQL 映射。这样每一层职责清晰,出了问题能直接定位到类。

3. 数据库设计:为什么座位库存不能只靠订单表反推

如果说整体架构是骨架,那数据库设计就是这套系统的地基。影院购票系统和普通商品秒杀系统最大的区别在于:座位是二维的,商品库存是一维的。同一个场次里,座位 3排5座 只能被一个人锁住,但座位 3排6座 可以被另一个人同时下单。所以你的库存控制必须精确定位到“某个场次下某一个座位”,而不是简单的库存数量加减。

3.1 核心表结构与关系

我最终稳定下来的核心表有六张:电影表、影厅表、场次表、座位表、订单表、用户表。

影厅表可以很简单,记录厅名(1号厅、IMAX厅)和座位规模(如10排,每排12座)。电影表记录片名、时长、导演、简介、海报地址。这两张表逻辑比较简单,不细说。

关键是场次表:

CREATE TABLE session_info ( id BIGINT PRIMARY KEY AUTO_INCREMENT, film_id BIGINT NOT NULL, hall_id BIGINT NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, price DECIMAL(10, 2) NOT NULL, status TINYINT DEFAULT 0, -- 0: 可售, 1: 已锁场, 2: 已结束 create_time DATETIME DEFAULT CURRENT_TIMESTAMP );

场次表关联了电影和影厅,还带了一个“状态”字段。为什么要状态?因为同一个影厅不能同时排两场,后台排片时要校验时间冲突;一旦开场时间到了,这个场次状态改成“已结束”,前端就不能再卖票了。

然后是很多初学者容易偷懒的座位表:

CREATE TABLE seat_status ( id BIGINT PRIMARY KEY AUTO_INCREMENT, session_id BIGINT NOT NULL, row_no INT NOT NULL, col_no INT NOT NULL, status TINYINT NOT NULL DEFAULT 0, -- 0: 可选, 1: 已锁, 2: 已售 lock_time DATETIME NULL, order_id BIGINT NULL, version INT NOT NULL DEFAULT 0, UNIQUE KEY uk_session_seat (session_id, row_no, col_no) );

这张表是全文的核心。每一行代表“某个场次中的某一个座位”,状态字段区分可选、锁定、已售三个值。有人可能会问:为什么不直接通过订单表反推座位是否已售?原因有两个:

  • 订单状态是变化的,用户下单后可能取消、可能支付失败、可能超时未支付。如果座位状态实时跟着订单状态走,那么每次查询座位图都要 join 订单表,还要处理订单历史记录,逻辑非常绕。
  • 座位状态需要支持“先锁定,后支付”的中间态,订单表无法直观表达这种临时占座。

所以我的做法是:座位状态表独立存在,当用户选座下单时,先把座位从“可选”改成“已锁”,并记录锁定时间;支付成功后改成“已售”,同时把 order_id 反填到座位行上。这样查询已售座位只需要查一张表,通过 order_id 还能反查订单,两条路都走得通。

3.2 订单表:既要展示信息,又要留“快照”

订单表的设计也有讲究:

CREATE TABLE order_info ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, user_id BIGINT NOT NULL, session_id BIGINT NOT NULL, film_name VARCHAR(100) NOT NULL, hall_name VARCHAR(50) NOT NULL, start_time DATETIME NOT NULL, seat_desc VARCHAR(200) NOT NULL, -- 快照:如 "3排5座,3排6座" total_amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0, -- 0: 待支付, 1: 已支付, 2: 已取消, 3: 已退票 create_time DATETIME NOT NULL, pay_time DATETIME NULL );

注意到 film_name、hall_name、start_time、seat_desc 这些字段,其实可以通过 session_id 和 seat 表 join 出来,为什么还要冗余存一份?因为电影可能下架,场次可能删除,订单作为历史记录必须保留用户购买当时看到的信息快照。这个设计思路在真实电商项目里也经常见到:订单快照不同于实时数据。

3.3 时区和日期字段:Java 8 时间类型别踩坑

现在的项目基本都用 LocalDateTime 而不是 java.util.Date。使用 LocalDateTime 时引入的 Web 层序列化问题,我在第5章会专门讲。这里先记住一点:数据库 DATETIME 类型对应 Java LocalDateTime 是常规用法,但如果你要支持多时区,就得考虑 TIMESTAMP 或使用 UTC。做课程设计的话,DATETIME + LocalDateTime 够用,不用过度设计。

4. 并发锁座:我被超卖问题折磨的过程

这部分是整个项目里最有技术含金量的地方,也是面试官和答辩老师最爱追问的点。核心问题描述起来就一句话:两个用户同时选同一个座位,怎么保证只有一个人能买到?

听起来简单,但真正用代码实现时,稍不注意就出 Bug。

4.1 错误示范:先查状态再插入

很多人的第一版逻辑是这样的:

// 伪代码:不可行 public boolean lockSeat(Long sessionId, int rowNo, int colNo) { Seat seat = seatMapper.findBySessionAndSeat(sessionId, rowNo, colNo); if (seat.getStatus() != 0) { return false; // 已被锁或已售 } seatMapper.updateStatus(sessionId, rowNo, colNo, 1); return true; }

在单线程测试环境下,这个逻辑没问题。但一旦有两个线程同时执行到seatMapper.findBySessionAndSeat这一行,两人读到的都是 status=0,然后各自执行 updateStatus,结果两个人都说锁座成功。

这就是典型的先查后改(check-then-act)竞态条件。程序员的直觉不靠谱,数据库的并发才是真相。

4.2 乐观锁方案:版本号字段解决竞争

我的第一版修正方案是乐观锁,也就是刚才座位表里那个 version 字段排上了用场:

@Transactional public boolean lockSeat(Long sessionId, int rowNo, int colNo) { // 1. 查询当前版本号 Seat seat = seatMapper.findBySessionAndSeat(sessionId, rowNo, colNo); if (seat.getStatus() != 0) { return false; } // 2. 更新时带上版本号条件 int rows = seatMapper.lockSeatByVersion(sessionId, rowNo, colNo, seat.getVersion()); return rows == 1; }

对应的 SQL:

<update id="lockSeatByVersion"> UPDATE seat_status SET status = 1, version = version + 1, lock_time = NOW() WHERE session_id = #{sessionId} AND row_no = #{rowNo} AND col_no = #{colNo} AND version = #{version} </update>

这里的原理是:当多个线程同时读到 version=0,去更新时 SQL 里的AND version=0只有一条执行成功,影响行数为 1;其他线程因为版本号已经变成 1,更新影响行数为 0,自然返回 false。

实测下来这个方案非常稳。日常场景下,大学生做项目用乐观锁完全够。但这里有一个隐藏问题:你执行完 select 到执行 update 之间,其他事务可能已经修改了数据,你的 update 会失败;此时你的事务里如果还插入了订单,就会产生一张“孤儿订单”。所以我必须在事务里先锁座,锁座成功才插入订单,锁座失败抛出异常,整个事务回滚。

4.3 悲观锁方案:SELECT ... FOR UPDATE 的体验

如果座位状态需要长时间锁定(比如用户支付过程中要一直占座),乐观锁的“失败重试”体验不够好。更接近真实电影院系统的做法是悲观锁:

SELECT id FROM seat_status WHERE session_id = #{sessionId} AND row_no = #{rowNo} AND col_no = #{colNo} FOR UPDATE;

在事务中执行这条 SQL,数据库会给这一行加行级排他锁。其他事务执行同样的语句时会阻塞等待,直到前一个事务提交或回滚释放锁。这样才能保证真正的“排队买票”。

用悲观锁时,要注意两个问题:

  • 死锁。用户一次选了多个座位,程序里 if 顺序固定,所有事务都按同样的顺序锁定行,可以避免死锁;但如果代码里两个事务按不同顺序锁座位,就可能死锁。最好把多座位锁定操作放在一个循环里,按 row_no 排序再逐个加锁。
  • 锁等待超时。用悲观锁必须设置合理的超时时间,不能无限等下去。MySQL InnoDB 的锁等待超时默认 50 秒,属实有点长,可以在代码里加@Transactional(timeout = 5)。

我只能说,如果让我再做一次,我会直接用悲观锁做,因为选座下单这个场景天然适合行锁。但乐观锁也有它的价值——代码逻辑更容易理解,非常适合写进课程设计文档当“技术亮点”。

4.4 事务边界:锁座和订单一定要在同一个事务里

经历了几次 Bug 之后,我总结出一个非常重要的经验:锁座、生成订单这两个动作必须放在同一个 @Transactional 方法里,这样它们要么一起成功,要么一起回滚。

@Transactional(rollbackFor = Exception.class) public CreateOrderResult createOrder(Long userId, Long sessionId, List<SeatPosition> seats) { // 1. 悲观锁锁定座位(按排序顺序) List<Seat> lockedSeats = seatMapper.lockSeatsForUpdate(sessionId, seats); // 2. 检查所有座位是否可选 for (Seat seat : lockedSeats) { if (seat.getStatus() != 0) { throw new BusinessException("座位已被选购"); } } // 3. 更新座位为已锁 seatMapper.updateStatusBatch(sessionId, seats, 1); // 4. 创建订单 Order order = buildOrder(...); orderMapper.insert(order); // 5. 返回订单信息 return new CreateOrderResult(order.getOrderNo()); }

如果第4步插入订单失败,那第3步的座位状态更新绝不能保留,否则这个座就莫名其妙被锁住了。事务注解就是保证这一点的关键。不要问我怎么知道的——我第一次犯的错误,就是锁座和创建订单分了两个 Service 方法,结果数据库里出现了一堆“已锁但没订单”的脏数据。

5. 接口联调中被反复摩擦的三个细节

系统后端跑通之后,真正的痛苦才刚开始:前端页面和后端接口联调。这个阶段暴露的问题,大多不是业务逻辑错了,而是数据格式、跨域、序列化等“细节”问题。

5.1 统一返回体:把所有响应包一层

接口返回给前端的数据,一定要统一格式。我用的结构是:

public class Result<T> { private int code; // 200 成功, 400 业务错误, 500 系统错误 private String msg; private T data; public static <T> Result<T> success(T data) { ... } public static <T> Result<T> error(int code, String msg) { ... } }

Controller 里所有接口都返回 Result 对象:

@PostMapping("/order/create") public Result<CreateOrderResult> createOrder(@RequestBody OrderCreateDTO dto) { try { return Result.success(orderService.createOrder(...)); } catch (BusinessException e) { return Result.error(e.getCode(), e.getMessage()); } }

好处是:前端可以用同一种逻辑处理成功和失败,不用每个接口单独判断返回结构;后端出异常时也能把业务提示信息(比如“座位已被购买”)透传给页面。

5.2 LocalDateTime 序列化:一个格式问题折腾两小时

前面提到用了 LocalDateTime,到了前后端联调时,这个选择会给你上一课。

前端传参时如果不做任何处理,默认的 Jackson 序列化会把 LocalDateTime 转成类似"2025-06-15T20:30:00"的 ISO 格式,甚至直接报错。前端想要的是"2025-06-15 20:30:00"。

解决方案有两种。第一种是对每个字段加注解:

@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime startTime;

第二种是全局配置,在启动类里注册一个 Jackson 配置。我用的是全局配置方案,省得每个字段都加注解:

@Configuration public class JacksonConfig { @Bean public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() { return builder -> { builder.serializers(new LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); builder.deserializers(new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); }; } }

同样,前端向后端传时间参数时,也要约定好格式yyyy-MM-dd HH:mm:ss,或者在 DTO 字段上加 @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")。

5.3 跨域配置:前后端分离项目的必答题

如果前端用 Vue 开发,本地调试时端口是 8081,后端是 8080,跨域问题几乎必现。没有跨域配置,前端浏览器会拦截所有非简单请求的响应,页面表现为“登录失败”“请求消失”。

办法是注册一个全局跨域配置:

@Configuration public class CorsConfig { @Bean public WebMvcConfigurer corsConfigurer() { return new WebMvcConfigurer() { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }; } }

要注意 allowCredentials(true) 时,allowedOriginPatterns 不能直接用"*",否则浏览器会报错。这个坑我踩过,查了半天才明白是安全策略不允许。

6. 从代码到交付物:LW、调试文档和讲解视频的组合拳

项目写完之后,离真正交付还有很长一段路。标题后缀里那串“源码+LW+调试文档+讲解”不是随便加的,它对应的是课程设计/毕设评审时你必须拿出来的四样东西。很多同学代码写得好,但交上去的文档一塌糊涂,直接被扣分。

6.1 LW(论文/设计文档)怎么写才像样

LW 不是把代码粘贴一遍就完事。正常的结构应该包含:

  • 引言与选题意义:讲清楚影院购票系统解决了什么问题(人工售票效率低、信息不透明、无法在线选座)。
  • 需求分析:列出用户和管理员各自的用例图、功能需求和非功能需求(性能要求、安全性要求)。这里建议直接用 PlantUML 或 PowerPoint 画用例图,截图放进去。
  • 系统设计:系统架构图(浏览器->Controller->Service->Mapper->数据库)、核心类图、数据库 ER 图以及主要表结构定义。
  • 核心功能实现:挑最有技术含量的部分详细写,比如“基于数据库行锁的并发选座机制”,把代码和 SQL 贴出来,配合文字说明为什么这样设计。
  • 系统测试:写测试用例表格,包括测试场景、输入数据、预期结果、实际结果。最好用真实的截图证明测过。

我写文档时最深的体会是:图比字有用。一张清晰的 ER 图抵得上五百字表格描述。数据库表结构的定义一定要和实际代码中的 mapper 对应,否则答辩时老师一眼看出文档和代码不一致。

6.2 调试文档:记录哪些信息最实用

调试文档是让你(或接手的人)快速把项目跑起来的指南。很多项目源码下载下来跑不起来,80% 是环境问题。调试文档应该包含:

  • 开发环境版本:JDK 版本(如 JDK 1.8 或 17)、Maven 版本、MySQL 版本。
  • 数据库初始化步骤:SQL 脚本文件路径、执行顺序、初始账号密码。
  • 配置文件说明:application.yml 里的数据库连接、端口、上传路径等。
  • 启动步骤:先启动 MySQL,再初始化数据库,然后 IDEA 打开项目,等待 Maven 依赖下载完成,运行启动类。
  • 项目访问方式:前端地址、后端 Swagger 地址(如果有)、管理员账号和用户测试账号。
  • 常见问题排查:比如“端口被占用”“数据库连接失败”“全国产数据库驱动不一致”怎么处理。

坦白讲,调试文档一开始被我当成了“凑字数”用的,直到我第一次给同学部署他的项目,为了把环境跑通花了整整一个下午,才意识到一份靠谱的调试文档有多重要。后来我每做一步新环境验证,都会把操作追加到文档里,让文档和实际能跑通的状态保持同步。

6.3 讲解和演示:说人话,别背代码

准备的讲解视频或答辩演示里,最容易出现两种极端:一种是全程低头念代码,老师听得云里雾里;另一种是只点鼠标逛页面,完全不讲技术点。比较合理的演示节奏是:

  1. 30秒讲项目背景:影院购票系统解决什么问题,面向谁使用。
  2. 2分钟走主流程:登录->选电影->选择场次->选座->下单->模拟支付->查看订单。
  3. 1分钟讲并发设计:这是核心亮点,演示时先开两个浏览器同时抢同一个座位,展示只有一个人成功;然后讲解用了行锁/乐观锁,贴核心代码。
  4. 1分钟讲管理后台:排片管理、订单管理、销售统计。
  5. 30秒总结:这个项目你用到了哪些技术栈,你最熟悉的是哪部分。

请注意:如果你在演示中只点自己准备好的页面,没有任何异常分支,看起来会像“背答案”。最好提前准备三份测试数据,一份正常流程,一份座位冲突(抢座失败),一份支付超时取消,展示系统对边界情况的处理。这比多写十个页面更能打动评委。

最后的实操心得

做完这个项目,我自己最大的收获并不是又掌握了一个框架,而是理解了什么叫“站在业务角度设计技术方案”。座位锁定用乐观锁还是悲观锁,不是网上说的“谁性能好选谁”,而是要看业务场景:如果锁座时间短、冲突少,乐观锁足够;如果用户要长时间占座、支付过程中座位不能释放,悲观锁才是正确选择。这个判断能力,写代码是练不出来的,必须在完整设计一个系统后才能真正建立。

另外分享一个实用小技巧:订单号不要用数据库自增 id,因为订单号是要展示给用户和发送到模拟支付接口的。我自己用的是yyyyMMddHHmmss + 6位随机数,简单够用;如果并发量真的很高,可以改成雪花算法。课设中用时间戳加随机数不会出问题,但千万别忘了加唯一索引,否则重复订单号会让你排查到崩溃。

如果你正打算做类似的 Java Web 课程设计或毕业设计,我建议你从这套思路出发:先画业务流程图,再设计数据库表,然后搭 SpringBoot 项目骨架,最后填充业务代码。核心功能一步一步实现,遇到问题记到调试文档里,做完之后再整理 LW。这样一路走下来,你会发现成果完整的程度远超预期。

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

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

立即咨询