Redis的Lua脚本编程的实现和应用

作者: 志华_C | 来源:发表于2017-09-10 20:34 被阅读0次

    [TOC]

    相关命令

    1. EVAL
    2. SCRIPT_LOAD
    3. EVALSHA(执行之前要求执行过EVAL或者SCRIPT_LOAD)
    4. SCRIPT EXISTS
    5. SCRIPT FLUSH(慎用)
    6. SCRIPT KILL(LUA的写操作务必谨慎,一旦有写入这个将失效效)

    简介

    • Redis 服务器在启动时, 会对内嵌的 Lua 环境执行一系列修改操作, 从而确保内嵌的 Lua 环境可以满足 Redis 在功能性、安全性等方面的需要。
    • Redis 服务器专门使用一个伪客户端来执行 Lua 脚本中包含的 Redis 命令。
    • Redis 使用脚本字典来保存所有被 EVAL 命令执行过, 或者被 SCRIPT_LOAD 命令载入过的 Lua 脚本, 这些脚本可以用于实现 SCRIPT_EXISTS 命令, 以及实现脚本复制功能。
    • EVAL 命令为客户端输入的脚本在 Lua 环境中定义一个函数, 并通过调用这个函数来执行脚本。
    • EVALSHA 命令通过直接调用 Lua 环境中已定义的函数来执行脚本。
    • SCRIPT_FLUSH 命令会清空服务器 lua_scripts 字典中保存的脚本, 并重置 Lua 环境。
    • SCRIPT_EXISTS 命令接受一个或多个 SHA1 校验和为参数, 并通过检查 lua_scripts 字典来确认校验和对应的脚本是否存在。
    • SCRIPT_LOAD 命令接受一个 Lua 脚本为参数, 为该脚本在 Lua 环境中创建函数, 并将脚本保存到 lua_scripts 字典中。
    • 服务器在执行脚本之前, 会为 Lua 环境设置一个超时处理钩子, 当脚本出现超时运行情况时, 客户端可以通过向服务器发送 SCRIPT_KILL 命令来让钩子停止正在执行的脚本, 或者发送 SHUTDOWN nosave 命令来让钩子关闭整个服务器。
    • 主服务器复制 EVAL 、 SCRIPT_FLUSH 、 SCRIPT_LOAD 三个命令的方法和复制普通 Redis 命令一样 —— 只要将相同的命令传播给从服务器就可以了。
    • 主服务器在复制 EVALSHA 命令时, 必须确保所有从服务器都已经载入了 EVALSHA 命令指定的 SHA1 校验和所对应的 Lua 脚本, 如果不能确保这一点的话, 主服务器会将 EVALSHA 命令转换成等效的 EVAL 命令, 并通过传播 EVAL 命令来获得相同的脚本执行效果。

    启动过程

    1. 创建并修改Lua环境
      1. 创建Lua环境-生成基本的Lua环境,接下来对Lua环境做进一步的修改

      2. 载入函数库

        1. 基础库
        2. 表格库:table library
        3. 字符串库:string.find、string.format、string.len、string.reverse
        4. 数学库
        5. 调试库
        6. Lua CJSON:用于处理UTF-8编码的JSON格式,其中方法 cjson.decode、cjson.encode
        7. Struct库:和c交互的库
        8. Lua cmsgpack库:用于处理MessagePack格式的数据,其中cmsgpack.pack行数将Lua值转换为MessagePack数据,而cmsgpack.unpack函数则将MessagePack数据转换为Lua值
      3. 创建redis全局表格

        1. 创建redis表格(table),并将它设置为全局变量
        2. redis.call、redis.pcall、redis.log、redis.sha1hex(计算sha1校验和)
        3. 用于返回错误信息的:redis.error_reply、redis.status_reply
      4. 修改可能产生不一致数据的命令和方法:保证脚本在不同机器上产生相同的结果,Redis要求所有传入服务器的Lua脚本,以及Lua环境中的的所有函数都是无副作用的纯函数。

        1. 替换Lua原有的随机函数
        2. 创建排序辅助函数
      5. 创建redis.pcall函数的错误报告辅助函数

      6. 保护Lua的全局环境

        1. 当脚本创建一个全局变量时,服务器会报告一个错误(保证不会因为忘记使用local关键字而将二外的全局变量添加到lua环境里面)
        2. 读取一个不存在的全局变量也会报错
        3. Redis没有禁止在脚本里修改全局变量,所以在执行Lua脚本的时候,必须小心防止错误修改已存在的全局变量
      7. 将Lua环境保存到服务器状态的lua属性里

        1. 因为Redis使用串行化的方式执行命令,所以在任何特定时间里,最多只会有一个脚本能够被放进Lua环境里面执行,因此整个Redis服务器只需要创建一个Lua环境即可
    2. 创建环境协作组件
      1. redis 伪客户端:伪客户端一直存在直到服务器关闭,执行命令的过程:
        1. image
      2. 保存传入服务器的Lua脚本的脚本字典:实现SCRIPT EXISTS 命令、实现脚本复制
        1. image

    Redis Lua 的特点和注意事项

    1. 特点

    2. 注意事项

    1. Lua脚本的bug特别可怕,由于Redis的单线程特点,一旦Lua脚本出现不会返回(不是返回值)得问题,那么这个脚本就会阻塞整个redis实例。
    2. Lua脚本应该尽量短小实现关键步骤即可。(原因同上)
    3. Lua脚本中不应该出现常量Key,这样会导致每次执行时都会在脚本字典中新建一个条目,应该使用全局变量数组KEYS和ARGV
    4. KEYS和ARGV的索引都从1开始
    5. 传递给lua脚本的的键和参数:传递给lua脚本的键列表应该包括可能会读取或者写入的所有键。传入全部的键使得在使用各种分片或者集群技术时,其他软件可以在应用层检查所有的数据是不是都在同一个分片里面。另外集群版redis也会对将要访问的key进行检查,如果不在同一个服务器里面,那么redis将会返回一个错误。(决定使用集群版之前应该考虑业务拆分),参数列表无所谓。。
    6. lua脚本跟单个redis命令和事务段一样都是原子的
    7. 已经进行了数据写入的lua脚本将无法中断,只能使用SHUTDOWN NOSAVE杀死Redis服务器,所以lua脚本一定要测试好。

    典型应用

    1.分布式全局锁(distlock)

    Yii2下的实现:

    
    <?php
    namespace yii\redis;
    
    use Yii;
    use yii\base\InvalidConfigException;
    use yii\di\Instance;
    //使用了Yii2互斥锁接口
    class Mutex extends \yii\mutex\Mutex
    {
        //锁过期时间,秒
        public $expire = 30;
        public $keyPrefix;
        public $redis = 'redis';
        private $_lockValues = [];
    
        public function init()
        {
            parent::init();
            $this->redis = Instance::ensure($this->redis, Connection::className());
            if ($this->keyPrefix === null) {
                $this->keyPrefix = substr(md5(Yii::$app->id), 0, 5);
            }
        }
    
        protected function acquireLock($name, $timeout = 0)
        {
            $key = $this->calculateKey($name);
            $value = Yii::$app->security->generateRandomString(20);
            $waitTime = 0;
            //使用setnx(理解为多机版sem_acquire)命令获取锁并自动重试(这个锁支持获取超时和自动过期)
            while (!$this->redis->executeCommand('SET', [$key, $value, 'NX', 'PX', (int) ($this->expire * 1000)])) {
                $waitTime++;
                //超时则直接返回获取失败
                if ($waitTime > $timeout) {
                    return false;
                }
                sleep(1);
            }
            $this->_lockValues[$name] = $value;
            return true;
        }
    
        protected function releaseLock($name)
        {
            //使用脚本最优化性能,如果不用脚本则需要使用事务段
            static $releaseLuaScript = <<<LUA
    if redis.call("GET",KEYS[1])==ARGV[1] then
        return redis.call("DEL",KEYS[1])
    else
        return 0
    end
    LUA;
            if (!isset($this->_lockValues[$name]) || !$this->redis->executeCommand('EVAL', [
                    $releaseLuaScript,
                    1,
                    $this->calculateKey($name),
                    $this->_lockValues[$name]
                ])) {
                return false;
            } else {
                unset($this->_lockValues[$name]);
                return true;
            }
        }
    
        protected function calculateKey($name)
        {
            return $this->keyPrefix . md5(json_encode([__CLASS__, $name]));
        }
    }
    
    

    分析:

    这个实现可以保证锁的互斥性(避免多个客户端同时获取锁)和超时性(避免资源一直处于锁定状态)

    SET resource_name my_random_value NX PX 30000
    

    这个命令仅在不存在key的时候才能被执行成功(NX选项),并且这个key有一个30秒的自动失效时间(PX属性)。这个key的值是“my_random_value”(一个随机值),这个值在所有的客户端必须是唯一的,所有同一key的获取者(竞争者)这个值都不能一样(保证释放资源的正确性)。

    但是这个例子是在支持故障转移的主从结构中会存在竞态,下边是redis官方推荐的一个分布式锁算法RedLock,官方版分布式式锁算法实现

    2.计数器信号量(counter semaphore)

    几乎器也是一种锁,通常用于限制一项资源最多能够同时被多少个进程访问。

    计数器信号量实现的功能(使用有序集合和时间戳分数处理计数器)

    • acquire
    /*
    ** KEYS[1] 信号量键
    ** ARGV[1] 最小有效分数
    ** ARGV[2] 信号量最大计数值
    ** ARGV[3] 当前时间戳
    ** ARGV[4] 客户端uniqueId
    */
        static $acquireLuaScript = <<<LUA
    --移除全部过期信号量
    redis.call('zremrangebyscore', KEYS[1], '-inf', ARGV[1])
    
    if redis.call('zcard', KEYS[1]) < tonumber(ARGV[2]) then
        redis.call('zadd', KEYS[1], ARGV[3], ARGV[4])
        return ARGV[4]
    end
    LUA;
    
    • reaease
    zrem(key, clientId)
    
    • refresh(有时需要)
    /*
    ** KEYS[1] 信号量键
    ** ARGV[1] 客户端uniqueId
    ** ARGV[2] 当前时间戳
    */
    static $refreshLuaScript = <<<LUA
    --如果信号量仍然存在,那么对它的时间戳进行更新(通过zscore判断key存在与否)
    if redis.call('zscore', KEYS[1], ARGV[1]) then
        return redis.call('zadd', KEYS[1], ARGV[2], ARGV[1]) or true
    end
    LUA;
    

    3.改造事务段

    4.对已有结构进行分片,用来压缩占用空间

    原文链接

    相关文章

      网友评论

        本文标题:Redis的Lua脚本编程的实现和应用

        本文链接:https://www.haomeiwen.com/subject/ktmtsxtx.html