【redis進階(1)】redis的Lua腳本控制(原子性)

[toc]html

爲何要用lua

  • 減小網絡開銷:原本5次網絡請求的操做,能夠用一個請求完成,原先5次請求的邏輯放在redis服務器上完成。使用腳本,減小了網絡往返時延。
  • 原子操做:Redis會將整個腳本做爲一個總體執行,中間不會被其餘進程或者進程的命令插入。(最重要)
  • 複用:客戶端發送的腳本會永久存儲在Redis中,意味着其餘客戶端能夠複用這一腳本而不須要使用代碼完成一樣的邏輯。

串講lua

相關連接與參考

主要用到的語法: 註釋,變量,方法調用和聲明,循環,流程控制python

編輯器與調試

下載安裝:http://luabinaries.sourceforg...
IDE編輯器:Settings -> Plugins -> Marketplace -> 搜索並安裝EmmyLuanginx

圖片描述

redis執行lua

eval

使用EVAL命令對 Lua 腳本進行求值redis

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

[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命令會將腳本添加到腳本緩存中,而且會當即對輸入的腳本進行求值。緩存

若是給定的腳本已經在緩存裏面了,那麼不作動做。服務器

在腳本被加入到緩存以後,經過 EVALSHA 命令,可使用腳本的 SHA1 校驗和來調用這個腳本。網絡

腳本能夠在緩存中保留無限長的時間,直到執行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 的任務消息進行處理。

# 生產延時消息
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 命令的自然原子性徹底能夠取代 SETNXEXPIRE 命令。

/**
 * 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來實現。
相關文章
相關標籤/搜索