WEBKT

高并发下的悬挂陷阱:利用 Redis 原子性与乐观锁优雅解决 Try 阶段重试难题

58 0 0 0

在高并发场景下,重试机制是一把双刃剑。特别是在涉及外部资源交互的“Try”阶段,如果缺乏合理的防护,原本用于容错的重试很容易演变成“雪崩”的导火索,甚至导致系统悬挂(Hang)或死锁。

用户提到的“Try阶段重试导致悬挂”,通常发生在以下两种情况:

  1. 竞争条件(Race Condition):多个线程同时重试,导致资源争抢激烈,锁持有时间过长。
  2. 脏写入(Dirty Write):重试时未校验前置状态,覆盖了其他线程已提交的有效数据。

针对如何利用 Redis 避免这些问题,我将从**乐观锁(基于原子性)悲观锁(分布式锁)**两个维度给出实战方案。

方案一:利用 Redis 原子性实现“乐观锁” (Optimistic Locking)

乐观锁的核心思想是:不加锁,但在更新数据时判断这期间是否有其他线程修改过数据。这非常适合“Try”阶段的重试,因为它能快速失败,避免无效的重试循环。

核心指令:WATCH + MULTI/EXEC (事务)

WATCH 命令可以监控一个或多个 key。如果在 WATCH 执行后、EXEC 执行前,这些 key 被其他命令修改了,那么事务队列将被丢弃,返回空回复。

场景模拟:库存扣减(Try阶段)

假设我们要尝试扣减库存,如果库存不足则进行重试,但必须保证不会超卖或覆盖其他人的扣减。

# 线程 A 和线程 B 同时尝试扣减库存
# 假设 stock_key = "product:1001:stock",初始值为 1

# 1. 开启监控 (Optimistic Lock)
WATCH product:1001:stock

# 2. 获取当前值并判断业务逻辑 (Try 阶段逻辑判断)
val = GET product:1001:stock
if val <= 0:
    # 库存不足,直接放弃
    UNWATCH
    return "Stock Empty"

# 3. 开启事务 (Redis Transaction)
MULTI
DECR product:1001:stock
# ... 这里可以加入其他原子操作,比如记录订单号
EXEC

为什么这能避免悬挂和脏数据?

如果在线程 A 执行 EXEC 之前,线程 B 成功修改了 product:1001:stock(例如将其减为 0),线程 A 的 EXEC 将返回 nil,事务失败。
此时,线程 A 必须捕获这个失败信号,进入下一轮重试(或者根据策略放弃),而不是盲目地继续执行后续逻辑。这从根本上杜绝了基于过期数据(Stale Data)的脏写入。

方案二:利用 Redis 分布式锁控制重试并发

如果重试频率极高,单纯依赖乐观锁可能会导致大量事务失败重试(虽然正确,但效率低)。此时需要引入分布式锁来限制“Try”阶段的并发数,即悲观锁思想。

这里推荐使用 Redisson 客户端实现的 可重入锁(Reentrant Lock),并配合 Watch Dog(看门狗) 机制。

核心逻辑:

  1. 加锁:在进入 Try 阶段前,尝试获取分布式锁。
  2. 执行:获取锁成功后,执行业务逻辑(包括重试逻辑)。
  3. 释放:逻辑执行完毕,释放锁。

代码思路(Java/Redisson 伪代码):

RLock lock = redissonClient.getLock("my_business_lock_try_phase");
try {
    // 尝试加锁,最多等待 3 秒,锁过期时间 10 秒
    // 这里的 leaseTime 必须大于业务执行时间,防止锁过期导致其他线程介入
    boolean isLocked = lock.tryLock(3, 10, TimeUnit.SECONDS);
    
    if (isLocked) {
        // --- 核心区域:这里是受保护的 Try 阶段 ---
        // 在这里执行业务操作,如果失败,可以在这里进行有限次数的重试
        // 因为锁的存在,同一时刻只有一个线程在执行此段代码
        doBusinessWithRetry(); 
        // ------------------------------------
    } else {
        // 未获取到锁,直接失败或等待下一次调度
        log.warn("未获取到锁,放弃本次Try阶段执行");
    }
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
} finally {
    if (lock.isHeldByCurrentThread()) {
        lock.unlock();
    }
}

Redis 锁的避坑指南:

  1. 锁粒度:不要使用全局大锁(如 lock("all")),尽量细化到资源级别(如 lock("product:1001")),减少阻塞。
  2. 锁续期:务必使用 Redisson 的 Watch Dog 机制。如果业务执行时间超过了锁的初始过期时间(如 10s),Watch Dog 会自动延长锁的时间,防止业务没做完锁就没了(导致另一个线程进来产生并发冲突)。
  3. 原子性释放:确保 unlock()finally 块中执行,且最好校验是否是当前线程持有的锁(防止误删其他线程的锁)。

总结

在高并发的重试场景下:

  • 如果你的目标是防止脏写,请优先使用 Redis 的 WATCH/MULTI/EXEC 乐观锁机制,它能保证数据的一致性,让无效的重试快速失败。
  • 如果你的目标是防止资源竞争导致的系统悬挂,请使用 Redisson 的 分布式锁,配合 TryLockWatch Dog,将并发流量串行化,保护数据库和下游服务。

这两者往往结合使用:先用分布式锁限制并发流量,再在业务逻辑内部使用乐观锁校验数据状态。

后端架构师老张 Redis分布式锁高并发乐观锁

评论点评