寫在前面的話
作為一個長期和關係型數據庫(RDBMS)打交道的開發者,初次查閲 Redis 文檔時,看到
MULTI、EXEC、DISCARD這些指令,心中難免涌起一股由於熟悉而帶來的安全感。我們的大腦會自動建立映射:
MULTI就是BEGIN,EXEC就是COMMIT,DISCARD就是ROLLBACK。這套組合拳打下來,所有的業務邏輯似乎都應該具備了“不成功便成仁”的原子性保障。但這恰恰是 Redis 給我上的第一課:相似的命名背後,往往藏着截然不同的靈魂。 當你把 MySQL 的事務觀生搬硬套到 Redis 身上時,錯付就已經開始了。
這篇文章將帶你剝開 Redis 事務的外衣,從“原子性”的定義偏差説起,聊聊為什麼在現代開發中,我們越來越傾向於用 Lua 腳本來替代它。
一、先把誤會解開:Redis 事務不是 ACID
在關係型數據庫的世界裏,“事務”二字重若千鈞,它幾乎等同於 ACID(原子性、一致性、隔離性、持久性)。我們習慣了“要麼全有,要麼全無”的安全感。
而在 Redis 的世界裏,MULTI 和 EXEC 更像是一個批處理信號:
把一堆命令先放進隊列裏排隊,等到
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 事務。
為什麼它比事務強?
- 邏輯原子性:一段 Lua 腳本被視作一條命令。Redis 保證腳本執行期間,不會有任何其他腳本或命令插入。
- 效率更高:不需要像
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 定義,而是懂得在由於物理限制而滿是遺憾的真實世界裏,做出那個最不壞的選擇。
文章的最後,想和你多聊兩句。
技術之路,常常是熱鬧與孤獨並存。那些深夜的調試、靈光一閃的方案、還有踩坑爬起後的頓悟,如果能有人一起聊聊,該多好。
為此,我建了一個小花園——我的微信公眾號「[努力的小鄭]」。
這裏沒有高深莫測的理論堆砌,只有我對後端開發、系統設計和工程實踐的持續思考與沉澱。它更像我的數字筆記本,記錄着那些值得被記住的解決方案和思維火花。
如果你覺得今天的文章還有一點啓發,或者單純想找一個同行者偶爾聊聊技術、談談思考,那麼,歡迎你來坐坐。
願你前行路上,總有代碼可寫,有夢可追,也有燈火可親。
