ARTICLE DETAIL

资讯详情

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

SpringBoot+Redis+RabbitMQ构建高并发秒杀系统实战解析

SpringBoot+Redis+RabbitMQ构建高并发秒杀系统实战解析 简介一份基于SpringBoot、MyBatis、MySQL及多种中间件构建的商城秒杀系统源码包面向已掌握Java Web基础知识、希望深入高并发场景下秒杀业务落地的开发者。项目整合Redis缓存、RabbitMQ消息队列、ZooKeeper统一协调调度中心、Redisson分布式锁等核心中间件完整呈现从商品库存预减、流量削峰、异步下单到订单一致性的实现思路。压缩包共258个文件以170个XML配置文件、48个Java源文件、12个JSP页面为主另含JS/CSS静态资源及SQL初始化脚本整体仅451KB目录按api、model、server等模块划分便于快速定位与部署。目前已有361人学习下载。通过此源码可深入理解秒杀系统在缓存、消息、锁协同下的设计要点掌握防超卖、缓存击穿、串行化处理等关键难点的落地解法熟悉分布式环境下数据一致性保障手段也适合作为中小型电商项目的参考脚手架便于二次开发与功能扩展。1. 秒杀系统的本质用 SpringBoot 搭骨架却要小心“架构幻觉”把“基于SpringBootMybatisMysql中间件构建的商城秒杀系统源码.zip”这个标题拆开看它其实是绝大多数 Java 从业者第一次接触高并发时必经的经典组合Web 层用 SpringBoot 收请求持久层用 Mybatis 拼 SQL数据落在 Mysql中间件负责扛流量和削峰。这类项目真正难的不是 CRUD而是秒杀特有的三座大山——瞬时流量打垮数据库、库存超卖、重复下单。网上不少类似源码把 Redis 和 MQ 加进来就算“高并发”但跑起来一压测就露馅要么库存被扣成负数要么订单重复落库要么 Mysql 连接池直接被拖死。这篇笔记我会按一条可复现的落地路径拆解这个项目——先建表写 SQL再用中间件把流量挡住最后告诉你哪些参数不调必翻车、哪些坑是血泪经验换来的。适合正在做毕业设计、跳槽准备高并发项目经验或者公司真要上一套秒杀活动但没时间从零设计的后端开发。2. 数据库建模与 Mybatis 持久层秒杀的 SQL 先行锁边界先踩2.1 订单表别设计成第三范式冗余字段要大胆加秒杀系统的表结构通常只有三张核心表秒杀商品表、订单表、支付流水表。很多新手从商城系统复制过来一张订单主表、一张订单明细表还带了商品快照外键这在日常下单没问题但秒杀场景下是给自己埋雷。为什么因为秒杀订单不需要复杂的多商品购物车逻辑用户一次只能抢一件订单明细表属于纯浪费。我一般在项目中是这样设计的miaosha_goods表存秒杀商品核心字段是goods_id关联普通商品、miaosha_price、stock_count、start_time、end_timemiaosha_order表直接冗余商品名称、秒杀价格、商品主图 URL哪怕商品后续改了信息用户看到的始终是下单那一刻的快照。这样查询订单列表时不会被迫 JOIN 商品表少一次关联就少一点数据库压力。CREATE TABLE miaosha_goods ( id bigint NOT NULL AUTO_INCREMENT, goods_id bigint NOT NULL COMMENT 普通商品id, miaosha_price decimal(10,2) NOT NULL COMMENT 秒杀价, stock_count int NOT NULL COMMENT 库存-1表示无限制, start_time datetime NOT NULL, end_time datetime NOT NULL, PRIMARY KEY (id), KEY idx_goods_id (goods_id), KEY idx_start_end (start_time, end_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE miaosha_order ( id bigint NOT NULL AUTO_INCREMENT, user_id bigint NOT NULL, goods_id bigint NOT NULL COMMENT 关联普通商品, order_id bigint DEFAULT NULL COMMENT 支付订单id, goods_name varchar(120) NOT NULL COMMENT 商品名称快照, miaosha_price decimal(10,2) NOT NULL COMMENT 下单时秒杀价快照, status tinyint NOT NULL DEFAULT 0 COMMENT 0新建 1支付成功 2取消, create_time datetime NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_user_goods (user_id, goods_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这段 DDL 里最关键的是uk_user_goods唯一索引。它不是随便加的而是用来从数据库层面拦截“同一个用户在同一秒杀活动中对同一商品重复下单”。就算应用层加锁漏了或者消息队列重投了这条唯一索引都会让第二次 INSERT 直接报Duplicate entry配合捕抓异常转换成“您已抢购过该商品”的提示这是最后一道兜底绝不能去掉。2.2 Mybatis XML 里的原子扣减WHERE stock_count 0 才是核心秒杀扣库存的 SQL 几乎每个人都会写错。新手常见的做法是先 SELECT 查库存判断大于 0再执行 UPDATE 扣减最后 INSERT 订单。这个流程在并发量只有几百的时候看似没问题但一旦 QPS 上了几千“查询到的库存”和“实际扣减时的库存”之间会插进无数个并发请求最后一个请求可能拿不到库存却依然执行了 INSERT——此时库存表已经变成负数。正确做法是把判断和扣减合成一条原子 UPDATE。Mybatis 的 XML 里面这样写update idreduceStockByCondition UPDATE miaosha_goods SET stock_count stock_count - 1 WHERE id #{goodsId} AND stock_count 0 AND start_time lt; NOW() AND end_time gt; NOW() /update这段 SQL 的逻辑用一句话讲就是能更新成功返回行数为 1说明你拿到了库存返回行数为 0说明库存不足或不在活动窗口内。根本不需要 SELECT 先查一遍。stock_count 0这个条件在 InnoDB 行锁机制下会锁住这条商品记录直到事务提交所以并发扣减是串行化的不会扣成负数。有一个隐蔽的坑要提醒你UPDATE 返回的行数不是“受影响的行数”而是“匹配到且被修改的行数”。如果数据库配置了sql_safe_updates或者 Mybatis 的useAffectedRows设置不同返回值的含义可能不一样。我踩过的版本坑是在 MySQL 驱动 8.0 以后JDBC 默认useAffectedRowsfalseUPDATE时如果stock_count值没变比如并发完全串行时返回行数是匹配行数而不是真实修改行数。好在扣减场景每次都会变化一般不受影响但如果你把这条 SQL 改造成 “UPDATE 库存为 0 则视为锁定” 这类写法返回含义必须心里有数。2.3 事务边界只把“扣库存写订单”包在一起前置校验一律不要进事务把事务范围划大是秒杀系统性能杀手。很多源码里从校验用户是否登录、校验是否重复秒杀、到扣库存、生成订单、更新支付流水一整串操作全部包在Transactional里。这会导致事务持有数据库连接的时间被拉长连接池很快被打满后面的请求全部排队等连接表现就是接口越来越慢最终超时。我一般是这样拆的前置校验Redis 查用户是否已秒杀过、本地内存判断活动是否开始放在事务之外事务里面只做两件事——扣库存的 UPDATE 和 INSERT 订单事务提交之后再发 MQ 消息通知支付服务。核心结构如下Service public class MiaoshaService { Autowired private MiaoshaGoodsMapper goodsMapper; Autowired private MiaoshaOrderMapper orderMapper; Transactional(rollbackFor Exception.class) public OrderResult doMiaosha(Long userId, Long goodsId) { // 1. 原子扣减库存行锁保护 int rows goodsMapper.reduceStockByCondition(goodsId); if (rows 0) { throw new MiaoshaException(StockNotEnough); } // 2. 生成订单唯一索引兜底重复下单 MiaoshaOrder order new MiaoshaOrder(); order.setUserId(userId); order.setGoodsId(goodsId); order.setStatus(0); order.setCreateTime(new Date()); try { orderMapper.insert(order); } catch (DuplicateKeyException e) { // 库存扣了但订单插入失败回滚让库存释放 throw new MiaoshaException(RepeatMiaosha); } return OrderResult.success(order.getId()); } }这段代码最容易被忽略的是DuplicateKeyException的处理。如果没有唯一索引重复下单可能直接把订单表写爆有了唯一索引第二次插入会抛异常但此时库存已经扣掉了。这里特意让异常向上抛触发事务回滚库存自动加回来。如果不回滚就会出现“用户看着库存少了一件实际订单却不存在”的数据不一致。事务回滚在这里就是用户的后悔药释放被无效应答占用的库存。3. Redis 预减库存与限流拦截把瞬时压力前置到内存层3.1 为什么必须做库存预热数据库行锁扛不住万级并发前面那套纯数据库的事务方案在每秒几百并发时是可行的但秒杀的真实场景往往集中在开抢后的前几秒比如某件商品放 3000 件库存开抢瞬间可能涌入几万请求。这些请求如果全部打到 Mysql即使每条 UPDATE 只锁一行InnoDB 的并发处理能力也会被锁等待拖垮。更麻烦的是大量请求都在等一下把锁后面排队的连接把连接池占满整个应用的其他接口全部不可用。所以常见的秒杀方案必须引入 Redis 做库存前置。做法是活动开始前把商品的库存数加载到 Redis用户在接口层先对 Redis 的库存做预减预减成功才允许进入后端的数据库事务预减失败直接返回“已抢光”完全不碰数据库。这样数据库承担的并发量从几万降到几百才扛得住。3.2 Redis 预热与 Lua 原子脚本预减库存的正确姿势预热最简单的做法是在 SpringBoot 启动时用ApplicationRunner加载商品库存或者在后台管理端发布活动时主动把库存推送到 Redis。注意 Redis 的库存数据必须和数据库的stock_count保持同源活动的初始库存以数据库为准Redis 只是它的缓存副本。预减库存不能简单的decr一句完事因为decr会把库存减成负数必须配合判断。这里推荐用 Lua 脚本保证原子性否则“判断扣减”两步操作在并发下会有时间窗口-- 判断库存并扣减的 Lua 脚本 local stock_key KEYS[1] local requested tonumber(ARGV[1]) local current tonumber(redis.call(get, stock_key)) if current false then return -1 end if current requested then return 0 end redis.call(decrby, stock_key, requested) return 1Java 侧用DefaultRedisScript执行这段脚本得到 1 表示预减成功拿到入场资格得到 0 表示库存不足直接返回秒杀失败。这里要说明一个边界为什么不用 Redis 事务MULTI/EXEC因为 Lua 脚本是原子执行的脚本执行期间不会被其他客户端命令插入而WATCH/MULTI/EXEC在库存热点上会造成大量重试性能差一个量级。参数上需要特别注意 Redis 的键过期时间。常见做法是给库存 key 设置活动结束时间作为过期时间但预减成功后如果用户下单失败Redis 里的库存不会自动加回这会导致数据库实际库存比 Redis 多活动结束后对不上账。我一般用两种方案处理一是直接让 Redis 库存承担“超卖风控”职责Redis 扣减成功但数据库事务失败时由事务的补偿逻辑对 Redis 的 key 做incr回补二是干脆不设置过期时间活动结束后写一个对账脚本按数据库实际库存校正 Redis。方案二更稳但需要多做一步清理。3.3 限流拦截器别让无意义的请求打到业务层预减库存挡掉了大部分库存不足的请求但活动刚开始那几秒即使库存充足也会有一批请求既不买商品也不是恶意攻击而是抢购软件在刷接口。所以秒杀系统还需要一层限流拦截器通常做两个维度一是单位时间内单 IP 的请求数上限二是单用户的对同一商品的重复请求拦截。Component public class MiaoshaRateLimitInterceptor implements HandlerInterceptor { Autowired private StringRedisTemplate redisTemplate; private static final int MAX_REQUESTS_PER_SECOND 5; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String userId request.getHeader(userId); String goodsId request.getParameter(goodsId); if (userId null || goodsId null) { response.setStatus(400); return false; } // 简单计数器key 为 ip:userId:goodsId窗口一秒 String key rate: request.getRemoteAddr() : userId : goodsId; Long count redisTemplate.opsForValue().increment(key); if (count ! null count 1) { redisTemplate.expire(key, 1, TimeUnit.SECONDS); } if (count ! null count MAX_REQUESTS_PER_SECOND) { response.setStatus(429); return false; } return true; } }这里用的是最简单的计数器限流够用但不够精确因为计数器算法在窗口切换瞬间会允许两倍于阈值的请求通过。如果活动价值高建议直接换成 Redisson 的RRateLimiter令牌桶或者 Guava 的RateLimiter做单机限流再加一层 Redis 分布式限流。我自己的项目里是单机限流和分布式限流同时启用单机限流拦掉同一个 JVM 内的重复刷分布式限流控制整体 QPS 不超过数据库能力的 70%。压测时你会发现限流参数是玄学调小了误伤正常用户调大了数据库还是会被打穿所以阈值必须根据压测数据反推不能拍脑袋定。4. RabbitMQ 异步下单把强一致变成最终一致削峰填谷的完整链路4.1 生产者预减库存成功后投递消息不落数据库Redis 预减成功还不能直接下单因为真正扣库存和生成订单的操作是重事务如果让用户请求同步等待事务完成接口响应时间会包含数据库锁等待时间体验很差。秒杀的正确姿势是Redis 预减成功之后立刻把“用户商品”信息封装成消息投递给 RabbitMQ接口直接返回“抢购成功正在排队处理订单”。用户收到成功提示后后台消费者异步完成真实扣库和订单落库。项目里常见的队列结构是这样的交换机direct路由键miaosha.order.create队列miaosha_order_queue消费者在另外一个服务或者同一个服务里独立部署。消息体要尽量精简只放必要字段避免把整个用户对象序列化进去毕竟 MQ 的消息要过磁盘的消息越大吞吐越低。public class MiaoshaMessage { private Long userId; private Long goodsId; private Long requestId; // 防重令牌唯一标识一次抢购 // getter/setter 省略 }生产者发送前需要配置confirm回调确保消息真正到达 Broker。没有确认机制的话Redis 库存扣了但消息丢了用户永远等不到订单这种事故在秒杀场景是致命的——你没法给用户交代“系统繁忙”之外的答案。4.2 消费者手动 ACK 与库存二次校验是必选项消费者拿到消息后开始执行数据库事务。但这里有个很多人忽略的问题消息从投递到消费之间是有延迟的可能消费者拿到消息时活动已经结束或者 Redis 预减成功的那批请求已经超过了数据库真实库存。因此消费者里必须再执行一次库存校验不能盲目信任 Redis 的结果。Component Slf4j public class MiaoshaOrderConsumer { Autowired private MiaoshaService miaoshaService; RabbitListener(queues miaosha_order_queue, ackMode MANUAL) public void onMessage(Channel channel, Payload MiaoshaMessage message, Header(AmqpHeaders.DELIVERY_TAG) long deliveryTag) throws IOException { try { // 消费者事务扣库存 建订单 miaoshaService.doMiaosha(message.getUserId(), message.getGoodsId()); // 成功手动确认 channel.basicAck(deliveryTag, false); } catch (MiaoshaException e) { // 业务失败比如库存不足直接确认丢弃不回补 Redis这里要配合补偿策略 channel.basicAck(deliveryTag, false); log.warn(消费秒杀订单失败, reason{}, userId{}, goodsId{}, e.getMessage(), message.getUserId(), message.getGoodsId()); } catch (Exception e) { // 未知异常不确认让 MQ 重投 log.error(消费秒杀订单异常, e); channel.basicNack(deliveryTag, false, true); } } }手动 ACK 是必须的因为默认的自动确认模式下消费者一旦从队列取出消息就视为已消费如果此时消费者宕机或者数据库事务失败消息就永久丢失了。手动 ACK 配合basicNack的requeuetrue才可能让消息重投。但注意不要把业务失败的消息也nack回去重投比如“库存不足”这种业务异常重投一百次也是一样的结果只会造成无效的数据库压力。正确的做法是把业务失败的消息也 ack 掉只对系统异常重投。4.3 消费幂等消息重投如何不产生重复订单RabbitMQ 的重投虽然能解决消息丢失但会带来重复消费的问题。消费者把消息取出后处理到一半挂了Broker 没收到 ACK消息会被重新投递到其他消费者或本消费者——此时上一次执行可能已经插入了订单只是没来得及 ACK。如果消费者直接再执行一次扣库存和插单库存会多扣一件订单表出现两条一模一样的记录。幂等方案在项目里用两层第一层是数据库的唯一索引这在第二章已经讲过了第二层是消费者在执行业务前先用requestId查 Redis 或数据库判断是否处理过。实际效果是唯一索引兜底Redis 标记避免无谓的数据库插入尝试。这里推荐的做法是把requestId也作为miaosha_order表的一个冗余字段加上唯一索引这样即使 Redis 数据被清掉数据库依然能挡住重复。5. 秒杀避坑五个最常见的翻车现场与排查路径5.1 现象库存显示已抢光但数据库还有货这个翻车场景特别经典前端一直提示“已售罄”后台一查stock_count还有很多。原因是 Redis 预减库存时扣得太狠——某个用户的请求预减成功了但 MQ 消息投递失败或者消费者事务回滚Redis 的库存没有同步回补。Redis 和数据库库存失去平衡且没有对账机制。解决消费者事务失败时必须把失败消息体里的goodsId收集起来异步回补 Redis 库存。回补动作必须保证幂等即同一个requestId只回补一次。我落地的方案是回补前先查 Redis 里是否存在“已消费”标记存在则跳过。另外每天凌晨跑一次对账脚本以数据库实际库存为准重建 Redis 的库存值双保险。5.2 现象同一个用户同一商品下单成功两次这个问题的隐蔽性很强表面看唯一索引已经加了但压测时依然出现了两条订单。排查下来是唯一索引建错了列——只给user_id建了普通索引没有把user_id和goods_id组合起来建唯一索引。另一个可能订单表拆了分表唯一索引被分到不同的物理表里数据库层的唯一约束失效。解决回到第二章的 DDL重新确认唯一索引的组合字段不要想当然。分表场景下唯一约束要靠 Redis 预减时加分布式锁锁粒度为userId:goodsId但分布式锁本身也有误删问题所以压测前的表结构审查不能省。5.3 现象Redis 突然连接不上秒杀接口直接 500活动进行中 Redis 挂了所有请求瞬间全部打到数据库数据库也扛不住整个服务雪崩。这种事故的原因多半是 Redis 单点部署且没有配置降级。秒杀场景对 Redis 的依赖很重但你不能假设 Redis 永不宕机。解决给 Redis 增加主从哨兵架构同时秒杀接口要配置降级开关——Redis 不可用时直接返回“活动太火爆请稍后重试”而不是抛异常让服务 500。注意这里的降级不是让请求落到数据库因为降级时数据库未必能扛住正确的降级策略是让用户稍后重试等 Redis 恢复后再参与秒杀。如果你觉得“稍后重试”太影响体验也可以降级到纯数据库模式但此时必须把限流阈值下调到数据库能承受的 20%这是活命策略。5.4 现象数据库连接池爆满Mysql 报 Too many connections连接池满往往不是单一方造成的而是事务范围过大、慢 SQL、以及连接池最大连接数配置过高三方面共同作用。秒杀项目里常见的问题是maxActive直接配了 200但数据库本身的max_connections只有 151应用还没打满数据库就先拒绝了。解决连接池参数必须按压测结果调。以 HikariCP 为例maximumPoolSize建议设置为数据库核数 * 2 磁盘IO等待系数但秒杀场景我一般控制在 30 到 50 之间锁竞争多的场景连接数大了反而时延更高。同时排查是否有慢查询拖长事务时间开启慢查询日志定位哪些 SQL 执行超过 500ms。秒杀项目里九成是MiaoshaOrderMapper.insert插入时并发插入同一索引页造成锁等待可以适当调大innodb_buffer_pool_size减少磁盘 IO。5.5 现象用户反馈“抢到了但一直没生成订单”消息队列积压了。秒杀开始时流量瞬间冲高MQ 消费者消费速度跟不上生产速度队列越积越长用户的订单迟迟不落库。常见原因消费者配置了prefetch1每次只取一条消息而每条消息处理包含一次事务提交的耗时消费能力被严重限制。解决把prefetch调整到 50 到 100让消费者每次拉取一批消息在本地处理。同时确认消费者线程数——RabbitListener的concurrency参数在秒杀场景下直接调到 10 到 20 是合理的但线程数增加会加大数据库并发必须同步校对连接池大小。另一个补救措施是对活动进行分批次开抢比如 10 万件库存拆成 10 场每场间隔五分钟给 MQ 充足的时间消化存量消息这也是运营侧最省心的消峰策略。6. 用 JMeter 验证秒杀系统压测参数与性能瓶颈定位6.1 压测场景与线程组设计项目上线前必须压测否则你根本不知道该给运营报多少库存。JMeter 用阶梯线程组模拟瞬间流量比普通线程组更贴近秒杀特征。我习惯把线程数设置在预估峰值的 1.5 倍比如预估 5000 人同时抢就压 7500 并发持续时间 60 秒让流量以 1 秒内全部注入的方式压。# JMeter 压测计划关键参数示例非脚本文件参数解释用 Threads (users): 7500 Ramp-up period: 1 Loop count: 1 HTTP Request: POST /miaosha/doMiaosha Parameters: goodsId1001userId${__Random(1,100000)}这里Ramp-up period1意味着 7500 个线程在 1 秒内全部启动真实的秒杀流量曲线比这个更陡所以 1 秒陡增已经足够暴露问题。别把 Ramp-up 拉到 60 秒那压出来的是匀速负载跟秒杀场景是两回事。6.2 三个核心指标怎么看压测结果里最值得盯的是三个TPS、平均响应时间、错误率。秒杀场景下 TPS 看峰值而不是平均值响应时间看 99 分位而不是平均值因为秒杀请求本来就是大量快速失败、少量成功平均值会被成功响应拉低错误率则要区分 HTTP 500 和业务返回“已抢光”——后者不是错误前者的比例必须控制在 0.1% 以内。如果 99 分位响应时间超过 800ms优先看是否卡在数据库行锁等待方法是进入 Mysql 执行SHOW ENGINE INNODB STATUS查看TRANSACTIONS段落的锁等待计数。如果锁等待高优先优化的是事务时间而不是加机器。6.3 从压测结果反推中间件参数压测之后你会拿到一组数据数据库事务吞吐上限、Redis 预减的 QPS 上限、MQ 消费速率上限。这三个数决定了整个系统的最大容量。容量规划公式建议这样算预估峰值流量 * 超卖风控系数(0.8) min(Redis 预减 QPS数据库事务吞吐上限)。如果压测结果显示数据库事务只有每秒 300 笔那么活动库存就不能超过 300 除以活动持续秒数或者你必须把 MQ 消费者扩容一倍。我习惯把每次压测的数据整理成一张表并发数、Redis 预减 TPS、数据库事务 TPS、MQ 积压数、接口 99 分位耗时、错误率。调整 2 到 3 组参数后再压一轮对比比如先调prefetch再调连接池最后调消费者线程数。这个方法论比盲目堆机器靠谱得多也方便你向团队解释为什么容量是这么定的。6.4 上线前最后一道检查清单秒杀系统上线前我会花半小时过一遍自己的固定检查项Redis key 的过期时间是否覆盖整个活动周期MQ 队列是否设置了死信队列消费失败的消息必须能追踪数据库唯一索引是否生效用SHOW INDEX FROM miaosha_order确认秒杀接口是否加了登录拦截和限流活动结束后是否有补偿任务把 MQ 积压的消息强制消费完毕。这里最容易被忽略的是死信队列——消费者反复重投失败的消息会被无限循环占满队列影响后续正常消息的消费。这套方案的落地经验我大概复用了三次每次都在避坑那一章里多记几条。秒杀系统没有完美方案只有“你清楚每个中间件在流量顶峰时怎么死、死之后怎么让用户体面地知道失败了”这一条准则。希望这篇笔记能帮你在自己的项目里少趟几个坑压测顺利上线不背锅。本文还有配套的精品资源点击获取
返回列表