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();

解锁的逻辑

posted @   小羊abc  阅读(327)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· .NET10 - 预览版1新功能体验(一)
点击右上角即可分享
微信分享提示