本篇记录使用Redis Pipeline时,调用redis.clients.jedis.PipelineBase#eval时,报错JedisMoveDataException问题;通过查看源码发现问题的原因,通过jedis在Githubissue了解了解决方案;涉及知识: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&gt; 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数据分布算法&哈希槽》

顾名思义,JedisMovedDataExceptio这类问题一般发生于执行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.

官方文档中提到的这个table,有一个很重要的信息,就是redis对于类型转换,是针对一个命令的,和key本身是个啥没有关系,这就是上面每一条对应的都是一个XXX reply;

结论lua内获得的redis的数据,不根据key的类型决定,而是根据key的reply决定;另外多提一句,eval中传入的ARGV数组,redis官方全部都是作为string处理

3. Lua scriptcluster中执行的目标机器

在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命令就可以触发;下面是springdata-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

原文地址:https://blog.csdn.net/minghao0508/article/details/130827658

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。

如若转载,请注明出处:http://www.7code.cn/show_12157.html

如若内容造成侵权/违法违规/事实不符,请联系代码007邮箱suwngjj01@126.com进行投诉反馈,一经查实,立即删除

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注