分布式事务 × 分布式锁 × 限流熔断 —— 生产落地指南

2 阅读24分钟

适用版本:Spring Boot 3.x / Node.js 20+ | Redis 7.x | RocketMQ / Kafka 更新日期:2026-03 聚焦:秒杀超卖、库存扣减、跨服务一致性——三大最难场景的根本解法


目录

  1. 核心认知:为什么这三个问题永远在一起
  2. 分布式事务
    • 2.1 本地事务 vs 分布式事务的本质区别
    • 2.2 两阶段提交(2PC)—— 理解原理,生产慎用
    • 2.3 SAGA 模式 —— 长事务的主流方案
    • 2.4 本地消息表 —— 最实用的最终一致性方案
    • 2.5 事务消息(RocketMQ)—— 生产首选
  3. 分布式锁
    • 3.1 为什么 synchronized 在分布式场景失效
    • 3.2 Redis SET NX 实现与致命陷阱
    • 3.3 Redisson 看门狗机制详解
    • 3.4 红锁(RedLock)—— 争议与取舍
    • 3.5 库存扣减完整实战
  4. 限流与熔断
    • 4.1 四种限流算法对比
    • 4.2 Redis + Lua 实现令牌桶
    • 4.3 Sentinel 规则配置与降级策略
    • 4.4 熔断器状态机与自愈机制
  5. 秒杀系统完整架构
    • 5.1 整体链路设计
    • 5.2 库存预扣 + 异步落库
    • 5.3 防超卖三道防线
  6. 常见生产事故与根本解法

1. 核心认知:为什么这三个问题永远在一起

一句话描述三者关系

限流熔断  →  控制"有多少请求能进来"
分布式锁  →  控制"同一时间只有一个人能操作"
分布式事务 → 控制"操作要么全成功,要么全回滚"

这三层缺一不可。只有限流没有锁,并发请求仍然会超卖;只有锁没有事务,扣库存成功但写订单失败导致数据不一致;只有事务没有限流,流量瞬间击穿整个系统。

单体应用为什么没这些问题?

在单体应用中:

  • 事务:一个数据库连接,BEGIN / COMMIT / ROLLBACK 原子执行
  • synchronizedReentrantLock,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,执行了 63. 第 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 防超卖三道防线

防线位置机制作用
第一道RedisLua 原子预扣库存拦截 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,继续等待。