本文主要介绍 Lua 脚本的安全性问题、以及解决这些问题的方法进行说明, 及对执行 Lua 脚本EVAL的实现原理进行介绍,最后还有Lua的相关示例。

Lua 脚本功能是 Reids 2.6 版本的最大亮点, 通过内嵌对 Lua 环境的支持, Redis 解决了长久以来不能高效地处理 CAS (check-and-set)命令的缺点, 并且可以通过组合使用多个命令, 轻松实现以前很难实现或者不能高效实现的模式。

Lua 脚本在 Redis 中通常是为了保证高并发下的原子性

Lua脚本

脚本的安全性

带有随机性的Lua写入脚本,它破坏了主从节点数据之间的一致性。(从 AOF 文件中载入带有随机性质的写入脚本时, 也会发生同样的问题)

为了解决这个问题, Redis 对 Lua 环境所能执行的脚本做了一个严格的限制 —— 所有脚本都必须是无副作用的纯函数(pure function)。

为此,Redis 对 Lua 环境做了一些列相应的措施:

  • 不提供访问系统状态状态的库(比如系统时间库)
  • 禁止使用loadfile函数
  • 如果脚本在执行带有随机性质的命令(比如RANDOMKEY),或者带有副作用的命令(比如TIME )之后,试图执行一个写入命令(比如 SET),那么 Redis 将阻止这个脚本继续运行,并返回一个错误。
  • 如果脚本执行了带有随机性质的读命令(如SMEMBERS),那么在脚本的输出返回给 Redis 之前,会先被执行一个自动的字典序排序,从而确保输出结果是有序的。
  • 用 Redis 自己定义的随机生成函数,替换 Lua 环境中math表原有的math.random函数和math.randomseed函数,新的函数具有这样的性质:每次执行 Lua 脚本时,除非显式地调用math.randomseed,否则math.random生成的伪随机数序列总是相同的。

经过这一系列的调整之后, Redis 可以保证被执行的脚本:

  1. 无副作用
  2. 没有有害的随机性
  3. 对于同样的输入参数和数据集,总是产生相同的写入命令

脚本的执行

在脚本环境的初始化工作完成以后, Redis 就可以通过 EVAL 命令或 EVALSHA 命令执行 Lua 脚本了。

EVALSHA 是基于 EVAL 构建的

其中, EVAL 直接对输入的脚本代码体(body)进行求值:

redis> EVAL "return 'hello world'" 0
"hello world"

而 EVALSHA 则要求输入某个脚本的 SHA1 校验和, 这个校验和所对应的脚本必须至少被 EVAL 执行过一次:

redis> EVAL "return 'hello world'" 0
"hello world"redis> EVALSHA 5332031c6b470dc5a0dd9b4bf2030dea6d65de91 0    // 上一个脚本的校验和
"hello world"

或者曾经使用 SCRIPT LOAD 载入过这个脚本:

redis> SCRIPT LOAD "return 'dlrow olleh'"
"d569c48906b1f4fca0469ba4eee89149b5148092"redis> EVALSHA d569c48906b1f4fca0469ba4eee89149b5148092 0
"dlrow olleh"

EVAL 命令的实现

EVAL 命令的执行可以分为以下步骤:
1, 为输入脚本定义一个 Lua 函数。
2. 执行这个 Lua 函数。

定义 Lua 函数

所有被 Redis 执行的 Lua 脚本, 在 Lua 环境中都会有一个和该脚本相对应的无参数函数: 当调用 EVAL 命令执行脚本时, 程序第一步要完成的工作就是为传入的脚本创建一个相应的 Lua 函数。

举个例子, 当执行命令EVAL "return 'hello world'" 0时, Lua 会为脚本"return 'hello world'"创建以下函数:

function f_5332031c6b470dc5a0dd9b4bf2030dea6d65de91()return 'hello world'
end

其中, 函数名以 f_ 为前缀, 后跟脚本的 SHA1 校验和(一个 40 个字符长的字符串)拼接而成。 而函数体(body)则是用户输入的脚本

以函数为单位保存 Lua 脚本有以下好处:

  • 执行脚本的步骤非常简单,只要调用和脚本相对应的函数即可。
  • Lua 环境可以保持清洁,已有的脚本和新加入的脚本不会互相干扰,也可以将重置 Lua 环境和调用 Lua GC 的次数降到最低。
  • 如果某个脚本所对应的函数在 Lua 环境中被定义过至少一次,那么只要记得这个脚本的 SHA1 校验和,就可以直接执行该脚本 —— 这是实现 EVALSHA 命令的基础,稍后在介绍 EVALSHA 的时候就会说到这一点。

在为脚本创建函数前,程序会先用函数名检查 Lua 环境,只有在函数定义未存在时,程序才创建函数。重复定义函数一般并没有什么副作用,这算是一个小优化。
另外,如果定义的函数在编译过程中出错(比如,脚本的代码语法有错), 那么程序向用户返回一个脚本错误, 不再执行后面的步骤。

执行 Lua 函数

在定义好 Lua 函数之后, 程序就可以通过运行这个函数来达到运行输入脚本的目的了。

不过, 在此之前, 为了确保脚本的正确和安全执行, 还需要执行一些设置钩子、传入参数之类的操作, 整个执行函数的过程如下:

  1. 将 EVAL 命令中输入的 KEYS 参数和 ARGV 参数以全局数组的方式传入到 Lua 环境中
  2. 设置伪客户端的目标数据库为调用者客户端的目标数据库: fake_client->db = caller_client->db ,确保脚本中执行的 Redis 命令访问的是正确的数据库
  3. 为 Lua 环境装载超时钩子,保证在脚本执行出现超时时可以杀死脚本,或者停止 Redis 服务器
  4. 执行脚本对应的 Lua 函数
  5. 如果被执行的 Lua 脚本中带有SELECT命令,那么在脚本执行完毕之后,伪客户端中的数据库可能已经有所改变,所以需要对调用者客户端的目标数据库进行更新:caller_client->db = fake_client->db
  6. 执行清理操作:清除钩子;清除指向调用者客户端的指针;等等
  7. 将 Lua 函数执行所得的结果转换成 Redis 回复,然后传给调用者客户端
  8. 对 Lua 环境进行一次单步的渐进式 GC

以下是执行命令 EVAL “return redis.call(‘DBSIZE’)” 0 时, 调用者客户端(caller)、伪客户端(fake client)、Redis 服务器和 Lua 环境之间的数据流表示图

          发送命令请求EVAL "return redis.call('DBSIZE')" 0
Caller ------------------------------------------> Redis为脚本 "return redis.call('DBSIZE')"创建 Lua 函数
Redis  ------------------------------------------> Lua绑定超时处理钩子
Redis  ------------------------------------------> Lua执行脚本函数
Redis  ------------------------------------------> Lua执行 redis.call('DBSIZE')
Fake Client <------------------------------------- Lua伪客户端向服务器发送DBSIZE 命令请求
Fake Client -------------------------------------> Redis服务器将 DBSIZE 的结果(Redis 回复)返回给伪客户端
Fake Client <------------------------------------- Redis将命令回复转换为 Lua 值并返回给 Lua 环境
Fake Client -------------------------------------> Lua返回函数执行结果(一个 Lua 值)
Redis  <------------------------------------------ Lua将 Lua 值转换为 Redis 回复并将该回复返回给客户端
Caller <------------------------------------------ Redis

因为 EVAL “return redis.call(‘DBSIZE’)” 只是简单地调用了一次 DBSIZE 命令, 所以 Lua 和伪客户端只进行了一趟交互, 当脚本中的 redis.call 或者 redis.pcall 次数增多时, Lua 和伪客户端的交互趟数也会相应地增多, 不过总体的交互方法和上图展示的一样

Lua示例

Spring RedisTemplate Lua示例:

  1. 统计每日总学习时长及课程学习时长
  2. 更新最后学习时间
  3. 更新学习进度

亦可导入Lua脚本文件

/*** KEYS[1]: 每日总学习时长key* KEYS[2]: 课程学习进度信息key** ARGV[1]: 心跳频率* ARGV[2]: 单次学习开始时间* ARGV[3]: 视频进度比例** ret[1]: 日总时长学习时长* ret[2]: 课程学习总时长* ret[3]: 提示信息*/
private static final String HB_LUA_SCRIPT = "local ret = '';\n" +"local heartBeat = ARGV[1]" +"local dayTotalDuration = tonumber(redis.call('incrby', KEYS[1], heartBeat));\n" +"local courseDuration = tonumber(redis.call('hincrby', KEYS[2], 'duration', heartBeat));\n" +"local oldLastStartTimestamp = tonumber(redis.call('hget', KEYS[2], 'lastStartTimestamp'));\n" +"if oldLastStartTimestamp == nil or oldLastStartTimestamp < tonumber(ARGV[2]) then \n" +"    redis.call('hmset', KEYS[2], 'lastDuration', heartBeat, 'lastStartTimestamp', ARGV[2]);\n" +"else \n" +"    redis.call('hincrby', KEYS[2], 'lastDuration', heartBeat);\n" +"end \n" +"local oldVideoRate = tonumber(redis.call('hget', KEYS[2], 'videoRate'));\n" +"if oldVideoRate == nil or oldVideoRate < tonumber(ARGV[3]) then \n" +"   redis.call('hset', KEYS[2], 'videoRate', ARGV[3]);\n" +"end \n" +"return ret..dayTotalDuration..','..courseDuration..',success';";
private static final DefaultRedisScript<String> REDIS_SCRIPT = new DefaultRedisScript<>(HB_LUA_SCRIPT, String.class);@Autowired
private StringRedisTemplate redisTemplate;public void incrCourseDuration(HeartBeatVO vo, String date) {Long uid = vo.getUid();log.info("incrCourseDuration deal vo:{}, date:{}", vo, date);List<String> keys = Lists.newArrayList(RedisKey.Duration.getDayDurationKey(date, uid),RedisKey.Duration.getCourseInfoKey(uid, vo.getCourseId()));Object[] args = {String.valueOf(3),String.valueOf(vo.getStartTimeMs()),String.valueOf(vo.getVideoRate())};String result = this.redisTemplate.execute(REDIS_SCRIPT, keys, args);log.info("incrCourseDuration, uid:{}, courseId:{}, result:{}", uid, vo.getCourseId(), result);
}

参考资料:

  1. Lua 脚本
  2. 一网打尽Redis Lua脚本并发原子组合操作

Redis Lua拓展及使用示例相关推荐

  1. Redis Lua 列表批量操作

    使用Redis列表时,通常使用LPOP命令弹出数据 LPOP key [count] 当需要一次弹出多条数据时 使用for循环则网络请求次数太多 使用pipeline则不能保证原子性,可能会出现多个实 ...

  2. Java并发:分布式应用限流 Redis + Lua 实践

    任何限流都不是漫无目的的,也不是一个开关就可以解决的问题,常用的限流算法有:令牌桶,漏桶.在之前的文章中,也讲到过,但是那是基于单机场景来写. 之前文章:接口限流算法:漏桶算法&令牌桶算法 然 ...

  3. 91免费视频Redis+Lua解决高并发场景在线秒杀问题

    为何要使用Lua脚本解决商品超卖的问题呢? Redis在2.6版本后原生支持Lua脚本功能,允许开发者使用Lua语言编写脚本传到Redis中执行. 将复杂的或者多步的redis操作,写为一个脚本,一次 ...

  4. 介绍一个基于Spring Redis Lua的无侵入应用级网关限流框架

    介绍一个基于Spring Redis Lua的无侵入应用级网关限流框架 项目介绍 为什么选择spring-redis-current-limit Quick Start 1. 引入spring-red ...

  5. Redis Lua 脚本常用操作总结及实现 CAS 操作

    一.什么是 Lua ?   Lua 是一个小巧的脚本语言.它是巴西里约热内卢天主教大学(Pontifical Catholic University of Rio de Janeiro)里的一个由 R ...

  6. 秒杀(PHP,Redis,Lua)

    2019独角兽企业重金招聘Python工程师标准>>> 虚拟机测试PHP+Redis与PHP+Redis+Lua性能比较 [root@bogon ~]# redis-cli --ve ...

  7. Redis Lua脚本中学教程(下)

    在中学教程的上半部分我们介绍了Redis Lua相关的命令,没有看过或者忘记的同学可以步行前往直接使用机票Redis Lua脚本中学教程(上).今天我们来简单学习一下Lua的语法. 在介绍Lua语法之 ...

  8. 高并发-【抢红包案例】之四:使用Redis+Lua脚本实现抢红包并异步持久化到数据库

    文章目录 导读 概述 实现步骤 注解方式配置 Redis lua脚本和异步持久化功能的开发 Service层添加Redis抢红包的逻辑 Controller层新增路由方法 构造模拟数据,测试 代码 总 ...

  9. Redis Lua脚本中学教程(上)

    失踪人口回来啦! 有读者问我为什么这么久都没有出Redis Lua中学教程,表示村头厕所已经好久没有纸了.其实我早就要写这篇中学教程了,奈何最近太忙了,就一拖再拖,直到今天我终于又开始动笔了.忘记Lu ...

最新文章

  1. Github近期最有趣的10款机器学习开源项目
  2. 彻底解决IAR中Go to definition of不可用
  3. 企业证书系列之数据加密
  4. 怎么给web 服务器 传文件,web文件传到服务器
  5. 如何写好一份工程师简历
  6. 中信国健临床通讯  2011年3月期 目 录
  7. [转载] 卷积神经网络做mnist数据集识别
  8. c#物联网_「物联网架构」Apache-Kafka:物联网数据平台的基石
  9. 联想微型计算机a20,联想乐player A20
  10. 哪个软件测试交易系统好用,交易系统测试结果的可信度检验
  11. EMD+EEMD+CEEMD+CEEMDAN分解论文代码复现
  12. QQ互联官网使用跳坑
  13. 20172304 《程序设计与数据结构》第七周学习总结
  14. 谷歌邮箱(@gmail.com):两步验证+应用专用密码登录
  15. 以太网交换机和普通交换机主要的8大区别介绍
  16. 2021年化工自动化控制仪表新版试题及化工自动化控制仪表找解析
  17. html doc,HTML咸蛋超人版.doc
  18. mac系统如何修改网卡mac地址
  19. html中字符间距怎么写,html段落内文字设置字间距间隔
  20. RocketMQ 集群告警

热门文章

  1. 树形数据的搜索方法---javascript
  2. 139邮箱山寨版push mail功能
  3. 【Python】获取roc、auc时候报错:raise ValueError({0} format is not supported.format(y_type))
  4. 如何控制弹出窗口的大小、尺寸、位置等的样式
  5. 知识分享之Golang——go-i18n国际化组件
  6. 报错 | vue-router.esm.js?3423:2065 Uncaught (in promise) NavigationDuplicated: Avoided redundant navig
  7. 联想计算机组装,联想主板跳线图解(新手电脑组装教程)
  8. 高等数学(下)曲线积分与曲面积分
  9. SitePoint播客#47:将死苹果
  10. FasterXML Jackson