天天看點

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>