拦截 Redis 命令导致的 Lua 脚本执行失败问题分析

大家好,今天分享一个在使用 redis lua 脚本过程中遇到的一个问题,问题不难,但是容易踩坑。

lua 脚本使用方式

 
 

java

复制代码

// 定义脚本资源 DefaultRedisScript redisScript = new DefaultRedisScript<>(); redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource(SCRIPT_PATH + scriptName))); redisScript.setResultType(List.class); // 预加载脚本(可选) redisTemplate.getConnectionFactory().getClusterConnection().scriptLoad(redisScript.getScriptAsString().getBytes()); // 执行脚本 stringRedisTemplate.execute(redisScript, keys, args);

redis script 相关命令说明

  • script load:将 lua 脚本加载到 redis 的脚本缓存中,返回该脚本的 sha1 校验和,之后通过 evalsha 命令用此校验和调用该脚本。
  • evalsha:根据 sha1 执行已加载入的 lua 脚本。
  • eval:执行一段 lua 脚本代码,执行完后该脚本也会缓存到 redis 脚本缓存中。
  • script exists:根据 sha1 检查脚本是否已经存在于脚本缓存中。
  • script flush:清空 redis 的脚本缓存,删除所有已加载的 lua 脚本。
  • script kill:kill 正在执行的 lua 脚本。

execute 方法执行过程

reidsTemplate 里持有一个 ScriptExecutor,最终的执行都代理给 ScriptExecutor,ScriptExecutor 会通过 evalsha 命令去 redis server 端执行脚本。

如果之前已经通过 script load 命令预加载了 lua 脚本,则 evalsha 会正常执行;如果没有事先加载脚本且第一次执行该脚本,则 evalsha 会返回 "NOSCRIPT No matching script. Please use EVAL." 异常,该异常会封装成 RedisSystemException 抛出。

捕获异常后,判断如果异常类型是 NonTransientDataAccessException,且异常信息里包含 "NOSCRIPT" 关键词,则再通过 eval 命令传递完整的脚本来执行一次,执行完之后会缓存脚本,以后每次调用只需通过 evalsha 命令传递 sha1 即可执行。

项目中遇到的问题

负责的项目中有一段 lua 脚本用来做短信发送频率的限流处理,服务部署到全新的一套环境后发现请求报错 "NOSCRIPT No matching script. Please use EVAL.",根据上述介绍,该错误表示 redis server 通过传递的 sha1 找不到相应的脚本。

该服务目前做法是没事先通过 script load 预加载脚本,是通过懒加载方式,由第一个请求去做加载操作。因为新的这套环境 redis 集群也是新搭建的,所以肯定是没缓存此脚本的,但是按照上述分析,第一个请求 evalsha 失败后是会执行 eval 的。

所以可以推断是异常类型不是 NonTransientDataAccessException,或者异常信息里没有包含 "NOSCRIPT" 关键词,导致异常直接抛出去了。

经过排查发现是前两周是接入了 sentinel-redis 流控功能引起的问题。

 
 

xml

复制代码

<dependency> <groupId>com.xxx</groupId> <artifactId>xxx-sentinel-spring-boot-starter-redis</artifactId> <version>${xxx-sentinel.version}</version> </dependency>

该模块会对每一个 redis 命令做拦截,然后通过 sentinel 流控 api 进行包裹。

实际命令是通过 method.invoke() 反射执行的。如果执行内部有异常,会抛出 InvocationTargetException。

综上,第一次执行 evalsha 命令抛出的 "NOSCRIPT" RedisSystemException 被包装成了 InvocationTargetException 异常,所以在此判断直接返回 false,导致异常直接抛出了,并没有执行后续的 eval 命令。

怎么拦截 redis 命令

我们知道 redis 命令都是通过 RedisConnection 对象执行的,RedisConnection 是从 RedisConnectionFactory 中 get 的。

RedisConnectionFactory 一般有 jedis、lettuce 这两种实现。 

通过 SpringBoot 自动装配装载进 Spring 容器的就是具体的 RedisConnectionFactory 实现。 

所以我们可以通过拦截 RedisConnectionFactory 的 getConnection 方法得到 RedisConnection,然后对 RedisConnection 在进行一次代理,这样所有的 redis 命令就都能走到我们自己的拦截器里了。

解决办法

回到主题,我们要怎么解决这个问题呢?

  1. 使用 lua 脚本最好在服务启动后通过 script load 做预加载。

  2. 对 redis 命令(不限于)做拦截后,最好返回原始异常。

总结

  1. 该问题还是比较坑的,不好复现,在迁移新环境之前,一直没出现过该问题,主要原因是 sentine-redis 包是最近才引入的,不管 dev、test、prod 各环境 lua 脚本其实早就已经缓存到 redis server 了,走不到第一次加载的逻辑里去。

  2. 对各种组件的执行流程做拦截、扩展前需仔细看下原有的执行流程,是否对异常有特殊处理,最好返回原始异常。

个人开源项目

DynamicTp 是一个基于配置中心实现的轻量级动态线程池管理工具,主要功能可以总结为动态调参、通知报警、运行监控、三方包线程池管理等几大类。

目前累计 4.3k star,欢迎大家试用,感谢你的 star,欢迎 pr,业务之余一起给开源贡献一份力量

猜你喜欢

转载自blog.csdn.net/BASK2312/article/details/131305279