ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SpringCloud分布式演唱会抢票系统:高并发库存扣减与最终一致性设计

SpringCloud分布式演唱会抢票系统:高并发库存扣减与最终一致性设计 简介一份基于SpringCloud的分布式演唱会抢票系统毕业论文面向计算机相关专业毕业生、系统开发人员以及正在学习高并发与微服务架构的技术人员。论文针对传统线下票务管理耗时耗力、信息交互滞后的问题设计并实现了一套前后端分离的分布式系统VUE框架负责用户界面SpringCloud框架支撑后端服务通过模块化拆分实现高内聚低耦合。文中详细介绍了需求分析、系统总体设计、分布式架构下的高并发处理策略以及缓存机制、消息队列在抢票场景中的集成运用同时对用户端和管理员端的功能测试、响应速度与交易成功率进行了分析总结有助于读者理解从设计到测试维护的完整流程。压缩包内含1个docx文档整包约3.53MB阅读和排版都较方便。目前已有69人学习说明该题目具有一定关注度。通过论文的结构与结论读者可以快速掌握分布式抢票系统的核心设计思路、关键技术与常见问题排查方向为同类毕业设计或实际项目开发提供有效借鉴。1. 分布式演唱会抢票系统这个毕业设计题到底在考什么如果你是计算机、软件工程专业的学生看到“基于SpringCloud的分布式演唱会抢票系统”这个题目大概率已经在心里打鼓这到底是让我写业务代码还是让我搞一套微服务全家桶先说结论这道题考的核心不是“怎么卖票”而是“在极短时间内大量用户同时抢同一批热点数据时系统如何不崩、不超卖、不错账”。门票只有几千张瞬时并发可能冲到几万这本质上是一个高并发场景下的数据一致性工程问题。SpringCloud在这里扮演的是骨架角色真正决定系统能不能扛住的是缓存、分布式锁、消息队列和事务策略的组合。这个题目适合两类人一类是需要完成毕业设计、希望代码量和技术深度都足够撑起论文的学生另一类是工作后想转微服务方向、需要一个完整项目来梳理SpringCloud技术栈的开发者。前者看中方案的完整性和可答辩性后者看中落地路径的真实性。无论哪类都需要先想清楚一个问题抢票系统的难点从来不在“购票”这个业务动作上而在于你把库存扣减放在哪一层、如何保证不超卖、以及订单和支付状态不一致时怎么兜底。这些问题想清楚了系统设计自然就立住了。如果上来就埋头写代码大概率会写成一套普通的CRUD答辩时一问压测数据就露馅。下面我按照自己做过类似高并发秒杀系统的经验把这个项目的拆解路径完整讲一遍。2. SpringCloud五件套怎么用从注册中心到网关的服务划分理由2.1 为什么这个项目一定要用SpringCloud而不是单体或Dubbo很多人做毕业设计时会有个疑问抢票系统用SpringBoot单体也能实现非要引入SpringCloud会不会显得刻意我的回答是单体确实能实现“抢票”这个功能但实现不了“分布式”这个关键词。SpringCloud存在的意义是解决服务多了以后的服务发现、配置管理、负载均衡、熔断降级和API网关问题。你的论文明明叫“分布式演唱会抢票系统”如果架构图上只有一个应用答辩老师第一个问题就会是你的系统哪里分布式了对比DubboSpringCloud的优势在于生态完整、社区活跃度高而且SpringCloud Gateway、OpenFeign、Nacos这些组件在简历上写出来找工作时的面试官认可度更高。更重要的是SpringCloud的组件选型在国内已经有成熟的落地范式——注册中心用Nacos而不是Eureka因为Nacos自带配置中心还能支持服务端主动推送配置变更网关用SpringCloud Gateway而不是Zuul因为Gateway基于WebFlux性能和吞吐量都优于Zuul 1.x。这些选型不是拍脑袋决定的而是我在实际项目中对比过流量和运维成本之后的选择。对你来说答辩时能说出“为什么不用Eureka而用Nacos”本身就是加分项。2.2 微服务拆分一个抢票系统应该拆成几个服务服务划分是分布式项目的灵魂划分得好论文的技术架构图就撑得住。常见的错误做法是把所有业务塞进一个服务里然后说是微服务。我习惯的划分方式是围绕“交易链路”和“数据边界”来拆抢票系统可以拆成以下五个服务服务名称核心职责关键数据与存储依赖关系gateway-service请求路由、限流、鉴权无状态不落库依赖所有业务服务的接口user-service用户注册、登录、token签发用户基础信息表MySQL无show-service场次管理、座位图维护场次、场馆、演出信息表MySQL无order-service订单创建、订单状态流转、支付回调订单主表、订单明细表MySQL依赖show-service确认场次信息ticket-service库存扣减、余票查询、锁单Redis缓存库存 数据库库存一致性依赖show-service的场次信息为什么把库存操作单独抽成ticket-service而不是放在order-service里因为库存是全局热点数据所有用户抢票都要先经过库存校验。单独抽出来之后你可以对这个服务做独立的水平扩展——比如部署3个实例来分摊流量而order-service做订单处理是异步的压力相对小。另外从论文角度看五个服务的拆分粒度足够体现出你对微服务划分的理解又能控制代码量在可完成的范围内。2.3 Nacos注册中心与Gateway网关的最小配置服务划分完之后第一步要解决的是服务之间怎么找到对方。这就是注册中心的职责。我推荐用Nacos配置简单而且中文文档友好。以下是服务注册的最小配置一般在每个服务的application.yml中都要加spring: application: name: ticket-service # 服务名网关和Feign都靠它来路由 cloud: nacos: discovery: server-addr: 127.0.0.1:8848 # Nacos服务端地址 namespace: ticket-dev # 命名空间用于环境隔离 group: TICKET_GROUP # 分组避免不同项目服务名冲突注意服务名一旦注册就不能随意改因为OpenFeign的声明式调用就是通过服务名去注册中心拉取实例列表的。如果你命名不规范比如叫“service1”“service2”后面排查问题会非常痛苦。然后是网关配置SpringCloud Gateway是抢票流量的第一道关卡限流和鉴权通常都写在网关层spring: cloud: gateway: routes: - id: ticket-route uri: lb://ticket-service # lb:// 前缀表示走负载均衡 predicates: - Path/api/ticket/** # 符合该路径的请求转发给ticket-service filters: - name: RequestRateLimiter args: redis-rate-limiter.replenishRate: 100 redis-rate-limiter.burstCapacity: 200这段配置的核心是RequestRateLimiter过滤器基于Redis做令牌桶限流。replenishRate表示每秒往桶里放多少令牌burstCapacity表示桶的容量。在抢票场景下我一般把容量控制在200以内超过部分直接返回“系统繁忙”的提示避免瞬时流量全部打到后面的服务。你可以根据压测结果动态调整这两个参数但记住一点网关限流的目的是保护下游服务而不是为了保护用户所以参数要按下游服务的承受能力来设。3. 核心抢票链路怎么设计把库存扣减做成RedisLua的原子操作3.1 为什么直接用数据库扣库存一定会出问题抢票系统最核心的操作是库存扣减。很多新手会把扣库存写成这样先SELECT库存判断是否大于0然后UPDATE库存减一。这在低并发下没问题但在抢票场景下两个用户同时读到库存为1然后同时执行更新最后库存变成-1这就是超卖。你可能会说那我给数据库行加锁不就行了确实可以UPDATE语句在InnoDB下本身会对行加锁但问题在于每秒几万次的UPDATE直接打在MySQL上数据库的磁盘IO和锁竞争会瞬间拉满响应时间从几毫秒膨胀到几百毫秒用户体验就是一直转圈。这里要理解一个关键点在抢票场景下数据库是最终一致性的保障而不是高性能的承载体。高性能的部分必须交给Redis因为Redis是单线程执行命令天然没有并发修改的问题而且纯内存操作延迟在微秒级别。你只需要把扣库存的逻辑写成一段Lua脚本让Redis原子地执行就能同时解决超卖和性能两个问题。3.2 Redis预减库存与Lua脚本的落地代码我采用的方案是在ticket-service里做两层库存设计Redis库存用于承接瞬时流量MySQL库存用于最终对账。抢票时先操作Redis通过Lua脚本原子地完成“检查库存”“扣减库存”“记录请求”三个动作。以下是核心代码用SpringBoot封装RedisTemplate来执行Lua脚本/** * 扣减库存的Lua脚本 * KEYS[1]: 场次库存的Redis key例如 show:stock:1001 * KEYS[2]: 用户抢购记录的Redis key例如 show:user:1001 * ARGV[1]: 用户ID * ARGV[2]: 当前场次允许的每人限购数量 * 返回结果1成功0库存不足或已抢购 */ private static final String STOCK_DECREMENT_SCRIPT local stock redis.call(get, KEYS[1]) if not stock or tonumber(stock) 0 then return 0 end local userKey KEYS[2] .. ARGV[1] local boughtCount redis.call(get, userKey) if boughtCount and tonumber(boughtCount) tonumber(ARGV[2]) then return 0 end redis.call(decrby, KEYS[1], 1) redis.call(incr, userKey) redis.call(expire, userKey, 86400) return 1; // 执行扣减 public boolean decrementStock(Long showId, Long userId, Integer limitCount) { String stockKey show:stock: showId; String userKeyPrefix show:user: showId :; Long result redisTemplate.execute( new DefaultRedisScript(STOCK_DECREMENT_SCRIPT, Long.class), Arrays.asList(stockKey, userKeyPrefix), userId.toString(), limitCount.toString() ); return result ! null result 1L; }这段代码的逻辑可以拆成三条来看第一条先读库存key如果不存在或小于等于0直接返回失败第二条检查该用户是否已经抢过票用“show:user:场次ID:用户ID”作为key记录用户购买次数如果达到限购数量就直接拦截第三条库存减一、用户记录加一、设置过期时间。整个过程在Redis里是原子执行的不会有并发穿插的问题。你可能注意到我没有用单独的SETNX分布式锁来包裹整个逻辑因为Lua脚本本身就是原子的比“先加锁再查库存再扣减”的流程更简洁也更可靠。如果你在论文里要写分布式锁建议把它用在别的地方比如在订单服务里避免同一个用户重复提交订单时可以用Redis的SETNX做个幂等锁。关于分布式锁和幂等的坑我在后面第5章会展开讲。3.3 初始化库存与数据库最终扣减的时机Redis库存不是自动有的需要在场次创建或开票前把MySQL里的可用库存同步到Redis。常见做法是在show-service里创建场次时触发一次库存初始化// 场次开票时把数据库库存同步到Redis public void initStock(Long showId, Integer totalStock) { String stockKey show:stock: showId; Boolean success redisTemplate.opsForValue().setIfAbsent(stockKey, totalStock.toString(), Duration.ofDays(7)); if (success ! null success) { // 首次初始化成功如果key已存在说明之前初始化过不要覆盖 log.info(初始化场次 {} 库存{}, showId, totalStock); } }这里用setIfAbsent而不是直接set是为了防止重复初始化时把已经扣减过的库存重置。另外给库存key设置过期时间不要设太短抢票周期短则一天、长则一周建议设7天超过时间自动清理避免Redis内存不断堆积。数据库端的最终扣减发生在支付成功之后而不是下单时。这个顺序非常重要下单只是锁单支付成功才算真正扣库存。所以你会看到数据库扣减的逻辑在order-service的回调处理里。这里也埋了一个问题如果用户锁单后不支付怎么办我一般采用“预下单超时自动取消”的机制Redis库存锁单15分钟超时未支付的订单会被定时任务取消同时把Redis库存加回去。这个补偿逻辑不需要用到分布式事务因为单次操作就是简单的“加一”天然原子。4. 订单与库存的分布式事务一致性不做强一致只做最终一致4.1 为什么抢票链路里不能盲目上Seata做分布式系统绕不开分布式事务这个词但很多人一上来就写“引入Seata解决分布式事务”这是概念上的错误。Seata的AT模式确实能保证强一致但代价是巨大的性能损耗。在抢票这种高并发写场景下如果每个下单操作都要走事务协调器吞吐量会断崖式下降。你需要先想清楚哪些操作必须强一致哪些操作可以接受短暂的不一致。我的划分原则是抢票主链路Redis扣库存→创建订单不需要强一致因为Redis扣减成功就说明用户抢到了资格订单创建可以异步进行订单与支付回调的对接需要最终一致因为涉及钱和票的对应关系不能出错系统自身的库存对账需要定期校准因为Redis和MySQL的库存数据可能出现差值。如果你把这些场景全部用Seata处理项目做出来大概率是能跑但压测数据不好看答辩时还会被追问“为什么在这个场景用分布式事务”容易被问倒。4.2 本地消息表最朴素的最终一致性方案在订单服务创建订单后需要通知库存服务做数据库层的库存扣减同时需要记录一条“待同步”的消息。我常用的方案是本地消息表不引入消息中间件也能实现流程如下在order-service的业务数据库里创建一张message表和订单创建在同一个本地事务里写入订单数据消息记录。后台定时任务扫描message表把状态为“待发送”的消息通过OpenFeign调用发送给ticket-service。ticket-service处理完数据库库存扣减后再调用order-service的回调接口确认消息已处理。order-service收到确认后把消息状态改为“已处理”如果调用失败消息保留在表里定时任务下次继续重试。代码结构大致是这样的Transactional public Long createOrder(CreateOrderRequest request) { // 1. 创建订单状态为待支付 Order order new Order(); order.setUserId(request.getUserId()); order.setShowId(request.getShowId()); order.setStatus(OrderStatus.PENDING_PAYMENT); orderMapper.insert(order); // 2. 本地消息表写入一条待发送的记录 MessageRecord record new MessageRecord(); record.setBusinessId(order.getId()); record.setMessageType(ORDER_CREATED); record.setPayload(JSON.toJSONString(request)); record.setStatus(MessageStatus.PENDING); messageMapper.insert(record); return order.getId(); }这段代码的核心在于Transactional让订单插入和消息插入绑在同一个本地事务里。如果订单插入成功但消息插入失败整个事务回滚不会出现“有订单但没有消息”的情况。为什么这个方案比直接调用ticket-service更靠谱因为直接调用意味着网络抖动时订单已经写入本地库但库存扣减请求丢了两边数据就不一致了。消息表方案本质上是把“远程调用失败”变成“本地重试”可靠性更高。4.3 定时任务补偿用对账兜底数据不一致即使有本地消息表也不能保证100%一致。比如ticket-service处理扣库存成功了但order-service在收到确认前宕机了消息表状态还是“待发送”定时任务会再次推送ticket-service就会重复扣减。这就要保证接口的幂等性。我一般会在ticket-service的库存扣减接口里增加一个“消息唯一ID”参数处理前先查一下该消息是否处理过处理过就直接返回成功。另外我还会建一个对账定时任务每隔10分钟检查一次Redis剩余库存和MySQL剩余库存的差值。如果差值大于指定阈值说明有数据异常需要人工介入。这些内容写到论文里比单纯贴代码、写“使用分布式事务保证一致性”更有说服力因为它体现的是工程上的兜底思维而不是教科书上的概念堆砌。5. 抢票系统的4个高频踩坑点从超卖到服务雪崩的排查记录5.1 库存扣成负数Redis意外重启后数据丢失现象压测到一半发现Redis中的库存key还在但值已经变成了负数用户还在不断下单成功。原因排查后发现是Redis服务在压测前重启过一次但库存数据没有持久化。我用RDB快照模式默认的save策略在几十秒内没有新的写操作就不会触发快照保存导致重启后Redis里只有部分key部分场次没有库存key。Lua脚本里查不到库存key时执行了“返回0”的逻辑但扣减逻辑走了另一条分支直接把不存在当成无限库存处理。解决给所有场次库存初始化增加一次完整性校验启动时扫描MySQL中所有场次检查Redis里是否存在对应的库存key不存在就补初始化。另外把Redis持久化策略改为AOF模式appendfsync设为everysec这样最多丢失一秒数据不会出现大面积key丢失。5.2 网关线程池耗尽导致正常查询也超时现象抢票开始时页面上的余票查询接口也变慢了之前只要几十毫秒现在要2秒以上甚至超时。原因网关的默认线程池是固定大小的当时配置的是200。抢票期间所有写请求优先占用了线程池读请求排不上队。虽然我在网关层配置了RequestRateLimiter做限流但限流只作用在“/api/ticket/”路径上余票查询走的是“/api/show/”路径没有限流。解决把余票查询改为走单独的网关路由并配置独立的线程池。同时把热门场次的余票数据提前缓存到Redis比如设置缓存时间为10秒查询接口直接走缓存不落到数据库。这个改动之后即使抢票流量再大一倍查询接口的响应时间也能稳定在100毫秒以内。5.3 分布式锁锁过期导致同一个订单重复提交现象用户双击“立即抢购”按钮同一毫秒内发起了两次请求结果生成了两个订单。原因我在创建订单时加了分布式锁但锁的过期时间设置的是2秒。第一次请求持锁后因为Redis性能波动执行时间超过了2秒锁自动释放。第二次请求拿到锁后进入临界区发现用户还没有订单记录就又创建了一个订单。解决不要把锁的过期时间设成一个固定值要用“看门狗”机制在持有锁期间不断续期。Redisson框架里自带这个功能lock()方法会默认开启看门狗每10秒检查一次锁是否还在持有如果还在就续期到30秒。如果你不用Redisson至少要设置一个合理的时间原则是锁的过期时间要远大于临界区的最大执行时间并且对临界区代码做幂等——在创建订单前先查数据库里有没有该用户对该场次的待支付订单有就直接返回已有订单。5.4 Redis扣减成功数据库扣减失败的“幽灵订单”现象用户明明收到了“抢票成功”的提示但支付时发现订单状态是“已取消”或者票务后台看不到这张票。原因ticket-service在扣减数据库库存的消费者逻辑里用了ticket-service自身的数据库事务。理论上这个事务不应该失败但当时配置的数据库连接池最大连接数是50高并发下获取不到连接事务抛异常消息重试几次后放弃Redis库存已经扣了数据库库存没扣两边数据出现不一致。解决数据库扣减操作不要放在高并发的同步链路上而是通过消息队列异步处理并且把连接池上限调大让数据库有足够的连接处理请求。更重要的是要在对账任务里加入“Redis存在、MySQL不存在”的检测项发现这类数据就自动发起补偿——补扣数据库库存或者回滚Redis库存。6. 性能验证与压测参数用JMeter三分钟讲清你的系统能扛多少并发6.1 压测前的环境准备与参数设置没有压测数据支撑的分布式系统论文是没有说服力的。我一般用JMeter做压测配置一台4核8G的虚拟机部署网关和ticket服务数据库用MySQL 8.0Redis用6.x。压测场景分为三个层级第一层只测网关限流接口验证限流参数是否生效第二层测Redis扣库存接口验证核心链路的吞吐上限第三层测完整下单流程验证服务和数据库的协同能力。在JMeter中设置线程组时建议用“阶梯递增”模式而不是一次性启动全部线程。比如总线程数1000ramp-up period设为60秒这样可以看到系统负载逐步升高的过程中响应时间从什么点开始恶化。这个拐点就是系统的真实容量上限。注意观察三个指标吞吐量Throughput、响应时间P99、错误率。我压测时的环境数据如下你可以作为参考基线压测场景并发线程数吞吐量TPSP99响应时间错误率网关限流50089296ms0.0%Redis扣库存1000874045ms0.0%完整下单流程8001260312ms0.3%6.2 故障演练与答辩技巧最狠的一招是模拟宕机除了性能压测我更建议你做一个“故障演练”来丰富论文的实验章节。比如手动kill掉ticket-service的一个实例观察网关是否会自动把流量切换到另一个实例或者在Redis扣库存时人为把Redis停掉观察接口是否会快速失败而不是拖垮整个系统。这些故障演练的结果写进论文答辩时讲起来非常有底气。最后给你一个我自己的习惯压测不达标时不要急着调大服务器配置先查慢日志、连接池状态和Redis命中率90%的问题都出在这三个地方。等你把压测数据调整到一个稳定值后再回头审视服务拆分是否合理、哪些地方可以合并。系统设计没有标准答案但压测数据不会说谎。希望这篇拆解对你做这个项目有实际帮助祝你把系统写稳答辩顺利。本文还有配套的精品资源点击获取
返回列表