Redis Lua 脚本运行原理
用户可以向服务器发送 lua 脚本来执行自定义动作,获取脚本的响应数据。Redis 服务器会单线程原子性执行 lua 脚本,保证 lua 脚本在处理的过程中不会被任意其它请求打断。

将匹配 key 和删除 key 合并在一起原子性执行,Redis 原生没有提供这样功能的指令,它可以使用 lua 脚本来完成。
if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end
执行脚本:
127.0.0.1:6379> set foo bar OK 127.0.0.1:6379> eval 'if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end' 1 foo bar (integer) 1 127.0.0.1:6379> eval 'if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end' 1 foo bar (integer) 0
在 lua 脚本中,数组下标是从 1 开始,所以通过 KEYS[1] 就可以得到 第一个 key,通过 ARGV[1] 就可以得到第一个附加参数。redis.call 函数可以让我们调用 Redis 的原生指令,上面的代码分别调用了 get 指令和 del 指令。return 返回的结果将会返回给客户端。
EVAL SCRIPT KEY_NUM KEY1 KEY2 ... KEYN ARG1 ARG2 ....
SCRIPT LOAD 和 EVALSHA 指令

local curVal = redis.call("get", KEYS[1]) if curVal == false then curVal = 0 else curVal = tonumber(curVal) end curVal = curVal * tonumber(ARGV[1]) redis.call("set", KEYS[1], curVal) return curVal
脚本单行化,语句之间使用分号隔开
local curVal = redis.call("get", KEYS[1]); if curVal == false then curVal = 0 else curVal = tonumber(curVal) end; curVal = curVal * tonumber(ARGV[1]); redis.call("set", KEYS[1], curVal); return curVal
加载脚本
127.0.0.1:6379> script load 'local curVal = redis.call("get", KEYS[1]); if curVal == false then curVal = 0 else curVal = tonumber(curVal) end; curVal = curVal * tonumber(ARGV[1]); redis.call("set", KEYS[1], curVal); return curVal' "be4f93d8a5379e5e5b768a74e77c8a4eb0434441"
使用这个唯一标识来执行指令:

错误处理
脚本参数要求传入的附加参数必须是整数,如果没有传递整数会怎样呢?

客户端输出了服务器返回的通用错误消息,注意这是一个动态抛出的异常,Redis 会保护主线程不会因为脚本的错误而导致服务器崩溃,近似于在脚本的外围有一个很大的 try catch 语句包裹。在 lua 脚本执行的过程中遇到了错误,同 redis 的事务一样,那些通过 redis.call 函数已经执行过的指令对服务器状态产生影响是无法撤销的,在编写 lua 代码时一定要小心,避免没有考虑到的判断条件导致脚本没有完全执行。

lua 原生没有提供 try catch 语句,异常包裹语句究竟是用什么来实现的呢?lua 的替代方案是内置 pcall(f) 函数调用。pcall 的意思是 protected call,会让 f 函数运行在保护模式下,f 如果出现了错误,pcall 调用会返回 false 和错误信息。而普通的 call(f) 调用在遇到错误时只会向上抛出异常。在 Redis 的源码中可以看到 lua 脚本的执行被包裹在 pcall 函数调用中。
Redis 在 lua 脚本中除了提供了 redis.call 函数外,同样也提供了 redis.pcall 函数。前者遇到错误向上抛出异常,后者会返回错误信息。使用时一定要注意 call 函数出错时会中断脚本的执行,为了保证脚本的原子性,要谨慎使用。
错误传递
redis.call 函数调用会产生错误
127.0.0.1:6379> hset foo x 1 y 2 (integer) 2 127.0.0.1:6379> eval 'return redis.call("incr", "foo")' 0 (error) ERR Error running script (call to f_8727c9c34a61783916ca488b366c475cb3a446cc): @user_script:1: WRONGTYPE Operation against a key holding the wrong kind of value
客户端输出的是一个通用的错误消息,而不是 incr 调用本应该返回的 WRONGTYPE 类型的错误消息。Redis 内部在处理 redis.call 遇到错误时是向上抛出异常,外围的用户看不见的 pcall调用捕获到脚本异常时会向客户端回复通用的错误信息。如果将上面的 call 改成 pcall,结果就会不一样,可以将内部指令返回的特定错误向上传递。
127.0.0.1:6379> eval 'return redis.pcall("incr", "foo")' 0 (error) WRONGTYPE Operation against a key holding the wrong kind of value
脚本死循环
Redis 的指令执行是个单线程,单线程还要执行来自客户端的 lua 脚本。如果 lua 脚本中来一个死循环?Redis 为了解决这个问题,提供了 script kill 指令用于动态杀死一个执行时间超时的 lua 脚本。不过 script kill 的执行有一个重要的前提,那就是当前正在执行的脚本没有对 Redis 的内部数据状态进行修改,因为 Redis 不允许 script kill 破坏脚本执行的原子性。比如脚本内部使用了 redis.call("set", key, value) 修改了内部的数据,那么 script kill 执行时服务器会返回错误。下面尝试以下 script kill 指令。

eval 指令执行后,可以明显看出来 redis 卡死了,死活没有任何响应,如果去观察 Redis 服务器日志可以看到日志在疯狂输出 hello 字符串。这时候就必须重新开启一个 redis-cli 来执行 script kill 指令。

脚本都卡死了,Redis 哪里来的闲功夫接受 script kill 指令?
Script Kill 的原理
lua 脚本引擎功能强大,提供了各式各样的钩子函数,允许在内部虚拟机执行指令时运行钩子代码。比如每执行 N 条指令执行一次某个钩子函数,Redis 正是使用了这个钩子函数。

void evalGenericCommand(client *c, int evalsha) { ... // lua引擎每执行10w条指令,执行一次钩子函数 luaMaskCountHook lua_sethook(lua,luaMaskCountHook,LUA_MASKCOUNT,100000); ... }
Redis 在钩子函数里会忙里偷闲去处理客户端的请求,并且只有在发现 lua 脚本执行超时之后才会去处理请求,这个超时时间默认是 5 秒。
Lua脚本应用
实际代码中的实现:
@Component public class RedisDel { @Autowired private JedisPool jedisPool; public void delLock(String key, String value) { try (Jedis resource = jedisPool.getResource()) { String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; resource.eval(script, Collections.singletonList(key), Collections.singletonList(value)); } catch (Exception e) { e.printStackTrace(); } } }
在不同线程加锁的时候,虽然key是相同的,但是value不同,在删除脚本中会根据value值来判断当前删除锁是否为自己设置的锁,是就删除,否则就不会删除。如果将这个命令分开写,先查后删,这样就会导致整个删除过程不符合原子性,如果出现异常可能会导致执行中断。比如执行查询后,在执行删除的时候网络波动,删除命令没有发出,这样就会导致删除失败。如果采用Lua脚本方式,就将删除和查询聚合,保证执行的原子性。
投票系统防丢失
//投票,一篇文章一个人只能投票一次 public static void vote(Long articleId, Long userId) { Jedis resource = JEDIS_POOL.getResource(); try { //检查当前用户是否已经投过票(使用set) Long addResult = resource.sadd(ARTICLE_VOTE + articleId, userId.toString()); if (addResult == 0) { System.out.println("此用户已为此文章投过票,请勿重复投票!"); return; } resource.zincrby(ARTICLE_QUEUE, 1D, ARTICLE_PREFIX + articleId); System.out.println(String.format("投票成功,用户:【%s】,文章:【%s】", userId, articleId)); } catch (Exception e) { e.printStackTrace(); } finally { resource.close(); } }
lua优化过:
/投票,一篇文章一个人只能投票一次 public static void vote(Long articleId, Long userId) { Jedis resource = JEDIS_POOL.getResource(); try { /** * evalsha方法参数: * 第一个:表示脚本加载到Redis中后返回的sha值 * 第二个:表示传入的参数中KEYS的个数 * 第三个到第N个:除了KEYS以外都是ARGV,按照顺序来,如KEYS的个数是2,那么后面第三第四两个参数就是KEYS[1]、KEYS[2],后面再有参数都是ARGV * 在jedis中evalsha方法不止一个,传入的参数个数和格式也不一样,上面以一个栗子说明 */ Object evalsha = resource.evalsha("312ab5bfe03c5428c92a6e743ab48c1c325ff424", 1, articleId.toString(), userId.toString()); Long result = Long.valueOf(evalsha.toString()); if (result == -1) { System.out.println("此用户已为此文章投过票,请勿重复投票!"); } else { System.out.println(String.format("投票成功,用户:【%s】,文章:【%s】", userId, articleId)); } } catch (Exception e) { e.printStackTrace(); } finally { resource.close(); } }
lua脚本内容
local setResult = redis.call('sadd','article:votes:' .. KEYS[1],ARGV[1])
if setResult ~= 0 then
return redis.call('zincrby','article:queues',1,'article:prefix:' .. KEYS[1])
else
return -1
end
浙公网安备 33010602011771号