ARTICLE DETAIL

资讯详情

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

秒杀系统架构设计与高并发库存扣减实战:从数据库到Redis

秒杀系统架构设计与高并发库存扣减实战:从数据库到Redis 简介基于Spring Boot与Vue的秒杀系统毕业设计完整源码包适合高校Java方向学生、毕业设计选题人及需要高并发项目参考的开发者。系统采用前后端分离架构涉及Redis缓存、消息队列、数据库事务等核心设计是电商秒杀场景的典型实战案例。压缩包共810个文件、20MB常见类型包括164个js前端交互脚本、123个java后端类、47个vue页面组件、53个css样式另有SQL数据库脚本、论文文档、PPT和部署脚本等内容覆盖项目源码、数据库、论文与演示文档目前已有50人学习。资源随附完整论文与PPT答疑可辅助理解项目设计、实现与答辩展示数据库脚本和1-install.bat、2-run.bat等部署脚本保证项目在Windows10/11下调试通过、下载即用使用说明文档全面覆盖安装、部署、运行全流程适合作为毕业设计、期末作业的参照也便于在此基础上进行二次功能开发。1. 秒杀系统的技术骨架不只是把价格调低一个商品 100 件库存开售瞬间涌进 3 万请求这是秒杀系统的典型画像。大多数 Java 学习者第一次面对这个题目时第一反应是“把下单接口写好”真正上线才会发现数据库在 1 秒内被几千条 update 语句打爆库存明明扣成了负数页面却还显示“抢购成功”。秒杀系统的设计与实现核心不是业务逻辑而是如何用有限的数据库连接和内存资源接住瞬时流量同时保证“不超卖、不漏单、不重复支付”。这篇博文会从数据库表设计、Redis 预扣库存、SpringBoot 接口实现到 Vue 页面联调把整条链路拆开讲透最后给出压测方法和防刷参数。无论你是做毕业设计还是把秒杀写进简历这套思路都是面试官愿意听的那版答案。2. 数据库层先守住底线库存表设计与超卖防治2.1 库存表怎么建才经得住并发秒杀系统的数据模型并不复杂核心就三张表商品表、订单表、库存表。很多数据库课程设计里喜欢把库存字段直接放在商品表里但秒杀场景下我建议把库存拆成独立表原因有两个一是避免商品信息的 update 与库存扣减相互争锁二是方便单独调整库存表的索引和存储参数。CREATE TABLE seckill_goods ( id bigint(20) NOT NULL AUTO_INCREMENT, goods_id bigint(20) NOT NULL COMMENT 商品ID, seckill_price decimal(10,2) NOT NULL COMMENT 秒杀价, stock_count int(11) NOT NULL COMMENT 剩余库存, start_time datetime NOT NULL COMMENT 秒杀开始时间, end_time datetime NOT NULL COMMENT 秒杀结束时间, version int(11) NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, PRIMARY KEY (id), KEY idx_goods_id (goods_id), KEY idx_start_time (start_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有几个关键设计。stock_count是剩余库存而不是总库存业务上每次扣减只更新这个字段减少 update 语句的写集。version字段为乐观锁预留后面讲扣减方案时会用到。start_time上建索引是因为秒杀开始前系统需要按时间筛出可秒杀的商品列表这一步在 Redis 缓存失效回源时很常见。如果你要支持“每个用户限购 N 件”还需要在订单表里加user_id goods_id的唯一索引这是防重复下单最便宜的方案。表字段只是第一步真正决定并发上限的是索引和事务的设计。stock_count的更新是典型的“热点行更新”InnoDB 在默认的REPEATABLE READ隔离级别下对同一行的 update 会加行锁并发请求会排队执行。这意味着即使你开了 100 个数据库连接同一件商品的扣库存请求依然是串行的——这不是坏事它天然保证了不超卖但吞吐量会被锁等待拉低。2.2 扣减库存的三种 SQL 方案对比扣库存是所有秒杀系统都必须面对的核心操作网上讨论最多的三种写法我实际都跑过这里直接说结论。第一种是“先查后改”SELECT stock_count FROM seckill_goods WHERE id 1; -- 假设查到 stock_count 10 UPDATE seckill_goods SET stock_count 9 WHERE id 1;这是最常见的错误写法两个请求同时读到 10各自减一最终库存变成 9一条数据被卖了两件。在并发下这条 SQL 必现超卖没有任何讨论价值但很多入门教程还在这么教需要注意这一点。第二种是“乐观锁 版本号”UPDATE seckill_goods SET stock_count stock_count - 1, version version 1 WHERE id 1 AND stock_count 0 AND version #{oldVersion};这种写法依靠update影响的行数来判断是否扣减成功如果返回 0 说明版本冲突或者库存不足程序里重试即可。优点是并发度高缺点是多次重试会增加数据库压力秒杀这种“失败就不要再试”的场景其实不太适合。第三种是“条件更新直接扣”UPDATE seckill_goods SET stock_count stock_count - 1 WHERE id 1 AND stock_count 0;这条 SQL 依靠stock_count 0这个条件在数据库层面拦截超卖配合 InnoDB 的行锁同一时刻只有一个请求能成功执行这次更新。执行完检查Affected rows为 1 才继续写订单。这是我在秒杀系统里推荐的做法单条 SQL 原子性天然成立代码里不需要额外加锁。2.3 事务隔离级别与回滚的边界扣库存和写订单应该在同一个事务里这是硬性要求。SpringBoot 里用Transactional可以解决但要注意事务的边界不能拉太长——事务里如果有远程调用、消息发送这类耗时操作数据库连接会被占住连接池很快耗尽。Transactional(rollbackFor Exception.class) public boolean doSeckill(Long userId, Long goodsId) { int updated seckillGoodsMapper.reduceStock(goodsId); if (updated 0) { return false; } SeckillOrder order new SeckillOrder(); order.setUserId(userId); order.setGoodsId(goodsId); order.setStatus(0); return orderMapper.insert(order) 0; }这段代码的逻辑是先扣库存扣成功才写订单任何一步抛异常事务回滚库存自动恢复。需要注意的是updated 0时直接返回 false不能抛出异常否则会触发事务回滚——虽然结果一样但抛异常在日志里会变成一堆让人误判的 ERROR 记录。提示数据库单表单事务的 TPS 上限大约在每秒几千量级这已经能满足大部分秒杀场景。如果你的压力测试显示数据库连接池被打满优先检查是不是事务里混入了无关操作而不是急着上 Redis。3. Redis 预扣库存让请求快过数据库3.1 缓存预热与库存预加载数据库能扛的量级在几千 TPS但对秒杀来说这不够。一个 10 万人同时在线的活动瞬时 QPS 可能到几万甚至十几万如果全部打到数据库连接池一分钟内必然被打穿。常见的做法是引入 Redis 做前置库存层把数据库的判断和扣减推迟到流量平稳之后。缓存预热在秒杀开始前就要完成。写一个PostConstruct初始化的任务或者在项目启动时执行把商品库存加载到 RedisSET seckill:stock:1001 100 SET seckill:start:1001 1700000000 SET seckill:end:1001 1700003600这里用了三组 keyseckill:stock:{goodsId}存剩余库存seckill:start和seckill:end存开始结束时间戳。时间戳存成字符串方便后续用 Lua 脚本做原子判断。预热完成后需要把商品状态也缓存一份比如seckill:status:1001 1表示可抢避免每次请求都去数据库查活动状态。预热还有一个容易被忽略的细节库存预热后数据库中的stock_count不能清零而是作为 Redis 扣减完之后的“对账基线”存在。后面讲补偿机制时你会看到数据库库存的意义是“最终能卖多少”Redis 库存的意义是“当前还剩多少”两者通过异步消息最终一致。3.2 Lua 脚本保证扣减的原子性Redis 的DECR命令虽然原子但“判断是否有库存再扣减”这两步组合在一起就不是原子的了。如果多个请求同时执行GET和DECR依然会出现超卖。解决方法是把判断和扣减写进同一个 Lua 脚本Redis 执行脚本是串行的天然解决竞态。-- keys[1]: seckill:stock:{goodsId} -- keys[2]: seckill:start:{goodsId} -- keys[3]: seckill:end:{goodsId} -- ARGV[1]: 当前时间戳 if tonumber(ARGV[1]) tonumber(redis.call(GET, KEYS[2])) then return -1 end if tonumber(ARGV[1]) tonumber(redis.call(GET, KEYS[3])) then return -2 end local stock tonumber(redis.call(GET, KEYS[1])) if stock 0 then return -3 end redis.call(DECR, KEYS[1]) return 0脚本先校验活动时间再检查剩余库存最后执行扣减。返回 -1 表示活动未开始-2 表示已结束-3 表示已抢完0 表示扣减成功。SpringBoot 里通过DefaultRedisScript调用这段脚本核心代码DefaultRedisScriptLong script new DefaultRedisScript(); script.setScriptText(seckillLuaScript); script.setResultType(Long.class); Long result redisTemplate.execute(script, Arrays.asList(seckill:stock: goodsId, seckill:start: goodsId, seckill:end: goodsId), System.currentTimeMillis() / 1000);这段代码里execute的第一个参数是脚本对象第二个是 key 列表第三个是参数数组。setResultType(Long.class)是必须的否则 Spring 的 RedisTemplate 会把返回值当字符串处理和 Integer 类型比较时很容易踩坑引发隐藏的 NPE。扣减成功后订单处理走异步队列这一点在后面展开。3.3 库存回滚与补偿机制Redis 扣减成功不代表订单一定生成成功。用户下单后可能取消支付也可能在写订单表时数据库异常这时候需要把库存“还回去”。常见的做法是发送一条 MQ 消息延迟 15 分钟检查订单状态如果超时未支付则回补库存。public void compensateStock(Long orderId, Long goodsId) { // 判断订单是否已支付 SeckillOrder order orderMapper.selectByPrimaryKey(orderId); if (order ! null order.getStatus() 0) { // 订单未支付回补库存 redisTemplate.opsForValue().increment(seckill:stock: goodsId, 1); // 更新订单状态为已取消 orderMapper.updateStatus(orderId, -1); } }回补库存时用increment而不是SET避免覆盖掉其他用户的正常扣减。这条逻辑的核心是“库存以 Redis 为准订单以数据库为准”两者通过定时任务最终一致。另一个需要注意的点是回补的库存量要同时更新数据库中的stock_count否则下次服务重启加载缓存时库存数会被旧值覆盖。提示补偿任务的延迟时间通常是支付超时时间的 1/5 到 1/3比如支付超时 30 分钟补偿检查可以每 6 分钟跑一次这样用户取消支付后库存能较快释放又不会打爆数据库。4. SpringBoot 接口与 Vue 页面联调完整链路落地4.1 SpringBoot 核心接口与参数配置后端接口设计遵循一条原则秒杀开始前和结束后请求不要进入扣库存逻辑。接口入口先做时间判断、用户登录校验再做 Redis 预扣最后发 MQ 异步写订单。RestController RequestMapping(/api/seckill) public class SeckillController { Autowired private StringRedisTemplate stringRedisTemplate; PostMapping(/{goodsId}) public ResultLong seckill(PathVariable Long goodsId, RequestAttribute(userId) Long userId) { // 1. 校验用户登录态拦截器中处理这里直接取 userId // 2. 检查该用户是否已下单避免重复秒杀 Boolean ifAbsent stringRedisTemplate.opsForValue() .setIfAbsent(seckill:user: userId : goodsId, 1, 30, TimeUnit.SECONDS); if (Boolean.FALSE.equals(ifAbsent)) { return Result.error(请勿重复提交); } // 3. Lua 脚本扣减库存 Long result executeSeckillLua(goodsId); if (result 0) { // 4. 发送 MQ 异步创建订单 seckillProducer.send(goodsId, userId); return Result.success(抢购成功正在生成订单); } return Result.error(商品已抢完); } }这里有一个容易忽略的细节setIfAbsent的过期时间设为 30 秒是为了防止用户在第一秒抢成功后刷新页面再次提交。30 秒后如果订单还没生成成功用户可以再次请求不影响正常使用。如果你的系统要求更严的“一人一单”这个幂等 key 的过期时间应该设置为支付超时时间或者在订单表里加唯一索引兜底。SpringBoot 配置文件里有几个参数值得单独调。server.tomcat.threads.max默认是 200秒杀场景建议调到 500 到 800但不要超过 1000否则上下文切换开销会抵消收益。spring.datasource.hikari.maximum-pool-size控制在 20 到 50连接数过多反而会让数据库 CPU 争抢加剧。这两个参数是秒杀系统最容易忽略的性能瓶颈也是面试时能体现项目深度的细节。4.2 Vue 前端页面与路由参数传递前端 Vue 页面承担两个任务展示秒杀倒计时和提交抢购请求。倒计时用原生 JavaScript 实现需要你注意页面卸载时清除定时器否则组件销毁后定时器还在跑控制台会报内存泄漏警告。template div classseckill-container h2{{ goodsName }}/h2 p当前库存{{ stockCount }}/p p v-ifcountdown 0距离开始还有 {{ formatTime(countdown) }}/p button v-else :disabledseckilling clickdoSeckill {{ seckilling ? 正在抢购... : 立即秒杀 }} /button /div /template script export default { data() { return { goodsId: this.$route.params.goodsId, countdown: 0, seckilling: false }; }, created() { this.loadSeckillInfo(); this.startCountdown(); }, methods: { async doSeckill() { this.seckilling true; try { const res await this.$http.post(/api/seckill/${this.goodsId}); if (res.data.code 0) { this.$message.success(抢购成功请尽快支付); } else { this.$message.error(res.data.msg); } } finally { this.seckilling false; } } }, beforeDestroy() { clearInterval(this.timer); } }; /scriptthis.$route.params.goodsId是 Vue Router 的动态路由参数路由定义里对应path: /seckill/:goodsId。created生命周期里同时启动信息加载和倒计时但要注意如果接口返回的startTime在未来倒计时应该显示距离开始的时间如果已经开售按钮直接变为可点击状态。按钮的disabled绑定要同时考虑“活动未开始”和“请求已发出”两种状态否则用户有可能在倒计时结束时连续点击发出多个请求。前端的防连点永远只能减少请求量真正的幂等控制必须依靠后端这个先后顺序在实际面试中经常被追问到。4.3 前后端联调中的异常处理与状态码约定前后端联调最容易出问题的不是功能逻辑而是状态码约定。我一般会约定三种全局状态0成功、-1业务失败、-2系统异常。秒杀场景额外增加-3表示排队中。前端 axios 拦截器统一处理这些状态码// Vue main.js 中配置 axios 拦截 service.interceptors.response.use( (response) { const res response.data; if (res.code -2) { this.$message.error(系统繁忙请稍后重试); return Promise.reject(new Error(res.msg)); } return res; }, (error) { this.$message.error(网络异常请检查后端服务); return Promise.reject(error); } );联调阶段常碰到的跨域问题在 SpringBoot 里一般用全局配置类解决重点注意允许的请求头和请求方法不要写死为GET和POST以外的值。体现技术水准的细节在于秒杀接口要显式把OPTIONS请求放行否则浏览器预检请求会直接 403这种现象表现为“别人能抢你不能抢”。5. 压测验证与反作弊技巧从能跑到扛得住5.1 用 JMeter 验证削峰效果和并发正确性系统写完后需要证明两件事并发下不超卖、吞吐量达标。压测工具用 JMeter 即可配置一个线程组模拟 2000 个并发用户每个用户只发一次请求。结束后检查三组数据订单总数是否等于库存消耗数、Redis 剩余库存是否为 0、数据库stock_count是否为 0。如果三者不一致说明补偿机制有遗漏。压测中发现一个常见坑1 秒内 2000 个请求打过来前端页面会有一批请求返回“网络异常”。这不是后端崩溃而是 Tomcat 默认的accept-count只有 100超过后直接拒绝连接。调大server.tomcat.accept-count到 1000同时把max-connections提到 2000可以缓解这个问题。这两个参数是秒杀压测的必调项。5.2 接口防刷与限流参数防刷和限流是秒杀系统的安全底线也是 java 面试八股文里最容易被拷问的实战点。常见的做法是接口限流加用户黑名单。Component public class RateLimitInterceptor implements HandlerInterceptor { Autowired private StringRedisTemplate redisTemplate; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String userId request.getHeader(userId); String key seckill:limit: userId; Long count redisTemplate.opsForValue().increment(key); if (count 1) { redisTemplate.expire(key, 10, TimeUnit.SECONDS); } if (count 5) { response.setStatus(429); return false; } return true; } }这个拦截器限定了单个用户 10 秒内最多请求 5 次increment是原子的多个请求同时进入时不会出现计数丢失。如果count超过阈值直接返回 HTTP 429前端拦截器统一弹出“操作过于频繁”的提示。黑名单机制则记录连续失败超过 20 次的用户 IP封禁 1 小时防止脚本刷接口。真实的秒杀场景通常还会加一个验证码接口把验证码 ID 存 Redis用户提交时校验。有的系统会采用点击按钮后前端禁用 2 秒这种体验优化把峰值流量均匀摊开配合限流器能让单机轻松扛住上万 QPS。秒杀系统的最后一块拼图永远是“能过压测的超卖校验 扛得住抖动的限流策略”面试问到这块时把压测数据和参数调整过程讲清楚比背十道基础题更有说服力。本文还有配套的精品资源点击获取
返回列表