资讯动态

SpringBoot+Redis售票系统防超卖实战:订单状态机与库存设计

发布时间:2026/10/3 9:59:25 来源:尧图企业网站定制
1. 项目背景与方案选型1.1 售票痛点与系统定位做线上动物园售票系统之前很多人第一反应是“这无非是个 CRUD 项目”。真上手之后你会发现订单、库存、支付状态、退票规则这些点每一个都能让你加班到半夜。这个系统的核心价值一方面是替游客省去现场排队买票的时间另一方面是帮园区把票务数据沉淀下来——销量统计、热门时段分析、游客画像全都得有数据支撑才能做。这个项目我定位为课程设计/毕业设计级别的完整案例不是玩具但也没有过度工程化。它要覆盖一条完整的购票链路游客浏览票种 → 注册登录 → 选择日期与数量 → 创建订单 → 模拟支付或对接真实支付 → 生成电子票二维码 → 入园时扫码核销。同时后台需要支持票种管理、订单管理、用户管理、公告发布、数据统计。整个系统用 SpringBoot 作为后端主框架前端我选了 Vue Element UI数据库用 MySQL缓存加了一层 Redis。如果你正准备拿 Java 方向做毕设或课设这套组合的覆盖面足够广答辩时也讲得出东西。1.2 技术选型背后的取舍逻辑SpringBoot 在 Java 生态中的地位不用多说我选它的核心原因是启动快、配置收敛、生态成熟。你不用像 SSM 时代那样写一堆 XML一个SpringBootApplication就能跑起来。但这不代表你不需要理解底层——比如 SpringBoot 默认整合了 Spring MVC、内嵌 Tomcat、自动配置机制你至少要明白spring-boot-starter-web帮你做了什么否则遇到诡异问题根本无从排查。持久层框架我选了 MyBatis Plus而不是原生 MyBatis 或 JPA。原因很简单单表 CRUD 要是手写 XML效率太低JPA 虽然省事但复杂查询和 SQL 调优不直观。MyBatis Plus 兼顾两者内置的BaseMapper能覆盖 80% 的简单操作复杂统计用注解 SQL 或 XML 自己写可控性很强。另外它自带分页插件后台列表页和订单查询都直接受益。Redis 的引入主要是两件事一是缓存票种信息和公告减少数据库压力二是购票时的库存预扣与防超卖处理。这两个场景用 Redis 的原子操作非常合适。如果只是纯课程设计完全不引入 Redis 也能跑但我想让这个项目有一点“生产味道”所以把它加了进来。至于支付真实对接微信/支付宝需要商户号个人项目拿不到所以我在项目中做了“模拟支付”模块同时把支付接口抽象出来后续要对接真实支付只需替换实现类。2. 系统模块与数据库设计2.1 角色权限与功能模块拆解整个系统按角色划分成三类游客、注册用户、管理员。游客只能浏览票种和公告注册用户在前者基础上增加了购票、订单查询、退票、个人信息管理管理员则进入后台管理票种、订单、用户、公告还能看销售统计报表。我画模块图的时候习惯先列角色再列每个角色能做什么最后落到页面和接口上。前台部分的核心模块是首页轮播公告、票种列表、购票流程选日期/选数量/创建订单、订单中心待支付/已支付/已退票状态流转、电子票展示、个人中心。后台部分的核心模块是登录鉴权、Dashboard 统计卡片、票种管理增删改查上下架、订单管理查看/退款、用户管理、公告管理。这里我特别想强调一个容易在设计阶段被忽略的点票种与日期库存的关系。很多初学设计数据库时只做一张 ticket 表存总量结果用户选不同日期买票时库存根本没法区分。我实际的做法是引入“日期库存表”每个票种对应多个日期的库存记录比如“成人票-2025-06-01-剩余500张”。这样既能控制单日可售量也为后续的限流和峰值控制留了余地。2.2 核心数据表结构详解数据库我总共设计了9张表挑核心的几张说一下设计思路。第一张是用户表user。字段包括主键 id、手机号登录账号、密码BCrypt 加密存储、昵称、头像、角色标识1-普通用户 2-管理员、创建时间。手机号作为唯一登录凭证在表上要加唯一索引。密码绝对不能明文存储这是底线我用 Spring Security 自带的BCryptPasswordEncoder做哈希。第二张是票种表ticket_type。字段有名称成人票/儿童票/学生票/家庭套票、描述、原价、售价、票种类型、状态0-下架 1-上架、创建时间。这里有个细节原价和售价要分开方便后续做优惠活动。金额字段用 DECIMAL(10,2)千万不要用 double/float线上环境因为浮点精度导致对不上账的例子太多了。第三张是日期库存表ticket_stock。字段有id、票种 id、售卖日期、总库存、剩余库存、版本号。这个表就是防超卖的关键战场。版本号字段是为了后续做乐观锁控制用的虽然我在最终方案里用了 Redis 预扣为主数据库还保留了乐观锁作为兜底双保险。第四张是订单表ticket_order。字段有订单编号自定义生成规则、用户 id、订单总金额、订单状态0-待支付 1-已支付 2-已取消 3-已退票 4-已完成、支付方式、支付时间、创建时间、更新时间。订单号的生成规则我用了“日期 随机数 用户ID后四位”示例202506011430221234。不建议直接用数据库自增 id 当订单号暴露给用户容易被爬取和猜测这个点面过几个面试官都问过。第五张是订单明细表order_item。因为一个订单可能包含多种票一张家庭套票 两张成人票订单明细用来记录每种票的数量、单价、小计金额。主表存总金额明细表存分项这是标准的 1:N 设计不要偷懒只建一张订单表。最后还有公告表、轮播图表、退款记录表逻辑相对直接不展开细说。2.3 订单状态机与关键字段设计状态机是这个系统里最值得细说的地方。订单状态我定义了五个0-待支付、1-已支付、2-已取消、3-已退票、4-已完成。它们之间的流转关系是待支付可以到已支付用户付款也可以到已取消用户主动取消或超时未付已支付可以到已退票用户申请退款也可以到已完成入园核销后自动流转已退票和已完成都是终态不能再做任何操作。这个状态机定义清楚后所有的接口逻辑都围绕它转。比如用户端“取消订单”接口第一件事就是判断当前状态是否为 0如果不是直接拒绝。又比如管理员“退款”接口只处理状态为 1 的订单。这样看起来是代码里几行 if 判断但设计阶段没想清楚后期就会陷入各种状态错乱的 bug 泥潭。我还在订单表里加了一个out_trade_no字段专门存第三方支付流水号。虽然模拟支付用不上但这是给未来对接真实支付留的扩展位。做设计时预留这类字段是很好的习惯答辩加分项往往就在这些细节上。3. 核心业务实现与实操细节3.1 购票流程与库存防超卖方案购票是整个系统最核心的链路。我把它拆成四个步骤参数校验 → 库存预扣 → 创建订单 → 超时自动释放。前端页面点击“提交订单”后后端接口按这个流程处理。参数校验阶段除了判断用户是否登录、票种是否存在、数量是否为正整数这些常规校验我还加了一个“售卖日期必须大于今天”的判断防止用户补买昨天的票。另外要校验单笔订单最大购买数量我限制为每个票种最多 5 张防止恶意刷单。库存预扣是我重点处理的部分。方案是这样先把票种信息和目标日期的库存量缓存到 Rediskey 设计为stock:ticket:{ticketId}:{date}value 存剩余数量。用户提交订单时用一段 Lua 脚本原子执行“检查剩余量大于等于购买量 → 扣减剩余量 → 返回成功”否则返回库存不足。为什么用 Lua 脚本因为 Redis 单线程执行Lua 脚本能保证判断和扣减这两步的原子性避免并发情况下两个请求同时读到剩余量 1然后都买成功导致超卖。库存预扣成功后才创建订单记录状态置为待支付。这里有一个关键细节预扣的库存并不会立即从数据库的ticket_stock表扣减而是通过一个定时任务每隔 5 分钟扫描待支付订单如果超过 15 分钟未支付就自动取消同时恢复 Redis 库存来兜底。为什么用延迟释放而不是立即释放因为用户可能在支付页停留如果提前释放库存别人把票抢走了用户支付成功却没票这体验太差了。而 15 分钟不支付基本可以断定用户已经放弃。数据库表的剩余库存字段只在订单支付成功后才真正扣减。也就是说Redis 库存负责“并发拦截”数据库库存负责“最终一致性”。等技术能力再强一点你可以用消息队列把扣减动作异步化但在这个项目体量下定时任务足够。核心 Lua 脚本我贴出来这个脚本我调试过很多次-- KEYS[1] stock:ticket:{ticketId}:{date} -- ARGV[1] 购买数量 local remain tonumber(redis.call(GET, KEYS[1])) local buyNum tonumber(ARGV[1]) if remain and remain buyNum then redis.call(DECRBY, KEYS[1], buyNum) return 1 else return 0 end对应的 Java 调用侧要注意执行完 Lua 脚本返回 1 才继续创建订单返回 0 直接抛业务异常“库存不足”。另外 Redis 的 key 要设置过期时间比如票种下架或售卖日期过后可以让它自动淘汰避免脏数据长期占用内存。3.2 订单生成与超时自动关闭订单创建时几个字段要特别处理。订单编号我用了自定义生成器规则是yyyyMMddHHmmss 4位随机数 用户ID后四位。UUID 虽然简单但很长且无序索引效率差纯自增 id 又太容易暴露业务量。我的这个规则在演示效果和查询性能之间比较均衡。创建订单的接口我加了Transactional事务注解保证订单主表和明细表要么一起成功要么一起回滚。有人可能疑惑Redis 库存已经扣了数据库事务失败怎么办我的处理是在事务失败时手动调用一个releaseRedisStock()方法把预扣的库存加回去。这一步不能依赖 Redis 的自动过期因为 15 分钟太久用户立刻重试时会看到库存被“吞了”。超时关闭订单的定时任务我用 Spring 自带的Scheduled实现固定间隔 30 秒执行一次。任务逻辑是查询状态为待支付且创建时间小于当前时间 15 分钟的订单列表逐单关闭并恢复 Redis 库存。这里我踩过一个坑千万不能用SELECT * FROM ticket_order WHERE create_time DATE_SUB(NOW(), INTERVAL 15 MINUTE)一次性查出所有订单然后 for 循环处理。如果订单量大长事务会把表锁住。正确做法是分页查询每批 100 条处理完再查下一批。这个习惯在大数据量下会救你一命。Scheduled(fixedDelay 30000) public void autoCloseExpiredOrders() { // 分页查询待支付且超时的订单 PageOrder page orderMapper.selectExpiredOrders(new Page(1, 100), 15); while (page.getRecords().size() 0) { for (Order order : page.getRecords()) { // 关闭订单恢复Redis库存 closeOrder(order); } // 继续查下一页 page orderMapper.selectExpiredOrders(new Page(page.getCurrent() 1, 100), 15); } }定时任务里还要注意并发执行问题。Scheduled默认单线程执行但如果任务执行时间超过间隔会出现任务堆积。我建议在方法上加一个Lock如果是用 ShedLock 的话或者用一个简单的AtomicBoolean标志位防止重入。项目里我用的是标志位方案够用。3.3 电子票生成与核销流程支付成功后系统要给用户生成电子票。我用的是二维码技术集成 ZXing 库生成二维码图片内容是一串加密后的凭证串。凭证串包含订单号、票种ID、入园日期、用户ID后四位我用 AES 对称加密再拼接防止有人伪造二维码。核销场景是这样的游客到了动物园入口打开公众号或 App 里的“我的电子票”出示二维码工作人员用后台的“检票核销”功能扫码。扫码后后端解析密文校验订单状态为已支付、入园日期与当日匹配、核销状态为未使用满足条件就更新状态为已完成。这个流程里我特别提醒一个问题二维码内容不要直接放明文订单号否则有人可以通过遍历订单号生成二维码逃票。AES 加密虽然谈不上绝对安全但在这种应用场景里足以挡住大部分低级攻击。密钥别写在代码里放到application.yml的外部配置或者环境变量里项目文档里也要注明。核销接口必须是幂等的。如果游客的二维码被扫了两次第一次成功第二次要返回“该凭证已核销”不能报系统异常。同时核销操作要加锁防止同一张票同时被两个入口的机器扫码导致并发更新。MySQL 行锁用SELECT ... FOR UPDATE或者用 Redis分布式锁都可以我这里用的是乐观锁更新时带上核销状态条件影响行数为 0 说明已被核销。3.4 接口设计与统一返回规范接口设计我走的是 RESTful 风格但也没有严格到教条的程度。比如“购票”这个动作用POST /api/ticket/order来创建订单“取消订单”用PUT /api/ticket/order/{orderNo}/cancel来表示状态变更。这样接口的语义清晰前端对接时也容易理解。所有接口的返回格式统一为{ code: 200, message: success, data: {} }code 为 200 表示业务成功其他为业务错误码比如 40001 表示库存不足40002 表示订单状态异常40003 表示参数校验失败。我建议错误码分段规划4xxxx 是前端传参问题5xxxx 是服务端处理问题这样通过错误码就能快速定位责任方。统一返回格式我用一个ResultT泛型类实现配合全局异常处理器RestControllerAdvice。业务异常类BizException里直接携带错误码和描述控制器代码里只需要throw new BizException(ErrorCode.STOCK_NOT_ENOUGH)异常处理器统一捕获并包装返回。这个模式也是实际生产项目里的标准做法值得从课设阶段就开始养成。另外接口层要加参数校验JSR 303 的Valid注解用起来。请求 DTO 里对数量字段加Min(1)、对日期字段加NotBlank。不要把这些校验依赖前端前端校验只是用户体验后端校验才是安全底线。4. 前端页面与接口对接4.1 前端技术栈与页面路由结构前端我选了 Vue 2 Element UI如果是新学建议直接上 Vue 3 Element Plus原理类似。通过 Axios 请求后端接口路由用 Vue Router状态管理用 Vuex。为了演示方便我用vue-cli搭的工程开发时通过proxy配置把/api前缀的请求代理到后端 8080 端口避免跨域开发问题。页面路由按用户侧和管理侧拆分。用户侧页面包括首页、票种列表、购票确认、订单列表、订单详情、电子票、个人中心、登录注册。管理侧页面包括Dashboard、票种管理、订单管理、用户管理、公告管理、数据统计。整套页面数量在 15 个左右工作量适中但覆盖面足够用来演示前后端分离开发的能力。4.2 购票页面的关键交互逻辑购票确认页是最核心的交互页面。它要完成加载票种列表 → 用户选择日期日期选择控件禁用今天之前的日期→ 选择数量 → 实时计算总价 → 提交订单。总价计算我做了前端实时计算展示但后端接口会重新计算一遍以后端为准。不要信任前端传过来的金额这是防篡改的基本常识。前端传参只传票种ID、日期、数量金额由后端查表计算得出。订单支付页的逻辑也值得一提。用户点击“确认支付”后前端调用支付接口后端在模拟支付模式下直接返回成功并将订单状态从待支付更新为已支付同时生成电子票。真实接入支付时这个接口会变成“调用支付平台下单返回支付参数”前端跳转收银台支付结果通过回调通知。我把PaymentService接口定义好分别实现了MockPaymentServiceImpl和预留的RealPaymentServiceImpl这块设计在答辩时讲出来会显得专业。前端还有一个细节用户从购物车一样的购票页跳到订单确认页时后端返回的订单号需要保存到 Vuex这样支付成功后的电子票页面才能查到属于当前用户的电子票凭证。我见过不少同学把订单号放在 URL query 里刷新页面就丢了体验比较糟糕。4.3 管理后台的统计报表实现Dashboard 统计页面需要展示三个核心指标今日销售额、今日订单数、本月售票总量。数据来源是订单表按时间维度做聚合统计我用 MyBatis Plus 的selectMaps方法写聚合 SQL返回ListMapString, Object前端用 ECharts 渲染柱状图和饼图。这里有个性能优化点统计接口每次实时查库如果订单量大了会很慢。我的处理是第一次查询后把结果缓存到 Redis设置 60 秒过期相当于容忍 1 分钟内的数据延迟。对于园区这种体量的业务这个延迟完全可接受。前端每 30 秒自动刷一次配合缓存生效时间体验和性能之间取得平衡。5. 常见问题与排查心得5.1 库存超卖问题排查实录我测试时故意用 JMeter 开 200 个线程同时买同一个票种的最后一张票第一次测试就翻车了——卖出了 7 张。排查过程很有意思虽然最后定位到原因很简单但把排查思路写下来对大家有参考价值。首先检查数据库库存表发现剩余库存变成了负数。再翻日志发现Redis 里的库存量其实扣减是正确的问题出在订单支付成功后的“数据库扣减”环节。我用的是先查库存再更新库存的代码Stock stock stockMapper.selectByTicketIdAndDate(...); if (stock.getRemain() 0) { stock.setRemain(stock.getRemain() - 1); stockMapper.updateById(stock); }这种“读改写”模式下多线程同时读到剩余 1条件都成立都执行了更新就超卖了。修复方式是直接更新int rows stockMapper.deductStock(ticketId, date, 1); if (rows 0) { throw new BizException(ErrorCode.STOCK_NOT_ENOUGH); }对应 SQL 是UPDATE ticket_stock SET remain remain - 1, version version 1 WHERE ticket_id ? AND sell_date ? AND remain 1。通过数据库行锁和受影响的 UPDATE 保证原子性。这也是我在前面提到“乐观锁作为兜底”的原因。5.2 定时任务不执行或重复执行的坑Scheduled(cron 0 */5 * * * ?)这个写法在单机环境下没问题但如果未来系统部署了多实例每个实例都会执行一遍定时任务导致重复处理。课程设计阶段不会遇到多实例部署但我还是在文档里提了一嘴解决方案用 Redis 的SETNX做一个简单的分布式锁SET key value NX EX 120获取到锁的实例才执行任务执行完删除锁或者引入 ShedLock 依赖两行配置就搞定。另外提醒一个小坑SpringBoot 的Scheduled默认线程池只有一个线程。如果你在任务内部调了远程接口导致阻塞后续的任务全部卡住。我建议在配置类里显式声明ThreadPoolTaskScheduler设置核心线程数为 5避免一个慢任务拖垮其他定时任务。5.3 时间格式化、跨域与配置项小坑时间格式化这个问题几乎每次都会遇到。后端返回LocalDateTime默认是2025-06-01T14:30:00这种 ISO 格式前端展示很难看。我在application.yml里统一配置了 Jackson 的格式spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8跨域问题也一样。前后端分离开发时前端 8080后端 9527浏览器会拦截跨域请求。我在后端写了一个CorsConfig配置类允许所有来源、所有请求方法开发环境足够。生产环境部署建议用 Nginx 反向代理统一入口彻底规避跨域问题同时也更安全。还有一个配置项坑SpringBoot 版本差异导致配置不生效。如果你用的 SpringBoot 2.4 以上的版本配置文件里的多环境配置写法变了spring.profiles.active需要放到application.yml最外层不要再写在spring.profiles下面。类似这些小坑我建议把踩过的记录下来写进配套的“遇到的问题与解决”文档中答辩时是一份很好的加分材料。5.4 配套文档、PPT与源码的组织经验这个项目带了完整的文档和 PPT这部分经验我从来没见别人认真整理过。毕设/课设答辩的评委会翻文档但更重要的是他们会在现场让你演示系统。所以我的文档组织逻辑是需求分析用户故事和功能清单 → 系统设计架构图数据库ER图接口文档 → 系统实现核心代码走读 → 测试报告功能测试并发测试 → 总结与展望。PPT 则控制在 15 页以内核心是讲清楚“我做了什么”和“我遇到了什么问题怎么解决的”不要堆代码。源码组织方面也有讲究。后端模块我按 controller、service、mapper、entity、common、config 分包一个包干一件事。前端把 api 请求封装到src/api目录下页面组件在src/views下。整个代码层级清晰别人拿到就能快速跑起来这是“含源码”项目最基本的要求——不是把代码甩出来就叫含源码而是要让接手的人能看明白、能改、能运行。最后再分享一个小技巧整个系统做下来我最大的感受是课设级别的项目重点不在于技术多新多全而在于逻辑闭环。你做一个售票系统就要保证从注册到购票到支付到核销到退票整条链路每一个分支都处理到位。我见过太多项目演示时主流程很顺畅一展示管理员退款就报错一展示库存不足就白屏——这种硬伤特别致命。我自己的习惯是交付前花半天时间把核心业务链路的每一个接口用 Postman 过一遍包括异常场景未登录访问订单接口、库存为 0 时下单、重复核销、非法参数请求。把这些异常处理正常了演示再过不了只能说明运气不好。另外记得MySQL 和 Redis 的本地环境配置、数据库初始化脚本、Redis 数据预置命令全都要写进 README。换一台电脑就跑不起来这比你代码写得漂亮但要花三小时才能启动要致命得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑