把“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() { List<Order> 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, List<Long> 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注解指定jdbcType=TINYINT,从源头规避映射歧义。
6.3 LocalDateTime序列化差八小时
前后端联调时又遇到一个历史老熟人:时间差八小时。后端返回的场次时间用LocalDateTime,JSON序列化之后传回前端,前端显示的时间比实际少了8个小时。排查下来发现是Jackson在序列化LocalDateTime时,默认没有配置时区格式,导致输出的字符串缺少了时区信息,前端按本地时区解析后正好差了一个时区。
这个问题在SpringBoot里解决起来很直接,在配置文件里统一指定格式即可:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8另外我还做了第二道保险:涉及前端展示的时间字段,在DTO层统一使用格式化后的字符串返回,不让前端直接处理时间类型。数据库连接串里也固定加上serverTimezone=Asia/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 部署时容易忽略的配置项
项目开发完成后部署上线,有几个配置项是我吃过亏之后才补上的。数据库连接串必须显式加上useUnicode=true&characterEncoding=utf8mb4,否则中文字符没问题,但用户评论里的emoji表情一插入就会报错,因为默认的utf8字符集存不下4字节字符。表结构也要统一使用utf8mb4。
打包方式直接使用SpringBoot的可执行Jar,通过java -jar启动,加上--spring.profiles.active=prod切到生产环境配置。生产环境建议把服务端口、数据库连接都外置到环境变量里,避免配置文件里写死。生产环境配置文件里要关掉Swagger或接口文档页面的外部访问,同时给SpringBoot的管理端点设置好访问权限。
线上部署我用的是单机方式加Nginx反向代理。Nginx负责静态资源缓存、请求转发和基本的限流配置。一个CPU核数2核、内存4G的服务器跑一套这样的系统绰绰有余。真正上线后最值得关注的是慢SQL日志,打开MySQL的慢查询记录,把执行时间超过1秒的SQL捞出来逐条分析,大部分性能问题都能在这个环节暴露出来,比盲目调整服务器参数有效得多。
最后多说一句个人体会。如果让我重新做一遍这个项目,我会在一开始就把服务层的接口边界画得更细,尤其是订票事务方法与评论校验逻辑独立出来,后面联调省下的时间远比一上来“一把梭”多。这个系统的业务规模并不大,但状态机设计和事务边界意识是通用的,把这两点想明白了,以后再面对更复杂的交易类系统,也会从容很多。