本文是大数据系列第 50 篇,介绍如何在 Redis 中通过 WATCH/MULTI/EXEC 实现乐观锁、用 SETNX 构建分布式锁,以及用 Lua 脚本保证释放锁的原子性,最后对比 Redisson 框架的生产级封装方案。
乐观锁原理
乐观锁基于 CAS(Compare And Swap)思想:不阻塞并发读,而是在提交写操作时检查数据是否被其他线程修改。常见做法是读取时记录版本号,提交前核对版本,若一致则更新并自增版本,否则重试。
相比悲观锁,乐观锁在读多写少场景下无锁等待开销;缺点是写冲突频繁时重试代价大。电商库存扣减、秒杀限量等是典型用例。
| 维度 | 乐观锁 | 悲观锁 |
|---|---|---|
| 假设 | 冲突概率低 | 冲突概率高 |
| 实现 | 版本号/CAS | 互斥锁占用 |
| 等待 | 失败重试,不阻塞 | 阻塞等待锁释放 |
| 适用 | 读多写少 | 写多竞争激烈 |
Redis WATCH 实现乐观锁
WATCH 命令监视一个或多个 key,在 MULTI/EXEC 事务执行前,若被监视的 key 被其他客户端修改,EXEC 返回 nil 表示事务中止,需要重试。
执行流程:
WATCH key— 开始监视GET key— 读取当前值用于客户端计算MULTI— 开启事务队列- 入队修改命令(如
INCR key) 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 乐观锁。