专栏名称: 数据分析与开发
伯乐在线旗下账号,分享数据库相关技术文章、教程和工具,另外还包括数据库相关的工作。偶尔也谈谈程序员人生 :)
相关文章推荐
数据分析与开发  ·  代码界的“瘟疫”?卡帕西“Vibe ... ·  2 天前  
数据分析与开发  ·  北京大学出的第四份 DeepSeek ... ·  2 天前  
数据中心运维管理  ·  AI数据中心 VS 普通数据中心,有什么不同? ·  2 天前  
非法加冯  ·  HTAP数据库,一场无人鼓掌的演出 ·  2 天前  
非法加冯  ·  HTAP数据库,一场无人鼓掌的演出 ·  2 天前  
数据分析与开发  ·  55 ... ·  2 天前  
51好读  ›  专栏  ›  数据分析与开发

Lua 语言模型与 Redis 应用

数据分析与开发  · 公众号  · 数据库  · 2016-10-10 20:29

正文

(点击 上方公众号 ,可快速关注)


来源:朱吉芳

链接:blog.csdn.net/zjf280441589/article/details/52716720


从 2.6 版本起,Redis 开始支持 Lua 脚本,可以让开发者自己扩展 Redis。本文主要介绍了 Lua 语言不一样的设计模型(相比于Java/C/C++、JS、PHP),以及 Redis 对 Lua 的扩展,最后结合 Lua 与 Redis 实现了一个支持过期时间的分布式锁。希望读者可以在读完后, 体会到 Lua 这门语言不一样的设计哲学,可以更加得心应手地使用/扩展 Redis。



案例-实现访问频率限制: 实现访问者 $ip 在一定的时间 $time 内只能访问 $limit 次.


非脚本实现




以上代码有两点缺陷


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


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

    Lua脚本实现


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


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




    Java


    private boolean accessLimit ( String ip , int limit , int timeout , Jedis connection ) throws IOException {

    List String > keys = Collections . singletonList ( ip );

    List String > argv = Arrays . asList ( String . valueOf ( limit ), String . valueOf ( timeout ));

    return 1 == ( long ) connection . eval ( loadScriptString ( "script.lua" ), keys , argv );

    }

    // 加载Lua代码

    private String loadScriptString ( String fileName ) throws IOException {

    Reader reader = new InputStreamReader ( Client . class . getClassLoader (). getResourceAsStream ( fileName ));

    return CharStreams . toString ( reader );

    }


    Lua 嵌入 Redis 优势:


      1. 减少网络开销: 不使用 Lua 的代码需要向 Redis 发送多次请求, 而脚本只需一次即可, 减少网络传输;


      2. 原子操作: Redis 将整个脚本作为一个原子执行, 无需担心并发, 也就无需事务;


      3. 复用: 脚本会永久保存 Redis 中, 其他客户端可继续使用.


      Lua语言模型


      Lua是一种 便于嵌入应用程序 的脚本语言, 具备了作为通用脚本语言的所有功能. 其高速虚拟机实现非常有名(Lua的垃圾回收很有讲究- 增量垃圾回收 ), 在很多虚拟机系性能评分中都取得了优异的成绩. Home lua.org.


      以嵌入式为方针设计的Lua, 在默认状态下简洁得吓人. 除了基本的数据类型外, 其他一概没有. 标注库也就 Coroutine、String、Table、Math、 I/O、OS, 再加上Modules包加载而已.


      注: 本文仅介绍 Lua 与众不同的设计模型(对比 Java/C/C++、JavaScript、Python 与Go), 语言细节可参考文内和附录推荐的文章以及Lua之父Roberto Ierusalimschy的《Programming in Lua》(中文版: LUA程序设计(第2版)>)


      基础


      1. 数据类型


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


        • 数值型:
          全部为浮点数型, 没有整型;
          只有 nil 和 false 作为布尔值的 false , 数字 0 和空串(‘’/‘’)都是 true;

        • 字符串

        • 用户自定义类型

        • 函数(function)

        • 表(table)


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


      2. 关键字



      3. 操作符



      • Tips:


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

        • 连接 .. 自动将数值转换成字符串;

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


      函数(function)


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


      • 使用方式类似JavaScript:


      -- 全局函数 : 求阶乘

      function fact ( n )

      if n == 1 then

      return 1

      else

      return n * fact ( n - 1 )

      end

      end

      -- 1. 赋给变量

      local func = fact

      print ( "func type: " .. type ( func ), "fact type: " .. type ( fact ), "result: " .. func ( 4 ))

      -- 2. 闭包

      local function new_counter ()

      local value = 0 ;

      return function ()

      value = value + 1

      return value

      end

      end

      local counter = new_counter ()

      print ( counter (), counter (), counter ())

      -- 3. 返回值类似 Go / Python

      local random_func = function ( param )

      return 9 , 'a' , true , "ƒ∂π" , param

      end

      local var1 , var2 , var3 , var4 , var5 = random_func ( "no param is nil" )

      print ( var1 , var2 , var3 , var4 , var5 )

      -- 4. 变数形参

      local function square (...)

      local argv = { ... }

      for i = 1 , #argv do

      argv [ i ] = argv [ i ] * argv [ i ]

      end

      return table . unpack ( argv )

      end

      print ( square ( 1 , 2 , 3 ))


      表(table)


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


      -- array

      local array = { 1 , 2 , 3 }

      print ( array [ 1 ], #array)

      -- hash

      local hash = { x = 1 , y = 2 , z = 3 }

      print ( hash . x , hash [ 'y' ], hash [ "z" ], #hash)

      -- array & hash

      array [ 'x' ] = 8

      print ( array . x , #array)


      Tips:


      • 数组索引从1开始;


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


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


      -- pairs 会遍历所有值不为 nil 的索引 , 与此类似的 ipairs 只会从索引 1 开始递遍历到最后一个值不为 nil 的整数索引 .

      for k , v in pairs ( _G ) do

      print ( k , " -> " , v , " type: " .. type ( v ))

      end


      用Hash实现对象的还有JavaScript, 将数组和Hash合二为一的还有PHP.


      元表


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



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


      MetaMethods


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




      MetaTables 与 面向对象


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


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


      local function gettable_event ( t , key )

      local h

      if type ( t ) == "table" then

      local value = rawget ( t , key )

      if value ~= nil then

      return value

      end

      h = getmetatable ( t ). __index

      if h == nil then

      return nil

      end

      else

      h = getmetatable ( t ). __index

      if h == nil then

      error ( "error" )

      end

      end

      if type ( h ) == "function" then

      -- call the handler

      return ( h ( t , key ))

      else

      -- or repeat opration on it

      return h [ key ]

      end

      end

      -- 测试

      obj = { 1 , 2 , 3 }

      op = {

      x = function ()

      return "xx"

      end

      }

      setmetatable ( obj , { __index = op [ 'x' ] })

      print ( gettable_event ( obj , x ))


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


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


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


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


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


        方法调用的实现


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


        -- obj 取键为 x 的值 , 将之视为 function 进行调用

        obj . x ( foo )


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


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


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


        proto = {

        x = function ()

        print ( "x" )

        end

        }

        local obj = {}

        setmetatable ( obj , { __index = proto })

        obj . x ()


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


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


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


        -- 这个语法糖对定义也有效

        function proto : y ( param )

        print ( self , param )

        end

        - Tips : 用冒号记法定义的方法 , 调用时最好也用冒号记法 , 避免参数错乱

        obj : y ( "parameter" )


        基于原型的编程


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


        《代码的未来》中松本行弘老师向我们展示了一个基于原型编程的Lua库, 通过该库, 即使没有深入解Lua原始机制, 也可以实现面向对象:


        --

        -- Author : Matz

        -- Date : 16 / 9 / 24

        -- Time : 下午 5 : 13

        --

        -- Object 为所有对象的上级

        Object = {}

        -- 创建现有对象副本

        function Object : clone ()

        local object = {}

        -- 复制表元素

        for k , v in pairs ( self ) do

        object [ k ] = v

        end

        -- 设定元表 : 指定向自身 ` 转发 `

        setmetatable ( object , { __index = self })

        return object

        end

        -- 基于类的编程

        function Object : new (...)

        local object = {}

        -- 设定元表 : 指定向自身 ` 转发 `

        setmetatable ( object , { __index = self })

        -- 初始化

        object : init (...)

        return object

        end

        -- 初始化实例

        function Object : init (...)

        -- 默认不进行任何操作

        end

        Class = Object : new ()


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


        require ( "prototype" )

        -- Point 类定义

        Point = Class : new ()

        function Point : init ( x , y )

        self . x = x

        self . y = y

        end

        function Point : magnitude ()

        return math . sqrt ( self . x ^ 2 + self . y ^ 2 )

        end

        -- 对象定义

        point = Point : new ( 3 , 4 )

        print ( point : magnitude ())

        -- 继承 : Point3D 定义

        Point3D = Point : clone ()

        function Point3D : init ( x , y , z )

        self . x = x

        self . y = y

        self . z = z

        end

        function Point3D : magnitude ()

        return math . sqrt ( self . x ^ 2 + self . y ^ 2 + self . z ^ 2 )

        end

        p3 = Point3D : new ( 1 , 2 , 3 )

        print ( p3 : magnitude ())

        -- 创建 p3 副本

        ap3 = p3 : clone ()

        print ( ap3 . x , ap3 . y , ap3 . z )


        Redis – Lua


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


        redis . call ( "set" , "foo" , "bar" )

        local value = redis . call ( "get" , "foo" )


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



        Reids返回值类型 Lua数据类型
        整数 数值
        字符串 字符串
        多行字符串 表(数组)
        状态回复 表(只有一个ok字段存储状态信息)
        错误回复 表(只有一个err字段存储错误信息)


        注: Lua 的 false 会转化为空结果.


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


        • EVAL


          EVAL script numkeys key [key ...] arg [arg ...]


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


        • EVALSHA


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


        创建Lua环境


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


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


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


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


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


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


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


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


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


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


        • 完成Redis的lua属性与Lua环境的关联:



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

        Lua环境协作组件


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


          • 伪客户端


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



          • lua_scripts字典


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


        EVAL命令原理


        EVAL命令执行分为以下三个步骤:


        1. 定义Lua函数:


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


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


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


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


      • 将脚本保存到lua_scripts字典;


      • 执行脚本函数:


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


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


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



        3). 执行脚本函数;


        4). 移除超时hook;


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


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


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


        实践


        使用Lua脚本重新构建带有过期时间的分布式锁.


        案例来源: Redis实战> 第6、11章, 构建步骤:


        • 锁申请


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

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

          • 首先尝试加锁:

          • wait.


        • 锁释放


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

          • 失败: 返回失败.

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


        非Lua实现


        String acquireLockWithTimeOut ( Jedis connection , String lockName , long acquireTimeOut , int lockTimeOut ) {

        String identifier = UUID . randomUUID (). toString ();

        String key = "lock:" + lockName ;

        long acquireTimeEnd = System . currentTimeMillis () + acquireTimeOut ;

        while ( System . currentTimeMillis () acquireTimeEnd ) {

        // 获取锁并设置过期时间

        if ( connection . setnx ( key , identifier ) != 0 ) {

        connection . expire ( key , lockTimeOut );

        return identifier ;

        }

        // 检查过期时间, 并在必要时对其更新

        else if ( connection . ttl ( key ) == - 1 ) {

        connection . expire ( key , lockTimeOut );

        }

        try {

        Thread . sleep ( 10 );

        } catch ( InterruptedException ignored ) {

        }

        }

        return null ;

        }

        boolean releaseLock ( Jedis connection , String lockName , String identifier ) {

        String key = "lock:" + lockName ;

        connection . watch ( key );

        // 确保当前线程还持有锁

        if ( identifier . equals ( connection . get ( key ))) {

        Transaction transaction = connection . multi ();

        transaction . del ( key );

        return transaction . exec (). isEmpty ();

        }

        connection . unwatch ();

        return false ;

        }


        Lua脚本实现


        Lua脚本: acquire


        local key = KEYS [ 1 ]

        local identifier = ARGV [ 1 ]

        local lockTimeOut = ARGV [ 2 ]

        -- 锁定成功

        if redis . call ( "SETNX" , key , identifier ) == 1 then

        redis .







请到「今天看啥」查看全文