天天看點

一文講透 Redis 事務 (事務模式 VS Lua 腳本)

準确的講,Redis 事務包含兩種模式 : 事務模式 和 Lua 腳本。

先說結論:

Redis 的事務模式具備如下特點:

  • 保證隔離性;
  • 無法保證持久性;
  • 具備了一定的原子性,但不支援復原;
  • 一緻性的概念有分歧,假設在一緻性的核心是限制的語意下,Redis 的事務可以保證一緻性。

但 Lua 腳本更具備實用場景,它是另一種形式的事務,他具備一定的原子性,但腳本報錯的情況下,事務并不會復原。Lua 腳本可以保證隔離性,而且可以完美的支援後面的步驟依賴前面步驟的結果。

Lua 腳本模式的身影幾乎無處不在,比如分布式鎖、延遲隊列、搶紅包等場景。

1 事務原理

Redis 的事務包含如下指令:

序号 指令及描述
1 MULTI 标記一個事務塊的開始。
2 EXEC 執行所有事務塊内的指令。
3 DISCARD 取消事務,放棄執行事務塊内的所有指令。
4 WATCH key [key ...] 監視一個(或多個) key ,如果在事務執行之前這個(或這些) key 被其他指令所改動,那麼事務将被打斷。
5 UNWATCH 取消 WATCH 指令對所有 key 的監視。

事務包含三個階段:

  1. 事務開啟,使用 MULTI , 該指令标志着執行該指令的用戶端從非事務狀态切換至事務狀态 ;
  2. 指令入隊,MULTI 開啟事務之後,用戶端的指令并不會被立即執行,而是放入一個事務隊列 ;
  3. 執行事務或者丢棄。如果收到 EXEC 的指令,事務隊列裡的指令将會被執行 ,如果是 DISCARD 則事務被丢棄。

下面展示一個事務的例子。

redis> MULTI 
  OK
  redis> SET msg "hello world"
  QUEUED
  redis> GET msg
  QUEUED
  redis> EXEC
  1) OK
  1) hello world           

這裡有一個疑問?在開啟事務的時候,Redis key 可以被修改嗎?

一文講透 Redis 事務 (事務模式 VS Lua 腳本)

在事務執行 EXEC 指令之前 ,Redis key 依然可以被修改。

在事務開啟之前,我們可以 watch 指令監聽 Redis key 。在事務執行之前,我們修改 key 值 ,事務執行失敗,傳回 nil 。

一文講透 Redis 事務 (事務模式 VS Lua 腳本)

通過上面的例子,watch 指令可以實作類似樂觀鎖的效果 。

2 事務的ACID

2.1 原子性

原子性是指:一個事務中的所有操作,或者全部完成,或者全部不完成,不會結束在中間某個環節。事務在執行過程中發生錯誤,會被復原到事務開始前的狀态,就像這個事務從來沒有執行過一樣。

第一個例子:

在執行 EXEC 指令前,用戶端發送的操作指令錯誤,比如:文法錯誤或者使用了不存在的指令。

redis> MULTI
  OK
  redis> SET msg "other msg"
  QUEUED
  redis> wrongcommand  ### 故意寫錯誤的指令
  (error) ERR unknown command 'wrongcommand' 
  redis> EXEC
  (error) EXECABORT Transaction discarded because of previous errors.
  redis> GET msg
  "hello world"           

在這個例子中,我們使用了不存在的指令,導緻入隊失敗,整個事務都将無法執行 。

第二個例子:

事務操作入隊時,指令和操作的資料類型不比對 ,入隊列正常,但執行 EXEC 指令異常 。

redis> MULTI  
  OK
  redis> SET msg "other msg"
  QUEUED
  redis> SET mystring "I am a string"
  QUEUED
  redis> HMSET mystring name  "test"
  QUEUED
  redis> SET msg "after"
  QUEUED
  redis> EXEC
  1) OK
  2) OK
  3) (error) WRONGTYPE Operation against a key holding the wrong kind of value
  4) OK
  redis> GET msg
  "after"           

這個例子裡,Redis 在執行 EXEC 指令時,如果出現了錯誤,Redis 不會終止其它指令的執行,事務也不會因為某個指令執行失敗而復原 。

綜上,我對 Redis 事務原子性的了解如下:

  1. 指令入隊時報錯, 會放棄事務執行,保證原子性;
  2. 指令入隊時正常,執行 EXEC 指令後報錯,不保證原子性;

也就是:Redis 事務在特定條件下,才具備一定的原子性 。

2.2 隔離性

資料庫的隔離性是指:資料庫允許多個并發事務同時對其資料進行讀寫和修改的能力,隔離性可以防止多個事務并發執行時由于交叉執行而導緻資料的不一緻。

事務隔離分為不同級别 ,分别是:

  • 未送出讀(read uncommitted)
  • 送出讀(read committed)
  • 可重複讀(repeatable read)
  • 串行化(serializable)

首先,需要明确一點:Redis 并沒有事務隔離級别的概念。這裡我們讨論 Redis 的隔離性是指:并發場景下,事務之間是否可以做到互不幹擾。

我們可以将事務執行可以分為 EXEC 指令執行前和 EXEC 指令執行後兩個階段,分開讨論。

  1. EXEC 指令執行前

在事務原理這一小節,我們發現在事務執行之前 ,Redis key 依然可以被修改。此時,可以使用 WATCH 機制來實作樂觀鎖的效果。

  1. EXEC 指令執行後

因為 Redis 是單線程執行操作指令, EXEC 指令執行後,Redis 會保證指令隊列中的所有指令執行完 。 這樣就可以保證事務的隔離性。

2.3 持久性

資料庫的持久性是指 :事務處理結束後,對資料的修改就是永久的,即便系統故障也不會丢失。

Redis 的資料是否持久化取決于 Redis 的持久化配置模式 。

  1. 沒有配置 RDB 或者 AOF ,事務的持久性無法保證;
  2. 使用了 RDB模式,在一個事務執行後,下一次的 RDB 快照還未執行前,如果發生了執行個體當機,事務的持久性同樣無法保證;
  3. 使用了 AOF 模式;AOF 模式的三種配置選項 no 、everysec 都會存在資料丢失的情況 。always 可以保證事務的持久性,但因為性能太差,在生産環境一般不推薦使用。

綜上,redis 事務的持久性是無法保證的 。

2.4 一緻性

一緻性的概念一直很讓人困惑,在我搜尋的資料裡,有兩類不同的定義。

  1. 維基百科

我們先看下維基百科上一緻性的定義:

Consistency ensures that a transaction can only bring the database from one valid state to another, maintaining database invariants: any data written to the database must be valid according to all defined rules, including constraints, cascades, triggers, and any combination thereof. This prevents database corruption by an illegal transaction, but does not guarantee that a transaction is correct. Referential integrity guarantees the primary key – foreign key relationship.

在這段文字裡,一緻性的核心是“限制”,“any data written to the database must be valid according to all defined rules ”。

如何了解限制?這裡引用知乎問題 如何了解資料庫的内部一緻性和外部一緻性,螞蟻金服 OceanBase 研發專家韓富晟回答的一段話:

“限制”由資料庫的使用者告訴資料庫,使用者要求資料一定符合這樣或者那樣的限制。當資料發生修改時,資料庫會檢查資料是否還符合限制條件,如果限制條件不再被滿足,那麼修改操作不會發生。

關系資料庫最常見的兩類限制是“唯一性限制”和“完整性限制”,表格中定義的主鍵和唯一鍵都保證了指定的資料項絕不會出現重複,表格之間定義的參照完整性也保證了同一個屬性在不同表格中的一緻性。

“ Consistency in ACID ”是如此的好用,以至于已經融化在大部分使用者的血液裡了,使用者會在表格設計的時候自覺的加上需要的限制條件,資料庫也會嚴格的執行這個限制條件。

是以事務的一緻性和預先定義的限制有關,保證了限制即保證了一緻性。

我們細細品一品這句話: This prevents database corruption by an illegal transaction, but does not guarantee that a transaction is correct。

寫到這裡可能大家還是有點模糊,我們舉經典轉賬的案例。

我們開啟一個事務,張三和李四賬号上的初始餘額都是1000元,并且餘額字段沒有任何限制。張三給李四轉賬1200元。張三的餘額更新為 -200 , 李四的餘額更新為2200。

從應用層面來看,這個事務明顯不合法,因為現實場景中,使用者餘額不可能小于 0 , 但是它完全遵循資料庫的限制,是以從資料庫層面來看,這個事務依然保證了一緻性。

Redis 的事務一緻性是指:Redis 事務在執行過程中符合資料庫的限制,沒有包含非法或者無效的錯誤資料。

我們分三種異常場景分别讨論:

  1. 執行 EXEC 指令前,用戶端發送的操作指令錯誤,事務終止,資料保持一緻性;
  2. 執行 EXEC 指令後,指令和操作的資料類型不比對,錯誤的指令會報錯,但事務不會因為錯誤的指令而終止,而是會繼續執行。正确的指令正常執行,錯誤的指令報錯,從這個角度來看,資料也可以保持一緻性;
  3. 執行事務的過程中,Redis 服務當機。這裡需要考慮服務配置的持久化模式。
  4. 無持久化的記憶體模式:服務重新開機之後,資料庫沒有保持資料,是以資料都是保持一緻性的;
  5. RDB / AOF 模式: 服務重新開機後,Redis 通過 RDB / AOF 檔案恢複資料,資料庫會還原到一緻的狀态。

綜上所述,在一緻性的核心是限制的語意下,Redis 的事務可以保證一緻性。

  1. 《設計資料密集型應用》

這本書是分布式系統入門的神書。在事務這一章節有一段關于 ACID 的解釋:

一文講透 Redis 事務 (事務模式 VS Lua 腳本)
Atomicity, isolation, and durability are properties of the database,whereas consistency (in the ACID sense) is a property of the application. The application may rely on the database’s atomicity and isolation properties in order to achieve consistency, but it’s not up to the database alone. Thus, the letter C doesn’t really belong in ACID.

原子性,隔離性和持久性是資料庫的屬性,而一緻性(在 ACID 意義上)是應用程式的屬性。應用可能依賴資料庫的原子性和隔離屬性來實作一緻性,但這并不僅取決于資料庫。是以,字母 C 不屬于 ACID 。

很多時候,我們一直在糾結的一緻性,其實就是指符合現實世界的一緻性,現實世界的一緻性才是事務追求的最終目标。

為了實作現實世界的一緻性,需要滿足如下幾點:

  1. 保證原子性,持久性和隔離性,如果這些特征都無法保證,那麼事務的一緻性也無法保證;
  2. 資料庫本身的限制,比如字元串長度不能超過列的限制或者唯一性限制;
  3. 業務層面同樣需要進行保障 。

2.5 事務特點

我們通常稱 Redis 為記憶體資料庫 , 不同于傳統的關系資料庫,為了提供了更高的性能,更快的寫入速度,在設計和實作層面做了一些平衡,并不能完全支援事務的 ACID。

Redis 的事務具備如下特點:

  • 保證隔離性;
  • 無法保證持久性;
  • 具備了一定的原子性,但不支援復原;
  • 一緻性的概念有分歧,假設在一緻性的核心是限制的語意下,Redis 的事務可以保證一緻性。

從工程角度來看,假設事務操作中每個步驟需要依賴上一個步驟傳回的結果,則需要通過 watch 來實作樂觀鎖 。

3 Lua 腳本

3.1 簡介

一文講透 Redis 事務 (事務模式 VS Lua 腳本)

Lua 由标準 C 編寫而成,代碼簡潔優美,幾乎在所有作業系統和平台上都可以編譯,運作。Lua 腳本可以很容易的被 C/C ++ 代碼調用,也可以反過來調用 C/C++ 的函數,這使得 Lua 在應用程式中可以被廣泛應用。

Lua 腳本在遊戲領域大放異彩,大家耳熟能詳的《大話西遊II》,《魔獸世界》都大量使用 Lua 腳本。Java 後端工程師接觸過的 api 網關,比如 Openresty ,Kong 都可以看到 Lua 腳本的身影。

從 Redis 2.6.0 版本開始, Redis内置的 Lua 解釋器,可以實作在 Redis 中運作 Lua 腳本。

使用 Lua 腳本的好處 :

  • 減少網絡開銷。将多個請求通過腳本的形式一次發送,減少網絡時延。
  • 原子操作。Redis會将整個腳本作為一個整體執行,中間不會被其他指令插入。
  • 複用。用戶端發送的腳本會永久存在 Redis 中,其他用戶端可以複用這一腳本而不需要使用代碼完成相同的邏輯。

Redis Lua 腳本常用指令:

序号 指令及描述
1 EVAL script numkeys key [key ...] arg [arg ...] 執行 Lua 腳本。
2 EVALSHA sha1 numkeys key [key ...] arg [arg ...] 執行 Lua 腳本。
3 SCRIPT EXISTS script [script ...] 檢視指定的腳本是否已經被儲存在緩存當中。
4 SCRIPT FLUSH 從腳本緩存中移除所有腳本。
5 SCRIPT KILL 殺死目前正在運作的 Lua 腳本。
6 SCRIPT LOAD script 将腳本 script 添加到腳本緩存中,但并不立即執行這個腳本。

3.2 EVAL 指令

指令格式:

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

說明:

  • script是第一個參數,為 Lua 5.1腳本;
  • 第二個參數numkeys指定後續參數有幾個 key;
  • key [key ...],是要操作的鍵,可以指定多個,在 Lua 腳本中通過KEYS[1], KEYS[2]擷取;
  • arg [arg ...],參數,在 Lua 腳本中通過ARGV[1], ARGV[2]擷取。

簡單執行個體:

redis> eval "return ARGV[1]" 0 100 
  "100"
  redis> eval "return {ARGV[1],ARGV[2]}" 0 100 101
  1) "100"
  2) "101"
  redis> eval "return {KEYS[1],KEYS[2],ARGV[1]}" 2 key1 key2 first second
  1) "key1"
  2) "key2"
  3) "first"
  4) "second"           

下面示範下 Lua 如何調用 Redis 指令 ,通過redis.call()來執行了 Redis 指令 。

redis> set mystring 'hello world'
  OK
  redis> get mystring
  "hello world"
  redis> EVAL "return redis.call('GET',KEYS[1])" 1 mystring
  "hello world"
  redis> EVAL "return redis.call('GET','mystring')" 0
  "hello world"           

3.3 EVALSHA 指令

使用 EVAL 指令每次請求都需要傳輸 Lua 腳本 ,若 Lua 腳本過長,不僅會消耗網絡帶寬,而且也會對 Redis 的性能造成一定的影響。

思路是先将 Lua 腳本先緩存起來 , 傳回給用戶端 Lua 腳本的 sha1 摘要。 用戶端存儲腳本的 sha1 摘要 ,每次請求執行 EVALSHA 指令即可。

一文講透 Redis 事務 (事務模式 VS Lua 腳本)

EVALSHA 指令基本文法如下:

redis> EVALSHA sha1 numkeys key [key ...] arg [arg ...]            

執行個體如下:

redis> SCRIPT LOAD "return 'hello world'"
  "5332031c6b470dc5a0dd9b4bf2030dea6d65de91"
  redis> EVALSHA 5332031c6b470dc5a0dd9b4bf2030dea6d65de91 0
  "hello world"           

4 事務 VS Lua 腳本

從定義上來說, Redis 中的腳本本身就是一種事務, 是以任何在事務裡可以完成的事, 在腳本裡面也能完成。 并且一般來說, 使用腳本要來得更簡單,并且速度更快。

因為腳本功能是 Redis 2.6 才引入的, 而事務功能則更早之前就存在了, 是以 Redis 才會同時存在兩種處理事務的方法。

不過我們并不打算在短時間内就移除事務功能, 因為事務提供了一種即使不使用腳本, 也可以避免競争條件的方法, 而且事務本身的實作并不複雜。

-- https://redis.io/

Lua 腳本是另一種形式的事務,他具備一定的原子性,但腳本報錯的情況下,事務并不會復原。Lua 腳本可以保證隔離性,而且可以完美的支援後面的步驟依賴前面步驟的結果。

Lua 腳本模式的身影幾乎無處不在,比如分布式鎖、延遲隊列、搶紅包等場景。

不過在編寫 Lua 腳本時,要注意如下兩點:

  1. 為了避免 Redis 阻塞,Lua 腳本業務邏輯不能過于複雜和耗時;
  2. 仔細檢查和測試 Lua 腳本 ,因為執行 Lua 腳本具備一定的原子性,不支援復原。

繼續閱讀