做过电商类项目的人应该都有同感:普通商城系统是“能跑就行”,秒杀商城是“一上线就暴露人性”——瞬间涌入的流量、反复点击的狂热、机器人脚本的疯狂刷新,每一层都在考验系统的设计边界。这篇项目实录围绕“springboot+vue基于web的电商产品秒杀商城网站管理系统”的完整开发过程展开,梳理从技术选型到最终上线的关键环节,包括库存扣减方案、接口幂等、缓存策略、前端倒计时状态管理这些真正决定成败的细节。不管你是在做毕业设计、接外包项目,还是公司内部要搞一个营销活动页,这篇文章都值得你花十分钟看完,很多坑我替你踩过了。
1. 一个秒杀项目背后的技术栈选择逻辑:SpringBoot+Vue为什么是主流组合
很多初学者拿到“电商秒杀商城”这个题目,第一反应是开始写代码。但我在接过不少类似项目后,越来越确认一个结论:先搞清楚技术栈的选型逻辑,比先写三个接口重要得多。
1.1 后端为什么是SpringBoot而不是SSH、SSM
不是SpringBoot有多炫技,而是它在“快速交付+易于维护”之间找到了最佳平衡点。早期我们用SSH(Struts+Spring+Hibernate)或者SSM(Spring+SpringMVC+MyBatis)做项目,配置文件的繁琐程度能让人崩溃:数据源、事务管理器、视图解析器、扫描器,每一样都要手写XML。SpringBoot把这些约定成俗的配置都自动化了,尤其对我们这种需要兼顾前后端分离、还要快速迭代秒杀活动的项目来说,省下的时间可以用来打磨真正的业务逻辑。
另外一个不得不提的理由是生态。秒杀场景需要Redis做缓存、RabbitMQ削峰、定时任务做商品预热、分布式锁解决并发问题,这些组件在SpringBoot下都有非常成熟的starter支持。尤其是Spring Boot 2.x版本之后,整合Redis、MongoDB、Elasticsearch这些中间件基本就是加依赖、写配置、注入模板类三件事,不需要自己手工拼Jedis连接池。
1.2 前端为什么选Vue而不是JSP、Thymeleaf
这个项目是前后端分离的,前端部分选了Vue全家桶(Vue Router + Vuex + Axios)。JSP和Thymeleaf这种服务端渲染方案的痛点在于:每次用户点击都要刷新整个页面,秒杀倒计时、库存变化这些需要频繁更新的数据没办法做到局部刷新,而且高并发下服务端渲染压力也大。
Vue的核心优势是响应式数据绑定和组件化开发。像秒杀商品列表、倒计时组件、订单结算弹窗、用户登录状态,全部拆成独立组件,开发时互不干扰,编译后就是纯静态文件,可以扔到Nginx上做CDN加速,极大分担应用服务器的压力。对于商城类的项目,Vue生态中还有Element UI这种现成的组件库,后台管理系统的表格、表单、弹窗一套组合拳下来,页面开发效率直接拉满。
实际项目中我一般用Vue 2.6 + Element UI + Axios这套组合,稳定、资料多、坑少。如果你刚接触Vue,不建议一上来就上Vue 3 + TypeScript + Pinia的全家桶,很多插件和教程默认给的是Vue 2的版本,边做边查成本低很多。
1.3 数据库与中间件的选择
数据库这块,MySQL 8.0是标配。商品信息、用户信息、订单信息都是结构化数据,MySQL的事务机制能保证数据一致性,配合InnoDB的行锁在低并发下也足够用。
Redis在整个系统里承担的角色不是“缓存装饰品”,而是秒杀系统的性能核心。我把秒杀商品的库存预加载到Redis,用原子操作来扣减库存,再配合过期时间完成商品预热和自动下架,具体方案后面单独开一章详细拆。
消息队列选了RabbitMQ,主要用来做订单创建的异步化。秒杀请求进来后,先进行资格校验和库存扣减,扣减成功后把“创建订单”这个操作丢到消息队列里,让消费者异步处理订单落库和库存状态更新。这样就算瞬间来了10万请求,后端数据库也不会被打爆。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 秒杀系统的核心业务闭环与数据库设计:从商品预热到订单生成的完整链路
秒杀系统的业务链路看似简单——用户抢购、扣库存、生成订单——但真正落地的时候,每一步都有江湖。
2.1 核心业务流程梳理
我先给这个系统定义了完整的业务闭环,后面所有代码都是在围绕这条链路走:
- 运营人员在后台添加秒杀商品,设置秒杀开始时间、结束时间、秒杀总数和限购数量。
- 系统通过定时任务在秒杀开始前把商品信息、库存数量预热到Redis。
- 用户进入秒杀页面,看到倒计时和商品详情,点击“立即秒杀”。
- 后端接口先校验用户是否登录、秒杀是否开始、用户是否重复下单。
- 通过校验后,Redis原子扣减库存,扣减成功则发送MQ消息创建订单。
- 订单消费者异步写订单表,同时更新数据库中的商品库存。
- 前端通过轮询或订单查询接口,判断用户是否秒杀成功。
这套流程把“高并发写操作”和“常规业务操作”彻底分离开,热点数据在Redis里快速处理,非热点数据(订单、用户)在MySQL里慢慢持久化。
2.2 数据库表结构设计
数据库设计直接决定系统能撑到多大的并发量。我这里给出秒杀系统最核心的几张表,你在设计自己的系统时可以直接参考。
用户表(user)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键,自增 |
| username | varchar(32) | 用户名,唯一索引 |
| password | varchar(128) | 密码(BCrypt加密) |
| phone | varchar(11) | 手机号 |
| create_time | datetime | 创建时间 |
秒杀商品表(seckill_goods),这个表存的是参与秒杀活动的商品信息,与普通商品表分开的好处是,秒杀商品有独立的库存、独立的开始结束时间,不会影响普通商品表。
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| goods_id | bigint | 关联普通商品ID |
| seckill_price | decimal(10,2) | 秒杀价格 |
| stock_count | int | 秒杀库存 |
| start_time | datetime | 开始时间 |
| end_time | datetime | 结束时间 |
| version | int | 乐观锁版本号 |
秒杀订单表(seckill_order),用于记录用户抢购成功的订单信息,这里有一个重要的唯一约束:(user_id, goods_id),用来避免用户重复下单。
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| user_id | bigint | 用户ID |
| goods_id | bigint | 商品ID |
| order_id | bigint | 关联的订单ID |
| create_time | datetime | 创建时间 |
| unique key uk_user_goods(user_id, goods_id) | 防重复下单 |
2.3 数据库层面防超卖的兜底设计
Redis扣减库存是高性能方案,但任何技术方案都不能保证100%可靠,如果Redis异常或者消息丢失,数据库的库存数据就会不准确。所以我做了一刀双保险:数据库里的秒杀商品表也加了防超卖逻辑。
具体做法是更新库存的时候,SQL语句里加上库存大于0的判断条件:
sql复制UPDATE seckill_goods
SET stock_count = stock_count - 1, version = version + 1
WHERE goods_id = #{goodsId}
AND stock_count > 0
AND version = #{version}
这条SQL的作用是用数据库层面的乐观锁保证,只有当库存大于0的时候才允许扣减。不管前面的Redis处理得多快,最终落到数据库的更新操作不会把库存扣成负数。这就是为什么秒杀系统可以依赖Redis做高性能处理,但数据库表设计也不能偷懒的原因。
2.4 为什么给订单表加唯一约束
在实际项目里,我遇到过用户狂点秒杀按钮导致同一用户生成多条秒杀订单的问题。原因就是前端做了防重复点击,但懂技术的人完全可以绕过前端直接请求接口。所以我在秒杀订单表上设计了(user_id, goods_id)的唯一联合索引。这样一来,即使同一用户并发发起多个秒杀请求,数据库层也只会有一条订单记录成功插入,其他请求直接报唯一键冲突,程序里捕获这个异常后返回“您已参与过该商品的秒杀”。
这个设计是秒杀系统里性价比最高的一个防御机制,不花一分钱硬件资源,就把重复下单的问题从根上堵死了。
3. 防超卖的库存扣减方案:数据库悲观锁、乐观锁与Redis预扣库存的对比
“库存扣减”是秒杀系统最核心的技术难点。如果扣多了,商家亏本;扣少了,用户投诉;扣重了,系统崩溃。这一章我把自己实际调研和实验过的三种方案全部分享出来,包括它们各自的实现思路、优缺点和适用场景。
3.1 方案一:数据库悲观锁(for update)
最简单粗暴的做法是:用户请求进来后,先用SELECT * FROM seckill_goods WHERE id = ? FOR UPDATE把这一行锁住,其他事务必须等当前事务提交后才能读取和修改这条记录。
java复制@Transactional
public boolean seckillByPessimisticLock(Long goodsId, Long userId) {
SeckillGoods goods = seckillGoodsMapper.selectByIdForUpdate(goodsId);
if (goods.getStockCount() <= 0) {
return false;
}
int update = seckillGoodsMapper.reduceStock(goodsId);
if (update > 0) {
seckillOrderMapper.insert(new SeckillOrder(userId, goodsId));
return true;
}
return false;
}
这个方案的好处是绝对安全,不会出现超卖。坏处是性能极其低下:数据库的行锁机制会阻塞大量并发事务,秒杀场景下的QPS一高,数据库连接池瞬间被占满,其他业务直接降级不可用。
结论:性能太差,不建议用于秒杀场景,适合后台管理、库存修改之类的低并发操作。
3.2 方案二:数据库乐观锁(版本号控制)
乐观锁的核心思想是“先更新,后检测冲突”。每次更新库存时带上版本号,如果版本号不匹配则更新失败。
sql复制UPDATE seckill_goods
SET stock_count = stock_count - 1, version = version + 1
WHERE goods_id = ? AND version = #{version}
它的优势是不使用数据库锁,并发处理能力比悲观锁强很多。但问题也很明显:多个请求同时读到相同版本号,更新时只有第一个能成功,其他请求全部失败。在秒杀这种超高并发的场景下,乐观锁的失败率会非常高,用户体验差,用户体验差,容易引发“永远抢不到”的投诉。
结论:比悲观锁好,但并不是秒杀场景的最优解,适用一些库存充裕、冲突概率相对低的场景。
3.3 方案三:Redis预扣库存(最终采用方案)
Redis的单线程模型天然避免了并发竞争问题,而且DECR命令是原子操作,可以安全地进行库存扣减。
我最终采用的流程是这样的:
- 秒杀活动开始前,通过定时任务将数据库中的库存数量加载到Redis中,键为
seckill:stock:{goodsId}。 - 用户请求进入秒杀接口时,先从Redis判断活动是否开始、用户是否重复下单。
- 执行
DECR seckill:stock:{goodsId}原子扣减,返回值如果大于等于0,说明扣减成功;如果小于0,说明库存不足,需要执行INCR回补库存。 - 扣减成功后,发送MQ消息,消费者异步去MySQL创建订单。
java复制@Autowired
private StringRedisTemplate stringRedisTemplate;
public boolean reduceStock(Long goodsId) {
String stockKey = "seckill:stock:" + goodsId;
Long stock = stringRedisTemplate.opsForValue().decrement(stockKey);
if (stock != null && stock >= 0) {
// 扣减成功
return true;
} else {
// 库存不足,回补
stringRedisTemplate.opsForValue().increment(stockKey);
return false;
}
}
这里有一个容易忽略的细节:DECR操作一定要判断返回值是否小于0,小于0时必须INCR回补。因为Redis的下限是Long.MIN_VALUE,不会自动停在那里,如果不回补,库存数据就漂移了。
结论:Redis预扣库存是目前秒杀系统的主流方案,把库存扣减的瓶颈从数据库转移到了Redis,性能提升了几个量级。 后续再配合MQ异步创建订单,整个写链路对数据库的冲击就非常小。
3.4 三种方案的对比总结
| 方案 | 防超卖 | 并发性能 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|
| 数据库悲观锁 | 强 | 低 | 低 | 后台低并发操作 |
| 数据库乐观锁 | 强 | 中 | 中 | 库存充足、冲突率低 |
| Redis预扣库存 | 强 | 高 | 高 | 秒杀、抢购等高并发场景 |
4. 秒杀接口的性能瓶颈与异步化改造:消息队列削峰与订单解耦
秒杀系统最怕的不是并发高,而是瞬间高并发+数据库写入并发的组合拳。本章重点讲如何用消息队列把秒杀接口和订单创建解耦,实现真正的削峰填谷。
4.1 同步处理为什么不行
先看一下不做异步化时,秒杀接口的调用链路:用户请求 -> 校验权限 -> 扣减库存 -> 插入订单 -> 更新数据库库存 -> 返回结果。整个链路中插入订单和更新库存的操作都要写MySQL,而MySQL的写入QPS通常只有几千到一万多。
假设秒杀活动有10万用户同时抢购100件商品,同步处理的后果是:瞬间1万QPS打到数据库,数据库连接池耗尽,行锁竞争激烈,慢查询暴增,最终服务雪崩。更麻烦的是,高并发下事务保持时间越长,死锁的概率越大,整个数据库都可能被拖垮。所以,同步处理在秒杀场景下就是死路一条。
4.2 引入消息队列后的异步化流程
我引入RabbitMQ之后,将“扣减库存”和“创建订单”两步解耦,流程变成:
- Redis扣减库存成功(高性能、快速响应)。
- 后端接口立即返回“正在排队中,请稍后查看结果”。
- 同时将
SeckillMessage(userId, goodsId)发送到RabbitMQ的秒杀队列。 - 消费者监听队列,本地事务中执行创建订单、更新数据库库存两步操作。
- 用户通过订单查询接口或者前端轮询获取最终秒杀结果。
java复制// 秒杀接口核心逻辑
public Result<String> seckill(Long goodsId) {
// 校验登录态
User user = userHolder.get();
if (user == null) {
return Result.error("请先登录");
}
// 校验秒杀活动是否开始
if (!checkSeckillTime(goodsId)) {
return Result.error("秒杀尚未开始或已结束");
}
// 防止重复下单
if (checkDuplicateOrder(user.getId(), goodsId)) {
return Result.error("您已参与过该商品的秒杀");
}
// Redis原子扣减库存
if (!redisStockService.reduceStock(goodsId)) {
return Result.error("商品已抢完");
}
// 发送MQ消息,异步创建订单
try {
SeckillMessage message = new SeckillMessage(user.getId(), goodsId);
rabbitTemplate.convertAndSend("seckill.exchange", "seckill.order", message);
return Result.success("排队中,请稍后查看订单结果");
} catch (Exception e) {
// 消息发送失败,回补Redis库存
stringRedisTemplate.opsForValue().increment("seckill:stock:" + goodsId);
return Result.error("系统繁忙,请重试");
}
}
这段代码的关键点是消息发送失败后的库存回补。我自己第一次写的时候漏了这个逻辑,导致压测时Redis库存已经被扣减了,但订单没有创建成功,很多用户什么都没抢到,系统还以为库存还有货。后来排查了半天才定位到是MQ异常导致的消息丢失。
4.3 消费者端实现:事务边界与幂等处理
消费者端的核心代码是@RabbitListener注解监听秒杀队列,然后在一个事务方法里完成订单创建和库存更新。
java复制@Component
@Slf4j
public class SeckillOrderReceiver {
@Autowired
private SeckillOrderService seckillOrderService;
@RabbitListener(queues = "seckill.order.queue")
public void receiveMessage(SeckillMessage message) {
try {
seckillOrderService.createOrder(message.getUserId(), message.getGoodsId());
} catch (DuplicateKeyException e) {
// 唯一索引兜底,说明重复消息或重复下单,忽略即可
log.warn("用户:{} 重复秒杀商品:{}", message.getUserId(), message.getGoodsId());
} catch (Exception e) {
log.error("创建秒杀订单失败", e);
// 这里根据业务决定是否重试,为了可靠性可以重新入队
}
}
}
消费者端必须做幂等处理。因为RabbitMQ消息可能因为网络抖动、消费者异常等原因被重新投递,如果不做幂等,同一个用户可能被创建两次订单。解决方案就是我刚才在数据库设计里提到的唯一联合索引,即使重复消息过来,数据库也会因为唯一键冲突拦截下来,代码里捕获DuplicateKeyException即可。
4.4 前端如何获取秒杀结果
既然秒杀接口变成了异步处理,前端就不能简单拿到“成功/失败”的结果了。我采用的是前端轮询方案:
- 用户点击秒杀按钮后,后端返回“排队中”的状态,前端开启定时轮询,每隔2秒请求一次订单查询接口。
- 如果订单已生成,返回抢购成功,跳转到订单详情页。
- 如果超时未生成,返回抢购失败,提示用户下次再来。
当然,更平滑的方案是用WebSocket推送秒杀结果,服务端在订单创建成功后主动通知前端。但在大多数秒杀场景下,轮询已经足够,还能省去维护WebSocket连接的开销。尤其是WebSocket在高并发下连接数本身就是一个隐性瓶颈,所以初期项目先上轮询,后续优化空间很大。
5. 前端Vue的倒计时竞态与防重复提交:页面交互细节决定成败
很多人把秒杀系统前端想简单了——“就是显示个倒计时,点击按钮调接口嘛”。但真正做完你会发现,前端有很多隐藏的“坑”,稍不注意用户就会在活动还没开始时疯狂刷接口,或者活动已经结束页面还残留可点击按钮,这些都是被技术Leader重点review的点。
5.1 倒计时:用服务器时间,不用本地时间
秒杀系统前端最容易犯的错误,是用new Date()获取本地时间来计算倒计时。用户的电脑时间可能比服务器快一分钟或慢两分钟,慢的用户会在活动还没开始时看到“立即秒杀”按钮处于可点击状态,快一点用户会错过开抢时机。
正确的做法是:页面初始化时先请求后端获取当前服务器时间,然后结合本地时间戳计算偏移量,后续倒计时全部基于“服务器时间 + 本地时间差值”来驱动。
javascript复制// 获取服务器时间
const res = await axios.get('/api/currentTime');
const serverTime = res.data.data;
// 计算本地与服务器时间偏移量
const offset = serverTime - Date.now();
// 倒计时更新函数
setInterval(() => {
const currentTime = Date.now() + offset;
const distance = this.seckillStartTime - currentTime;
if (distance > 0) {
// 计算时分秒
this.countdown = format(distance);
this.isStart = false; // 按钮置灰
} else {
this.countdown = '00:00:00';
this.isStart = true; // 按钮可点击
}
}, 1000);
还有一点很容易被忽略:轮询接口的频率不要死板地写死为1秒。我实际测试发现,倒计时组件每秒执行一次即可,但接口轮询最好放到2~3秒一次,否则用户同时开着多个浏览器标签页时,整个系统会平白多出大量无意义的请求流量。
5.2 防止重复提交的多层拦截
前端防重复提交是秒杀系统的“第一道防线”。虽然后端有幂等处理,但前端不能给后端增加无畏的压力。
我在按钮上做了三层拦截:
第一层是按钮禁用:点击后立刻将按钮disabled置为true,直到接口返回或者超时后再恢复。
第二层是请求加锁:使用一个isSubmitting布尔变量,防止用户在接口响应前重复点击触发新的请求。
第三层是状态轮询覆盖:秒杀请求成功进入“排队中”后,页面立即切换到“查询结果”状态,同一商品不再允许二次点击。
javascript复制async handleSeckill() {
if (this.isSubmitting) return;
if (!this.isStart) {
this.$message.warning('秒杀还未开始');
return;
}
this.isSubmitting = true;
try {
const res = await seckillApi.submit(this.goodsId);
if (res.data.code === 200) {
this.$message.success('已进入排队,请稍后查看结果');
// 开启轮询查询订单结果
this.startPolling();
} else {
this.$message.error(res.data.msg);
}
} finally {
this.isSubmitting = false;
}
}
5.3 商品列表与详情页的缓存与懒加载
秒杀活动的商品列表页,数据变动非常快。刚开始我做的是每次进入页面都请求后端接口,后来发现MySQL和Tomcat的压力巨大,商品列表数据其实变化没那么频繁,完全可以放到前端做缓存。
我最终的方案是:商品列表页使用Vuex做全局状态管理,首次加载后缓存30秒,30秒内再次访问直接使用缓存数据。秒杀商品详情页则使用动态路由/seckill/:goodsId,配合KeepAlive组件缓存页面状态,避免用户来回切换时反复请求接口、重新初始化倒计时。
5.4 秒杀开始前与结束后的前端状态
活动开始前后,页面状态也要精心处理。这里整理了我在项目中总结的状态切换清单:
| 状态 | 倒计时 | 按钮文案 | 按钮可点击 |
|---|---|---|---|
| 未开始 | 显示剩余时间 | “即将开始” | 否 |
| 进行中 | 显示00:00:00 | “立即秒杀” | 是 |
| 已抢完 | 显示活动结束 | “已抢完” | 否 |
| 已下单 | 隐藏倒计时 | “查看订单” | 调整至订单页 |
这些状态的切换逻辑全部封装在一个Vue组件中,通过props接收从后端返回的商品状态字段,一次开发、多处复用,后续加新的秒杀活动页面不需要再重复写状态判断代码。
6. Redis缓存击穿、穿透与雪崩的应对:压测时死磕出来的经验
Redis不是万能药,用不好反而会变成系统的“阿喀琉斯之踵”。这一节聊聊我在这个项目里实际遇到、修复的缓存问题。
6.1 缓存穿透:用户疯狂请求不存在的商品
缓存穿透指的是查询一个必然不存在的数据(比如一个不存在的商品ID),由于缓存中没有数据,每次请求都要去查数据库,导致数据库压力暴增。
我遇到的情况是压测时模拟客户端大量请求不存在的商品ID,导致MySQL的查询QPS暴涨。解决这个问题有两个思路:
第一种是缓存空值:如果某个查询在数据库中不存在,也把空结果缓存起来,设置一个较短的过期时间(比如2~5分钟)。这样后续同样的请求就不会直接打到数据库。
第二种是布隆过滤器:把所有合法商品ID提前加载到布隆过滤器里,查询前先判断ID是否存在,不存在则直接返回。这种方式更节省内存,但实现复杂度更高。对于秒杀这种商品数量固定且不多的场景,我建议用缓存空值方案,简单有效。
java复制public SeckillGoods getSeckillGoods(Long goodsId) {
// 1. 先查缓存
SeckillGoods goods = stringRedisTemplate.opsForValue().get("seckill:goods:" + goodsId);
if (goods != null) {
return goods;
}
// 2. 缓存为空,查数据库
goods = seckillGoodsMapper.selectById(goodsId);
if (goods == null) {
// 3. 缓存空值,防止穿透
stringRedisTemplate.opsForValue().set("seckill:goods:" + goodsId, "", 5, TimeUnit.MINUTES);
return null;
}
// 4. 写入缓存
stringRedisTemplate.opsForValue().set("seckill:goods:" + goodsId, JSON.toJSONString(goods), 30, TimeUnit.MINUTES);
return goods;
}
6.2 缓存击穿:热点商品缓存过期瞬间的打爆
缓存击穿是秒杀场景最典型的问题。某个热点秒杀商品的缓存正好在某一刻过期,此时大量用户同时请求这个商品,所有请求同时发现缓存失效,直接打到数据库,数据库瞬间崩溃。
解决击穿的核心思想是互斥锁或逻辑过期。
互斥锁的方案是:当缓存失效时,不是所有请求都去查数据库,而是先尝试获取分布式锁,只有拿到锁的请求才能查数据库并更新缓存,其他请求先短暂等待,然后重新读取缓存。
java复制public SeckillGoods getGoodsWithLock(Long goodsId) {
SeckillGoods goods = getFromCache(goodsId);
if (goods != null) {
return goods;
}
// 尝试获取分布式锁
Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock:goods:" + goodsId, "1", 5, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(lock)) {
try {
goods = getFromDB(goodsId);
setCache(goodsId, goods);
return goods;
} finally {
stringRedisTemplate.delete("lock:goods:" + goodsId);
}
}
// 没拿到锁,短暂sleep后再查缓存
Thread.sleep(50);
return getFromCache(goodsId);
}
还有一种更轻量的方案是逻辑过期:缓存中不设置物理过期时间,而是存入一个过期时间戳字段。每次读取时判断逻辑时间是否过期,过期后由独立线程去重建缓存,其他请求直接返回旧数据。这个方案对原子性要求较高,适合我这种对Redis比较熟悉、想减少数据库压力的场景。
6.3 缓存雪崩:大量缓存同时过期的连锁反应
缓存雪崩是指大量缓存在同一时间段内集中过期,导致大量请求同时穿透到数据库。秒杀场景下,多个秒杀商品如果设置了相同的过期时间,非常容易触发雪崩。
解决方案很简单:给缓存过期时间加一个随机偏移量,避免缓存同时失效。
java复制// 随机过期时间,基础值 + 随机数,避免雪崩
int baseExpire = 30 * 60;
int randomExpire = baseExpire + new Random().nextInt(10 * 60);
stringRedisTemplate.opsForValue().set(key, value, randomExpire, TimeUnit.MINUTES);
6.4 压测时我踩到的Redis连接池耗尽问题
最后这条经验很具体,也很有价值。压测时我发现在高并发下,系统会时不时报出“JedisConnectionException: Could not get a resource from the pool”异常。排查后发现是Redis连接池的max-total和max-idle配置太小,高并发下连接被占满,新的请求拿不到连接直接报错。
那时候真是万万没想到,系统本身的逻辑代码都扛住了,最后被Redis连接池这个小配置卡了脖子。调整之后,QPS从两千多直接拉到八千多,这波操作让我意识到,中间件的连接池配置同样是性能调优的重点。
code复制spring.redis.jedis.pool.max-active=100
spring.redis.jedis.pool.max-idle=50
spring.redis.jedis.pool.min-idle=20
如果你做压测发现Redis相关异常,第一反应就去看连接池监控,往往能快速定位问题。
7. 秒杀系统的安全防护:接口防刷、恶意脚本与数据一致性加固
秒杀系统是最容易招黑产的业务之一。羊毛党用脚本在毫秒级发起请求,普通用户手动点击根本抢不过他们。这一章分享我在项目中实际落地的安全防护措施。
7.1 登录校验与接口限流
首先,所有秒杀接口必须登录才能访问。我使用JWT + Spring拦截器实现登录态校验,同时把用户信息存入ThreadLocal,方便在Service层直接获取当前用户。
在此基础上,加接口限流。我用了Guava RateLimiter实现简单的令牌桶,每个用户ID维度的限流:同一个用户每秒最多访问秒杀接口5次,超出直接返回“操作频繁,请稍后再试”。
java复制@Component
public class AccessLimitInterceptor extends HandlerInterceptorAdapter {
private final Cache<String, RateLimiter> userLimiterCache = CacheBuilder.newBuilder()
.expireAfterWrite(5, TimeUnit.MINUTES)
.build();
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String userId = UserHolder.getUser().getId().toString();
RateLimiter rateLimiter = userLimiterCache.get(userId, () -> RateLimiter.create(5));
if (!rateLimiter.tryAcquire()) {
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":500,\"msg\":\"操作过于频繁\"}");
return false;
}
return true;
}
}
令牌桶算法好在哪?突发流量进来时不会直接拒绝所有请求,而是平滑地控制速率,用户点击稍微快点也不会被误杀,脚本刷子则被限制得死死的。比单纯计数器限流体验好很多。
7.2 接口签名与参数混淆
对于重点秒杀接口,可以加一层简单的接口签名校验:前端在请求时,按照约定规则生成一个sign参数(比如MD5(goodsId + userId + salt + timestamp)),后端使用相同的规则校验签名。签名正确才继续处理,防止恶意用户直接篡改参数重复刷接口。
签名方案不是绝对安全,但在大多数业务场景下已经足够拦住90%的“业余脚本党”。要防住专业黑产,还需要配合更完善的风控体系,比如设备指纹、行为分析等等,那是另一个量级的项目了。不要贪多,先把手头的能力边界做扎实。
7.3 数据一致性:Redis库存与数据库库存的最终一致性
前面提到,秒杀系统用了Redis做预扣库存,同时MySQL中也有库存字段。两条链路之间如何保证最终一致?
我的方案是消息驱动的对账补偿。订单消费者在创建订单成功后,会更新数据库中的库存字段。如果消息丢失或者订单创建失败,Redis的库存可能已经被扣减了,但数据库的库存没有变。
针对这个问题,我增加了一个定时任务,每隔5分钟扫描库存不一致的数据:
- 对账任务读取Redis中的库存和MySQL中的库存。
- 如果差值对不上,说明存在扣减未落库的情况。
- 定时任务将差额补偿回来,或者重新发送未处理的消息。
java复制@Scheduled(cron = "0 */5 * * * ?")
public void stockConsistencyTask() {
// 1. 查询所有参与秒杀的商品
// 2. 逐个对比Redis库存和MySQL库存
// 3. 如果Redis库存比MySQL库存小,触发补偿机制
}
这套对账机制在秒杀项目收尾阶段很重要,虽然没有直接提升系统性能,但是给运维和后续运营吃了定心丸。无论中间出什么问题,最多延迟5分钟就能自动修正,不用人工凌晨爬起来改数据库。
7.4 前端是否要加图形验证码
秒杀活动要不要加图形验证码?我的结论是:要加,但要加得聪明。在秒杀正式开始前或者用户点击频率异常时弹出验证码,能有效过滤掉一批低成本的自动化脚本。但是每笔订单都验证码会极大影响体验,秒杀本身就是拼手速的场景,你是要让用户5秒内完成点击,而不是让他花10秒去识别扭曲的数字。
我最后的折中方案是:非秒杀时段不弹验证码;秒杀开始瞬间,如果用户请求距离上次请求超过2秒,正常通过;如果1秒内请求超过5次,强制弹验证码。这样既能拦住脚本,又不影响大多数真人用户。
8. 压测结果分析与部署优化:从2000 QPS到9000 QPS的调优实录
很多项目演示时一切正常,一上生产就崩。为了避免这种情况,在项目交付前,我用JMeter做了一轮完整的压测,并根据压测结果做了一轮调优。现在把这轮压测的详细数据和调优思路记录下来。
8.1 压测环境准备
压测场景:100件秒杀商品,5000个用户同时发起请求。机器配置:4核8G的云服务器,Redis、MySQL、RabbitMQ都部署在同一台机器上(生产环境不建议这样,但测试环境图方便)。
JMeter线程组配置:
| 参数 | 数值 |
|---|---|
| 线程数 | 5000 |
| Ramp-Up时间 | 10秒 |
| 循环次数 | 1 |
| HTTP请求 | POST /api/seckill/ |
8.2 第一轮压测结果:惨不忍睹
第一轮压测的结果惨不忍睹:峰值QPS只有2000多,错误率高达18%,大量请求超时。查看日志和监控,主要发现了三个问题:
- Redis连接池耗尽:高并发下Jedis连接池被占满,大量请求拿不到连接。
- 数据库连接池过大但队列阻塞:HikariCP默认连接池20个,但秒杀场景下20个连接根本不够,很多请求在等待获取数据库连接。
- Java应用GC频繁:默认堆内存配置偏小,瞬时大流量导致GC频繁,STW时间增加。
8.3 调优三板斧
第一板斧:调整Redis连接池参数,把max-active从默认的8调大到100,max-idle从8调大到50,min-idle调整为20,保证高峰时段能有足够的Redis连接。
第二板斧:调整HikariCP数据库连接池配置,maximum-pool-size调到50,connection-timeout设置为3000毫秒,防止请求无限期等待数据库连接。
第三板斧:调整JVM参数。测试环境分配了4G堆内存,-Xms2g -Xmx2g -XX:+UseG1GC,并对对象晋升阈值做了适当调整,避免频繁Full GC。
properties复制# application.yml 配置调整
spring:
redis:
jedis:
pool:
max-active: 100
max-idle: 50
min-idle: 20
datasource:
hikari:
maximum-pool-size: 50
connection-timeout: 3000
8.4 第二轮压测结果:稳如泰山
调优后重新压测,结果有了质的飞跃:
| 指标 | 第一轮 | 第二轮 |
|---|---|---|
| 峰值QPS | 约2000 | 约9000 |
| 错误率 | 18% | 0.2% |
| 平均响应时间 | 650ms | 120ms |
| 99%响应时间 | 2.1s | 350ms |
最终100件商品成功被抢购,数据库库存扣减正确,秒杀订单全部生成,无超卖、无漏单。这个结果基本满足中小型秒杀活动的需求,但如果要做大促级别的秒杀(比如几万人同时抢),还需要更精细的限流和隔离方案。
8.5 上线部署的架构建议
项目开发完成后,下面是我最终采用的上线部署架构思路:
-
Nginx
-
Vue打包后的静态文件
-
反向代理后端接口
-
负载均衡到多个SpringBoot实例
-
SpringBoot应用集群
-
基于JWT的无状态登录,支持水平扩展
-
通过Nacos或Consul实现服务注册与发现(如果拆微服务)
-
Redis
-
缓存秒杀商品、库存、用户限流计数
-
集群或哨兵模式保障高可用
-
RabbitMQ
-
秒杀订单队列,异步解耦
-
开启持久化,防止消息丢失
-
MySQL
-
主从复制,读写分离
-
定时备份,防止数据丢失
对于一般学习项目或者中小型商城,把SpringBoot、Redis、MySQL、RabbitMQ分别部署到不同服务器或容器中,用Docker Compose一键编排,足够了。不需要一上来就上Kubernetes,那只会增加运维成本。
9. 项目开发中的常见问题与避坑清单
最后这一章,我把开发和压测过程中踩到的高频问题汇总成清单,每条都是真金白银换来的经验,方便你后续开发时对照自查。
9.1 订单超时未支付怎么办
秒杀订单如果用户抢到后长时间不支付,会占用库存资源。我目前的方案是:在订单表中建立create_time字段,开启一个定时任务扫描超过15分钟未支付的秒杀订单,取消订单并回补库存。更优雅的方案是使用RabbitMQ的延迟队列插件(rabbitmq-delayed-message-exchange),订单创建后发送一个延迟15分钟的消息,到期自动检查支付状态并处理。
9.2 秒杀接口的可用性监控
秒杀活动上线后,要重点关注几个监控指标:
- Redis连接数和内存使用率
- RabbitMQ队列积压情况
- MySQL慢查询日志
- 应用QPS、响应时间、错误率
这些指标一定要在秒杀开始前就配上,不然活动一到大流量来了手忙脚乱。Spring Boot Actuator + Prometheus + Grafana这套组合可以满足大部分中小项目的监控需求,部署简单、社区资料多。
9.3 前端跨域问题
前后端分离项目必然遇到跨域问题。CORS配置时,注意不要直接使用allowedOrigins("*"),推荐指定允许的域名列表。另外,处理登录态的CORS预检请求(OPTIONS)时,要正确响应,否则前端无法携带Cookie。
java复制@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("http://localhost:8080", "https://www.example.com")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
9.4 开发环境与生产环境的配置分离
最后说一个非常基础但容易被忽略的经验。有些人习惯把所有配置都写在同一个application.yml里,切环境的时候手动改IP和密码。我建议从一开始就使用Spring Boot的Profile机制,分三套配置:
- application-dev.yml:本机开发环境
- application-test.yml:测试环境
- application-prod.yml:生产环境
应用启动时通过--spring.profiles.active=dev指定使用哪套配置。这样切环境的时候只改启动参数,不用动代码,也避免手滑把生产数据库密码提交到代码仓库。
顺带提一句,MySQL密码、Redis密码这类敏感信息,生产环境建议用环境变量或者配置中心管理,不要硬编码在配置文件里。这是很多项目后期做安全审计时发现的典型隐患,越早注意越好。
