[toc]
## 為什么要用lua
* 減少網絡開銷:本來5次網絡請求的操作,可以用一個請求完成,原先5次請求的邏輯放在redis服務器上完成。使用腳本,減少了網絡往返時延。
* **原子操作:Redis會將整個腳本作為一個整體執行,中間不會被其他進程或者線程的命令插入**。(最重要)
* 復用:客戶端發送的腳本會永久存儲在Redis中,意味著其他客戶端可以復用這一腳本而不需要使用代碼完成同樣的邏輯。
>[info] 所謂**原子操作**是指不會被線程調度機制打斷的操作;這種操作一旦開始,就一直運行到結束,中間不會有任何 context switch 線程切換。
## 串講lua
### 相關鏈接與參考
* [官方文檔](http://www.lua.org/docs.html)(英文)
* [Lua 5.3 參考手冊](https://www.runoob.com/manual/lua53doc/contents.html) (中文)
* [菜鳥教程](https://www.runoob.com/lua/lua-tutorial.html)
主要用到的語法: **注釋,變量,方法調用和聲明,循環,流程控制**
### 編輯器與調試
下載安裝:http://luabinaries.sourceforge.net/download.html
IDE編輯器:Settings -> Plugins -> Marketplace -> 搜索并安裝EmmyLua

## redis執行lua
### eval
使用[EVAL](http://doc.redisfans.com/script/eval.html#eval)命令對 Lua 腳本進行求值
**EVAL script numkeys key \[key ...\] arg \[arg ...\]**
>[info] numkeys : keys的數量有幾個。這是一個必傳的參數,即使沒有keys也要傳個0;
```
# 注意redis的計數是從1開始的
127.0.0.1:6379> eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 key1 key2 first second
1) "key1"
2) "key2"
3) "first"
4) "second"
```
### 腳本緩存
[*EVAL*](http://doc.redisfans.com/script/eval.html#eval)命令會將腳本添加到腳本緩存中,并且會立即對輸入的腳本進行求值。
如果給定的腳本已經在緩存里面了,那么不做動作。
在腳本被加入到緩存之后,通過 EVALSHA 命令,可以使用腳本的 SHA1 校驗和來調用這個腳本。
腳本可以在緩存中保留無限長的時間,直到執行[*SCRIPT FLUSH*](http://doc.redisfans.com/script/script_flush.html#script-flush)為止。
~~~
redis> SCRIPT LOAD "return 'hello moto'"
"232fd51614574cf0867b83d384a5e898cfd24e5a"
# 判斷腳本是否存在
redis> SCRIPT EXISTS 232fd51614574cf0867b83d384a5e898cfd24e5a
1) (integer) 1
redis> EVALSHA 232fd51614574cf0867b83d384a5e898cfd24e5a 0
"hello moto"
# 清空緩存
redis> SCRIPT FLUSH
OK
~~~
### call與pcall
在 Lua 腳本中,可以使用兩個不同函數來執行 Redis 命令,它們分別是:
* redis.call()
* redis.pcall()
~~~
# 0表示沒有keys
> eval "return redis.call('set','foo','bar')" 0
OK
# 以參數的形式傳入
> eval "return redis.call('set',KEYS[1],'bar')" 1 foo
OK
~~~
redis.call()和redis.pcall()的唯一區別在于它們對錯誤處理的不同。redis.pcall()出錯時并不引發(raise)錯誤,而是返回一個帶err域的 Lua 表(table) ,用于表示錯誤:
~~~
redis 127.0.0.1:6379> EVAL "return redis.pcall('get', 'foo')" 0
(error) ERR Operation against a key holding the wrong kind of value
~~~
### redis中已預先加載的lua庫
Redis 內置的 Lua 解釋器加載了以下 Lua 庫:
* base
* table
* string
* math
* debug
* cjson
* cmsgpack
其中cjson庫可以讓 Lua 以非常快的速度處理 JSON 數據,除此之外,其他別的都是 Lua 的標準庫。
每個 Redis 實例都保證會加載上面列舉的庫,從而確保每個 Redis 腳本的運行環境都是相同的。
### 全局變量保護
為了防止不必要的數據泄漏進 Lua 環境, Redis 腳本不允許創建全局變量。如果一個腳本需要在多次執行之間維持某種狀態,它應該使用 Redis key 來進行狀態保存。
~~~
redis 127.0.0.1:6379> eval 'a=10' 0
(error) ERR Error running script (call to f_933044db579a2f8fd45d8065f04a8d0249383e57): user_script:1: Script attempted to create global variable 'a'
~~~
### 日志打印
在 Lua 腳本中,可以通過調用redis.log函數來寫 Redis 日志(log):
redis.log(loglevel,message)
其中,message參數是一個字符串,而loglevel參數可以是以下任意一個值:
* redis.LOG\_DEBUG
* redis.LOG\_VERBOSE
* redis.LOG\_NOTICE
* redis.LOG\_WARNING
**打印的日志在redis日志文件中**,redis的日志文件可以在其配置里面找logfile。默認是沒有的。redis必須帶配置文件啟動,如果直接啟動的話,它會使用默認配置(而且并不存在這個默認配置文件,所以不要想改它)。
### redis-cli測試腳本
~~~
[root@test-02 bin]# cat test.lua
return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}
# 通過逗號來分割key和arg,注意,這個逗號必須前后要有空格
[root@test-02 bin]# ./redis-cli --eval test.lua key1 key2 , first second
1) "key1"
2) "key2"
3) "first"
4) "second"
~~~
>[info] 注意: 逗號前后必須要有空格。
## PHP中調用
$redis->eval($lua,array('key1','key2','first','second'),2)
~~~
$lua = <<<SCRIPT
return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}
SCRIPT;
//對應的redis命令如下 eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 key1 key2 first second
$s = $redis->eval($lua,array('key1','key2','first','second'),2);
~~~
## 實戰redis的lua腳本應用
### 頻率控制
10秒內只能訪問3次。 后續該腳本可以在nginx或者程序運行腳本中直接使用,判斷返回是否為0,就0就不讓其繼續訪問。
~~~
-- redis-cli --eval ratelimiting.lua rate.limitingl:127.0.0.1 , 10 3
-- rate.limitingl + 1
local times = redis.call('incr',KEYS[1])
-- 第一次訪問的時候加上過期時間10秒(10秒過后從新計數)
if times == 1 then
redis.call('expire',KEYS[1], ARGV[1])
end
-- 注意,從redis進來的默認為字符串,lua同種數據類型只能和同種數據類型比較
if times > tonumber(ARGV[2]) then
return 0
end
return 1
~~~
**以上,如果不使用redis+lua,那高并發下incr和expire就會出現原子性破壞,造成expire執行多次浪費**
### 延時隊列
Zset 里面存儲的是 Value/Score 鍵值對,我們將 Value 存儲為序列化的任務消息,Score 存儲為下一次任務消息運行的時間(Deadline),然后輪詢 Zset 中 Score 值大于 Now 的任務消息進行處理。
~~~python
# 生產延時消息
zadd(queue-key, now_ts+5, task_json)
# 消費延時消息
while True:
task_json = zrevrangebyscore(queue-key, now_ts, 0, 0, 1)
if task_json:
grabbed_ok = zrem(queue-key, task_json)
if grabbed_ok:
process_task(task_json)
else:
sleep(1000) // 歇 1s
~~~
當消費者是多線程或者多進程的時候,這里會存在競爭浪費問題。當前線程明明將 task\_json 從 Zset 中輪詢出來了,但是通過 Zrem 來爭搶時卻搶不到手。
這時就可以使用 LUA 腳本來解決這個問題,將輪詢和爭搶操作原子化,這樣就可以避免競爭浪費。
~~~
local res = nil
local tasks = redis.pcall("zrevrangebyscore", KEYS[1], ARGV[1], 0, "LIMIT", 0, 1)
if #tasks > 0 then
local ok = redis.pcall("zrem", KEYS[1], tasks[1])
if ok > 0 then
res = tasks[1]
end
end
return res
~~~
### 分布式自增ID
~~~
local key = KEYS[1]
local id = redis.call('get',key)
if(id == false)
then
redis.call('set',key,1)
return key.."0001"
else
redis.call('set',key,id+1)
return key..string.format('%04d',id + 1)
end
~~~
通過lua使get和set命令原子化,杜絕高并發下的
### 秒殺或者搶紅包
業務需求: 每次只允許領取10個紅包
操作流程:**判斷是否能搶->搶到紅包**->記錄搶到紅包的人->異步發紅包
解決問題:高并發下的紅包超發(或者商品超賣),判斷能否搶和搶一定要原子性的捆綁在一起,否則就會出現超發
~~~
-- 搶紅包腳本
--[[
--red:list 為 List 結構,存放預先生成的紅包金額
red:draw_count:u:openid 為 k-v 結構,用戶領取紅包計數器
red:draw為 Hash 結構,存放紅包領取記錄
red:task 也為 List 結構,紅包異步發放隊列
openid 為用戶的openid
]]--
local openid = KEYS[1]
local isDraw = redis.call("HEXISTS","red:draw",openid)
-- 已經領取
if isDraw ~= 0 then
return true
end
-- 領取太多次了
local times = redis.call("INCR","red:draw_count:u:"..openid)
if times and tonumber(times) > 9 then
return 0
end
local number = redis.call("RPOP","red:list")
-- 沒有紅包
if not number then
return {}
end
-- 領取人昵稱為Fhb,頭像為 https:// xxxxxx
local red = {money=number,name=KEYS[2] , pic = KEYS[3] }
-- 領取記錄
redis.call("HSET","red:draw",openid,cjson.encode(red))
-- 處理隊列
red["openid"] = openid
redis.call("RPUSH","red:task",cjson.encode(red))
return true
~~~
### 分布式鎖
Redis在 `2.6`以前的版本用setnx做分布式鎖的時候,會出現`setnx`?和?`expire`遭到原子性破壞的可能,必須要配合lua腳本來實現原子性。但在`2.6.12` 版本開始,為 `SET` 命令增加了一系列選項:
**`SET key value[EX seconds][PX milliseconds][NX|XX]`**
* EX seconds:設置指定的過期時間,單位秒。
* PX milliseconds:設置指定的過期時間,單位毫秒。
* NX:僅當key不存在時設置值。
* XX:僅當key存在時設置值。
可以看出來, `SET` 命令的天然原子性完全可以取代 `SETNX` 和 `EXPIRE` 命令。
~~~
/**
* redis排重鎖
* @param $key
* @param $expires
* @param int $value
* @return mixed
*/
public function redisLock($key, $expires, $value = 1)
{
//在key不存在時,添加key并$expires秒過期
return $this->redis->set($key, $value, ['nx', 'ex' => $expires]);
}
~~~
>[info] 總結:凡是需要多條redis命令需要捆綁在一起原子性操作的,都要使用lua來實現。
--------------------------
時間復雜度和空間復雜度