基于SpringBoot的在线票务预订平台设计与实现:并发控制与订单状态机实践

📅 发布时间:2026/9/20 21:50:12
基于SpringBoot的在线票务预订平台设计与实现:并发控制与订单状态机实践
简介这是一份基于Spring Boot的在线票务预订平台毕业设计论文面向计算机专业学生、开发人员及需要完成同类型课题的毕业设计者。内容为完整毕业论文涵盖课题背景、开发技术、需求分析、数据库设计、详细系统设计等章节系统采用Java、Spring Boot、MySQL和Vue.js实现支持在线订票、选座、支付、电子票发放以及后台票务库存和销售管理可作为论文写作和系统开发的直接参考。资源包共1个doc文件大小7.32MB纯文档格式便于阅读和编辑。目前已有80人学习浏览。论文详细阐述了系统可行性、UML用例、流程图设计并包含前端交互与后台管理模块的逻辑读者可从中获取从需求分析到系统实现的全过程经验以及数据库结构设计和高并发性能保障思路。 毕业设计题目拿到手的那一刻“SpringBoot在线票务预订平台”听起来并不算惊艳——无非就是查场次、选座位、下订单这一套流程。但真正动手从零实现一遍我才意识到这个题目其实是练完整开发链路的好素材Spring Boot搞定服务端Vue负责页面交互MySQL存核心业务数据Redis扛热点场次的库存压力再叠上JWT登录态和Swagger接口文档几乎覆盖了一个企业级项目从设计到部署的主要环节。这个项目特别适合两类人参考一类是正在纠结毕设选题、想找个“看着不水又能讲清楚”方向的计算机专业学生另一类是自学Java全栈、想把散落的技术栈串成完整系统的开发者。我后面写的内容全部基于自己实际敲过的代码和踩过的坑没有空话只讲能落地的方案。1. 最终选型Spring Boot到底解决了什么问题1.1 为什么放弃SSH/SSM直接选Spring Boot先说结论对在线票务预订这种偏业务的系统Spring Boot是当前最稳妥的选型没有之一。放到十年前同样一个功能做下来可能要经历Struts、Spring、Hibernate三座大山的XML配置地狱就算后来换成SSMSpringSpringMVCMyBatis也免不了写一大堆spring-mvc.xml、mybatis-config.xml、web.xml。Spring Boot把所有能自动化的部分都自动化了内嵌Tomcat解决了外部容器部署问题起步依赖Starter把依赖冲突问题降到最低自动配置让我从写配置变成“写业务”。这个“省下来的时间”对毕设来说是决定性的。票务平台真正的难点在业务上——座位怎么锁、订单状态怎么流转、支付回调怎么保证幂等——如果时间都耗在调XML配置上核心逻辑反而没精力打磨。Spring Boot的价值不是“不用写配置”而是把工程复杂度压缩到最低让我能集中火力处理业务。1.2 前后端分离与单体部署两者并不冲突很多同学一听“前后端分离”就觉得要搞分布式、搞微服务其实完全不是一回事。我的方案是开发阶段前端Vue单独跑后端Spring Boot单独跑两边通过REST接口联调部署阶段前端执行npm run build生成静态文件直接扔进Spring Boot的src/main/resources/static目录打成一个Jar包。这样既享受了前后端分离的开发体验又不用在服务器上额外配Nginx托管前端页面一台机器一个Java进程就能跑完整套系统。具体选型如下组件选型理由后端框架Spring Boot 2.7.x稳定、资料多、网络热词里也全是它ORMMyBatis-Plus单表CRUD不用写SQL复杂查询可以自定义Mapper数据库MySQL 8.0免费、常见、索引和事务支持完善缓存Redis 6.x热点场次库存、验证码、登录token都能用权限JWT无状态、前后端分离场景下最省心接口文档knife4jSwagger增强版自动生成接口文档答辩演示效果好JDKJDK 8或17两者都能稳定运行按自己电脑环境选这套组合的套路感很强但胜在务实。毕业设计最重要的不是技术多新而是每一环你都能说清楚“为什么这么选”。2. 数据模型先行核心表结构与订单状态机2.1 六张核心表管住“从演出登记到出票”全流程我设计表结构的时候坚持一个原则先梳理业务流程再画E-R图最后落DDL绝不边写边改。票务平台的主链路是“用户登录 → 浏览演出 → 选场次 → 选座位 → 下单 → 支付 → 出票”反向还有管理员“登记演出 → 配置场次 → 管理座位 → 处理订单”。按这个链路拆出来的六张核心表user用户表用role字段区分普通用户和管理员避免多建一张表。show演出表存演出名称、封面图、场馆、城市、演出时间、基础价格等信息。schedule场次表一个演出可以有多个场次比如同一部剧演三天就有三个场次。seat座位格表这是整套系统的关键。一个场次下有多少个座位就生成多少条座位记录每条记录有独立的status字段。orders订单主表存订单号、用户ID、总金额、订单状态。order_seat订单座位关联表因为一个订单可能买多张座位需要中间表维护多对多关系。关键字段和索引设计如下简化版CREATE TABLE seat ( id BIGINT PRIMARY KEY AUTO_INCREMENT, schedule_id BIGINT NOT NULL COMMENT 场次ID, row_no VARCHAR(10) NOT NULL COMMENT 排号, col_no VARCHAR(10) NOT NULL COMMENT 列号, seat_status TINYINT NOT NULL DEFAULT 0 COMMENT 0待售 1锁定 2已售 9停售, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, KEY idx_schedule_status (schedule_id, seat_status) ) ENGINEInnoDB COMMENT座位格表;seat表上那个联合索引idx_schedule_status是我调试性能时加上的。选座页打开时前端要按场次查所有座位状态这个查询频率极高没有索引的话数据量一上来就会慢。CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 业务订单号, user_id BIGINT NOT NULL, schedule_id BIGINT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, order_status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已出票 3已取消 4已退款, create_time DATETIME NOT NULL, pay_time DATETIME DEFAULT NULL, UNIQUE KEY uk_order_no (order_no) ) ENGINEInnoDB COMMENT订单主表;order_no我采用“时间戳随机数”的雪花ID变体前端照着这个字符串能查订单状态。这里有个容易被忽略的点业务订单号必须建唯一索引否则并发下单时可能生成重复号。2.2 订单状态机的每一步都要能对答辩老师解释订单状态我定义成五个待支付、已支付、已出票、已取消、已退款。整个流转逻辑是用户下单后先锁座位订单状态为待支付此时限定时间内未支付则自动关闭。用户完成支付状态变为已支付同时座位状态从锁定变为已售。平台确认后出票状态变为已出票用户可以查看电子票。用户主动取消订单变为已取消锁定座位释放回待售。售后退款订单从已支付或已出票变为已退款。这套状态机看着简单实际上它决定了后面所有接口的分支判断。写代码时必须统一在一个枚举类里管理状态值千万不要在Controller里散落各种魔法数字否则改一个状态就要全局搜索替换非常痛苦。3. 最容易翻车的高并发场景座位库存与超卖控制3.1 超卖的本质是“读-判断-写”之间存在并发窗口票务平台的座位就是库存。两个人同时抢同一个座位时如果都先查一遍“座位空闲”再执行更新就会出现两个人都下单成功、同一位子卖两次的情况。这就是超卖。解决超卖最直接的办法是数据库乐观锁。我一开始用的是“先查后更”的常规写法压测时复现了超卖问题后来改成一条带条件的UPDATEUPDATE seat SET seat_status 1, version version 1 WHERE id #{seatId} AND seat_status 0 AND version #{oldVersion}这条SQL的含义是只有当座位当前状态还是待售、版本号还是我之前查到的值时才允许把状态改成锁定。UPDATE影响的行数如果为0说明座位已经被别人抢走此次锁座失败整个下单流程直接终止。MyBatis-Plus里对应的写法是boolean locked seatMapper.update( new LambdaUpdateWrapperSeat() .eq(Seat::getId, seatId) .eq(Seat::getSeatStatus, 0) .eq(Seat::getVersion, oldVersion) .set(Seat::getSeatStatus, 1) .set(Seat::getVersion, oldVersion 1) ) 0;3.2 事务边界和死锁问题靠排序更新化解乐观锁只解决了“单座位并发”问题。一个订单买多个座位时还需要保证“要么所有座位都锁成功要么一个都不锁”——这就是事务的职责。我在OrderService里加Transactional方法内依次完成三件事插入订单记录、批量更新座位状态、返回订单号。任何一步抛异常整个事务回滚座位状态恢复原样。这里有个特别隐蔽的坑批量更新多个座位时如果并发事务A先锁座位1再锁座位2事务B先锁座位2再锁座位1两个事务就会互相等待对方释放锁造成死锁。MySQL会直接报错事务回滚用户体验极差。解决办法很简单更新座位前先把座位ID排序所有事务都按ID升序加锁死锁自然消失。ListLong sortedSeatIds seatIds.stream().sorted().collect(Collectors.toList()); for (Long seatId : sortedSeatIds) { // 执行乐观锁更新 }3.3 缓存热点场次Redis的定位是“扛读请求”而不是“扛写请求”数据库乐观锁方案能保证数据绝对一致但有一个性能瓶颈每个选座请求都要查一次数据库热门演出开票瞬间大量用户同时刷新数据库压力很大。我引入了Redis做两层优化第一层缓存场次基础信息和座位状态。开票前先把场次座位列表预加载到Redis用户选座页直接读缓存不用打数据库。第二层是余票数量用Redis的DECR命令做预扣。用户提交订单时先扣Redis余票扣减成功再去更新数据库座位状态如果数据库更新失败再执行INCR把余票加回来。必须强调一点Redis的预扣方案只适合做“前置挡流”最终数据一致性还得靠数据库事务兜底。Redis宕机时系统要能自动降级回纯数据库方案绝不能让缓存层决定最终数据。4. 接口设计里的加分项鉴权、统一返回与分页4.1 JWT登录态与Swagger放行在线票务平台至少有两类用户普通用户和管理员。用户能下单、查订单、退票管理员能新增演出、配置场次、查所有订单。这些权限绝不能靠前端按钮隐藏来实现后端的每个接口都必须做校验。登录成功时我用JWT生成一个带userId和role的token返回给前端。后续请求中前端在Authorization头里带上这个token。后端用一个拦截器解析token解析失败直接返回401。实现逻辑并不复杂核心代码如下String token request.getHeader(Authorization); if (token null || !JwtUtil.verify(token)) { throw new BusinessException(401, 登录已过期请重新登录); }给Swagger放行是必须做的否则接口文档页面会被拦截器挡住。我在配置文件里设置放行路径spring: security: ignore: urls: - /doc.html - /webjars/** - /swagger-resources/** - /v2/api-docs - /favicon.ico不过这里要留个心眼放行不代表这些接口完全裸奔登录接口本身就是要放行的但管理员接口除了验证JWT还要额外判断role字段否则任何人都能拿着普通用户token去创建演出。4.2 统一返回R类和全局异常处理器答辩老师一看就加分一开始我写接口时每个Controller都自己拼返回结构MapString, Object塞两个键有的返回data有的返回result后期维护特别乱。后来我统一封装了一个RT类public class RT { private Integer code; private String message; private T data; public static T RT ok(T data) { RT r new R(); r.setCode(200); r.setMessage(操作成功); r.setData(data); return r; } public static T RT error(String message) { RT r new R(); r.setCode(500); r.setMessage(message); return r; } }所有接口统一返回RT前端拿到code字段做判断不用再猜返回结构。再配合RestControllerAdvice做全局异常处理业务异常、参数校验异常、未知异常各自返回不同的错误码整个系统的健壮性直接上一个台阶。4.3 列表分页与模糊搜索后台管理端最常见的就是列表页——演出列表、订单列表。我的分页用的是MyBatis-Plus自带的分页插件PageShow page new Page(current, size); LambdaQueryWrapperShow wrapper new LambdaQueryWrapper(); if (StringUtils.hasText(keyword)) { wrapper.like(Show::getName, keyword); } if (status ! null) { wrapper.eq(Show::getStatus, status); } IPageShow result showMapper.selectPage(page, wrapper);这里有个小技巧分页查询一定要有排序字段否则数据量大了之后页与页之间可能出现重复数据。一般按create_time倒序排让新数据排在最前面。模糊搜索的字段要先加索引否则LIKE %关键词%这种写法在数据量大时会全表扫描。5. 实战排坑被这几个问题卡住的一整晚5.1 数据库时区导致的时间差八小时第一次部署到云服务器时我发现所有订单的create_time都比本地时间慢了8个小时。查了很久才发现是MySQL连接串的问题。JDBC连接MySQL 8.0时默认时区是服务器系统时区而我的服务器是UTC时区中国用户看到的时间自然不对。解决办法是在application.yml里显式指定时区spring: datasource: url: jdbc:mysql://localhost:3306/ticket_db?serverTimezoneAsia/ShanghaiuseSSLfalsecharacterEncodingutf8排查这个问题的思路值得记录一下先看前端请求时间对不对再看后端接口返回的JSON时间对不对最后看数据库里存的值。逐层缩小范围后才发现根因在JDBC连接参数。后面每次搭新环境我第一件事就是确认时区配置再也没被这个坑卡过。5.2 事务自调用失效同类内部方法调用没走代理这是Spring事务最经典的坑。我在OrderService里写了一个createOrder方法内部又调用了同一个类的deductStock方法deductStock上标了Transactional。结果测试时发现deductStock抛了异常createOrder却照样提交成功座位照常锁定。原因在于Spring的事务是基于AOP代理实现的。外部调用createOrder时走的是代理对象但类内部方法之间直接调用时this.deductStock()调用的是原始对象的方法不会经过代理事务注解自然失效。解决办法有两种要么把deductStock拆到另一个Service类里让外部类调用代理对象要么在createOrder方法内部通过AopContext.currentProxy()获取代理对象再调用。我选择了前者因为拆类还能顺便让代码职责更清晰。5.3 定时任务关单Scheduled默认单线程执行订单支付限时15分钟超时自动关单释放座位。我第一时间想到Scheduled(cron 0 */1 * * * ?)每分钟扫描一次待支付订单超过时限就取消。功能跑通后才发现隐患Spring默认的Scheduler是单线程的如果一个任务执行时间过长比如一次性处理几千个过期订单下一个任务就会被阻塞。我的改进方案是把定时任务拆成两步每次任务只取一批超时订单ID然后逐批处理避免单次任务执行时间太长。另外还配置了线程池Configuration public class SchedulingConfig implements SchedulingConfigurer { Override public void configureTasks(ScheduledTaskRegistrar taskRegistrar) { taskRegistrar.setScheduler(Executors.newScheduledThreadPool(5)); } }如果项目将来部署成多实例这里还需要引入分布式锁防止两个实例同时扫描订单重复处理。毕设阶段单实例可以不加但这个问题要在答辩时能讲出来。5.4 支付回调的幂等处理接入模拟支付后我发现支付平台偶尔会重复回调同一个订单。如果后端每次都把订单状态从“待支付”改成“已支付”第一次改完再收到重复回调就会覆盖错误的状态。幂等处理的思路很简单但很重要处理回调前先查支付记录表如果该订单已经是已支付状态直接返回成功不再执行任何更新操作。同时支付回调的整个逻辑要放在数据库事务里用订单号加唯一索引兜底确保同一笔支付永远不会被处理两次。6. 答辩前的自查清单从“能跑”到“能讲清楚”6.1 每个“为什么”都要准备好答案很多同学的项目能跑但答辩时经不住追问。我整理了几个必问方向每个都自己先写了一遍答案为什么选Spring Boot而不是Spring MVC从自动配置、内嵌容器、起步依赖三个方面回答。怎么防止库存超卖讲清楚乐观锁的UPDATE ... WHERE条件和事务回滚机制。订单状态为什么用数据库字段而不是存内存数据要持久化服务重启后状态不能丢。JWT和Session有什么区别JWT无状态、适合前后端分离和分布式环境Session需要服务端维护会话信息多实例部署时还要解决Session共享问题。Redis和数据库的一致性怎么保证我的方案是Redis做前置挡流最终以数据库确认为准缓存允许短暂不一致但不能长期不一致。这些问题都不深但要求你真的理解底层逻辑而不是背答案。6.2 演示稳定性比功能数量更重要毕设答辩现场最容易翻车的不是功能缺失而是演示过程中报错或页面卡顿。我的经验是演示前单独准备一套干净数据——几个真实感的演出名称、几十个测试用户、一批不同状态的订单让页面一打开就有内容可看而不是从空白表单开始录入。演示路径也要提前走一遍登录 → 选场次 → 选座位 → 下单 → 模拟支付 → 查订单。每一步大概几秒流程顺畅远比堆砌十个没讲透的模块更打动人。最后再分享一点个人经验这个项目做完之后我最大的体会是在线票务平台的表象是CRUD里子却藏着并发控制、状态机设计、缓存策略、幂等处理这些真正值钱的东西。毕设阶段不需要堆技术栈把两三个核心难点从原理到实现都吃透就能在答辩时说得头头是道。后续如果想继续扩展可以往两个方向走一是对接业务审批流用Flowable把演出上架、退票审核流程化二是把前端升级成微信小程序把用户渠道拓宽。但记住一点任何扩展都建立在核心链路扎实的基础上先保证“一张票只能卖一次”再去追求花哨的功能。本文还有配套的精品资源点击获取