Redis 分布式锁:从入门到踩坑再到正确姿势

jxq
1
2026-05-11

Redis 分布式锁:从入门到踩坑再到正确姿势

一个订单重复支付的惨案

“用户投诉:支付成功但订单状态没变。”

查了半小时日志,发现同一个订单在 200ms 内被两个支付回调同时处理,导致状态异常。进一步排查,发现是负载均衡下多个服务实例同时抢到了同一把"锁"

实例 A: 查询订单状态 → 待支付 → 开始处理
实例 B: 查询订单状态 → 待支付 → 开始处理  ← 同一时刻!
实例 A: 更新状态为已支付
实例 B: 再次更新状态(覆盖了 A 的操作)

本地锁(synchronized / ReentrantLock)在分布式环境下彻底失效。你需要分布式锁。


一、分布式锁是什么

flowchart LR
    subgraph "单机环境"
        A1[线程1] --> L1[本地锁]
        A2[线程2] --> L1
        L1 --> R1[共享资源]
    end
    
    subgraph "分布式环境"
        B1[实例1] --> DL[Redis 分布式锁]
        B2[实例2] --> DL
        B3[实例3] --> DL
        DL --> R2[共享资源<br/>如订单状态]
    end

核心诉求:多个进程 / 多台机器,同一时刻只有一个能操作共享资源。


二、Redis 分布式锁的演进

第一版:SETNX

// 伪代码:最简单的分布式锁
public boolean tryLock(String key) {
    // SETNX: key 不存在时才设置成功
    return redis.setnx(key, "1") == 1;
}

public void unlock(String key) {
    redis.del(key);
}

问题:如果获取锁后程序崩溃,锁永远无法释放 → 死锁

第二版:SETNX + EXPIRE

// 伪代码:加上过期时间
public boolean tryLock(String key, int expireSeconds) {
    if (redis.setnx(key, "1") == 1) {
        redis.expire(key, expireSeconds);  // 设置过期时间
        return true;
    }
    return false;
}

问题:SETNX 和 EXPIRE 是两条命令,不是原子操作。如果 SETNX 成功后、EXPIRE 执行前崩溃,依然死锁。

第三版:SET NX EX(正确姿势)

// 伪代码:原子设置值 + 过期时间
public boolean tryLock(String key, String value, int expireSeconds) {
    // SET key value NX EX seconds
    // NX: 不存在才设置
    // EX: 设置过期时间(秒)
    return "OK".equals(redis.set(key, value, "NX", "EX", expireSeconds));
}

一条命令,原子完成。这是 Redis 2.6.12 之后推荐的方式。


三、还有坑:锁误删问题

场景重现

sequenceDiagram
    participant A as 实例A
    participant R as Redis
    participant B as 实例B
    
    A->>R: SET lock:order:123 valueA NX EX 10
    Note over A: 获取锁成功,开始处理
    
    Note over A: 业务处理耗时 15s(超过锁过期时间)
    Note over R: 锁自动过期
    
    B->>R: SET lock:order:123 valueB NX EX 10
    Note over B: 获取锁成功(A的锁已过期)
    
    Note over A: 业务处理完成
    A->>R: DEL lock:order:123
    Note over R: 删除的是 B 的锁!
    
    B->>B: 😭 我的锁被删了...

问题本质:实例 A 删除了实例 B 的锁。

解决方案:加锁时设置唯一标识,解锁时校验

// 伪代码:加锁时设置唯一值
public boolean tryLock(String key, int expireSeconds) {
    String lockValue = UUID.randomUUID().toString();
    boolean success = "OK".equals(redis.set(key, lockValue, "NX", "EX", expireSeconds));
    if (success) {
        // 保存 lockValue,解锁时要用
        currentThreadLockValue.set(lockValue);
    }
    return success;
}

// 伪代码:解锁时校验是否是自己的锁
public void unlock(String key) {
    String lockValue = currentThreadLockValue.get();
    
    // 使用 Lua 脚本保证原子性
    String luaScript = """
        if redis.call('GET', KEYS[1]) == ARGV[1] then
            return redis.call('DEL', KEYS[1])
        else
            return 0
        end
        """;
    
    redis.eval(luaScript, Collections.singletonList(key), Collections.singletonList(lockValue));
}

为什么用 Lua 脚本? GET + DEL 不是原子操作,中间可能被其他实例抢锁。Lua 脚本在 Redis 中原子执行。


四、还有坑:锁过期时间如何设置

问题

  • 设置太短:业务还没处理完,锁就过期了
  • 设置太长:服务崩溃后,锁要很久才能释放

解决方案:看门狗(Watchdog)

// 伪代码:自动续期机制
public boolean tryLockWithWatchdog(String key, int expireSeconds) {
    String lockValue = UUID.randomUUID().toString();
    boolean success = tryLock(key, lockValue, expireSeconds);
    
    if (success) {
        // 启动后台线程,定期续期
        ScheduledExecutorService watchdog = Executors.newSingleThreadScheduledExecutor();
        watchdog.scheduleAtFixedRate(() -> {
            // 每隔 expireSeconds/3 续期一次
            redis.expire(key, expireSeconds);
        }, expireSeconds / 3, expireSeconds / 3, TimeUnit.SECONDS);
        
        // 保存 watchdog 引用,解锁时关闭
        watchdogRef.set(watchdog);
    }
    return success;
}

原理:只要业务还在执行,看门狗就持续续期;业务结束或服务崩溃,看门狗停止,锁自动过期。

Redisson 已经实现了这个机制

// Java: Redisson 示例
RLock lock = redisson.getLock("order:123");
lock.lock();  // 自动启动看门狗
try {
    // 业务逻辑,即使执行很久也不会锁过期
    processOrder();
} finally {
    lock.unlock();  // 自动关闭看门狗
}

五、Redis 集群下的新问题:Redlock

主从切换导致锁丢失

sequenceDiagram
    participant C as 客户端
    participant M as Master
    participant S as Slave
    
    C->>M: SET lock:order NX EX 10
    Note over M: 加锁成功
    Note over M: 还未同步到 Slave
    
    Note over M: Master 宕机!
    M--xS: 同步中断
    
    Note over S: Slave 升级为 Master
    C->>S: SET lock:order NX EX 10
    Note over S: 加锁成功(同一把锁被两次获取)

Redlock 算法

核心思想:向多个独立的 Redis 节点申请锁,多数派成功才算成功

# 伪代码:Redlock 算法
def redlock(resource, ttl):
    quorum = len(redis_nodes) // 2 + 1  # 多数派
    acquired = 0
    
    for node in redis_nodes:
        if try_lock(node, resource, ttl):
            acquired += 1
    
    if acquired >= quorum:
        return True  # 获取锁成功
    else:
        # 释放已获取的锁
        for node in redis_nodes:
            unlock(node, resource)
        return False

Redisson 实现

// Java: Redisson Redlock
RLock lock1 = redisson1.getLock("order:123");
RLock lock2 = redisson2.getLock("order:123");
RLock lock3 = redisson3.getLock("order:123");

RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
redLock.lock();

Redlock 的争议

反对派(Martin Kleppmann)

  • 时钟同步问题可能导致锁安全性被破坏
  • 系统复杂度大幅增加,收益不一定成正比

支持派(Redis 作者 antirez)

  • 多数场景不需要 Redlock
  • 使用 fencing token(递增序号)可以更简单地解决问题

实际建议:大多数场景下,单实例 Redis 锁 + 正确实现已经足够


六、正确姿势总结

// Java: 推荐的分布式锁使用方式(Redisson)
public void processOrder(String orderId) {
    String lockKey = "lock:order:" + orderId;
    RLock lock = redisson.getLock(lockKey);
    
    try {
        // 尝试获取锁,最多等待 5 秒,锁自动过期时间 30 秒
        boolean acquired = lock.tryLock(5, 30, TimeUnit.SECONDS);
        
        if (!acquired) {
            throw new BusinessException("系统繁忙,请稍后重试");
        }
        
        // 执行业务逻辑
        doBusiness(orderId);
        
    } finally {
        // 只释放自己持有的锁
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}

关键要点

要点 说明
原子加锁 使用 SET NX EX,一条命令完成
唯一标识 加锁时设置 UUID,解锁时校验
Lua 解锁 GET + DEL 必须原子执行
合理过期 根据业务时长设置,或使用看门狗自动续期
超时机制 tryLock 设置等待时间,避免无限阻塞
异常处理 finally 中确保释放锁,但要先判断是否持有

七、什么时候不该用 Redis 分布式锁

不是所有并发问题都需要分布式锁

场景 更好的方案
数据库乐观更新 乐观锁(version 字段)
幂等接口 唯一索引 / 去重表
简单计数 Redis INCR(原子操作)
消息顺序消费 单队列单消费者

分布式锁的成本:引入外部依赖、增加系统复杂度、潜在的性能瓶颈。能不用就不用,用了就要用对。


回到开头的问题

那次订单重复支付事故,最后是怎么解决的?

  1. 短期:在支付回调入口加分布式锁(Redis + Redisson)
  2. 中期:数据库订单状态字段加乐观锁(version)
  3. 长期:支付回调改为幂等设计,同一订单号只处理一次

三层防护,缺一不可。 分布式锁是盾牌,但不是唯一的防线。


思考:分布式锁的本质

分布式锁解决的核心问题是跨进程的互斥。但分布式系统的复杂性在于:

  • 网络可能断
  • 进程可能挂
  • 时钟可能不同步

不存在完美的分布式锁,只有适合场景的取舍。

选择 Redis 分布式锁,意味着你接受:

  • CP 系统中的 AP 特性(可用性优先,极端情况可能丢锁)
  • 外部依赖带来的运维成本
  • 正确实现的复杂度

如果业务对一致性要求极高(如金融交易),也许应该考虑 ZooKeeper / etcd 的分布式锁(CP 系统),或者直接用数据库事务,而不是 Redis。

选择的技术,要和业务风险相匹配。

动物装饰