SpringBoot+Redis+MyBatis高并发秒杀实战:防超卖架构设计
发布时间:2026/10/10 4:23:19 锦皓数字建站

简介一份面向Java后端开发者与高并发场景学习者的商品秒杀单体应用完整源码基于Spring Boot、Redis与MyBatis实现重点展示限流、Redis预减库存、订单生成等核心逻辑适合正在研究高并发与数据一致性、希望从单体架构切入分布式进阶的读者对照学习。资源共61个文件包含40个Java源文件、11个XML映射或配置、3个Properties属性文件并附有README说明和项目图片整体压缩包192KB工程结构包含源码、测试目录与构建配置可直接用Maven启动运行。目前已有63人学习下载项目中用户鉴权、商品展示、秒杀请求处理、订单处理、数据分析与系统监控等模块均有对应实现可帮助读者理解单体架构下如何通过Redis缓存库存、MyBatis操作数据库来应对高并发并结合实际代码掌握秒杀算法、接口限流与数据一致性保障思路。1. 秒杀项目凭什么能扛住高并发先说清这个单体应用在解决什么做商品秒杀最怕的不是库存少而是活动一开用户拼命点页面明明显示有库存下单却全失败后台日志里同时蹦出主键冲突、库存负数、连接超时。这套基于 SpringBoot Redis MyBatis 的商品秒杀单体应用解决的正是这个矛盾让 Redis 在前面拦流量让 MyBatis 在最后落库一台服务器也能扛住常规秒杀活动的峰值。我见过不少准备 java 面试题的读者把类似项目写进简历却说不清为什么加了分布式锁还会超卖也见过直接把数据库放在热路径上、压测时被瞬间打挂的真实案例。这篇文章按我实际做秒杀项目的顺序来写先定架构和数据模型再把主链路落地最后把超卖、重复下单、连接池被打满这些坑挨个拆掉。2. 为什么是 SpringBoot Redis MyBatis单体架构的选型边界与数据设计SpringBoot、Redis、MyBatis 这个组合在电商项目里常见得不能再常见但秒杀恰好是它们配合最紧密的场景。先给结论单体不背性能的锅真正背锅的是没有把正确的组件放到正确的位置上。本章先把“为什么够用”讲清楚再给你一套可以直接抄的表结构和 Redis Key 设计。2.1 单体到底能扛多大并发先定量级再谈架构经常有人一上来就问“单体能不能做秒杀”这问题本身就不成立应该问“你的秒杀活动峰值是多少”。常见的秒杀活动不是持续流量峰值通常集中在开场后前 10 秒到 30 秒。假设一场活动有 10 万人参与其中 2 万人在前 10 秒内抢完换算下来平均也就 2000 QPS 上下。这个量级对 Redis 来说毫无压力——Redis 单线程基于 IO 多路复用单实例每秒处理几万次简单读写很轻松真正的瓶颈在 MySQL如果每个请求都直接查库扣库存2000 QPS 能让数据库连接池立刻打满。所以单体架构做秒杀工程上最常见的做法是用 Redis 承接全部读与扣减流量把 MySQL 的访问量压到只剩“真正抢到的人”这一小撮。SpringBoot 在这里的角色是组织业务逻辑和提供接口MyBatis 只负责最终入库那一下。这样分工之后单台 Tomcat 配默认线程池也能支撑几千 QPS 的瞬时流量完全够一个小型电商活动用了。至于什么时候才需要拆微服务、上消息队列我的判断标准是Redis 单实例开始成为瓶颈或者同一套秒杀逻辑要部署到多个机房做异地多活。在那之前单体不仅便宜还特别好排查问题凌晨两点活动出故障时你不需要翻几十个服务日志。2.2 库表与 Redis Key 设计一张图看清数据流秒杀的数据模型比普通下单多一层“活动”概念。同一个商品可能参加多场活动每场活动的开始时间、结束时间、限购数量都不一样。我一般建三张表活动表、商品表、订单表。活动表与商品表分开是为了避免每个活动都要复制一份商品数据的尴尬。CREATE TABLE seckill_activity ( id bigint(20) NOT NULL AUTO_INCREMENT, goods_id bigint(20) NOT NULL COMMENT 活动关联的商品ID, activity_name varchar(128) NOT NULL, start_time datetime NOT NULL, end_time datetime NOT NULL, total_stock int(11) NOT NULL COMMENT 活动总库存, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0未开始 1进行中 2已结束, PRIMARY KEY (id), KEY idx_goods_id (goods_id) ) ENGINEInnoDB COMMENT秒杀活动表; CREATE TABLE seckill_goods ( id bigint(20) NOT NULL AUTO_INCREMENT, goods_name varchar(128) NOT NULL, stock int(11) NOT NULL COMMENT 当前剩余库存, version int(11) NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, PRIMARY KEY (id) ) ENGINEInnoDB COMMENT秒杀商品表; CREATE TABLE seckill_order ( id bigint(20) NOT NULL AUTO_INCREMENT, activity_id bigint(20) NOT NULL, goods_id bigint(20) NOT NULL, user_id bigint(20) NOT NULL, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_user_activity (user_id, activity_id), KEY idx_goods_id (goods_id) ) ENGINEInnoDB COMMENT秒杀订单表;注意订单表里的唯一索引uk_user_activity它的作用是在数据库层面兜底“同一用户同一场活动只能下一单”。库存字段我用int(11)够用到一天卖出几十亿单的场景不建议用smallint省那几个字节。顺序上我习惯先建活动表再建商品表先确定“卖什么、什么时候卖”才有后续的库存扣减。Redis Key 约定是这类项目最容易写出乱码的地方。核心 Key 就三个库存 Key 用seckill:stock:{activityId}:{goodsId}存剩余库存幂等 Key 用seckill:order:{activityId}:{userId}防止重复下单分布式锁 Key 用seckill:lock:{activityId}:{goodsId}。Key 中带上 activityId 和 goodsId 的双维度是为了支持同一商品同时跑多场活动。过期时间统一设置为活动结束时间加上 24 小时保证压测和后续对账时数据还在活动彻底结束后又不会长期占内存。2.3 拿到压缩包后先确认的五个文件从这类压缩包解压出来第一件事不是急着启动而是先确认工程能不能在你的环境里跑起来。常见工程结构大同小异核心就是pom.xml、application.yml、mapper目录、db脚本目录和src/main/java下的包结构。我把检查顺序固定成下面五步省得每次都被环境问题卡半小时。第一步看pom.xml里的 SpringBoot 父版本。2.x 时代用的是javax.servlet命名空间3.x 起整体切到jakarta.servlet如果你本地的 JDK 版本和项目依赖不匹配启动时会出现大量类找不到的报错。很多“springboot版本太高”导致的诡异问题本质就是 JDK 版本和 Boot 版本没有对应上SpringBoot 3.x 至少要 JDK 17。第二步看application.yml里 Redis 的 host、port、database 和密码本地连不上 Redis 八成是 database 配错或者密码没关。第三步看 MyBatis 的mapper-locations配置最常见的是classpath:mapper/*.xml如果 XML 文件放错目录启动时不报错一调接口就说 statement 不存在。第四步找db或sql目录下的建表脚本先执行脚本再启动应用否则 MyBatis 启动阶段感知不到表缺失真正写库时才抛异常。第五步看application.yml里有没有配server.servlet.context-path配了路径的话接口访问地址前面要带上这个前缀压测时最容易漏掉。3. 秒杀主链路落地库存预热、下单接口与订单落库的代码实现主链路就三条把库存从数据库搬到 Redis接口接收秒杀请求并在 Redis 里扣库存最后让抢到的人落库生成订单。本章直接贴可运行的代码每一段都说明这么写的理由和参数选择。3.1 库存预热别把数据库读请求放进热路径秒杀开始的瞬间所有请求都会来查库存。如果让每次查询都穿透到 MySQL几百并发就能把数据库打垮。常见的做法是在应用启动时把库存一次性加载到 Redis之后接口只读 Redis。Component public class StockPreheatRunner implements ApplicationRunner { Autowired private SeckillGoodsMapper goodsMapper; Autowired private StringRedisTemplate stringRedisTemplate; Override public void run(ApplicationArguments args) { ListSeckillGoods goodsList goodsMapper.selectAll(); for (SeckillGoods goods : goodsList) { // 预热库存TTL 设置为 2 天覆盖活动周期并留出对账时间 stringRedisTemplate.opsForValue().set( seckill:stock: goods.getId(), String.valueOf(goods.getStock()), 2, TimeUnit.DAYS ); } } }这里有个关键选择用StringRedisTemplate而不是RedisTemplate。原因很简单RedisTemplate默认使用 JDK 序列化写入 Redis 的 value 会带上一串二进制头不仅肉眼没法排查其他语言读取也会乱码。秒杀场景存的就是纯数字字符串用 StringRedisTemplate 省心得多。TTL 设置成 2 天而不是永久是为了避免活动结束后 Key 一直占着内存但也不能太短比如 10 分钟否则活动没结束库存 Key 就过期了后面所有请求都会落到数据库上。3.2 秒杀接口的三道闸门时间、幂等、预扣接口层的设计我习惯用三道闸门串起来每一道都只做一件事谁出问题都好定位。第一道闸门是活动时间校验第二道闸门是用户幂等校验第三道闸门才是真正扣库存。这样排序是有讲究的时间校验和幂等校验都不消耗库存只有最后一步才动 Redis 里的库存数字。RestController RequestMapping(/seckill) public class SeckillController { Autowired private SeckillService seckillService; PostMapping(/{activityId}/{goodsId}) public Result seckill(PathVariable Long activityId, PathVariable Long goodsId, RequestParam Long userId) { // 第一道闸门活动是否在进行中 if (!seckillService.isActivityOpen(activityId)) { return Result.error(活动未开始或已结束); } // 第二道闸门同一用户同一活动只能下单一次 if (seckillService.isDuplicateOrder(activityId, userId)) { return Result.error(您已经参与过该秒杀); } // 第三道闸门Redis 原子扣库存并发安全 SeckillResult result seckillService.deductStock(activityId, goodsId); if (!result.isSuccess()) { return Result.error(商品已被抢光); } // 扣减成功异步或同步落库 seckillService.createOrder(activityId, goodsId, userId); return Result.success(抢购成功); } }注意路径参数里 activityId 和 goodsId 都从 URL 来userId 从RequestParam来。这样的好处是接口天然适合压测——JMeter 里可以直接通过参数化把不同 userId 填进去。第三道闸门里的deductStock具体实现下一章专门讲这章先把它当黑匣子它返回成功时Redis 里的库存已经被扣掉了返回失败时说明库存已经为 0。秒杀接口的并发问题适合用“先成功后落库”的策略也就是先占住 Redis 库存再写 MySQL。反过来先写订单再扣库存的做法在秒杀场景里会导致大量无效的订单插入数据库白白挨打。异步落库的常见做法有两种一是直接用Async丢到线程池二是先写本地内存队列再慢慢消费。单体应用用前者就行注意线程池大小不要超过数据库连接池上限。3.3 MyBatis 落库两条 SQL 的顺序决定成败扣减 Redis 库存不代表订单一定成功最后一步必须把订单写进 MySQL同时把数据库里的真实库存也减掉。这里两条 SQL 的顺序是很多新人翻车的地方先写订单还是先扣库存会直接影响死锁概率。Transactional(rollbackFor Exception.class) public void createOrder(Long activityId, Long goodsId, Long userId) { // 第一步条件更新库存受影响行数为 0 说明库存已被抢完 int rows goodsMapper.decreaseStock(goodsId); if (rows 0) { throw new BizException(库存不足); } // 第二步插入订单记录 SeckillOrder order new SeckillOrder(); order.setActivityId(activityId); order.setGoodsId(goodsId); order.setUserId(userId); orderMapper.insert(order); }对应的 MyBatis Mapper XML 里扣库存的 SQL 必须带库存大于零的条件这是数据库层的最后一道防线update iddecreaseStock UPDATE seckill_goods SET stock stock - 1, version version 1 WHERE id #{goodsId} AND stock 0 /update这里的逻辑是UPDATE语句的行锁保证同一个商品在同一时刻只有一个事务能扣成功stock 0条件保证永远不会扣成负数。rows 0时抛异常触发事务回滚之前的订单插入也会一起回滚不会出现“扣了库存但没订单”的脏数据。事务其实不用太长事务范围越长持锁时间越久死锁概率越高。createOrder方法里只有两次数据库操作事务范围控制在这两个操作之外不要在里面加 Redis 调用Redis 操作不管成功失败都不应该回滚 MySQL 事务。这也是把库存预扣放在 Redis、把最终扣减放在 MySQL 的原因——Redis 可以接受短暂不一致MySQL 必须强一致。4. Redis 分布式锁与防超卖从 SETNX 到 Lua 脚本的正确姿势防超卖是整个秒杀项目里被问得最多的技术点也是 redis 面试题里的高频考点。很多初学方案上来就 setIfAbsent 加锁却不知道锁本身的坑比超卖还多。本章从最原始的 synchronized 开始一路拆到 Lua 脚本说清楚什么场景该用什么。4.1 单体应用里的 synchronized 为什么不够秒杀项目放在单台服务器上时不少人的第一反应是用synchronized锁住扣库存方法认为这样才能防止并发扣成负数。但synchronized锁的是同一个 JVM 里的多个线程它保护的也是 Java 代码里的临界区而秒杀的库存现在放在 Redis 里扣减走的是网络请求synchronized根本管不到 Redis 那边。加上单体应用早晚要部署多个实例来做容灾一旦变成两台机器synchronized就彻底失效。另一个隐藏问题是吞吐量synchronized会让所有请求串行进入方法即使 Redis 本身能扛每秒上万的扣减操作串行化之后也只能跑出单线程的吞吐量。秒杀场景真正想要的不是“锁住所有请求”而是“让正确的请求快速通过、让抢不到的请求快速失败”。所以结论是单体应用做秒杀主要并发控制手段不应该依赖 JVM 锁而应该依赖 Redis 单线程执行的原子操作。4.2 SETNX 手写锁的三个坑误删、过期、超时网上大量教程会让你用setIfAbsent写一把简单分布式锁代码看起来天衣无缝实际跑起来全是雷。// 简化写法仅用于展示问题 Boolean locked stringRedisTemplate.opsForValue() .setIfAbsent(seckill:lock: goodsId, 1, 10, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { try { // 业务逻辑 deductStock(goodsId); } finally { // 问题就藏在这一行 stringRedisTemplate.delete(seckill:lock: goodsId); } }第一个坑是误删别人的锁。假设线程 A 拿到锁后业务执行超过了 10 秒锁自动过期线程 B 拿到新锁此时 A 执行完finally里直接delete把 B 的锁删掉了B 的临界区立刻失去保护。第二个坑是过期时间设多少完全靠猜设短了上面的情况频繁发生设长了如果服务宕机锁会一直占着直到过期活动期间所有请求全部被挡。第三个坑是 “判断锁是不是自己的” 和 “删除锁” 不是原子操作即使你改成先 get 再 delete两步之间锁依然可能过期或被其他线程拿到。解决这三个坑的常见做法是换成 Redisson 的getLock它的看门狗机制会自动续期业务没执行完不会因为锁过期而失去保护而且底层释放锁用的是 Lua 脚本把“校验持有者”和“删除”合并成一个原子操作。如果项目不想引入 Redisson折中方案是给锁设置一个足够长的过期时间并把 owner 标识放进 value释放前先比较 value 再执行 Lua 删除。但说实话秒杀库存扣减本身用不上分布式锁真正需要锁的地方是少数下一节拆开讲。4.3 更优雅的做法用 Lua 脚本把“查库存、扣库存”做成一步秒杀接口最核心的诉求不是互斥而是原子性。判断库存是否充足、扣减库存、返回结果这三个动作必须作为一个整体执行中间不能被其他请求打断。Redis 的 Lua 脚本天然满足这个要求脚本在单线程的 Redis 里执行执行期间其他命令全部排队。-- 扣减库存脚本 -- KEYS[1]库存 Key形如 seckill:stock:1001:2001 -- 返回值-1 表示 Key 不存在0 表示已售罄1 表示扣减成功 if (redis.call(exists, KEYS[1]) 0) then return -1 end local stock tonumber(redis.call(get, KEYS[1])) if (stock 0) then redis.call(set, KEYS[1], 0) return 0 end redis.call(decr, KEYS[1]) return 1这段脚本先查 Key 是否存在不存在返回 -1 让上层感知到库存没预热存在但库存不大于零则把 Key 重置为 “0” 再返回 0避免负数残留库存大于零则执行 DECR返回 1 表示成功。三个分支覆盖了所有并发情况不存在“两个请求同时读到库存为 1然后都扣减成功”的可能。SpringBoot 里调用这段脚本也很直接private static final String STOCK_SCRIPT if (redis.call(exists, KEYS[1]) 0) then return -1 end local stock tonumber(redis.call(get, KEYS[1])) if (stock 0) then redis.call(set, KEYS[1], 0) return 0 end redis.call(decr, KEYS[1]) return 1; public SeckillResult deductStock(Long activityId, Long goodsId) { DefaultRedisScriptLong script new DefaultRedisScript(); script.setScriptText(STOCK_SCRIPT); script.setResultType(Long.class); String stockKey seckill:stock: activityId : goodsId; Long result stringRedisTemplate.execute(script, Collections.singletonList(stockKey)); if (result null || result 0) { return SeckillResult.fail(库存未初始化); } if (result 0) { return SeckillResult.fail(已售罄); } return SeckillResult.success(); }DefaultRedisScript需要显式声明返回类型为Long否则 Redis 返回的整数会被当成字符串处理比较结果时类型不一致。execute方法第二个参数传的是 Key 列表这里故意只传一个 Key是为了在 Redis 集群模式下保证脚本涉及的 Key 都在同一个槽位避免 CROSSSLOT 错误。实际项目中我建议把这段脚本写成静态常量放到单独类里方便其他模块复用也方便压测时单独对它做基准测试。4.4 分布式锁真正该用在哪儿加锁粒度与场景判断上一节把库存扣减变成 Lua 脚本后分布式锁在秒杀主链路里的角色其实已经非常边缘了。原因很简单库存扣减的原子性由 Redis 单线程和 Lua 保证不再需要互斥锁。真正需要分布式锁的是两个场景一是用户重复提交时并发创建同一笔订单二是活动结束后补偿任务和用户下单链路同时操作订单表。对于前者幂等校验就是用setIfAbsent做一个标记 Key例如seckill:order:1001:8888成功插入标记的请求才能继续下单其余的直接返回“已参与秒杀”。这个 Key 的 TTL 设置成活动时长加上对账窗口一般 24 小时足够。这里用分布式锁来控制同一用户的重复提交粒度是用户维度而不是全局商品维度并发量小得多也不会拖慢主链路。给一个简单的方案对比表方便你直接判断自己的场景该选哪个方案原子性自动续期吞吐影响适用场景synchronized仅单机 JVM 内有效无串行化吞吐最低单体应用内保护本地资源SETNX 手写锁加锁本身原子释放有坑无中等低频后台任务能接受手动管理Redisson 分布式锁加锁释放均原子有看门狗略高于 SETNX跨实例订单幂等、后台对账任务Lua 扣库存脚本完全原子不涉及锁最高单线程天然串行秒杀库存扣减主链路Lua 脚本把并发控制的复杂度移到了 Redis 内部业务代码只需要关心返回的三种状态。这样设计以后接口层几乎看不到锁的痕迹出问题时也只需要盯着脚本和 Key 的状态排查成本比手写锁低一个量级。5. 秒杀项目避坑实录5 个最常见的翻车现场与排查流程秒杀项目的坑集中在并发和缓存一致性上很多问题不压测根本暴露不出来。本章整理 5 个我在类似项目里反复见到的翻车现场每条都按“现象 → 原因 → 解决”写你可以直接当排查手册用。5.1 库存扣成负数超卖压测刚开始几秒数据库里商品的库存字段就变成了负数。原因是代码里用的是先查询库存、判断大于零、再执行扣减的三步操作并发请求同时读到库存为 1各自认为可以扣最终扣出负数。解决方法是数据库扣减 SQL 强制带上stock 0条件并在 Redis 层使用 Lua 脚本原子扣减两层双保险。排查时直接看seckill_goods.stock是否小于 0再用订单总数减去初始库存如果差值大于库存总量说明问题已经发生。5.2 Redis 里显示有库存用户却下单失败活动进行中Redis 里库存还剩 100接口却一直返回“已售罄”。原因往往是 MyBatis 的二级缓存开启后订单写入成功但缓存里的商品库存没有同步失效后续请求读到了旧数据。也可能是应用自己加了 JVM 层缓存把热点商品的库存缓存了几分钟。解决方法是排查 MyBatis 配置里cache-enabled是否为 true秒杀相关 Mapper 不建议开二级缓存同时清理应用中的本地缓存只用 Redis 作为库存的唯一读源。还有一种玄学情况是库存 Key 在活动过程中触发了过期导致所有请求走到 Lua 脚本的return -1分支表现为“有库存但抢不到”排查时用TTL seckill:stock:{activityId}:{goodsId}看一眼剩余时间即可定位。5.3 活动开始瞬间 Redis 连接被拒绝接口大量超时秒杀开始那几秒Redis 日志里出现ERR max number of clients reached应用日志全是连接超时。原因是 Redis 默认最大连接数 10000而应用连接池配置得过大比如max-active500多个服务实例叠加后瞬间打满。另一个隐藏原因是 SpringBoot 2.x 到 3.x 的配置项前缀变了2.x 用spring.redis.jedis.pool.max-active3.x 用spring.data.redis.jedis.pool.max-active版本升级后没有同步迁移配置连接池实际没生效。解决方法是把连接池max-active控制在 50 到 100 之间Redis 端的maxclients配到 20000同时给接口层加上流量限制最常见的做法是单个用户 ID 在 1 秒内的请求数不超过 2 次。5.4 同一个用户刷出多笔订单活动结束后对账发现同一个 userId 有多笔订单。原因是没有做用户维度的幂等校验或者校验逻辑放在了扣库存之后导致用户疯狂点击时每个请求都能扣到库存再落库。解决方法是把“用户-活动”维度唯一索引加在订单表上数据库层面直接拒绝重复插入再在接口层用 Redis setnx 做第一道拦截。最容易被忽略的是 MVC 的 Controller 层重复进入比如前端同时提交表单和触发 Ajax两个请求间隔不到 50 毫秒Redis 里的幂等标记都还没来得及写入所以幂等标记的 TTL 要给足不要用默认的 1 分钟。5.5 MySQL 死锁日志刷屏下单接口大面积失败错误日志里出现Deadlock found when trying to get lock订单表插入和商品库存更新交织在一起。原因是事务里两条 SQL 的执行顺序不统一有的线程先更新商品表再插入订单表有的线程先插入订单表再更新商品表两个事务互相持有对方需要的行锁InnoDB 只能回滚其中一个。解决方法是把事务内的 SQL 顺序固定为“先扣库存、再写订单”并且同一商品的所有请求都走同一把行锁相当于把冲突收敛到一行。排查死锁最直接的办法是在 MySQL 执行SHOW ENGINE INNODB STATUS看 LATEST DETECTED DEADLOCK 部分它会明确显示是哪两条 SQL 互锁。这里我一般还会额外查一下连接池是否耗尽死锁回滚让连接占用时间变长连接池一旦满整个应用会被拖死。6. 压测验证这套秒杀能不能上生产jmeter 场景配置与三个观察指标代码跑通只是第一步真正检验秒杀方案的是压测。没有经过压测验证的秒杀项目上线那天就是翻车那天。本章给一套最小可用的压测方案重点不是压出多高的数字而是验证三件事有没有超卖、库存相不相符、接口有没有大面积报错。压测工具我用 jmeter 的情况比较多因为它免安装、脚本可视化。配置线程组时把并发数设为 1000Ramp-Up 设为 5 秒循环次数 1这个配置模拟“开场瞬间 1000 人涌入”的经典场景。HTTP 请求指向POST /seckill/{activityId}/{goodsId}参数里userId用 jmeter 的函数助手生成随机数保证每个线程模拟不同用户。完成后重点看聚合报告里的异常率和响应时间异常率必须为 099 线响应时间低于 1 秒。如果嫌 jmeter 太重也可以用 wrk 加 Lua 脚本快速验证wrk -t8 -c500 -d30s -s seckill.lua http://localhost:8080/seckill/1001/2001压测时盯住三个观察指标就够了第一是 Redis 的INFO stats里的instantaneous_ops_per_sec看它是否接近预期 QPS如果 Redis 侧 QPS 很高而应用侧吞吐上不去瓶颈在 Tomcat 线程池第二是 MySQL 的慢查询日志正常情况下一场压测下来不应该出现超过 100ms 的 SQL如果出现基本可以断定是某个请求穿透到数据库做了全表查询第三是应用日志里扣减失败或回滚的次数这个数字应该只来源于“库存已售罄”的业务分支而不是系统异常。压测结束后做一次对账这是验证有没有超卖的关键操作SELECT (SELECT COUNT(*) FROM seckill_order WHERE goods_id 1001) AS ordered_count, (SELECT stock FROM seckill_goods WHERE id 1001) AS remain_stock;如果ordered_count remain_stock等于活动初始库存说明 Redis 预扣和 MySQL 落库是一致的如果不相等差额就是超卖或缺卖的数量。我第一次做这个验证时发现库存扣减先走了数据库再回写 Redis1000 并发直接打穿了连接池后来改成 Redis 先预扣、MySQL 条件更新兜底才把对账结果跑平。这套单体方案还有一个隐藏收益压测暴露出来的连接池参数和 SQL 顺序问题几乎都是换到任何架构都会遇到的通用问题你在这里踩过的坑做微服务也一样要踩一遍。希望这次压测通关之后你能大胆地把这套秒杀项目写进简历也能在面试官追问“Redis 挂了怎么办”时给出数据库兜底和缓存预热补偿的完整回答。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。