面試官:Redis的事務滿足原子性嗎?

碼農參上發表於2021-09-09

原創:碼農參上(微信公眾號ID:CODER_SANJYOU),歡迎分享,轉載請保留出處。

談起資料庫的事務來,估計很多同學的第一反應都是ACID,而排在ACID中首位的A原子性,要求一個事務中的所有操作,要麼全部完成,要麼全部不完成。熟悉redis的同學肯定知道,在redis中也存在事務,那麼它的事務也滿足原子性嗎?下面我們就來一探究竟。

什麼是Redis事務?

和資料庫事務類似,redis事務也是用來一次性地執行多條命令。使用起來也很簡單,可以用MULTI開啟一個事務,然後將多個命令入隊到事務的佇列中,最後由EXEC命令觸發事務,執行事務中的所有命令。看一個簡單的事務執行例子:

127.0.0.1:6379> multi
OK
127.0.0.1:6379> set name Hydra
QUEUED
127.0.0.1:6379> set age 18
QUEUED
127.0.0.1:6379> incr age
QUEUED
127.0.0.1:6379> exec
1) OK
2) OK
3) (integer) 19

可以看到,在指令和運算元的資料型別等都正常的情況下,輸入EXEC後所有命令被執行成功。

Redis事務滿足原子性嗎?

如果要驗證redis事務是否滿足原子性,那麼需要在redis事務執行發生異常的情況下進行,下面我們分兩種不同型別的錯誤分別測試。

語法錯誤

首先測試命令中有語法錯誤的情況,這種情況多為命令的引數個數不正確或輸入的命令本身存在錯誤。下面我們在事務中輸入一個存在格式錯誤的命令,開啟事務並依次輸入下面的命令:

127.0.0.1:6379> multi
OK
127.0.0.1:6379> set name Hydra
QUEUED
127.0.0.1:6379> incr
(error) ERR wrong number of arguments for 'incr' command
127.0.0.1:6379> set age 18
QUEUED

輸入的命令incr後面沒有新增引數,屬於命令格式不對的語法錯誤,這時在命令入隊時就會立刻檢測出錯誤並提示error。使用exec執行事務,檢視結果輸出:

127.0.0.1:6379> exec
(error) EXECABORT Transaction discarded because of previous errors.

在這種情況下,只要事務中的一條命令有語法錯誤,在執行exec後就會直接返回錯誤,包括語法正確的命令在內的所有命令都不會被執行。對此進行驗證,看一下在事務中其他指令執行情況,檢視set命令的執行結果,全部為空,說明指令沒有被執行。

127.0.0.1:6379> get name
(nil)
127.0.0.1:6379> get age
(nil)

此外,如果存在命令本身拼寫錯誤、或輸入了一個不存在的命令等情況,也屬於語法錯誤的情況,執行事務時會直接報錯。

執行錯誤

執行錯誤是指輸入的指令格式正確,但是在命令執行期間出現的錯誤,典型場景是當輸入引數的資料型別不符合命令的引數要求時,就會發生執行錯誤。例如下面的例子中,對一個string型別的值執行列表的操作,報錯如下:

127.0.0.1:6379> set key1 value1
OK
127.0.0.1:6379> lpush key1 value2
(error) WRONGTYPE Operation against a key holding the wrong kind of value

這種錯誤在redis實際執行指令前是無法被發現的,只能當真正執行才能夠被發現,因此這樣的命令是可以被事務佇列接收的,不會和上面的語法錯誤一樣立即報錯。

具體看一下當事務中存在執行錯誤的情況,在下面的事務中,嘗試對string型別資料進行incr自增操作:

127.0.0.1:6379> multi
OK
127.0.0.1:6379> set name Hydra
QUEUED
127.0.0.1:6379> set age eighteen
QUEUED
127.0.0.1:6379> incr age
QUEUED
127.0.0.1:6379> del name
QUEUED

redis一直到這裡都沒有提示存在錯誤,執行exec看一下結果輸出:

127.0.0.1:6379> exec
1) OK
2) OK
3) (error) ERR value is not an integer or out of range
4) (integer) 1

執行結果可以看到,雖然incr age這條命令出現了錯誤,但是它前後的命令都正常執行了,再看一下這些key對應的值,確實證明了其餘指令都執行成功:

127.0.0.1:6379> get name
(nil)
127.0.0.1:6379> get age
"eighteen"

階段性結論

對上面的事務的執行結果進行一下分析:

  • 存在語法錯誤的情況下,所有命令都不會執行
  • 存在執行錯誤的情況下,除執行中出現錯誤的命令外,其他命令都能正常執行

通過分析我們知道了redis中的事務是不滿足原子性的,在執行錯誤的情況下,並沒有提供類似資料庫中的回滾功能。那麼為什麼redis不支援回滾呢,官方文件給出了說明,大意如下:

  • redis命令失敗只會發生在語法錯誤或資料型別錯誤的情況,這一結果都是由程式設計過程中的錯誤導致,這種情況應該在開發環境中檢測出來,而不是生產環境
  • 不使用回滾,能使redis內部設計更簡單,速度更快
  • 回滾不能避免程式設計邏輯中的錯誤,如果想要將一個鍵的值增加2卻只增加了1,這種情況即使提供回滾也無法提供幫助

基於以上原因,redis官方選擇了更簡單、更快的方法,不支援錯誤回滾。這樣的話,如果在我們的業務場景中需要保證原子性,那麼就要求了開發者通過其他手段保證命令全部執行成功或失敗,例如在執行命令前進行引數型別的校驗,或在事務執行出現錯誤時及時做事務補償。

提到其他方式,相信很多小夥伴都聽說使用Lua指令碼來保證操作的原子性,例如在分散式鎖中通常使用的就是Lua指令碼,那麼,神奇的Lua指令碼真的能保證原子性嗎?

簡單的Lua指令碼入門

在驗證lua指令碼的原子性之前,我們需要對它做一個簡單的瞭解。redis從2.6版本開始支援執行lua指令碼,它的功能和事務非常類似,一段lua指令碼被視作一條命令執行,這樣將多條redis命令寫入lua,即可實現類似事務的執行結果。我們先看一下下面幾個常用的命令。

EVAL 命令

最常用的EVAL用於執行一段指令碼,它的命令的格式如下:

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

簡單解釋一下其中的引數:

  • script是一段lua指令碼程式
  • numkeys指定後續引數有幾個key,如沒有key則為0
  • key [key …]表示指令碼中用到的redis中的鍵,在lua指令碼中通過KEYS[i]的形式獲取
  • arg [arg …]表示附加引數,在lua指令碼中通過ARGV[i]獲取

看一個簡單的例子:

127.0.0.1:6379> eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 key1 key2 value1 vauel2
1) "key1"
2) "key2"
3) "value1"
4) "vauel2"

在上面的命令中,雙引號中是lua指令碼程式,後面的2表示存在兩個key,分別是key1key2,之後的引數是附加引數value1value2

如果想要使用lua指令碼執行set命令,可以寫成這樣:

127.0.0.1:6379> EVAL "redis.call('SET', KEYS[1], ARGV[1]);" 1 name Hydra
(nil)

這裡使用了redis內建的lua函式redis.call來完成set命令,這裡列印的執行結果nil是因為沒有返回值,如果不習慣的話,其實我們可以在指令碼中新增return 0;的返回語句。

SCRIPT LOAD 和 EVALSHA命令

這兩個命令放在一起是因為它們一般成對使用。先看SCRIPT LOAD,它用於把指令碼載入到快取中,返回SHA1校驗和,這時候只是快取了命令,但是命令沒有被馬上執行,看一個例子:

127.0.0.1:6379> SCRIPT LOAD "return redis.call('GET', KEYS[1]);"
"228d85f44a89b14a5cdb768a29c4c4d907133f56"

這裡返回了一個SHA1的校驗和,接下來就可以使用EVALSHA來執行指令碼了:

127.0.0.1:6379> EVALSHA "228d85f44a89b14a5cdb768a29c4c4d907133f56" 1 name
"Hydra"

這裡使用這個SHA1值就相當於匯入了上面快取的命令,在之後再拼接numkeyskeyarg等引數,命令就能夠正常執行了。

其他命令

使用SCRIPT EXISTS命令判斷指令碼是否被快取:

127.0.0.1:6379> SCRIPT EXISTS 228d85f44a89b14a5cdb768a29c4c4d907133f56
1) (integer) 1

使用SCRIPT FLUSH命令清除redis中的lua指令碼快取:

127.0.0.1:6379> SCRIPT FLUSH
OK
127.0.0.1:6379> SCRIPT EXISTS 228d85f44a89b14a5cdb768a29c4c4d907133f56
1) (integer) 0

可以看到,執行了SCRIPT FLUSH後,再次通過SHA1值檢視指令碼時已經不存在。最後,還可以使用SCRIPT KILL命令殺死當前正在執行的 lua 指令碼,但是隻有當指令碼沒有執行寫操作時才會生效。

從這些操作看來,lua指令碼具有下面的優點:

  • 多次網路請求可以在一次請求中完成,減少網路開銷,減少了網路延遲
  • 客戶端傳送的指令碼會存在redis中,其他客戶端可以複用這一指令碼,而不需要再重複編碼完成相同的邏輯

Java程式碼中使用lua指令碼

在Java程式碼中可以使用Jedis中封裝好的API來執行lua指令碼,下面是一個使用Jedis執行lua指令碼的例子:

public static void main(String[] args) {
    Jedis jedis = new Jedis("127.0.0.1", 6379);
    String script="redis.call('SET', KEYS[1], ARGV[1]);"
            +"return redis.call('GET', KEYS[1]);";
    List<String> keys= Arrays.asList("age");
    List<String> values= Arrays.asList("eighteen");
    Object result = jedis.eval(script, keys, values);
    System.out.println(result);
}

執行上面的程式碼,控制檯列印了get命令返回的結果:

eighteen

簡單的鋪墊完成後,我們來看一下lua指令碼究竟能否實現回滾級別的原子性。對上面的程式碼進行改造,插入一條執行錯誤的命令:

public static void main(String[] args) {
    Jedis jedis = new Jedis("127.0.0.1", 6379);
    String script="redis.call('SET', KEYS[1], ARGV[1]);"
            +"redis.call('INCR', KEYS[1]);"
            +"return redis.call('GET', KEYS[1]);";
    List<String> keys= Arrays.asList("age");
    List<String> values= Arrays.asList("eighteen");
    Object result = jedis.eval(script, keys, values);
    System.out.println(result);
}

檢視執行結果:

再到客戶端執行一下get命令:

127.0.0.1:6379> get age
"eighteen"

也就是說,雖然程式丟擲了異常,但異常前的命令還是被正常的執行了且沒有被回滾。再試試直接在redis客戶端中執行這條指令:

127.0.0.1:6379> flushall
OK
127.0.0.1:6379> eval "redis.call('SET', KEYS[1], ARGV[1]);redis.call('INCR', KEYS[1]);return redis.call('GET', KEYS[1])" 1 age eight
(error) ERR Error running script (call to f_c2ea9d5c8f60735ecbedb47efd42c834554b9b3b): @user_script:1: ERR value is not an integer or out of range
127.0.0.1:6379> get age
"eight"

同樣,錯誤之前的指令仍然沒有被回滾,那麼我們之前經常聽說的Lua指令碼保證原子性操作究竟是怎麼回事呢?

其實,在redis中是使用的同一個lua直譯器來執行所有命令,也就保證了當一段lua指令碼在執行時,不會有其他指令碼或redis命令同時執行,保證了操作不會被其他指令插入或打擾,實現的僅僅是這種程度上的原子性。

但是遺憾的是,如果指令碼執行時出錯並中途結束,之後的操作不會進行,但是之前已經發生的寫操作不會撤銷,所以即使使用了lua指令碼,也不能實現類似資料庫回滾的原子性。

本文基於redis 5.0.3 進行測試

官方文件相關說明:https://redis.io/topics/transactions

作者簡介,碼農參上(CODER_SANJYOU),一個熱愛分享的公眾號,有趣、深入、直接,與你聊聊技術。個人微信DrHydra9,歡迎新增好友,進一步交流。

相關文章