博客 / 詳情

返回

Redis 事務的“原子性”迷思:為什麼我們最終選擇了 Lua 腳本

寫在前面的話

作為一個長期和關係型數據庫(RDBMS)打交道的開發者,初次查閲 Redis 文檔時,看到 MULTIEXECDISCARD 這些指令,心中難免涌起一股由於熟悉而帶來的安全感。

我們的大腦會自動建立映射:MULTI 就是 BEGINEXEC 就是 COMMITDISCARD 就是 ROLLBACK。這套組合拳打下來,所有的業務邏輯似乎都應該具備了“不成功便成仁”的原子性保障。

但這恰恰是 Redis 給我上的第一課:相似的命名背後,往往藏着截然不同的靈魂。 當你把 MySQL 的事務觀生搬硬套到 Redis 身上時,錯付就已經開始了。

這篇文章將帶你剝開 Redis 事務的外衣,從“原子性”的定義偏差説起,聊聊為什麼在現代開發中,我們越來越傾向於用 Lua 腳本來替代它。


一、先把誤會解開:Redis 事務不是 ACID

在關係型數據庫的世界裏,“事務”二字重若千鈞,它幾乎等同於 ACID(原子性、一致性、隔離性、持久性)。我們習慣了“要麼全有,要麼全無”的安全感。

而在 Redis 的世界裏,MULTIEXEC 更像是一個批處理信號

把一堆命令先放進隊列裏排隊,等到 EXEC 時,一次性、按順序地執行它們。

這裏有一個巨大的認知偏差。當我們談論 Redis 的“原子性”時,Redis 指的其實是 隔離性(Isolation),而不是 回滾(Rollback)

  • 它保證的是我執行這段命令的時候,別人不能插隊(獨佔執行)。
  • 它不保證的是如果我執行到一半報錯了,我會幫你把前面的操作撤銷(失敗回滾)。

為了更直觀地理解,我們可以對比一下 Redis 事務和標準 ACID 事務的區別:

特性 關係型數據庫 (MySQL) Redis 事務 差異解讀
原子性 (Atomicity) All or Nothing
失敗即回滾,如同未發生過
All or Partial
沒得商量,錯了就錯了,剩下的接着幹
Redis 不支持 Rollback,部分成功是常態
一致性 (Consistency) 強一致性
約束必須滿足
弱一致性
依賴業務代碼保障
Redis 不會校驗業務約束(如外鍵、非空等)
隔離性 (Isolation) 有多種隔離級別 (RC/RR/Serializable) 串行化執行
執行期間不可被打斷
得益於單線程模型,EXEC 期間天然隔離
持久性 (Durability) WAL 日誌保障
掉電不丟失
取決於 AOF/RDB 配置 默認配置下通常有數據丟失風險

一句話總結:
Redis 事務是“命令隊列 + 獨佔執行”,絕不是“失敗回滾 + 強一致”。


二、殘酷的真相:它真的不包回滾

為了把這個概念刻進 DNA,我們看兩種真實的錯誤場景。

1. 入隊時的“低級錯誤”(全員連坐)

如果你在命令入隊階段就犯了語法錯誤(比如參數寫少了),Redis 還是講道理的,它會直接拒絕整個事務。

127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET key1 value1
QUEUED
127.0.0.1:6379> SET key2      # <--- 語法錯誤:少了參數
(error) ERR wrong number of arguments for 'set' command
127.0.0.1:6379> EXEC
(error) EXECABORT Transaction discarded because of previous errors.

這時候,所有命令都不會執行。這符合我們對“事務”的預期。

2. 執行時的“運行時錯誤”(雖死猶進)

這才是真正的坑。假設語法沒問題,但在執行期間,某條命令因為數據類型不匹配報錯了:

127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET user:A:points 100
QUEUED
127.0.0.1:6379> LPUSH user:A:points "error_data"  # <--- 對 String 類型做 List 操作,註定運行報錯
QUEUED
127.0.0.1:6379> INCR user:A:points                 # <--- 後續命令
QUEUED

127.0.0.1:6379> EXEC
1) OK
2) (error) WRONGTYPE Operation against a key holding the wrong kind of value  <--- 報錯!
3) (integer) 101                                                              <--- 依然成功了!

目瞪口呆了嗎?
第二條命令報錯了,但第三條命令依然歡快地執行了。數據出現了中間態:即所謂的“不一致”。

Redis 官方對此的解釋非常“直男”:

“只有語法錯誤才會被攔截,運行時錯誤屬於程序員的邏輯 Bug(比如把 String 當 List 用)。數據庫不應該為了程序員的 Bug 買單,去搞複雜的回滾機制。


三、進階之路:從原生批量到 Lua 腳本

💡 預備知識:RTT 是性能殺手

一個 Redis 命令的執行可以簡化為 4 步:發送命令 → 命令排隊 → 命令執行 → 返回結果

其中,第 1 步和第 4 步的時間之和稱為 RTT (往返時間)。如果我有 100 個命令,一個個發就需要 100 次 RTT,大部分時間都浪費在網絡傳輸上。
批量操作的核心意義,就是把 100 次 RTT 壓縮成 1 次。

既然 MULTI/EXEC 這麼“頭鐵”,那我們在實際開發中到底該怎麼選?我們可以把 Redis 的批量操作能力分為幾個段位。

Lv1. 原生批量命令 (MSET / MGET)

這是最簡單、最快的方式。

  • 特點:原生的原子性。MSET key1 val1 key2 val2 是一個原子操作,要麼都成功,要麼都失敗(在 Redis 層面)。
  • 示例
    MSET key1 "Hello" key2 "World"
    
  • 侷限:只能處理同一種命令,邏輯死板。

Lv2. 管道 (Pipeline)

當你需要批量執行幾十個不同的命令,且不需要它們之間有邏輯依賴時,Pipeline 是首選。

  • 特點唯快不破。它把幾十個命令打包,一次網絡請求(RTT)發給服務器,服務器執行完再一次性返回。
  • 形象理解下 100 個單 -> 一次性收 100 個快遞 (1 次 RTT)
  • 與事務的區別
    • 非原子性:Pipeline 只是打包發送,Redis 可能會在處理 Pipeline 中間穿插執行其他客户端的命令(交錯執行)。
    • 效率更高:不需要像事務那樣每個命令都發一次,只需要發送一次。

Lv3. 事務 (MULTI / EXEC)

比 Pipeline 多了一層保障:獨佔執行

  • 特點原子操作(隔離性)
    • 兩個不同的事務不會同時運行。在 EXEC 執行期間,Redis 會“以此為尊”,保證沒有其他客户端能插隊。
  • 缺點
    • RTT 開銷大:事務中 每個命令都需要單獨發送 到服務端入隊,請求次數並沒有減少。
    • 不支持回滾,不支持在事務中間做邏輯判斷。

Lv3.1 事務 + WATCH (樂觀鎖)

單純的 MULTI/EXEC 往往比較雞肋,因為它無法感知中間狀態。但這套機制唯一的“王牌”組合是配合 WATCH 命令,實現樂觀鎖 (CAS)。

  • 場景:秒殺扣減庫存。

    • MULTI 之前 WATCH stock
    • 如果在 EXEC 執行前 stock 被別人改了,整個事務原地取消(返回 nil)。
  • 代碼示例

    WATCH stock:001                # 1. 監視庫存
    GET stock:001                  # 2. 讀庫存,發現是 10
    MULTI                          # 3. 開啓事務 (開始排隊)
    DECR stock:001                 # 4. 減庫存
    EXEC                           # 5. 執行
    # 如果在步驟 1-5 之間,別人改了 stock:001,這裏會返回 (nil),事務回滾。
    
  • 致命弱點高併發下性能極差

    • 就像一羣人搶一個麥克風,一個人搶到了,其他人的 CAS 全部失敗,只能客户端重試(自旋)。
    • 競爭越激烈,重試越頻繁,CPU 空轉越嚴重。

Lv4. 最終兵器 —— Lua 腳本

從 Redis 2.6 開始,Lua 腳本成為了解決複雜原子性問題的核心方案,它完美替代了 WATCH 事務。

為什麼它比事務強?

  1. 邏輯原子性:一段 Lua 腳本被視作一條命令。Redis 保證腳本執行期間,不會有任何其他腳本或命令插入
  2. 效率更高:不需要像 WATCH 那樣反覆重試。腳本在服務器端執行,只有一次 RTT。

示例:安全的“先查後改”

-- 判斷 key 是否等於預期值,如果是則刪除
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end

⚠️ 必須警惕的缺陷:Lua 也不回滾!
雖然 Lua 腳本被稱為“原子操作”,但請注意:它的原子性依然指的是不被打擾,而不是失敗回滾

如果 Lua 腳本運行到中途出錯(比如調用了不存在的命令,或顯式報錯退出),腳本會停止執行,但之前已經執行過的寫操作,是不會被撤銷的!

這意味着,即使是 Lua,也不能給你帶來 RDBMS 那種“回滾一切”的安全感。你依然需要在代碼層面保證邏輯的嚴密性。


四、總結:選型決策表

為了讓你在實際業務中不再糾結,我整理了一份簡單的決策表:

需求場景 推薦方案 核心理由
簡單批量讀寫 (KV) MSET / MGET 原生命令,最快,最省心。
大量離散命令 (無關聯) Pipeline 網絡開銷最低,吞吐量最高。
需要 CAS (低併發) WATCH + MULTI 事務唯一的用武之地。 適合低頻競爭,實現簡單。
複雜邏輯 / 高併發 Lua 腳本 行業標準。 避免了 CAS 自旋的性能開銷,原子性強。
即使報錯也要回滾 MySQL / RDBMS 別為難 Redis。 它沒有 Undo Log,做不到真正的回滾。

寫在最後

回頭看,Redis 事務這套機制,就像是一個“如果不仔細讀説明書一定會用錯”的半成品。

但正是這個“半成品”,折射出了 Redis 最底層的價值觀:為了性能,可以犧牲一切“看起來很美”的抽象。它拒絕了沉重的 Undo Log,拒絕了複雜的隔離級別,只留下了一個最簡單的“排隊執行”邏輯。

所以,當我們下次再寫下 MULTI 的時候,心裏要清楚:

  • 如果只是為了快,Pipeline 才是那個不講武德的“加速器”。
  • 如果只是為了防插隊,Transaction 夠用了,但在高併發下,它脆弱得像個易碎品。
  • 如果要處理真正的複雜邏輯,請毫不猶豫地擁抱 Lua —— 雖然它也不會回滾,但至少在“執行原子性”上,它是我們手裏最穩的那張牌。

真正的技術成熟,不是背誦八股文裏的 ACID 定義,而是懂得在由於物理限制而滿是遺憾的真實世界裏,做出那個最不壞的選擇。


文章的最後,想和你多聊兩句。

技術之路,常常是熱鬧與孤獨並存。那些深夜的調試、靈光一閃的方案、還有踩坑爬起後的頓悟,如果能有人一起聊聊,該多好。

為此,我建了一個小花園——我的微信公眾號「[努力的小鄭]」。

這裏沒有高深莫測的理論堆砌,只有我對後端開發、系統設計和工程實踐的持續思考與沉澱。它更像我的數字筆記本,記錄着那些值得被記住的解決方案和思維火花。

如果你覺得今天的文章還有一點啓發,或者單純想找一個同行者偶爾聊聊技術、談談思考,那麼,歡迎你來坐坐。
85f114bceb12e933bb817ec5fecdfef7

願你前行路上,總有代碼可寫,有夢可追,也有燈火可親。

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.