Redis分布式锁的演进:从setnx到Redisson

引言

在微服务架构盛行的今天,分布式锁已经成为解决并发问题的标配工具。我曾在一次线上事故中深刻体会到它的重要性:某电商平台的库存服务在双11大促期间,由于并发扣减库存时没有使用分布式锁,导致超卖现象——数据库显示库存为负,最终造成了数十万元的损失。

事后复盘时我们发现,问题的根源不在于代码逻辑错误,而在于分布式环境下多个节点同时操作共享资源时缺乏有效的互斥机制。传统的synchronizedReentrantLock只能保证单个JVM进程内的线程安全,无法跨进程、跨节点生效。

这正是分布式锁要解决的问题。而在众多实现方案中,基于Redis的分布式锁因其高性能、易用性,成为了业界最主流的选择。但Redis分布式锁的实现并非一蹴而就,从最初的SETNX命令,到SET加过期时间的原子操作,再到Redisson框架的完整实现,每一步演进都解决了特定的痛点。

本文将带你深入理解这一演进过程,从源码层面剖析各种方案的优缺点,并给出生产环境的实践建议。

核心概念

生活类比:厕所里的"有人"标识

想象一下商场里的公共厕所,每个隔间的门锁就是一把分布式锁。当有人进入隔间后,会挂上"有人"的标识(相当于Redis中的锁标识),其他人看到后就会选择其他隔间或等待。

  • SETNX:相当于"如果有人就返回失败"的挂牌操作
  • 过期时间:相当于厕所的"最长使用时限",防止有人进去后不出来
  • 锁续期(Watchdog):相当于保洁员每隔一段时间检查一下,如果发现还在使用就延长时限
  • RedLock:相当于商场要求所有楼层的厕所都采用相同的规则,避免单点故障导致规则失效

技术定义

分布式锁需要满足三个核心性质:

  1. 互斥性:任意时刻,只有一个客户端能持有锁
  2. 安全性:锁只能被持有者释放,不能误删他人的锁
  3. 可用性:锁服务不能因单点故障而不可用

源码/原理深度分析

第一阶段:SETNX 的诞生与局限

Redis 2.6.12版本之前,获取分布式锁最简单的方式是使用SETNX命令:

// 伪代码
if (redis.setnx("lock:order:123", "1")) {
    // 获取锁成功,执行业务
    try {
        doSomething();
    } finally {
        redis.del("lock:order:123"); // 释放锁
    }
}

这个方案看似简单,却有个致命缺陷:如果获取锁的客户端在执行业务时崩溃,锁永远不会被释放,形成死锁。于是开发者们想到了一个补救方案——给锁加上过期时间:

if (redis.setnx("lock:order:123", "1")) {
    redis.expire("lock:order:123", 30); // 30秒后自动过期
    // 执行业务...
}

但这里又引入了新的问题:SETNXEXPIRE是两个独立的操作,不具备原子性。如果SETNX成功但EXPIRE执行前客户端崩溃,锁依然会永久存在。

第二阶段:SET 命令的原子进化

Redis 2.6.12版本引入了SET命令的扩展参数,将加锁和过期时间合并为原子操作:

SET lock:order:123 uniqueId NX PX 30000
  • NX:只有键不存在时才设置(等价于SETNX)
  • PX:设置过期时间为毫秒
  • uniqueId:客户端唯一标识,用于安全释放锁

这里有个关键设计:为什么释放锁时要校验唯一标识?

考虑这样一个场景:客户端A获取锁后因GC暂停超过锁的过期时间,锁自动过期。此时客户端B获取了同一把锁。A恢复后如果直接执行DEL操作,就会误删B的锁,导致B的业务逻辑在没有锁保护的情况下执行。

解决方式是使用Lua脚本保证"先校验再删除"的原子性:

if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

第三阶段:Redisson 的封装与进化

Redisson是一个基于Redis的Java客户端框架,它封装了分布式锁的完整实现。其核心类RedissonLockSET命令的基础上,增加了以下关键机制:

#### Watchdog 自动续期机制

// RedissonLock.java 核心续期逻辑(简化版)
private void renewExpiration() {
    Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
        @Override
        public void run(Timeout timeout) throws Exception {
            // 异步续期:将锁的过期时间重置为30秒
            RFuture<Boolean> future = renewExpirationAsync(lockName, internalLockLeaseTime);
            future.whenComplete((res, e) -> {
                if (res) {
                    // 续期成功,重新调度
                    renewExpiration();
                }
            });
        }
    }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
}

Watchdog的工作机制是:默认锁的超时时间为30秒,但Redisson会每10秒(30秒/3)检查一次,如果锁还被持有,就自动续期到30秒。这解决了业务执行时间超过锁过期时间的问题。

但这里有个值得注意的细节:为什么续期间隔是锁超时时间的1/3? 这是为了留出足够的缓冲时间。即使网络抖动导致一次续期失败,锁也不会立即过期,给客户端二次续期的机会。

#### 可重入锁支持

Redisson的锁支持可重入特性,内部通过一个ThreadLocal维护重入次数:

// RedissonLock.java 简化版
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) {
    // 获取当前线程ID
    long threadId = Thread.currentThread().getId();
    // 尝试获取锁
    Long ttl = tryAcquire(lockName, leaseTime, unit, threadId);
    if (ttl == null) {
        // 获取成功,记录重入次数为1
        return true;
    }
    // 等待获取锁...
}

// 重入加锁的Lua脚本
// KEYS[1] = lockName, ARGV[1] = leaseTime, ARGV[2] = threadId
// 如果锁存在且持有者是当前线程,则重入次数+1
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
    redis.call('hincrby', KEYS[1], ARGV[2], 1);
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return nil;
end;

这里使用Hash结构而非简单的String,就是为了支持重入计数。每次重入时hincrby加1,释放时减1,直到减为0才真正删除锁。

第四阶段:RedLock 算法的争议

即使有了Redisson的完整实现,分布式锁仍然面临一个根本性问题:如果Redis主节点宕机,锁的安全性如何保证?

考虑以下场景:

  1. 客户端A在主节点上获取了锁
  2. 主节点在将写操作同步到从节点之前宕机
  3. 从节点被提升为新的主节点,但锁数据丢失
  4. 客户端B在新的主节点上成功获取了同一把锁

为了解决这个问题,Redis作者antirez提出了RedLock算法,核心思想是同时向多个独立的Redis节点申请锁

graph TD A[客户端] --> B[Redis节点1] A --> C[Redis节点2] A --> D[Redis节点3] A --> E[Redis节点4] A --> F[Redis节点5] B --> G{获取锁成功} C --> G D --> G E --> G F --> G G --> H[加锁成功
节点数 >= N/2 + 1] G --> I[加锁失败
释放所有节点锁]

但RedLock算法也遭到了业界大牛的质疑。Martin Kleppmann在《How to do distributed locking》一文中指出,RedLock本质上仍然依赖时钟假设,在GC暂停或网络分区时可能失效。他认为:

  1. GC暂停:客户端A获得锁后发生Full GC,超过锁过期时间,锁被其他客户端获取
  2. 网络分区:客户端A与Redis集群的网络被隔离,无法续期,锁过期

这些场景下,RedLock并不能比单节点方案提供更好的保障。因此,如果你的业务场景对锁的安全性要求极高(如金融交易),建议使用ZooKeeper或etcd等强一致性方案

实战代码

示例一:基于 SET 命令的手写分布式锁

import redis.clients.jedis.Jedis;
import java.util.Collections;
import java.util.UUID;

/**
 * 基于SET命令的手写分布式锁
 * 适用于简单场景,生产环境建议使用Redisson
 */
public class SimpleRedisLock {
    
    private final Jedis jedis;
    private final String lockKey;
    private final String lockValue; // 唯一标识,用于安全释放锁
    private static final int DEFAULT_EXPIRE_TIME = 30000; // 默认过期时间30秒
    
    // Lua脚本:原子性校验并删除锁
    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 SimpleRedisLock(Jedis jedis, String lockKey) {
        this.jedis = jedis;
        this.lockKey = lockKey;
        this.lockValue = UUID.randomUUID().toString();
    }
    
    /**
     * 尝试获取锁
     * @param timeoutMillis 获取锁的超时时间(毫秒)
     * @return 是否获取成功
     */
    public boolean tryLock(long timeoutMillis) {
        long startTime = System.currentTimeMillis();
        try {
            // 使用SET命令的NX和PX参数,保证原子性
            String result = jedis.set(lockKey, lockValue, "NX", "PX", DEFAULT_EXPIRE_TIME);
            
            // 如果获取失败且在超时时间内,自旋重试
            while (result == null && 
                   (System.currentTimeMillis() - startTime) < timeoutMillis) {
                Thread.sleep(100); // 避免空转,休眠100ms
                result = jedis.set(lockKey, lockValue, "NX", "PX", DEFAULT_EXPIRE_TIME);
            }
            
            return result != null;
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return false;
        }
    }
    
    /**
     * 释放锁
     * 使用Lua脚本保证"校验-删除"的原子性
     */
    public void unlock() {
        jedis.eval(UNLOCK_SCRIPT, 
                   Collections.singletonList(lockKey), 
                   Collections.singletonList(lockValue));
    }
    
    // 使用示例
    public static void main(String[] args) {
        Jedis jedis = new Jedis("localhost", 6379);
        SimpleRedisLock lock = new SimpleRedisLock(jedis, "lock:order:123");
        
        if (lock.tryLock(5000)) { // 最多等待5秒
            try {
                // 执行业务逻辑
                System.out.println("获取锁成功,执行业务...");
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally {
                lock.unlock();
                System.out.println("锁已释放");
            }
        } else {
            System.out.println("获取锁失败,请稍后重试");
        }
        jedis.close();
    }
}

使用场景:适合简单的互斥场景,不要求可重入,业务执行时间可控且不会超过锁的过期时间。

示例二:使用 Redisson 实现可重入分布式锁

import org.redisson.Redisson;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import java.util.concurrent.TimeUnit;

/**
 * Redisson分布式锁示例
 * 自动续期 + 可重入 + 公平锁
 */
public class RedissonLockDemo {
    
    private final RedissonClient redissonClient;
    
    public RedissonLockDemo() {
        // 初始化Redisson配置
        Config config = new Config();
        config.useSingleServer()
              .setAddress("redis://localhost:6379")
              .setConnectionPoolSize(10)
              .setConnectionMinimumIdleSize(5);
        this.redissonClient = Redisson.create(config);
    }
    
    /**
     * 演示可重入锁
     */
    public void demoReentrantLock() {
        RLock lock = redissonClient.getLock("lock:inventory:1001");
        
        try {
            // 尝试获取锁,最多等待3秒,锁自动过期时间默认30秒
            if (lock.tryLock(3, TimeUnit.SECONDS)) {
                System.out.println("第一次获取锁成功");
                
                // 模拟嵌套调用:同一个线程可以重入
                if (lock.tryLock(3, TimeUnit.SECONDS)) {
                    System.out.println("第二次获取锁成功(可重入)");
                    lock.unlock(); // 释放重入的锁
                    System.out.println("第二次释放锁");
                }
                
                // 执行业务逻辑
                Thread.sleep(10000); // 模拟耗时业务,超过默认30秒会自动续期
                System.out.println("业务执行完成");
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            // 注意:只有在持有锁的情况下才释放
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
                System.out.println("第一次释放锁");
            }
        }
    }
    
    /**
     * 演示公平锁
     */
    public void demoFairLock() {
        // 公平锁保证线程按照请求顺序获取锁
        RLock fairLock = redissonClient.getFairLock("lock:fair:order");
        
        try {
            // 等待10秒获取公平锁
            if (fairLock.tryLock(10, TimeUnit.SECONDS)) {
                System.out.println("获取公平锁成功");
                // 业务逻辑...
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            if (fairLock.isHeldByCurrentThread()) {
                fairLock.unlock();
            }
        }
    }
    
    /**
     * 演示读写锁
     */
    public void demoReadWriteLock() {
        RLock readLock = redissonClient.getReadWriteLock("lock:config").readLock();
        RLock writeLock = redissonClient.getReadWriteLock("lock:config").writeLock();
        
        // 读锁:多个线程可以同时持有
        readLock.lock(10, TimeUnit.SECONDS);
        try {
            System.out.println("读取配置...");
        } finally {
            readLock.unlock();
        }
        
        // 写锁:独占
        writeLock.lock(10, TimeUnit.SECONDS);
        try {
            System.out.println("更新配置...");
        } finally {
            writeLock.unlock();
        }
    }
    
    public static void main(String[] args) {
        RedissonLockDemo demo = new RedissonLockDemo();
        demo.demoReentrantLock();
    }
}

使用场景:生产环境首选,支持可重入、自动续期、公平锁、读写锁等多种特性,适合大多数业务场景。

示例三:Redisson 锁的 Watchdog 源码级剖析

import org.redisson.RedissonLock;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import java.util.concurrent.TimeUnit;

/**
 * 深入剖析Redisson的Watchdog机制
 */
public class WatchdogAnalysis {
    
    /**
     * 演示Watchdog自动续期
     * 
     * 核心流程:
     * 1. 默认lockLeaseTime为30秒
     * 2. 获取锁成功后,启动一个定时任务,每10秒执行一次续期
     * 3. 续期时调用renewExpirationAsync,将过期时间重置为30秒
     * 4. 如果业务执行完成(unlock),取消续期任务
     * 5. 如果JVM崩溃,续期任务随之消失,锁在30秒后自动过期
     */
    public void demoWatchdog() {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://localhost:6379");
        RedissonClient client = Redisson.create(config);
        
        // 获取锁时,未指定leaseTime,则使用默认的30秒+Watchdog
        RLock lock = client.getLock("lock:watchdog:demo");
        
        try {
            // 注意:这里不传leaseTime参数,才会触发Watchdog机制
            lock.lock(10, TimeUnit.SECONDS);
            System.out.println("获取锁成功,Watchdog已启动");
            
            // 模拟长时间业务,比如执行60秒
            long startTime = System.currentTimeMillis();
            while (System.currentTimeMillis() - startTime < 60000) {
                // 执行业务...
                Thread.sleep(5000);
                System.out.println("业务执行中... 锁剩余过期时间: " + 
                                 ((RedissonLock) lock).remainTimeToLive() + "ms");
            }
            
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
                System.out.println("锁已释放,Watchdog已停止");
            }
            client.shutdown();
        }
    }
    
    /**
     * 自定义锁过期时间(不使用Watchdog)
     * 
     * 注意:如果传入leaseTime参数,Watchdog不会生效
     */
    public void demoCustomLeaseTime() {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://localhost:6379");
        RedissonClient client = Redisson.create(config);
        
        RLock lock = client.getLock("lock:custom-lease:demo");
        
        try {
            // 传入leaseTime=10秒,此时Watchdog不生效
            // 锁在10秒后自动过期,即使业务还没执行完
            lock.lock(10, TimeUnit.SECONDS);
            System.out.println("获取锁成功(自定义过期时间),Watchdog未启动");
            
            // 模拟业务执行15秒 > 锁过期时间10秒
            Thread.sleep(15000);
            // 此时锁可能已被自动释放,其他线程可以获取到锁
            System.out.println("业务执行完成,但锁可能已过期");
            
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            // 注意:这里释放锁时会校验持有者,如果锁已过期,unlock会抛出异常
            try {
                if (lock.isHeldByCurrentThread()) {
                    lock.unlock();
                }
            } catch (Exception e) {
                System.err.println("锁已被自动释放,无需手动解锁");
            }
            client.shutdown();
        }
    }
    
    public static void main(String[] args) {
        WatchdogAnalysis analysis = new WatchdogAnalysis();
        // analysis.demoWatchdog();
        analysis.demoCustomLeaseTime();
    }
}

关键提示:使用Watchdog时,务必确保业务代码不会无限期执行。如果业务确实需要长时间运行,应该显式指定合适的leaseTime,并做好锁续期的监控。

方案对比

业界主流分布式锁方案对比

方案 一致性 性能 可用性 复杂度 适用场景
Redis SETNX 弱(AP) 极高 高(依赖主从架构) 允许短时间数据不一致的业务
Redisson 弱(AP) 大多数业务场景,需要可重入/续期
RedLock 弱(AP) 低(需要多次网络请求) 低(依赖节点数量) 对安全性要求极高的场景(仍有争议)
ZooKeeper 强(CP) 金融、订单等强一致场景
etcd 强(CP) 云原生环境下的分布式协调

为什么说Redis分布式锁是"AP"方案?

Redis默认采用异步主从复制,主节点写入成功后立即返回,从节点异步同步数据。这意味着:

  1. 主节点宕机前,锁数据可能还没同步到从节点
  2. 从节点晋升为主节点后,锁数据丢失
  3. 其他客户端可以获取到同一把锁

如果业务允许短暂的不一致(如秒杀场景),Redis方案完全够用。如果要求严格的互斥性(如转账),建议使用ZooKeeper或etcd。

最佳实践与避坑指南

常见坑位

  1. 锁过期时间设置不合理
  • 过短:业务未执行完锁就过期,导致并发问题
  • 过长:客户端崩溃后,锁长时间不释放,影响系统可用性
  • 建议:默认30秒+Watchdog,或根据业务P99耗时设置2-3倍余量
  1. 忘记释放锁
  • finally块中释放锁,确保异常时也能释放
  • 使用try-with-resources或切面编程自动管理
  1. 锁粒度过大
  • 不要对整个业务流程加锁,应该只锁临界区
  • 使用分段锁或细粒度锁提升并发能力
  1. 忽略锁续期问题
  • 如果使用手写SETNX方案,需要自己实现续期逻辑
  • Redisson的Watchdog虽好,但不要无脑依赖,要评估业务最坏执行时间
  1. Redis集群的脑裂问题
  • 主节点与从节点之间网络分区,导致锁数据不一致
  • 可以考虑使用RedLock或强一致性方案

最佳实践清单

// 最佳实践模板
public void businessMethod() {
    RLock lock = redissonClient.getLock("lock:business:" + bizId);
    boolean locked = false;
    try {
        // 设置合理的等待时间和锁过期时间
        locked = lock.tryLock(3, 30, TimeUnit.SECONDS);
        if (!locked) {
            // 获取锁失败,可以重试或返回错误
            throw new BizException("系统繁忙,请稍后重试");
        }
        // 执行业务逻辑(控制在锁过期时间以内)
        doBusiness();
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        throw new BizException("获取锁被中断", e);
    } finally {
        if (locked && lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}

总结

回顾Redis分布式锁的演进历程,我们可以清晰地看到技术迭代的脉络:

  1. SETNX:最原始的尝试,解决了分布式环境下的互斥问题,但缺乏过期机制
  2. SET NX PX:通过原子操作解决了锁过期问题,但缺少可重入和续期支持
  3. Redisson:通过Watchdog、可重入、公平锁等特性,将分布式锁推向成熟
  4. RedLock:尝试解决Redis主从架构的固有缺陷,但引入了新的复杂性

从架构设计的角度来看,没有完美的方案,只有最合适的方案。在选择分布式锁时,你需要权衡:

  • 业务对一致性的要求:是否允许短暂的不一致?
  • 系统的可用性要求:Redis集群的可用性是否足够?
  • 团队的技术栈:是否已经引入了Redis等中间件?

最后,无论选择哪种方案,都要做好监控和告警。锁的获取失败率、持有时间分布、续期次数等指标,都能帮助你提前发现潜在问题。

延伸思考

如果你对分布式锁的极致性能有追求,可以考虑:

  1. 本地锁+分布式锁的二级缓存:热点数据优先使用本地锁,减少Redis网络开销
  2. 基于Redis Streams的消息队列替代锁:在某些场景下,用消息队列天然避免并发
  3. 数据库悲观锁/乐观锁:对于低频操作,数据库锁可能更简单可靠

分布式锁的世界远不止Redis,ZooKeeper的顺序节点、etcd的Lease机制都有其独特优势。希望本文能帮你建立起完整的知识框架,在实际项目中做出更明智的技术选型。