總覽與定位
Redis 是單執行緒的記憶體內資料結構伺服器。本篇講它自己怎麼跑、怎麼活過重啟、怎麼從一台長成一群——不重講應用層怎麼 Cache-Aside。
把 Redis 當「比較快的 Memcached」會漏掉它真正的契約:命令在單一事件迴圈裡依序執行,資料以型別與編碼住在行程記憶體裡,持久化、複寫、叢集都是這份記憶體的外掛,不是另一套資料庫。應用層讀寫模式(Cache-Aside、Write-Through)已經在 快取策略 講完;穿透、擊穿、雪崩的防護在 快取失效模式與雪崩防護。本篇站在 Redis 行程內部:執行模型、編碼、落盤、主從、slot、maxmemory。
flowchart LR
C["Client"] --> E["單執行緒事件迴圈"]
E --> M["記憶體資料結構"]
M -. "fork 後背景寫出" .-> P["RDB / AOF"]
M -. "命令流 + backlog" .-> R["Replica / Cluster"]
所有命令都穿過同一條單執行緒事件迴圈;持久化與複寫是掛在記憶體之後的兩條旁路(虛線),不在命令的關鍵路徑上——這也是為什麼一個 O(N) 命令就能卡住全部人,而 AOF rewrite 卻只在 fork 那一瞬間才痛。
Cache-Aside、Read-Through、TTL vs LRU 的應用層語意交給 快取策略。
Bloom、負快取、單 key 互斥重建、TTL jitter 交給 快取失效模式與雪崩防護。
Hash ring、虛擬節點、節點增減時 key 遷移量,交給 一致性雜湊——Cluster 只用固定 16384 slot 與 MOVED/ASK。
同步/異步複寫、R+W>N、版本向量交給 資料複寫基礎 與 進階複寫一致性;這裡只談 Redis 自己的 backlog、offset 與 replica lag。
單執行緒事件迴圈與命令複雜度
所有命令走同一條迴圈。延遲不是「機器慢」,是上一條命令還沒做完。
Redis 主執行緒用 I/O 多工(epoll/kqueue)收 socket,從輸入緩衝區解析出一條 RESP 命令,執行,再把回覆寫回。執行期間不切換到下一條命令。這讓每個 key 的操作天然原子——INCR 不需要應用層鎖——代價是:一條 O(N) 命令會讓後面所有客戶端一起排隊。
1 · 哪些命令是延遲炸彈
| 命令 | 複雜度 | 何時會卡住迴圈 |
|---|---|---|
GET / SET / INCR / HGET |
O(1) | 正常路徑。這是 Redis 低延遲的來源 |
KEYS * |
O(N) 全庫 | 生產環境等同停機。用 SCAN 分批 |
HGETALL / SMEMBERS / LRANGE 0 -1 |
O(N) 該 key | 百萬 field 的 Hash 一次拉完,迴圈被這一個 key 綁住 |
DEL 大 key |
O(N) 釋放 | 同步釋放物件圖。改 UNLINK(lazy free)把釋放丟給背景執行緒 |
FLUSHALL / 大量 EXPIRE |
視實作 | 過期是惰性刪除 + 定時抽樣,不是「時間到自動免費」 |
O(N) 的 N 是「這個結構有多少元素」,不是「你有多少台機器」。集群切再細,單一 Hash 百萬 field 仍會卡住那一台的迴圈。
2 · Redis 6 的 I/O threads 不解這個契約
I/O threads 只分擔 socket 讀寫:把位元組從網卡搬進緩衝、把回覆寫出去。命令執行、物件編碼、過期抽樣、eviction 候選,仍在主執行緒。io-threads-do-reads 打開之後,benchmark 的吞吐量會升,但 KEYS 照樣阻塞所有人。
單執行緒在記憶體操作上極快:沒有鎖、快取親和、原子語意免費。慢的是你丟進去的命令複雜度與大 key。面試被問「Redis 為什麼單執行緒還那麼快」,答:執行模型適合記憶體 O(1) 操作;被問「如何水平擴充讀」,答 replica 與 Cluster,不是把主執行緒改成多執行緒跑命令。
KEYS,大集合用 HSCAN/UNLINK;I/O threads 只搬位元組,不平行執行命令。
資料結構選型
對外四種常用型別,對內各有兩套編碼。記憶體帳單在編碼,不在型別名。
同一個「使用者資料」,SET user:1 "{json}"、HSET user:1 name …、ZADD rank score member 佔的記憶體與能做的查詢完全不同。選型規則是:先列出要做的操作(單點讀、欄位更新、範圍、成員測試),再選型別;最後確認編碼會不會在資料長大時從緊湊結構升級成散列表/跳表。
| 型別 | 緊湊編碼 | 長大後 | 適合 | 不適合 |
|---|---|---|---|---|
| String | embstr / raw SDS | 單一緩衝 | 快取 blob、計數器、INCR、短 TTL session |
經常改 JSON 裡一個欄位——每次都重寫整串 |
| Hash | listpack(舊稱 ziplist) | hashtable | 物件欄位局部更新、HINCRBY、field 級 TTL(依版本) |
把整張表塞進一個 Hash 再 HGETALL |
| ZSet | listpack | skiplist + hash | 排行榜、延遲佇列(score = 時間)、範圍查詢 | 只測「在不在」——那是 Set 的工作 |
| Set | intset | hashtable | 去重、標籤、交集/差集、隨機抽樣 | 需要順序或分數;整數集合一旦混入非整數就升級 |
listpack/intset 是為了 CPU cache 與指標開銷:小集合連續存放。一旦超過 hash-max-listpack-entries 等閾值,就變成真正的 hashtable/skiplist,記憶體會跳一截——這是「昨天還好好的,今天 RSS 爆了」的常見原因。
- 整份文件、很少改欄位:String + 應用層序列化
- 常改單一屬性:Hash,避免每次序列化整包 JSON
- 要 top-N 或時間排序:ZSet,score 就是排序鍵
- 只要唯一集合:Set;純整數 ID 可吃到 intset 的便宜
- 百萬把 16-byte 的短 key,過期字典與 SDS header 可能比 value 還大
- Hash 的每個 field 都有獨立開銷;欄位極多時,拆成多把 Hash 或改文件型儲存
- ZSet 同時維持 skiplist 與 hash,同一 member 存兩份索引
左欄是選型時該按的順序(先問操作、再選型別、最後才看編碼閾值),右欄是同一個決定在記憶體上的代價。對照著看會發現:真正讓 RSS 跳一截的不是 payload,而是超過閾值後從 listpack/intset 換成 hashtable/skiplist 所帶來的指標開銷。
一個 8 MB 的 String 當使用者資料、一個 50 萬 member 的 ZSet 當「全站排行」,讀寫都會卡主迴圈,複寫與 RDB 也會被這一個 key 拖住。正確做法是把大結構按查詢邊界切開(按時間分片的 ZSet、按 field 前綴切開的 Hash),而不是先加 replica 指望讀擴散——replica 仍要完整複製那把大 key。
持久化:RDB 與 AOF
記憶體是真相來源;RDB 與 AOF 是兩種不同的「死了能救回多少」契約。
不開持久化的 Redis 重啟後是空的——這對純快取可以接受(回填交給應用,見 快取策略)。一旦 Redis 被當成 session、佇列、排行榜的權威,你必須回答兩個問題:崩潰時最多丟多久的資料,以及重啟要多久才能再接寫。
- 子行程
fork,利用 OS copy-on-write 把當下的 keyspace 寫成壓縮的.rdb - 復原快、檔案小,適合備份與跨機房種子
- 上次快照之後的寫入,崩潰就沒了。間隔是你設的
save規則 fork當下的 RSS 會跳:寫入密集時 COW 讓父子各留一份髒頁
- 每條寫命令追加到日誌。重啟時重放,狀態回到「日誌裡最後一筆 fsync 成功的寫入」
appendfsync always:每命令 fsync,幾乎不丟,延遲最高everysec(預設):最多丟約一秒。這是大多數生產的折衷no:交給 OS。崩潰窗口不確定- 日誌會膨脹,靠背景 rewrite 壓回;Redis 4+ 可用 RDB preamble 加速 rewrite
兩欄的分野是「崩潰之後還剩什麼」:RDB 給的是一個完整但過去的時間點,AOF 給的是一條可重放的命令流。看 AOF 那欄的 appendfsync 三行——資料安全的刻度就在那裡,其餘都是還原速度與檔案大小的取捨。
| 設定 | 崩潰最多丟 | 寫入延遲 | 重啟速度 |
|---|---|---|---|
| 無持久化 | 全部 | 最低 | 瞬間(空的) |
| 僅 RDB,例如 60 秒 | 最多一個快照間隔 | 低(背景 fork) | 快(載入快照) |
| AOF everysec | 約 1 秒 | 中 | 中(重放;有 RDB preamble 則快很多) |
| AOF always | 約 0 | 高 | 同 AOF |
兩種都開時,重啟預設以 AOF 為準(它更新)。面試答「開了 RDB 就不會丟資料」是錯的:RDB 保證的是「有一份過去的完整點」,不是「最後一筆寫入還在」。
寫入很忙時做 RDB/AOF rewrite,子行程 COW 會讓實際記憶體接近 2×,Linux 可能在 overcommit 不允許時直接拒絕 fork——於是備份失敗,而主迴圈還在。磁碟慢則 AOF fsync 回壓主執行緒(everysec 也會在上一秒還沒 sync 完時記錄延遲)。觀測要用 latest_fork_usec、aof_delayed_fsync,不要只看 QPS。
主從複寫與 Sentinel
Replica 複製的是 Redis 自己的命令流與 backlog,不是通用的 quorum 協議。Sentinel 只決定誰變成 master。
一主多從是 Redis 最常見的高可用形狀:寫進 master,replica 用非同步命令流追上。通用的同步/異步取捨與「讀到什麼」的一致性光譜,資料複寫基礎 已經講過。這裡只留下 Redis 專屬的三個旋鈕:PSYNC 能否部分重同步、replica lag 怎麼觀測、Sentinel 如何選主而避免腦裂。
1 · 全量 SYNC 與部分 PSYNC
flowchart TD
A["Replica 重新連上"] --> B["送 PSYNC + offset"]
B --> D{"offset 還在 backlog?"}
D -- "是" --> E["部分重同步"]
D -- "否" --> F["master fork RDB"]
F --> G["期間寫入進 backlog"]
G --> E
分岔點只有一個:replica 的 replication offset 是否還落在 master 的 repl_backlog 窗口內。掉出窗口(長斷線、backlog 太小、master 重啟換了 runid)的帳單是 master 再 fork 一份 RDB——這才是「replica 閃斷會拖垮 master」的來源。
Master 維持一份固定大小的 repl_backlog。Replica 短暫斷線後,若它的 replication offset 仍在 backlog 窗口內,就只補那一段(PSYNC)。窗口外——長斷線、backlog 太小、master 重啟換了 runid——就走全量:master 再 fork 一份 RDB 傳過去,期間寫入仍進 backlog,傳完再補增量。全量期間 master 的 CPU、fork、網路都是帳單;這是「為什麼 replica 閃斷會拖垮 master」的原因。
2 · 延遲觀測與寫入保護
INFO replication的master_repl_offset與 replica 的slave_repl_offset相減,就是以位元組計的落後。master_last_io_seconds_ago看連線是否活著;offset 接近但秒數很大,常常是沒寫入,不是健康。min-replicas-to-write+min-replicas-max-lag:落後超過閾值的 replica 不計入,寫入會被拒絕。這是 Redis 版的「不要在沒有足夠新複本時承認寫入」——不是 quorum 協議,是 master 自己數人頭。- 讀 replica 預設可能讀到舊值。需要讀已確認寫入,讀 master,或在應用層等 offset。
3 · Sentinel 故障轉移
Sentinel 是一組獨立行程,對同一份 Redis 拓撲做監控。主觀下線(SDOWN)只是「我連不上」;客觀下線(ODOWN)要達到設定的 quorum 個 Sentinel 同意。然後 Sentinel 之間再選一個 leader 來執行 failover:挑 replica(看優先級、offset、runid)、SLAVEOF NO ONE、通知其他 replica 改追新主、通知客戶端。
- Sentinel 至少 3 個,quorum 為多數。兩個機房對半切時,少數側不該能單獨 failover
replica-priority 0:這台永不被提升(通常是跨區唯讀、或資料明顯落後的分析 replica)- 客戶端必須訂閱 Sentinel 的切主事件,或走會更新拓撲的代理;寫死 master IP 等於沒做 HA
- 網路切成兩半,少數 Sentinel 與舊主在一起:若 quorum 不夠大,可能出現兩邊都以為自己是主
- 即使 Sentinel 做對,舊主在被降級前仍可能接受寫入。那些寫入在重新成為 replica 時會被丟掉
- 這不是 Raft:沒有 fencing token 自動作廢舊主的寫入。重要寫入要能冪等,或接受「failover 窗口會丟」
左欄是讓「多數派存在且唯一」的設定,右欄是這組設定沒做對時的長相。重點在右欄第一行:腦裂的成因不是 Sentinel 數量不夠,而是舊主在分區另一側仍然接受寫入——所以要靠 min-replicas-to-write 這類寫入保護把它自己關掉。
Sentinel 適用「一台邏輯 master + 若干 replica」。資料仍在同一份 keyspace。要水平切資料,用 Cluster(第 6 節),不要把 Sentinel 與 Cluster 疊成同一套拓撲——兩者的客戶端協定、故障轉移與組態來源都不同。
Cluster:16384 slot 與線上 resharding
Cluster 把 keyspace 切成固定 16384 個 slot。遷移的單位是 slot,客戶端靠 MOVED 與 ASK 找到新家。
一致性雜湊用 ring 與虛擬節點,讓節點增減時只搬鄰近的 key——那是 一致性雜湊 的題。Redis Cluster 不走那條路:它預先切好 16384 個槽,每個槽指到一台主節點。新增節點 = 從既有節點搬走一些槽;刪節點 = 把它的槽交給別人。Hash 函式只決定 key 進哪一槽,不決定槽在哪一台機器。
花括號 hash tag 讓相關 key 進同一槽:{user:100}:profile 與 {user:100}:cart 都只雜湊 user:100,於是 MULTI/WATCH 這類單槽事務才可能成立。沒有 tag 的跨 key 事務,Cluster 會直接拒。
1 · 客戶端必須是 cluster-aware
客戶端本地快取一份 slot → node 對照表。第一次可以對任意節點下命令;之後靠兩種重定向自我修正:
| 回應 | 何時出現 | 客戶端該做什麼 |
|---|---|---|
MOVED slot host:port |
槽的歸屬已永久改到該節點 | 更新本地對照表,把命令重打到新節點。之後同一槽走新家 |
ASK slot host:port |
槽正在遷移:本節點是來源且這把 key 已經被搬走 | 這一次帶 ASKING 去目標節點。不要改對照表——槽還沒搬完,下一把 key 可能還在來源 |
TRYAGAIN |
叢集忙碌或遷移中無法服務 | 退避重試,不要當成應用錯誤 |
L4 負載平衡把連線打到隨機節點,卻期望「永遠打對槽」,會讓每一條命令都先被 MOVED 一次。正確做法是 client-side 對照表,或懂 cluster 協定的 L7 proxy。
2 · 線上遷移:MIGRATING / IMPORTING
管理員把 slot 7 從節點 A 遷到 C 時,A 標成 MIGRATING,C 標成 IMPORTING,然後一把一把 MIGRATE(實作上接近 DUMP → RESTORE → DEL)。期間:
- key 還在 A:A 直接服務。客戶端對照表仍指向 A,這是 HIT。
- key 已到 C:A 回
ASK C。客戶端這一次去 C;沒更新對照表。 - 整槽搬完、
SETSLOT 7 NODE C:歸屬切到 C。之後任何人問 A,得到MOVED C,對照表才改。
sequenceDiagram
participant CL as Client
participant A as 節點 A
participant C as 節點 C
Note over CL,C: 遷移中:A MIGRATING、C IMPORTING
CL->>A: GET cart:42,對照表指向 A
A-->>CL: ASK C
CL->>C: ASKING + GET
C-->>CL: 回值
Note over CL: 對照表不變,下一次仍先問 A
Note over CL,C: SETSLOT 7 NODE C 之後
CL->>A: GET cart:42,對照表仍指向 A
A-->>CL: MOVED C
CL->>C: GET cart:42
C-->>CL: 回值
Note over CL: 對照表改寫,之後直打 C
兩趟都先打到 A,差別在回程之後:ASK 不改對照表,下一次仍先問 A;MOVED 才讓客戶端把 slot 7 記到 C。所以 ASK 是「這一次借道」,MOVED 是「歸屬換人」。
3 · 互動:在 16384 格上看歸屬、傾斜與重定向
下面這張格帶就是完整的 keyspace:128 欄 × 128 列 = 16384 格,一格一個 slot(左上是 slot 0,右下是 slot 16383),顏色是歸屬節點。key 的 slot 由 CRC16 當場算出,不是查表。
在格帶上點一下,可查該格的 slot 號與歸屬。
「填一把落在遷移段的 key」是真的從 job:1 開始逐一算 CRC16,優先找落在正在搬的那一格、而且會拿到 ASK 的那一把(搜尋上限 20 萬把,按下去大約要等 0.3 秒)。
三個可驗的點:(1) 加一台的搬遷量正好是 16384 ÷ 新節點數——Redis 只從既有各節點的尾端切走要給新節點的那一份,其餘 slot 不動,所以新節點在格帶上是好幾段同色而不是一整條;這與一致性雜湊的鄰近重映射殊途同歸,但靠的是固定對照表。(2) 同一把 key 在遷移進度 0%/中途/100% 會依序得到 HIT、ASK、MOVED,三者由「它的 slot 排在遷移清單的第幾格」算出。(3) 「正在搬的那一格裡,這把 key 到底搬走了沒」現實中取決於 MIGRATE 跑到哪;本 demo 為了可重現,改用該 key 的 CRC16 落在 slot 索引之外的高位當代理規則(低 14 位就是 slot 本身,對同一格的所有 key 都相同,拿來當代理會讓整格答案一致)——所以同一格裡不同的 key 會拿到不同答案,這正是遷移窗口裡 MGET 會一半 ASK、一半本地的原因。
MIGRATING 時,同一槽裡有的 key 在 A、有的在 C。客戶端若對兩把 key 做一次 MGET,可能一半 ASK、一半本地——這就是為什麼遷移期要避免跨 key 批次,或接受重試。另外:新增節點不會自動把熱 slot 拆開。熱 key 若都落在同一槽(沒設計 hash tag),那一槽再怎麼遷,流量仍綁在一台。熱槽要靠拆 key,不是靠多加節點。
MOVED 更新對照表;ASK 只重試這一次。線上 resharding 一次搬一個 slot,遷移期來源與目標同時活著。
maxmemory、淘汰策略與大/熱 key
記憶體上限是硬牆。牆撞上時,Redis 不是變慢,是開始丟 key 或拒絕寫入。
maxmemory 把 RSS 目標設死。超過之後,下一個會增加記憶體的命令先走 eviction:依政策挑犧牲者,直到騰出空間,或發現政策不允許而回 OOM。這與 快取策略 裡「快取滿了用 LRU 踢」是同一類問題,但政策名稱與「哪些 key 有資格被踢」是 Redis 產品細節。
| 政策 | 候選集合 | 挑誰 | 寫入撞牆時 |
|---|---|---|---|
noeviction |
— | — | 回 OOM,讀仍可 |
allkeys-lru / allkeys-lfu |
全部 key | 最久未碰 / 最低頻 | 踢掉後寫入成功 |
volatile-lru / volatile-lfu |
僅設了 expire 的 key | 同上,但只在有 TTL 的子集 | 沒有可踢的就 OOM |
volatile-ttl |
僅設了 expire 的 key | TTL 最短的先走 | 持久 key 永不被這條政策踢 |
「session 設 TTL、排行榜不設」時用 volatile-*,撞牆只犧牲 session。兩邊都不設 TTL 卻開 volatile-lru,等價於 noeviction——這是上線後寫入突然 OOM 的經典誤配。
大 key 與熱 key
- 偵測:
MEMORY USAGE、redis-cli --bigkeys、慢日誌裡的HGETALL/LRANGE - 處置:按查詢邊界拆鍵(Hash 按 field 前綴、ZSet 按時間桶),刪除用
UNLINK - 複寫與 RDB 都會完整搬這把 key——replica 救不了大 key
- 偵測:
redis-cli --hotkeys(需 LFU 政策或等效計數)、代理層 per-key QPS - 處置:本地進程快取那一把、讀打 replica、把 value 複製成
key#1 … key#N打散讀 - Cluster 幫不上忙:同一 key 永遠同一槽、同一主節點
左右兩欄是兩種不同的病:左邊是單鍵太大(一次命令就卡住整條迴圈,複寫與 RDB 也被它拖住),右邊是單鍵太熱(key 不大,但流量全壓在同一個 slot 的同一台主節點)。看處置那一列就知道差別:大 key 要拆結構,熱 key 要複製或打散,兩者都不是加節點能解的。
快取 miss 瞬間打穿 DB、用 singleflight/mutex 合併回源,是 快取失效模式與雪崩防護 的範圍。這裡的熱 key 是「Redis 自己的這把 key 被讀寫到主迴圈飽和」——即使 DB 完全沒被打到,這台 Redis 也已經是瓶頸。兩個層次都要看,但解法不同。
allkeys-lru 什麼都可踢;volatile-ttl 只踢有過期的,持久資料撞牆就 OOM。大 key 要拆,熱 key 要打散;Cluster slot 搬不走單鍵熱點。
互動 · maxmemory 撞牆:誰被踢、命中率掉多少
同一份工作集、同一條存取軌跡,只換政策就換一整組犧牲者。這裡把「候選集合是誰」造成的差別算出來。
模擬條件:一條 4000 次存取的軌跡,主要依 Zipf 熱度分佈抽 key,另外每 500 次插入一段 150 次的冷 key 掃描(模擬跑在旁邊的批次作業)。下面所有數字都是這條軌跡跑完後的計數。
預設就停在最經典的誤配上:政策是 volatile-lru,但只有 40% 的 key 設了 expire——無 TTL 的那 60% 會慢慢佔滿記憶體且永遠不被選中,於是驅逐與 OOM 同時出現。把 TTL 比例拉到 0,數字會與 noeviction 一模一樣;切到 volatile-ttl,「被踢的是熱鍵嗎」那一列會跳上去,因為它挑的是 TTL 最短的,不是最冷的。另外兩點要誠實說明:真實 Redis 的 LRU/LFU 是 maxmemory-samples 取樣近似,本模擬用的是精確排序,所以實機的誤殺通常比這裡更多;「業務讀取命中率」刻意把掃描批次的存取排除,因為掃描本來就必然 miss,混在一起會蓋掉政策之間的差別。
allkeys-* 全部可踢,volatile-* 只踢有 expire 的,沒有候選就回 OOM;volatile-ttl 挑的是活得最短的,不是最冷的,所以它會誤殺熱鍵。
面試折衷與邊界
先畫執行模型,再決定持久化與拓撲;Cluster 與 Sentinel 不要混成一句「開了高可用」。
| 面試常問 | 先答這句 | 不要滑去 |
|---|---|---|
| 為什麼單執行緒還快? | 記憶體 O(1) 命令無鎖、快取親和;慢的是 O(N) 與大 key | 把 I/O threads 說成平行執行命令 |
| RDB 還是 AOF? | 要時間點備份用 RDB;要崩潰窗口約一秒用 AOF everysec。常兩者都開,復原聽 AOF | 「開持久化就不會丟資料」 |
| Sentinel 與 Cluster? | Sentinel:同一份 keyspace 的主從切換。Cluster:16384 slot 水平切,客戶端懂 MOVED/ASK | 用 hash ring/虛擬節點描述 Cluster |
| 滿記憶體怎麼辦? | 先講政策候選集合:allkeys 什麼都踢,volatile 只踢有 TTL 的,noeviction 拒寫 | 只答「開 LRU」而不說哪些 key 有資格 |
| 熱 key 怎麼拆? | 同一 key 同一槽,加節點無效;本地快取、讀 replica、或複製成 N 把讀鍵 | 與快取擊穿的 mutex 回源混為一談 |
本篇停在 Redis 行程與拓撲。下一步若走訊息骨幹,從 訊息佇列基礎 進;若走 API 契約,先備是 gRPC 與 Protocol Buffers。
單執行緒給你原子命令,不給你跨 key 的分散式交易;AOF everysec 給你約一秒窗口,不給你零遺失;Sentinel 給你新主,不給你舊主寫入的 fencing;Cluster 給你 slot 遷移,不給你熱槽自動分裂。把這些窗口寫進 SLO,比把 Redis 當「不會壞的記憶體」安全。