Lua 脚本功能是 Reids 2.6 版本的最大亮点, 通过内嵌对 Lua 环境的支持, Redis 解决了长久以来不能高效地处理 CAS (check-and-set)命令的缺点, 并且可以通过组合使用多个命令, 轻松实现以前很难实现或者不能高效实现的模式。本文将介绍如何使用 Lua重写锁,和重写之前与重写之后的性能对比。前期准备
本文使用的是 Python Redis 客户端,为了防止客户端并未为 Redis2.6 提供直接载入或者执行Lua 脚本的功能,所以我们需要花费一点时间来创建一个脚本载入程序。
将脚本载入 Redis,需要用到一个名为 SCRIPT LOAD 的命令,这个命令接受一个字符串格式的Lua 脚本为参数,它会把脚本存储起来等待之后使用,然后返回被存储脚本的 SHA1 校验和。之后,用户只要调用 EVALSHA 命令,并输入脚本的 SHA1 校验和以及脚本所需的全部参数,就可以调用之前存储的脚本。
将脚本载入 Redis 的 script_load 函数
1、将 SCRIPT LOAD 命令返回的已载入脚本的 SHA1 校验和存储到一个列表里面,以便之后在 call()函数内部对其进行修改。
2、在调用已载入脚本的时候,用户需要将 Redis 连接、脚本要处理的键以及脚本的其他参数传递给脚本。
3、程序只会在 SHA1 校验和未被缓存的情况下尝试载入脚本。
4、使用以缓存的 SHA1 校验和执行命令。
5、如果错误与脚本缺失无关,那么重新抛出异常。
6、当程序接收到脚本错误时,或者程序需要强制执行脚本时,它会使用 EVAL 命令直接执行给定的脚本。EVAL 命令在执行完脚本之后,会自动把脚本缓存起来,而缓存产生的 SHA1 校验和跟使用 EVALSHA 命令缓存脚本产生的 SHA1 校验和是完全相同的。
7、返回一个函数,这个函数在被调用的时候会自动载入并执行脚本。
除了调用 SCRIPT LOAD 命令和 EVALSHA 命令之外,script_load()函数还会捕捉一个异常,当函数缓存了某个脚本的 SHA1 校验和,但是服务器却并没有存储这个 SHA1 校验和对应的脚本时,异常就会被抛出。
在服务器重启之后,或者用户执行了 SCRIPT FLUSH 命令,清空脚本缓存之后,又或者程序在不同的时间给函数提供了指向不同 Redis 服务器的连接时,这个异常都会出现。
当函数检测到脚本缺失的时候,它就会使用 EVAL 命令直接执行脚本,而 EVAL 命令,除了会执行脚本之外,还会将被执行的脚本缓存到 Redis 服务器里面。
除此之外,script_load()函数还允许用户通过 force_eval 参数来直接执行脚本,当我们需要在事务或者流水线里面执行脚本的时候,这个功能就会非常有用。为什么要重写锁
第一个原因:
可以将 CAS 操作变为一个原子操作。这样做的主要目的是为了让 Redis 的集群服务器可以拒绝那些尝试在指定的分片上面,对不可用的键进行读取或者写入的脚本。
第二个原因:
减少网络通信次数。在处理 Redis 存储的数据时,程序可能需要一些数据,但这些数据没办法再最开始的调用中取得。其中的一个例子就是,从 Redis 获取一些散列值,然后使用这些值去访问存储在关系型数据库里面的信息,最后再把这些信息写入 Redis 里面。
基于以上这两个原因,我们需要使用Lua 脚本重写锁实现。重写锁实现
加锁操作首先生成一个 ID,然后使用 SETNX 命令对键进行了有条件的设置操作,并在设置操作执行成功的时候,为键设置了过期时间。尽管加锁操作在概念上并不复杂,但程序还是需要处理各种失败和重试情况。
原版代码如下:
重写之前加锁实现源代码
1、128 位随机标识符。
2、确保传给 EXPIRE 的都是整数。
3、获取锁并设置过期时间。
4、检查过期时间,并在有需要时对其进行更新。
使用 Lua 重写之后的代码:
重写之后加锁实现源代码
1、执行实际的锁获取操作,通过检查确保 Lua 调用已经执行成功。
2、检测锁是否已经存在。(提醒,Lua 表格的索引是从 1 开始的。)
3、使用给定的过期时间以及标识符去设置键。
除了将之前的 SETNX 命令和 EXPIRE 命令替换成 SETEX 命令,从而确保客户端获取的锁总是具有过期时间之外,Lua 脚本实现的加锁操作跟原来的加锁操作之间并无明显的不同。
接下来让我们乘胜前进,继续使用Lua 脚本重写锁的释放操作。
锁释放操作首先要做的就是使用 WATCH 去监视代表锁的键,检查该键是否仍然存储着加锁时设置的标识符。如果是的话,程序就解除锁;如果不是的话,程序就说指定的锁已经丢失。
使用 Lua 重写的 release_lock 函数
1、调用负责释放锁的 Lua 函数。
2、检查锁是否匹配。
3、删除锁并确保脚本总是返回真值。
跟加锁操作不同,Lua 版本的锁释放操作比原版更为简洁,因为程序无需再执行典型的 WATCH/MULTI/EXEC 步骤。
虽然减少代码量是一件非常好的事情,但是如果 Lua 版本的锁实现不能带来实际的性能提升,那么它的作用将是非常有限的。
为了测试原版锁实现和 Lua 锁实现之间的性能差异,我们给这两种锁实现的代码增加了一些指令,并通过测试代码分别执行 1 个、2 个、5 个和 10 个并行的进程,让这些进程反复不断的对锁执行获取操作和释放操作,然后记录两个版本的锁实现在十秒内执行锁获取操作的次数以及成功取得锁的次数。结果如图所示:
原版锁实现和 Lua 版本的锁实现在 10 秒内的性能对比
通过观察表中右边那一栏可以看到,在测试循环里面,Lua 版本的锁实现在获取锁和释放锁方面的表现,要明显优于原版锁实现:在使用单个客户端的情况下,Lua 锁的性能要高 40%多;在使用两个客户端的情况下,Lua 锁的性能要高 87%;而在使用五个或者十个客户端的情况下,Lua 锁的性能要高一倍以上。
通过对比中间栏和右边栏,我们还可以看到,由于 Lua 版本的锁实现,减少了加锁时所需的通信往返次数,所以 Lua 版本的锁实现在尝试获取锁时的速度比原版的锁要快得多。
除了性能变得更好之外,Lua 版本的加锁操作和锁释放操作的代码也明显的变得更容易理解了,这使得我们可以很容易的验证这些代码的正确性。总结
使用Lua 脚本可以极大地提高性能,并对需要执行的操作进行大幅的简化。大家可以多尝试一下。本文作者长期致力于互联网技术研究,擅长互联网相关知识包括高并发、大数据、架构、前后端语言、框架、算法、常见面试题等,欢迎关注。