本篇记录使用Redis Pipeline时,调用redis.clients.jedis.PipelineBase#eval时,报错JedisMoveDataException的问题;通过查看源码发现问题的原因,通过jedis在Github的issue了解了解决方案;涉及知识:Redis slot、Redis Pipeline、Redis Lua;
问题背景
有一段涉及用户通知疲劳度控制相关的代码,由于要保证执行逻辑的原子性,用到了Lua脚本:先判断rateLimiterKey是否exist存在,若存在则返回0-不通过,否则对该key赋值并设置ttl(setex),返回1-通过;如下:
local key = KEYS[1]
local duration = tonumber(ARGV[1])
local exists = tonumber(redis.call('exists', key))
--exists=1,则表示存在,返回0;
if exists == 1 then
return 0
end
--否则设置key及ttl,返回1;
redis.call('setex', key, duration, "")
return 1
在对批量用户做上述校验时,想到是否可用Redis Pipeline来优化,从而减小网络传输开销;关于Redis Pipeline的知识可查阅我之前的文章《编码技巧——Redis Pipeline》;
通过查看JedisClusterPipeLine对象的方法,发现Jedis确实提供了pipeline下的eval方法,如下:
于是,立即试了下,代码如下:
try {
pipeline = jedisCluster.pipelined();
for (String receiverId : receiverIds) {
final String rateLimitKey = buildRecieverRateLimiterKey(msgType, receiverId, topic);
pipeline.eval(LuaScript.setexFlag(), Lists.newArrayList(rateLimitKey), Lists.newArrayList(String.valueOf(ttl)));
}
// pipeline执行并获取结果
final List<Object> allVal = pipeline.syncAndReturnAll();
if (CollectionUtils.isNotEmpty(allVal)) {
final List<Long> flags = allVal.stream().map(val -> Long.valueOf(String.valueOf(val))).collect(Collectors.toList());
if (flags.size() == receiverIds.size()) {
final List<String> filtered = Lists.newArrayList();
for (int i = 0; i < flags.size(); i++) {
final String receiverId = receiverIds.get(i);
if (EXISTS != flags.get(i)) {
filtered.add(receiverId);
log.warn("rateLimiterFilter_pass. [rateLimiterKey={}]", buildRecieverRateLimiterKey(msgType, receiverId, topic));
} else {
log.warn("rateLimiterFilter_not_pass. [rateLimiterKey={}]", buildRecieverRateLimiterKey(msgType, receiverId, topic));
}
}
return filtered;
}
}
} catch (Exception e) {
log.error("pipeline_rateLimiter_fr_redis_error. [msgType={} topic={} receiverIds={}]", msgType, topic, JSON.toJSONString(receiverIds), e);
} finally {
if (pipeline != null) {
pipeline.close();
}
}
问题现象
以上代码一旦执行到 pipeline.syncAndReturnAll() 时,返回Lua执行的结果中,全是异常,异常类型为 redis.clients.jedis.exceptions.JedisMovedDataException,描述为"MOVED 10493 10.101.39.148:11115";
原因分析
Redis集群模式下,通过hash槽算法维护key与slot的映射,从而来确定一次命令需要路由到哪个节点执行;关于Redis solt的知识点,可参考我之前的文章《Redis——Cluster数据分布算法&哈希槽》;
顾名思义,JedisMovedDataException 这类问题一般发生于执行redis命令时,发现准备路由的节点与该key实际所在的节点不一致;常见的场景如Redis节点扩缩容导致的slot迁移,最简单的解决办法就是使用JedisCluster对象替换Jedis对象;
但是有时候,"JedisCluster对象替换Jedis对象"并不能由我们决定,如此次使用的JedisClusterPipeLine对象,执行eval方法时,封装好的源码就是通过Jedis来执行的,也就是说我们改不了;
既然不能直接解决问题,就得分析原因了,为什么根据key找不到正确的slot?在搞清楚这个问题之前,先要弄清楚一个问题——Lua脚本是允多key和多参数的,对应Lua中的KEY[N]和ARGV[N],既然hash槽算法是通过 CRC16("key")%16384 计算slot的,那么当可能存在单key OR 多key 情况时——
Redis Cluster模式下能否使用Lua脚本呢?
先说结论——可以,但是对Multi-keys有要求;分以下2种情况:
- 单Key
- 多Key
集群模式下,是支持执行单Key的Lua脚本的;比较容易理解,因为只有1个key,所以直接根据这个key来找slot即可,不存在冲突问题;
但是对于多key,当这多个key执行 CRC16("key")%16384 落到相同的slot时,Lua脚本可以正常执行;当结果分散在不同的slot时,会发现Redis报错:
(error) CROSSSLOT Keys in request don’t hash to the same slot
如何能保证Lua中的多个key落在相同的hash槽呢?
默认下,redis在计算key的槽时,会对整个key做CRC16哈希取值,但是其API也开放了功能——redis hashtag,即通过tag,对key中指定的一段做CRC16哈希取值,这样可以让不同的key落到相同的哈希槽上;
通过redis hashtag源码解析可知,仅对key中{...}里的部分参与hash,如果有多个花括号,从左向右,取第一个花括号中的内容进行hash;若第一个花括号中内容为空如:a{}c{d},则整个key参与hash;达到的效果就是,相同的hashtag被分配到相同的槽,即相同的redis节点;不过滥用hash tag可能导致节点上的key数量分布不均匀;
明确上面的问题后,再来看下我们的问题——
既然我们的Lua只有单个Key,为什么根据key找不到正确的slot?
查看 pipeline.eval(...) 的源码,如下:
可以看到,源码是用script来作为计算slot的参数的,而非key;那这算不算Bug呢?
——算!查阅jedis的GitHub issues,发现确实有:fix eval & evalsha in pipeline by minisancy · Pull Request #2257 · redis/jedis · GitHub
后面咨询了公司中间件工程师,了解到该问题于2020年9月修复,已经是Jedis 3.x版本了,我们当前的Jedis版本不支持;
所以,问题的原因是Jedis旧版本的一个BUG;
看了下后序Jedis的一个commit修复记录,如下:
src/main/java/redis/clients/jedis/MultiNodePipelineBase.java
使用了CommandObjects对象来包装命令,根据命令key来计算slot,解决了pipeline执行Lua脚本的问题;
此外还发现了旧版本Jedis执行Lua脚本的一个"限制 OR Bug",Jedis封装Lua脚本的执行结果时,强制返回类型为String,如果Lua返回的值为数值型,执行后会报错Long转byte[]的类型转换错误,代码如下:
小结
问题的结论就是,老版本的Jedis的pipeline开放了eval方法,但是实际上是不支持的,因为无法确保对script取slot跟对原key取到的slot一致;此外,执行eval的结果强制为String类型,而我们一般的Lua返回结果为数值型,在执行时会出现运行时异常java.lang.ClassCastException;
可见,第三方的中间件包也不一定靠谱,在发现问题时要能根据源码和原理分析原因,敢于去官方Git下去咨询或提Issue;
补充:使用Lua脚本常遇到的问题
1. Lua中get命令获得的东西判断nil的坑
现象:
此时的key不存在,那么按理说Lua结果应该返回nil,也就是说上面的执行结果应该是0才对,但是Lua给出了1这个匪夷所思的结果;
参考了stackoverflow上的这个问题redis lua can't work properly due to wrong type comparison - Stack Overflow以及它里面提到的官方的这篇文章EVAL | Redis,发现了下面这个“潜规则”:
Redis Nil bulk reply and Nil multi bulk reply -> Lua false boolean type
也就是说,Lua中的get会将nil转换成false,根据这个结果,我们重新调整了上面的Lua script如下:
结论:对get的返回结果预期为数值型时,使用tonumber转换结果再判断;若非数值型,通过false判断是否存在而不要使用nil判断;
2. Redis到Lua数据的类型转换
示例:
127.0.0.1:6379> set test 2
OK
127.0.0.1:6379> object encoding test
"int"
可以看到,我们将key test设置成了整型2,查看它的encoding发现也是int类型,那么我们在lua script中将test get成一个变量,感觉应该也是number类型吧,但是现实是残酷的:
127.0.0.1:6379> eval "local a = redis.call('get', 'test'); return type(a);" 0
"string"
我们得到的是string类型。这是为啥嘞?
这个问题我们google了很久,最后查看了很长时间的源码,后来发现官方的文档其实已经说明了这个问题,但是不是那么明显。在官方的这篇文档中,提到了Redis将会怎样把类型映射到lua中:
Redis to Lua conversion table.
- Redis integer reply -> Lua number
- Redis bulk reply -> Lua string
- Redis multi bulk reply -> Lua table (may have other Redis data types nested)
- Redis status reply -> Lua table with a single ok field containing the status
- Redis error reply -> Lua table with a single err field containing the error
- Redis Nil bulk reply and Nil multi bulk reply -> Lua false boolean type
官方文档中提到的这个table,有一个很重要的信息,就是redis对于类型的转换,是针对每一个命令的,和key本身是个啥没有关系,这就是上面每一条对应的都是一个XXX reply;
结论:lua内获得的redis的数据,不根据key的类型决定,而是根据key的reply决定;另外多提一句,eval中传入的ARGV数组,redis官方全部都是作为string来处理的!
3. Lua script在cluster中执行的目标机器
在redis cluster环境中,key是按照slot槽来存储的,而不同的slot槽又是存储在不同的机器上的,那当我们运行的一个lua script涉及到多个key时,到底由哪个机器来执行呢?
这个坑里涉及2个问题:
- (1)能不能把key直接写到lua script里?
- (2)如果我们有多个key,redis到底会把lua script放到哪个机器上去执行?
eval命令后面会跟一个key的列表,但是同样,命令没有禁止我们把key直接写到lua script里,即也提供了不含key的eval重载方法;
如果我们把key写到了lua script中,那么即使lua script能够顺利进入某个机器开始执行,大概率也会出现当前机器中没有我们写死的这个key,此时会得到下面的错误:
Lua script attempted to access a non local key in a cluster node
下面这段是来自Redis官方的eval命令的文档:
All Redis commands must be analyzed before execution to determine which keys the command will operate on. In order for this to be true for EVAL, keys must be passed explicitly. This is useful in many ways, but especially to make sure Redis Cluster can forward your request to the appropriate cluster node.
加粗的那句话表明,为了能让redis正确的搞明白key到底该怎么执行,需要显式的传进去,也就是说,我们需要用eval的key的参数列表来传入我们要操作的key,而不能把它直接写到lua script里!
接下来的一个问题就是,如果我们有多个key,redis到底会把lua script放到哪个机器上去执行?
我们其实可以自己去尝试一下,执行一个需要多个key的lua script,极大概率你会得到下面的错误:
(error) CROSSSLOT Keys in request don't hash to the same slot
Redis要求,在使用eval的时候,涉及到的key必须在同一个slot槽中,否则,就会出现上面的错误!
解决上面问题的方法就是使用hash tag(hash tag可以参照官方文档);我们需要把key中的一部分使用{}包起来,redis将通过{}中间的内容作为计算slot的key,类似key1{mykey}、key2{mykey}这样的都会存放到同一个slot中;当然,hash tag带来的一个问题就是会让cluster中某个节点压力增加,这个只能取舍了;
4. eval和evalsha的使用区别
eval会把script全部都发过去执行,而evalsha是执行缓存在redis服务器的scipt(通过参数中的script的hash值去找),如果redis服务器上没有缓存这个script,会抛出错误NoScriptError;
建议使用evalsha方法,因为会节省网络传输的数据提升性能,但是首次需要先把写好的lua script使用script load方式加载到redis服务器,事实上通过eval命令就可以触发;下面是spring-data-redis的代码实现:
Object result;
try {
result = connection.evalSha(script.getSha1(), returnType, numKeys, keysAndArgs);
} catch (Exception e) {
if (!ScriptUtils.exceptionContainsNoScriptError(e)) {
throw e instanceof RuntimeException ? (RuntimeException) e : new
RedisSystemException(e.getMessage(), e);
}
// 通过eval命令达到load script的作用
result = connection.eval(scriptBytes(script), returnType, numKeys, keysAndArgs);
}
5. Redis LuaScript并非真的强原子性
redis script 不具备 all or nothing 特性的,可能是 crud 程序猿会遇到,这可能是思维惯性导致的;举个例子:
redis.call('SET', 'key1', 'value1');
local a = b;
redis.call('SET', 'key2', 'value2');
redis 在执行 local a = b; 这一行时,就会报错如下的错误:
(error) ERR Error running script (call to f_71007e955106f406b23cfaba7647eec1081fda7d):
@enable_strict_lua:15: user_script:1: Script attempted to access nonexistent global variable 'b'
然后后续的代码便不再执行,对于长期习惯于丢异常就会回滚修改的 crud 程序员来说,key1 和 key2 的值肯定没有设置成功;然而事实是,上诉代码是一半成功(成功设置 key1),一半根本没有执行(没有执行到 key2 的位置);
简而言之,redis script 的原子性特性只是指 redis 只使用一个 lua 解释器执行 script,且是单线程执行 script;但是 script 执行中途报错,是不会将修改回滚的,回滚特性应该属于事务,而 redis 其实是没有严格的事务特性的,redis script 是没有 all or nothing 的特性;关于Lua脚本事务性的验证可参考我之前的文章Redis——“事务“/Lua脚本_lua脚本;
其他:B站之前一次大的线上问题也是由网关层的Lua脚本对执行结果的预期值与实际结果类型不一致导致的:2021.07.13 B站是这样崩的_哔哩哔哩_bilibili
参考:
Redis——Cluster数据分布算法&哈希槽
Redis Cluster中使用Lua脚本
Redis集群扩容导致的Jedis客户端报JedisMovedDataException异常
fix eval & evalsha in pipeline by minisancy · Pull Request #2257 · redis/jedis · GitHub
Redis eval命令踩得那些坑 · Issue #7 · nethibernate/blog · GitHub