1. 秒杀系统核心挑战解析
在电商促销活动中,秒杀场景是最考验系统架构设计的典型场景之一。以黑马点评这类本地生活服务平台为例,当某家网红餐厅推出限量特价套餐时,瞬时并发请求往往会达到日常流量的数十倍甚至上百倍。这种极端场景下,系统需要同时应对三大核心挑战:
首先是库存超卖问题。假设某套餐库存仅剩最后1份,此时100个用户同时点击购买,如果采用简单的"查询库存→扣减库存"流程,很可能出现多个请求同时判断库存充足,导致实际卖出数量远超库存量的情况。这个问题在MySQL默认的RR(可重复读)隔离级别下尤为突出,因为多个事务读取到的可能是相同的快照数据。
其次是服务雪崩风险。当秒杀开始时,大量请求瞬间涌入,如果系统没有做好防护措施,可能导致:
- 数据库连接池耗尽
- Redis连接数达到上限
- Tomcat线程池被占满
- 服务器CPU/内存资源耗尽
最后是用户体验问题。用户最直观的感受包括:
- 页面卡顿或白屏(前端未做静态化)
- 重复点击导致多次下单(防重放缺失)
- 看到"有货"却下单失败(状态不一致)
- 活动开始前页面无法访问(未预热)
2. 分布式锁的选型与实践
2.1 Redis分布式锁实现方案
在黑马点评的秒杀场景中,我们采用Redis实现分布式锁,核心命令如下:
java复制// 加锁
String lockKey = "seckill:lock:" + seckillId;
String clientId = UUID.randomUUID().toString();
Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, clientId, 30, TimeUnit.SECONDS);
// 解锁(Lua脚本保证原子性)
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
redisTemplate.execute(new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(lockKey), clientId);
这个方案有几个关键设计点:
- 使用setIfAbsent而非setnx+expire组合,避免非原子性操作导致死锁
- 每个锁设置唯一clientId,确保只有加锁者能解锁
- 锁过期时间设置为30秒,防止业务阻塞导致死锁
- 解锁使用Lua脚本保证查询+删除的原子性
2.2 锁优化:分段锁与红锁
当秒杀商品库存较大时(如1000件),可以采用分段锁策略。将库存拆分为多个段(如10个段,每段100件),每个段独立加锁。这样可以将并发度提升N倍(N为分段数),显著降低锁竞争。
对于更高要求的场景,可以考虑RedLock算法,它需要满足:
- 获取当前时间(毫秒)
- 依次尝试从多个Redis实例获取锁
- 计算获取锁花费的总时间,如果小于锁过期时间且获得多数实例认可,则加锁成功
- 锁的实际有效时间 = 初始有效时间 - 获取锁花费时间
提示:RedLock实现复杂且存在争议,在99%的业务场景中,单Redis实例的分布式锁已经足够。除非你的业务对一致性要求极高且能承担性能损耗,否则不建议轻易使用。
3. 库存扣减的四种方案对比
3.1 方案一:数据库乐观锁
sql复制UPDATE seckill_goods
SET stock_count = stock_count - 1
WHERE goods_id = #{goodsId} AND stock_count > 0
优点:
- 实现简单,无需额外组件
- 保证不会超卖
缺点:
- 成功率随并发量上升急剧下降
- 大量失败请求会给数据库带来压力
3.2 方案二:Redis原子操作
java复制Long remain = redisTemplate.opsForValue().increment("seckill:stock:" + goodsId, -1);
if (remain < 0) {
// 库存不足,回滚
redisTemplate.opsForValue().increment("seckill:stock:" + goodsId, 1);
return Result.fail("秒杀结束");
}
优点:
- 性能极高(Redis单机可达10W+ QPS)
- 实现相对简单
缺点:
- 需要处理Redis与数据库的数据一致性
- 网络抖动可能导致库存不一致
3.3 方案三:Redis+Lua脚本
lua复制local stock = tonumber(redis.call('get', KEYS[1]))
if stock > 0 then
redis.call('decr', KEYS[1])
return 1
end
return 0
优点:
- 原子性执行,无需担心并发问题
- 比单独命令更灵活
缺点:
- 脚本调试相对复杂
- 长脚本会阻塞Redis
3.4 方案四:分布式事务
适用于需要严格保证多个系统数据一致的场景,如:
- 扣减Redis库存
- 创建订单
- 扣减数据库库存
可以采用TCC(Try-Confirm-Cancel)模式:
- Try阶段:预留资源
- Confirm阶段:确认执行业务
- Cancel阶段:取消预留
注意:分布式事务会显著降低系统吞吐量,秒杀场景中应尽量避免使用。通常只在支付等强一致性场景采用。
4. 流量削峰与异步化设计
4.1 前端优化策略
- 静态资源分离:将活动页面的CSS/JS/图片等静态资源部署到CDN
- 按钮防重复点击:点击后立即禁用按钮,倒计时结束后恢复
- 随机延迟提交:在前端添加0-500ms的随机延迟,避免绝对同时请求
- 本地缓存验证:先检查本地缓存的库存状态,不足直接提示
4.2 服务端队列缓冲
采用Redis List作为秒杀请求队列:
java复制// 入队操作
redisTemplate.opsForList().rightPush("seckill:queue:" + seckillId, userId);
// 异步处理线程
while (true) {
Object item = redisTemplate.opsForList().leftPop("seckill:queue:" + seckillId, 1, TimeUnit.SECONDS);
if (item != null) {
handleSeckillRequest((String) item);
}
}
4.3 结果异步返回
采用WebSocket或长轮询通知最终结果:
java复制// 轮询接口
@GetMapping("/result/{orderId}")
public Result getSeckillResult(@PathVariable String orderId) {
Object result = redisTemplate.opsForValue().get("seckill:result:" + orderId);
if (result == null) {
return Result.ok().data("status", "processing");
}
return Result.ok().data("status", result);
}
5. 系统压测与监控
5.1 JMeter压测配置要点
-
线程组设置:
- 并发用户数:阶梯式增加(如500→1000→1500)
- 持续时间:至少5分钟
- 加速时间(Ramp-up):模拟真实用户增长曲线
-
关键监控指标:
- 吞吐量(Throughput):系统每秒处理的请求数
- 响应时间:90%线(90%请求的响应时间)
- 错误率:应低于0.1%
- 服务器资源:CPU、内存、网络IO、磁盘IO
5.2 全链路监控方案
-
应用层:
- Spring Boot Actuator暴露健康指标
- Micrometer对接Prometheus
- 关键业务指标(如秒杀成功率)埋点
-
中间件层:
- Redis监控:连接数、内存使用、命中率
- MySQL监控:QPS、慢查询、连接池状态
- RabbitMQ监控:队列积压、消费速率
-
基础设施层:
- 服务器CPU/内存/磁盘/网络
- 容器资源使用情况(如Docker)
- JVM指标:GC次数、堆内存
6. 真实踩坑案例复盘
6.1 缓存击穿事故
现象:某次秒杀活动开始瞬间,数据库CPU飙升至100%,大量请求超时。
根因分析:
- Redis库存设置为活动开始前5分钟加载
- 运维人员误操作导致Redis缓存被清空
- 大量请求直接穿透到数据库
解决方案:
- 采用多级缓存策略(Redis+本地缓存)
- 实现缓存预热脚本,提前2小时加载
- 添加空值缓存,防止反复查询不存在的key
- 引入熔断机制(如Hystrix)
6.2 订单重复创建
现象:部分用户收到多个相同订单,导致库存多扣。
排查过程:
- 检查分布式锁实现,发现未设置clientId
- 线程A获取锁后因GC暂停导致锁过期
- 线程B获取锁并处理相同请求
- 线程A恢复后继续执行
最终方案:
- 完善分布式锁实现(如2.1节方案)
- 数据库添加唯一索引(user_id+goods_id)
- 幂等性设计:请求带上唯一token
7. 面试常见问题解析
7.1 如何保证Redis与MySQL数据一致?
推荐方案:
- 异步双写:先更新数据库,再通过消息队列更新Redis
- 定时补偿:定期扫描数据库,修复不一致数据
- 添加版本号:每次更新携带版本号,防止旧数据覆盖
不推荐方案:
- 分布式事务:性能代价过高
- 强一致读写:违背Redis设计初衷
7.2 如何处理热点Key问题?
解决方案:
- 本地缓存:在应用层缓存热点数据
- 分片存储:将热点Key拆分为多个子Key
- 随机过期:避免大量Key同时过期
- 读写分离:热Key读操作路由到从节点
7.3 秒杀系统QPS从1000提升到10万的关键步骤?
性能提升路径:
- 基准测试:明确当前瓶颈(CPU/IO/网络)
- 静态化:前后端分离,CDN加速
- 异步化:请求队列+异步处理
- 缓存化:多级缓存+预加载
- 池化:数据库/Redis连接池优化
- 分布式:水平扩展无状态服务
- 限流降级:保护核心链路
