redis 实现分布式锁
本文共8462字,阅读本文大概需要17~28分钟
- 先搭建环境,一步一步慢慢完善
引入依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
文件配置
server.port=8080
spring.redis.host=192.168.1.31
spring.redis.port=6379
业务代码
@RestController
public class IndexController {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@RequestMapping("/deduct_stock")
public String deductStock() {
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
if(stock > 0) {
int realStock = stock - 1;
stringRedisTemplate.opsForValue().set("stock",realStock + "");
System.out.println("扣减成功,剩余库存:" + realStock);
}else {
System.out.println("扣减失败,库存不足");
}
return "end";
}
}
启动redis,在redis中设置库存 set stock 300
,若浏览器输出如下信息,说明环境搭建完毕
第一个版本:给代码加synchronized锁
如果多个线程同时对库存进行减扣,会造成线程不安全的问题,也就是我们所说的超卖问题,首先我们会想到加锁,即给代码加synchronized 锁,代码如下
@RequestMapping("/deduct_stock")
public String deductStock() {
synchronized (this) {
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
if(stock > 0) {
int realStock = stock - 1;
stringRedisTemplate.opsForValue().set("stock",realStock + "");
System.out.println("扣减成功,剩余库存:" + realStock);
}else {
System.out.println("扣减失败,库存不足");
}
}
return "end";
}
在单机环境下,这样的确会解决问题,但是多机【分布式】环境下呢?
模拟高并发分布式场景如下
- 以不同的端口号【8080,8090】启动相同的项目两次,如下
- 使用nginx实现负载均衡
- 使用 jmeter 工具模拟高并发
- 结果如下
如图可见,出现了重复的扣减,由此可见 synchronized 不能解决分布式扣减的问题
第二个版本
利用 setnx 特性
格式: setnx key value
将 key 设置为 value,当且仅当 key 不存在;若给定的 key 已经存在,则 setnx 不做任何动作。 SETNX 是【SET if Not eXists】(如果不存在,则SET)的简写
@RequestMapping("/deduct_stock")
public String deductStock() {
String lockKey = "lock:product:1001";
Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "zhuge");
if(!result) {
return "err_code";
}
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
if(stock > 0) {
int realStock = stock - 1;
stringRedisTemplate.opsForValue().set("stock",realStock + "");
System.out.println("扣减成功,剩余库存:" + realStock);
}else {
System.out.println("扣减失败,库存不足");
}
stringRedisTemplate.delete(lockKey);
return "end";
}
最简单的分布式锁的实现,这种方式同样会产生问题,代码在任意一行都可以出现异常,导致无法执行stringRedisTemplate.delete(lockKey);
的代码,最终导致死锁
第三个版本
@RequestMapping("/deduct_stock")
public String deductStock() {
String lockKey = "lock:product:1001";
Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "zhuge");
if(!result) {
return "err_code";
}
try {
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
if(stock > 0) {
int realStock = stock - 1;
stringRedisTemplate.opsForValue().set("stock",realStock + "");
System.out.println("扣减成功,剩余库存:" + realStock);
}else {
System.out.println("扣减失败,库存不足");
}
}finally {
stringRedisTemplate.delete(lockKey);
}
return "end";
}
这样改进的确可以解决版本2无法释放锁导致死锁的问题,这样改进的好处是无论是否出现异常,最终都会释放锁。但是如果代码执行了一般,突然系统宕机了怎么办?
第四个版本:引入超时时间
@RequestMapping("/deduct_stock")
public String deductStock() {
String lockKey = "lock:product:1001";
//这样改进会有原子性问题,在没有执行expire之前便出异常了就无法执行expire的命令了
/*
Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "zhuge");
stringRedisTemplate.expire(lockKey,30, TimeUnit.SECONDS);
*/
Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "zhuge",30, TimeUnit.SECONDS);
if(!result) {
return "err_code";
}
try {
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
if(stock > 0) {
int realStock = stock - 1;
stringRedisTemplate.opsForValue().set("stock",realStock + "");
System.out.println("扣减成功,剩余库存:" + realStock);
}else {
System.out.println("扣减失败,库存不足");
}
}finally {
stringRedisTemplate.delete(lockKey);
}
return "end";
}
这样改进任然有问题,如果设置过期时间10秒,但一个业务执行了15秒了,这个key就过期掉了,这个时候允许第二个线程加锁成功,执行了5秒然后是否掉了,线程1把线程2是否掉了!!!
第5个版本
@RequestMapping("/deduct_stock")
public String deductStock() {
String lockKey = "lock:product:1001";
//这样改进会有原子性问题,在没有执行expire之前便出异常了就无法执行expire的命令了
/*
Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "zhuge");
stringRedisTemplate.expire(lockKey,30, TimeUnit.SECONDS);
*/
String clinetId = UUID.randomUUID().toString();
Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, clinetId,30, TimeUnit.SECONDS);
if(!result) {
return "err_code";
}
try {
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
if(stock > 0) {
int realStock = stock - 1;
stringRedisTemplate.opsForValue().set("stock",realStock + "");
System.out.println("扣减成功,剩余库存:" + realStock);
}else {
System.out.println("扣减失败,库存不足");
}
}finally {
if(clinetId.equals(stringRedisTemplate.opsForValue().get(lockKey))) {
stringRedisTemplate.delete(lockKey);
}
}
return "end";
}
在一般的小公司这样写分布式锁已经OK了,但是还是有点小瑕疵,在释放锁的时候依然有原子性的问题。比如说,代码进了 clinetId.equals
的判断,在执行 delete 之前卡顿了一下,其他的线程又允许加锁了,这个时候突然卡顿又好了,导致线程1又释放掉了其他的线程,同样可能造成超卖问题
问题的改进
- 版本5的问题
// 释放锁
if(clientId.equals(stringRedisTemplate.opsForValue().get(lockKey))){
stringRedisTemplate.delete(lockKey);
}
上面这段释放锁的代码(并不具备原子性)仍然还是锁的过期时间问题。如这种情况:线程从redis中根据lockkey取出来了clientId.但是此时由于线程切换,该线程挂起来了,在这段时间中,恰好锁的过期时间到了,那么系统中的其他线程就能获取到锁。而该线程被唤醒时,删除的锁就是其他线程加的锁了。
- 改进【使用Lua脚本可以保证原子性,这也是Redisson实现的主要逻辑】
"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
"if (counter > 0) then " +
"redis.call('pexpire', KEYS[1], ARGV[2]); " +
"return 0; " +
"else " +
"redis.call('del', KEYS[1]); " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; "+
"end; " +
"return nil;"
- 关于锁过期了,代码还没执行完,怎么办?
- 解决方案【锁续命[Watch Dog]】
开一个分线程来监控主线程是否执行完业务代码,如果在锁超时时间内还没有执行完成,分线程将增加锁的过期时间
最终版本:使用开源框架 redisson
@RequestMapping("/deduct_stock")
public String deductStock() {
String lockKey = "lock:product:1001";
RLock lock = redisson.getLock(lockKey);
lock.lock();
try {
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
if(stock > 0) {
int realStock = stock - 1;
stringRedisTemplate.opsForValue().set("stock",realStock + "");
System.out.println("扣减成功,剩余库存:" + realStock);
}else {
System.out.println("扣减失败,库存不足");
}
}finally {
lock.unlock();
}
return "end";
}
redisson
底层采用的是 Lua 脚本
Redisson核心架构思想
redisson.lock()加锁原理
exists key 判断key是否存在,如果不存在返回0,存在返回1
- 首先判断要加锁的key是否存在,如果不存在才会加锁成功,会执行红框中的代码。给key设置值并指定 internalLockLeaseTime 秒后过期【internalLockLeaseTime获取的lockWatchdogTimeout看门狗超时时间,默认30 * 1000 ms 也就是 30s】
- lua脚本执行完之后,会异步回调下面的方法。加锁成功会返回nil也就是null,最后会进入scheduleExpirationRenewal()方法
- scheduleExpirationRenewal内部是一个RFuture,这块代码会延时执行,在主线程执行完
internalLockLeaseTime / 3
也就是10秒后执行。
private void scheduleExpirationRenewal(final long threadId) {
if (expirationRenewalMap.containsKey(getEntryName())) {
return;
}
Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
@Override
public void run(Timeout timeout) throws Exception {
RFuture<Boolean> future = commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return 1; " +
"end; " +
"return 0;",
Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
future.addListener(new FutureListener<Boolean>() {
@Override
public void operationComplete(Future<Boolean> future) throws Exception {
expirationRenewalMap.remove(getEntryName());
if (!future.isSuccess()) {
log.error("Can't update lock " + getName() + " expiration", future.cause());
return;
}
if (future.getNow()) {
// reschedule itself
scheduleExpirationRenewal(threadId);
}
}
});
}
}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
if (expirationRenewalMap.putIfAbsent(getEntryName(), task) != null) {
task.cancel();
}
}
- 【续命逻辑】首先判断加锁的key是否存在,如果存在再重新设置过期时间30秒完成续命
- 续命成功之后会执行如下方法进行回调
加锁失败逻辑
- 加锁失败,会返回第一个线程锁的超时时间,接着执行下面的逻辑
- 接着会再次尝试加锁,加锁的逻辑是对 ttl【可以理解为 获取到锁的线程的超时时间】进行刷新
if (ttl >= 0) {
//这行代码可以理解为这行代码阻塞ttl秒,ttl秒后再次执行while循环尝试加锁。
//阻塞等待不会占用CPU反而会会释放CPU,不会耗费性能
getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} else {
getEntry(threadId).getLatch().acquire();
}
- 边边角角的代码a
//加锁失败后会订阅一个queue,释放锁间解锁的逻辑
RFuture<RedissonLockEntry> future = subscribe(threadId);
commandExecutor.syncSubscription(future);
- 边边角角的代码b
凡是调用了发布的方法之后,会调用 onMessage 方法回调
//这行代码的逻辑是唤醒阻塞,与前面代码【getEntry(threadId).getLatch().tryAcquire】形成了闭环
value.getLatch().release();
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· .NET10 - 预览版1新功能体验(一)