适用版本:Spring Boot 3.x / Node.js 20+ | Redis 7.x | RocketMQ / Kafka 更新日期:2026-03 聚焦:秒杀超卖、库存扣减、跨服务一致性——三大最难场景的根本解法
目录
- 核心认知:为什么这三个问题永远在一起
- 分布式事务
- 2.1 本地事务 vs 分布式事务的本质区别
- 2.2 两阶段提交(2PC)—— 理解原理,生产慎用
- 2.3 SAGA 模式 —— 长事务的主流方案
- 2.4 本地消息表 —— 最实用的最终一致性方案
- 2.5 事务消息(RocketMQ)—— 生产首选
- 分布式锁
- 3.1 为什么 synchronized 在分布式场景失效
- 3.2 Redis SET NX 实现与致命陷阱
- 3.3 Redisson 看门狗机制详解
- 3.4 红锁(RedLock)—— 争议与取舍
- 3.5 库存扣减完整实战
- 限流与熔断
- 4.1 四种限流算法对比
- 4.2 Redis + Lua 实现令牌桶
- 4.3 Sentinel 规则配置与降级策略
- 4.4 熔断器状态机与自愈机制
- 秒杀系统完整架构
- 5.1 整体链路设计
- 5.2 库存预扣 + 异步落库
- 5.3 防超卖三道防线
- 常见生产事故与根本解法
1. 核心认知:为什么这三个问题永远在一起
一句话描述三者关系
限流熔断 → 控制"有多少请求能进来"
分布式锁 → 控制"同一时间只有一个人能操作"
分布式事务 → 控制"操作要么全成功,要么全回滚"
这三层缺一不可。只有限流没有锁,并发请求仍然会超卖;只有锁没有事务,扣库存成功但写订单失败导致数据不一致;只有事务没有限流,流量瞬间击穿整个系统。
单体应用为什么没这些问题?
在单体应用中:
- 事务:一个数据库连接,
BEGIN/COMMIT/ROLLBACK原子执行 - 锁:
synchronized或ReentrantLock,JVM 内存共享,锁一定有效 - 限流:流量直打单机,容量有限但可预测
微服务拆分之后:
- 订单服务、库存服务、支付服务各有自己的数据库 → 跨库事务无法用
BEGIN - 多个服务实例各自的 JVM →
synchronized只在本进程内有效 - 流量入口分散到多个网关节点 → 单机限流计数不准
根本原因:分布式系统没有"共享内存",一切协调必须通过网络通信。
2. 分布式事务
2.1 本地事务 vs 分布式事务的本质区别
本地事务(单数据库):
BEGIN
UPDATE inventory SET stock = stock - 1 WHERE id = 1 ← 同一个数据库连接
INSERT INTO orders(...) ←
COMMIT ← 要么全成功,要么全回滚,ACID 由数据库引擎保证
分布式事务(跨服务):
库存服务(MySQL-A):UPDATE inventory SET stock = stock - 1
订单服务(MySQL-B):INSERT INTO orders(...)
支付服务(MySQL-C):UPDATE accounts SET balance = balance - price
问题:三个操作在三台机器上,任何一步失败,如何回滚其他两步?
CAP 理论的现实约束:分布式系统无法同时满足一致性(C)、可用性(A)、分区容错性(P)。生产中通常选择 AP(高可用 + 最终一致),放弃强一致性。
2.2 两阶段提交(2PC)—— 理解原理,生产慎用
2PC 是什么?
2PC(Two-Phase Commit)是解决分布式事务的经典协议,有一个"协调者"和多个"参与者"。
阶段一(Prepare):
协调者 → 库存服务:"你能执行扣库存吗?先锁住资源,告诉我你准备好了"
协调者 → 订单服务:"你能写订单吗?先锁住资源,告诉我你准备好了"
协调者 → 支付服务:"你能扣款吗?先锁住资源,告诉我你准备好了"
阶段二(Commit/Rollback):
如果所有人都回复"准备好了" → 协调者发出 COMMIT,所有参与者提交
如果任何一个回复"失败" → 协调者发出 ROLLBACK,所有参与者回滚
为什么生产慎用?
| 问题 | 说明 |
|---|---|
| 同步阻塞 | Prepare 阶段所有参与者都持有锁等待,高并发下性能极差 |
| 协调者单点 | 协调者宕机后,参与者永远持锁无法释放(需要人工介入) |
| 脑裂风险 | 阶段二网络分区,部分提交部分回滚,数据永久不一致 |
结论:2PC 理解原理即可,生产中几乎不直接使用。
2.3 SAGA 模式 —— 长事务的主流方案
SAGA 的核心思想:把一个长事务拆成一系列本地事务,每个本地事务都有对应的补偿操作(反向操作)。
正向流程:
T1(扣库存)→ T2(创建订单)→ T3(扣款)→ T4(发货)
任意步骤失败,反向补偿:
T4 失败 → 执行 C3(退款)→ C2(取消订单)→ C1(恢复库存)
注意:补偿不是"回滚",而是用新的业务操作抵消已执行的操作
SAGA 的两种执行方式
方式一:编排式(Choreography) 各服务通过消息事件触发,没有中央协调者。
库存服务 --[库存已扣减]--> 消息队列 --> 订单服务
订单服务 --[订单已创建]--> 消息队列 --> 支付服务
支付服务 --[支付失败]---> 消息队列 --> 订单服务(触发补偿)
- 优点:去中心化,服务间松耦合
- 缺点:全局流程分散,难以追踪和调试
方式二:编制式(Orchestration) 有一个 SAGA 协调器负责调度每一步。
SAGA 协调器
├── 第1步:调用库存服务.扣减()
├── 第2步:调用订单服务.创建()
├── 第3步:调用支付服务.扣款()
└── 失败时:按顺序调用各服务的补偿接口
- 优点:流程集中,可视化,易于监控
- 缺点:协调器本身需要高可用保障
生产建议:中小团队优先选编制式,用 Seata(阿里开源)的 SAGA 模式落地。
// Seata SAGA 状态机配置示例(JSON)
{
"Name": "orderSaga",
"StartState": "DeductInventory",
"States": {
"DeductInventory": {
"Type": "ServiceTask",
"ServiceName": "inventoryService",
"ServiceMethod": "deduct",
"CompensateState": "CompensateInventory",
"Next": "CreateOrder"
},
"CreateOrder": {
"Type": "ServiceTask",
"ServiceName": "orderService",
"ServiceMethod": "create",
"CompensateState": "CancelOrder",
"Next": "Deduct Payment"
}
}
}
2.4 本地消息表 —— 最实用的最终一致性方案
核心思想:利用本地数据库事务的原子性,把"发消息"这个动作和业务操作绑定在同一个本地事务里。
传统方式(会丢消息):
1. 扣库存(本地事务成功)
2. 发 MQ 消息(网络波动失败!)
→ 库存扣了,但下游订单服务没收到消息,数据不一致
本地消息表方式:
本地事务(原子):
1. 扣库存
2. 在 local_message 表中插入一条"待发送"消息
→ 要么两件事都成功,要么都失败
独立轮询任务:
3. 扫描 local_message 表,找到"待发送"的消息
4. 发送到 MQ(失败则重试,直到成功)
5. 发送成功后,更新消息状态为"已发送"
表结构设计
CREATE TABLE local_message (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
topic VARCHAR(100) NOT NULL, -- MQ Topic
payload TEXT NOT NULL, -- 消息内容(JSON)
status TINYINT DEFAULT 0, -- 0:待发送 1:已发送 2:失败
retry_count INT DEFAULT 0, -- 重试次数
created_at DATETIME DEFAULT NOW(),
sent_at DATETIME,
INDEX idx_status (status, created_at) -- 轮询查询走这个索引
);
轮询发送器(Spring Boot)
@Component
public class LocalMessagePublisher {
@Autowired
private LocalMessageMapper messageMapper;
@Autowired
private RocketMQTemplate rocketMQTemplate;
// 每 5 秒扫描一次待发送消息
@Scheduled(fixedDelay = 5000)
@Transactional
public void publishPendingMessages() {
// 查询待发送消息,每次最多处理 100 条
List<LocalMessage> messages = messageMapper.findPending(100);
for (LocalMessage msg : messages) {
try {
rocketMQTemplate.convertAndSend(msg.getTopic(), msg.getPayload());
messageMapper.markSent(msg.getId());
} catch (Exception e) {
// 超过 5 次重试,标记为失败,人工介入
if (msg.getRetryCount() >= 5) {
messageMapper.markFailed(msg.getId());
} else {
messageMapper.incrementRetry(msg.getId());
}
}
}
}
}
业务代码中的使用
@Service
public class InventoryService {
@Transactional // 本地事务,保证原子性
public void deductStock(Long productId, int quantity, Long orderId) {
// 1. 扣库存
int affected = inventoryMapper.deduct(productId, quantity);
if (affected == 0) {
throw new InsufficientStockException("库存不足");
}
// 2. 同一事务中插入待发送消息
LocalMessage msg = new LocalMessage();
msg.setTopic("inventory-deducted");
msg.setPayload(JSON.toJSONString(Map.of(
"orderId", orderId,
"productId", productId,
"quantity", quantity
)));
messageMapper.insert(msg);
// 事务提交后,轮询任务会自动发送这条消息
}
}
优缺点
| 维度 | 评价 |
|---|---|
| 实现复杂度 | 低,只需要一张额外的表 |
| 可靠性 | 高,依赖数据库事务保证原子性 |
| 实时性 | 中,取决于轮询间隔(通常 1-10 秒) |
| 适用场景 | 跨服务最终一致性,不要求强实时 |
| 主要缺点 | 消费者需要做幂等处理(消息可能重复投递) |
2.5 事务消息(RocketMQ)—— 生产首选
RocketMQ 4.3+ 内置了事务消息机制,比本地消息表更优雅,原理类似但由 MQ 承担轮询工作。
流程:
1. 生产者发送"半消息"到 RocketMQ(此时消费者看不到)
2. 生产者执行本地事务(扣库存)
3a. 本地事务成功 → 发送 COMMIT,半消息变为可消费
3b. 本地事务失败 → 发送 ROLLBACK,半消息被删除
3c. 网络超时,生产者没有回复 → RocketMQ 主动回查生产者(checkLocalTransaction)
@RocketMQTransactionListener
public class InventoryTransactionListener implements RocketMQLocalTransactionListener {
@Autowired
private InventoryService inventoryService;
/**
* 执行本地事务
* 在"半消息"发送成功后被调用
*/
@Override
public RocketMQLocalTransactionState executeLocalTransaction(Message msg, Object arg) {
try {
DeductDTO dto = JSON.parseObject((byte[]) msg.getPayload(), DeductDTO.class);
inventoryService.deductStock(dto.getProductId(), dto.getQuantity());
return RocketMQLocalTransactionState.COMMIT; // 通知 RocketMQ 提交消息
} catch (InsufficientStockException e) {
return RocketMQLocalTransactionState.ROLLBACK; // 通知 RocketMQ 删除消息
} catch (Exception e) {
return RocketMQLocalTransactionState.UNKNOWN; // 不确定,等待回查
}
}
/**
* RocketMQ 回查本地事务状态
* 当上面返回 UNKNOWN 或网络超时时调用
*/
@Override
public RocketMQLocalTransactionState checkLocalTransaction(Message msg) {
DeductDTO dto = JSON.parseObject((byte[]) msg.getPayload(), DeductDTO.class);
// 检查数据库中是否有这条扣减记录
boolean exists = inventoryService.checkDeductRecord(dto.getOrderId());
return exists
? RocketMQLocalTransactionState.COMMIT
: RocketMQLocalTransactionState.ROLLBACK;
}
}
3. 分布式锁
3.1 为什么 synchronized 在分布式场景失效
单机(有效):
实例A: synchronized(lock) {
检查库存 → 扣减库存 ← 同一个 JVM,lock 对象共享
}
分布式(失效):
实例A: synchronized(lockA) { 检查库存=1 → 扣减 }
实例B: synchronized(lockB) { 检查库存=1 → 扣减 } ← lockA 和 lockB 是不同对象!
结果:两个实例都检查到库存=1,都执行扣减,库存变成 -1(超卖!)
根本原因:synchronized 依赖 JVM 内存中的对象监视器(monitor),多个服务实例没有共享内存,锁根本不是同一把。
3.2 Redis SET NX 实现与致命陷阱
基础实现
# 原子命令:SET key value NX(Not eXists)PX(毫秒过期)
SET lock:inventory:product_1 "uuid-abc123" NX PX 5000
# 返回 OK → 加锁成功
# 返回 nil → 加锁失败(锁已被其他进程持有)
完整 Java 实现
public class RedisDistributedLock {
private static final String LOCK_PREFIX = "lock:";
private static final long DEFAULT_EXPIRE_MS = 5000;
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 加锁
* @param lockKey 锁的 key
* @param lockValue 锁的值(必须唯一,用于解锁时验证身份)
* @param expireMs 自动过期时间(防止死锁)
*/
public boolean tryLock(String lockKey, String lockValue, long expireMs) {
Boolean result = redisTemplate.opsForValue().setIfAbsent(
LOCK_PREFIX + lockKey,
lockValue,
Duration.ofMillis(expireMs)
);
return Boolean.TRUE.equals(result);
}
/**
* 解锁(必须用 Lua 脚本保证原子性)
* 为什么不能直接 DEL?
* 场景:A 持有锁,锁刚好过期,B 加锁成功,A 执行完毕想解锁 → A 的 DEL 会删掉 B 的锁!
* Lua 脚本:先检查 value 是不是自己的,是才删除,原子执行
*/
private static final String UNLOCK_SCRIPT =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
public boolean unlock(String lockKey, String lockValue) {
DefaultRedisScript<Long> script = new DefaultRedisScript<>(UNLOCK_SCRIPT, Long.class);
Long result = redisTemplate.execute(
script,
Collections.singletonList(LOCK_PREFIX + lockKey),
lockValue
);
return Long.valueOf(1L).equals(result);
}
}
使用示例
String lockKey = "inventory:product:" + productId;
String lockValue = UUID.randomUUID().toString(); // 每次加锁的唯一标识
try {
boolean locked = distributedLock.tryLock(lockKey, lockValue, 5000);
if (!locked) {
throw new BusyException("系统繁忙,请稍后重试");
}
// 执行业务逻辑...
deductStock(productId, quantity);
} finally {
distributedLock.unlock(lockKey, lockValue); // 必须在 finally 中解锁
}
3.3 Redisson 看门狗机制详解
自行实现的致命问题:锁提前过期
场景:
1. 线程 A 加锁,设置 5 秒过期
2. 线程 A 执行业务逻辑,因为 Full GC 或慢 SQL,执行了 6 秒
3. 第 5 秒时,锁自动过期
4. 线程 B 加锁成功(因为 A 的锁已过期)
5. 线程 B 开始执行,线程 A 也在执行 ← 两个线程同时在临界区!
Redisson 看门狗(Watchdog)解决方案
Redisson 加锁后,后台启动一个定时任务(看门狗)
每隔 lockTime/3 的时间,检查线程是否还��有锁
如果是 → 自动给锁续期(重置过期时间)
直到线程手动解锁,看门狗停止续期
// 引入依赖
// implementation 'org.redisson:redisson-spring-boot-starter:3.24.0'
@Autowired
private RedissonClient redissonClient;
public void deductStockWithRedisson(Long productId, int quantity) {
RLock lock = redissonClient.getLock("inventory:product:" + productId);
try {
// 方式一:阻塞等待(会一直等到加锁成功)
lock.lock();
// 方式二:带超时的尝试(推荐)
// waitTime=3s 等待时间,leaseTime=10s 锁超时时间
// leaseTime > 0 时,看门狗不会自动续期(由你控制超时)
// leaseTime = -1 时,看门狗启用自动续期
boolean locked = lock.tryLock(3, 10, TimeUnit.SECONDS);
if (!locked) {
throw new BusyException("系统繁忙,请稍后重试");
}
// 执行业务逻辑(看门狗会自动续期,不怕业务慢)
doDeductStock(productId, quantity);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
// 只有当前线程持有锁才能解锁(Redisson 内部已做 isHeldByCurrentThread 检查)
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
Redisson 还提供的锁类型
| 锁类型 | 用途 |
|---|---|
RLock | 普通互斥锁(最常用) |
RReadWriteLock | 读写锁,读读不互斥,读写/写写互斥 |
RSemaphore | 信号量,控制并发数量(如限制同时处理 10 个) |
RCountDownLatch | 分布式 CountDownLatch |
RRateLimiter | 分布式限流器 |
3.4 红锁(RedLock)—— 争议与取舍
为什么 Redis 主从架构下单节点锁不安全?
场景(主从复制的异步特性导致):
1. 客户端 A 在 Master 上加锁成功(写入 lock key)
2. Master 还没把这个 key 同步到 Slave,Master 宕机
3. Slave 晋升为新 Master(key 不存在)
4. 客户端 B 在新 Master 上加锁成功
5. A 和 B 同时持有锁!
RedLock 算法:在 N 个独立的 Redis 节点(非主从,彼此独立)上同时加锁,超过 N/2+1 个成功才算加锁成功。
// Redisson RedLock 示例(需要多个独立 RedissonClient)
RLock lock1 = redissonClient1.getLock("distributed-lock");
RLock lock2 = redissonClient2.getLock("distributed-lock");
RLock lock3 = redissonClient3.getLock("distributed-lock");
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
try {
boolean locked = redLock.tryLock(3, 10, TimeUnit.SECONDS);
// ...
} finally {
redLock.unlock();
}
RedLock 的争议(Martin Kleppmann vs Redis 作者 antirez 的著名争论)
| 一方观点 | 另一方观点 |
|---|---|
| 时钟漂移可能导致锁提前失效,在 GC 停顿期间仍然不安全 | 实际业务中这种极端情况概率极低,RedLock 够用 |
| 要实现强安全的分布式锁,应该用 Zookeeper 或 etcd | 用 Zookeeper 引入了更重的依赖和更高的运维成本 |
生产建议:
- 大多数业务场景:Redisson 单节点(主从)锁已经足够,加
leaseTime+ 幂等设计兜底 - 金融/扣款等强一致场景:考虑 Zookeeper 分布式锁(ZK 的 CP 模型,主从切换不会丢节点数据)
- RedLock:理解原理即可,实际生产中使用较少
3.5 库存扣减完整实战
结合分布式锁 + 数据库乐观锁的双重保障:
@Service
public class StockService {
@Autowired
private RedissonClient redissonClient;
@Autowired
private StockMapper stockMapper;
/**
* 生产级库存扣减
* 第一道防线:Redis 预检(快速失败)
* 第二道防线:分布式锁(串行化)
* 第三道防线:数据库乐观锁(最终保障)
*/
public boolean deductStock(Long productId, int quantity, Long orderId) {
// 第一道:Redis 预检,快速失败(避免无效加锁)
String stockKey = "stock:" + productId;
Long currentStock = (Long) redisTemplate.opsForValue().get(stockKey);
if (currentStock != null && currentStock < quantity) {
return false; // 快速返回,不进入锁竞争
}
// 第二道:分布式锁,串行化操作
RLock lock = redissonClient.getLock("deduct:product:" + productId);
try {
boolean locked = lock.tryLock(3, 10, TimeUnit.SECONDS);
if (!locked) {
throw new BusyException("库存服务繁忙");
}
// 第三道:数据库乐观锁(WHERE stock >= quantity 防止超卖)
int affected = stockMapper.deductWithOptimisticLock(productId, quantity, orderId);
if (affected == 0) {
return false; // 并发中其他人先扣完了
}
// 同步更新 Redis 缓存
redisTemplate.opsForValue().decrement(stockKey, quantity);
return true;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
-- 数据库乐观锁:WHERE stock >= #{quantity} 是关键
-- 如果并发时多个线程都通过了 Redis 预检,这里保证最终只有库存充足的更新成功
UPDATE product_stock
SET stock = stock - #{quantity},
updated_at = NOW()
WHERE product_id = #{productId}
AND stock >= #{quantity} -- 乐观锁核心:不足则不更新,返回 affected=0
AND deduct_order_id != #{orderId} -- 幂等:同一订单不重复扣减
4. 限流与熔断
4.1 四种限流算法对比
固定窗口:每 N 秒允许 M 个请求
问题:边界突刺
窗口1(0-60s)最后1秒:100个请求
窗口2(60-120s)第1秒:100个请求
→ 2秒内实际通过200个请求,超过限制1倍
滑动窗口:用队列记录最近 N 秒的请求时间戳,超过则拒绝
优点:解决边界突刺问题
缺点:内存占用高(需要存储每个请求的时间戳)
适用:精度要求高但流量不太大的场景
漏桶算法:请求以固定速率流出,超出容量直接丢弃
请求流入(速率不定)
↓
┌────────┐ 桶容量 = 100
│ 漏桶 │
└───┬────┘
↓ 固定速率流出(如每秒10个)
处理请求
特点:输出速率绝对平滑
缺点:无法应对正常的突发流量(比如早高峰)
令牌桶算法(生产首选):以固定速率向桶中放令牌,请求需消耗令牌,桶空则拒绝
令牌生成速率:每秒 100 个
桶容量:200 个(允许短时突发)
正常情况:每秒消耗 100 令牌,桶始终有余量
低谷期: 令牌积累,桶逐渐填满(最多 200)
突发期: 瞬间消耗积累的 200 令牌,之后恢复正常速率
优势:既能限制平均速率,又允许合理的突发流量
4.2 Redis + Lua 实现令牌桶
-- token_bucket.lua
-- KEYS[1]: 桶的 key
-- ARGV[1]: 桶的最大容量
-- ARGV[2]: 每秒令牌生成速率
-- ARGV[3]: 本次请求消耗的令牌数
-- ARGV[4]: 当前时间戳(秒)
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local requested = tonumber(ARGV[3])
local now = tonumber(ARGV[4])
-- 读取桶的当前状态(令牌数 + 上次更新时间)
local bucket = redis.call('HMGET', key, 'tokens', 'last_time')
local tokens = tonumber(bucket[1]) or capacity
local last_time = tonumber(bucket[2]) or now
-- 计算自上次更新后新增的令牌
local elapsed = now - last_time
local new_tokens = math.min(capacity, tokens + elapsed * rate)
-- 判断是否有足够令牌
if new_tokens >= requested then
-- 消耗令牌,允许请求
redis.call('HMSET', key, 'tokens', new_tokens - requested, 'last_time', now)
redis.call('EXPIRE', key, 3600)
return 1 -- 允许
else
-- 令牌不足,拒绝请求
redis.call('HMSET', key, 'tokens', new_tokens, 'last_time', now)
redis.call('EXPIRE', key, 3600)
return 0 -- 拒绝
end
@Component
public class TokenBucketRateLimiter {
@Autowired
private StringRedisTemplate redisTemplate;
private static final DefaultRedisScript<Long> TOKEN_BUCKET_SCRIPT;
static {
TOKEN_BUCKET_SCRIPT = new DefaultRedisScript<>();
TOKEN_BUCKET_SCRIPT.setScriptSource(
new ResourceScriptSource(new ClassPathResource("lua/token_bucket.lua"))
);
TOKEN_BUCKET_SCRIPT.setResultType(Long.class);
}
/**
* @param key 限流 key(如 "rate:user:123" 或 "rate:api:order")
* @param capacity 桶容量(允许的突发量)
* @param rate 每秒补充令牌数
* @param tokens 本次请求消耗令牌数(通常为 1)
*/
public boolean isAllowed(String key, int capacity, int rate, int tokens) {
long now = System.currentTimeMillis() / 1000;
Long result = redisTemplate.execute(
TOKEN_BUCKET_SCRIPT,
Collections.singletonList(key),
String.valueOf(capacity),
String.valueOf(rate),
String.valueOf(tokens),
String.valueOf(now)
);
return Long.valueOf(1L).equals(result);
}
}
4.3 Sentinel 规则配置与降级策略
Sentinel(阿里开源)是生产中最常用的流量控制框架。
核心概念
资源(Resource):需要保护的代码块,可以是接口、方法、外部调用
规则(Rule): 定义何种条件下触发限流/降级
降级(Fallback):触发规则后的处理逻辑(返回兜底数据、抛异常等)
流量控制规则
@Configuration
public class SentinelRuleConfig {
@PostConstruct
public void initRules() {
// 流量控制规则:每秒最多 100 个请求
FlowRule flowRule = new FlowRule();
flowRule.setResource("createOrder"); // 资源名
flowRule.setGrade(RuleConstant.FLOW_GRADE_QPS); // QPS 限流(也可以用并发线程数)
flowRule.setCount(100); // 阈值:100 QPS
flowRule.setControlBehavior(RuleConstant.CONTROL_BEHAVIOR_WARM_UP); // 预热:前 3 秒逐渐增加到 100
// 熔断降级规则:1 秒内错误率超过 50%,熔断 10 秒
DegradeRule degradeRule = new DegradeRule();
degradeRule.setResource("createOrder");
degradeRule.setGrade(RuleConstant.DEGRADE_GRADE_EXCEPTION_RATIO); // 按错误率
degradeRule.setCount(0.5); // 50% 错误率
degradeRule.setMinRequestAmount(20); // 至少 20 个请求才统计(避免样本太少误判)
degradeRule.setTimeWindow(10); // 熔断持续 10 秒
FlowRuleManager.loadRules(Collections.singletonList(flowRule));
DegradeRuleManager.loadRules(Collections.singletonList(degradeRule));
}
}
在业务代码中使用
@Service
public class OrderService {
// 使用注解方式(推荐)
@SentinelResource(
value = "createOrder",
blockHandler = "createOrderBlocked", // 触发限流/熔断时调用
fallback = "createOrderFallback" // 业务异常时调用
)
public OrderResult createOrder(CreateOrderDTO dto) {
// 正常业务逻辑
return doCreateOrder(dto);
}
// 限流/熔断降级处理(参数必须与原方法一致,最后加 BlockException)
public OrderResult createOrderBlocked(CreateOrderDTO dto, BlockException ex) {
if (ex instanceof FlowException) {
return OrderResult.fail("系统繁忙,请稍后重试");
} else if (ex instanceof DegradeException) {
return OrderResult.fail("服务暂时不可用,请稍后重试");
}
return OrderResult.fail("请求被拦截");
}
// 业务异常 fallback
public OrderResult createOrderFallback(CreateOrderDTO dto, Throwable t) {
log.error("创建订单异常,返回兜底数据", t);
return OrderResult.fail("系统异常,请稍后重试");
}
}
4.4 熔断器状态机与自愈机制
熔断器有三个状态,理解状态转换是掌握熔断机制的关键:
错误率超过阈值
CLOSED ─────────────────────────→ OPEN
(正常) (熔断)
↑ │
│ 半开探测成功 │ 等待恢复时间窗口
│ ↓
└──────────────────────── HALF-OPEN
(探测)
CLOSED:正常状态,请求正常通过,统计错误率
OPEN: 熔断状态,请求直接走降级逻辑(不调用实际服务)
HALF-OPEN:恢复探测,放少量请求进来,成功则恢复 CLOSED,失败则回到 OPEN
为什么需要熔断?
没有熔断的情况:
下游服务(库存服务)响应变慢(500ms → 3s)
→ 上游服务(订单服务)等待,线程被占用
→ 订单服务线程池耗尽
→ 订单服务对外也变慢
→ 网关层等待,线程耗尽
→ 整个系统雪崩
有熔断的情况:
下游服务响应变慢,错误率超过阈值
→ 熔断器打开,请求直接返回兜底数据(不等待)
→ 上游服务线程立即释放
→ 系统保持正常响应,等待下游自愈
5. 秒杀系统完整架构
5.1 整体链路设计
用户请求
│
├── 前端防重:按钮点击后置灰,防止重复提交
│
▼
CDN / 静态资源(商品页面 99% 的读流量在这里解决)
│
▼
API 网关
├── 身份验证(JWT 校验)
├── 接口限流(令牌桶,每用户每秒 1 次)
└── 黑名单过滤(恶意 IP / 刷接口用户)
│
▼
秒杀服务
├── Redis 库存预检(快速失败,毫秒级响应)
├── 用户去重(Redis SET:已参与用户集合)
├── 写入 MQ(异步处理,立即返回"排队中")
└── 返回排队 Token
│
▼
MQ(Kafka / RocketMQ)
│
▼
订单处理服务(消费 MQ)
├── 分布式锁(Redisson,串行化扣减)
├── 数据库库存扣减(乐观锁兜底)
├── 写入订单表
└── 发送支付消息
│
▼
支付服务(继续异步处理)
5.2 库存预扣 + 异步落库
@RestController
@RequestMapping("/seckill")
public class SeckillController {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private RocketMQTemplate mqTemplate;
@Autowired
private TokenBucketRateLimiter rateLimiter;
@PostMapping("/buy")
public ApiResult<String> buy(@RequestBody SeckillDTO dto, @AuthUser Long userId) {
// 1. 接口限流(用户维度:每用户每秒 1 次)
boolean allowed = rateLimiter.isAllowed("seckill:user:" + userId, 3, 1, 1);
if (!allowed) {
return ApiResult.fail("操作太频繁,请稍后再试");
}
String productKey = "seckill:stock:" + dto.getProductId();
String userSetKey = "seckill:users:" + dto.getProductId();
// 2. 用 Lua 脚本原子执行"检查库存 + 用户去重 + 预扣库存"
// 避免分开执行时的竞态条件
Long result = executeSeckillScript(productKey, userSetKey, userId.toString());
if (result == -1) {
return ApiResult.fail("您已参与过本次秒杀");
}
if (result == 0) {
return ApiResult.fail("手慢了,已售罄");
}
// 3. 异步写入 MQ,立即返回
String orderToken = generateOrderToken();
SeckillMessage msg = new SeckillMessage(userId, dto.getProductId(), orderToken);
mqTemplate.asyncSend("seckill-topic", JSON.toJSONString(msg));
// 4. 返回排队 Token,前端轮询订单状态
return ApiResult.success(orderToken);
}
private static final String SECKILL_SCRIPT =
"local stock = tonumber(redis.call('get', KEYS[1])) \n" +
"if stock == nil or stock <= 0 then return 0 end \n" + // 已售罄
"if redis.call('sismember', KEYS[2], ARGV[1]) == 1 then \n" +
" return -1 \n" + // 已参与
"end \n" +
"redis.call('decr', KEYS[1]) \n" + // 预扣库存
"redis.call('sadd', KEYS[2], ARGV[1]) \n" + // 记录参与用户
"return 1"; // 成功
private Long executeSeckillScript(String stockKey, String userSetKey, String userId) {
DefaultRedisScript<Long> script = new DefaultRedisScript<>(SECKILL_SCRIPT, Long.class);
return redisTemplate.execute(
script,
Arrays.asList(stockKey, userSetKey),
userId
);
}
}
5.3 防超卖三道防线
| 防线 | 位置 | 机制 | 作用 |
|---|---|---|---|
| 第一道 | Redis | Lua 原子预扣库存 | 拦截 99% 流量,毫秒级响应 |
| 第二道 | 分布式锁 | Redisson 锁串行化 | 防止并发穿透第一道防线时的竞态 |
| 第三道 | 数据库 | WHERE stock >= 1 乐观锁 | 数据库层面兜底,永不超卖 |
幂等性保障(防止重复下单)
// MQ 消费者处理时,必须保证幂等
@RocketMQMessageListener(topic = "seckill-topic", consumerGroup = "seckill-group")
@Component
public class SeckillConsumer implements RocketMQListener<String> {
@Override
public void onMessage(String message) {
SeckillMessage msg = JSON.parseObject(message, SeckillMessage.class);
// 幂等检查:orderToken 写入 Redis,设置 24 小时过期
String idempotentKey = "seckill:processed:" + msg.getOrderToken();
Boolean isNew = redisTemplate.opsForValue().setIfAbsent(
idempotentKey, "1", Duration.ofHours(24)
);
if (!Boolean.TRUE.equals(isNew)) {
log.warn("重复消息,跳过处理: {}", msg.getOrderToken());
return; // 幂等:已处理过,直接跳过
}
// 执行实际的库存扣减和订单创建
stockService.deductStock(msg.getProductId(), 1, msg.getOrderToken());
orderService.createOrder(msg);
}
}
6. 常见生产事故与根本解法
事故一:Redis 预扣成功,但 MQ 发送失败 → 库存丢失
场景:
Redis 库存 -1(预扣成功)
MQ 发送超时,消息丢失
→ 用户没有产生订单,但库存永久少了一个
根本解法:本地消息表
将 MQ 发送和"记录用户已参与"绑定在一个本地事务中
用轮询任务保证消息最终发出去
或者:MQ 发送失败时,执行补偿
if (mqSendFailed) {
redisTemplate.opsForValue().increment(stockKey); // 回补库存
redisTemplate.opsForSet().remove(userSetKey, userId); // 移除参与记录
}
事故二:分布式锁未释放 → 死锁
场景:
线程 A 加锁成功,执行过程中服务器宕机
锁的 TTL 设置过长(1 小时)
→ 1 小时内没有任何人能操作这个商品的库存
根本解法:
1. TTL 不要设置过长(10-30 秒足够,配合 Redisson 看门狗续期)
2. 运维监控:报警锁持有超过 N 秒的异常情况
3. 提供人工解锁接口(后台管理系统)
事故三:缓存雪崩 → 数据库压垮
场景:
大量商品缓存同时设置相同的 TTL(如都是 30 分钟)
30 分钟后,大量缓存同时过期
瞬间所有请求都打到数据库 → 数据库 CPU 100% → 系统崩溃
根本解法(三选一或组合):
1. TTL 加随机抖动:ttl = 30分钟 + random(0, 300秒)
2. 互斥加载:缓存未命中时用分布式锁控制只有一个线程去加载
3. 双层缓存:L1 本地缓存(Caffeine,5分钟)+ L2 Redis缓存(30分钟)
事故四:SAGA 补偿失败 → 数据永久不一致
场景:
支付服务扣款失败,触发补偿:调用库存服务恢复库存
补偿时,库存服务也宕机了
→ 支付没有成功,但库存也没有恢复
根本解法:
1. 补偿操作必须有重试机制(指数退避,最多重试 N 次)
2. 最终失败写入人工处理队列(死信队列),人工介入
3. 定期数据对账任务:扫描"补偿失败"状态的记录,自动或人工修复
事故五:热点 Key 限流不均匀 → 部分实例被打爆
场景:
秒杀接口限流 1000 QPS,部署了 10 个实例
但请求哈希到某台实例特别多,该实例实际承受 2000 QPS
其他实例只有 500 QPS,整体限流计数不准确
根本解法:
1. Redis 集中限流(令牌桶 key 在 Redis 上,所有实例共享)
2. 网关层统一限流(Kong / APISIX),请求进入服务前已完成限流
3. 一致性哈希负载均衡(同一商品的请求路由到同一实例)
快速参考
技术选型速查表
| 场景 | 推荐方案 | 备注 |
|---|---|---|
| 跨服务数据一致性 | RocketMQ 事务消息 | 最终一致,生产首选 |
| 长流程事务(>3个服务) | Seata SAGA 编制式 | 可视化流程,易于监控 |
| 单商品库存扣减 | Redisson 分布式锁 + DB 乐观锁 | 双重保障 |
| 接口限流 | Redis 令牌桶 + Sentinel | 分布式 + 本地双层 |
| 下游服务不稳定 | Sentinel 熔断降级 | 防止雪崩 |
| 秒杀库存预扣 | Redis Lua 原子脚本 | 高性能,防超卖 |
| 消息幂等 | Redis SET NX + DB 唯一索引 | 双重兜底 |
面试高频问题
Q:分布式锁和数据库乐观锁有什么区别,什么时候用哪个?
分布式锁(悲观):先占位,别人排队等。适合竞争激烈、业务逻辑复杂的场景。 乐观锁(乐观):先做,提交时检查。适合竞争不激烈、操作简单的场景。 生产中通常组合使用:分布式锁在外层串行化,乐观锁在数据库层兜底。
Q:SAGA 和 2PC 的核心区别?
2PC 是强一致(同步阻塞,锁资源到提交/回滚);SAGA 是最终一致(各步骤独立提交,失败通过补偿抵消)。生产中 2PC 性能太差,SAGA 是主流。
Q:令牌桶和漏桶的区别?
漏桶:出口速率固定,不允许突发。令牌桶:允许积累令牌,可以应对短时突发流量。生产中令牌桶更常用,因为真实流量有合理的波峰波谷。
Q:熔断后如何自愈,HALF-OPEN 状态做了什么?
OPEN 状态等待恢复时间窗口(如 10 秒)后,进入 HALF-OPEN,放少量请求(如 10%)实际调用下游。成功率达到阈值则关闭熔断,恢复正常;否则重新 OPEN,继续等待。