SpringBoot+Redis秒杀系统实战:高并发库存扣减与防超卖
简介一套基于SpringBoot与Redis实现的高并发商城秒杀系统源码及配套数据库面向计算机类相关专业的在校学生、教师和企业开发者覆盖计科、信息安全、大数据、人工智能、物联网等领域适合作为课程设计、毕业设计、大作业或初期项目演示的实战资料。项目整合SpringBoot与Redis完成商品秒杀流程包含完整前后端代码、数据库脚本与配置文件有助于理解高并发场景下的缓存读写、库存扣减、订单生成和限购逻辑等核心环节。压缩包共436个文件其中64个Java源码文件与64个class编译文件构成后端主体另有56个HTML页面、52个JS脚本、36个CSS样式、22个XML配置以及2个SQL数据库脚本等前端与后端资源齐全总大小仅11.15MB便于下载和部署。目前已有150人学习浏览项目功能经过验证可稳定运行将解压目录命名为英文路径后即可正常启动适合在此基础上二次开发也可作为高校课程设计或毕业设计的功能底稿。1. 秒杀系统落地的第一道坎库存扣减与超卖秒杀系统的难点从来不是前端倒计时而是高并发下库存扣减不超卖、不重复下单。课程作业里常见做法是把库存放MySQL里直接update压测一上去就出现负数。其实用SpringBootRedis做预扣减库存热点先落在内存下单落库能直观解决这个问题。这套源码是典型三层结构GoodsController负责商品展示OrderController负责秒杀下单RedisUtil统一封装Redis操作配MySQL持久化订单。功能不多但秒杀链路齐全适合做课程设计或毕业设计原型的人参考。下面从工程结构、数据库脚本、Controller链路、压测排错一路拆到Lua原子脚本。项目路径不要带中文否则Spring Boot解析资源会有问题。2. SpringBoot Redis 工程结构、数据库脚本与配置2.1 模块划分与核心依赖源码包解压后是一个标准Maven工程项目名建议改成英文。后端严格按Controller/Service/Dao分层实体类放在entity包。RedisUtil放在util包里所有Redis操作都过它。这个设计对课程作业来说够用也方便后面替换成Spring Data Redis模板。pom.xml里必须引入的依赖有spring-boot-starter-web、spring-boot-starter-data-redis、mybatis-spring-boot-starter、mysql-connector-java最后是lombok可选。版本以本机Spring Boot稳定版为准不需要追新2.x即可覆盖本项目所有语法。如果换成Spring Boot 3.xjavax要改jakarta部分Redis连接池参数会失效课设环境建议锁2.7.x。dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency /dependencies说明spring-boot-starter-data-redis内置Lettuce连接池不需要额外引jedis。MyBatis用2.3.x兼容Spring Boot 2.x。mysql-connector不要加版本号交给spring-boot-dependencies统一管理。2.2 数据库脚本与库存字段设计秒杀表只需要两张核心表seckill_goods保存商品和库存seckill_order保存成功订单。库存字段用int状态字段用tinyint。秒杀商品库存一般几百到几千int够用而且Redis里的库存就是从数据库这个字段初始化过去的。CREATE TABLE seckill_goods ( id BIGINT AUTO_INCREMENT PRIMARY KEY, goods_name VARCHAR(120) NOT NULL, goods_stock INT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0, start_time DATETIME, end_time DATETIME ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE seckill_order ( id BIGINT AUTO_INCREMENT PRIMARY KEY, goods_id BIGINT NOT NULL, user_id BIGINT NOT NULL, order_status TINYINT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL, UNIQUE KEY uk_goods_user (goods_id, user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;字段设计里有几个点很关键。uk_goods_user唯一键直接阻止同一个用户重复下单比在代码里先查再插入可靠得多。version字段是预留的乐观锁位如果后面不用Redis也可以改成update ... set stock stock - 1 where id ? and stock 0的写法。字段作用说明goods_stock库存字段启动时写入Redis数据库不参与高并发扣减version乐观锁备用课设可以先留空uk_goods_user唯一索引防止一个用户秒杀同一商品两次2.3 Redis连接池与序列化配置application.yml里Redis配置是重点。先看连接池参数不打开Lettuce pool的话压测一开始就会报RedisConnectionFailureException。spring: redis: host: 127.0.0.1 port: 6379 password: database: 0 timeout: 3000ms lettuce: pool: max-active: 32 max-idle: 16 min-idle: 4 max-wait: 3000msmax-active建议是Tomcat线程数的2到4倍课设环境给32就够。max-wait3000ms表示连接池耗尽时最多等3秒不无限阻塞。min-idle保持4避免压测刚开始时频繁建连。Spring Boot 2.x默认用Lettuce如果用Jedis配置前缀要换掉。还需要重写RedisTemplate序列化器。源码里的RedisUtil依赖这个Template不配置的话key会带\xac\xed前缀肉眼排查非常痛苦。Configuration public class RedisConfig { Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); GenericJackson2JsonRedisSerializer jsonSerializer new GenericJackson2JsonRedisSerializer(); StringRedisSerializer stringSerializer new StringRedisSerializer(); template.setKeySerializer(stringSerializer); template.setHashKeySerializer(stringSerializer); template.setValueSerializer(jsonSerializer); template.setHashValueSerializer(jsonSerializer); template.afterPropertiesSet(); return template; } }注意GenericJackson2JsonRedisSerializer会在反序列化时把类型信息写进class字段前端直接读Redis时会看到多余内容。课设里业务方只关心结果问题不大但建议库存这种纯数字直接用StringRedisSerializer就够。2.4 项目路径与启动参数源码包备注里强调“项目名字和项目路径不要用中文”这是真踩过坑的。Spring Boot的ClassPathResource解析在中文路径下偶尔正常但Maven编译和MyBatis扫描Mapper XML时可能拿不到路径。把压缩包解压到英文路径比如D:\workspace\seckill-demo再改pom里的finalName。启动前依次执行建库导入SQL、启动Redis、启动Spring Boot。如果打包后运行加--spring.profiles.activelocal指定配置。3. 秒杀核心链路从GoodsController到Redis预扣减3.1 商品列表与Redis库存预热先从GoodsController看商品查询。秒杀列表不适合每次都查数据库常见做法是启动时把库存加载到Redis用String结构key为seckill:stock:1。RedisUtil封装了set、get、decr、expire四个核心方法。RequestMapping(/goods) public class GoodsController { Autowired private GoodsService goodsService; Autowired private RedisUtil redisUtil; GetMapping(/list) public ListGoods list() { ListGoods goodsList goodsService.listGoods(); for (Goods g : goodsList) { redisUtil.set(seckill:stock: g.getId(), String.valueOf(g.getStock())); redisUtil.expire(seckill:stock: g.getId(), 3600); } return goodsList; } }expire设置为3600秒保证第二天不会残留旧库存。实际项目里商品列表应该有缓存淘汰策略但课设先返回数据库结果也可以只要把库存预热到Redis秒杀链路就已经成立。Redis Key类型说明seckill:stock:{goodsId}String秒杀剩余库存seckill:users:{goodsId}Set已秒杀用户集合Lua方案使用3.2 秒杀接口的扣减流程秒杀接口流程看起来直接先通过RedisUtil的decr扣库存扣成功创建订单落库失败再回补库存。源码里OrderController大致是下面这种写法。PostMapping(/seckill) public String doSeckill(Long goodsId, Long userId) { String stockKey seckill:stock: goodsId; long stock redisUtil.decr(stockKey, 1); if (stock 0) { redisUtil.incr(stockKey, 1); return 没有库存了; } OrderInfo order new OrderInfo(); order.setGoodsId(goodsId); order.setUserId(userId); order.setCreateTime(new Date()); try { orderMapper.insert(order); return 秒杀成功订单ID order.getId(); } catch (DuplicateKeyException e) { redisUtil.incr(stockKey, 1); return 您已秒杀过该商品; } }这段代码能演示但不是严格的原子方案。decr本身是原子的可数据库插入失败后的incr回补存在逻辑窗口如果插入动作超时但实际已提交回补会把库存加回去最终库存偏多。课设答辩时一定要主动把这个风险讲出来然后引出第5章的Lua方案。注意判断条件是stock 0不是stock -1。并发请求多时decr一次可能直接扣到负几十不能用等号去判断。3.3 RedisUtil的实现方式源码包里的RedisUtil注入RedisTemplate内部把增量操作直接委托给increment方法。这样代码量少语义也清晰。Component public class RedisUtil { Autowired private RedisTemplateString, Object redisTemplate; public boolean set(String key, Object value) { redisTemplate.opsForValue().set(key, value); return true; } public Object get(String key) { return redisTemplate.opsForValue().get(key); } public long decr(String key, long delta) { return redisTemplate.opsForValue().increment(key, -delta); } public boolean expire(String key, long seconds) { return redisTemplate.expire(key, seconds, TimeUnit.SECONDS); } }increment支持负数所以decr直接传-delta。返回long是操作后的新值。expire只在商品加载时调用一次不需要每单都设置过期时间避免多余网络往返。3.4 订单落库后的对账SQL秒杀表结构里存在两个数据源压测后必须做对账。最简单的方式是查库存加上订单数是否等于初始库存。SELECT (SELECT goods_stock FROM seckill_goods WHERE id 1) AS current_stock, (SELECT COUNT(*) FROM seckill_order WHERE goods_id 1) AS order_count, (SELECT goods_stock FROM seckill_goods WHERE id 1) (SELECT COUNT(*) FROM seckill_order WHERE goods_id 1) AS total FROM dual;total不等于初始库存说明库存或订单不一致。注意对账SQL必须在压测结束、Redis库存回写完成后再跑否则Redis已经扣了库存订单还在排队插入差数会被误判。4. 并发压测与超卖排查调参、日志与死锁4.1 压测脚本和预期指标代码跑通以后第一步是用ab打接口。我用2000个请求、50并发打秒杀地址。ab -n 2000 -c 50 -p post.txt -T application/x-www-form-urlencoded \ http://localhost:8080/api/order/seckillpost.txt内容为goodsId1userId1001。-n是总请求数-c是并发数。同一个userId的情况下预期只有1单成功其余请求在数据库唯一键处拦截。压测后看两个指标非200响应数量和Redis连接错误频率。出现Unable to connect to Redis; nested exception is io.lettuce.core.RedisConnectionException优先查application.yml里lettuce.pool.max-active是否太小。Spring Boot 2.x默认不开启Lettuce连接池你不配置高并发时连接会立刻打满。4.2 参数对照表与调优方向参数调小会怎样调大风险spring.redis.lettuce.pool.max-active连接池耗尽请求排队占用Redis文件描述符内存上升spring.redis.timeout读Redis超时提前失败线程卡在等待上故障难发现server.tomcat.threads.max吞吐量低秒杀排队严重线程数过多CPU切换成本高spring.datasource.hikari.maximum-pool-size数据库连接阻塞超过MySQL上限报too many connectionsTomcat线程数、Redis连接数、数据库连接数不要差出数量级。Tomcat最大200线程时Redis给32、HikariCP给20这个比例课设场景够用。如果压到200并发Redis 32还不够可以提到64。4.3 超卖与重复下单排查顺序遇到库存变负按下面步骤排查。先确认Redis里的扣减是否原子。用get再set一定不是原子的这种代码并发一定超卖。再确认数据库唯一索引存在。没有uk_goods_user一个用户点击多次会创建大量重复订单。同一个用户秒杀同一商品必须只允许一单。看日志有没有DuplicateKeyException被吞掉。如果orderMapper.insert抛异常后被catch住并返回成功前端会以为自己秒杀成功实际订单不存在。插入异常必须显式处理。最后检查Controller里是否先查了一次数据库库存再走Redis。两次请求之间库存已经变化带着旧值继续下单这一层是多余的直接删掉。4.4 SpringBoot线程池与HikariCP调整页面秒杀点击时tomcat线程数决定多少请求能同时进入服务。下面是调整后的配置。server: port: 8080 tomcat: threads: max: 200 min-spare: 20 accept-count: 100 spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 3000accept-count是等待队列超过200100后请求直接被拒这比让所有线程卡死要合理。connection-timeout设成3000ms数据库故障时请求不会挂30秒。课设环境Redis和MySQL通常在一台机器重点观察内存占用。5. 用Lua脚本收尾原子扣减与Redis限流5.1 为什么需要Lua前面decr加回补的方案有竞态窗口。真正想让“检查用户是否已购买”和“扣库存”同时完成单靠decr不够。Redis的Lua脚本能在服务端原子执行多个命令中间不会被其他客户端插入。这是答辩时能加分的点也是当前主流秒杀方案的基础。5.2 Lua脚本实现原子扣减下面这段脚本把“判断库存、判断重复用户、扣减库存”合并为一个原子操作。先用SISMEMBER查用户是否在集合里再GET库存判断是否大于0然后DECR扣减并把用户写入集合。local stock_key KEYS[1] local user_key KEYS[2] local user_id ARGV[1] if redis.call(SISMEMBER, user_key, user_id) 1 then return -2 end local stock tonumber(redis.call(GET, stock_key) or 0) if stock 1 then return -1 end redis.call(DECR, stock_key) redis.call(SADD, user_key, user_id) redis.call(EXPIRE, user_key, 86400) return 1返回1表示成功-1表示无库存-2表示重复秒杀。user_key建议设为seckill:users:11是商品ID避免所有商品共用同一个集合。Java里用DefaultRedisScript执行这段脚本。DefaultRedisScriptLong script new DefaultRedisScript(); script.setScriptText(...Lua 脚本内容...); script.setResultType(Long.class); Long result redisTemplate.execute(script, Arrays.asList(stockKey, userKey), userId);result为1才做订单插入。订单插入失败时需要在catch里SREM移除用户保证下次还能重试。MySQL操作不能放进Redis脚本所以最终一致性靠业务补偿。5.3 接口级限流秒杀入口还要做限流。用Redis的INCR加过期时间做计数器限制单用户每秒请求次数。local limitKey KEYS[1] local count redis.call(INCR, limitKey) if count 1 then redis.call(EXPIRE, limitKey, 1) end if count 5 then return 0 end return 1这个脚本同样由RedisTemplate执行逻辑简单误伤率低。EXPIRE只在第一次INCR时设置保证窗口是1秒。改完Lua后重新压测直接看两个数据订单表行数和商品库存总和。如果库存为0且订单数等于初始库存说明扣减完全一致。再用同一个用户连续请求两次第二次必须返回重复秒杀错误。这两个验证过了课设的并发核心就算真正收口。本文还有配套的精品资源点击获取