天天看点

Lua语言模型 与 Redis应用 Lua语言模型 与 Redis应用

标签: Java与NoSQL

从 2.6版本 起, Redis 开始支持 Lua 脚本 让开发者自己扩展 Redis.

本篇博客主要介绍了 Lua 语言不一样的设计模型(相比于Java/C/C++、JS、PHP), 以及 Redis 对 Lua 的扩展, 最后结合 Lua 与 Redis 实现了一个支持过期时间的分布式锁. 我们希望这篇博客的读者朋友可以在读完这篇文字之后, 体会到 Lua 这门语言不一样的设计哲学, 以及 更加得心应手的使用/扩展 Redis.

非脚本实现

以上代码有两点缺陷

可能会出现竞态条件: 解决方法是用 <code>WATCH</code> 监控 <code>rate.limit:$IP</code> 的变动, 但较为麻烦;

以上代码在不使用 <code>pipeline</code> 的情况下最多需要向Redis请求5条指令, 传输过多.

Lua脚本实现

Redis 允许将 Lua 脚本传到 Redis 服务器中执行, 脚本内可以调用大部分 Redis 命令, 且 Redis 保证脚本的原子性:

首先需要准备Lua代码: script.lua

Java

Lua 嵌入 Redis 优势: 减少网络开销: 不使用 Lua 的代码需要向 Redis 发送多次请求, 而脚本只需一次即可, 减少网络传输; 原子操作: Redis 将整个脚本作为一个原子执行, 无需担心并发, 也就无需事务; 复用: 脚本会永久保存 Redis 中, 其他客户端可继续使用.

作为通用脚本语言, Lua的数据类型如下:

数值型:

全部为浮点数型, 没有整型;

只有 <code>nil</code> 和 <code>false</code> 作为布尔值的 <code>false</code> , 数字 <code>0</code> 和空串(<code>‘’</code>/<code>‘\0’</code>)都是 <code>true</code>;

字符串

用户自定义类型

函数(function)

表(table)

变量如果没有特殊说明为全局变量(那怕是语句块 or 函数内), 局部变量前需加<code>local</code>关键字.

Tips:

数学操作符的操作数如果是字符串会自动转换成数字;

连接 <code>..</code> 自动将数值转换成字符串;

比较操作符的结果一定是布尔类型, 且会严格判断数据类型(<code>'1' != 1</code>);

在 Lua 中, 函数是和字符串、数值和表并列的基本数据结构, 属于第一类对象( first-class-object /一等公民), 可以和数值等其他类型一样赋给变量、作为参数传递, 以及作为返回值接收(闭包):

使用方式类似JavaScript:

Lua最具特色的数据类型就是表(Table), 可以实现数组、<code>Hash</code>、对象所有功能的万能数据类型:

数组索引从<code>1</code>开始;

获取数组长度操作符<code>#</code>其’长度’只包括以(正)整数为索引的数组元素.

Lua用表管理全局变量, 将其放入一个叫<code>_G</code>的table内:

用<code>Hash</code>实现对象的还有JavaScript, 将数组和<code>Hash</code>合二为一的还有PHP.
Every value in Lua can have a metatable/元表. This metatable is an ordinary Lua table that defines the behavior of the original value under certain special operations. You can change several aspects of the behavior of operations over a value by setting specific fields in its metatable. For instance, when a non-numeric value is the operand of an addition, Lua checks for a function in the field “__add” of the value’s metatable. If it finds one, Lua calls this function to perform the addition. The key for each event in a metatable is a string with the event name prefixed by two underscores<code>__</code>; the corresponding values are called metamethods. In the previous example, the key is “__add” and the metamethod is the function that performs the addition.

metatable中的键名称为事件/event, 值称为元方法/metamethod, 我们可通过<code>getmetatable()</code>来获取任一值的metatable, 也可通过<code>setmetatable()</code>来替换table的metatable. Lua 事件一览表:

对于这些操作, Lua 都将其关联到 metatable 的事件Key, 当 Lua 需要对一个值发起这些操作时, 首先会去检查其metatable中是否有对应的事件Key, 如果有则调用之以控制Lua解释器作出响应.

MetaMethods主要用作一些类似C++中的运算符重载操作, 如重载<code>+</code>运算符:

Lua本来就不是设计为一种面向对象语言, 因此其面向对象功能需要通过元表(metatable)这种非常怪异的方式实现, Lua并不直接支持面向对象语言中常见的类、对象和方法: 其<code>对象</code>和<code>类</code>通过<code>表</code>实现, 而<code>方法</code>是通过<code>函数</code>来实现.

上面的Event一览表内我们看到有<code>__index</code>这个事件重载,这个东西主要是重载了<code>find key</code>操作, 该操作可以让Lua变得有点面向对象的感觉(类似JavaScript中的prototype). 通过Lua代码模拟:

对于任何事件, Lua的处理都可以归结为以下逻辑:

如果存在规定的操作则执行它;

否则从元表中取出各事件对应的<code>__</code>开头的元素, 如果该元素为函数, 则调用;

如果该元素不为函数, 则用该元素代替<code>table</code>来执行事件所对应的处理逻辑.

这里的代码仅作模拟, 实际的行为已经嵌入Lua解释器, 执行效率要远高于这些模拟代码.

面向对象的基础是创建对象和调用方法. Lua中, 表作为对象使用, 因此创建对象没有问题, 关于调用方法, 如果表元素为函数的话, 则可直接调用:

不过这种实现方法调用的方式, 从面向对象角度来说还有2个问题:

首先: <code>obj.x</code>这种调用方式, 只是将表<code>obj</code>的属性<code>x</code>这个函数对象取出而已, 而在大多数面向对象语言中, 方法的实体位于类中, 而非单独的对象中. 在JavaScript等基于原型的语言中, 是以原型对象来代替类进行方法的搜索, 因此每个单独的对象也并不拥有方法实体. 在Lua中, 为了实现基于原型的方法搜索, 需要使用元表的<code>__index</code>事件:

如果我们有两个对象<code>a</code>和<code>b</code>,想让<code>b</code>作为<code>a</code>的prototype需要<code>setmetatable(a, {__index = b})</code>, 如下例: 为<code>obj</code>设置<code>__index</code>加上<code>proto</code>模板来创建另一个实例:

<code>proto</code>变成了原型对象, 当<code>obj</code>中不存在的属性被引用时, 就会去搜索<code>proto</code>.

其次: 通过方法搜索得到的函数对象只是单纯的函数, 而无法获得最初调用方法的表(接收器)相关信息. 于是, 过程和数据就发生了分离.JavaScript中, 关于接收器的信息可由关键字<code>this</code>获得, 而在Python中通过方法调用形式获得的并非单纯的函数对象, 而是一个“方法对象” –其接收器会在内部作为第一参数附在函数的调用过程中.

而Lua准备了支持方法调用的语法糖:<code>obj:x()</code>. 表示<code>obj.x(obj)</code>, 也就是: 通过冒号记法调用的函数, 其接收器会被作为第一参数添加进来(<code>obj</code>的求值只会进行一次, 即使有副作用也只生效一次).

Lua虽然能够进行面向对象编程, 但用元表来实现, 仿佛把对象剖开看到五脏六腑一样.

另存为prototype.lua, 使用时只需<code>require()</code>引入即可:

在传入到Redis的Lua脚本中可使用<code>redis.call()</code>/<code>redis.pcall()</code>函数调用Reids命令:

<code>redis.call()</code>返回值就是Reids命令的执行结果, Redis回复与Lua数据类型的对应关系如下:

Reids返回值类型

Lua数据类型

整数

数值

多行字符串

表(数组)

状态回复

表(只有一个<code>ok</code>字段存储状态信息)

错误回复

表(只有一个<code>err</code>字段存储错误信息)

注: Lua 的 <code>false</code> 会转化为空结果.

redis-cli提供了<code>EVAL</code>与<code>EVALSHA</code>命令执行Lua脚本:

EVAL

<code>EVAL script numkeys key [key ...] arg [arg ...]</code>

key和arg两类参数用于向脚本传递数据, 他们的值可在脚本中使用<code>KEYS</code>和<code>ARGV</code>两个table访问: <code>KEYS</code>表示要操作的键名, <code>ARGV</code>表示非键名参数(并非强制).

EVALSHA

<code>EVALSHA</code>命令允许通过脚本的SHA1来执行(节省带宽), Redis在执行<code>EVAL</code>/<code>SCRIPT LOAD</code>后会计算脚本SHA1缓存, <code>EVALSHA</code>根据SHA1取出缓存脚本执行.

为了在 Redis 服务器中执行 Lua 脚本, Redis 内嵌了一个 Lua 环境, 并对该环境进行了一系列修改, 从而确保满足 Redis 的需要. 其创建步骤如下:

创建基础 Lua 环境, 之后所有的修改都基于该环境进行;

载入函数库到 Lua 环境, 使 Lua 脚本可以使用这些函数库进行数据操作: 如基础库(删除了<code>loadfile()</code>函数)、Table、String、Math、Debug等标准库, 以及CJSON、 Struct(用于Lua值与C结构体转换)、 cmsgpack等扩展库(Redis 禁用Lua标准库中与文件或系统调用相关函数, 只允许对 Redis 数据处理).

创建全局表<code>redis</code>, 其包含了对 Redis 操作的函数, 如<code>redis.call()</code>、 <code>redis.pcall()</code> 等;

替换随机函数: 为了确保相同脚本可在不同机器上产生相同结果, Redis 要求所有传入服务器的 Lua 脚本, 以及 Lua 环境中的所有函数, 都必须是无副作用的纯函数, 因此Redis使用自制函数替换了 Math 库中原有的 <code>math.random()</code>和 <code>math.randomseed()</code> .

创建辅助排序函数: 对于 Lua 脚本来说, 另一个可能产生数据不一致的地方是那些带有不确定性质的命令(如: 由于<code>set</code>集合无序, 因此即使两个集合内元素相同, 其输出结果也并不一样), 这类命令包括SINTER、SUNION、SDIFF、SMEMBERS、HKEYS、HVALS、KEYS 等.

Redis 会创建一个辅助排序函数<code>__redis__compare_helper</code>, 当执行完以上命令后, Redis会调用<code>table.sort()</code>以<code>__redis__compare_helper</code>作为辅助函数对命令返回值排序.

创建错误处理函数: Redis创建一个 <code>__redis__err__handler</code> 错误处理函数, 当调用 <code>redis.pcall()</code> 执行 Redis 命令出错时, 该函数将打印异常详细信息.

Lua全局环境保护: 确保传入脚本内不会将额外的全局变量导入到 Lua 环境内.

小心: Redis 并未禁止用户修改已存在的全局变量.

完成Redis的<code>lua</code>属性与Lua环境的关联:

整个 Redis 服务器只需创建一个 Lua 环境.

Redis创建两个用于与Lua环境协作的组件: 伪客户端- 负责执行 Lua 脚本中的 Redis 命令, <code>lua_scripts</code>字典- 保存 Lua 脚本:

伪客户端

执行Reids命令必须有对应的客户端状态, 因此执行 Lua 脚本内的 Redis 命令必须为 Lua 环境专门创建一个伪客户端, 由该客户端处理 Lua 内所有命令: <code>redis.call()</code>/<code>redis.pcall()</code>执行一个Redis命令步骤如下:

<code>lua_scripts</code>字典

字典key为脚本 SHA1 校验和, value为 SHA1 对应脚本内容, 所有被<code>EVAL</code>和<code>SCRIPT LOAD</code>载入过的脚本都被记录到 <code>lua_scripts</code> 中, 便于实现 <code>SCRIPT EXISTS</code> 命令和脚本复制功能.

<code>EVAL</code>命令执行分为以下三个步骤:

定义Lua函数:

在 Lua 环境内定义 Lua函数 : 名为<code>f_</code>前缀+脚本 SHA1 校验和, 体为脚本内容本身. 优势:

执行脚本步骤简单, 调用函数即可;

函数的局部性可保持 Lua 环境清洁, 减少垃圾回收工作量, 且避免使用全局变量;

只要记住 SHA1 校验和, 即可在不知脚本内容的情况下, 直接调用 Lua 函数执行脚本(<code>EVALSHA</code>命令实现).

将脚本保存到<code>lua_scripts</code>字典;

执行脚本函数:

执行刚刚在定义的函数, 间接执行 Lua 脚本, 其准备和执行过程如下:

1). 将<code>EVAL</code>传入的键名和参数分别保存到<code>KEYS</code>和<code>ARGV</code>, 然后将这两个数组作为全局变量传入到Lua环境;

2). 为Lua环境装载超时处理<code>hook</code>(<code>handler</code>), 可在脚本出现运行超时时让通过<code>SCRIPT KILL</code>停止脚本, 或<code>SHUTDOWN</code>关闭Redis;

3). 执行脚本函数;

4). 移除超时<code>hook</code>;

5). 将执行结果保存到客户端输出缓冲区, 等待将结果返回客户端;

6). 对Lua环境执行垃圾回收.

对于会产生随机结果但无法排序的命令(如只产生一个元素, 如 SPOP、SRANDMEMBER、RANDOMKEY、TIME), Redis在这类命令执行后将脚本状态置为<code>lua_random_dirty</code>, 此后只允许脚本调用只读命令, 不允许修改数据库值.

锁申请

首先尝试加锁:

成功则为锁设定过期时间; 返回;

失败检测锁是否添加了过期时间;

wait.

锁释放

检查当前线程是否真的持有了该锁:

持有: 则释放; 返回成功;

失败: 返回失败.

Lua脚本: acquire

Lua脚本: release

Pre工具: 脚本执行器

Client

<dl></dl>

<dt>参考 &amp; 推荐</dt>