Redis 與 Memcached
兩個最常見的記憶體快取:Memcached 只做簡單的鍵值、多執行緒;Redis 有豐富的資料型別、持久化和複製,淘汰用的則是抽樣近似的 LRU(Least Recently Used,最近最少使用)。
看哪個面向
快取大小
熱度集中度 s
- 真正的 LRU64.8%
- 抽 10 個64.7%
- 抽 5 個+候選池(Redis)64.6%
- 抽 5 個64.6%
- 隨機淘汰59.6%
Redis 不維護 LRU 串列——那要每個鍵多兩個指標。它抽幾個鍵、淘汰其中最久沒用的。只抽 5 個,命中率就只比真正的 LRU 低 0.3 個百分點;隨機淘汰則低 5.3 個百分點。用很少的記憶體,拿到 LRU 大部分的好處。
20,000 次請求、5,000 個鍵,熱門程度依 Zipf 分布;命中率不計前 4,000 次暖機。候選池保留歷次抽樣裡最久沒用的 16 個,和 Redis 3.0 之後的做法相同。Memcached 則是每個 slab class 一條真正的 LRU,1.5 版之後再分成 hot/warm/cold 三段。
亮起來的是這一步執行的程式碼
class SampledLru { lastUsed = new Map<number, number>(); keys: number[] = []; slot = new Map<number, number>(); pool: { key: number; idle: number }[] = []; clock = 0; constructor(private capacity: number, private samples: number, private usePool: boolean, private random: () => number) {} access(key: number): boolean { this.clock++; if (this.lastUsed.has(key)) { this.lastUsed.set(key, this.clock); return true; } if (this.keys.length >= this.capacity) this.remove(this.victim()); this.lastUsed.set(key, this.clock); this.slot.set(key, this.keys.length); this.keys.push(key); return false; } victim(): number { const picked: number[] = []; for (let i = 0; i < this.samples; i++) { picked.push(this.keys[Math.floor(this.random() * this.keys.length)]); } if (!this.usePool) { let best = picked[0]; for (const key of picked) { if (this.lastUsed.get(key)! < this.lastUsed.get(best)!) best = key; } return best; } for (const key of picked) { if (this.pool.some((e) => e.key === key)) continue; this.pool.push({ key, idle: this.clock - this.lastUsed.get(key)! }); } this.pool.sort((a, b) => b.idle - a.idle); this.pool = this.pool.slice(0, 16); while (this.pool.length) { const { key } = this.pool.shift()!; if (this.lastUsed.has(key)) return key; } return picked[0]; } remove(key: number): void { const at = this.slot.get(key)!; const last = this.keys.pop()!; if (last !== key) { this.keys[at] = last; this.slot.set(last, at); } this.slot.delete(key); this.lastUsed.delete(key); }} function crc16(bytes: Uint8Array): number { let crc = 0; for (const byte of bytes) { crc ^= byte << 8; for (let bit = 0; bit < 8; bit++) { crc = crc & 0x8000 ? ((crc << 1) ^ 0x1021) & 0xffff : (crc << 1) & 0xffff; } } return crc;} function keySlot(key: string): number { const open = key.indexOf("{"); const close = open === -1 ? -1 : key.indexOf("}", open + 1); const hashed = close > open + 1 ? key.slice(open + 1, close) : key; return crc16(new TextEncoder().encode(hashed)) % 16384;} class SortedSet { scores = new Map<string, number>(); order: [number, string][] = []; // ascending by score, then member position(score: number, member: string): number { let lo = 0, hi = this.order.length; while (lo < hi) { const mid = (lo + hi) >> 1; const [s, m] = this.order[mid]; if (s < score || (s === score && m < member)) lo = mid + 1; else hi = mid; } return lo; } add(member: string, score: number): void { const old = this.scores.get(member); if (old !== undefined) this.order.splice(this.position(old, member), 1); this.scores.set(member, score); this.order.splice(this.position(score, member), 0, [score, member]); } incrBy(member: string, by: number): number { const score = (this.scores.get(member) ?? 0) + by; this.add(member, score); return score; } top(n: number): [string, number][] { return this.order.slice(-n).reverse().map(([s, m]) => [m, s]); } rank(member: string): number | null { const score = this.scores.get(member); if (score === undefined) return null; return this.order.length - 1 - this.position(score, member); }} class AppendOnlyFile { pending: string[] = []; // written, not yet fsynced onDisk: string[] = []; lastSync = 0; constructor(private policy: "always" | "everysec") {} append(command: string, nowMs: number): void { this.pending.push(command); if (this.policy === "always") this.fsync(nowMs); } tick(nowMs: number): void { if (this.policy === "everysec" && nowMs - this.lastSync >= 1000) this.fsync(nowMs); } fsync(nowMs: number): void { this.onDisk.push(...this.pending); this.pending = []; this.lastSync = nowMs; }}模型假設與範圍
- 這是可重現的教學模型;延遲、容量、故障率與工作負載是設定或樣本,不能直接當作正式系統的效能承諾。
- 選定的淘汰、hash slot、排行榜與持久化行為;不執行實際 Redis/Memcached,無法當作版本完整功能比較或 benchmark。
什麼時候用
- Memcached:純粹的快取——放算好的頁面片段、查詢結果,丟了就重算;要用滿多核心、行為簡單可預期。
- Redis:需要資料結構和原子操作(計數器、排行榜、排隊、限流、分散式鎖、session),或需要持久化與複製。
和其他主題的關係
出現在這些架構裡
時間與空間複雜度(Big O)
| 操作 | 平均 | 最差 |
|---|---|---|
| GET/SET 兩者都是雜湊表 | O(1) | O(1) |
| 淘汰一個鍵(Redis) S 是抽樣數,預設 5 | O(S) | O(S) |
| ZADD/ZINCRBY Redis 用跳躍串列;這裡的程式用排序陣列,插入是 O(n) | O(log n) | O(log n) |
| 計算雜湊槽 L 是鍵(或 hash tag)的長度 | O(L) | O(L) |
空間:O(n),近似 LRU 每個鍵只多存一個時間戳,不用兩個指標
Big O 實測:n 變大時步數怎麼長
數的是:選出一個要淘汰的鍵,要看幾個鍵(n 是快取裡的鍵數)
| Big O | n = 1,000 | n = 10,000 | n = 50,000 | 成長倍數:實測(理論) | |
|---|---|---|---|---|---|
| Redis:抽 5 個 | O(1) | 5 | 5 | 5 | ×1.0 (×1.0) |
| 沒有串列時找真正最舊的 | O(n) | 1,000 | 10,000 | 50,000 | ×50 (×50) |
抽樣的成本跟快取多大無關;要精確找出最久沒用的,不是每個鍵多存兩個指標(雙向串列),就是每次掃過全部。
和其他做法比
| 快取 5% 的鍵 | 快取 10% 的鍵 | |
|---|---|---|
| 真正的 LRU | 54.9% | 64.8% |
| 抽 10 個 | 54.8% | 64.7% |
| 抽 5 個+候選池(Redis) | 54.7% | 64.6% |
| 抽 5 個 | 54.5% | 64.6% |
| 隨機淘汰 | 49.9% | 59.6% |
命中率,Zipf s = 1、5,000 個鍵。抽樣越多越接近真正的 LRU;候選池讓抽 5 個的結果再往 LRU 靠近一點。Redis 的 `maxmemory-samples` 預設就是 5。
| Memcached | Redis | |
|---|---|---|
| 資料型別 | 字串(位元組) | 字串、hash、list、set、sorted set、stream… |
| 執行緒 | 多執行緒 | 指令單執行緒執行(I/O 可多執行緒) |
| 持久化 | 沒有 | RDB 快照、AOF 日誌 |
| 複製與故障轉移 | 沒有(用戶端自己處理) | 主從複製、Sentinel、Cluster |
| 分片 | 用戶端一致性雜湊 | Cluster:16,384 個雜湊槽 |
| 淘汰 | 每個 slab 一條 LRU | 抽樣的近似 LRU/LFU(Least Frequently Used) |
只需要放「算好的結果」、可以整個丟掉重建的快取,Memcached 簡單又能吃滿多核心;需要資料結構、原子操作、持久化或複製時選 Redis。
真實世界裡的它
- Facebook 用了上千台 Memcached 擋在 MySQL 前面(論文〈Scaling Memcache at Facebook〉)。
- Redis 的 sorted set 可做遊戲排行榜。固定時間窗限流可用
rate:alice:<unix-second>當鍵,以MULTI → INCR → EXPIRE → EXEC一起增加計數並設定過期;每個秒數有自己的計數器,超過門檻就拒絕。 - 若用單一鍵、從第一個請求起算窗口,就用 Lua 把
INCR與「計數為 1 時才EXPIRE」包成一次原子操作:local n = redis.call('INCR', KEYS[1]); if n == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end; return n。ARGV[1]是窗口秒數,再用回傳的 n 判斷是否超額。 - 限時搶購用 Redis 的原子
DECR扣庫存,見限時搶購案例。
取捨與陷阱
- 計數與過期必須一起設定:分開送
INCR、EXPIRE,若在增加計數後斷線、沒設定過期,鍵可能一直留下。單一鍵也不能每次都重設 TTL,否則持續的請求會一直延後窗口結束;用 Lua 在第一次增加時才設定過期。固定時間窗在邊界附近可能放行兩個窗口的額度;需要平滑流量時,改用滑動窗口或 token bucket。 - 把 Redis 當成主要資料庫:預設的持久化會在當機時丟資料,複製也是非同步的。重要資料要有真正的資料庫在後面。
- Redis 指令在單一執行緒上跑:一個
KEYS *或很大的ZRANGE會卡住所有人。用SCAN、限制範圍。 - Cluster 裡的多鍵指令只能用在同一個雜湊槽的鍵:設計鍵名時就要用 hash tag 把相關的鍵綁在一起。