资讯详情

资讯详情

Spring Boot+Redis单体秒杀应用:防超卖与高并发实践

简介一个基于Spring Boot、Redis与MyBatis实现的商品秒杀单体应用源码包面向具备一定Java基础、希望掌握高并发库存扣减与订单流程的开发者可作课程设计或秒杀场景入门参考。压缩包共61个文件其中40个Java源码实现控制器、服务与实体逻辑11个XML配置用于MyBatis映射及Spring装配3个properties文件管理端口、数据源等环境参数另附Maven包装器、README与项目截图整体仅192KB目录结构清晰适合导入IDE直接运行分析。已有63人学习下载属于小型但完整的学习型项目。核心代码覆盖用户鉴权、商品展示、秒杀请求处理、订单生成等模块采用Redis预减库存配合MyBatis数据库操作并包含限流与防超卖思路运行说明和目录结构能帮助快速定位启动类、配置文件和测试用例便于对照理解秒杀系统的核心流程与工程组织方式。1. 商品秒杀单体应用在讲什么先解决超卖再谈高并发一个秒杀系统的技术评估最常踩的误区是把并发规模想得太大一上来就拆微服务、上消息队列结果瓶颈没在架构上反而在库存扣减那一条简单 SQL 上。基于 Spring Boot Redis MyBatis 的商品秒杀单体应用本质上就是一套把库存预减、防超卖、落单回滚放在一个进程里打通的完整骨架它在几十甚至几百 QPS 的抢购场景下完全够用还能让你把秒杀最核心的并发一致性逻辑看透。这套组合能解决的问题很具体高并发下库存不超卖、同一用户不重复下单、下单失败后库存能恢复。它不适合做千万级流量的大促但非常适合做技术验证、毕业设计、小范围营销活动的后端底座也适合想在 Java 电商方向入门的人作为秒杀主链路的第一份可运行代码来研究。整篇文章会从表结构一路吃到压测讲清楚每个参数为什么这么设。2. 先把秒杀链路想清楚表结构、Redis 数据设计与 Spring Boot 装配2.1 秒杀需求拆解与四张表的职责秒杀和普通购买最大的区别在于“瞬间流量远大于库存量”。如果不做任何拦截所有请求都会打到数据库的行锁上MySQL 在几百并发下就会出现连接等待和超时。所以在动手写代码之前先要把数据模型和请求链路拆干净。常见的做法是把用户、商品、活动、订单分开建模而不是在一个 order 表里塞一堆冗余字段。我一般会建四张表秒杀活动表seckill_activity、秒杀商品表seckill_goods、用户表seckill_user、秒杀订单表seckill_order。活动表管理一场秒杀的起止时间商品表冗余一份活动价和库存快照用户表就是常规的用户信息订单表记录谁在什么时间秒到了哪个商品。这里的关键是库存快照不要读实时库存表否则秒杀期间你还要去 join 库存明细压力完全降不下来。CREATE TABLE seckill_goods ( id bigint NOT NULL AUTO_INCREMENT, activity_id bigint NOT NULL COMMENT 秒杀活动ID, goods_name varchar(128) NOT NULL, seckill_price decimal(10,2) NOT NULL, stock_count int NOT NULL DEFAULT 0 COMMENT 秒杀库存快照, version int NOT NULL DEFAULT 0, PRIMARY KEY (id), KEY idx_activity (activity_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE seckill_order ( id bigint NOT NULL AUTO_INCREMENT, user_id bigint NOT NULL, goods_id bigint NOT NULL, activity_id bigint NOT NULL, order_status tinyint NOT NULL DEFAULT 0 COMMENT 0待支付,1已支付,2已取消, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_user_goods_activity (user_id,goods_id,activity_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这段建表 SQL 里有两个点值得注意。第一个是uk_user_goods_activity唯一索引它在数据库层面做防重即使应用层漏掉了判断同一个用户对同一商品也无法插入两条订单记录。第二个是stock_count直接冗余在商品表上不单独建库存流水表这是单体秒杀应用最省事的方案——因为回滚只需要改回这个数字不需要维护流水状态。2.2 Redis 里放什么库存预减、用户标记和数据类型选择Redis 在秒杀链路里的角色是“前置拦截层”它挡在 MySQL 前面把真正能落到数据库的请求数量压到和库存量同一个量级。最常见的 Redis 数据设计是三个 keyseckill:stock:{goodsId} 存剩余库存Stringseckill:user:{userId}:{goodsId} 标记用户是否已秒过Stringseckill:success:{goodsId} 记录已售数量String。库存和已售数量用 DECR / INCR 原子操作用户标记用 SETNX。这里最容易犯的错是用 Hash 存库存再配合 GET SET 来扣减两个操作之间没有原子性高并发下必然超卖。Redis 的 String 类型配合 DECR 是单键原子的扣减操作本身不会丢更新。如果你想让一次请求里同时完成“检查用户是否已秒过 扣减库存 打标记”那就得用 Lua 脚本把这三个操作放进同一个脚本里执行Redis 保证脚本整体原子执行。# 秒杀开始前由管理员或定时任务预加载库存 SET seckill:stock:1001 100 # 用户点击秒杀时先尝试扣减 DECR seckill:stock:1001 # 扣减成功后标记用户 SETNX seckill:user:2001:1001 1这个流程有一个隐患如果DECR已经执行了但随后的SETNX发现用户已经秒过那库存就白白减掉了。所以生产上一定要把“检查用户标记、扣库存、写标记”三个动作合并为 Lua 脚本而不是在 Java 代码里分步调用。后面第三章我会给出完整脚本这是整个秒杀应用防超卖和防重复下单的核心。2.3 Spring Boot 整合 Redis 与 MyBatis 的配置要点Spring Boot 2.x 整合这两个中间件非常成熟依赖上只需要spring-boot-starter-data-redis、mybatis-spring-boot-starter、mysql-connector-java三项。但整合简单不代表配置不用管默认配置在秒杀场景下会连续踩坑Lettuce 连接池默认是关闭的Redis 的RedisTemplate默认用 JDK 序列化MyBatis 的驼峰映射默认不开启。spring: redis: host: 127.0.0.1 port: 6379 password: timeout: 3000ms lettuce: pool: max-active: 200 max-idle: 50 min-idle: 10 max-wait: 1000ms datasource: url: jdbc:mysql://127.0.0.1:3306/seckill?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: root hikari: maximum-pool-size: 50 minimum-idle: 10 connection-timeout: 3000 mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImplRedis 连接池的max-active要结合你压测的并发数来调200 是一个常见的起步值。max-wait设置 1000ms 意味着当连接池耗尽时请求最多等 1 秒超时直接抛异常这比让请求无限等待要好得多。MyBatis 的map-underscore-to-camel-case: true一定要开否则stock_count映射不到stockCount属性上你会在运行时发现库存永远读不到。3. 秒杀主链路落地从预减库存到生成订单的完整代码走读3.1 Controller、Service 与 Mapper 的三层实现秒杀接口从用户点击按钮到返回结果前端只关心“成功或失败”但后端要做的动作其实有四个检查活动是否在有效期内、检查用户是否已秒过、预减 Redis 库存、插入秒杀订单。这四个动作里前两个是查询后两个是写入写入操作必须在一个事务边界内完成。RestController RequestMapping(/seckill) public class SeckillController { Autowired private SeckillService seckillService; PostMapping(/{goodsId}) public Result doSeckill(PathVariable Long goodsId, RequestParam Long userId) { // 实际项目中 userId 从登录态获取这里直接入参方便测试 return seckillService.seckill(goodsId, userId); } }Controller 层不做任何业务判断只做参数接收和结果封装。真正重要的是 Service 层的方法它负责把 Redis 预减、数据库落单、异常回滚串起来。这里要注意一个细节doSeckill的返回类型不要用 void因为秒杀是异步感知很强的场景用户需要立刻知道自己是抢到了还是没抢到。Service public class SeckillService { Autowired private StringRedisTemplate redisTemplate; Autowired private GoodsMapper goodsMapper; Autowired private OrderMapper orderMapper; private static final String STOCK_KEY seckill:stock:; private static final String USER_KEY seckill:user:; Transactional(rollbackFor Exception.class) public Result seckill(Long goodsId, Long userId) { String stockRedisKey STOCK_KEY goodsId; String userRedisKey USER_KEY goodsId : userId; // 执行 Lua 脚本检查用户 扣库存 打标记三个动作原子完成 Long result redisTemplate.execute(seckillLuaScript, Arrays.asList(stockRedisKey, userRedisKey)); if (result null || result ! 1L) { return Result.fail(秒杀失败库存不足或已参与); } // 走到这一步说明库存预减成功且用户未重复下单 try { int rows goodsMapper.deductStock(goodsId); if (rows 0) { // 数据库行锁竞争后库存已不足需要回滚 Redis 预减 redisTemplate.opsForValue().increment(stockRedisKey); redisTemplate.delete(userRedisKey); return Result.fail(秒杀失败库存不足); } SeckillOrder order new SeckillOrder(); order.setUserId(userId); order.setGoodsId(goodsId); order.setOrderStatus(0); orderMapper.insert(order); return Result.success(order.getId()); } catch (Exception e) { // 任何异常都要恢复 Redis 预扣数据 redisTemplate.opsForValue().increment(stockRedisKey); redisTemplate.delete(userRedisKey); throw e; } } }这段代码是整个秒杀应用的核心骨架逻辑上分成三段。第一段执行 Lua 脚本做 Redis 层的原子判断和预扣第二段更新数据库的库存快照并插入订单第三段是异常补偿。Transactional注解保证deductStock和insert要么同时成功要么同时失败但这里的回滚只覆盖数据库Redis 的预扣必须由手动代码恢复——这是单体秒杀实现里最容易漏掉的一环。3.2 库存扣减 SQL 与 MyBatis 动态 SQL 写法数据库层的库存扣减不能写成“先 SELECT 再 UPDATE”两步因为两步之间有时间差并发请求都读到库存为 1然后各自执行 UPDATE超卖就发生了。正确写法是单条条件 UPDATE通过stock_count 0这个条件让数据库行锁去仲裁谁成功。update iddeductStock UPDATE seckill_goods SET stock_count stock_count - 1, version version 1 WHERE id #{goodsId} AND stock_count 0 /updateMyBatis 的#{}参数预编译能避免拼接 SQL 注入问题stock_count 0是防超卖在数据库层面最后一道闸门。这个方法返回的rows是受影响行数如果为 0说明 UPDATE 执行了但条件不满足——库存已经在 Redis 预减之后被其他请求扣光了。这时业务层必须回滚 Redis 的预减否则会出现“数据库没扣成功Redis 却少了一个库存”的数据不一致。订单插入的 Mapper 就简单很多常规的 insert 语句加上useGeneratedKeystrue回填自增主键方便返回订单号给前端。唯一要注意的是 insert 语句必须依赖数据库的唯一索引兜底防重如果你的 Service 层 Lua 脚本因为某种原因没生效唯一索引能避免产生两条相同的订单。3.3 引入 Redisson 分布式锁单体为什么还需要锁看到“单体应用”四个字很多人会觉得加锁用synchronized就够了。但在秒杀场景里有一个容易被忽略的真相即使你的应用是单体的部署时也可能开了多个实例做负载均衡而且即使只有一个实例后续要做灰度发布时也会拆成两个。为了不留隐患我一般会直接引入 Redisson 的分布式锁用 Redis 做锁的载体。Autowired private RedissonClient redissonClient; public Result seckillWithLock(Long goodsId, Long userId) { String lockKey seckill:lock: goodsId; RLock lock redissonClient.getLock(lockKey); boolean locked false; try { locked lock.tryLock(3, 10, TimeUnit.SECONDS); if (!locked) { return Result.fail(系统繁忙请重试); } // 加锁成功后执行与上面 seckill 方法相同的逻辑 return doSeckill(goodsId, userId); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return Result.fail(秒杀失败); } finally { if (locked) { lock.unlock(); } } }这里有几个参数值得说明tryLock的第一个参数是等待锁时间设为 3 秒意味着请求最多等 3 秒拿不到就直接返回失败第二个参数是锁的自动过期时间设为 10 秒是防止业务执行过程中宕机导致死锁Redisson 的看门狗机制会在锁快要过期时自动续期所以这里手动设置 10 秒后看门狗不会无限续期业务超过 10 秒会被迫中断这是一个安全边界。4. 防超卖与一致性Lua 原子脚本、事务边界和库存补偿4.1 Lua 脚本实现检查、扣减、标记的原子操作第三章的代码里我预留了seckillLuaScript这个变量它就是防超卖的最关键组件。拆开来看Redis 执行 Lua 脚本时整个脚本是原子执行的中间不会插入其他客户端的命令这就保证了一个用户请求的三个状态变更不会被并发请求穿插。脚本逻辑是先判断用户标记是否存在存在则返回 0不存在则判断库存是否大于 0小于等于 0 返回 -1最后扣库存、设置用户标记、返回 1。if redis.call(EXISTS, KEYS[2]) 1 then return 0 end local stock redis.call(GET, KEYS[1]) if not stock or tonumber(stock) 0 then return -1 end redis.call(DECR, KEYS[1]) redis.call(SETEX, KEYS[2], 3600, 1) return 1这段 Lua 脚本在 Java 里怎么塞进去呢常见做法是定义一个DefaultRedisScriptLong的 Bean构造时传入脚本内容执行时通过redisTemplate.execute(script, keys)调用。返回值 0 表示已经秒过-1 表示库存不足1 表示秒杀成功可以继续落库。注意SETEX设置了 3600 秒过期这是用户标记的自动清理时间避免长期占用内存。4.2 事务边界数据库回滚了Redis 凭什么必须手动恢复在 Spring 的声明式事务里Transactional只管理数据库连接的事务。Redis 的操作不在这个事务里它们已经提前执行完了所以当数据库插入订单失败时Spring 只能回滚 MySQLRedis 里的库存和用户标记还停留在“已扣减”的状态。这就是为什么第三章的代码里所有返回Result.fail和抛出异常的分支都要手动increment恢复库存、delete删除用户标记。事务补偿的代码要放在try-catch-finally的哪个位置是有讲究的。我的习惯是把补偿放在 return 之前或 catch 里显式执行而不是放在 finally 里统一处理。因为 finally 里无法判断业务到底是成功还是失败如果每次请求都恢复库存会导致成功请求的库存被错误加回。另外补偿操作本身也可能失败比如 Redis 刚好在那一刻宕机所以更稳妥的方案是记录一条库存流水表由定时任务扫描补偿但这在单体秒杀里属于后话先保证显式补偿即可。4.3 MyBatis 二级缓存对秒杀读写的隐藏影响MyBatis 的二级缓存默认是关闭的但如果你为了查询性能打开了它就会在秒杀场景遇到一个非常隐蔽的坑seckill_goods的库存数据被缓存了。第一个请求查到库存为 100写入二级缓存随后大量请求都从缓存里读到 100即使数据库里的库存已经被扣到 10。因为 UPDATE 语句默认不会主动刷新二级缓存只有显式配置flushCachetrue才会生效。cache evictionLRU flushInterval60000 size512 readOnlytrue/这里readOnlytrue表示缓存对象只读MyBatis 会直接返回缓存对象的引用而不做序列化拷贝性能更好但不允许修改缓存对象。在秒杀应用里我一般直接注释掉这个cache配置因为库存数据属于强一致数据不适合放进缓存。如果你确实要缓存用户信息这类低频变更数据就把可能变更的所有 Mapper 方法都加上flushCachetrue但别用在你承载秒杀写入的那个 Mapper 上。5. 秒杀单体应用的避坑清单现象、原因、解决5.1 抢购失败但 Redis 库存越来越少现象压测时发现返回“秒杀失败”的请求越来越多但 Redis 里的库存数字持续下降数据库库存却基本没动。原因Lua 脚本已经扣减了 Redis 库存但后续数据库扣减被行锁阻塞后超时抛异常异常分支里 Redis 补偿代码没有执行。常见于把补偿写在 finally 里但误用了条件判断或者异常类型没被 catch 捕获。解决把 Redis 库存恢复逻辑放到 catch 块的最前面无论什么异常先恢复库存再决定是否重抛。在恢复库存后打一条 WARN 日志记录 goodsId、userId 和当时的库存快照方便事后核对补偿是否正确——这条日志非常值得留因为补偿出问题的时候你能快速定位是哪个分支漏了。5.2 Redis 连接池被打满导致整个应用卡死现象压测跑到一半所有请求都超时Redis 的日志显示连接数持续增长Spring Boot 应用线程池耗尽。原因Lettuce 连接池默认没有上限在高并发下会创建大量连接而每个连接在 Redis 端也有开销。另一个常见原因是 Redis 操作没有设置超时时间慢查询把连接占住不释放。解决在配置文件中显式配置max-active、max-wait和timeout同时给 RedisTemplate 设置操作超时。最关键的是给秒杀接口加一个前置限流比如用RateLimiter或直接判断 Redis 里的请求计数器超过阈值直接返回“系统繁忙”。连接池的参数不是越大越好200 个连接对应 50 个数据库连接池是比较均衡的组合。5.3 MyBatis 条件更新不生效库存没扣但返回成功现象秒杀接口返回“成功”用户也收到了订单号但数据库里stock_count没有任何变化。原因Mapper 的deductStock方法返回值没有被正确检查或者 XML 里的 update 语句因为if条件写错被 MyBatis 解析成了空更新。最常见的是动态 SQL 里test条件用了单等号比如testgoodsId #{goodsId}这个写法会被 XML 解析器当成字符串比较运行时结果永远为 true 或 false。解决在压测前先打印 MyBatis 的执行日志确认每条 update 语句确实被真实执行。在application.yml里配置log-impl: org.apache.ibatis.logging.stdout.StdOutImpl让每条 SQL 和参数都输出到控制台。检查更新语句的条件不要过度使用if防御性代码在秒杀场景反而制造麻烦。5.4 事务回滚不生效出现脏订单现象数据库插入订单时抛了异常但订单数据依然存在部分库存被错误扣减。原因自调用导致事务失效。SeckillService内部某个方法调用了同类里的另一个Transactional方法Spring 的代理对象没有参与事务注解被跳过。另一个原因是异常被catch吞掉后没有重新抛出Spring 根本感知不到异常。解决不要在同一类里调用带事务的 public 方法把事务方法放到独立的 Service 类里或者通过ApplicationContext.getBean获取代理对象再调用。catch 里处理完补偿后务必throw new RuntimeException(e)或TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()让 Spring 执行真正的回滚。5.5 压测时一切正常上线后库存错乱现象本地用 JMeter 压测 50 并发没有超卖但上线后真实用户进来数据库库存变成负数。原因压测数据是连续 userIdRedis 的 key 分布均匀但真实用户会集中在热点商品上数据库的行锁竞争远高于压测。另外线上 Redis 和数据库之间的网络延迟更高Lua 脚本执行后到数据库更新的时间窗口变大。解决压测时分两个场景一个用少量用户循环压另一个用大量用户随机压。上线前在网关或 Nginx 层做限流比如单商品每秒钟最多放行库存数 5 倍的请求进入后端超出直接拒绝。这个参数不是拍脑袋定的先压出单体应用的实际吞吐量再设置限流阈值。6. 从压测到调优验证秒杀单体应用的几项动手操作秒杀单体应用写完不等于能上线你得先回答一个问题它在你的机器上到底能扛多少 QPS我用 JMeter 压测时线程组会设置成 200 线程、持续 60 秒监听器重点看聚合报告里的“吞吐量”和“异常率”而不是响应时间的平均值——平均响应时间在秒杀场景没有意义因为 95% 的失败请求会快速返回平均值会被拉低。压测时观察 MySQL 的慢查询日志如果出现大量deductStock的慢 SQL说明行锁等待严重这时候优先检查数据库连接池大小是否合理。参数调优的顺序也是固定的先调 Redis 连接池再看数据库连接池最后调 Tomcat 线程数。因为秒杀链路的前端是 Redis 预减如果 Redis 都扛不住后续环节全部排队。Tomcat 线程数我会设成 400超过这个数的请求直接让操作系统排队数据库连接池设成 50因为一个秒杀事务占用连接的时间极短50 个连接足够支撑几百 QPS。Redis 连接池的max-active比数据库大一些200 比较均衡。压测时把这些参数全部暴露成 Spring Boot 配置项不要硬编码方便后续调整。还有一个值得做的验证操作是模拟 Redis 故障把 Redis 进程 kill 掉观察应用是否会快速失败而不是卡死。如果timeout设置过小会出现大量请求堆积在线程池里如果设置过大用户体验会很差。我一般设 3 秒。接着重启 Redis验证库存是否还能对上——这里就能暴露事务补偿逻辑写得好不好如果补偿缺失重启后的 Redis 库存会和数据库差一大截。最后从经验上说单体秒杀应用的价值不在于扛多大流量而在于它训练你把并发一致性的每一步都落在明处。我早年间做秒杀吃过最大的亏就是觉得单体应用不需要分布式锁结果一次压测中 Nginx 负载均衡到两个实例库存瞬间超卖。从那以后无论项目多小我都会把锁、Lua 脚本、事务补偿这套完整写进去——不做秒杀应用就是个定时炸弹做了它就是你最有说服力的并发编程作品。希望这套落地路径能帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →