Redis+Lua实现分布式锁
Lua 脚本功能是 Reids 2.6 版本的最大亮点, 通过内嵌对 Lua 环境的支持, Redis 解决了长久以来不能高效地处理 CAS (check-and-set)命令的缺点, 并且可以通过组合使用多个命令, 轻松实现以前很难实现或者不能高效实现的模式。
交互过程:Redis 客户端发送一段Lua脚本到redis服务端,该Lua脚本会作为一个可执行的函数体,内部不能再自定义新的函数。redis服务端接收到Lua脚本命令后,进行解析执行,将结果返回给客户端。
2.Lua语言相关知识
2.1 什么是Lua?
Lua 是一种轻量小巧的脚本语言,用标准C语言编写并以源代码形式开放, 其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能
2.2 Lua特性
a) 轻量级: 采用标准C语言编写并以源代码形式开放,编译后仅仅一百余K,并且很方便嵌入到其他程序中。
b) 可扩展: 提供了非常易于使用的扩展接口和机制:由宿主语言(通常是C或C++)提供这些功能
2.3 使用场景
a) 游戏开发
b) 独立应用脚本
c) Web 应用脚本
d) 扩展和数据库插件如:MySQL Proxy 和 MySQL WorkBench
e) 安全系统,如入侵检测系统
3. Redis执行Lua脚本命令模式
EVAL和EVALSHA用于使用从2.6.0版开始内置于Redis中的Lua解释器来执行脚本。
3.1 EVAL模式
命令:EVAL luascript numkeys key [key …] arg [arg …]
script 参数是一段 Lua 脚本程序,numkeys 参数用于指定redis健名参数的个数, redis健名参数 key [key …] 从 EVAL 的第三个参数开始算起,表示在脚本中所用到的那些 Redis 键(key),这些键名参数可以在 Lua 中通过全局变量 KEYS 数组,用 1 为基址的形式访问( KEYS[1] , KEYS[2] ,以此类推)。附加参数 arg [arg …] ,可以在 Lua 中通过全局变量 ARGV 数组访问,访问的形式 ARGV[1] 、 ARGV[2] 等。
3.1.2 EVAL 使用列子
(1) 在本地按默认方式启动redis服务(默认端口6379):
>redis-server
(2) 在本地按默认方式启动redis客户端(默认端口6379):
>redis-cli
127.0.0.1:6379> eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 key1 key2 first second
1) "key1"
2) "key2"
3) "first"
4) "second"
3.1.3 EVAL模式的缺点
EVAL模式需要每次发送Lua脚本到redis服务端,因而会产生额外的网络带宽开销。
其实redis内部提供了缓存机制,完全可以利用这一点进行性能改善,下面介绍的EVALSHA模式就是利用了这一点。
3.2 EVALSHA模式
命令:EVALSHA sha1 numkeys key [key …] arg [arg …]
EVALSHA根据给定的 sha1 校验码,对缓存在服务器中的脚本进行求值。参数传递方式与EVAL相同。
3.2.1 如何知道Lua脚本的sha1校验码
命令:SCRIPT LOAD script
script load命令会在redis服务器缓存你的Lua脚本,并且返回脚本内容的SHA1校验和,然后通过evalsha 传递SHA1校验和来找到服务器缓存的脚本进行调用
redis> SCRIPT LOAD "return 'hello moto'"
"232fd51614574cf0867b83d384a5e898cfd24e5a"
redis> EVALSHA "232fd51614574cf0867b83d384a5e898cfd24e5a" 0
"hello moto"
3.2.2 EVALSHA 模式的优点
前面提到eval模式每次都要把执行的脚本发送过去,这样势必会有一定的网络开销,evalsha最大的好处是降低了网络开销。
4.Redis中执行Lua脚本的原子性
Redis 使用单个 Lua 解释器去运行所有脚本,并且, Redis 也保证脚本会以原子性(atomic)的方式执行:当某个脚本正在运行的时候,不会有其他脚本或 Redis 命令被执行。这和使用 MULTI / EXEC 包围的事务很类似。
需要注意的是Lua脚本执行具有原子性的,因此脚本中不宜使用大量的命令或者影响执行效率的脚本,尤其是在高并发的情况下,执行开销较大的Lua脚本更不可取,会导致整个redis服务器阻塞。
5. Lua脚本中如何使用Redis命令
在Lua脚本中采用如下两个不同的Lua函数进行Redis命令调用
redis.call()
redis.pcall()
redis.call()类似于redis.pcall(),唯一的区别是如果Redis命令调用将导致错误,redis.call()将引发Lua错误,反过来会强制EVAL返回错误到命令调用者,而redis.pcall将捕获错误并返回一个Lua错误码。
一个Lua调用Redis例子:
> eval "return redis.call('set','foo','bar')" 0
OK
6.使用Lua脚本实现分布锁
分布式锁的两种场景(参考:https://juejin.im/post/58b3a93c1b69e60058b49767):
6.1 lock 脚本:
在项目的classpath下创建lock.lua 脚本
//调用系统时间作为锁标识:相当于线程获取了锁,并给锁加一个标志,这个标志是独有的,
不被其他线程所知或者所得;其中KEYS[1] 是传入redis的key参数,不同的key值对应不同的锁
local ostime=redis.call('TIME') ;
local time=string.format("%d",ostime[1]*1000000+ostime[2]) ;
//CAS模式设值,保证锁只能被一个线程设置成功
local flag = redis.call("setnx",KEYS[1],time);
if(flag > 0) then //锁标识设置成功
// 对获取的锁设置过期时间(根据业务场景增加过期设置)
redis.call("expire",KEYS[1],ARGV[1]);
return redis.call("get",KEYS[1]);
else
return "0"; // 锁标识设置失败
end
6.2 unLock 脚本:
在项目的classpath下创建unLock.lua 脚本
local lockKey = redis.call("get",KEYS[1]);
local lk = ARGV[1];
//增加锁标识判断,防止错误删除:防止没有获取到锁的线程进行锁的释放
if(lockKey == lk) then
redis.call("del",KEYS[1]);
return true;
else
return false;
end
6.1、6.2 非必要步骤: 可以在代码中直接硬编码,然后进行脚本加载,这里仅仅是方便维护查看。
6.3 java语言简单使用如下:
public class RedisLuaLock {
public static JedisPool jedisPool;
public static String lockScriptSha;
public static String unLockScriptSha;
//初始化redispool
static {
JedisPoolConfig config = new JedisPoolConfig();
config.setMaxTotal(20);
config.setMaxIdle(10);
config.setMinIdle(5);
jedisPool = new JedisPool(config, "127.0.0.1", 6379);
}
public static void main(String[] args) {
TestMain tm = new TestMain();
// 使用redisClient 提供的scriptLoad 方法进行lua脚本加载,返回脚本的sha校验码
// 也可在代码中直接硬编码,然后进行脚本加载
lockScriptSha = jedisPool.getResource().scriptLoad(tm.getLuaScript("lock.lua"));
unLockScriptSha = jedisPool.getResource().scriptLoad(tm.getLuaScript("unlock.lua"));
String lockKey = "lockKey1";
for (int i = 0; i < 80; i++) {
new Thread(() -> {
Long lock = tm.getLocksha(lockKey, 30);
System.out.println(Thread.currentThread().getName() + "=====lock:" + lock);
if (lock <= 0) {
return;
}
System.out.println(Thread.currentThread().getName() + "=====unlock:"
+ tm.unlocksha(lockKey, lock));
}).start();
}
}
//释放锁方式1:通过eval模式:参数lock --线程的锁标识
public boolean unlock(String lockKey, Long lock) {
Long flag = (Long) jedisPool.getResource().eval(new RedisLuaLock().getLuaScript("unlock.lua"),
Arrays.asList(lockKey), Arrays.asList(lock.toString()));
if (flag > 0) {
return true;
}
return false;
}
//释放锁方式2:通过evalsha模式:参数lock --线程的锁标识
public boolean unlocksha(String lockKey, Long lock) {
Long flag = (Long) jedisPool.getResource().evalsha(unLockScriptSha,
Arrays.asList(lockKey), Arrays.asList(lock.toString()));
if (flag > 0) {
return true;
}
return false;
}
//获取锁方式1:通过eval模式:参数lock --线程的锁标识
public Long getLock(String lockKey, Integer expireTime) {
return Long.valueOf((String) jedisPool.getResource().eval(new RedisLuaLock().getLuaScript("lock.lua"),
Arrays.asList(lockKey), Arrays.asList(expireTime.toString())));
}
//获取锁方式2:通过evalsha模式:参数lock --线程的锁标识
public Long getLocksha(String lockKey, Integer expireTime) {
return Long.valueOf((String) jedisPool.getResource().evalsha(lockScriptSha,
Arrays.asList(lockKey), Arrays.asList(expireTime.toString())));
}
//工具方法:获取lua配置脚本
private String getLuaScript(String pathName) {
ClassLoader loader = getClass().getClassLoader();
InputStream inputStream = loader.getResourceAsStream(pathName);
StringBuilder lua = new StringBuilder();
BufferedReader reader = null;
try {
reader = new BufferedReader(new InputStreamReader(inputStream));
String cmdline = null;
while ((cmdline = reader.readLine()) != null) {
lua.append(cmdline);
}
reader.close();
} catch (IOException e) {
e.printStackTrace();
} finally {
if (reader != null) {
try {
reader.close();
} catch (IOException e1) {
}
}
}
return lua.toString();
}
}
通过模拟测试:eval模式下的锁获取性能比evalsha差很多,读者可以自行验证。
最佳实践建议:上述获取锁的过程可以增加超时获取,线程一直阻塞获取锁;释放锁也尽量保证释放成功;另外锁的过期时间设置需要结合业务进行设置,过期时间需要大于业务正常执行完成的时间,否则业务还未完成就释放锁,进而造成业务并发问题。
以上如有问题,请批评指正。