1、数据库实现(效率低,不推荐)
2、redis实现(使用redission实现,但是需要考虑思索,释放问题。繁琐一些)
3、Zookeeper实现 (使用临时节点,效率高,失效时间可以控制)
4、Spring Cloud 实现全局锁(内置的)
数据库的分布式锁
悲观锁
- STEP1 - 获取锁:SELECT * FROM database_lock WHERE id = 1 FOR UPDATE;。
- STEP2 - 执行业务逻辑。
- STEP3 - 释放锁:COMMIT。
乐观锁
加版本号,每次读出版本号,进行操作时对比版本号
Zookeeper实现分布式锁原理
使用zookeeper创建临时序列节点来实现分布式锁,适用于顺序执行的程序,大体思路就是创建临时序列节点,找出最小的序列节点,获取分布式锁,程序执行完成之后此序列节点消失,通过watch来监控节点的变化,从剩下的节点的找到最小的序列节点,获取分布式锁,执行相应处理,依次类推……
redis分布式锁
直接用SETNX和DEL实现加解锁。SETNX 是『SET if Not eXists』,如果不存在,才会设置
不过直接使用 SETNX 有一个缺陷,我们没办法对其设置过期时间,如果加锁客户端宕机了,这就导致这把锁获取不了。
不过这个问题在 Redis 2.6.12 版本 就可以被完美解决。这个版本增强了 SET 命令,可以通过带上 NX,EX 命令原子执行加锁操作,解决上述问题。参数含义如下:
-
EX second :设置键的过期时间,单位为秒
-
NX 当键不存在时,进行设置操作,等同与 SETNX 操作
使用 SET 命令实现分布式锁只需要一行代码:
SET lock_name anystring NX EX lock_time
不过这种方式却存在一个缺陷,可能会发生错解锁问题。
假设应用 1 加锁成功,锁超时时间为 30s。由于应用 1 业务逻辑执行时间过长,30 s 之后,锁过期自动释放。
这时应用 2 接着加锁,加锁成功,执行业务逻辑。这个期间,应用 1 终于执行结束,使用 DEL
成功释放锁。
这样就导致了应用 1 错误释放应用 2 的锁,另外锁被释放之后,其他应用可能再次加锁成功,这就可能导致业务重复执行。
这时候就可以考虑乐观锁的版本号方法
为了使锁不被错误释放,我们需要在加锁时设置随机字符串,比如 UUID。
SET lock_name uuid NX EX lock_time
释放锁时,需要提前获取当前锁存储的值,然后与加锁时的 uuid 做比较,伪代码如下:
var value= get lock_name
if value == uuid
// 释放锁成功
else
// 释放锁失败
但是以上代码我们不能通过 Java 代码运行,因为无法保证上述代码原子化执行。要用Lua脚本
lua 代码可以运行在 Redis 服务器的上下文中,并且整个操作将会被当成一个整体执行,中间不会被其他命令插入。
Redis 可以使用 EVAL 执行 LUA 脚本,而我们可以在 LUA 脚本中执行判断求值逻辑。EVAL 执行方式如下:
在 Lua 脚本可以使用下面两个函数执行 Redis 命令:
-
redis.call()
-
redis.pcall()
两个函数作用法与作用完全一致,只不过对于错误的处理方式不一致,感兴趣的小伙伴可以具体点击以下链接,查看错误处理一章。http://doc.redisfans.com/script/eval.html
EVAL
命令每次执行时都需要发送 Lua 脚本,但是 Redis 并不会每次都会重新编译脚本。
当 Redis 第一次收到 Lua 脚本时,首先将会对 Lua 脚本进行 sha1 获取签名值,然后内部将会对其缓存起来。后续执行时,直接通过 sha1 计算过后签名值查找已经编译过的脚本,加快执行速度。
虽然 Redis 内部已经优化执行的速度,但是每次都需要发送脚本,还是有网络传输的成本,如果脚本很大,这其中花在网络传输的时间就会相应的增加。
所以 Redis 又实现了 EVALSHA
命令,原理与 EVAL
一致。只不过 EVALSHA
只需要传入脚本经过 sha1计算过后的签名值即可,这样大大的减少了传输的字节大小,减少了网络耗时。
EVALSHA
命令如下:
evalsha c686f316aaf1eb01d5a4de1b0b63cd233010e63d 1 foo 楼下小黑哥
可以看到,如果之前未执行过 EVAL
命令,直接执行 EVALSHA
将会报错。
//连接本地的 Redis 服务
Jedis jedis = new Jedis("localhost", 6379);
jedis.auth("1234qwer");
System.out.println("服务正在运行: " + jedis.ping());
String lua_script = "return redis.call('set',KEYS[1],ARGV[1])";
String lua_sha1 = DigestUtils.sha1DigestAsHex(lua_script);
try {
Object evalsha = jedis.evalsha(lua_sha1, Lists.newArrayList("foo"), Lists.newArrayList("楼下小黑哥"));
} catch (Exception e) {
Throwable current = e;
while (current != null) {
String exMessage = current.getMessage();
// 包含 NOSCRIPT,代表该 lua 脚本从未被执行,需要先执行 eval 命令
if (exMessage != null && exMessage.contains("NOSCRIPT")) {
Object eval = jedis.eval(lua_script, Lists.newArrayList("foo"), Lists.newArrayList("楼下小黑哥"));
break;
}
}
}
String foo = jedis.get("foo");
System.out.println(foo);
上面的代码看起来还是很复杂吧,不过这是使用原生 jedis 的情况下。如果我们使用 Spring Boot 的话,那就没这么麻烦了。Spring 组件执行的 Eval
方法内部就包含上述代码的逻辑。
不过需要注意的是,如果 Spring-Boot 使用 Jedis 作为连接客户端,并且使用Redis Cluster 集群模式,需要使用 2.1.9 以上版本的spring-boot-starter-data-redis,不然执行过程中将会抛出。
优化分布式锁
/**
* 非阻塞式加锁,若锁存在,直接返回
*
* @param lockName 锁名称
* @param request 唯一标识,防止其他应用/线程解锁,可以使用 UUID 生成
* @param leaseTime 超时时间
* @param unit 时间单位
* @return
*/
public Boolean tryLock(String lockName, String request, long leaseTime, TimeUnit unit) {
// 注意该方法是在 spring-boot-starter-data-redis 2.1 版本新增加的,若是之前版本 可以执行下面的方法
return stringRedisTemplate.opsForValue().setIfAbsent(lockName, request, leaseTime, unit);
}
由于setIfAbsent
方法是在 spring-boot-starter-data-redis 2.1 版本新增加,之前版本无法设置超时时间。
解锁需要使用 Lua 脚本:
-- 解锁代码
-- 首先判断传入的唯一标识是否与现有标识一致
-- 如果一致,释放这个锁,否则直接返回
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
这段脚本将会判断传入的唯一标识是否与 Redis 存储的标示一致,如果一直,释放该锁,否则立刻返回。
释放锁的方法如下:
/**
* 解锁
* 如果传入应用标识与之前加锁一致,解锁成功
* 否则直接返回
* @param lockName 锁
* @param request 唯一标识
* @return
*/
public Boolean unlock(String lockName, String request) {
DefaultRedisScript<Boolean> unlockScript = new DefaultRedisScript<>();
unlockScript.setLocation(new ClassPathResource("simple_unlock.lua"));
unlockScript.setResultType(Boolean.class);
return stringRedisTemplate.execute(unlockScript, Lists.newArrayList(lockName), request);
}
Redis 分布式锁的缺陷
无法重入
由于上述加锁命令使用了 SETNX
,一旦键存在就无法再设置成功,这就导致后续同一线程内继续加锁,将会加锁失败。
如果想将 Redis 分布式锁改造成可重入的分布式锁,有两种方案:
-
本地应用使用 ThreadLocal 进行重入次数计数,加锁时加 1,解锁时减 1,当计数变为 0 释放锁
-
第二种,使用 Redis Hash 表存储可重入次数,使用 Lua 脚本加锁/解锁
Redis 分布式锁集群问题
redisson 已经实现的 RedLock
简单的 Redis 分布式锁的实现方式还是很简单的,我们可以直接用 SETNX/DEL 命令实现加解锁。
不过这种实现方式不够健壮,可能存在应用宕机,锁就无法被释放的问题。
所以我们接着引入以下命令以及 Lua 脚本增强 Redis 分布式锁。
摘抄自公众号程序通事,大家可以去关注学习下。