ARTICLE DETAIL

资讯详情

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

基于Spring Boot+Redis+MyBatis的单体秒杀系统设计与防超卖实践

基于Spring Boot+Redis+MyBatis的单体秒杀系统设计与防超卖实践 简介高并发场景下秒杀系统的核心挑战在于有限的库存与海量请求之间的矛盾而数据库的并发写能力往往是瓶颈所在。通过引入Redis作为前置缓存层利用其原子操作完成库存预减与接口限流可拦截绝大部分无效请求再结合数据库乐观锁与唯一索引确保库存扣减的准确性和订单幂等性从而在单体架构下即可实现高效、可靠的秒杀服务。这种方案不仅适用于中小型抢购活动也为理解缓存、限流、防超卖等经典技术提供了完整实践路径。本文围绕Spring Boot、Redis与MyBatis的组合从表结构设计、核心流程、缓存一致性到接口防护与压测排坑系统拆解一套可落地的秒杀单体应用帮助开发者掌握应对高并发写入的实用方法论。 秒杀这种场景很多人的第一反应是上消息队列、Redis Cluster、分库分表恨不得把微服务全家桶一次性搬上去。但实际落地时绝大多数项目的并发量根本撑不起这套架构反而是把单体应用的每一层做扎实先顶住常规流量再谈扩展更有意义。这个基于 Spring Boot Redis MyBatis 的商品秒杀单体应用就是一套很典型、很务实的解法。它解决的问题非常明确秒杀场景下同一件商品会被大量用户同时抢购最大的技术挑战是库存有限而请求量巨大稍有不慎就会出现超卖、重复下单、接口被刷爆这类事故。Spring Boot 负责搭起整个接口框架Redis 承担高并发下的库存预减和接口限流MyBatis 负责最终的数据库落库与事务控制。三者配合在单体架构下就能做到大多数中小型秒杀活动的技术指标。这套项目很值得两类人参考一类是正在准备 Java 后端面试、需要完整理解秒杀业务链路的开发者另一类是手头有真实抢购需求但架构预算有限想先用单体方案扛住压力的团队。下面我直接按项目真正的搭建顺序来拆解从表结构到核心接口从防超卖到压测排坑把这份代码的方方面面讲透。1. 秒杀项目为什么选这套技术栈而不是直接怼高并发中间件1.1 单体应用做秒杀很多人第一步就判断错了我在很多技术群里见过类似的讨论一聊到秒杀就有人说“必须用 MQ 削峰”“必须上分库分表”“必须微服务化”。实际上秒杀系统的瓶颈并不在应用服务器而在数据库的并发写能力。单体应用只要把库存扣减这个关键路径上的压力分流出去完全能支撑每秒几千请求的抢购活动。这个项目之所以选 Spring Boot Redis MyBatis 的组合核心原因有三点。第一Spring Boot 足够轻量内嵌 Tomcat 加上自动配置不需要额外部署容器而且对事务、定时任务、拦截器这些能力都有很好的支持。秒杀业务真正需要的依赖很少没必要为了一个抢购接口把注册中心、配置中心、网关全部拉进来单体反而是成本最低且最容易维护的方案。第二Redis 是整个项目的关键。秒杀场景里 90% 以上的请求其实都到不了数据库它们在前置阶段就被库存判断和限流拦截了。把库存预减放到 Redis 里做利用它的原子自减操作可以在毫秒级判断出是否还有库存只有少量真正抢到的请求才去请求数据库。第三MyBatis 作为持久层框架胜在 SQL 可控。库存扣减这条 SQL 必须精确到行级更新MyBatis 贴着自己写 SQL对于 where 条件的控制非常明确这种场景比 JPA 之类的全自动框架更直白。加上 MyBatis 的一级缓存、二级缓存机制在特定场景下还能再省一层数据库压力。1.2 各层之间怎么协作一次请求的生命周期用这套技术栈搭出来的单体应用一次完整的秒杀请求会走完这么一条链路请求先到 Spring Boot 的 Controller 层这里会做参数校验、用户身份识别。然后进入 Service 层先查 Redis 判断活动是否在有效期内商品缓存是否存在。Service 调用 Redis 的原子递减操作预扣库存如果扣成功进入下单流程。下单时MyBatis 执行对商品库存表的更新用乐观锁保证扣减的准确性。订单插入数据库后异步清理 Redis 中的用户已购标记返回成功信息。整个过程中Redis 挡住了大量无效请求数据库只需要处理真正抢到的下单请求这样压力就在可控范围内。1.3 项目包结构和核心实体拿到这个 zip 项目后你会看到典型的 Maven 结构。com.example.seckill 作为根包下面按照 controller、service、mapper、pojo、config、common 这些层级划分。实际开发中我建议再加一个 interceptor 包来放限流和登录校验的拦截器加一个 exception 包做全局异常处理这样代码职责会更清晰。核心的实体类就三个Goods商品、SeckillGoods秒杀活动商品、SeckillOrder秒杀订单。SeckillGoods 和 Goods 之间是一对一关系SeckillGoods 里存秒杀价、秒杀库存、活动起止时间。我见过很多项目在这里偷懒直接在 Goods 表上加秒杀字段这样做的后果是商品管理和秒杀活动管理耦合在一起活动配置极为别扭建议还是拆开。2. 商品与订单的表结构设计以及索引怎么建2.1 三张核心表的字段与含义表设计决定了整个秒杀逻辑能否顺利落地。这个项目里最常见的表结构是这样的goods 表id商品主键goods_name商品名称goods_price商品原价stock总库存create_time、update_timeseckill_goods 表id秒杀记录主键goods_id关联商品 IDseckill_price秒杀价格stock_count秒杀库存这个字段是关键start_time活动开始时间end_time活动结束时间version乐观锁版本号seckill_order 表id订单主键user_id用户 IDorder_id订单号goods_id商品 IDseckill_id关联 seckill_goods 的 ID这里面最需要注意的是 seckill_goods 的 stock_count 字段它只代表秒杀活动剩余的库存而 goods 表里的 stock 是商品总库存。秒杀开始前需要把秒杀库存从总库存里锁定出来。换句话说goods.stock 减去 seckill_goods.stock_count 就是被活动占用的库存这样设计方便活动结束后的库存回滚。2.2 唯一索引是防超卖的最后一堵墙秒杀系统的头号问题就是超卖。很多人的第一反应是“用乐观锁”也有人说“用 Redis 原子操作”但我要强调数据库层面的唯一约束才是防超卖的最后一道硬门槛。在 seckill_order 表上必须给 user_id 和 seckill_id 加上联合唯一索引。为什么因为高并发下即使 Redis 预减库存已经拦截了一部分请求仍然可能出现多个请求同时进入落库阶段同一个用户短时间内连点两次提交或是分布式环境下两个实例同时处理。这时候唯一索引能保证同一个用户对同一场秒杀活动只会生成一条订单记录第二次插入直接报 DuplicateKeyException从物理上堵死重复下单。没有这个唯一索引前面做得再好都可能出漏子。我在实际项目中就遇到过Redis 扣了库存数据库也更新了库存但因为用户瞬间重试程序在事务提交前没拦住重复请求最后产生了两个订单而库存只减了一次。后来加了唯一索引这个坑就彻底没了。2.3 乐观锁版本号字段让 update 自己判断乐观锁的经典做法是在 seckill_goods 表上增加 version 字段每次更新库存时同时更新版本号。对应的 MyBatis 更新语句长这样update idreduceStockByVersion UPDATE seckill_goods SET stock_count stock_count - 1, version version 1 WHERE seckill_id #{seckillId} AND version #{version} AND stock_count 0 /update这条 SQL 的巧妙之处在于把库存判断和版本判断融合进了 update 语句的 where 条件。如果执行后返回的影响行数为 0说明版本号不匹配或者库存已经为 0此时事务回滚用户就抢不到了。用这种方式数据库行锁的持有时间被压缩到最短比用 select update 的方式在并发性能上要好很多。3. 核心秒杀逻辑Redis 预减库存 MyBatis 落库的完整流程3.1 最朴素的实现以及它为什么必然崩溃先说一个反面例子。很多人第一次写秒杀接口时是在 Service 方法里直接查数据库库存判断是否充足然后插入订单更新库存。伪代码是这样的Transactional public SeckillOrder seckill(Long userId, Long seckillId) { SeckillGoods goods seckillGoodsMapper.selectById(seckillId); if (goods.getStockCount() 0) { throw new RuntimeException(已售罄); } SeckillOrder order new SeckillOrder(); // 构建订单、插入订单表 seckillOrderMapper.insert(order); // 扣库存 goods.setStockCount(goods.getStockCount() - 1); seckillGoodsMapper.updateById(goods); return order; }这种写法在测试环境没有任何问题但一旦并发上来就会出两件事。第一库存判断和库存扣减不是原子的A 请求查到库存还剩 1B 请求也查到库存还剩 1两个请求都进入下单流程最后库存变成了 -1这就是超卖。第二select 查询锁不住行数据数据库连接池很快被打满接口响应时间飙升。所以真正的秒杀核心流程必须把库存判断这一步放在 Redis 层利用 Redis 单线程模型和原子操作来挡住绝大多数无效请求。3.2 Redis 原子操作预减库存Redis 的 DECR 命令是原子性的在高并发下不需要加锁就能保证递减操作的正确性。核心逻辑如下public boolean preReduceStock(Long seckillId) { String stockKey seckill:stock: seckillId; Long stock redisTemplate.opsForValue().decrement(stockKey); if (stock ! null stock 0) { return true; } // 库存不足需要回补 redisTemplate.opsForValue().increment(stockKey); return false; }这里有一个很关键的细节当 decrement 返回负数时必须立刻 increment 回补。如果不回补当前请求虽然被判定为失败但库存已经被扣成了负数后续所有请求都会直接判定无货实际却又一部分库存无人购买浪费掉了。还要注意的是不要把 Redis 的库存作为唯一依据。Redis 里的数值必须在活动开始前从数据库加载并且在扣减成功后要立即发起数据库的库存更新。这两步之间的时间窗口很短但因为有乐观锁兜底并不会出现超卖。我在这套逻辑里还会做一个细微的调整预减库存之前先判断活动时间。如果还没到开始时间或者已经结束直接返回失败不在 Redis 上做无效的递减操作这样可以减少 Redis 的写压力。3.3 落库与下单事务边界怎么控制预减库存成功后Service 层进入真正的下单逻辑。这一步我建议拆成两个事务先做库存扣减再做订单插入而不是把商品查询、库存判断、订单插入全塞在一个大事务里。大事务的弊端是持有数据库行锁的时间过长高峰期会拖垮数据库。实际项目中库存扣减和订单插入放在同一个事务里是合理的因为库存扣减影响行数如果为 0整个事务必须回滚不能留下孤儿订单。代码结构大概是这样的Transactional(rollbackFor Exception.class) public SeckillOrder doSeckill(Long userId, Long seckillId, Long goodsId) { // 1. 乐观锁扣减库存 int rows seckillGoodsMapper.reduceStockByVersion(seckillId, version); if (rows 0) { throw new SeckillException(手慢了商品已抢完); } // 2. 创建订单 SeckillOrder order SeckillOrder.builder() .userId(userId) .goodsId(goodsId) .seckillId(seckillId) .orderId(generateOrderId()) .build(); seckillOrderMapper.insert(order); return order; }注意这里的事务只包裹了数据库操作Redis 的预减操作在进入这个事务之前就已经完成了。因为 Redis 的操作不是数据库事务能管的如果 Redis 预减成功了而数据库事务失败需要手动把 Redis 库存加回去这个过程在 catch 块里处理。3.4 生成唯一订单号别用自增主键当订单号秒杀订单号在用户端是敏感信息不能直接暴露自增主键。我推荐用一个简单的雪花算法或者基于 Redis 的 INCR 生成有序 ID。这个项目里常见的做法是时间戳加用户 ID 加随机数拼接出一个 19 位左右的订单号既能保证唯一又不容易被猜测。如果你用 Redis可以这么生成String orderId String.format(%d%06d, System.currentTimeMillis(), redisTemplate.opsForValue().increment(seckill:order:id));用 INCR 生成的序列是递增的配合时间戳前缀业务上也好排查问题。当然对于追求极致分布式唯一键的场景还是要上雪花算法但单体应用里这种方式已经足够了。4. 缓存与数据库的一致性以及缓存穿透的处理4.1 秒杀商品的缓存预热秒杀活动开始前需要从数据库把秒杀商品的库存加载到 Redis。这个动作叫做缓存预热。如果放到用户请求来了再查数据库加载第一波请求就会全部打到数据库上Redis 就没有起到挡流量的作用。预热的方式可以是在项目启动时用 ApplicationRunner 加载也可以做一个管理接口活动开始前手动触发。预热的 key 设计很关键推荐格式为seckill:stock:1 对应的值是秒杀库存数量seckill:detail:1 对应的值是商品的 JSON 详情这个项目里使用的是 StringRedisTemplate 还是 RedisTemplate 会影响存储格式。如果用 RedisTemplate 且没有指定序列化器默认走 JDK 序列化在 Redis Desktop Manager 里看到的是一串乱码非常影响排查。建议统一使用 GenericJackson2JsonRedisSerializer或者用 StringRedisTemplate 手动把对象序列化成 JSON 字符串再写入读取时再反序列化回来。4.2 删除缓存而不是更新缓存为什么秒杀开始后Redis 中的库存和数据库的库存会有一段短暂的差异期。比如 Redis 预减了 10 个库存数据库可能才更新了 5 个。那 Redis 中的缓存应该实时更新吗我的建议是库存在秒杀场景中不要走“更新缓存”这条路。正确的做法是数据库扣减成功后删除对应的商品详情缓存让下一次请求重新查库并回填。而库存余量这个 key走的是预减逻辑它本身就是被主动 DECR 的不需要再通过数据库同步更新否则会出现数值混乱。删除缓存这个动作看似简单但要注意顺序。先更新数据库再删除缓存如果删除失败了缓存里就会一直存着旧数据。解决方案有两个一个是定期对热点 key 做过期处理另一个是引入可靠的延迟双删策略先删缓存再更新数据库隔几百毫秒再删一次缓存。单体应用里用最简单的定时任务兜底就够了没必要把消息队列引进来。4.3 缓存穿透的布隆过滤器方案秒杀系统还有一个高并发下的麻烦问题缓存穿透。攻击者或者异常请求会拿一个不存在的商品 ID 反复请求每次都会落到数据库上数据库会被无效查询拖垮。推荐的方式是在项目启动时加载所有秒杀商品的 ID 到布隆过滤器请求进来时先经过布隆过滤器判断如果商品 ID 不存在直接返回“商品不存在”。因为布隆过滤器的查询是 O(1) 的内存占用也小对于秒杀这种商品数量有限且固定的场景非常合适。没有布隆过滤器时退而求其次的做法是缓存空对象。也就是在数据库查询返回 null 时仍然在 Redis 里放一个标记为空的缓存过期时间设为 30 秒这样同一商品的反复请求就不会再打到数据库。4.4 过期时间的打散与缓存雪崩预热时如果所有秒杀商品的缓存过期时间都设置成同一个值极端情况下它们会同时过期数据库会瞬间被打满。这个问题的解决办法很简单在基础过期时间上加上一个随机数。比如每个商品的缓存过期时间设置为 30 到 60 分钟之间随机这样大批商品的过期时间会自然错开数据库的压力也就被平摊了。同时把秒杀商品详情设置为永不过期由后台主动更新再配合前面说的定时任务兜底应对普通抢购活动是足够的。5. 接口层三板斧限流、防重复、防刷5.1 基于 Redis 的计数器限流Redis 不仅管库存还管接口流量。在 Spring Boot 里可以基于 Redis 的 INCR 命令实现一个简单的固定窗口限流器。每来一个请求对某个 key 做 INCR并设置过期时间当计数达到阈值时直接拒绝后续请求。实现代码如下public boolean tryAcquire(String key, int limit, long expireSeconds) { Long count redisTemplate.opsForValue().increment(key); if (count ! null count 1) { redisTemplate.expire(key, expireSeconds, TimeUnit.SECONDS); } return count ! null count limit; }这个逻辑可以放在拦截器里以用户 ID 或者 IP 为维度进行限流。比如每个用户每秒最多请求 5 次超过就返回“请求过于频繁”。固定窗口限流有一个边界问题窗口临界点时可能出现短时间的请求翻倍但对于单体秒杀应用来说这个误差完全可以接受。如果要求更平滑的限流效果可以换成令牌桶算法项目里用 Redis Lua 脚本就可以实现。5.2 用户维度防重复秒杀秒杀业务还有一个很常见的现象同一用户狂点按钮一个活动只允许购买一件商品但请求发出了很多次。Redis 预减库存阶段没法区分同一个用户是否已经买过因为预减是针对库存的用户维度的状态需要单独处理。做法是在用户第一次成功抢到后设置一个标记位Boolean success redisTemplate.opsForValue() .setIfAbsent(seckill:user: seckillId : userId, 1, 10, TimeUnit.MINUTES); if (Boolean.FALSE.equals(success)) { throw new SeckillException(你已经参与过该活动); }setIfAbsent 这个操作对应 Redis 的 SET NX只有 key 不存在时才会设置成功。利用这个特性可以天然地实现用户维度的防重。这个标记的过期时间要设置得比活动时间长一点通常建议活动结束后再保留 10 分钟避免活动刚结束就允许用户再次下单。别觉得这个逻辑和数据库唯一索引重复了。Redis 标记位拦住的是绝大多数提前到达的重复请求数据库唯一索引在极端并发下兜底两道防线各有各的价值。5.3 隐藏秒杀地址防脚本不少秒杀系统会被脚本刷单脚本直接绕过前端页面请求秒杀接口。单体应用里最实用的办法是引入动态地址。秒杀接口的真实路径不固定用户需要先请求一个获取秒杀地址的接口服务端生成一个带随机数的秒杀 token然后拼接到秒杀接口路径上。每次请求经过拦截器校验这个 token 是否存在且未过期校验通过才放行。比如秒杀地址设计为 /seckill/{path}/doSeckill用户先访问 /seckill/path 获取动态 path这个 path 是在 Redis 里保存的一份随机的字符串过期时间很短。这样脚本无法预先知道真正的秒杀地址能挡住大部分低水平的刷单。同时接口路径上再配合用户 ID 和商品 ID 的绑定校验基本可以保证请求是真实用户发起的。6. 压测数据与踩坑记录6.1 我在 JMeter 下看到的真实效果我用 JMeter 对这套单体应用做了压测机器配置是 4 核 8G单实例部署。模拟 1000 个用户同时抢购库存为 500 的商品对比了裸实现和完整优化后的实现。裸实现的接口 QPS 大概在 800 左右错误率超过 20%出现了超卖现象数据库连接池被打满接口响应时间普遍超过 3 秒。而采用 Redis 预减库存、乐观锁扣库存、Redis 限流、唯一索引防重之后的版本接口 QPS 到了 3200 左右错误率在 0.3% 以内无超卖数据库连接池稳定响应时间平均在 200 毫秒以内。这个数据说明一个事实单体应用做秒杀瓶颈往往不在应用代码而在是否能把数据库压力正确转移。Redis 预减库存这一步做好了后面数据库落库的量就非常小了。6.2 连接池耗尽最经典的高并发故障第一次压测时把数据库连接池打满了报错是 HikariPool-1 - Connection is not available request timed out。原因是秒杀接口的事务范围太大数据库连接一直被占用加上没有前置 Redis 缓存所有请求都涌向数据库。后来做了两个调整一是把 HikariCP 的 maximumPoolSize 从默认的 10 调到了 50并设置 connection-timeout 为 5 秒二是把 Redis 预减库存的逻辑放在事务入口之前让数据库只处理真正抢到的请求。连接池耗尽的问题基本不再出现。修改 HikariCP 配置spring: datasource: hikari: maximum-pool-size: 50 minimum-idle: 10 connection-timeout: 5000 max-lifetime: 1800000需要说明的是线上环境的连接池大小要根据数据库的 max_connections 来定不要盲目调大否则应用连接池没打满数据库先挂了。50 是一个比较常见的经验值压测后要观察数据库负载再做微调。6.3 Redis 序列化问题乱码和类型不匹配Redis 序列化问题是这个项目里最容易踩的坑。使用 RedisTemplate 时默认的 key 和 value 序列化器是 JdkSerializationRedisSerializer写入 Redis 的值会在前面带一堆 \xAC\xED 的字节在可视化工具里完全无法阅读而且跨语言读取几乎不可能。我还遇到过一个更隐蔽的问题Redis Desktop Manager 里看到库存值是字符串 10但用 RedisTemplate 的 decrement 方法操作时报了类型错误。原因是用 StringRedisTemplate 写入的 key 和用 RedisTemplate 读取的 key 编码方式不一致两个客户端访问的不是同一个 key。解决办法是统一配置序列化器。项目里建议这样配置Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); StringRedisSerializer stringSerializer new StringRedisSerializer(); GenericJackson2JsonRedisSerializer jsonSerializer new GenericJackson2JsonRedisSerializer(); template.setKeySerializer(stringSerializer); template.setHashKeySerializer(stringSerializer); template.setValueSerializer(jsonSerializer); template.setHashValueSerializer(jsonSerializer); template.afterPropertiesSet(); return template; }之前我们团队有一个生产故障就是因此引发的Redis 里同时存在两种编码的 key库存实际扣完了但预减判断不准确导致用户一直看到“有货”却下单失败。排查了大半天才发现是两个序列化器并存的问题。后来在项目启动时就统一配置好再也没有出现过类似情况。6.4 Transactional 不生效同类内部调用下单方法上的 Transactional 在压测过程中出现过不生效的情况。问题在于同一个类 A 的方法调用同类的另一个方法时Spring 的代理不会拦截这次调用事务也就不会开启。比如 seckill 方法没有加事务doSeckill 加了事务seckill 内部直接调用 doSeckill后者就没有事务生效。解决方式有三种一是把事务方法放到独立的 Service 类里二是使用编程式事务在代码里获取 TransactionTemplate 手动开启事务三是把自己的实例注入进来用注入的代理对象去调用事务方法。我最常用的是第一种因为它最符合单一职责的编码习惯也方便后续做单元测试。6.5 压测中发现的一个隐藏超卖场景最后分享一个比较隐蔽的超卖问题。我的乐观锁 SQL 里一开始只判断了 version没判断 stock_count 0UPDATE seckill_goods SET stock_count stock_count - 1, version version 1 WHERE seckill_id #{seckillId} AND version #{version}压测时发现库存偶尔会变成负数。原因是 version 字段可能因为某些原因被改过导致两个请求使用同一版本号同时通过了条件都执行了减一操作。加上 stock_count 0 条件之后库存永远不可能被扣到负数因为当库存为 0 时update 影响行数为 0事务回滚请求返回失败。这个教训告诉我任何库存扣减 SQL无论有多少前置校验where 里都必须加上 stock_count 0 这个条件。它是最简单也最有效的库存保护。对于秒杀这类强一致性的场景宁可同一时刻少卖出去几件也不能让库存出现负数。这个思想贯穿整个项目Redis 预减是效率数据库乐观锁是准确唯一索引是兜底层层叠加才能保证不超卖。这套单体应用跑通之后再往上扩展就很容易了。演进的方向无非是把 Redis 预减库存逻辑抽出来做成独立服务把订单落库用消息队列异步化再把秒杀商品单独维护一份内容库。如果当前的流量用单体方案已经能扛住就不要急着微服务化。先把基础业务做对、做稳比堆一堆分布式组件有意义得多。本文还有配套的精品资源点击获取
返回列表