ARTICLE DETAIL

资讯详情

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

SpringCloud分布式抢票系统:高并发秒杀架构设计与实践

SpringCloud分布式抢票系统:高并发秒杀架构设计与实践 简介一份基于SpringCloud的分布式演唱会抢票系统毕业论文面向计算机相关专业毕业生与Java后端开发者针对传统线下票务管理耗时费力、难以支撑抢票高并发等痛点给出了完整的系统设计与实现方案。文中以Java为开发语言采用VUE做前台、SpringCloud做后台服务通过前后端分离架构实现高内聚低耦合同时引入分布式部署、缓存机制与消息队列有效缓解短时大量用户同时抢票的压力并对高并发场景下的响应速度、交易成功率及操作流畅性进行了功能测试分析了系统中存在的缺陷与改进方向。资源包内为1个docx文件压缩包大小约3.53MB全文涵盖选题背景、需求分析、系统设计、功能测试及结果分析等章节并附有摘要、目录与关键词结构完整、条理清晰。目前已有69人学习下载适合正在筹备毕业设计、需要参考分布式系统论文写法或希望了解高并发抢票场景技术选型的读者可帮助快速把握论文框架与核心实现思路。1. 把抢票系统做成 SpringCloud 分布式这份资源能让你少走三个月弯路演唱会抢票这件事本质是「同一瞬间几万人盯着同一张票池」的高并发场景。你会发现单体架构在这个场景下特别尴尬数据库行锁一上每秒处理量上不去加机器只能把应用做集群但数据库还是单点瓶颈。这套基于 SpringCloud 的分布式演唱会抢票系统用微服务把用户、库存、订单、支付拆开再配合 Redis 预减库存、分布式锁、MQ 异步削峰来完成整条抢票链路是典型的电商秒杀玩法。它解决的问题非常明确高并发下不超卖、不重复下单、订单与库存最终一致。适合两类人——准备做毕业设计的学生以及想通过一个完整案例把 SpringCloud、Redis、MQ 串起来跑一遍的 Java 从业者。接下来我从架构拆解、核心代码、踩坑记录三个维度把它讲透。2. 服务划分与数据建模订单、库存、支付模块怎么拆才不打架2.1 为什么抢票系统必须要微服务而不是单体应用很多人的第一反应是抢票系统业务量没那么复杂单体不行吗如果在低并发下单体确实能把事情做完。但抢票场景有一个特点流量瞬间到达峰值峰值过后快速归零。单体应用遇到这种流量要么所有请求都挤在那台机器上排队要么你横向复制整个应用但应用里每个模块都跟着复制一遍。微服务的价值在于「按压力拆服务」。抢票链路里压力最大的是库存服务订单服务和支付服务相比之下要轻得多。我把库存服务单独拆出来给它独立的三节点部署订单服务只保留两个节点支付服务甚至一个节点就能扛住。这样扩容成本跟着压力走而不是无脑复制整个应用。SpringCloud 在这里提供的核心能力是服务注册与发现、网关路由、声明式调用配合熔断器把故障限制在局部——库存服务挂了不会把订单服务拖死。2.2 服务清单与调用链路说明这套系统里共拆了五个核心服务每个服务职责单一配合关系和调用方向如下服务名核心职责关键组件gateway-service统一入口、鉴权、限流SpringCloud Gateway Redis 令牌桶user-service用户登录、鉴权、用户信息JWT Spring Securityconcert-service场次查询、票档管理MyBatis-Plusorder-service下单、订单状态流转RabbitMQ 生产端 订单状态表inventory-service库存预扣、库存回补Redis Lua 脚本 分布式锁调用链路是客户端 → gateway → concert/order/inventory。查询场次时请求打向 concert-service下单时 order-service 通过 Feign 调用 inventory-service 预扣库存扣减成功后才把订单消息投递到 MQ 做异步落库最终由独立的消费者完成数据库写入。提示Feign 调用这里的超时时间要单独调默认的 1 秒在抢票高峰期很容易触发 read timeout 熔断。我一般把库存服务的 Feign 超时调到 3 秒配合 Hystrix 的线程池隔离避免大面积雪崩。2.3 核心数据表设计与 SQL 参考数据模型是整个系统能稳定运行的基石。设计上有一个核心原则库存表用乐观锁控制并发订单表用状态机管理生命周期。库存表参考 SQL 如下这里注意 version 字段不是摆设它是防超卖的第一道关卡。CREATE TABLE ticket_stock ( id bigint(20) NOT NULL AUTO_INCREMENT, concert_id bigint(20) NOT NULL COMMENT 演唱会ID, ticket_type varchar(20) NOT NULL COMMENT 票档VIP/看台/内场, total_stock int(11) NOT NULL COMMENT 总库存, remain_stock int(11) NOT NULL COMMENT 剩余库存, version int(11) NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, update_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_concert_type (concert_id,ticket_type) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这段 SQL 的关键设计是唯一索引 uk_concert_type确保同一场次下同一票档只有一条库存记录并发扣减时不会出现两条 update 相互覆盖的情况。version 字段配合后面的 UPDATE 语句利用数据库的行锁语义来保证原子性。订单表在扣减库存后写入状态字段记录订单从「已创建」到「已支付」再到「已完成」的流转路径。支付超时的订单进入「已取消」状态同时触发库存回补逻辑这部分在第四章展开。注意库存表不要和订单表放在同一个库。抢票高频操作集中在 inventory-service独立数据库可以把锁竞争范围缩小到库存这一个库避免订单库的写入拖累库存扣减性能。3. 库存预减与分布式锁防超卖的核心代码与参数调优3.1 为什么不能依赖数据库 UPDATE 来扣库存很多初学者第一版是这么写的用户点抢票时直接UPDATE ticket_stock SET remain_stock remain_stock - 1 WHERE remain_stock 0。这个写法在并发量低时没什么问题但抢票场景下所有请求会集中在同一行记录上InnoDB 的行锁会让请求排队等待数据库连接池瞬间耗尽后面进来的请求全部超时。就算你优化成乐观锁UPDATE ticket_stock SET remain_stock remain_stock - 1, version version 1 WHERE remain_stock 0 AND version #{version}一旦更新失败应用层还要重新查询、重试多一次数据库往返。每秒几千次重试数据库依然扛不住。所以生产上先做一层 Redis 预减。库存总量在活动开始前加载到 Redis用户抢票时先在 Redis 里扣减真正落库的只有抢到票的那一小部分请求。这是典型的「流量挡在上游压力不进 DB」。3.2 Redis 预减库存的 Lua 脚本实现Redis 预减必须保证原子性不能先查后减否则并发下会超卖。Redis 的 Lua 脚本是原子执行的我用一个脚本完成「校验余票 → 扣减 → 返回剩余值」三步操作。local key KEYS[1] local field ARGV[1] local stock tonumber(redis.call(hget, key, field)) local decrement tonumber(ARGV[2]) if not stock or stock decrement then return -1 end redis.call(hincrby, key, field, -decrement) return redis.call(hget, key, field)这个脚本的核心逻辑是把检查库存和扣减库存放在同一个原子操作里Redis 单线程执行 Lua 脚本期间不会有其他命令插入所以不会出现两个请求同时读到剩余库存为 1 然后都扣减成功的情况。KEYS[1] 是库存的 Hash 键名field 是票档类型我把一个场次的多个票档放在同一个 Hash 里减少 key 数量。提示脚本里 decrement 参数要固定传 1因为抢票场景一次只能抢一张。如果你后续要做连座购买一次买多张把 decrement 改成用户选择的票数即可脚本不用改。Java 侧调用 Lua 脚本的代码对应如下。public boolean preDeductStock(String concertId, String ticketType, int count) { String key stock:concert: concertId; SubmitScript; Long result jedis.eval( STOCK_DEDUCT_SCRIPT, Collections.singletonList(key), Arrays.asList(ticketType, String.valueOf(count)) ); return result ! null result 0; }这里 eval 方法的第一个参数是上面那段 Lua 脚本内容第二个参数是 KEYS 数组第三个参数是 ARGV 数组。返回值是剩余库存如果返回 -1 说明库存不足直接返回给前端「手速慢了票已抢光」。这块要注意Lua 脚本要写成静态常量复用不要每次调用都重新拼接脚本字符串否则 Redis 端会消耗额外的内存来做脚本缓存。3.3 分布式锁防止串号下单和并发写库Redis 预减只是解决了库存的数量问题但还有一个恶意场景要防同一用户疯狂点击抢票按钮瞬间发几十个请求每个请求都走到 Redis 扣减抢到的票全落在一个用户名下。业务上要限制一个订单只能包含一张票就得在下单入口加分布式锁锁的粒度是「用户 场次」。分布式锁我用 Redisson 实现配置和加锁代码如下。Autowired private RedissonClient redissonClient; public boolean tryLock(String userId, String concertId) { String lockKey lock:order: userId : concertId; RLock lock redissonClient.getLock(lockKey); try { // waitTime0 拿不到锁立即返回抢票场景不适合排队等待 // leaseTime-1 启用看门狗自动续期 return lock.tryLock(0, -1, TimeUnit.SECONDS); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; } }tryLock 三个参数值得单独解释。waitTime 设为 0 是因为抢票场景必须快速失败——拿不到锁就让用户重试或提示排队不能让他傻等。leaseTime 设为 -1 代表启用 Redisson 的看门狗机制锁默认 30 秒过期但看门狗会在锁持有期间每 10 秒自动续期一次业务还没执行完锁就不会释放。这样做的好处是不用手动评估业务执行时间坏处是如果业务代码里又手动调用了 unlock看门狗会被取消逻辑上要小心。3.4 锁和库存预减的执行顺序问题这里有一个顺序陷阱。实际操作时我会先执行 Redis 预减再尝试获取分布式锁。理由是预减库存是纯粹的 Redis 操作性能极快一万个并发请求瞬间就能扣完超卖的请求在预减阶段就被挡掉了。锁是保护后续的写库流程只有抢到库存的请求才需要加锁这样可以减少锁的争抢范围。反过来先加锁再预减锁会先挡住所有请求预减 Redis 的高性能优势就发挥不出来了。4. 异步下单与订单号生成MQ 削峰、雪花 ID 与事务边界4.1 为什么下单流程要引入 MQ 而不是同步写库抢票请求经过 Redis 预减和分布式锁后如果直接同步调用订单服务写数据库订单库的写入压力依然会被瞬间击穿。原因很简单数据库每秒能处理的写入事务就几千条但抢票峰值请求每秒能到达几十万不是每个都抢到票但每个都要查一次库存。所以我把下单流程改成了异步请求到达 order-service 后校验通过就把订单消息投递到 RabbitMQ接口立即返回「抢票成功订单支付中」。真正落库的动作由消费者异步执行削峰能力非常明显。提示如果你用的是 RocketMQ它的事务消息可以做到本地事务和消息发送同生共死天然适合下单场景。但毕业设计和中小项目用 RabbitMQ 足够注意使用 confirm 回调机制保证消息不丢。4.2 订单号生成雪花算法参数调整订单号用 Snowflake 雪花算法生成。标准雪花算法的结构是 1 位符号位 41 位时间戳 10 位机器 ID 12 位序列号这套系统里我把机器 ID 拆成了「机房 ID 服务实例 ID」各占 5 位避免多实例部署时集群数量超过 1024 台导致 ID 重复。public class SnowflakeIdGenerator { private final long workerId; private final long datacenterId; private long sequence 0L; private long lastTimestamp -1L; public synchronized long nextId() { long timestamp System.currentTimeMillis(); if (timestamp lastTimestamp) { throw new RuntimeException(时钟回拨); } if (timestamp lastTimestamp) { sequence (sequence 1) 4095; if (sequence 0) { timestamp tilNextMillis(lastTimestamp); } } else { sequence 0L; } lastTimestamp timestamp; return ((timestamp - 1288834974657L) 22) | (datacenterId 17) | (workerId 12) | sequence; } private long tilNextMillis(long lastTimestamp) { long timestamp System.currentTimeMillis(); while (timestamp lastTimestamp) { timestamp System.currentTimeMillis(); } return timestamp; } }这段代码里注意 timestamp lastTimestamp 这个判断。标准写法是 timestamp lastTimestamp 就抛异常但实际运行时系统时钟经常出现毫秒级的轻微回拨我改成循环等待当前时钟追平上次时间戳小范围回拨不会生成重复 ID。只有当回拨时间超过 5 秒才抛异常让服务重启刷新。时间戳起始值 1288834974657 是 Twitter 官方的纪元时间不要随意改动改动了生成的 ID 里时间部分就会偏移。4.3 下单主流程完整链路代码整合前面的内容一个完整的下单接口代码如下。PostMapping(/buy) public Result buy(RequestBody BuyRequest request, RequestHeader(userId) String userId) { // 1. Redis 预减库存 boolean deducted inventoryService.preDeductStock( request.getConcertId(), request.getTicketType(), request.getCount()); if (!deducted) { return Result.error(库存不足); } // 2. 防重复抢购同一用户一场次只能下一单 boolean locked orderService.tryLock(userId, request.getConcertId()); if (!locked) { return Result.error(手速太快了请稍后重试); } // 3. 生成订单号并投递 MQ long orderId snowflakeIdGenerator.nextId(); OrderMessage message new OrderMessage(); message.setOrderId(orderId); message.setUserId(userId); message.setConcertId(request.getConcertId()); message.setTicketType(request.getTicketType()); message.setCount(request.getCount()); rabbitTemplate.convertAndSend( order.exchange, order.create, JSON.toJSONString(message)); // 4. 写入订单缓存秒回状态给前端 orderCache.savePendingOrder(orderId, userId, request.getConcertId()); return Result.success(抢票成功, orderId); }这段代码的顺序是有讲究的。Redis 预减失败直接返回不进入锁的争抢逻辑锁获取失败返回「手速太快」这时候 Redis 预减的那一票需要回补否则用户以为自己没抢到票却少了一张。回补逻辑在 inventory-service 里通过 MQ 消息触发锁获取失败时发送一条回补消息消费者拿到后执行hincrby key field 1加回库存。注意这里 MQ 发出去的消息要以 JSON 字符串序列化不要直接传对象。RabiitMQ 的 Java 客户端在反序列化时会校验类的 serialVersionUID一旦消费者和服务提供者的版本不一致消息消费直接失败这个坑线上不好排查。5. 常见问题与避坑分布式锁失效、缓存穿透、重复下单、事务回滚5.1 锁提前过期导致库存扣减和下单不同步现象并发压测时发现某个用户下了两单但 Redis 里只扣了一张票的库存。原因Redisson 看门狗默认 30 秒续期但我在初始化 Redisson 时手动配置了 lockWatchdogTimeout5000业务执行时间略长锁在业务完成前过期了。第二个请求进入后拿到的是一把新锁自然可以重复下单库存却被扣了两张。解决把 lockWatchdogTimeout 恢复到默认值 30000同时检查锁内业务的耗时瓶颈。锁内的核心操作是 Redis 预减加 MQ 投递这两步加在一起应该不超过 100 毫秒如果你发现锁内耗时超过 2 秒大概率是 Feign 调用了数据库查询要把耗时的数据库操作移出锁的范围。5.2 缓存穿透恶意请求负载到空数据层现象压测工具模拟一万个不存在的 userId 抢票Redis 缓存里没有这些用户请求全部穿透到数据库查用户表数据库 CPU 瞬间飙到 100%。原因缓存里只有真实用户的信息恶意请求用随机 ID 访问时缓存未命中直接查库数据库需要处理大量无效查询。解决用布隆过滤器拦截。项目里引入了 Redisson 的 RBloomFilter初始化时把全部用户 ID 加进去请求进来先走布隆过滤器存在的才继续查询。过滤器的误判率设置成 0.01 就够了误判只会多放几个请求进来不会影响正确性。5.3 MQ 重复消费导致同一订单重复入库现象消费者日志里看到同一个 orderId 的消息被消费了两次数据库订单表出现重复记录。原因RabbitMQ 的消费者在处理完消息后、提交 ack 之前发生了网络抖动Broker 认为消息没被消费重新投递。解决在订单表上加了order_id唯一索引消费者每次消费前先执行INSERT IGNORE INTO ticket_order (order_id, ...) VALUES (...),如果插入失败说明消息已处理过直接 ack 丢弃。这个方案简单有效不用引入独立的幂等表唯一索引的约束在数据库层面最可靠。5.4 支付超时回补库存时 Redis 与数据库不一致现象支付服务判定订单超时调用 inventory-service 回补库存数据库里库存加回来了但 Redis 里还是扣减后的值用户看到的剩余票数偏少。原因回补逻辑只写了数据库的UPDATE ticket_stock没有同步刷新 Redis。设计时我以为 Redis 里的库存和数据库库存是完全镜像的实际上 Redis 预减是异步落库的DB 的库存值只代表已落库的快照两者天然有时间差。解决回补操作以数据库为准回补完成后再主动刷新 Redis 的对应 Hash 字段。刷新前要清掉旧缓存否则并发请求可能读到脏数据。5.5 本地事务方法自调用导致 Transactional 失效现象订单服务里有一个方法同时做写订单表和更新订单状态标注了 Transactional 注解但异常时订单表回滚了、状态表没回滚。原因Spring 的 Transactional 基于 AOP 代理实现同类的内部方法调用this.xxx()不会经过代理事务切面无法拦截。解决把事务方法拆到独立 Service 类或者通过注入自身的代理对象调用。最常见做法是新增一个OrderTransactionService由它负责事务方法外部 Service 调用orderTransactionService.executeOrder(...)。6. 压测验证与数据一致性对账从 JMeter 脚本到库存回补基线6.1 JMeter 模拟抢票流量与关键指标动手验证这套系统能不能扛住真实抢票流量我用 JMeter 做了两轮压测。第一轮用 500 个线程模拟 10 万次抢票请求看基础吞吐量第二轮把线程数调到 2000让瞬时流量直接冲到 Redis 和 MQ观察系统边缘行为。压测前先准备 100 万用户数据灌入数据库避免用户表查询成为瓶颈。压测线程组配置参考Ramp-Up Period 设为 0让 2000 个线程同时发起请求这是最接近真实抢票的「瞬间洪峰」模型。每个线程循环执行 50 次总计 10 万次请求。HTTP 请求里加入一个随机参数 token模拟不同用户的请求头。压测时观察的核心指标有三个吞吐量Throughput能否稳定在 8000 次/秒以上错误率是否低于 0.5%TP99 延迟是否小于 500 毫秒。提示JMeter 的聚合报告里重点关注 Response time 的 90% 分位数和 99% 分位数平均响应时间在秒杀场景下意义不大——前端用户只关心自己那一秒有没有抢到票。实际压测结果 TP99 稳定在 300 毫秒左右错误率集中在锁争抢失败的返回上这部分业务上算正常流量而非系统异常。6.2 一致性对账脚本检验超卖与漏卖压测结束后最怕的就是票卖多了或者卖少了。写一个对账脚本比对订单表和库存表的最终数据-- 检查超卖订单中某场次某票档的购买数量大于库存表中该场次该票档的剩余扣减量 SELECT o.concert_id, o.ticket_type, SUM(o.count) AS sold_count, s.total_stock - s.remain_stock AS expected_sold FROM ticket_order o JOIN ticket_stock s ON o.concert_id s.concert_id AND o.ticket_type s.ticket_type WHERE o.order_status IN (PAID, PENDING) GROUP BY o.concert_id, o.ticket_type, s.total_stock, s.remain_stock HAVING sold_count expected_sold;这个查询是关键的对账基线。sold_count 是订单表里统计出的已售出数量expected_sold 是库存表里记录的总库存减剩余库存。如果超卖sold_count 一定会大于 expected_sold这条查询就能直接查出来。还有一个漏查场景要处理Redis 预减成功但 MQ 消息丢失导致订单表少记录。这种情况单独一条 SQL 查不出来我的办法是每天凌晨跑定时任务把 Redis 里当天所有演唱会的扣减流水导出和订单表的成功记录做比对缺一条就触发一次人工补偿。这也是为什么我在设计时特意给订单表加了source_redis_id字段就是为了这个对账流程能精确到每一笔扣减。6.3 压测后的几个观察与收尾习惯压测过程中有一个细节让我印象特别深2000 并发下RabbitMQ 的消费者默认一次拉取 250 条消息导致内存占用飙升消费者 GC 频繁消息处理吞吐反而下降。我把消费者的 prefetch 从 250 调到 50再观察吞吐量直接提升了 30%GC 频率也明显降低。这个参数平时不显眼但高并发下它就是压死消费者的最后一根稻草。从那以后每次压测完我都会强制走一遍数据对账流程——超卖查询、漏卖比对、Redis 与 DB 库存一致性校验三个检查缺一不可。抢票系统这种场景一次超卖事故就能让平台口碑归零对账的习惯比任何代码优化都重要。希望这套基于 SpringCloud 的分布式抢票系统源码和配套文档能帮你在搭建这类高并发项目时少踩几个坑把精力放在真正有价值的业务优化上。本文还有配套的精品资源点击获取
返回列表