redis4.0之Lua腳本新姿勢

原文地址


前言

Redis內嵌了Lua環境來支持用戶擴展功能,但是出於數據一致性考慮,要求腳本必須是純函數的形式,也就是說對於一段Lua腳本給定相同的參數,寫入Redis的數據也必須是相同的,對於隨機性的寫入Redis是拒絕的。

從Redis 3.2開始Lua腳本支持隨機性寫入,最近在總結4.0的新特性,索性就都歸到4.0裏,方便查閱。

Redis中的Lua腳本

1. Lua腳本簡介

在Redis中使用Lua腳本不可避免的要用到以下三個命令:EVAL、EVALSHA和SCRIPT,下面我們來簡單介紹一下:

  • 1. EVAL script numkeys key [key ...] arg [arg ...]
    script參數就是一段Lua腳本

    numkeys參數用於指明後面key的數量

    key鍵名,在Lua腳本中可以通過全局變量KEYS[]數組訪問,其數量已經由numkeys指明

    arg爲輔助參數,在Lua腳本中可以通過全局變量ARGV[]數組訪問,其個數並沒有限制
  • 2. EVALSHA sha1 numkeys key [key ...] arg [arg ...]
    EVALSHA與EVAL基本一致,只不過其中script腳本替換成了這段腳本的sha1校驗碼

    sha1校驗碼通常是取SCRIPT LOAD的返回值
  • 3. SCRIPT subcommand
    SCRIPT LOAD script: 將一段Lua腳本加載到Redis中緩存起來,並返回其sha1校驗碼

    SCRIPT EXISTS sha1 [sha1 ...]: 判斷給定的一個或多個sha1校驗碼在Redis中是否有對應的Lua腳本

    SCRIPT FLUSH: 清空所有已加載的Lua腳本

    SCRIPT KILL: 殺死正在運行的Lua腳本,當且僅當這個腳本沒有執行過任何寫命令時才生效,如果執行過寫命令那麼就只能等待這個腳本執行完畢,或者執行SHUTDOWN NOSAVE來關閉Redis

關於Lua腳本的用法此處就不詳細展開了,具體可以參考官網的文檔:https://redis.io/commands/eval

2. Lua腳本的持久化及主從複製

Redis允許在Lua腳本中調用redis.call()或者redis.pcall()來執行Redis命令,如果Lua腳本對Redis的數據做了更改,那麼除了執行腳本本身以外還需要兩個額外的操作:

  1. 把這段Lua腳本持久化到AOF文件中,保證Redis重啓時可以回放執行過的Lua腳本。

  2. 把這段Lua腳本複製給備庫執行,保證主備庫的數據一致性。

由於上述兩步,現在就很容易理解爲什麼Redis要求Lua腳本必須是純函數的形式了,想象一下給定一段Lua腳本和輸入參數卻得到了不同的結果,這就會造成重啓前後和主備庫之間的數據不一致,Redis不允許對數據一致性的破壞。

3. Redis如何防止隨機寫入

上一節我們介紹了Lua腳本的持久化及主從複製,很明顯隨機寫入會對數據一致性造成破壞,那麼本節就來介紹Redis是如何防止Lua腳本中隨機寫入的。

首先我們來執行一段腳本,嘗試把time命令返回的當前時間寫入到鍵now中,看下會怎樣:

127.0.0.1:6379> eval "local now = redis.call('time')[1]; redis.call('set','now',now); return redis.call('get','now')" 0
(error) ERR Error running script (call to f_e745355f11745192bd45376618a34bec9145653b): @user_script:1: @user_script: 1: Write commands not allowed after non deterministic commands. Call redis.replicate_commands() at the start of your script in order to switch to single commands replication mode.

不出意外的被拒絕了,這是因爲在Redis中time命令是一個隨機命令(時間是變化的),在Lua腳本中調用了隨機命令之後禁止再調用寫命令,Redis中一共有10個隨機類命令:

spop、srandmember、sscan、zscan、hscan、randomkey、scan、lastsave、pubsub、time

熟悉Lua的讀者也許會問,那要是使用math.random()來生成隨機數呢?

Redis是允許在Lua腳本中使用隨機數發生器的,不過大家也應該知道生成的其實都是僞隨機序列,除非顯示調用math.randomseed()。通常情況下都會選擇系統時間來作爲math.randomseed()的參數,然而Redis在初始化Lua環境時出於安全考慮並沒有加載os庫,所以os.time無法使用,而Redis的time命令屬於隨機命令就又回到了上面的問題。

  • 這裏小插曲下,Redis自己實現了隨機數發生器,替換掉了math.randomseed()和math.random(),以保證在不同運行環境下生成的僞隨機數序列總是相同的。

4. redis.replicate_commands()

綜上所述,Redis無法在Lua腳本中進行隨機寫入,是因爲受到了持久化和主從複製的制約,而制約的根本原因是持久化和複製的粒度是整個Lua腳本,如果能夠只把發生更改的數據做持久化和主從複製,那麼就可以化隨機爲確定,進一步豐富Lua在Redis中的使用。

OK,從新版本開始,Redis提供了redis.replicate_commands()
函數來實現這一功能,把發生數據變更的命令以事務的方式做持久化和主從複製,從而允許在Lua腳本內進行隨機寫入,下面來舉例說明:

127.0.0.1:6379> eval "redis.replicate_commands(); local now = redis.call('time')[1]; redis.call('set','now',now); return redis.call('get','now')" 0
"1504460040"

可以看到,相同的腳本只是在開頭插入了redis.replicate_commands()就可以成功把時間寫入;這是因爲執行了redis.replicate_commands()之後,Redis就開始使用multi/exec來包圍Lua腳本中調用的命令,持久化和複製的只是*1\r\n$5\r\nMULTI\r\n*3\r\n$3\r\nset\r\n$3\r\nnow\r\n$10\r\n1504460595\r\n*1\r\n$4\r\nEXEC\r\n而不是整個Lua腳本,那麼AOF文件和備庫中拿到的就是一個確定的結果。

並且在Lua腳本中讀多寫少的情況下,只持久化和複製寫命令,可以節省重啓和備庫的CPU時間。

5. redis.replicate_commands()注意事項

replicate_commands雖好但是也不能亂用,有幾個事項還是需要注意的:

  • 1. 在寫命令之前調用redis.replicate_commands()
    上一節說道調用redis.replicate_commands()之後Redis開始用事務來替代整個Lua腳本做持久化和主從複製。
    但是Redis並沒有緩存redis.replicate_commands()之前的命令,如果在此之前調用了寫命令是會破壞數據一致性的(因爲Redis不支持undo操作,無法回滾到執行Lua腳本的初始狀態)。
    此時redis.replicate_commands()並不會生效。

127.0.0.1:6379> eval "redis.call('set','foo','bar'); redis.replicate_commands(); local now = redis.call('time')[1]; redis.call('set','now',now); return redis.call('get','now')" 0
(error) ERR Error running script (call to f_7dd09c943ce6841d59c54b1f4618f9cc670c7b74): @user_script:1: @user_script: 1: Write commands not allowed after non deterministic commands. Call redis.replicate_commands() at the start of your script in order to switch to single commands replication mode.

    可以看到在redis.replicate_commands()之前先調用了寫命令會造成失敗。
    所以建議如果想進行隨機寫入的話,在腳本一開始就調用redis.replicate_commands()。
  • 2. 當有大流量寫入時不建議用redis.replicate_commands()
    誠然redis.replicate_commands()豐富了Lua的用法,但是不可避免的也會有些副作用。
    比如有時候會放大主備複製的流量:

127.0.0.1:6379> eval "for i=1,10000,1 do redis.call('set',i,i) end" 0

    以上這段腳本會進行1萬次的循環寫入,在不調用redis.replicate_commands()的情況下只會給備庫複製這段腳本,而調用之後就會進行1萬次的寫命令複製,增加了主從複製的流量。
    所以在不需要進行隨機寫入時,如果有可能造成大流量寫入的話儘量不要使用redis.replicate_commands()。
  • 3. 慎用redis.set_repl()
    redis.replicate_commands()可以和redis.set_repl()配合,來控制寫命令是否進行持久化和主從複製:
    redis.set_repl(redis.REPL_ALL) -- 既持久化也主從複製。
    redis.set_repl(redis.REPL_AOF) -- 只持久化不主從複製。
    redis.set_repl(redis.REPL_SLAVE) -- 只主從複製不持久化。
    redis.set_repl(redis.REPL_NONE) -- 既不持久化也不主從複製。
    默認REPL_ALL,當設置爲其他模式時會有數據不一致的風險,所以不建議使用redis.set_repl(),使用redis.replicate_commands()來進行隨機寫入足矣。

原文地址


發佈了0 篇原創文章 · 獲贊 6 · 訪問量 1萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章