本文是大数据系列第 50 篇,介绍如何在 Redis 中通过 WATCH/MULTI/EXEC 实现乐观锁、用 SETNX 构建分布式锁,以及用 Lua 脚本保证释放锁的原子性,最后对比 Redisson 框架的生产级封装方案。

完整图文版(含截图):CSDN 原文 | 掘金

乐观锁原理

乐观锁基于 CAS(Compare And Swap)思想:不阻塞并发读,而是在提交写操作时检查数据是否被其他线程修改。常见做法是读取时记录版本号,提交前核对版本,若一致则更新并自增版本,否则重试。

相比悲观锁,乐观锁在读多写少场景下无锁等待开销;缺点是写冲突频繁时重试代价大。电商库存扣减、秒杀限量等是典型用例。

维度乐观锁悲观锁
假设冲突概率低冲突概率高
实现版本号/CAS互斥锁占用
等待失败重试,不阻塞阻塞等待锁释放
适用读多写少写多竞争激烈

Redis WATCH 实现乐观锁

WATCH 命令监视一个或多个 key,在 MULTI/EXEC 事务执行前,若被监视的 key 被其他客户端修改,EXEC 返回 nil 表示事务中止,需要重试。

执行流程:

  1. WATCH key — 开始监视
  2. GET key — 读取当前值用于客户端计算
  3. MULTI — 开启事务队列
  4. 入队修改命令(如 INCR key
  5. EXEC — 提交;若 key 未变则成功,否则返回 nil 需重试

以下 Java 示例用 20 个线程模拟 300 次并发自增,演示乐观锁防止超卖:

public class Test02 {
    public static void main(String[] args) {
        String redisKey = "lock";
        ExecutorService executor = Executors.newFixedThreadPool(20);
        Jedis jedis = new Jedis("h121.wzk.icu", 6379);
        jedis.del(redisKey);
        jedis.set(redisKey, "0");

        for (int i = 0; i < 300; i++) {
            executor.execute(() -> {
                Jedis j = new Jedis("h121.wzk.icu", 6379);
                try {
                    j.watch(redisKey);
                    int value = Integer.parseInt(j.get(redisKey));
                    if (value < 20) {
                        Transaction tx = j.multi();
                        tx.incr(redisKey);
                        List<Object> list = tx.exec();
                        if (list != null && !list.isEmpty()) {
                            System.out.println("成功抢到:" + (value + 1));
                        }
                    }
                } finally {
                    j.close();
                }
            });
        }
    }
}

WATCH 配合 MULTI/EXEC 保证了”读-判断-写”的一致性,但它是乐观的——冲突时不阻塞,适合业务层自行控制重试逻辑。

SETNX 分布式锁

SETNX(SET if Not eXists)是 Redis 的原子操作:key 不存在时写入并返回 1,已存在则返回 0。利用这一特性可以实现跨进程的互斥锁。

推荐写法(原子设置 + 过期时间):

// 一条命令同时设置 NX 和 EX,避免 setnx + expire 的非原子问题
String result = jedis.set(lockKey, requestId, "NX", "EX", expireTime);
if ("OK".equals(result)) {
    // 获锁成功
}

早期拆成两步(setnx 后再 expire)存在宕机导致锁永不过期的风险,现代 Redis 的 SET key value NX EX seconds 原子指令已解决此问题。

释放锁必须用 Lua 脚本保证原子性:

// 先校验 requestId 再删除,防止误删他人锁
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
                "return redis.call('del', KEYS[1]) else return 0 end";
jedis.eval(script,
    Collections.singletonList(lockKey),
    Collections.singletonList(requestId));

单纯 DEL 的问题:若锁已超时被其他客户端持有,当前客户端的 DEL 会删掉别人的锁,产生竞态。Lua 脚本在 Redis 中原子执行,彻底规避此问题。

分布式锁必须满足的四个特性:

特性说明
互斥性同一时刻只有一个客户端持锁
身份归属只有加锁方能释放锁
可重入性持锁方可多次获取不死锁
容错性自动过期防止永久阻塞

Redisson 框架

手写 SETNX 锁在锁续期、可重入、Redlock 等场景下较繁琐,Redisson 是生产级解决方案。

Maven 依赖:

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>2.7.0</version>
</dependency>

连接配置(集群模式):

Config config = new Config();
config.useClusterServers()
    .addNodeAddress("redis://h121.wzk.icu:6379")
    .addNodeAddress("redis://h122.wzk.icu:6379");
Redisson redisson = Redisson.create(config);

加锁 / 释放:

public static boolean acquire(String lockName) {
    RLock rLock = redisson.getLock("redisLock_" + lockName);
    rLock.lock(3, TimeUnit.SECONDS);  // 最多持锁 3 秒
    return true;
}

public static void release(String lockName) {
    RLock rLock = redisson.getLock("redisLock_" + lockName);
    rLock.unlock();
}

Redisson 内部通过 Lua 脚本实现加锁/续期/释放的原子操作,并支持 WatchDog 自动续期——每隔 lockWatchdogTimeout / 3(默认 10 秒)检查业务是否仍在执行,若是则自动延长锁的过期时间,彻底解决”业务未完成、锁已过期”的问题。

方案对比与选型

方案性能可靠性实现复杂度
Redis WATCH 乐观锁
Redis SETNX极高
Redis Lua 原子释放
Redisson低(框架封装)
ZooKeeper较低极高

选型建议:简单防重场景用 SETNX + Lua 释放;生产高并发且需要可重入、自动续期时优先选 Redisson;读多写少、冲突概率低的场景可用 WATCH 乐观锁