简介:基于SpringCloud的分布式演唱会抢票系统毕业论文,是一份面向计算机软件工程、分布式系统开发及毕业设计场景的完整技术研究文档。论文以传统演唱会票务管理耗时耗力、数字化程度低为切入点,系统开展了需求分析、架构设计、编码实现与功能测试,覆盖了用户端与管理员端两大核心模块。技术层面采用Java语言、VUE前台与SpringCloud后台组成前后端分离架构,并结合分布式负载均衡、缓存机制与消息队列,对抢票场景短时高并发的压力进行了针对性优化,体现了高内聚低耦合的设计思想。资源包内仅收录1个docx文件,整篇论文约3.53MB,正文包含中英文摘要、目录、绪论、需求分析、系统设计、测试结果总结等章节,结构完整、层次分明。目前已有69人浏览学习,对正在撰写分布式系统论文或希望从需求到实现完整理解抢票系统的读者,具有直接的参考价值和可复用的设计思路。
1. 分布式抢票系统,难点从来不在那台服务器上
每年到了演唱会开票节点,我都能在朋友圈里刷到一片“服务器已跪”的截图。可真正参与过抢票系统开发的人清楚,并发高只是外在印象,系统能不能扛住,核心看的是库存扣减和订单状态这两条链路上的数据一致性。这篇笔记要聊的,是一个基于 SpringCloud 的分布式演唱会抢票系统,从服务拆分、Redis 库存扣减、RabbitMQ 削峰,到分布式事务补偿和压测闭环,把一个毕业设计级别的项目做成能写进论文、能现场演示、能回答上答辩追问的完整落地路径。适合正在做 SpringCloud 方向毕设的学生,也适合刚转分布式开发的初级工程师照着搭一套最小可用原型。
2. 服务拆到哪个粒度:抢票系统的服务划分与 Maven 依赖
2.1 先看业务链路再谈微服务
很多人拿到“抢票系统”第一反应是模仿电商秒杀,把订单、商品、库存照搬过来。但演唱会抢票有一条完全不同的特征:库存是离散的。同一场演唱会存在多个票价档位,每个档位可能只有几百张票,用户从一个档位点进来抢,实际上是在和同档位那几百个人竞争。这和电商那种海量商品池的秒杀模型不太一样,压力点集中在少数几个热点 SKU 上。
所以在拆服务之前,要先画出最小可用链路。用户打开页面看到票档和余票,这个余票数据来自 Redis 缓存;点击抢票后请求经网关转发到订单服务,订单服务尝试锁库存,库存够则创建订单、发送延迟消息用于超时关单,最后提示用户支付。围绕这条链路,我一般拆成四个业务服务加两个基础服务:用户服务、票档服务、库存服务、订单服务、网关服务、注册与配置中心。网关用 SpringCloud Gateway,注册中心用 Nacos,服务间调用用 OpenFeign。
选 Nacos 而不是 Eureka,不是因为性能差多少,而是 Nacos 同时具备注册中心和配置中心两个身份。论文里可以写成“减少一套中间件部署成本”,实际操作时也少维护一个组件,对单机部署的毕设环境非常友好。另外 Nacos 在 K8s 和 SpringCloud Alibaba 体系里表现稳定,后续如果要讲“系统演进”也有素材。
2.2 SpringCloud Gateway 的路由配置与核心参数
网关承担两件事:请求转发和简单限流。先别在网关上做太复杂的鉴权逻辑,毕设阶段把路由、超时、限流配好就够用。
spring: cloud: gateway: routes: - id: order-route uri: lb://order-service predicates: - Path=/api/order/** - id: stock-route uri: lb://stock-service predicates: - Path=/api/stock/** default-filters: - name: RequestRateLimiter args: redis-rate-limiter.replenishRate: 20 redis-rate-limiter.burstCapacity: 40 key-resolver: "#{@apiKeyResolver}"这段配置里有两个容易忽略的参数。replenishRate是每秒往令牌桶里放的令牌数,相当于平均每秒允许的请求量;burstCapacity是桶的容量,允许瞬时突发流量。毕设演示时通常只开放一个入口,这两个值不宜配太大,否则压测时 Nacos 里能看到大量调用,但网关日志里全是 429,答辩现场会很难看。
lb://order-service表示开启负载均衡后按服务名从 Nacos 拉取实例列表。写这篇笔记时我特意强调这个前缀,是因为很多新手把网关配成了http://localhost:8082这种直连地址,一旦服务实例数量变化或端口调整,网关配置就得跟着改,完全失去了微服务动态发现的意义。
2.3 Nacos 注册中心与 OpenFeign 调用的最小配置
有了网关还不够,服务之间也要能互相找到。用户点下抢票按钮时,大致调用链是:Gateway → order-service → stock-service,中间这一跳用 OpenFeign 完成。
<dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId> <version>2021.0.1.0</version> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-openfeign</artifactId> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-gateway</artifactId> </dependency>spring: application: name: order-service cloud: nacos: discovery: server-addr: 127.0.0.1:8848 namespace: publicOpenFeign 调用时最关键的注解是@FeignClient(name = "stock-service"),name 写的是服务注册名,不是 IP 地址。调用方通过 Nacos 拿到库存服务的实例列表后,再配合 Ribbon 或 LoadBalancer 做负载均衡。
这里给个提示:服务名叫什么,注册名就是什么,Feign 里的 name 也必须一模一样。比如库存服务的 application.name 配置的是stock-service,Feign 里写成STOCK-SERVICE也能通,但毕设代码里最好全程保持小写,避免在 Linux 部署时因为环境变量覆盖配置而产生莫名报错。
服务拆分到这一步,已经能完成一个最基础的闭环:网关转发到订单服务,订单服务通过 Feign 调用库存服务。但距离“能抢票”还差最关键的一环,库存到底怎么扣才能不超卖。
3. 拒绝超卖:Redis 分布式锁与 Lua 原子扣减的正确用法
3.1 为什么 synchronized 和数据库行锁在这里都不够
先问一个最基础的问题:单机版本地锁能不能解决抢票超卖?答案是不能,而且和性能无关。抢票系统部署了多个订单服务实例,用户 A 的请求打到实例 1,用户 B 的请求打到实例 2,两个实例各有一把 synchronized 锁,它们互相看不见对方。A 和 B 同时读到剩余票数是 1,各自通过本地校验,然后各自扣减,最后数据库里可能出现 -1 张票。
那直接用数据库的乐观锁行不行?比如在库存表加一个version字段,更新时带上版本号,版本号不匹配就重试。逻辑上可以防止超卖,但当你压测时会发现 QPS 一旦上来,大量请求在版本冲突后疯狂重试,数据库的 update 语句排队,CPU 和连接池双双被打满。抢票场景的读写比极度失衡,读多写少且写集中在热点行,这种模式天生适合用 Redis 做前置承接,而不是让所有流量直接穿透到数据库。
所以在抢票系统里,库存数据要放一份在 Redis,数据库的表作为最终落账和后续对账的依据。库存扣减统一走 Redis,并且要保证“检查库存 + 扣减库存”这两个动作是原子的。
3.2 方案一:用 Redisson 分布式锁锁住下单入口
最简单也最好理解的方案,是用 Redis 分布式锁把整个“从检查库存到扣减”的代码临界区保护起来。
@Autowired private RedissonClient redissonClient; public Boolean lockBuy(Long eventId, Long userId) { String lockKey = "ticket:lock:" + eventId; RLock lock = redissonClient.getLock(lockKey); int waitTime = 2; try { if (lock.tryLock(waitTime, TimeUnit.SECONDS)) { return doBuy(eventId, userId); } else { return false; } } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } }这段代码里有两个参数值得讲。waitTime是获取锁的等待时间,抢票场景建议设置在 1 到 3 秒之间,超过这个时间直接视为抢票失败,避免大量线程堆积在锁等待上把系统拖垮。leaseTime我没有显式设置,走的是 Redisson 的看门狗机制,默认每 10 秒续期一次。这是很多人忽略的点:如果自己用SETNX加锁并手动设置过期时间,一旦业务执行超过过期时间,锁会被自动释放,下一个线程拿到锁继续处理同一批库存,超卖就出现了。
Redisson 的看门狗也不是完全没有代价。它会额外占用 Redis 连接和线程资源,在每把锁持有时每 10 秒发一次续期命令。但和超卖风险相比,这点开销是值得的。毕设论文里可以把“看门狗续期机制”单独作为一个小节讲清楚,比罗列一堆架构图要打动答辩老师。
3.3 方案二:用 Lua 脚本把检查与扣减合成一步
锁方案清晰易懂,但每次抢票都经历“加锁、执行业务、解锁”三步,锁等待会拉高请求延迟。生产环境我更常用的是 Lua 脚本方案,直接把扣减逻辑推进 Redis 执行。
-- ticket_stock_decr.lua local key = KEYS[1] local quantity = tonumber(ARGV[1]) local current = tonumber(redis.call('GET', key)) if not current then return -2 end if current < quantity then return -1 end redis.call('DECRBY', key, quantity) return 1这一段脚本的逻辑很直白:先拿到当前剩余票数,数量不够就返回 -1,键不存在就返回 -2,够就执行DECRBY并返回 1。Redis 执行 Lua 脚本是单线程串行执行的,所以不会出现两个请求同时读到同一个库存的情况。Java 侧的调用代码配合拦截器或切面,在库存扣减失败时直接抛出异常,不走后续的下单逻辑。
用这个方案后,原本要加锁保护的临界区变成了一个原子操作。它减掉了锁等待时间,抢票请求的耗时能降到原来的三分之一左右。代价是把业务判断搬到了 Redis 里,出问题时排查链路会更依赖对脚本的理解。建议把脚本放在 resources 目录下保存,不要用字符串拼接,不然 IDE 里看不出脚本结构。
3.4 库存预热与订单号的生成
Redis 里库存的 key 结构决定了压测表现。我习惯用stock:event:{eventId}:{tierId}作为 key,value 直接存剩余票数。开票前通过一个定时任务或管理接口把数据库库存预热到 Redis,抢票开始后数据库不再承接高频扣减。
这个设计也给论文提供了对比数据:预热前直接扣数据库的 QPS 大概在几百,预热后能到几千,差异非常直观。
订单号生成同样是个值得写进论文的细节。数据库自增主键在分布式环境下会有重复风险,因为多实例同时插入时无法保证全局唯一。我推荐用“时间戳 + 机器标识 + 随机序列”的方式,也就是常说的分布式 ID。实现上可以直接用 Hutool 的IdUtil.getSnowflakeNextId(),也可以参照 MyBatis-Plus 的ASSIGN_ID策略。论文里把这段写成“基于雪花算法的全局订单号生成方案”,既说明了为什么不用自增主键,也堵住了答辩时“订单号会不会重复”的追问。
4. 订单与库存的分布式事务:从强一致到最终一致的真实取舍
4.1 抢票场景只能做最终一致
如果库存扣减和订单创建在同一个事务里,事务回滚时会遇到一个问题:Redis 里已经扣掉的库存要怎么还原?最简单的是先写订单,订单创建失败后再把 Redis 库存加回去。可这两步不在同一个数据库事务里,任何一个环节宕机都会留下脏数据。
分布式事务的教科书方案是 Seata 的 AT 模式,它通过事务协调器记录 SQL 的前后镜像,自动完成回滚。但 AT 模式在高并发抢票场景下并不理想,全局锁会拖住整个扣库存链路,性能消耗比分布式锁方案还要明显。更重要的是,抢票业务本身允许“下单失败”和“支付超时关闭订单”,这些场景天然不需要强一致,最终一致就可以满足业务要求。
所以我的选择是:把“扣库存”和“创建订单”拆成两个通过可靠消息异步衔接的步骤。库存扣减成功后就发送一条消息,订单服务消费消息创建订单。哪怕消息短暂积压,用户看到的也只是稍微慢几百毫秒的反馈,但不会出现超卖,也不会出现“扣了钱没有订单”这种灾难。
4.2 用 RabbitMQ 削峰并手动确认消费
抢票瞬间的流量峰值远高于系统平均处理能力,直接让后端接口硬扛峰值意味着把大量服务器资源浪费在 99% 时间都用不上的容量上。常见做法是引入消息队列削峰填谷,先把请求收下,再让订单服务按自己的节奏处理。
@RabbitListener(queues = "ticket.order.queue") public void onOrderMessage(Message message, Channel channel) throws IOException { long deliveryTag = message.getMessageProperties().getDeliveryTag(); try { TicketOrder order = JSON.parseObject(new String(message.getBody()), TicketOrder.class); orderService.createOrder(order); channel.basicAck(deliveryTag, false); } catch (Exception e) { channel.basicNack(deliveryTag, false, true); } }这段代码里最关键的是basicAck和basicNack的配合。消费成功后手动确认,消息才会从队列移除;处理异常时basicNack的第三个参数是requeue,设置为 true 表示重新放回队列。比较典型的坑是:异常消息如果不做次数限制,会无限循环消费,把日志打爆。我的习惯是在消息里带一个retryCount字段,消费时先判断重试次数,超过三次就把消息转入死信队列。
RabbitMQ 的交换器和队列绑定关系建议在配置类里通过 Java Config 声明,而不是在管理后台手动创建。这样每次部署新环境时队列会自动创建,减少“测试环境一切正常,生产环境队列缺失”的尴尬。
4.3 超时未支付关单:分布式定时任务里的锁
订单创建后通常给十五分钟支付时间,超过时间要自动关单并释放库存。单机环境下一个@Scheduled定时任务就能解决,但微服务架构下订单服务有多个实例,定时任务会在每个实例上同时执行,必须保证同一时刻只有一个实例在跑关单逻辑。
常见做法是给定时任务加一把 Redis 分布式锁。进入任务时先尝试加锁,抢到锁的实例才继续执行,其他实例直接跳过本次调度。
@Scheduled(cron = "0 */2 * * * *") public void closeExpiredOrders() { String lockKey = "task:lock:closeOrder"; boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(90)); if (!locked) { return; } try { List<String> orderIds = orderMapper.findExpiredOrders(); orderIds.forEach(orderId -> { closeOrder(orderId); releaseStock(orderId); }); } finally { redisTemplate.delete(lockKey); } }scheduledLock的过期时间要略大于整个任务预计执行时间的上限。设置太短会出现锁提前失效,多个实例同时执行;设置太长会影响后续任务的调度。90 秒是我验证下来比较合理的中间值。这里只能给经验值,实际要结合订单表的扫描 SQL 耗时来微调。另外注意,关单后释放库存和扣减库存一样涉及 Redis,释放操作也要带上超时时间等防御参数,避免 Redis 操作失败导致库存永久丢失。
4.4 本地消息表做补偿:答辩时怎么把这个讲透
真正容易翻车的地方不在设计的优雅程度,而在消息是否可靠。如果订单服务在发送消息到 RabbitMQ 之前宕机,库存已经扣了,但订单服务永远不知道要创建订单。这时需要一套补偿机制来兜底。
我采用的补偿方案是本地消息表。订单服务和库存扣减在同一套业务逻辑中先记录一条本地消息记录,状态为“待发送”,再去发送 MQ。RabbitMQ 发送完成后把状态改为“已发送”。另起一个定时任务,扫描所有超过三十秒仍未变为“已发送”的消息,重新发送。
这其实是用数据库做了一次持久化日志,再配合定时任务做最终一致。和 RocketMQ 的事务消息相比,它多了一步手动轮询,但好处是不依赖特定消息中间件的特性。论文答辩时如果被问“为什么不用 RocketMQ 事务消息”,可以回答:本地消息表机制对中间件不敏感,讲解成本低,且便于在代码中直接看到补偿逻辑,属于可演示、可测试的务实方案。
5. 抢票系统避坑手册:五条血泪经验与排查路径
5.1 锁提前释放导致超卖
现象:压测线程数调到两百后,明明用了 Redis 分布式锁,Redis 里库存还是出现了负数。原因:自己写的加锁代码用的是SETNX+EXPIRE两步,过期时间固定为 10 秒,但某些请求在执行业务逻辑时花了超过 10 秒,锁先失效,另一个请求乘虚而入。解决:要么使用 Redisson 的看门狗自动续期,要么把过期时间设到足够大,同时在加锁 value 里写入请求唯一标识(如 UUID),释放锁时用 Lua 脚本比较 value 再删除,防止误删别人持有的锁。
5.2 消息重复消费导致重复下单
现象:RabbitMQ 消费端偶尔出现同一个用户创建两条订单,数据库订单表里出现重复记录。原因:发送端开启了发布确认,但消费端在处理完成之前宕机,消息被重新投递;或者消费者处理超时触发重试。解决:订单表加唯一约束,用(user_id, event_id, tier_id)作为唯一索引,消费端捕获DuplicateKeyException时直接确认消息,不走补单逻辑。这个坑在答辩时很值得写进“可靠性设计”一节。
5.3 Nacos 配置不生效
现象:改了bootstrap.yml里的配置,重启服务后没有任何变化,服务注册名还是老名字。原因:Nacos 客户端默认从application.properties读取配置,而 SpringCloud 2021 之后必须使用bootstrap.properties且配置spring-cloud-starter-bootstrap依赖,否则 bootstrap 配置不会加载。解决:项目中显式引入spring-cloud-starter-bootstrap,并确保 Nacos 的命名空间、group 与配置中心一致。
5.4 压测时数据库连接池被打满
现象:压测到 500 并发时,库存 Redis 毫无压力,但数据库连接池报connection pool exhausted,订单数据写入失败。原因:流量虽然被 Redis 分流了,但订单创建最终还是落在数据库上。数据库连接池大小、数据库实例的最大连接数、还有 MyBatis 的批量插入配置都会成为瓶颈。解决:用 HikariCP 时把maximum-pool-size设置为 50,数据库侧调大max_connections,订单写入改用批量插入,并且可以考虑在数据库前加一层本地缓存去重,过滤同一用户的重复请求。
5.5 网关超时阈值不当引发重试风暴
现象:下游服务接口偶发抖动,网关层直接报 504,但调用方看到 504 后立即重试,结果把原本已经恢复的服务又压垮了。原因:网关默认的连接和响应超时设置都比较短,配合调用方无节制的重试形成恶性循环。解决:网关超时时间分开配置,连接超时设短、读取超时设长;调用方增加重试次数上限;下游服务接口保持幂等,避免重试带来的副作用。
6. 怎么证明它能扛住抢票:压测脚本、监控指标与答辩话术
到了这一步,项目已经不只是“能跑”,而是可以“拿出来验证”。最常见的验证工具是 JMeter,毕设答辩时不需要花哨的监控大盘,三组数据足够说明问题。
第一组数据是基础能力:模拟 100 个并发用户抢 3000 张票,观察订单创建成功率、平均响应时间、TPS 三项指标。第二组数据是峰值冲击:把并发调到 500,保持五分钟,观察 Redis 库存是否出现超卖、数据库是否出现死锁。第三组数据是恢复能力:压测过程中手动停掉一个订单服务实例,观察请求是否自动转发到剩余实例,以及已经产生的订单是否依然能正常完成支付超时关单流程。
JMeter 线程组配置我一般这样设:线程数 500,Ramp-Up Period 设为 5 秒,循环次数 20。Ramp-Up 时间不要太短,否则请求会在同一瞬间全部涌出,压测结果会体现为网关 429 而不是系统真实处理能力。断言部分要同时检查 HTTP 状态码和响应体里的业务码,避免出现 HTTP 200 但业务失败的情况。
演示时把这三组数字整理成表格,配合两张图:一张是 Redis 库存变化曲线,一张是 MySQL 订单数量变化曲线。答辩老师通常会追问两件事:怎么保证不超卖,以及 Redis 宕机了怎么办。前者可以回答“扣减脚本是原子的,在 Redis 单线程模型下不存在并发覆盖”,后者要承认这是缓存方案的通病,并补充“Redis 做了 RDB 持久化,重启后可以恢复库存快照;极端情况下会启动数据库对账任务,以数据库订单明细为准反向校准库存”。到这里整个系统的雪球才真正滚完:有架构设计、有核心代码、有故障兜底、有量化验证,也祝你把这套思路跑通,希望帮到你。
本文还有配套的精品资源,点击获取