Lua語言模型 與 Redis應用

參考:https://blog.csdn.net/zjf280441589/article/details/52716720javascript

從 2.6版本 起, Redis 開始支持 Lua 腳本 讓開發者本身擴展 Redis. 
本篇博客主要介紹了 Lua 語言不同的設計模型(相比於Java/C/C++、JS、PHP), 以及 Redis 對 Lua 的擴展, 最後結合 Lua 與 Redis 實現了一個支持過時時間的分佈式鎖. 咱們但願這篇博客的讀者朋友能夠在讀完這篇文字以後, 體會到 Lua 這門語言不同的設計哲學, 以及 更加駕輕就熟的使用/擴展 Redis.php

案例-實現訪問頻率限制: 實現訪問者 $ip 在必定的時間 $time 內只能訪問 $limit 次.
  • 非腳本實現
private boolean accessLimit(String ip, int limit, int time, Jedis jedis) { boolean result = true; String key = "rate.limit:" + ip; if (jedis.exists(key)) { long afterValue = jedis.incr(key); if (afterValue > limit) { result = false; } } else { Transaction transaction = jedis.multi(); transaction.incr(key); transaction.expire(key, time); transaction.exec(); }  return result; }
  • 以上代碼有兩點缺陷 
    1. 可能會出現競態條件: 解決方法是用 WATCH 監控 rate.limit:$IP 的變更, 但較爲麻煩;
    2. 以上代碼在不使用 pipeline 的狀況下最多須要向Redis請求5條指令, 傳輸過多.

  • Lua腳本實現 
    Redis 容許將 Lua 腳本傳到 Redis 服務器中執行, 腳本內能夠調用大部分 Redis 命令, 且 Redis 保證腳本的原子性:html

    • 首先須要準備Lua代碼: script.lua
local key = "rate.limit:" .. KEYS[1] local limit = tonumber(ARGV[1]) local expire_time = ARGV[2] local is_exists = redis.call("EXISTS", key) if is_exists == 1 then if redis.call("INCR", key) > limit then return 0 else return 1 end else redis.call("SET", key, 1) redis.call("EXPIRE", key, expire_time) return 1 end
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 中, 其餘客戶端可繼續使用.

redisson實現分佈式鎖的原理:java

參考:https://www.jianshu.com/p/de5a69622e49git

Lua語言模型

Lua是一種 便於嵌入應用程序 的腳本語言, 具有了做爲通用腳本語言的全部功能. 其高速虛擬機實現很是有名(Lua的垃圾回收頗有講究- 增量垃圾回收 ), 在不少虛擬機系性能評分中都取得了優異的成績. Home lua.org
此處輸入圖片的描述github

以嵌入式爲方針設計的Lua, 在默認狀態下簡潔得嚇人. 除了基本的數據類型外, 其餘一律沒有. 標註庫也就 CoroutineStringTableMath、 I/OOS, 再加上Modules包加載而已. 參考: Lua 5.1 Reference Manual - Standard Libraries(中文版: Lua 5.1 參考手冊).redis

注: 本文僅介紹 Lua 不同凡響的設計模型(對比 Java/C/C++、JavaScript、Python 與 Go), 語言細節可參考文內和附錄推薦的文章以及Lua之父Roberto Ierusalimschy的<Programming in Lua>(中文版: <LUA程序設計(第2版)>)sql


基礎

1. 數據類型

  • 做爲通用腳本語言, Lua的數據類型以下: 
    • 數值型: 
      所有爲浮點數型, 沒有整型; 
      只有 nil 和 false 做爲布爾值的 false , 數字 0 和空串(‘’/‘\0’)都是 true;
    • 字符串
    • 用戶自定義類型
    • 函數(function)
    • 表(table)

變量若是沒有特殊說明爲全局變量(那怕是語句塊 or 函數內), 局部變量前需加local關鍵字.shell


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.


元表

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__; 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, 咱們可經過getmetatable()來獲取任一值的metatable, 也可經過setmetatable()來替換table的metatable. Lua 事件一覽表: 

對於這些操做, Lua 都將其關聯到 metatable 的事件Key, 當 Lua 須要對一個值發起這些操做時, 首先會去檢查其metatable中是否有對應的事件Key, 若是有則調用之以控制Lua解釋器做出響應.


MetaMethods

MetaMethods主要用做一些相似C++中的運算符重載操做, 如重載+運算符:

local frac_a = { numerator = 2, denominator = 3 } local frac_b = { numerator = 4, denominator = 8 } local operator = { __add = function(f1, f2) local ret = {} ret.numerator = f1.numerator * f2.denominator + f1.denominator * f2.numerator ret.denominator = f1.denominator * f2.denominator return ret end, __tostring = function(self) return "{ " .. self.numerator .. " ," .. self.denominator .. " }" end } setmetatable(frac_a, operator) setmetatable(frac_b, operator) local frac_res = frac_a + frac_b setmetatable(frac_res, operator) -- 使tostring()方法生效 print(tostring(frac_res))
  • 關於更多Lua事件處理可參考文檔: Metamethods.

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事件: 
    若是咱們有兩個對象ab,想讓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")
  • 1
  • 2

redis.call()返回值就是Reids命令的執行結果, Redis回覆與Lua數據類型的對應關係以下:

Reids返回值類型 Lua數據類型
整數 數值
字符串 字符串
多行字符串 表(數組)
狀態回覆 表(只有一個ok字段存儲狀態信息)
錯誤回覆 表(只有一個err字段存儲錯誤信息)

注: Lua 的 false 會轉化爲空結果.

redis-cli提供了EVALEVALSHA命令執行Lua腳本:

  • EVAL 
    EVAL script numkeys key [key ...] arg [arg ...] 
    keyarg兩類參數用於向腳本傳遞數據, 他們的值可在腳本中使用KEYSARGV兩個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集合無序, 所以即便兩個集合內元素相同, 其輸出結果也並不同), 這類命令包括SINTERSUNIONSDIFFSMEMBERSHKEYSHVALSKEYS 等. 
    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 對應腳本內容, 全部被EVALSCRIPT LOAD載入過的腳本都被記錄到 lua_scripts 中, 便於實現 SCRIPT EXISTS 命令和腳本複製功能.

EVAL命令原理

EVAL命令執行分爲如下三個步驟:

  1. 定義Lua函數: 
    在 Lua 環境內定義 Lua函數 : 名爲f_前綴+腳本 SHA1 校驗和, 體爲腳本內容自己. 優點:

    • 執行腳本步驟簡單, 調用函數便可;
    • 函數的局部性可保持 Lua 環境清潔, 減小垃圾回收工做量, 且避免使用全局變量;
    • 只要記住 SHA1 校驗和, 便可在不知腳本內容的狀況下, 直接調用 Lua 函數執行腳本(EVALSHA命令實現).
  2. 將腳本保存到lua_scripts字典;

  3. 執行腳本函數: 
    執行剛剛在定義的函數, 間接執行 Lua 腳本, 其準備和執行過程以下: 
    1). 將EVAL傳入的鍵名和參數分別保存到KEYSARGV, 而後將這兩個數組做爲全局變量傳入到Lua環境; 
    2). 爲Lua環境裝載超時處理hook(handler), 可在腳本出現運行超時時讓經過SCRIPT KILL中止腳本, 或SHUTDOWN關閉Redis; 
    3). 執行腳本函數; 
    4). 移除超時hook
    5). 將執行結果保存到客戶端輸出緩衝區, 等待將結果返回客戶端; 
    6). 對Lua環境執行垃圾回收.

對於會產生隨機結果但沒法排序的命令(如只產生一個元素, 如 SPOPSRANDMEMBERRANDOMKEYTIME), Redis在這類命令執行後將腳本狀態置爲lua_random_dirty, 此後只容許腳本調用只讀命令, 不容許修改數據庫值.


實踐

使用Lua腳本從新構建帶有過時時間的分佈式鎖.
  • 1
  • 2

案例來源: <Redis實戰> 第六、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.call("EXPIRE", key, lockTimeOut) return 1 elseif redis.call("TTL", key) == -1 then redis.call("EXPIRE", key, lockTimeOut) end return 0
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • Lua腳本: release
local key = KEYS[1] local identifier = ARGV[1] if redis.call("GET", key) == identifier then redis.call("DEL", key) return 1 end return 0
  • Pre工具: 腳本執行器
/** * @author jifang * @since 16/8/25 下午3:35. */ public class ScriptCaller { private static final ConcurrentMap<String, String> SHA_CACHE = new ConcurrentHashMap<>(); private String script; private ScriptCaller(String script) { this.script = script; } public static ScriptCaller getInstance(String script) { return new ScriptCaller(script); } public Object call(Jedis connection, List<String> keys, List<String> argv, boolean forceEval) { if (!forceEval) { String sha = SHA_CACHE.get(this.script); if (Strings.isNullOrEmpty(sha)) { // load 腳本獲得 sha1 緩存 sha = connection.scriptLoad(this.script); SHA_CACHE.put(this.script, sha); } return connection.evalsha(sha, keys, argv); } return connection.eval(script, keys, argv); } }
  • Client
public class Client { private ScriptCaller acquireCaller = ScriptCaller.getInstance( "local key = KEYS[1]\n" + "local identifier = ARGV[1]\n" + "local lockTimeOut = ARGV[2]\n" + "\n" + "if redis.call(\"SETNX\", key, identifier) == 1 then\n" + " redis.call(\"EXPIRE\", key, lockTimeOut)\n" + " return 1\n" + "elseif redis.call(\"TTL\", key) == -1 then\n" + " redis.call(\"EXPIRE\", key, lockTimeOut)\n" + "end\n" + "return 0" ); private ScriptCaller releaseCaller = ScriptCaller.getInstance( "local key = KEYS[1]\n" + "local identifier = ARGV[1]\n" + "\n" + "if redis.call(\"GET\", key) == identifier then\n" + " redis.call(\"DEL\", key)\n" + " return 1\n" + "end\n" + "return 0" ); @Test public void client() { Jedis jedis = new Jedis("127.0.0.1", 9736); String identifier = acquireLockWithTimeOut(jedis, "ret1", 200 * 1000, 300); System.out.println(releaseLock(jedis, "ret1", identifier)); } String acquireLockWithTimeOut(Jedis connection, String lockName, long acquireTimeOut, int lockTimeOut) { String identifier = UUID.randomUUID().toString(); List<String> keys = Collections.singletonList("lock:" + lockName); List<String> argv = Arrays.asList(identifier, String.valueOf(lockTimeOut)); long acquireTimeEnd = System.currentTimeMillis() + acquireTimeOut; boolean acquired = false; while (!acquired && (System.currentTimeMillis() < acquireTimeEnd)) { if (1 == (long) acquireCaller.call(connection, keys, argv, false)) { acquired = true; } else { try { Thread.sleep(10); } catch (InterruptedException ignored) { } } } return acquired ? identifier : null; } boolean releaseLock(Jedis connection, String lockName, String identifier) { List<String> keys = Collections.singletonList("lock:" + lockName); List<String> argv = Collections.singletonList(identifier); return 1 == (long) releaseCaller.call(connection, keys, argv, true); } }

參考 & 推薦
代碼的將來
Redis入門指南
Redis實戰
Redis設計與實現
雲風的Blog: Lua與虛擬機
Lua簡明教程- CoolShell
Lua-newbie
Lua-Users
redis.io
相關文章
相關標籤/搜索