Redis 單執行緒為什麼反而快?——以及 O(N) 命令的地雷
· tech
📑 目錄
第一篇說 Redis 快的原因之一是「單執行緒 + 免鎖」。這聽起來很反直覺——單執行緒不是慢嗎? 這篇就把這件事講透:為什麼單執行緒反而快、它換來什麼,以及它一體兩面的代價——一個慢命令會卡住所有人。
單執行緒為什麼反而快
關鍵在於:Redis 的瓶頸從來不是 CPU,而是記憶體與網路。 它的每個命令都是簡單的記憶體操作,CPU 幾乎永遠有餘力;真正的限制在「資料搬多快、網路吞多快」。既然 CPU 不是瓶頸,多執行緒能帶來的那點平行運算好處就很有限,反而要付出鎖、競態、context switch的代價。所以 Redis 反其道而行:乾脆單執行緒,把這些代價全部省掉。
那一個執行緒怎麼同時服務上萬條連線?答案是 event loop + I/O 多工(epoll):
Redis 6 的「多執行緒」是什麼(別誤會)
你可能聽過「Redis 6 開始支援多執行緒了」——這句話很容易讓人誤會。事實是:Redis 6 加的多執行緒,只用在網路 I/O(讀取請求、把回應寫回 socket 這些序列化雜活,在高流量下確實佔時間);但命令的執行本身,依然是單執行緒。所以它沒有變成「多執行緒資料庫」,前面說的無鎖、原子那些好處全都還在。理解這點很重要:別因為「Redis 現在多執行緒了」就以為慢命令的問題消失了——沒有,執行還是排一條隊。
代價:一個慢命令,卡住所有人
單執行緒最大的代價,就藏在「所有命令排同一條隊」這件事裡:只要有一個命令跑得久,後面所有 client 全部乾等。 因為沒有第二個執行緒能去服務他們:
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——任何你要放進熱路徑的操作,先估它的複雜度與最壞情況,是把「平常很快、偶爾炸掉」這種最難查的問題,擋在上線之前的基本功。