编码踩坑——Redis Pipeline中调用Lua脚本报错JedisMoveDataException的问题 / Lua脚本常遇到的问题

news2024/12/28 18:31:40

本篇记录使用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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/563513.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

FP独立站支付问题你还没解决?out了!

目前FP独立站是很多跨境卖家的变现方式&#xff0c;但是这类外贸电商会遇到一些收款问题&#xff0c;这些问题很容易就让卖家的资金被冻结、账号被风控、关联账号被限制&#xff0c;损失真是不小。那FP卖家的收款问题该怎么解决呢&#xff1f;往下看。 一、FP独立站常见收款方式…

抖音账号运营技巧,让你的短视频更火爆

抖音是目前最火爆的短视频平台之一&#xff0c;拥有着庞大的用户群体和广阔的市场前景。在这个平台上&#xff0c;每天都有大量的用户在发布自己的短视频内容&#xff0c;让自己的账号脱颖而出并吸引更多的粉丝&#xff0c;成为每个用户所追求的目标。下面就来介绍一些抖音账号…

新用户如何选择WMS仓储管理系统解决方案

引言&#xff1a;随着现代化物流技术的不断发展&#xff0c;WMS仓储管理系统已成为企业管理的重要工具。一款合适的WMS系统可以帮助企业提高库存管理效率、减少库存成本、提升物流服务质量。对于初学者来说&#xff0c;如何选择适合自己的WMS系统&#xff0c;往往是一项挑战。本…

如何做好app的测试工作?一文6个步骤到你秒变APP测试高手

先说结论: 想要做好 APP 的测试工作, 离不开相对完整的测试要点! 本篇文章不仅有完整的App测试介绍&#xff0c;还有相对完整的App测试视频分享。 闲话少叙, 咱们直奔主题, APP 应用测试应该主要包含以下几个方面的测试要点: 需要注意的是: APP 应用测试是个相对繁杂的测试类…

电脑远程连接软件推荐

您可以考虑使用多种可靠的计算机远程连接软件选项来远程连接和控制计算机。 以下是一些流行的选项&#xff1a; TeamViewer TeamViewer 是一种广泛使用的远程访问软件&#xff0c;以其易用性和跨平台兼容性而闻名。 它提供远程控制、文件传输和桌面共享等功能。 TeamViewer 通…

解密JavaScript混淆加密技术:揭秘隐藏的代码之谜

让我们通过一个案例来更好地理解JavaScript混淆加密的工作原理。假设我们有以下原始的JavaScript代码&#xff1a; function addNumbers(a, b) {return a b; }上述代码非常简单易懂&#xff0c;但对于一些恶意攻击者来说&#xff0c;他们可能会试图窃取您的代码或者修改其中的…

终身学习(LifeLong Learning)/ 增量学习(Incremental Learning)、在线学习(Online Learning)

1、在线学习 实时获得一个新样本就进行一次模型更新。显然&#xff0c;在线学习时增量学习的特例&#xff0c;而增量学习可视为“批模式”的在线/离线学习。 online主要相对于offline或者说batch&#xff0c;强调的是每次只进入一个或者很少的几个样本&#xff0c;多见于推荐…

缓存被穿透了怎么办?

首先来了解几个概念&#xff1a; 缓存穿透&#xff1a;大量请求根本不存在的key 缓存雪崩&#xff1a;redis中大量key集体过期 缓存击穿&#xff1a;redis中一个热点key过期&#xff08;大量用户访问该热点key&#xff0c;但是热点key过期&#xff09; 穿透解决方案 对空值…

windows powershell 下使用【docker cuda choco vim conda ......】

powershell 下可以使用的linux命令 ls可以完全替代llimgcat可以安装&#xff0c;但是显示不了图片&#xff0c;可以用start命令来替换 start .\wallhaven-9m5321.jpgcat touch history可以用 chmod 不能用下面介绍一下alias在powershell下的使用 这里的$profile相当于linux…

搭建Scala环境

搭建Scala开发环境 到官网上下载Scala Scala2.13.10下载网址&#xff1a;https://www.scala-lang.org/download/2.13.10.html 下载文件 安装Scala 根据提示安装&#xff0c;可以安装到默认文件&#xff0c;也能选择其他路径 配置Scala环境变量 变量名变量值SCALA_HOMEC:\Pr…

【软件测试用例篇】

&#x1f389;&#x1f389;&#x1f389;点进来你就是我的人了博主主页&#xff1a;&#x1f648;&#x1f648;&#x1f648;戳一戳,欢迎大佬指点! 欢迎志同道合的朋友一起加油喔&#x1f93a;&#x1f93a;&#x1f93a; 目录 1. 测试用例的概念 2. 设计测试用例的好处 3…

ESP32-C2模组 透传示例

WIFI-TTL透传模块说明 V 1.0 2022-11-24 1 简介 WiFi-TTL透传模块基于我司DT-ESPC2-12模块研发&#xff0c;引出串口TTL、EN、STATE 等引脚。产品内置我司最新版本的串口透传固件可完成设备TTL 端口到WiFi/云的数据实时透传。本模块可直接取代原有的有线串口&#xff0c;实现…

ZooKeeper快速入门学习+在springboot中的应用+监听机制的业务使用

目录 前言 基础知识 一、什么是ZooKeeper 二、为什么使用ZooKeeper 三、数据结构 四、监听通知机制 五、选举机制 使用 1 下载zookeeper 2 修改 3 排错 在SpringBoot中的使用 安装可视化插件 依赖 配置 安装httpclient方便测试 增删查改 新建控制器 创建节点…

k8s中部署nginx-ingress实现外部访问k8s集群内部服务

k8s通过nginx-ingress实现集群外网访问功能 一&#xff1a;ingress概述 1.1 ingress 工作原理 step1&#xff1a;ingress contronler通过与k8s的api进行交互&#xff0c;动态的去感知k8s集群中ingress服务规则的变化&#xff0c;然后读取它&#xff0c;并按照定义的ingress规…

jsp手机回收软件系统Myeclipse开发mysql数据库web结构java编程计算机网页项目

一、源码特点 jsp手机回收软件系统 是一套完善的web设计系统&#xff0c;对理解JSP java编程开发语言有帮助 &#xff0c;系统具有完整的源代码和数据库&#xff0c;系统主要采用B/S模式开发。开发环境为 TOMCAT7.0,Myeclipse8.5开发&#xff0c;数据库为Mysql&#xff0c;使…

Moonbeam社区治理|参与委托投票问卷,瓜分2000U奖励

社区治理升级意味着公链正走向可持续和透明化发展&#xff0c;让每位GLMR所有者都参与治理&#xff0c;是Moonbeam成为真正去中心化公链的重要一环。 Moonbeam治理 OpenGov为Moonbeam生态带来了多角色委托功能&#xff0c;使Token持有者能够根据track委托Token进行投票。委托…

零基础如何入门渗透测试

作为一名多年的渗透测试工程师&#xff0c;了解到很多零基础的初学者都面临着学习渗透测试的困难。在这里&#xff0c;我会提供一些指导性的建议和方法&#xff0c;帮助初学者快速入门&#xff0c;开启学习之旅。 一、什么是渗透测试 在学习渗透测试之前&#xff0c;建议先了解…

虹科技术 | 虹科EtherCAT增量编码器输入模块数据采集实操测试

1. 背景介绍 编码器是将信号或数据进行编制、转换为可用以通讯、传输和存储的信号形式的设备。编码器把角位移或直线位移转换成电信号&#xff0c;前者称为码盘&#xff0c;后者称为码尺。按照读出方式编码器可以分为接触式和非接触式两种&#xff1b;按照工作原理编码器可分为…

Android | Android 系统架构

参考&#xff1a; Android Developers(https://developer.android.google.cn/) 平台架构 Android 是基于 Linux 的开源软件栈&#xff0c;下图为官网给出的 Android 平台主要组件。 Android 平台从上&#xff08;直接与用户交互&#xff09;到下&#xff08;直接与硬件交互&a…

Mastodon 长毛象多租户:自定义域名、自定义账号别名

概念 自定义域名后缀 假设&#xff0c;Mastodon 主节点域名 domain1.com&#xff0c;我在该域名下拥有一个用户 user1domain1.com。 配置自定义域名后缀支持后&#xff0c;也可以通过 user1domain2.com 搜索到。该配置需要在主节点中设置 ALTERNATE_DOMAINS。 自定义账号别…