跳到主要內容

系統設計

把資料結構放大到好幾台機器

主題 · Redis 與 Memcached

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 On = 1,000n = 10,000n = 50,000成長倍數:實測(理論)
Redis:抽 5 個O(1)555×1.0 (×1.0)
沒有串列時找真正最舊的O(n)1,00010,00050,000×50 (×50)

抽樣的成本跟快取多大無關;要精確找出最久沒用的,不是每個鍵多存兩個指標(雙向串列),就是每次掃過全部。

和其他做法比

快取 5% 的鍵快取 10% 的鍵
真正的 LRU54.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。

MemcachedRedis
資料型別字串(位元組)字串、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 把相關的鍵綁在一起。

LeetCode 練習