谈谈基于Redis分布式锁(上)- 手写方案

分布式锁的使用场景

单体架构的应用可以直接使用本地锁(Synchronized)就可以解决多线程资源竞争的问题。如果公司业务发展较快,可以通过部署多个服务节点来提高系统的并行处理能力。由于本地锁的作用范围只限于当前应用的线程。高并发场景下,集群中某个应用的本地锁并不会对其它应用的资源访问产生互斥,就会产生数据不一致的问题,所以分布锁就派上了用场

常见的分布式锁应用场景:秒杀活动、优惠券抢购、接口幂等性校验等

分布式锁的三种实现方案

1、数据库乐观锁
2、基于Redis的分布式锁
3、基于Zookeeper的分布式锁

本篇博客主要介绍基于Redis实现的分布式锁

分布式锁需要满足的条件

1、互斥性
任何时候锁资源只能被一个线程持有
2、死锁
获取锁的客户端由于某些原因未能释放锁,也要保证其他客户端可以获取到锁
3、可重入
同一线程已经获得某个锁,可以再次获取锁而不会出现死锁
4、安全性
锁只能被持有该锁的客户端删除,不能由其它客户端删除

基于Redis手写的分布式锁

在高并发场景下,应用程序在执行过程中往往会受到网络、CPU、内存等因素的影响,所以实现一个线程安全的分布式组件,往往需要考虑很多case,下面我们通过手写的方式来探索一个完美的分布锁方案的复杂性

使用setnx()和UUID

/**
 * 获取锁
 */
public static boolean getLock(Jedis jedis, String key, int time) {
	Long flag = jedis.setnx(key, "object");
	if (flag == 1) {
		//如果程序突然宕机,则无法设置过期时间,将发生死锁
		jedis.expire(key, time);
		return true;
	}
	return false;
}

/**
 * 释放锁
 */
public static void releaseLock(Jedis jedis) {
	jedis.del("object");
}

上面的代码存在以下2个问题:
1、由于这是两条Redis命令,不具有原子性,如果程序在执行完setnx()之后突然崩溃,导致锁没有设置过期时间,那么锁资源将永远不会被释放
2、jedis.del(“object”)会导致操作超时的线程继续运行时解锁其它正在执行的线程锁资源,可以通过给value赋值为requestId,我们就知道这把锁是哪个请求加的,在解锁的时候就可以区分了

requestId可以使用UUID.randomUUID().toString()方法生成,修改代码如下:

/**
 * @param reqId 可用使用UUID生成
 */
public static boolean getLock(Jedis jedis, String reqId, String key, int time) {
	String result = jedis.set(key, reqId, "NX", "EX", time);
	// set操作成功时返回 OK
	if ("OK".equals(result)) {
		return true;
	}
	return false;
}
/**
 * 释放锁
 */
public static void releaseLock(Jedis jedis, String key, String reqId) {
	if(reqId.equals(jedis.get(key))) {
		// 若在此时,这把锁突然不是当前线程的,则会误解锁
		jedis.del(reqId);
	}
}

public static void main(String[] args) {
	Jedis jedis = new Jedis();
	String reqId = UUID.randomUUID().toString();
	try {
		if (getLock(jedis, reqId, "key", 10)) {
			// 数据库操作
		}
	} finally {
		releaseLock(jedis, "key", reqId);
	}
}

解锁好像还是有问题!
如果调用jedis.del()方法的时候,这把锁已经不属于当前线程的时候会解除其它线程加的锁。

那么是否真的有这种场景?

答案是肯定的,比如线程A在执行jedis.del()之前,锁突然过期了,此时线程B尝试加锁成功,然后线程A再执行del()方法,则将线程B的锁给删除了! 原因是删除操作分两条命令去执行的,考虑使用lua脚本原子操作来解决。关于lua脚本的相关知识可以查看其它博文

修改如下:

public static boolean releaseLock(Jedis jedis, String reqId, String key) {
	String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
	Object result = jedis.eval(script, Collections.singletonList(key), Collections.singletonList(reqId));
	
	if ("1".equals(result)) {
		return true;
	}
	return false;
}

使用setnx()和过期时间

执行过程如下:
通过setnx()方法尝试加锁,如果当前锁不存在,返回加锁成功。如果锁已经存在(其它线程获取成功)则获取锁的过期时间和当前时间比较。如果锁已过期,则设置新的过期时间,返回加锁成功。

public static boolean getLock(Jedis jedis, String key, int time) {
	// 过期时间
	long expires = System.currentTimeMillis() + time;
	String expiresValue = String.valueOf(expires);

	String result = jedis.set(key, expiresValue, "NX", "EX", time);
	// 加锁成功时返回 OK
	if ("OK".equals(result)) {
		return true;
	}

	// 锁存在,获取锁的过期时间
	String currentValue = jedis.get(key);
	if (currentValue != null && Long.parseLong(currentValue) < System.currentTimeMillis()) {
		// 锁已过期,获取上一个锁的过期时间,并设置当前锁的过期时间
		String oldValue = jedis.getSet(key, expiresValue);
		if (oldValue != null && oldValue.equals(currentValue)) {
			// 多线程并发的情况,只有一个线程的设置值和当前值相同时,加锁成功
			return true;
		}
	}
	
	return false;
}

以上方案有如下问题:

1、System.currentTimeMillis()是不同的客户端服务器的系统时间,分布式环境下每个客户端的时间必须同步。
2、锁过期时,如果多个线程同时执行jedis.getSet()方法,虽然只有一个线程可以加锁成功,但这个线程的锁过期时间可能被其他线程所覆盖
3、因为客户端系统时间不同步,根据系统时间删除锁时会误删其它系统的锁

手写分布式锁方案总结

手写方案看似好像很完美,遗憾的是,还是有问题!!!
1、不满足可重入性
2、A线程获锁成功,只是由于一些客观因素(如:数据库I/O偶尔过慢)操作延时,导致key失效,但是业务代码还会是继续往下执行。因为key失效的原因,其它线程可以继续获取到锁,从某种意义上讲还是没有达到从根本上解决资源互斥的效果!

有什么解决方案吗?有,超时时间续期!可以通过定时任务延长失效时间(参考Redisson的Watch Dog),但是实现起来有点麻烦。另外,可重入性问题也可以得到解决,比如:先将获锁成功的线程信息暂存起来,再次请求锁资源时判断一下即可。
(完)

下篇文章我们将介绍Redisson的分布式锁实现原理。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值