基于 redis 实现分布式锁,在 java 面试中通常被归纳为三种方式:手写基础版、lua 脚本增强版,以及使用 redisson 框架。
这几种方式本质上是递进的,核心都是在解决手写实现中存在的原子性、锁续期和可重入等痛点。
🔧 方式一:基于 setnx + expire(基础版)
这是最原始的写法,利用 redis 的 setnx(不存在才设置)命令实现互斥。
- 核心思路:用
setnx抢占锁,成功后用expire设置超时,防止宕机死锁。 - 关键问题:
setnx和expire是两条命令,不是原子操作。如果设置锁成功后,设置过期时间前服务宕机,锁将永远无法释放,导致死锁。
⚙️ 方式二:基于 set nx ex + lua 脚本(原子增强版)
为了解决方式一的原子性问题,redis 2.6.12 之后支持了带 nx 和 ex 参数的 set 命令,并配合 lua 脚本解决误删问题。
- 加锁改进:使用
set lockkey uniquevalue ex 30 nx,一条命令完成“不存在则设置”和“设置过期时间”,保证原子性。 - 解锁改进:释放锁时不能直接
del。因为可能存在线程 a 业务超时导致锁过期,线程 b 拿到锁后,线程 a 业务执行完误删了 b 的锁。解决方法是把uniquevalue(如 uuid+线程id)作为锁的值,释放前通过 lua 脚本判断值是否匹配,匹配才删除,保证“判断+删除”的原子性。
🚀 方式三:基于 redisson 框架(企业级工业版)
手写方案仍然难以解决业务超时导致锁提前释放以及可重入等问题。在实际生产环境或面试中,redisson 是更高级的答案。
- 看门狗(watch dog)机制:这是 redisson 解决锁续期的核心。当获取锁且未指定超时时间时,redisson 会启动一个后台线程,默认每隔 10秒(约锁超时时间的 1/3)检查一次业务是否执行完毕。若未完毕,则自动延长锁的过期时间,有效防止业务未完成时锁自动失效。
- 可重入性:利用 redis 的 hash 结构存储线程 id 和重入次数。同一线程再次获取锁时,重入次数加 1,而不是被阻塞。
- 重试机制:底层利用信号量(semaphore)和发布订阅(pubsub)功能,在获取锁失败时等待其他线程释放锁并唤醒自己,实现等待重试。
📊 三种方式对比总结
| 特性 | 基础版 (setnx + expire) | 原子增强版 (set nx ex + lua) | redisson 框架 |
|---|---|---|---|
| 加锁原子性 | ❌ 非原子 | ✅ 原子(单命令) | ✅ 原子(lua 封装) |
| 防误删 | ❌ 无法防止 | ✅ 通过 lua 校验 uniquevalue | ✅ 内部封装 |
| 锁续期(看门狗) | ❌ 无 | ❌ 无 | ✅ 自动续期 |
| 可重入 | ❌ 不支持 | ❌ 不支持 | ✅ 支持 |
| 获取锁失败重试 | ❌ 直接失败 | ❌ 直接失败 | ✅ 支持(带等待) |
| 适用场景 | 仅学习原理 | 简单临时场景 | 生产环境首选 |
在面试时,建议这样回答:“主要分三种,最基础的是用 setnx 和 expire 组合,但有原子性问题。改进版是用 set nx ex 加 lua 脚本保证原子释放,但依然无法解决业务超时和可重入。所以生产上通常直接用 redisson,它的看门狗机制能自动续期,还支持可重入和重试,是目前的工业标准。”
redis 分布式锁"误删锁"问题 demo
我用生活化的比喻 + 完整可运行代码,帮你彻底搞懂这个问题。
一、先讲个故事(理解问题本质)
假设你去公共厕所,门上有一把锁:
- 锁 = redis 里的 lockkey
- 钥匙 = uniquevalue(唯一标识)
- 30秒自动开锁 = 锁的过期时间
事故现场:
1. 张三(a) 进厕所,上锁,钥匙上写"张三的钥匙" —— 但张三拉肚子,30秒没出来
2. 30秒到,锁自动开了(张三还在里面!)
3. 李四(b) 进来,上锁,钥匙写"李四的钥匙"
4. 张三终于出来了,看都没看,直接把门上的锁拆了 —— 拆的是李四的锁!
5. 王五(c) 推门进来 —— 厕所里李四还在,乱套了
核心问题:张三拆锁时,没确认这把锁是不是自己的。
解决方案:拆锁前先看钥匙上写的是不是自己的名字,确认是自己的才拆。而且"看+拆"必须一次做完(不能看的时候是张三的,手伸过去的瞬间变成李四的)。
这就是 uniquevalue + lua 脚本 的作用。
二、错误示范(会误删别人的锁)
public class wronglock {
// ❌ 错误:直接删除,不管锁是不是自己的
public static void unlock(stringredistemplate redis, string lockkey) {
redis.delete(lockkey); // 危险!!!
}
}触发场景:
| 时间 | 线程 a | 线程 b | redis 里的锁 |
|---|---|---|---|
| t0 | 加锁成功(超时5秒) | 锁=a | |
| t1 | 业务卡住(超过5秒) | 锁=a | |
| t6 | 还在执行... | 锁过期,自动删除 | |
| t6 | 加锁成功 | 锁=b | |
| t7 | 执行完毕,调用 delete | 锁被a删了!(b的锁) | |
| t8 | 还在执行 | 锁=空 | |
| t8 | 线程c可以加锁成功,b和c同时执行 💥 |
三、正确实现(uniquevalue + lua)
1. 加锁:set nx ex + 唯一值
import org.springframework.data.redis.core.stringredistemplate;
import org.springframework.data.redis.core.script.defaultredisscript;
import org.springframework.stereotype.component;
import java.util.collections;
import java.util.uuid;
@component
public class redislock {
private final stringredistemplate redis;
public redislock(stringredistemplate redis) {
this.redis = redis;
}
/**
* 尝试加锁
* @param lockkey 锁的key,如 "lock:order:123"
* @param expiresec 过期时间(秒)
* @return 成功返回 uniquevalue,失败返回 null
*/
public string trylock(string lockkey, long expiresec) {
// 生成唯一标识:uuid + 线程id,保证每个线程的值不同
string uniquevalue = uuid.randomuuid() + ":" + thread.currentthread().getid();
// 一条命令完成"不存在才设置"+"设置过期",原子操作
boolean success = redis.opsforvalue()
.setifabsent(lockkey, uniquevalue, expiresec, timeunit.seconds);
return boolean.true.equals(success) ? uniquevalue : null;
}
/**
* 解锁:先判断是不是自己的锁,是才删除
* 用 lua 保证"判断+删除"的原子性
*/
public boolean unlock(string lockkey, string uniquevalue) {
// lua 脚本:值匹配才删除,返回1;不匹配返回0
string luascript =
"if redis.call('get', keys[1]) == argv[1] then " +
" return redis.call('del', keys[1]) " +
"else " +
" return 0 " +
"end";
long result = redis.execute(
new defaultredisscript<>(luascript, long.class),
collections.singletonlist(lockkey),
uniquevalue // 传入自己加锁时的唯一值
);
return result != null && result == 1l;
}
}⚠️ 注意:需要
import java.util.concurrent.timeunit;
2. 为什么必须用 lua?
因为 "get 判断" 和 "del 删除" 是两步操作,中间可能被打断:
线程a: get lockkey → 值是我的 (判断通过) ✅
↓ 【此刻锁刚好过期,b加锁成功,锁变成b的】
线程a: del lockkey → 删掉的是b的锁!❌
lua 脚本在 redis 中是单线程原子执行的,"判断"和"删除"之间不会被其他命令插入,从根本上杜绝了这个问题。
3. 业务代码使用示例
@service
public class orderservice {
@autowired
private redislock redislock;
public void createorder(long orderid) {
string lockkey = "lock:order:" + orderid;
string uniquevalue = redislock.trylock(lockkey, 30);
if (uniquevalue == null) {
throw new runtimeexception("操作太频繁,请稍后再试");
}
try {
// ===== 真正的业务逻辑 =====
dobusiness(orderid);
} finally {
// 无论如何都要尝试释放锁,但只会释放自己的
redislock.unlock(lockkey, uniquevalue);
}
}
private void dobusiness(long orderid) {
system.out.println("处理订单:" + orderid);
}
}四、完整可运行的测试 demo
用 main 方法模拟"业务超时导致锁过期"的场景:
import org.springframework.data.redis.core.stringredistemplate;
import org.springframework.data.redis.core.script.defaultredisscript;
import org.springframework.data.redis.connection.lettuce.lettuceconnectionfactory;
import java.util.collections;
import java.util.uuid;
import java.util.concurrent.timeunit;
public class lockdemo {
static stringredistemplate redis;
public static void main(string[] args) throws exception {
// 1. 初始化 redis 连接(本地 redis 默认端口 6379)
lettuceconnectionfactory factory = new lettuceconnectionfactory("localhost", 6379);
factory.afterpropertiesset();
redis = new stringredistemplate(factory);
redis.afterpropertiesset();
string lockkey = "lock:order:1001";
// ================= 场景1:正常释放 =================
system.out.println("===== 场景1:线程a正常执行并释放 =====");
string valuea = trylock(lockkey, 30);
system.out.println("a 加锁: " + valuea);
boolean r1 = unlock(lockkey, valuea);
system.out.println("a 解锁结果(应为true): " + r1 + "\n");
// ================= 场景2:误删演示 =================
system.out.println("===== 场景2:模拟业务超时,锁自动过期后被b拿到 =====");
string valuea2 = trylock(lockkey, 2); // a 只锁2秒
system.out.println("a 加锁(2秒过期): " + valuea2);
system.out.println("a 业务执行中,休眠3秒导致锁过期...");
thread.sleep(3000); // 模拟a业务超时,锁自动过期
string valueb = trylock(lockkey, 30); // b 此时能拿到锁
system.out.println("b 加锁成功: " + valueb);
// a 执行完,尝试解锁 —— 关键点来了
boolean r2 = unlock(lockkey, valuea2);
system.out.println("a 解锁结果(应为false,因为锁是b的): " + r2);
// 验证 b 的锁还在
string currentvalue = redis.opsforvalue().get(lockkey);
system.out.println("当前锁的值: " + currentvalue);
system.out.println("b的锁是否安然无恙: " + valueb.equals(currentvalue) + "\n");
// 清理
unlock(lockkey, valueb);
factory.destroy();
}
/** 加锁 */
static string trylock(string key, long expiresec) {
string value = uuid.randomuuid() + ":" + thread.currentthread().getid();
boolean ok = redis.opsforvalue().setifabsent(key, value, expiresec, timeunit.seconds);
return boolean.true.equals(ok) ? value : null;
}
/** 解锁:lua 保证原子性 */
static boolean unlock(string key, string value) {
string lua =
"if redis.call('get', keys[1]) == argv[1] then " +
" return redis.call('del', keys[1]) " +
"else return 0 end";
long result = redis.execute(
new defaultredisscript<>(lua, long.class),
collections.singletonlist(key), value);
return result != null && result == 1l;
}
}运行结果:
===== 场景1:线程a正常执行并释放 =====
a 加锁: 8a3f...:1
a 解锁结果(应为true): true
===== 场景2:模拟业务超时,锁自动过期后被b拿到 =====
a 加锁(2秒过期): 5b2c...:1
a 业务执行中,休眠3秒导致锁过期...
b 加锁成功: 9d1e...:1
a 解锁结果(应为false,因为锁是b的): false ← 关键!a没删掉b的锁
当前锁的值: 9d1e...:1
b的锁是否安然无恙: true
看结果就懂了:a 虽然执行完了想去删锁,但因为锁已经是 b 的了,lua 判断值不匹配,拒绝删除,b 的锁安全了。✅
五、maven 依赖(spring boot 项目)
<dependency>
<groupid>org.springframework.boot</groupid>
<artifactid>spring-boot-starter-data-redis</artifactid>
</dependency>application.yml:
spring:
redis:
host: localhost
port: 6379六、一图总结
加锁:set lockkey uniquevalue nx ex 30
└─ uniquevalue = uuid + 线程id,标识"这是谁的锁"
解锁:lua 脚本
├─ 判断:get lockkey == 我的 uniquevalue ?
│ ├─ 是 → del lockkey(只删自己的)✅
│ └─ 否 → 什么都不做(别人的锁不能碰)✅
└─ 整个过程原子执行,中间不会被插入七、温馨提示(面试加分点)
这套手写方案虽然解决了误删,但仍有 2 个没解决的问题,正是 redisson 存在的意义:
- 业务超时问题:a 业务执行超过 30 秒,锁还是会过期 → 需要看门狗自动续期
- 可重入问题:同一线程再次加锁会失败 → 需要 hash 结构记录重入次数
所以面试时可以这样收尾:
"手写版用 uniquevalue + lua 解决了误删,但业务超时和可重入还得自己处理,生产上直接上 redisson 更省心。"
这样回答,既有原理深度,又有工程视野。
到此这篇关于使用redis来实现锁的方式的文章就介绍到这了,更多相关redis实现锁的方式内容请搜索代码网以前的文章或继续浏览下面的相关文章希望大家以后多多支持代码网!
发表评论