资讯动态

SpringBoot+Redis+RabbitMQ秒杀系统:高并发防超卖源码解析

发布时间:2026/10/27 16:15:24 来源:尧图企业网站定制
简介一套基于Spring Boot、MyBatis和MySQL构建的商城秒杀系统完整源码整合Redis、RabbitMQ、ZooKeeper、Redisson等主流中间件面向正在学习高并发业务、分布式系统设计以及中间件集成的Java开发者适合作为课程设计或面试项目演练。压缩包共258个文件主体为170个XML配置、48个Java类、12个JSP页面并辅以CSS、JavaScript前端资源、数据库初始化SQL及Markdown说明文档整体仅451KB目录按功能模块划分便于定位秒杀接口、业务逻辑与页面资源。代码覆盖商品列表、秒杀下单、库存扣减、订单超时等核心流程清晰演示了缓存预热、消息队列削峰、分布式锁协调等典型中间件应用方式能帮助读者快速掌握多中间件在真实电商场景中的协同打法。目前已有361人学习下载是一份短小精悍、可直接运行学习的高质量源码包。1. 秒杀系统不是电商系统这份SpringBootMybatisMysql源码拆给你看如果你见过大促页面开抢瞬间的卡顿和秒罄大概会好奇一个商品瞬间被几十万人点开系统到底是怎么扛住的这份基于SpringBootMybatisMysql中间件Redis做缓存与分布式锁、RabbitMQ做异步削峰的商城秒杀系统源码把高并发场景下最核心的库存防超卖问题做成了一套可以直接导入IDEA跑起来的工程。它不是普通商城系统——普通订单流程在秒杀量级下根本撑不住需要单独设计限流、缓存预减、队列削峰和乐观锁兜底四层防线。适合谁一是刚接触SpringBoot想找真实业务练手的同学二是公司要做秒杀活动、想先看一套完整方案再动手的开发者。如果你已经写过CRUD这份源码能让你看到Mybatis的Mapper层、MySQL索引设计、Redis中间件和消息队列在同一个项目里是怎么配合的而不是零散地学了一堆理论不会串。下面我按数据表、核心下单链路、踩坑记录和压测验证四个环节把这套代码的关键部分拆开讲。2. 数据表与项目结构从秒杀订单表的索引设计看系统的第一道防线秒杀系统的代码可以写得很花哨但数据表设计不对后面全白搭。这一章先把项目里各个中间件的位置讲清楚然后落到建表SQL和Mybatis的Mapper写法上——这是整套代码的地基。2.1 项目结构梳理nginx、Redis、RabbitMQ、MySQL各自扮演什么角色打开这套源码的工程目录你会发现它不是一个单机应用至少由四层构成。最外层是负载均衡和静态资源分发一般用nginx挡在前面把秒杀页面和接口请求分开。第二层是Redis主要承担两个任务一是把商品信息和库存数量提前预热到缓存里让绝大部分请求在读库存阶段根本不碰数据库二是用分布式锁保证同一时间只有一个线程在处理同一件商品的下单。第三层是RabbitMQ它负责削峰前端所有真正下单的请求先丢进队列后台消费者按固定速率去处理避免瞬间流量把数据库打崩。第四层才是MySQL它只做最终的数据落地。这个分层逻辑对应一段经典的流量漏斗nginx挡掉静态流量Redis挡掉重复查询消息队列挡掉瞬时并发MySQL只处理真正要写的数据。你如果在面试里被问到秒杀系统怎么设计按这条链路讲基本不会被挑毛病。源码里对应的包结构也沿用了这个思路controller层很薄service层拆成了cache包、mq包和db包找代码的时候顺着包名就能定位。2.2 建表SQL与索引设计秒杀订单为什么要单独建一张表项目里没有复用普通订单表而是单独建了一张秒杀订单表这点很重要。普通订单表字段多、索引多插入一条要更新好几个索引秒杀场景下每秒要写几百上千条订单表结构必须极简。核心表就两张CREATE TABLE seckill_goods ( id bigint(20) NOT NULL AUTO_INCREMENT, goods_name varchar(64) NOT NULL COMMENT 商品名称, stock int(11) NOT NULL DEFAULT 0 COMMENT 可用库存, seckill_price decimal(10,2) NOT NULL COMMENT 秒杀价, start_time datetime NOT NULL COMMENT 秒杀开始时间, end_time datetime NOT NULL COMMENT 秒杀结束时间, version int(11) NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, PRIMARY KEY (id), KEY idx_start_end (start_time,end_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT秒杀商品表; CREATE TABLE seckill_order ( id bigint(20) NOT NULL AUTO_INCREMENT, user_id bigint(20) NOT NULL COMMENT 用户ID, goods_id bigint(20) NOT NULL COMMENT 商品ID, order_no varchar(64) NOT NULL COMMENT 订单号, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 订单状态0-新建 1-已支付 2-已取消, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_user_goods (user_id, goods_id), KEY idx_order_no (order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT秒杀订单表;两张表的字段都很克制。seckill_goods表加了version字段这是给乐观锁用的后面扣库存的时候会讲。seckill_order表上最关键的索引是uk_user_goods这个联合唯一索引它的作用是兜底防重复下单——哪怕代码逻辑漏了数据库层面也保证一个用户对一个商品只能有一条有效订单。idx_start_end索引则是为了在秒杀开始前查询可秒杀商品列表时不走全表扫描。这里要提醒一句别为了省事把秒杀记录塞进业务订单表更别在create_time上建普通索引。秒杀订单表的写入是append-only模式按时间查列表的需求很少create_time索引纯属浪费空间而唯一索引建错了并发插入时数据库会频繁抛死锁错误这就是MySQL锁分类里行锁与插入意向锁打架的典型场景。2.3 Mybatis配置与Mapper层把打印SQL、二级缓存、驼峰映射一次配对拿到源码先别急着跑把application.yml里的Mybatis配置检查一遍。我见过不少项目启动后莫名其妙的字段为null大多是因为驼峰映射没开。下面这段是这套源码里的标准配置mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.seckill.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl cache-enabled: falsemap-underscore-to-camel-case必须设为true否则数据库里的create_time映射不到createTime属性上。log-impl配成StdOutImpl可以直接在控制台看到每个SQL的执行时间和参数排查慢查询时非常有用。最后那个cache-enabled: false是秒杀项目里一个容易踩的坑—Mybatis默认用一级缓存二级缓存默认是关闭的但很多人会手痒打开它。在秒杀场景下一旦开启二级缓存恰好又没做缓存失效策略商品库存就可能被脏数据覆盖后面避坑章节我再细说。Mapper层的代码写法也有讲究。秒杀项目的Mapper接口不需要复杂的动态SQL重点是参数传递和更新影响行数。看一下SeckillGoodsMapper里的扣库存方法public interface SeckillGoodsMapper { int reduceStock(Param(goodsId) Long goodsId, Param(version) Integer version); }对应XML里的update语句update idreduceStock UPDATE seckill_goods SET stock stock - 1, version version 1 WHERE id #{goodsId} AND stock 0 AND version #{version} /update注意三条WHERE条件和两条SET子句。stock stock - 1是原子操作加上AND stock 0数据库层面就保证了扣减时不会出现负数。version参数则是乐观锁的比对条件执行前你从Redis或缓存拿到当前的version值执行时如果version已经变了这条update的影响行数就是0代码里通过影响行数判断当前请求失败。这就是乐观锁兜底的核心你要提前准备好一个version执行时检查它是否变化而不是靠数据库行锁硬扛。这不是什么新概念但把它放在秒杀这个场景里你就能理解为什么数据库层的update要这么写——它是在中间件防线全部被穿透后最后一道不能失守的底线。3. 下单链路实现Redis预减库存 Redisson锁 Mybatis乐观锁的三层保险这一章是整个项目的灵魂。一个用户点了立即秒杀请求从进入网关到最终落库要经过限流、缓存预减、分布式锁、消息队列、数据库扣减五步。我会按真实调用顺序把每一层的代码和参数讲清楚。3.1 限流层用令牌桶把无效请求挡在最前面秒杀刚开始那一秒进来的请求量可能是正常值的几百倍。如果这些请求全部进入后续逻辑再好的缓存也撑不住。这套源码在拦截器层用了Guava的RateLimiter做令牌桶限流粒度是每秒处理的请求数可以按接口单独配置。Component public class SeckillRateLimitInterceptor implements HandlerInterceptor { private final RateLimiter rateLimiter RateLimiter.create(100); Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException { if (!rateLimiter.tryAcquire()) { response.setStatus(503); response.getWriter().write({\code\:503,\msg\:\system busy\}); return false; } return true; } }RateLimiter.create(100)表示每秒向桶里放100个令牌tryAcquire()会尝试拿一个令牌拿不到就立刻返回false不会阻塞等待。这个设计很关键秒杀场景宁可拒绝请求也不能让请求排队拖垮后面的链路。response.setStatus(503)告诉客户端系统繁忙前端拿到这个状态码可以提示用户稍后重试而不是一直转圈。这里有个调参心得令牌桶大小不要拍脑袋定。我一般会先用JMeter压出下游接口的真实承载能力比如数据库在乐观锁下每秒能稳定处理500个更新那限流到100就留了5倍的余量。源码里默认配置是100你实际部署时应该改成自己压测出来的数字。3.2 缓存层Redis预减库存与消息队列削峰限流通过的请求进入Service层后第一件事不是查数据库而是查Redis。商品信息和库存数量在秒杀开始前已经通过一个定时任务预热到Redis里了这样百分九十的请求只跟Redis打交道不会给MySQL任何压力。public boolean preReduceStock(Long goodsId) { String key seckill:stock: goodsId; Long remaining redisTemplate.opsForValue().decrement(key); if (remaining 0) { // 库存已经减到负数说明卖完了回补一次 redisTemplate.opsForValue().increment(key); return false; } return true; }decrement在Redis里是原子操作多个线程同时执行也不会出现加减错乱。返回值remaining小于0就回补并返回false表示已经没有库存。这里有一个很多人忽略的细节回补必须用increment(key)而不是把key重新set成某个值因为在高并发下set会覆盖掉其他线程的操作。使用Redis做中间件时凡是先读后写的操作都要警惕竞态能用一个原子命令解决的坚决不用两个命令。如果预减库存成功下一步是把下单消息丢进RabbitMQ。这里用的是异步削峰模式前端请求只负责把消息投递到队列然后立即返回真正的订单业务由消费者处理。public void sendSeckillMessage(Long goodsId, Long userId) { SeckillMessage message new SeckillMessage(goodsId, userId); rabbitTemplate.convertAndSend( seckill.exchange, seckill.routing, JSON.toJSONString(message) ); }convertAndSend的第一个参数是交换机名第二个是路由键第三个是消息体这里用FastJSON把对象序列化成了字符串。交换机、队列和绑定关系在配置类里定义消费者用RabbitListener监听指定队列。源码里的消费者逻辑是收到消息后先查Redis里的预减结果如果预减成功才执行数据库的库存扣减和订单插入。为什么要把下单做成异步直接同步写订单行不行行但你会遇到一个现实问题如果下单逻辑里有任何慢操作比如生成订单号要调外部服务用户的HTTP请求会一直挂着。一旦大量请求同时挂着Tomcat线程池瞬间被打满整个应用就失去了响应能力。改成异步后用户请求都变成秒回后台消费者按自己的节奏处理队列这就是削峰的本质。3.3 数据库层Redisson分布式锁与乐观锁兜底Redis预减库存解决了判断有没有库存的并发问题但真正写订单是在RabbitMQ消费者里多个消费者实例可能同时处理同一个商品的订单。这时候要防的是两个消费者同时对同一商品执行查库存、扣库存、写订单这个组合操作中间任何一步被打断都会产生超卖。所以项目里引入了Redisson的分布式锁锁的粒度是商品ID。Autowired private RedissonClient redissonClient; public boolean lockSeckill(Long goodsId, Long userId) { RLock lock redissonClient.getLock(seckill:lock: goodsId); try { // waitTime0拿不到锁立即失败leaseTime55秒后自动释放 return lock.tryLock(0, 5, TimeUnit.SECONDS); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; } }tryLock(0, 5, TimeUnit.SECONDS)三个参数各有用意。第一个0表示等待时间为0即拿不到锁立刻返回false不排队等待——秒杀场景下排队等待会让线程全部阻塞反而拖垮应用。第二个5是锁的自动释放时间单位秒防止因为程序异常导致锁一直不释放这就是起了个后悔药的作用。第三个是时间单位。拿到锁之后消费者才去执行乐观锁的update SQL用影响行数判断是否扣成功。Redisson这种中间件的一个重要特性是看门狗机制如果业务执行时间超过5秒Redisson会自动续期避免业务没执行完锁就过期了。但这里特意把leaseTime显式设成了5秒而不是用默认的看门狗是因为下单业务本身应该控制在几毫秒内完成。如果超过5秒还没完成说明下游有问题让锁超时反而是一种保护。3.4 下单主流程串联一个完整请求从进入到落库的调用顺序把前面三层串起来整个下单流程是这样的客户端请求先打到拦截器令牌桶限流拿不到令牌直接返回503限流通过后进入Service先查Redis里的商品状态判断秒杀是否开始、是否卖完Redis通过后执行preReduceStock预减库存这一步就过滤掉了99%的无库存请求预减成功则把下单消息发送到RabbitMQ接口立即返回下单成功正在处理RabbitMQ消费者收到消息后先加Redisson分布式锁再加锁成功的情况下执行Mybatis的reduceStock乐观锁更新最后插入订单记录并提交事务。这套链路里每一层都有独立职责也都有各自的失败处理。限流失败直接返回Redis预减失败回补库存分布式锁失败结束流程乐观锁影响行数为0则回补Redis库存并记录日志。需要特别注意的是Redis预减和数据库扣减不是强一致的Redis先减了库存但数据库扣减失败必须主动回补Redis的库存值否则用户会看到有库存却下不了单。源码的消费者里有一段catch块专门处理这个问题原理就是上面说的increment回补。4. 避坑实战超卖、缓存穿透、死锁与连接池耗尽的五类翻车现场这套源码我前后给不同团队讲过很多次每次都能遇到同样的几个问题。下面五条是出现频率最高的按现象、原因、解决的格式记录你可以对照自己的项目排查。4.1 超卖库存扣成了负数现象压测结束后查数据库stock字段变成了-1、-2甚至更离谱的负数。订单数量比商品总库存还多。原因这是最典型的先查后改竞态。原始代码是先执行SELECT stock FROM seckill_goods WHERE id ?判断库存大于0再执行UPDATE seckill_goods SET stock stock - 1。在高并发下两个线程同时查到库存是1都判断大于0然后都执行扣减库存就被扣成负数了。解决思路不是加if判断而是让检查与扣减变成一个原子操作。按这套源码的做法用UPDATE ... WHERE id ? AND stock 0直接让数据库在更新时判断库存是否大于0影响行数为0就说明没库存了。Redis预减那层已经挡掉了大部分超卖但数据库这层仍然不能省因为消息队列消费者多实例部署时Redis和数据库不是同一个事务。4.2 Redis缓存穿透请求全部打到MySQL现象秒杀还没开始数据库就被打挂了。查看监控发现每秒有上万条SQL在查同一个不存在的商品ID。原因商品ID是前端抓包猜出来的Redis里没有这个key每次查询都落到MySQL。这就是缓存穿透——查询一个不存在的key缓存不命中请求直接打穿到数据库。解决思路分两步。第一步秒杀开始前把所有可秒杀商品的ID和基本信息都预热到Redis这是本项目里定时任务做的事情。第二步给查询操作加兜底查不到商品就缓存一个空值设置很短的有效期比如30秒或者用布隆过滤器拦截不可能的ID。我在生产环境一般两种都上空值缓存解决单个ID反复查询布隆过滤器解决随机ID扫描。4.3 长事务与死锁下单接口RT飙到3秒现象压测过程中下单接口的平均响应时间从几十毫秒突然涨到3秒以上数据库日志里频繁出现Deadlock found。查看监控发现information_schema.innodb_trx里有大量长时间未提交的事务。原因这个坑很隐蔽——下单方法上有Transactional注解整个方法从查库存到扣库存再到插入订单全程持有一个数据库事务。高并发下多个事务同时操作同一行商品数据InnoDB的行锁会互相等待。如果事务里还掺杂了调用外部接口或者大量日志写入事务持有时间更长死锁概率直线上升。MySQL锁分类里行锁与间隙锁在UPDATE和INSERT混合时特别容易产生死锁。解决思路是缩小事务边界Redis预减和消息发送放在事务外事务内只保留reduceStock和insert两个最快的数据库操作。源码里把事务方法单独抽了一个类用Transactional只标注真正需要原子的部分而不是整条链路。4.4 数据库连接池耗尽并发还没到1000应用先挂了现象压测刚开始没多久日志里大量抛Connection is not available, request timed out连接池全部被占满。原因HikariCP的默认maximum-pool-size是10也就是说数据库连接最多同时只能有10条SQL在执行。前面说了事务持有连接时间太长连接被占住不放新的请求拿不到连接只能等待等待超时就直接报错。解决思路分三块。第一把maximum-pool-size调到50左右同时minimum-idle保持在10——连接池不是越大越好越大数据库压力越大。第二把connection-timeout设成1000毫秒超过1秒拿不到连接就快速失败而不是傻等30秒。第三也是最重要的——配合限流和消息队列让到达数据库的并发数本来就是可控的而不是指望连接池扛住所有并发。spring: datasource: hikari: maximum-pool-size: 50 minimum-idle: 10 connection-timeout: 10004.5 Mybatis二级缓存读到脏库存数据现象Redis里的库存已经更新了但查询接口返回的库存还是旧值。重启应用后数据正常过一段时间又变旧。原因项目里有人为了提升查询性能把Mybatis的二级缓存打开了。二级缓存是Mapper级别的多个会话共享但秒杀场景下库存变更是高频操作二级缓存的失效策略跟不上更新频率就会读到脏数据。查缓存时返回了旧值而真正的库存其实已经在库里被扣掉了。解决思路很明确秒杀项目直接关掉二级缓存mybatis.configuration.cache-enabledfalse。查询压力交给Redis扛Mybatis这一层不需要缓存也不需要一级缓存跨方法共享。一级缓存只在同一个SqlSession内有效秒杀场景中每次请求都是独立的SqlSession一级缓存几乎帮不上忙反而占用内存。5. 压测验证与参数调优用JMeter确认3000并发的真实表现代码写完只是第一步秒杀系统必须压测过才知道配置是否合理。我习惯在本机起一套完整环境——MySQL 5.7用Docker容器跑Redis和RabbitMQ也放容器里然后用JMeter开线程组模拟3000个用户同时抢购。如果你本机还没装MySQL直接跑这套源码自带的docker-compose脚本就行注意把容器内存至少给到1GB否则启动阶段容易报内存不足。JMeter的测试计划里我一般配3000线程、Ramp-Up Period设为1秒也就是一秒内3000个请求全部发出。这里不推荐把循环次数设成多次秒杀场景要的就是瞬间流量循环次数越多数据越容易失真。聚合报告里重点看三个指标99%响应时间要低于500毫秒、吞吐量要稳定不下跌、错误率不能超过0.1%。如果错误率偏高先看是不是限流器把大量请求挡了——503响应也会被JMeter记为错误这时候反而说明限流生效了可以适当调高RateLimiter.create()的参数再压。压测过程中要同时盯三个监控。第一个是Redis执行redis-cli --stat每秒快照一次看缓存命中率和内存变化。当库存预减到0之后Redis的opsForValue().decrement调用量会明显下降这证明限流和预减在起作用。第二个是数据库锁等待执行下面这条SQLSELECT * FROM information_schema.innodb_trx WHERE TIME_TO_SEC(TIMEDIFF(NOW(), trx_started)) 5;如果有记录说明存在长事务要回到4.3节排查事务边界。第三个是应用日志里的SQL执行时长Mybatis的StdOutImpl会打印每个SQL的耗时如果发现reduceStock语句执行超过50毫秒优先查seckill_goods表的行锁冲突而不是数据库性能。调优我有一套固定顺序先确认限流参数没有过度拦截再看RabbitMQ消费者的处理速度是否匹配生产速度——如果队列积压越来越深说明消费者太慢可以增加消费者实例数量或者把预减库存逻辑前置最后才调整HikariCP的连接池参数。从那以后我每次做完秒杀项目都会强制自己走一遍这套压测流程跑完拿到监控数据再改代码而不是凭感觉调参。秒杀系统的任何配置改动都要有压测数据支撑这个习惯帮我避开了无数次线上事故希望帮到你。这套源码值得你下载到本地把每个环节的代码对照本文章节走一遍能理解得比只看文章深刻得多。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑