SpringBoot电影订票网站实战:锁座、下单与并发控制全解析 把“SpringBoot电影订票及评论网站”这个题目拆开看它其实是两个难度完全不同的东西电影信息展示和评论管理属于标准CRUD只要会用SpringBoot的基础开发套路就能做但“订票”两个字背后藏着的选座、锁座、下单、超时释放这一整条状态链路才是这个项目真正值得写进简历的部分。我前段时间把这样一个网站从零到一完整实现了一遍算上前后端代码整个项目体量刚好在一万一千行左右数据库设计、接口开发、联调部署都走了一轮。这篇文章把设计和实现过程完整拆开重点放在那些不亲自动手做一遍根本发现不了的问题上希望能给正在做同类项目或者打算拿它当毕业设计题目的同学一些实在的参考。1. 先拆清楚这个项目到底要做什么1.1 用户视角下的功能地图很多同学拿到这种题目之后第一反应就是“电影管理、订单管理、评论管理”这类罗列式清单。这么理解没错但它只回答了“系统有哪些功能”没有回答“用户来这个网站是干什么的”。我习惯先用一条用户故事把业务流程串起来一个用户打开网站先浏览正在热映的电影列表点击某部电影进入详情页看到场次信息然后选择场次、选择座位、提交订单支付之后获得观影凭证观影结束后给这部电影写一条评论和评分。这条链路里最核心的实体其实是三个电影、场次、订单。电影是内容主体场次是某部电影在某个影厅某个时间点的放映安排订单则是用户和场次之间的购买关系。评论虽然也重要但它更像是订单完成之后的一个附加动作设计的时候可以把它拆成独立模块但数据关系上必须挂在订单或电影下面不然很容易变成一张孤立的表。明确了这条链路之后后台管理员的角色也就清晰了。管理员需要维护电影信息、排片场次、影厅座位布局同时还要能查看订单流水、审核异常评论。用户端和管理端共用同一套服务层只是权限不同这一点在架构设计上要提前想清楚千万别做成两套独立的系统。1.2 选型时为什么是SpringBoot这套组合技术栈选择上我的判断标准很简单学习成本适中、资料多、能覆盖完整的Web开发链路。后端用SpringBoot 2.x搭配MyBatis数据库用MySQL前端用Thymeleaf模板加Bootstrap缓存按需引入Redis。这套组合本身没什么新意但对于这类项目来说稳定性和可复现性比炫技重要得多。SpringBoot的价值在于它把配置和启动流程大幅简化了不需要像传统SSH那样写一堆XML配置文件。MyBatis则适合这种表结构相对固定的业务系统SQL手写可控性强调试起来也直观接口性能不好时一眼就能定位到SQL问题。Thymeleaf的好处是服务端渲染对SEO友好而且页面模板能直接复用不用前后端分离之后还要额外搭一套接口文档和跨域方案。有一点要提醒如果你的项目打算前后端分离页面用Vue或React那服务端就只需要提供JSON接口。这个题目的规模其实两种做法都能做但服务端渲染会让整个系统少很多CORS、Token过期、跨域携带Cookie之类的问题完成度更容易做高。我个人在这类偏毕设性质的项目里更推荐服务端渲染能把精力集中在业务本身。2. 数据库是订票系统的地基表设计里的关键决策2.1 七张核心表与它们的关系数据库设计是这类系统最不应该省时间的环节。表结构一旦定下来后面改动牵一发动全身。拿我这个项目最终落地的表设计来说七张表基本可以覆盖全部业务用户表、电影表、场次表、座位表、场次座位表、订单表、评论表。如果还想做影院维度可以再加影院表但单影院模式下这七张表足够了。用户表存账号密码和基础信息密码必须加密别用明文。电影表存片名、简介、海报路径、上映状态、时长另外我专门留了评分和评论数字段这两个字段是冗余聚合用的后面讲评论模块会细说。场次表描述“哪部电影在哪个时间放”字段包括电影ID、放映时间、影厅编号、票价。座位表描述影厅的物理座位布局行号列号、排数列数都在这里。核心中的核心是场次座位表。它把“场次”和“座位”两个维度组合起来每一行代表“某场次下的某一个具体座位当前是什么状态”。这个设计的巧妙之处在于同一个影厅的座位布局只需要保存一份但每个场次都有自己独立的状态集。状态字段我用数字表示0可售、1锁定、2已售。锁定状态是下单未支付时临时占用的已售状态是支付完成后彻底不可再卖的。2.2 场次座位表每一行代表一个可售座位场次座位表是用户下单时真正要操作的表它的每一行都带有唯一的业务定位session_id加seat_id的组合唯一确定一个场次下的一个座位。建表时我给这个组合加了唯一索引从数据库层面杜绝同一个座位在场次下重复初始化或重复操作的可能。初始化逻辑通常是这样的后台管理员创建场次之后系统自动根据影厅的座位表把每个座位复制一份到场次座位表中初始状态全部置为0。这个动作也可以在管理员点“发布场次”时同步触发比在SQL层面用存储过程更方便排查问题。如果影厅有100个座位一场就会生成100行记录数据量完全可控。预订时的高频SQL就是对一个或多个座位行做状态更新判断条件必须包含“当前状态为0”。这样即使两个请求同时打过来数据库的行锁和UPDATE条件的原子性也会保证只有一个请求能成功。这一条是整个订票并发安全的地基后面还会展开讲。2.3 订单表里的状态机字段订单表的设计同样有讲究。除了常规的订单号、用户ID、场次ID、座位ID列表、总价、创建时间之外我还加了三个字段状态、支付时间、超时时间。状态字段用整数枚举表示0待支付、1已完成、2已取消、3已超时关闭。支付时间只在状态流转到已完成时写入超时时间则用于后续的自动关单。订单号建议自己生成比如年月日时分秒加用户ID后几位再加随机数不用数据库自增ID直接暴露给前端避免别人通过订单号推测业务量。订单表要单独对订单号建唯一索引程序里生成订单号时即使有小概率重复插入时也会被数据库挡住兜底很关键。这里有一个容易被忽视的点订单里要存座位ID列表但展示时需要显示座位号。如果订单表只存了座位ID字符串后面查看订单详情时还得回查座位表。我建议订单表冗余一份座位编号字符串比如“3排5座,3排6座”下单时直接从座位信息拼接好存进去。查询订单列表时少一次关联表查询速度明显快这是典型的空间换时间。3. 最容易翻车的订票流程锁座、下单与并发控制3.1 一次完整购票的状态流转先说清楚一次成功的购票应该经历哪些状态因为这里的状态机如果没有理顺后面会出现“座位被卖两次”或者“已支付的订单显示未支付”这种致命Bug。用户选好座位点击提交订单时系统要做的第一件事不是生成订单而是锁定座位。锁定成功后生成一条待支付订单同时给订单设置一个超时时间比如15分钟。用户在这15分钟内完成支付订单状态变为已完成座位状态从锁定改为已售。如果15分钟内没有支付订单被标记为超时关闭同时座位状态必须改回可售。这里面最容易踩坑的点是锁座位和生成订单必须发生在同一个事务里。也就是说要么座位锁定成功并且订单创建成功要么全部回滚绝对不能出现“座位锁了但订单没生成”或者“订单生成了但座位没锁住”的中间状态。3.2 数据库行锁实现座位锁定我的座位锁定逻辑依赖一个UPDATE语句的原子性。你可能在网上看到过用Redis分布式锁或者悲观锁的实现方案。对于这种单机部署、规模不大的系统数据库自身的行锁其实是最可靠也最简单的一种方案。核心SQL长这样UPDATE t_session_seat SET status 1, lock_user_id #{userId}, lock_time NOW() WHERE session_id #{sessionId} AND seat_id #{seatId} AND status 0注意关键点UPDATE操作会对满足WHERE条件的行加行锁而且判断status 0和把状态改成1是同一句SQL完成的没有先查再改的间隙。两个请求同时对同一个座位的同一行执行这个UPDATE数据库层面会让他们排队第一个成功把status改成了1第二个再执行时发现status已经是1受影响行数为0就能立刻判定座位已被占用。Service层判断返回值即可int rows sessionSeatMapper.lockSeat(sessionId, seatId, userId); if (rows 0) { throw new BusinessException(座位已被锁定或售出请重新选择); }多个座位的话就循环执行任何一个失败就抛异常整个事务回滚。整个过程不需要任何复杂的锁组件靠的是数据库自身的原子性结构清晰也容易排查。3.3 订单超时释放的两种方案订单超时释放这块我在设计时对比过两种做法。第一种是定时任务扫单每30秒扫描一次订单表把所有“待支付但已超时”的订单批量标记为超时关闭同时把对应座位状态重置为可售。第二种是懒释放在用户查询座位状态时检查某条锁定记录是否超过超时时间如果超了就顺手把它恢复成可售再返回给用户。两种方案各有优劣。定时任务逻辑集中、可控性高但会有延迟极端情况下用户看到座位被锁但实际已经可以买过最多30秒就能恢复。懒释放没有延迟但会把释放逻辑散落在查询接口里增加复杂度。我最后采用的是定时任务为主、查询时做兜底双保险。定时任务用SpringBoot自带的Scheduled注解就能实现不需要引入额外的分布式调度框架Component public class OrderTimeoutTask { Scheduled(fixedDelay 30000) public void processExpiredOrders() { ListOrder expiredOrders orderMapper.selectWaitingPaymentExpired(); for (Order order : expiredOrders) { orderService.closeOrderAndReleaseSeats(order.getId()); } } }实现closeOrderAndReleaseSeats的时候必须注意它要在一个事务里完成两件事把订单状态改成超时关闭把订单关联座位状态改回可售。如果考虑到用户刚支付成功定时任务就扫到这张单的极端并发还要在UPDATE时加上状态等于待支付的条件保证不会把已支付订单强行关掉。UPDATE t_order SET status 3 WHERE id #{orderId} AND status 0只有受影响行数为1时才去做释放座位的操作。这条SQL是全链路中最后一个“闸门”有它存在并发场景下的状态错乱问题基本可以消除。4. 评论系统不要只做一个能发评论的功能4.1 评论与订单、电影的关联设计评论表的设计从表面看很简单用户ID、电影ID、评分数、评论内容、创建时间。但只做到这一层有隐患最典型的场景是用户没买票也能对着电影乱评论或者同一个用户刷几十条评论把电影口碑刷上去。要让评论可控必须把它和订单关联起来。我的做法是评论表里加了订单号字段并且在表上建立了组合唯一索引(order_no, user_id)从数据库层面保证一个订单只能产生一条评论。发布评论时先查订单是否存在、是否属于当前用户、订单状态是否已完成、这条订单是否已经评过四个条件全部通过才允许插入评论。这样一来评论的可信度就建立在真实的购票行为上了也能挡住大部分刷好评的行为。具体情况还可以做进一步的区分有些系统要求必须评价后才能看到完整场次信息或者评价后给积分这种运营玩法等基础评论功能稳定之后再加都来得及别在第一个版本里塞进去容易把自己绕晕。4.2 一单一评约束与评分计算一单一评的实现逻辑并不复杂难的是在“写评论”和“更新电影评分”之间保持一致性。假设用户提交了评论但更新电影平均分的SQL失败了就会出现评论存在但评分没变的脏数据。我在Service层写的核心逻辑是这样的事务内先插入评论记录更新订单的已评论标记然后重新统计这部电影的平均分和评论总数回写到电影表的冗余字段里。Transactional public void addComment(CommentAddDTO dto, Long userId) { Order order orderMapper.selectByOrderNo(dto.getOrderNo()); if (order null || !order.getUserId().equals(userId)) { throw new BusinessException(订单不存在或无权评论); } if (!OrderStatus.COMPLETED.equals(order.getStatus())) { throw new BusinessException(只有已完成的订单才能评论); } Comment comment new Comment(); comment.setUserId(userId); comment.setMovieId(dto.getMovieId()); comment.setOrderNo(dto.getOrderNo()); comment.setRating(dto.getRating()); comment.setContent(dto.getContent()); comment.setStatus(CommentStatus.NORMAL); commentMapper.insert(comment); orderMapper.markCommented(order.getId()); MovieStats stats commentMapper.selectAvgRatingAndCount(dto.getMovieId()); movieMapper.updateRatingInfo(dto.getMovieId(), stats.getAvgRating(), stats.getCount()); }这里有个小优化统计电影平均分时只要AVG(rating)和COUNT(*)两条聚合就够了不必每次把全部评论加载到内存里算。数据量大了以后这个聚合SQL会稍微变慢到时可以引入Redis缓存或异步刷新初版直接查库完全够用。4.3 防刷与内容过滤的轻量做法评论模块还会遇到两个不那么引人注意但早晚会头疼的问题刷评和垃圾内容。在项目初期我建议用轻量方式处理不要一上来就上审核工作流和敏感词算法。刷评的防线主要是前面说的一单一评再叠加一个简单的频率限制同一个用户一小时内最多发5条评论。用一张评论时间记录表或者Redis带过期时间的计数器都能实现后者更简单每次发布评论时给comment:userId计数超过阈值就拒绝。垃圾内容过滤可以分两层。第一层是长度控制评论内容设置最小5字、最大500字的限制能挡住大部分纯表情或空白评论。第二层是关键词替换维护一个简单关键词列表发布时把这些词替换成*号。关键词列表建议独立放在配置表里管理员可以在后台维护不用改代码。别指望这个方案能过滤所有内容但挡住绝大多数低质量内容已经够了。真正需要严管的话可以在评论表加一个状态字段管理员后台可以下架某条评论这样即使后续出现漏网之鱼也有补救手段。5. 从Mapper到Service的订票链路核心方法怎么写5.1 场次查询接口与缓存处理场次查询是前端页面请求量最高的接口用户进详情页看场次、选座位时都会反复请求。一个合格的场次查询接口至少要避免两个问题一是每次都把电影的详情也关联查出来二是不加缓存导致数据库压力大。我实际项目里的做法是拆成两个轻量接口一个是场次列表接口返回某部电影在未来几天的场次字段只要场次ID、时间、影厅、票价、剩余可售座位数不关联电影详情。另一个是座位状态接口返回某个场次下所有座位当前的状态前端根据状态渲染选座界面。剩余可售座位数不要实时去统计因为它会被高频率请求打爆。我的做法是场次表里冗余一个available_seat_count字段锁座成功和释放座位时在同一事务里对这个字段做增减。查询时直接SELECT这个字段性能开销极小。第一次上线时我也没意识到这个冗余字段的必要性直到联调时发现每点一次选座页面就要COUNT一次几百行的状态表响应时间肉眼可见地变慢才下决心改。5.2 createOrder的完整实现与事务边界下单接口的核心逻辑前面已经铺垫了这里把完整的Service方法写出来你会看到事务边界到底应该划在哪里。Transactional public Order createOrder(Long userId, Long sessionId, ListLong seatIds) { SessionDetail session sessionMapper.selectBySessionIdForUpdate(sessionId); if (session null) { throw new BusinessException(场次不存在); } if (session.getAvailableSeatCount() seatIds.size()) { throw new BusinessException(该场次余票不足); } for (Long seatId : seatIds) { int rows sessionSeatMapper.lockSeat(sessionId, seatId, userId); if (rows 0) { throw new BusinessException(座位已被锁定请重新选择); } } Order order new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setSessionId(sessionId); order.setSeatIds(StringUtils.join(seatIds, ,)); order.setSeatText(buildSeatText(seatIds)); order.setTotalPrice(calculateTotalPrice(sessionId, seatIds)); order.setStatus(OrderStatus.WAITING_PAYMENT.getCode()); order.setExpireTime(DateUtil.addMinutes(new Date(), 15)); orderMapper.insert(order); sessionMapper.decreaseAvailableSeatCount(sessionId, seatIds.size()); return order; }注意selectBySessionIdForUpdate这个方法名里的ForUpdate它意味着查询场次时对场次行加了悲观锁。可能有人觉得这里没必要加锁但加上之后能保证“查余票判断足够”和“后面锁座位”之间不会被另一个并发的下单请求插入避免最后余票字段变成负数。这个场景属于典型的先判断后操作用FOR UPDATE是最稳的解读方式。事务边界就划在整个方法上座位锁定、订单插入、余票扣减三个动作要么全部成功要么全部回滚。这意味着某一步抛异常时之前已经被锁定的座位会随着事务回滚自动恢复到可售状态完全不需要额外写补偿逻辑非常省心。5.3 评论发布接口的权限校验评论接口的权限校验和订票不同它的核心不是并发而是身份和资格校验。设计接口时除了常规的登录拦截之外我在Controller层做了一层简单的参数校验然后在Service层做业务校验两层各司其职。Controller层只做两件事从Session或登录态里取到当前用户ID把DTO里的字段做非空校验。Service层才是业务规则真正落地的地方校验顺序是订单存在、订单属于当前用户、订单已完成、订单未评论。这四个条件必须全部满足否则直接抛出业务异常。从开发体验上说这种“Controller薄Service厚”的写法维护起来非常舒服。后面如果要加一个管理员审核评论的接口只需要在Service层加对应方法Controller只负责接收参数和返回统一结果不用改动已有逻辑。这一点对后续扩展很重要评论可能会加点赞、举报、置顶等功能核心校验都在Service层扩展成本会低很多。6. 事务失效、映射错乱、日期八小时三个真实的翻车现场6.1 事务自调用导致座位被重复下单这个坑我在联调阶段遇到过现场堪称诡异一个请求同时选两个座位第一次请求锁座成功第二次请求也锁座成功两笔订单拿到的座位竟然不一样。顺着日志排查发现锁座逻辑本身没问题问题出在我把lockSeatAndCreateOrder这个事务方法放到同一个Service类里然后在类内部直接调用了this.createOrder。Spring的事务是通过AOP动态代理实现的事务注解只对代理对象的外部调用生效。同一个类内部方法之间直接调用相当于绕过了代理Transactional完全失效。结果就是座位锁定那句UPDATE不在事务保护范围内异常时不会回滚于是出现脏数据。修复方案有两种。第一种是把需要事务的方法拆到另一个Service类由Controller或其他Service调用。第二种是在当前类中注入自身代理通过Autowired或在SpringBoot 2.6之后用Lazy注解然后用代理对象调用目标方法。我最终选择了拆类因为它在结构上最清晰后续扩展也方便。接收一个经验写完事务方法之后一定要看一眼这个方法的调用链路确认是否经过代理对象。这种Bug不报错只有并发压测或者特定场景下才会暴露排查成本非常高。6.2 MyBatis把tinyint映射成Boolean数据库里很多状态字段我用的是tinyint比如订单状态、座位状态、删除标记。最初用MyBatis的Map接收结果集时tinyint(1)字段会被自动映射成Java的Boolean导致order.getStatus()读出来的不是0、1、2这样的数字而是true或false判断逻辑全部乱掉。这里要特别说明不是所有版本都会这么映射这通常和MySQL驱动对tinyint(1)的特殊处理有关。用实体类接收时如果字段声明是Integer一般不会出问题一旦用Map接收踩中的概率很大。我的解决方式是在实体类和Mapper接口返回类型上都明确指定为具体的实体对象而不是Map。同时也建议别把除“是否删除”之外的字段设计成0和1的布尔型订单状态这种有多个取值的字段直接用tinyint(4)并在Mapper里用Result注解指定jdbcTypeTINYINT从源头规避映射歧义。6.3 LocalDateTime序列化差八小时前后端联调时又遇到一个历史老熟人时间差八小时。后端返回的场次时间用LocalDateTimeJSON序列化之后传回前端前端显示的时间比实际少了8个小时。排查下来发现是Jackson在序列化LocalDateTime时默认没有配置时区格式导致输出的字符串缺少了时区信息前端按本地时区解析后正好差了一个时区。这个问题在SpringBoot里解决起来很直接在配置文件里统一指定格式即可spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8另外我还做了第二道保险涉及前端展示的时间字段在DTO层统一使用格式化后的字符串返回不让前端直接处理时间类型。数据库连接串里也固定加上serverTimezoneAsia/Shanghai保证数据库侧获取的时间与服务器时区一致。三层全设置好之后时间问题再也没有出现。这种时区类问题的表现形式往往是“偶发”的比如部署到不同机房或者数据库服务器时区不一致时才暴露所以建议在项目一开始就把时区和日期格式规范好别等到联调再去补。7. 从能跑到扛得住索引、缓存与部署细节7.1 三组关键索引与SQL写法数据库索引这种东西没有的时候查得慢加错了又会拖累写入。我在这个项目里最终只保留了三组关键索引每一组都是根据业务查询路径设计的。第一组是订单表的订单号唯一索引同时服务于按订单号查询详情和防止订单号重复插入。第二组是场次座位表的组合唯一索引(session_id, seat_id)这既是业务唯一性的保证也是锁座SQL能快速定位行记录的基础。第三组是评论表的组合索引(movie_id, id)电影详情页展示评论列表时按电影ID筛选并按时间倒序这个索引能让翻页查询稳定地走覆盖索引减少回表。写SQL时要避免出现隐式类型转换比如订单号字段是varchar查询时用数字类型做条件MySQL就不会走索引。另外分页查询用LIMIT时页数越大越慢评论列表这种场景可以改成基于游标的方式或者限制最大可翻页数防止有人一直往后翻把数据库压垮。7.2 热点数据的Redis缓存写这类系统时如果不加Redis数据库在几百个并发请求下就可能出现明显延迟。我的做法是只缓存两类数据一类是几乎不变的基础数据比如电影列表和电影详情另一类是计算量较大但实时性要求不高的统计数据比如某部电影的评分信息。缓存更新策略采用最经典也最稳的Cache Aside模式。查询时先读缓存没有就查库并回写。更新电影信息时先更新数据库再删除对应缓存让下一次查询重新回填。这里值得展开讲的是删除时机一定要在数据库事务提交成功之后再删缓存否则事务回滚了但缓存被删掉会让旧数据重新暴露出来。座位状态和可售余票这类数据我没有放缓存因为它们和订单流程绑定实时性要求极高一旦缓存和数据库之间出现短暂不一致就可能造成超卖或错卖。让这些数据直接查库配合好索引读性能完全够用别为了“用了Redis”而强行缓存不该缓存的业务数据。7.3 部署时容易忽略的配置项项目开发完成后部署上线有几个配置项是我吃过亏之后才补上的。数据库连接串必须显式加上useUnicodetruecharacterEncodingutf8mb4否则中文字符没问题但用户评论里的emoji表情一插入就会报错因为默认的utf8字符集存不下4字节字符。表结构也要统一使用utf8mb4。打包方式直接使用SpringBoot的可执行Jar通过java -jar启动加上--spring.profiles.activeprod切到生产环境配置。生产环境建议把服务端口、数据库连接都外置到环境变量里避免配置文件里写死。生产环境配置文件里要关掉Swagger或接口文档页面的外部访问同时给SpringBoot的管理端点设置好访问权限。线上部署我用的是单机方式加Nginx反向代理。Nginx负责静态资源缓存、请求转发和基本的限流配置。一个CPU核数2核、内存4G的服务器跑一套这样的系统绰绰有余。真正上线后最值得关注的是慢SQL日志打开MySQL的慢查询记录把执行时间超过1秒的SQL捞出来逐条分析大部分性能问题都能在这个环节暴露出来比盲目调整服务器参数有效得多。最后多说一句个人体会。如果让我重新做一遍这个项目我会在一开始就把服务层的接口边界画得更细尤其是订票事务方法与评论校验逻辑独立出来后面联调省下的时间远比一上来“一把梭”多。这个系统的业务规模并不大但状态机设计和事务边界意识是通用的把这两点想明白了以后再面对更复杂的交易类系统也会从容很多。