Spring Boot电影院购票系统实战:选座并发控制与订单闭环设计
2026/9/24 19:10:13 网站建设 项目流程

1. 项目整体设计与技术选型

1.1 核心需求解析

电影院购票系统,看上去是个已经被写烂了的选题,课程设计里有、毕业设计里有、网上开源项目一抓一大把。但真敢说把这个项目做得能上线、能扛住并发、用户操作体验还顺手的,并不多。我当初选这个题目,就是看中了它的业务链条足够完整:从电影排片、选座、下单、支付到订单管理,每一环都切入了真实电商系统的核心痛点,麻雀虽小五脏俱全。

先梳理一下这个系统必须要面的用户角色和核心功能:

  • 普通用户:注册登录、浏览电影列表、查看电影详情与排片场次、在线选座、提交订单、模拟支付、查看历史订单。
  • 管理员:管理电影信息、管理影厅与座位、排片管理(配置场次)、订单查询与统计。

这个需求拆出来之后就会发现,它本质上是一个“简化版交易系统”,覆盖了用户认证、资源展示、状态流转、事务处理、并发控制等后端开发的核心技能点。用Spring Boot来做落地非常合适,它生态成熟、起步快,社区资料多,遇到问题基本都能搜到现成的解决方案。

1.2 技术栈选型与取舍

技术选型这块,我不太建议一上来就堆微服务、消息队列这些重型组件。单机环境能解决的问题,不需要提前引入分布式复杂度。我的选型思路是“够用、稳妥、能扩展”,最终敲定这套组合:

技术组件用途选型理由
Spring Boot 2.7.x应用框架稳定成熟、资料多、上手快
MyBatis-PlusORM省去大量单表CRUD代码,分页好用
MySQL 8.0主数据库存储用户、电影、排片、订单等核心业务数据
Redis缓存 + 分布式锁缓存热门电影数据、预占座位锁
Thymeleaf + Bootstrap服务端渲染前端项目体量下不需要前后端分离,服务端渲染开发效率高
Maven项目管理依赖管理、打包部署方便

很关键的一点是,前端用Thymeleaf服务端渲染,而不是单独拆Vue项目。原因很简单:这个项目的重点在后端业务逻辑,前端拆出去反而增加了CORS、联调、构建的成本。Thymeleaf能直接复用Controller里的数据模型,配合Ajax做局部刷新,就足够实现流畅的选座交互了。

1.3 数据库设计逻辑

数据库是这类系统的地基,设计不好后面写代码处处别扭。我按业务模块把核心表拆成了下面几张:

  • movie:电影信息表,包含片名、海报、导演、主演、片长、上映日期、简介、状态。
  • cinema_hall:影厅信息表,包含影厅名称、座位行数、座位列数。
  • seat:影厅座位表,记录每个影厅每行每列的座位编号。
  • schedule:排片表,关联电影和影厅,包含播放时间、语言版本、票价。
  • user:用户表,包含用户名、密码(BCrypt加密存储)、昵称、手机号。
  • orders:订单表,关联用户和排片,包含订单号、总金额、状态、创建时间。
  • order_seat:订单座位关联表,记录某订单锁定了哪些座位。

这里有一个设计上容易踩坑的点:座位和排片的关联。很多人会把座位直接挂在影厅下,但真实业务中,座位是和“某一场次”绑定的。也就是说,同一影厅、不同场次,座位是独立的资源。所以我采用了schedule + seat + order_seat三张表配合:通过order_seat表记录某场次下已被哪个订单占用的座位。

数据库设计的核心原则就是:订单表和座位关联表的粒度要足够细,才能支撑后面做并发控制。

2. 核心功能模块的实现思路

2.1 用户登录与注册的实现

用户模块看起来很基础,但有一些细节值得认真处理。注册时密码一定要加密存储,我选的是Spring Security自带的BCryptPasswordEncoder,它每次生成的哈希值都带随机盐,比MD5加固定盐要安全得多,而且用法很简单:

@Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }

注册时调用passwordEncoder.encode(rawPassword)存入数据库,登录时用passwordEncoder.matches(rawPassword, encodedPassword)做比对。这是一个很多初学者容易忽略的点,直接把明文密码存库,一旦数据库泄露所有用户账号都会暴露,这是绝对不可接受的。

会话管理我用的是Session方案加拦截器,没有引入JWT。原因很简单:单体服务端渲染项目,Session天然可用,配合Spring Boot的拦截器做一个LoginInterceptor,在未登录访问需要认证的接口时直接重定向到登录页。

public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { Object user = request.getSession().getAttribute("user"); if (user == null) { response.sendRedirect("/user/login"); return false; } return true; } }

再通过WebMvcConfigurer注册拦截器并配置放行路径,比引入Spring Security全家桶要轻量得多。管理员角色则在用户表中加一个role字段区分,管理员路径单独拦截判断。

2.2 电影列表与详情页设计

电影列表页的主要逻辑相对直接,但缓存策略值得说明。首页和电影列表是用户访问频率最高的入口,如果每次请求都去打MySQL,数据库压力会比较大。我的做法是把热门电影列表缓存到Redis里,设置5分钟的过期时间。

public List<Movie> listHotMovies() { String cacheKey = "movie:hot"; String json = redisTemplate.opsForValue().get(cacheKey); if (StringUtils.isNotBlank(json)) { return JSON.parseArray(json, Movie.class); } List<Movie> movies = movieMapper.selectList( new LambdaQueryWrapper<Movie>().eq(Movie::getStatus, 1) ); redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(movies), 5, TimeUnit.MINUTES); return movies; }

这里用到了简单的Cache-Aside模式,先读缓存、缓存未命中再回源数据库并回填缓存。要注意缓存数据的序列化方式,避免Redis里存了不可读的二进制对象导致排查困难。我在项目中统一使用JSON字符串存储,配合Fastjson/Jackson直接转换对象,简单直观。

详情页除了电影基本信息,还要展示当前院线的排片场次。排片数据的查询条件通常是电影 + 日期,关联schedule表和cinema_hall表,查出每个场次的播放时间、影厅名称、票价、剩余座位数。剩余座位数是通过订单关联表统计出来的,这里我用了一条子查询避免N+1问题:

SELECT s.*, h.name AS hall_name, h.row_count, h.column_count, (SELECT COUNT(*) FROM order_seat os WHERE os.schedule_id = s.id) AS sold_count FROM schedule s LEFT JOIN cinema_hall h ON s.hall_id = h.id WHERE s.movie_id = #{movieId} AND s.play_date = #{date} ORDER BY s.play_time

2.3 选座与下单的业务流程

选座是整个系统的核心链路,业务流程是:用户选电影 -> 选场次 -> 进入座位图 -> 选择座位 -> 提交订单 -> 锁定座位 -> 模拟支付 -> 生成订单。这个流程每一步都涉及状态检查,尤其是座位图的渲染和订单提交之间的并发一致性,是整个项目的难点。

座位图渲染逻辑:前端根据影厅的行列数动态生成座位格子,已经售出或被锁定的座位显示为灰色不可选,可选座位高亮显示。座位状态的后端判定规则是:

  • 已售出:该排片下存在支付成功的订单,且订单关联了该座位。
  • 锁定中:该座位存在订单但订单未支付,且未超过锁定时间。

用户点击座位后,前端把选中的座位ID列表和排片ID传递到后端,后端做校验后创建订单并锁定座位。这个环节就是最典型的并发安全场景。

我记得第一次测试时,同时开两个浏览器窗口抢同一个座位,结果两个订单都创建成功了。这就是典型的超卖问题,我在后面专门花了一个章节来解决选座并发控制,这是整个项目最有含金量的地方。

3. 选座并发控制方案

3.1 先看清并发问题的根源

一个场次有一批座位,用户A和用户B同时看中了第5排第6座,两个请求几乎同时到达后端。传统做法是先查询座位是否空闲,如果空闲则创建订单并占用座位。但“查询-判断-写入”这三步之间存在时间差,就会出现两个请求都通过了“空闲判断”,都写入了订单,于是同一个座位被卖出了两次。

这个问题的本质是竞态条件(race condition),只有在并发场景下才会暴露。解决思路无非两种:一是让座位资源从查询到占用保持原子性,别人在我操作期间碰不到它;二是在数据库层面加约束,让第二个写入请求直接失败。

我会从最简单的方案说起,逐步过渡到最终版本。

3.2 方案一:数据库行级锁

利用MySQL的SELECT ... FOR UPDATE给座位记录加排他锁,事务提交后锁才释放。这样当一个请求在事务中锁定座位时,另一个请求查询同一批座位就会被阻塞,直到第一个事务结束。

@Transactional public Order createOrderWithLock(Long scheduleId, List<Long> seatIds) { List<Seat> seats = seatMapper.selectForUpdate(scheduleId, seatIds); // 检查seats中是否有已售出或已锁定的座位 // 如果没有,插入订单和关联记录 }

对应的Mapper方法:

<select id="selectForUpdate" resultType="com.example.entity.Seat"> SELECT s.* FROM seat s INNER JOIN order_seat os ON os.seat_id = s.id WHERE os.schedule_id = #{scheduleId} AND s.id IN <foreach collection="seatIds" item="seatId" open="(" separator="," close=")"> #{seatId} </foreach> FOR UPDATE </select>

这个方案能解决问题,但有几个缺陷:一是如果座位没有对应的order_seat记录,就锁不到任何行,所以必须先保证每张座位在order_seat里预留记录;二是锁的范围可能扩大,影响同一场次其他座位的并发下单;三是在高并发下数据库连接容易被长时间占用。

3.3 方案二:Redis预占锁实现

这是我在项目中最终采用的方案。思路是:在Redis中使用SETNX命令对每个座位ID创建一个短时锁,锁的value是用户ID,过期时间设置为10分钟。谁成功创建了这个key,谁就获得了座位的预占权。

public boolean tryLockSeat(String keyPrefix, Long seatId, Long userId) { String lockKey = "seat:lock:" + keyPrefix + ":" + seatId; Boolean result = redisTemplate.opsForValue() .setIfAbsent(lockKey, String.valueOf(userId), 10, TimeUnit.MINUTES); return Boolean.TRUE.equals(result); }

下单流程改造后变成:

  1. 前端提交排片ID和座位ID列表。
  2. 后端遍历座位ID,逐个尝试获取Redis锁。
  3. 如果某个座位锁获取失败,说明该座位已被别人预占,直接返回“座位已被锁定”。
  4. 全部锁获取成功后,进入数据库事务,创建订单并写入座位关联记录。
  5. 事务成功后,删除Redis锁;事务失败或订单超时未支付,则删除锁。

这个方案的好处是锁的粒度精确到单个座位,互不干扰,而且Redis的操作性能远高于数据库行锁。用户90秒内不支付,订单自动取消,同时释放Redis锁,座位重新变成可选状态。

3.4 规避删锁的经典坑

Redis锁的使用中有个非常经典的坑:用户A通过Redis锁抢到了座位,但订单还没创建完,锁的过期时间到了,锁自动释放。此时用户B抢到了座位锁,开始创建订单。紧接着用户A的事务完成了,代码里执行delete lockKey,把用户B的锁删掉了。这时用户C又来抢座位,也能成功,导致A、B、C三个人都以为自己买到了座位。

正确的删除方式是在删除前校验锁的value是否还是自己的用户ID,确认是自己的锁才删除,用Lua脚本保证原子性:

public void releaseLock(String lockKey, Long userId) { String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Collections.singletonList(lockKey), String.valueOf(userId)); }

另外还要把过期时间设置成合理的业务时间:我最初设置的是5分钟,后来发现在支付环节停留时间稍长就会导致锁过期失效,改成了10分钟,同时配合订单的自动取消任务兜底。

3.5 定时清理超时订单

锁有自动过期,订单也不能无限期占用座位。我在项目中用Spring的@Scheduled注解实现了一个定时任务,每30秒扫描一次订单表,将创建时间超过10分钟且状态为“待支付”的订单改为“已取消”,并释放该订单关联的座位锁。

@Scheduled(fixedRate = 30000) public void cancelExpiredOrders() { LocalDateTime deadline = LocalDateTime.now().minusMinutes(10); List<Order> expiredOrders = orderMapper.selectExpiredPendingOrders(deadline); for (Order order : expiredOrders) { order.setStatus(OrderStatus.CANCELLED.getCode()); orderMapper.updateById(order); List<OrderSeat> orderSeats = orderSeatMapper.selectList( new LambdaQueryWrapper<OrderSeat>().eq(OrderSeat::getOrderId, order.getId()) ); for (OrderSeat os : orderSeats) { releaseLock("seat:lock:" + os.getScheduleId() + ":" + os.getSeatId(), order.getUserId()); } } }

这里需要注意的是,定时任务要和消费者的支付确认做联动处理。如果一个订单在超时边缘被支付了,定时任务需要先判断订单状态是否为待支付,只有在确认仍是待支付时才执行取消,避免扣了用户的款还取消了订单这种严重问题。

4. 前端页面与接口交互

4.1 页面结构化拆分

前端我用Thymeleaf模板引擎配合Bootstrap来实现,整体页面分成三大块:

  • 公共布局:导航栏、用户登录状态、底部版权信息。
  • 用户端页面:首页、电影详情、选座页面、订单确认页、订单列表页。
  • 管理端页面:电影管理、排片管理、影厅管理、订单管理。

Thymeleaf的布局用th:fragment语法抽公共部分,比如导航栏单独抽出一个fragment,各个页面通过th:replace引入,避免重复写HTML。这个用法比JSP的include更优雅,而且天然支持服务端数据渲染。

选座页面是交互最复杂的部分,座位图用CSS Grid布局,根据影厅的行列数动态生成。每行每列用一个div表示,包含座位ID和状态信息,默认状态从后端的接口获取。

4.2 用Ajax做座位状态刷新

选座页面的前端逻辑有几个关键点:

  • 进入页面后,向后端发起请求获取当前排片的座位状态。
  • 已被售出或锁定的座位置灰,不可点击。
  • 用户点击座位,座位高亮为选中状态,再次点击取消选中。
  • 右上角显示当前已选座位数量和总价。
  • 点击提交按钮后,把所有选中座位ID一次性提交到后端。

这些交互全部用原生JavaScript加jQuery的Ajax实现,不额外引入前端框架。代码不复杂,但要注意防重复提交:用户点击“提交订单”后,按钮要立即置为禁用状态,防止用户手误连点导致创建多个订单。

4.3 订单确认与支付模拟

订单确认页展示用户选择的场次、座位明细、总价,以及一个“模拟支付”按钮。很多新手在这个环节容易犯的错误是,订单创建成功后直接跳出支付按钮,但订单状态的管理其实是两个步骤:

  1. 提交订单:生成订单,锁定座位,状态为pending(待支付)。
  2. 模拟支付:将订单状态从pending改为paid(已支付),正式完成座位占用。

这样设计的理由是:订单创建和支付在真实场景中是两个独立的动作,支付环节可能因为各种原因失败或超时。如果创建订单时直接置为已支付,就无法实现超时取消、座位自动释放机制。

模拟支付的接口也很简单,前端把orderId传给后端,后端校验订单属于当前登录用户且状态为待支付,然后更新状态并标注支付时间。为了演示效果,我在支付接口中故意加了500毫秒的Thread.sleep,模拟支付网关的处理延迟。

4.4 后台管理页的表格联动

管理端页面基本是标准CRUD,但有一个联动场景值得提一下:排片管理页面。新增排片时要选择电影、选择影厅、设置播放时间和票价。这里有个业务校验很容易被忽略:同一个影厅在同一时间段只能排一场电影,播放时间需要和片长做重叠校验。

public boolean checkScheduleConflict(LocalDateTime playTime, Integer hallId, Integer movieDuration) { LocalDateTime endTime = playTime.plusMinutes(movieDuration + 20); // 加20分钟散场时间 List<Schedule> schedules = scheduleMapper.selectList( new LambdaQueryWrapper<Schedule>() .eq(Schedule::getHallId, hallId) .eq(Schedule::getPlayDate, playTime.toLocalDate()) ); for (Schedule s : schedules) { LocalDateTime sEnd = s.getPlayTime().plusMinutes(s.getMovieDuration() + 20); if (playTime.isBefore(sEnd) && endTime.isAfter(s.getPlayTime())) { return false; } } return true; }

这个场景我一开始没做校验,导致测试数据里出现了同一影厅同时上映两部电影的笑话。数据校验无小事,越细节的地方越能体现一个开发者的工程素养。

5. 常见问题与排查心得

5.1 事务不生效的问题

在开发期间,我遇到过@Transactional明明加了却不起作用的情况。订单状态更新一半,后面抛异常,前面的数据库操作也没有回滚,导致座位被占用但订单数据不完整。

排查后发现是两个原因:一是方法被同类内部调用,Spring事务是基于AOP代理的,内部调用不走代理对象,事务注解自然失效。二是异常被方法内部catch住了,事务感知不到异常,自然不会回滚。

解决办法很简单:把需要事务的方法拆分到独立的Service类中,确保通过Spring容器调用;异常要么抛出,要么在catch里手动调用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()标记回滚。

5.2 数据库时间字段的时区坑

电影院购票系统对时间非常敏感,排期、订单时间、定时任务都依赖准确的时间。我排查过一个很有意思的问题:订单显示创建时间比实际时间晚了8个小时。

原因是MySQL JDBC连接的serverTimezone参数设置不当,默认使用了UTC时区,而本地是东八区。解决办法是在数据库连接URL中显式指定serverTimezone=Asia/Shanghai,并在Java侧统一使用LocalDateTime,不依赖系统默认时区。

另外,排片表里存的是“播放日期”和“播放时间”两个字段,日期用LocalDate、时间用LocalTime,不要全部塞进一个DateTime字段,后续按日期查询排片时会遇到很多不必要的转换问题。

5.3 Redis缓存穿透与失效场景

我有一个热点数据缓存策略是缓存电影列表5分钟,这就引出了缓存穿透问题:如果用户频繁刷新一个不存在的电影ID的详情页,请求会绕过缓存直接打到数据库。我当时没太在意这个问题,后来测试时发现数据库日志多了很多可疑的查询。

解决方式有两种:一是对不存在的电影ID,也在Redis里缓存一个空值占位,设置较短的过期时间;二是用布隆过滤器在请求到达缓存前过滤掉大部分非法ID。考虑到项目体量,我采用了第一种简单方案,够用且容易理解。

比较隐蔽的是缓存失效的雪崩问题:如果所有电影信息的缓存都设置了相同的过期时间,同一时刻集体失效,数据库瞬间压力就上来了。我的做法是给过期时间加一个随机偏移量,让缓存的失效时间散布在不同的时间点,从根源上避免了这个问题。

5.4 部署时端口被占用

最后说说一个看似低级却很影响心情的问题:本地启动Spring Boot项目时提示端口被占用。这个问题大多数情况下是上一个开发实例没有完全关闭,监听8080端口的进程还活着。Windows下用netstat -ano | findstr 8080找到PID,再用taskkill /PID 进程号 /F强制结束。

为了避免反复出现,我在项目中配置了server.port=8080,同时设置了spring.devtools.restart.enabled=true,开发时改动代码自动重启,能少踩很多手工重启带来的操作失误。

6. 项目扩展方向与我的个人体会

做完这个项目之后,我最大的感受是:选座购票系统虽然业务不算复杂,但把并发控制、缓存策略、事务管理、定时任务这些后端核心技能全部串了起来,形成了一个完整的实战闭环。如果你的目标是找工作或者巩固Spring Boot技能,这个项目是非常合适的练手选题。

如果想让这个项目更有亮点,可以从几个方向继续扩展:

  • 引入消息队列,将订单创建和支付回调解耦,提升系统吞吐量。
  • 将Redis锁替换成Redisson的看门狗机制,进一步优化锁的自动续期问题。
  • 接入真实支付网关SDK替代模拟支付,让业务流程更贴近生产环境。
  • 增加观影评价、积分体系、会员折扣等附加功能,丰富业务层次。

我个人在实际操作中最受益的环节是并发锁的方案演进:从一开始的数据库FOR UPDATE,到Redis SETNX锁,再到解决锁误删的Lua脚本,每一步都是被真实问题推着走,这种“遇到问题 -> 分析根因 -> 设计方案 -> 落地验证”的循环,才是做项目最大的收获。如果你也在做类似的单体应用项目,我强烈建议不要只停留在“增删改查能跑通”的层面,多想一想数据一致性、异常场景、用户体验这些边角问题,你会发现自己对系统的理解会完全不一样。

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

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

立即咨询