Redis 單執行緒為什麼反而快?——以及 O(N) 命令的地雷

· tech

#redis#performance

📑 目錄

第一篇說 Redis 快的原因之一是「單執行緒 + 免鎖」。這聽起來很反直覺——單執行緒不是慢嗎? 這篇就把這件事講透:為什麼單執行緒反而快、它換來什麼,以及它一體兩面的代價——一個慢命令會卡住所有人。

單執行緒為什麼反而快

關鍵在於:Redis 的瓶頸從來不是 CPU,而是記憶體與網路。 它的每個命令都是簡單的記憶體操作,CPU 幾乎永遠有餘力;真正的限制在「資料搬多快、網路吞多快」。既然 CPU 不是瓶頸,多執行緒能帶來的那點平行運算好處就很有限,反而要付出鎖、競態、context switch的代價。所以 Redis 反其道而行:乾脆單執行緒,把這些代價全部省掉。

那一個執行緒怎麼同時服務上萬條連線?答案是 event loop + I/O 多工(epoll):

單執行緒 + event loop:一條隊,一個一個處理 client client client client 上萬連線 epollI/O 多工一個執行緒盯全部 命令佇列(一條隊)cmd1cmd2cmd3 單執行緒執行逐一執行、每個原子無鎖、無 race Redis 6 的「多執行緒」只用在讀寫 socket 這些網路雜活—— 命令的「執行」仍是單執行緒,所以原子性、免鎖的好處通通不變
一個執行緒用一個迴圈,靠 epoll 同時盯住上萬條連線:誰有資料進來就處理誰,把命令排進一條隊,再一個一個執行完。這個模型換來三件事——無鎖(沒有多執行緒搶資源)、無 race(不會有兩個命令同時改同一個 key)、原子(每個命令跑完才輪下一個,天生不可分割)。單執行緒不是妥協,是拿「簡單與可預測」換來的設計優勢

Redis 6 的「多執行緒」是什麼(別誤會)

你可能聽過「Redis 6 開始支援多執行緒了」——這句話很容易讓人誤會。事實是:Redis 6 加的多執行緒,只用在網路 I/O(讀取請求、把回應寫回 socket 這些序列化雜活,在高流量下確實佔時間);但命令的執行本身,依然是單執行緒。所以它沒有變成「多執行緒資料庫」,前面說的無鎖、原子那些好處全都還在。理解這點很重要:別因為「Redis 現在多執行緒了」就以為慢命令的問題消失了——沒有,執行還是排一條隊。

代價:一個慢命令,卡住所有人

單執行緒最大的代價,就藏在「所有命令排同一條隊」這件事裡:只要有一個命令跑得久,後面所有 client 全部乾等。 因為沒有第二個執行緒能去服務他們:

一個慢命令,卡住所有人 ❌ 一次做完 KEYS * 掃全庫 O(N) 一次做完(佔住唯一執行緒) → 這段時間 client B、C、D 全部乾等 → timeout → 上游 retry → 雪上加霜 ✅ 游標分批 SCAN(1) 別的 cmd SCAN(2) 別的 cmd SCAN(3) 每次只掃一小批 O(1),中間讓別的命令插空 → 不卡全場 同理 HSCAN / SSCAN / ZSCAN;大 key 刪除用 UNLINK(非同步)取代 DEL;開 SLOWLOG 抓慢命令
因為只有一條隊,一個 O(N) 的慢命令會讓整台 Redis 在那幾十、幾百毫秒裡對所有 client 沒反應。可怕的是後果會放大:客戶端 timeout → 重試 → 更多壓力,一路滾成 連鎖失效。解法的共通精神是「別一次做完,分批做、讓路」——用 SCAN 家族游標分批取代 KEYS/HGETALL 全撈,用 UNLINK 非同步釋放大 key

實務上的地雷清單值得背下來:KEYS *(掃全庫)、對大集合做 HGETALL/SMEMBERS/LRANGE 0 -1(整包撈回)、SORT 大集合、DEL 一個幾百萬元素的大 key(光是釋放記憶體就是 O(N))、以及跑太久的 Lua 腳本。它們的共通點都是 O(N) 且一次做完,而在單執行緒的世界裡,「一次做完」就等於「這段時間誰都別想用」。

維運命令:避開 O(N)、揪出慢命令

把上面的坑變成實際操作——這幾條是維運 Redis 的日常肌肉記憶:

# ✗ 別在 Production 打這些全掃 O(N),一條就卡住所有人
KEYS *                              # 掃全庫
SMEMBERS bigset                     # 一次拉整個大集合
# ✓ 改用游標分批,不阻塞
SCAN 0 MATCH user:* COUNT 100       # 反覆用回傳的游標再打,直到游標回 0
# 揪兇手
SLOWLOG GET 10                      # 最近 10 筆慢命令(含耗時與參數)
redis-cli --bigkeys                 # 掃出佔記憶體的大 key
OBJECT ENCODING mykey               # 看底層編碼,巨大結構常是元凶
UNLINK bigkey                       # ✓ 非阻塞刪除(DEL 大 key 會卡住全場)

心法就一句:單執行緒下,任何 O(N) 都是全場的稅。用 SCAN 取代 KEYS、用 SLOWLOG / --bigkeys 抓兇手、用 UNLINK 取代 DEL 大 key——這三件事幾乎涵蓋了 Redis 效能事故的一大半。

反思

單執行緒是「用簡單換可預測」的經典取捨

Redis 的單執行緒設計,是我最愛拿來講「工程取捨」的例子。多數人的直覺是「多執行緒 = 快 = 好」,但 Redis 反過來證明:當你的瓶頸不在 CPU 時,多執行緒帶來的平行好處很小,付出的鎖與競態代價卻很大——這時候「單執行緒」才是更快、更簡單、更可預測的選擇。它讓我學會一件事:效能設計不是「把所有能加速的都加上」,而是先搞清楚瓶頸在哪,再決定投資方向。加了一堆用不到的平行能力,只會換來一堆用得到的 bug。搞錯瓶頸的優化,是白費力氣裡最貴的一種。

慢命令卡全場,是我看過最容易踩、後果最嚴重的 Redis 坑

KEYS * 這個坑,我看太多人踩過——在本機資料少的時候跑得飛快,一上 Production、庫裡幾百萬個 key,一行 KEYS * 就讓整台 Redis 凍住好幾百毫秒,所有依賴它的服務同時 timeout。最陰險的是它平常完全沒事,只在資料長大、且剛好有人手賤下一個全掃命令時爆炸。從那之後我立了兩條規矩:Production 禁用 KEYS(用 SCAN),以及任何會碰到「整個集合」的操作都要先問一句「這集合會不會長很大」。單執行緒的好處是可預測,但這份可預測是有前提的——前提是你沒有往那條唯一的隊裡,塞一個跑不完的命令。

看命令的複雜度,是用好任何記憶體系統的基本功

Redis 逼我養成一個好習慣:用一個命令前,先去看它的時間複雜度。 Redis 文件很貼心,每個命令都標了複雜度——GET 是 O(1)、ZADD 是 O(log N)、SMEMBERS 是 O(N)、SINTERSTORE 取決於最小集合。這些不是學術細節,而是「這行命令會不會在半夜搞垮服務」的直接指標。我現在的習慣是:凡是看到 O(N) 或更糟的命令,就自動多想一步「這個 N 會多大、會不會爆」。這個習慣不只用在 Redis——任何你要放進熱路徑的操作,先估它的複雜度與最壞情況,是把「平常很快、偶爾炸掉」這種最難查的問題,擋在上線之前的基本功。