紅包雨中:Redis 和 Lua 的邂逅

2022-06-20 06:07:15

2018年,王思聰的衝頂大會,西瓜視訊的百萬英雄,再到映客的芝士超人,直播答題火爆全網。

我服務的一家電商公司也加入了這次熱潮,技術團隊研發了直播答題功能。答題結束之後,紅包會以紅包雨的形式落下,使用者點選螢幕上落下的紅包,若搶到紅包,紅包會以現金的形式進入使用者賬戶。

紅包雨是一個典型的高並行場景,短時間內有海量請求存取伺服器端,技術團隊為了讓系統執行順暢,搶紅包採用了基於 Redis + Lua 指令碼的設計方案。

1 整體流程

我們分析下搶紅包的整體流程 :

  1. 運營系統設定紅包雨活動總金額以及紅包個數,提前計算出各個紅包的金額並儲存到 Redis 中;
  2. 搶紅包雨介面,使用者點選螢幕上落下的紅包,發起搶紅包請求;
  3. TCP 閘道器接收搶紅包請求後,呼叫答題系統搶紅包 dubbo 服務,搶紅包服務本質上就是執行 Lua 指令碼,將結果通過 TCP 閘道器返回給前端;
  4. 使用者若搶到紅包,非同步任務會從 Redis 中 獲取搶得的紅包資訊,呼叫餘額系統,將金額返回到使用者賬戶。

2 紅包 Redis 設計

搶紅包有如下規則:

  • 同一活動,使用者只能搶紅包一次 ;
  • 紅包數量有限,一個紅包只能被一個使用者搶到。

如下圖,我們設計三種資料型別:

  1. 運營預分配紅包列表 ;

佇列元素 json 資料格式 :

{
    //紅包編號
    redPacketId : '365628617880842241' 
    //紅包金額
    amount : '12.21'          
}
  1. 使用者紅包領取記錄列表;

佇列元素 json 資料格式:

{
    //紅包編號
    redPacketId : '365628617880842241'
    //紅包金額
    amount : '12.21',
    //使用者編號
    userId : '265628617882842248'
}
  1. 使用者紅包防重 Hash 表;

搶紅包 Redis 操作流程 :

  1. 通過 hexist 命令判斷紅包領取記錄防重 Hash 表中使用者是否領取過紅包 ,若使用者未領取過紅包,流程繼續;
  2. 從運營預分配紅包列表 rpop 出一條紅包資料 ;
  3. 操作紅包領取記錄防重 Hash 表 ,呼叫 HSET 命令儲存使用者領取記錄;
  4. 將紅包領取資訊 lpush 進入使用者紅包領取記錄列表。

搶紅包的過程 ,需要重點關注如下幾點 :

  • 執行多個命令,是否可以保證原子性 , 若一個命令執行失敗,是否可以回滾;
  • 在執行過程中,高並行場景下,是否可以保持隔離性;
  • 後面的步驟依賴前面步驟的結果。

Redis 支援兩種模式 : 事務模式Lua 指令碼,接下來,我們一一展開。

3 事務原理

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 可以被修改嗎?

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

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

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

4 事務的ACID

4.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 事務在特定條件下,才具備一定的原子性

4.2 隔離性

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

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

  • 未提交讀(read uncommitted)
  • 提交讀(read committed)
  • 可重複讀(repeatable read)
  • 序列化(serializable)

首先,需要明確一點:Redis 並沒有事務隔離級別的概念。這裡我們討論 Redis 的隔離性是指:並行場景下,事務之間是否可以做到互不干擾

我們可以將事務執行可以分為 EXEC 命令執行前EXEC 命令執行後兩個階段,分開討論。

  1. EXEC 命令執行前

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

  1. EXEC 命令執行後

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

4.3 永續性

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

Redis 的資料是否持久化取決於 Redis 的持久化設定模式 。

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

綜上,redis 事務的永續性是無法保證的

4.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 服務宕機。這裡需要考慮服務設定的持久化模式。
    • 無持久化的記憶體模式:服務重啟之後,資料庫沒有保持資料,因此資料都是保持一致性的;
    • RDB / AOF 模式: 服務重啟後,Redis 通過 RDB / AOF 檔案恢復資料,資料庫會還原到一致的狀態。

綜上所述,在一致性的核心是約束的語意下,Redis 的事務可以保證一致性

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

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

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. 業務層面同樣需要進行保障 。

4.5 總結

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

Redis 的事務具備如下特點:

  • 保證隔離性;
  • 無法保證永續性;
  • 具備了一定的原子性,但不支援回滾;
  • 一致性的概念有分歧,假設在一致性的核心是約束的語意下,Redis 的事務可以保證一致性。

另外,在搶紅包的場景下, 因為每個步驟需要依賴上一個步驟返回的結果,需要通過 watch 來實現樂觀鎖 ,從工程角度來看, Redis 事務並不適合該業務場景。

5 Lua 指令碼

5.1 簡介

「 Lua 」 在葡萄牙語中是「月亮」的意思,1993年由巴西的 Pontifical Catholic University 開發。

該語言的設計目的是為了嵌入應用程式中,從而為應用程式提供靈活的擴充套件和客製化功能。

Lua 指令碼可以很容易的被 C/C ++ 程式碼呼叫,也可以反過來呼叫 C/C++ 的函數,這使得 Lua 在應用程式中可以被廣泛應用。不僅僅作為擴充套件指令碼,也可以作為普通的組態檔,代替 XML, Ini 等檔案格式,並且更容易理解和維護。

Lua 由標準 C 編寫而成,程式碼簡潔優美,幾乎在所有作業系統和平臺上都可以編譯,執行。

一個完整的 Lua 直譯器不過 200 k,在目前所有指令碼引擎中,Lua 的速度是最快的。這一切都決定了 Lua 是作為嵌入式指令碼的最佳選擇。

Lua 指令碼在遊戲領域大放異彩,大家耳熟能詳的《大話西遊II》,《魔獸世界》都大量使用 Lua 指令碼。

Java 後端工程師接觸過的 api 閘道器,比如 OpenrestyKong 都可以看到 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 新增到指令碼快取中,但並不立即執行這個指令碼。

5.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"

5.3 EVALSHA 命令

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

思路是先將 Lua 指令碼先快取起來 , 返回給使用者端 Lua 指令碼的 sha1 摘要。 使用者端儲存指令碼的 sha1 摘要 ,每次請求執行 EVALSHA 命令即可。

EVALSHA 命令基本語法如下:

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

範例如下:

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

5.4 事務 VS Lua 指令碼

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

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

不過我們並不打算在短時間內就移除事務功能, 因為事務提供了一種即使不使用指令碼, 也可以避免競爭條件的方法, 而且事務本身的實現並不複雜。

-- https://redis.io/

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

綜上,Lua 指令碼是搶紅包場景最優的解決方案。

但在編寫 Lua 指令碼時,要注意如下兩點:

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

6 實戰準備

我選擇 Redisson 3.12.0 版本作為 Redis 的使用者端,在 Redisson 原始碼基礎上做一層薄薄的封裝。

建立一個 PlatformScriptCommand 類, 用來執行 Lua 指令碼。

// 載入 Lua 指令碼 
String scriptLoad(String luaScript);
// 執行 Lua 指令碼
Object eval(String shardingkey, 
            String luaScript, 
            ReturnType returnType,
            List<Object> keys, 
            Object... values);
// 通過 sha1 摘要執行Lua指令碼
Object evalSha(String shardingkey, 
               String shaDigest,
               List<Object> keys, 
               Object... values);

這裡為什麼我們需要新增一個 shardingkey 引數呢 ?

因為 Redis 叢集模式下,我們需要定位哪一個節點執行 Lua 指令碼。

public int calcSlot(String key) {
    if (key == null) {
        return 0;
    }
    int start = key.indexOf('{');
    if (start != -1) {
        int end = key.indexOf('}');
        key = key.substring(start+1, end);
    }
    int result = CRC16.crc16(key.getBytes()) % MAX_SLOT;
    log.debug("slot {} for {}", result, key);
    return result;
}

7 搶紅包指令碼

使用者端執行 Lua 指令碼後返回 json 字串。

  • 使用者搶紅包成功
{
    "code":"0",
    //紅包金額   
    "amount":"7.1",
    //紅包編號
    "redPacketId":"162339217730846210"
}
  • 使用者已領取過
{
    "code":"1"
}
  • 使用者搶紅包失敗
{
    "code":"-1"
}

Redis Lua 中內建了 cjson 函數,用於 json 的編解碼。

-- KEY[1]: 使用者防重領取記錄
local userHashKey = KEYS[1];
-- KEY[2]: 運營預分配紅包列表
local redPacketOperatingKey = KEYS[2];
-- KEY[3]: 使用者紅包領取記錄 
local userAmountKey = KEYS[3];
-- KEY[4]: 使用者編號
local userId = KEYS[4];
local result = {};
-- 判斷使用者是否領取過 
if redis.call('hexists', userHashKey, userId) == 1 then
  result['code'] = '1'; 
  return cjson.encode(result);
else
   -- 從預分配紅包中獲取紅包資料
   local redPacket = redis.call('rpop', redPacketOperatingKey);
   if redPacket
   then
      local data = cjson.decode(redPacket);
      -- 加入使用者ID資訊
      data['userId'] = userId; 
     -- 把使用者編號放到去重的雜湊,value設定為紅包編號
      redis.call('hset', userHashKey, userId, data['redPacketId']);
     --  使用者和紅包放到已消費佇列裡
      redis.call('lpush', userAmountKey, cjson.encode(data));
     -- 組裝成功返回值
      result['redPacketId'] = data['redPacketId'];
      result['code'] = '0';
      result['amount'] = data['amount'];
      return cjson.encode(result);
   else
      -- 搶紅包失敗
      result['code'] = '-1';
      return cjson.encode(result);
   end 
end

指令碼編寫過程中,難免會有疏漏,如何進行偵錯?

個人建議兩種方式結合進行。

  1. 編寫 junit 測試用例 ;
  2. 從 Redis 3.2 開始,內建了 Lua debugger(簡稱LDB), 可以使用 Lua debugger 對 Lua 指令碼進行偵錯。

8 非同步任務

在 Redisson 基礎上封裝了兩個類 ,簡化開發者的使用成本。

  1. RedisMessageConsumer : 消費者類,設定監聽佇列名,以及對應的消費監聽器
String groupName = "userGroup";
String queueName = "userAmountQueue";
RedisMessageQueueBuilder buidler =
        redisClient.getRedisMessageQueueBuilder();
RedisMessageConsumer consumer =
        new RedisMessageConsumer(groupName, buidler);
consumer.subscribe(queueName, userAmountMessageListener);
consumer.start();
  1. RedisMessageListener : 消費監聽器,編寫業務消費程式碼
public class UserAmountMessageListener implements RedisMessageListener {
  @Override
  public RedisConsumeAction onMessage(RedisMessage redisMessage) {
   try {
    String message = (String) redisMessage.getData();
    // TODO 呼叫使用者餘額系統
    // 返回消費成功
    return RedisConsumeAction.CommitMessage;
   }catch (Exception e) {
    logger.error("userAmountService invoke error:", e);
    // 消費失敗,執行重試操作
    return RedisConsumeAction.ReconsumeLater;
  }
 }
}

9 寫到最後

"紙上得來終覺淺, 絕知此事要躬行" 。

學習 Redis Lua 過程中,查詢了很多資料,一個例子一個例子的實踐,收穫良多。

非常坦誠的講 , 寫這篇文章之前,我對 Redis Lua 有很多想當然的理解,比如 Redis 的事務不能回滾就讓我驚訝不已。

所以當面對自己不熟悉的知識點時,不要輕易下結論,以謙卑的心態去學習,才是一個工程師需要的心態。

同時,沒有任何一項技術是完美的,在設計和編碼之間,有這樣或者那樣的平衡,這才是真實的世界。


如果我的文章對你有所幫助,還請幫忙點贊、在看、轉發一下,你的支援會激勵我輸出更高質量的文章,非常感謝!