Chapter 06

NoSQL 與 Redis 快取

NoSQL 分類與選型、Redis 內部原理(單執行緒模型、底層編碼、過期淘汰、持久化、Cluster、分散式鎖)、快取模式與一致性、穿透 / 擊穿 / 雪崩,以及 MongoDB、Cassandra、DynamoDB 與 Bloom filter。Appier 職缺明列 MongoDB 與 Redis,而廣告投放的使用者特徵查詢、頻次控制、即時計數,都是低延遲 NoSQL 的典型戰場。

預估閱讀 75 分鐘

為何需要 NoSQL 與分類比較 基礎

關聯式資料庫擅長強一致的交易與彈性查詢,但在以下情境會吃力:

  • 寫入量與資料量超過單機:RDBMS 的水平擴展(分庫分表)需要大量應用層工作;許多 NoSQL 從設計之初就是分散式、可自動分片。
  • 極低延遲:記憶體型 KV(Redis)可做到次毫秒級讀寫,適合請求熱路徑。
  • Schema 多變或半結構化:使用者屬性、事件 payload 欄位常變,document model 更自然。
  • 特殊存取模式:全文檢索、時間序列、圖關係、超寬表、海量分析聚合。
  • 可用性優先:跨機房、分區時仍要能寫(Cassandra、DynamoDB 的設計取向)。

NoSQL 的代價通常是:放棄或弱化跨列 / 跨分區交易、JOIN 能力弱、一致性模型較複雜(最終一致)、必須依查詢模式設計資料模型(query-driven modeling),事後新增查詢方式很痛苦。

分類與代表產品

類型代表資料模型擅長不擅長
Key-ValueRedis、Memcached、DynamoDB、Aerospikekey → value(Redis 的 value 有豐富資料結構)快取、session、計數、排行榜、低延遲查找複雜查詢、以 value 內容查找
DocumentMongoDB、Couchbase、FirestoreJSON / BSON 文件,巢狀結構schema 彈性、以聚合根為單位讀寫、二級索引豐富多文件強關聯、複雜 JOIN
Wide-columnCassandra、ScyllaDB、HBase、Bigtablepartition key → 有序的多列(row key + column family)海量寫入、時間序列、線性擴展、多機房ad-hoc 查詢、二級索引、強一致交易
GraphNeo4j、Amazon Neptune、JanusGraph節點 + 邊 + 屬性多跳關係查詢(社群、推薦、反詐欺)大量聚合掃描
Time-seriesInfluxDB、TimescaleDB、Prometheus時間戳 + 標籤 + 數值監控指標、降採樣、保留期限高基數標籤、任意更新
SearchElasticsearch、OpenSearch倒排索引文件全文檢索、模糊搜尋、日誌分析強一致寫入、作為唯一真實來源
OLAP(補充)ClickHouse、Druid、BigQuery欄式儲存(columnar)億級事件即時聚合報表高頻單列更新、點查詢 OLTP

主要產品比較

產品架構一致性擴展方式典型延遲 / 特點
Redis單執行緒事件迴圈、記憶體為主單節點強一致;複製為非同步Redis Cluster 16384 slots次毫秒;資料量受記憶體限制
MongoDBReplica set(primary + secondaries),WiredTiger(B-tree)可調 write / read concernmongos + 分片(range / hashed)毫秒級;文件上限 16MB;支援多文件交易
Cassandra / ScyllaDB無主(masterless)環狀、一致性雜湊、LSM tree可調一致性(ONE / QUORUM / ALL)加節點線性擴展寫入極快;ScyllaDB 以 C++ 與 shard-per-core 重寫,尾延遲更低
DynamoDBAWS 全託管,partition 自動分裂預設最終一致讀,可選強一致讀;支援交易 API依 partition key 自動擴展個位數毫秒;單項目上限 400KB;DAX 快取可達微秒級
HBaseHDFS 之上、RegionServer + HMaster、LSM單列強一致Region 分裂與 Hadoop 生態整合;維運較重
ElasticsearchLucene 分片 + 副本近即時(refresh 預設 1 秒)primary shards(建立後不能直接改數量)全文檢索與聚合
ClickHouse欄式、MergeTree(類 LSM 的背景合併)非交易型;副本最終一致分散式表 + 分片單機每秒可掃數億列;適合即時報表

SQL vs NoSQL 選型決策

flowchart TD S["新的資料需求"] --> T{"需要跨多筆資料的強一致交易?"} T -->|是| RDB["RDBMS: MySQL / PostgreSQL"] T -->|否| L{"延遲要求次毫秒、可放記憶體?"} L -->|是| R["Redis / 記憶體 KV"] L -->|否| W{"寫入量極大、時間序列、多機房?"} W -->|是| C["Cassandra / ScyllaDB / DynamoDB"] W -->|否| Q{"主要是分析聚合?"} Q -->|是| O["ClickHouse / BigQuery"] Q -->|否| F{"全文搜尋?"} F -->|是| E["Elasticsearch"] F -->|否| D{"schema 多變、以文件為單位存取?"} D -->|是| M["MongoDB"] D -->|否| RDB
面試官想聽到什麼

不要說「NoSQL 比較快」或「SQL 無法擴展」。好的回答以存取模式、一致性需求、規模與團隊維運能力為依據:「交易與帳務用 MySQL;使用者特徵查詢要求 P99 在幾毫秒內、key-based 存取,用 Redis 或 Cassandra;事件明細與報表用 Kafka + ClickHouse / 資料湖。通常是多種儲存並用(polyglot persistence),並明確定義哪個是 source of truth、其他如何同步(CDC、雙寫、批次)。」

Appier 情境

以廣告平台常見做法為例:RTB 請求進來時,要在幾毫秒內取得使用者的特徵(興趣分群、近期行為、頻次計數)給模型打分。這類「依 user_id 點查、極高 QPS、可容忍最終一致」的負載,典型選擇是 Redis(熱資料)、Aerospike / ScyllaDB / Cassandra(大容量、SSD 上低延遲)或 DynamoDB;而離線產出的使用者分群由 Spark 批次寫入這些線上儲存。

Redis 為何單執行緒仍然很快 高頻

Redis 處理命令的核心是單執行緒事件迴圈(event loop),但它在一般硬體上單實例仍可做到每秒約 10 萬等級的簡單命令(使用 pipeline 時更高,實際依硬體與命令而定)。原因:

  1. 純記憶體操作:大部分命令只是記憶體中的雜湊查找與指標操作,耗時在微秒以下,瓶頸通常是網路與系統呼叫,不是 CPU。
  2. I/O 多工(I/O multiplexing):用 epoll(Linux)/ kqueue(BSD/macOS)同時監聽上萬個 socket,只處理就緒的連線,一個執行緒就能服務大量連線(Reactor 模式)。
  3. 單執行緒避免鎖與 context switch:資料結構不需加鎖,沒有鎖競爭與執行緒切換開銷,每個命令天然原子。
  4. 高效的資料結構與編碼:SDS、listpack、intset、skiplist,小資料用緊湊編碼省記憶體並提升 CPU cache 命中。
  5. 簡單的協定:RESP 協定易於解析。

不是「完全」單執行緒:背景執行緒處理 AOF fsync、關閉檔案、UNLINK / FLUSHALL ASYNC 的惰性釋放(lazy free,4.0+);RDB 與 AOF rewrite 用 fork 子行程。Redis 6.0 引入 I/O threads:多個執行緒負責 socket 讀寫與協定解析,命令執行仍是單執行緒,用來緩解大量連線時網路 I/O 成為瓶頸的問題(io-threads 預設為 1,即不開啟)。

flowchart LR C1["Client 1"] --> EP["epoll
I/O multiplexing"] C2["Client 2"] --> EP C3["Client N"] --> EP EP --> IO["I/O threads(6.0+,選用)
讀 socket、解析 RESP"] IO --> MAIN["主執行緒
依序執行命令"] MAIN --> IO2["I/O threads
寫回應"] MAIN -.-> BG["背景執行緒
fsync、lazy free"] MAIN -.-> FORK["fork 子行程
RDB / AOF rewrite"]
常見陷阱:單執行緒的代價

任何慢命令都會阻塞所有客戶端:KEYS *(O(N) 掃全部 key,線上禁用,改用 SCAN)、對大集合 HGETALL / SMEMBERS / LRANGE 0 -1、DEL 一個百萬元素的 key(改用 UNLINK)、長時間執行的 Lua script、FLUSHALL(改用 ASYNC)。用 SLOWLOG GET 與 LATENCY DOCTOR 診斷。fork 在記憶體很大時也會造成短暫停頓(複製頁表),且 copy-on-write 可能讓記憶體用量暴增。

面試官想聽到什麼

「Redis 單執行緒為什麼快?要怎麼利用多核?」→ 先講記憶體 + epoll + 無鎖,再補充 6.0 I/O threads 只多執行緒化網路 I/O,命令執行仍單執行緒所以原子性不變。利用多核的方式:同一台機器跑多個 Redis 實例、或用 Redis Cluster 分片;或考慮相容的多執行緒實作(例如 KeyDB、Dragonfly)——但要說明取捨。

Redis 資料結構、底層編碼與典型用例 高頻 深入

Redis 對外的型別(type)與內部的編碼(encoding)是分離的:同一型別在資料小的時候用緊湊編碼(省記憶體、CPU cache 友善),超過閾值自動轉換成適合大量資料的編碼。用 OBJECT ENCODING key 可查看。以下以 Redis 7.x 為準(7.0 起以 listpack 取代 ziplist)。

型別底層編碼轉換條件(預設)典型用例
Stringint(可表示為 long 的整數)、embstr(≤ 44 bytes,與物件頭一次配置)、raw(SDS)長度 / 內容決定快取 JSON、計數器 INCR、分散式鎖、session
Listquicklist(listpack 節點組成的雙向鏈結串列);元素少時為單一 listpack(7.2+)list-max-listpack-size 預設 -2(每節點 8KB)訊息佇列(LPUSH + BRPOP)、最新 N 筆動態
Hashlistpack → hashtable欄位數 > 128 或任一值 > 64 bytes物件屬性(user profile)、購物車、計數群組
Setintset(全整數、≤ 512 個)、listpack(7.2+,≤ 128 個)→ hashtable出現非整數 / 超過數量標籤、去重、共同好友(SINTER)、抽獎(SRANDMEMBER)
Sorted Set (ZSet)listpack → skiplist + hashtable元素 > 128 或成員 > 64 bytes排行榜、延遲佇列(score = 執行時間)、滑動視窗限流
BitmapString 上的位元操作上限 512MB(2³² 位元)每日簽到、使用者是否活躍(offset = user 序號)、bloom filter 底層
HyperLogLog稀疏 / 密集表示,最多約 12KB—UV / DAU 近似計數,標準誤差 0.81%
Streamradix tree + listpack—持久化訊息流、consumer group、ACK(輕量 Kafka)
Geo以 ZSet 實作,score 為 52-bit geohash—附近的店家(GEOSEARCH)

String 與 SDS

Redis 不直接用 C 字串,而是 SDS(Simple Dynamic String):結構中記錄 len 與 alloc。好處:(1) O(1) 取長度;(2) 二進位安全(可存 \0,能放圖片、protobuf);(3) 追加時預先多配置空間(小於 1MB 時加倍、超過則每次多 1MB),減少 realloc;(4) 防止緩衝區溢位。embstr 把 redisObject 與 SDS 放在同一塊連續記憶體,一次配置、cache 友善。

緊湊編碼:listpack / intset

listpack 是一塊連續記憶體,元素依序緊密排列,每個元素記錄自身長度(取代舊的 ziplist;ziplist 每個元素記錄「前一個元素長度」,插入時可能觸發連鎖更新(cascade update),listpack 解決了這個問題)。小資料時查找雖是 O(N),但 N 很小且記憶體連續,實際比雜湊表快、且省下大量指標開銷。這也是為什麼把大量小物件組織成多個小 hash(例如把 user:1000~user:1099 放進同一個 hash bucket)可以大幅省記憶體。

ZSet 為何用 skiplist 而不是平衡樹

大 ZSet 同時使用兩種結構:hashtable(member → score,O(1) 取分數)與 skiplist(依 score 排序,支援範圍查詢與排名)。

flowchart LR H["HEAD"] --> L3a["L3: 10"] --> L3b["L3: 70"] --> NIL3["NULL"] H2["HEAD"] --> L2a["L2: 10"] --> L2b["L2: 40"] --> L2c["L2: 70"] --> NIL2["NULL"] H1["HEAD"] --> L1a["L1: 10"] --> L1b["L1: 25"] --> L1c["L1: 40"] --> L1d["L1: 55"] --> L1e["L1: 70"] --> L1f["L1: 90"]

跳躍表是多層有序鏈結串列,每個節點以機率(Redis 用 p = 1/4)晉升到上一層,查找從最高層開始往右走、走不動就往下,期望時間 O(log N)。選擇它而非紅黑樹 / AVL 的理由(作者 antirez 曾公開說明):

  • 範圍查詢簡單高效:ZRANGEBYSCORE 找到起點後沿最底層鏈結串列往後走即可;平衡樹需要中序走訪。
  • 實作與除錯簡單:沒有旋轉、重新著色等複雜操作;插入刪除只影響局部指標。
  • 記憶體可調:平均每節點的指標數由 p 決定,p = 1/4 時平均約 1.33 個,比平衡樹的 2 個指標少。
  • 容易支援排名:每層前進指標記錄跨度(span),ZRANK 可在 O(log N) 算出排名。
  • 單執行緒下不需要考慮併發;若要無鎖併發,跳躍表也比平衡樹容易實作(Java 的 ConcurrentSkipListMap)。

典型用例指令

# 排行榜:活動中各創意的點擊數
ZINCRBY leaderboard:camp:7 1 creative:42
ZREVRANGE leaderboard:camp:7 0 9 WITHSCORES       # Top 10
ZREVRANK leaderboard:camp:7 creative:42             # 某創意的名次(0 起算)

# UV 計數(近似):每個活動今天的不重複使用者數,每個 key 最多約 12KB
PFADD uv:camp:7:20261103 user:a user:b user:c
PFCOUNT uv:camp:7:20261103
PFMERGE uv:camp:7:week uv:camp:7:20261101 uv:camp:7:20261102 uv:camp:7:20261103

# Bitmap:使用者每日活躍(user 有整數序號時),1 億使用者只需約 12MB
SETBIT active:20261103 1234567 1
BITCOUNT active:20261103                            # 當日活躍人數
BITOP AND active:both active:20261102 active:20261103   # 連續兩天都活躍

# 去重:同一曝光的點擊只算一次(SET NX + 過期)
SET dedup:click:imp_9f2c 1 NX EX 86400              # 回傳 OK 代表第一次,nil 代表重複

# Session
HSET session:abc123 user_id 42 role admin
EXPIRE session:abc123 1800

# Stream:輕量事件佇列 + consumer group
XADD events * type click campaign 7
XGROUP CREATE events billing $ MKSTREAM
XREADGROUP GROUP billing worker-1 COUNT 10 BLOCK 2000 STREAMS events >
XACK events billing 1730600000000-0

# Geo
GEOADD stores 121.5654 25.0330 taipei101
GEOSEARCH stores FROMLONLAT 121.56 25.03 BYRADIUS 2 km ASC
Appier 情境:即時計數

以廣告平台常見做法為例:每個活動當日的曝光、點擊、花費用 HINCRBY stats:camp:7:20261103 imps 1 即時累加,供 pacing(預算平滑花費)與儀表板使用;UV 用 HyperLogLog;資料定期(例如每分鐘)由背景任務落地到 MySQL / OLAP 作為正式紀錄。要提醒:Redis 計數是「近即時、可能有少量誤差」的營運指標,計費以事件日誌重算的結果為準。

過期、淘汰與持久化 高頻

過期策略:惰性刪除 + 定期刪除

設了 TTL 的 key 存在一個 expires 字典中。Redis 不會為每個 key 設計時器(成本太高),而是兩種策略並用:

惰性刪除(lazy expiration)
存取 key 時才檢查是否過期,過期就刪除並回傳不存在。CPU 友善,但從不被存取的過期 key 會一直佔記憶體。
定期刪除(active expiration)
背景週期任務(頻率由 hz 決定,預設 10 次 / 秒)每次從設有 TTL 的 key 中隨機取樣一批(經典實作為 20 個),刪除其中過期者;若過期比例超過 25% 就繼續下一輪,並有時間上限避免阻塞。可用 active-expire-effort 調整積極程度。

兩者結合仍可能有過期 key 殘留,最後由記憶體淘汰兜底。從庫不主動刪除過期 key,而是等主庫刪除後傳來 DEL(從庫讀取時會依 TTL 判斷回傳不存在,3.2+)。

淘汰策略(maxmemory-policy)

記憶體用量達到 maxmemory 時依策略淘汰:

策略範圍行為適用
noeviction(預設)—不淘汰,寫入命令回錯誤Redis 當資料庫、資料不可丟
allkeys-lru所有 key淘汰最久未使用純快取最常用
allkeys-lfu(4.0+)所有 key淘汰使用頻率最低存取有明顯熱點、需避免偶發掃描污染快取
allkeys-random所有 key隨機存取均勻
volatile-lru / volatile-lfu / volatile-random只限有 TTL 的 key同上快取與持久資料混用同一實例
volatile-ttl只限有 TTL 的 key淘汰剩餘 TTL 最短—

近似 LRU:精確 LRU 需要雙向鏈結串列,每次存取都移動節點,記憶體與 CPU 開銷大。Redis 在每個物件存 24-bit 的最後存取時間,淘汰時隨機取樣 maxmemory-samples(預設 5)個 key,放入一個淘汰候選池(eviction pool,大小 16),淘汰其中最久未使用的。取樣數調到 10 已非常接近真正的 LRU。LFU 則把同一個 24-bit 欄位拆成 16-bit 時間 + 8-bit 對數計數器(Morris counter,機率性遞增,最大 255),並隨時間衰減(lfu-decay-time),解決 LRU 被一次性批次掃描污染的問題。

常見陷阱

用 volatile-* 策略但大部分 key 沒設 TTL,記憶體滿時沒有可淘汰的 key,行為等同 noeviction → 寫入報錯。純快取用途請一律設定 TTL,並用 allkeys-lru 或 allkeys-lfu。另外 maxmemory 要預留 fork / 複製緩衝區 / 碎片的空間,不要設成機器記憶體的 100%。

持久化:RDB vs AOF vs 混合

RDB(快照)AOF(Append Only File)
原理BGSAVE fork 子行程,利用 copy-on-write 把某時間點的資料寫成壓縮二進位檔把每個寫命令追加到日誌,重啟時重放
資料遺失上次快照之後的所有寫入(可能數分鐘)取決於 fsync 策略:always 幾乎不丟、everysec(預設)最多約 1 秒、no 由 OS 決定(Linux 通常約 30 秒)
檔案大小 / 恢復速度小、恢復快大、重放慢
對效能影響fork 時的停頓與 COW 記憶體每次寫入的追加與 fsync;需要 AOF rewrite 壓縮
適合備份、災難復原、全量複製需要較高持久性
  • AOF rewrite:日誌會無限增長(對同一 key INCR 一萬次),BGREWRITEAOF fork 子行程依目前資料產生最精簡的命令集合。Redis 7.0 起採用 multi-part AOF(base 檔 + 增量檔 + manifest),避免舊版 rewrite 期間雙寫緩衝的記憶體與 I/O 開銷。
  • 混合持久化(aof-use-rdb-preamble yes,5.0 起預設開啟):AOF rewrite 時 base 部分以 RDB 格式寫入,之後的增量用 AOF 格式。兼顧恢復速度與資料完整性。
  • 建議:純快取可只開 RDB 甚至都不開;當作重要資料存放時開 AOF everysec + RDB 定期備份到異地(S3)。
# redis.conf 常用設定(快取型實例)
maxmemory 12gb
maxmemory-policy allkeys-lfu
maxmemory-samples 10

save 3600 1 300 100 60 10000      # RDB:3600 秒內至少 1 次寫入、或 300 秒 100 次、或 60 秒 10000 次即快照
appendonly yes
appendfsync everysec
aof-use-rdb-preamble yes

lazyfree-lazy-eviction yes         # 淘汰、過期、DEL 時在背景執行緒釋放記憶體
lazyfree-lazy-expire yes
lazyfree-lazy-user-del yes
slowlog-log-slower-than 10000      # 微秒,超過 10ms 記入 slowlog
io-threads 4                       # 6.0+,網路 I/O 多執行緒(依 CPU 核心數調整)
面試官想聽到什麼

「Redis 掛掉資料會丟嗎?」→ 取決於持久化設定與複製:AOF everysec 最多丟約 1 秒;但主從複製是非同步的,主庫故障切換時,尚未同步到從庫的寫入也會遺失。所以 Redis 不適合作為「不可遺失」資料的唯一儲存(例如計費)。可以用 WAIT numreplicas timeout 提高持久性,但它不讓 Redis 變成強一致系統。

複製、Sentinel 與 Redis Cluster 進階

主從複製

  1. 從庫發送 PSYNC replid offset。第一次或無法續傳時,主庫執行 全量同步:BGSAVE 產生 RDB 傳給從庫(或 diskless 直接走 socket),期間的新寫入存在複製緩衝區,之後補傳。
  2. 之後主庫把每個寫命令非同步串流給從庫(命令傳播)。
  3. 斷線重連時,若從庫的 offset 仍在主庫的 replication backlog(環狀緩衝區,repl-backlog-size 預設 1MB)內,就只做部分同步;否則全量同步。寫入量大的系統要把 backlog 調大,避免頻繁全量同步打爆主庫。

Sentinel:自動故障轉移

Sentinel 是一組(至少 3 個、部署在不同機器)監控行程:

  • 主觀下線(SDOWN):單一 Sentinel 在 down-after-milliseconds 內沒收到主庫有效回應。
  • 客觀下線(ODOWN):達到 quorum 個 Sentinel 都認為主庫下線。
  • Sentinel 之間以類 Raft 的方式選出 leader,由 leader 挑選最合適的從庫(優先順序、複製 offset 最大、run ID)提升為主庫,並通知其他從庫與客戶端。
  • 客戶端向 Sentinel 詢問目前主庫位址(redis-py 有 Sentinel 類別)。

Sentinel 只解決高可用,不解決容量與寫入擴展——所有資料仍在單一主庫。

Redis Cluster

flowchart LR CL["Client
(快取 slot 對照表)"] -->|"CRC16(key) mod 16384"| S{"slot?"} S -->|"0 - 5460"| M1["Master A"] S -->|"5461 - 10922"| M2["Master B"] S -->|"10923 - 16383"| M3["Master C"] M1 --> R1["Replica A1"] M2 --> R2["Replica B1"] M3 --> R3["Replica C1"] M1 -.-|gossip| M2 M2 -.-|gossip| M3
  • key 空間分成 16384 個 hash slot,slot = CRC16(key) mod 16384,每個 master 負責一部分 slot,各有自己的 replica。無中心節點,節點間透過 cluster bus(資料埠 + 10000)以 gossip 協定交換狀態與故障偵測,master 故障時由其他 master 投票讓其 replica 接手。
  • 為什麼是 16384:節點心跳封包要攜帶自己負責哪些 slot 的 bitmap,16384 bits = 2KB,若用 65536 則要 8KB,心跳開銷太大;而官方建議叢集不超過約 1000 個節點,16384 個 slot 已足夠細分。
  • MOVED 重新導向:客戶端送到錯誤節點時,回 MOVED 3999 10.0.0.2:6379,表示該 slot 已永久屬於別的節點,客戶端應更新本地 slot 對照表。
  • ASK 重新導向:slot 正在遷移中,該 key 已搬到目標節點,客戶端應「這一次」對目標節點先送 ASKING 再送命令,但不更新對照表。
  • Hash tag:key 中 {...} 內的部分才參與雜湊,例如 user:{42}:profile 與 user:{42}:freq 會落在同一 slot。多 key 命令(MGET、SUNION)、交易與 Lua script 要求所有 key 在同一 slot,否則回 CROSSSLOT 錯誤。注意濫用 hash tag 會造成資料傾斜。
  • 限制:只能用 db 0;跨 slot 的多 key 操作受限;擴縮容需要遷移 slot(redis-cli --cluster reshard)。
實戰提示:其他分片方案

Cluster 之前常見的是 proxy 架構(Twemproxy、Codis):客戶端連 proxy,由 proxy 依 key 分片,客戶端不需支援 cluster 協定,但多一跳延遲。雲端託管的 ElastiCache / MemoryDB / Memorystore 也都支援 cluster mode。

Pipeline、Transaction、Lua 與 Big key / Hot key 高頻

三者比較

PipelineMULTI / EXECLua script(EVAL)/ Functions
目的減少網路往返(RTT)一組命令依序執行、中間不插入其他客戶端命令在伺服器端原子執行包含邏輯判斷的操作
原子性無,命令之間可能穿插其他客戶端的命令執行期間不被打斷,但不支援回滾整個 script 原子執行
能否依中間結果做判斷不能(結果最後才一起回來)不能(命令先排隊,EXEC 才執行);可用 WATCH 做樂觀鎖可以(if / else、迴圈)
Cluster客戶端需按節點拆分所有 key 需同一 slot所有 key 需同一 slot,且應透過 KEYS 參數傳入
風險一次送太多會占用大量記憶體—執行太久會阻塞整個 Redis

MULTI/EXEC 為何不回滾:

  • 排隊時就發現的錯誤(命令不存在、參數數量錯)→ EXEC 時整個交易被拒絕,全部不執行。
  • 執行時才發生的錯誤(例如對 string 做 LPUSH,WRONGTYPE)→ 只有該命令失敗,其他命令照常執行,沒有回滾。
  • 官方理由:這類錯誤是程式 bug,應在開發階段發現;不支援回滾讓 Redis 保持簡單快速。
  • WATCH key:EXEC 前若被 watch 的 key 被其他客戶端修改,EXEC 回傳 nil(交易放棄),應用程式重試——這是 CAS 樂觀鎖。
# 執行時錯誤不會回滾
MULTI
SET a 1
LPUSH a x          # 排隊成功(QUEUED),但執行時 WRONGTYPE
SET b 2
EXEC
# 1) OK
# 2) (error) WRONGTYPE Operation against a key holding the wrong kind of value
# 3) OK             → a 與 b 都被設定了

Big key 問題

常見經驗值:String 超過 10KB 以上、或集合元素超過數千~上萬個就值得注意(各團隊標準不同)。危害:

  • 讀寫大 key 佔用大量網路頻寬與主執行緒時間 → 其他請求延遲飆高。
  • DEL 大 key 同步釋放記憶體可能阻塞數百毫秒以上。
  • Cluster 中造成記憶體傾斜;遷移 slot 時大 key 遷移阻塞。

偵測:redis-cli --bigkeys / --memkeys(以 SCAN 取樣,對線上影響小)、MEMORY USAGE key、離線分析 RDB 檔(如 redis-rdb-tools)。解法:拆分(大 hash 依欄位雜湊拆成 N 個小 hash:profile:{uid}:0..N)、只取需要的欄位(HMGET 而非 HGETALL,大集合用 HSCAN / SSCAN 漸進讀)、壓縮 value、刪除用 UNLINK。

Hot key 問題

某個 key 的 QPS 遠高於其他(例如爆紅活動的設定、首頁推薦清單),所有請求集中打到同一個 slot 所在的節點,即使叢集總容量足夠也會單點過載。偵測:redis-cli --hotkeys(需 LFU 淘汰策略)、客戶端或 proxy 統計、MONITOR(成本高,只能短暫使用)。解法:

  • 本地快取(L1):在應用程式行程內快取熱 key 幾秒鐘,最有效。
  • Key 複製分散:寫入 hot:key:0 ~ hot:key:9 十份副本(落在不同 slot),讀取時隨機挑一份。
  • 讀寫分離:讀請求分散到 replica(需接受複製延遲)。
  • 熱點寫入(例如全域計數器):在應用程式端先聚合再批次寫入,或拆成多個子計數器。
Appier 情境

以廣告平台常見做法為例:所有投放節點都需要讀「活動設定 / 出價規則」,若每個 RTB 請求都打 Redis 讀同一個 key,就是典型 hot key。常見設計是投放服務在記憶體中保存完整活動設定,透過訂閱變更(Pub/Sub、Kafka)或每隔數秒輪詢版本號刷新,Redis 只存放「每位使用者」層級的資料(分散良好的 key)。

Redis 分散式鎖 高頻 深入

多個服務實例需要互斥地執行某件事(例如同一廣告主的結算任務只能一個 worker 跑、快取重建只讓一個請求去查 DB),需要分散式鎖。正確的單實例 Redis 鎖有三個要點:

  1. 原子地「不存在才設定 + 設過期」:SET lock:key <token> NX PX 30000。不能分開寫 SETNX 再 EXPIRE——中間當機會留下永不過期的死鎖。
  2. value 是唯一值(token,例如 UUID):用來辨識鎖的擁有者。
  3. 釋放時用 Lua 比對 token 再刪除:避免誤刪別人的鎖。情境:A 拿到鎖但執行超過 30 秒,鎖過期,B 取得鎖;A 完成後直接 DEL 就會刪掉 B 的鎖。「GET 比對再 DEL」兩步驟不是原子的,必須用 Lua。
sequenceDiagram participant A as Worker A participant R as Redis participant B as Worker B A->>R: SET lock token_A NX PX 30000 R-->>A: OK(取得鎖) Note over A: GC 停頓或執行過久,超過 30 秒 R->>R: lock 過期自動刪除 B->>R: SET lock token_B NX PX 30000 R-->>B: OK(B 取得鎖) A->>R: EVAL 若 GET lock == token_A 則 DEL R-->>A: 0(token 不符,不刪除,保護了 B 的鎖) Note over A,B: 但 A 與 B 曾同時認為自己持有鎖,需要 fencing token
import time
import uuid
import redis

r = redis.Redis(host="localhost", port=6379, decode_responses=True)

RELEASE_LUA = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
  return redis.call('DEL', KEYS[1])
end
return 0
"""
EXTEND_LUA = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
  return redis.call('PEXPIRE', KEYS[1], ARGV[2])
end
return 0
"""
release_script = r.register_script(RELEASE_LUA)   # 內部用 EVALSHA,script 只傳一次
extend_script = r.register_script(EXTEND_LUA)

class RedisLock:
    def __init__(self, client: redis.Redis, name: str, ttl_ms: int = 30_000):
        self.r, self.key, self.ttl_ms = client, f"lock:{name}", ttl_ms
        self.token = uuid.uuid4().hex

    def acquire(self, wait_s: float = 5.0) -> bool:
        deadline = time.monotonic() + wait_s
        while time.monotonic() < deadline:
            if self.r.set(self.key, self.token, nx=True, px=self.ttl_ms):
                return True
            time.sleep(0.05)                   # 簡單輪詢;可加 jitter
        return False

    def extend(self) -> bool:                  # 看門狗(watchdog)定期延長,避免任務未完成鎖就過期
        return bool(extend_script(keys=[self.key], args=[self.token, self.ttl_ms]))

    def release(self) -> bool:
        return bool(release_script(keys=[self.key], args=[self.token]))

    def __enter__(self):
        if not self.acquire():
            raise TimeoutError(f"could not acquire {self.key}")
        return self

    def __exit__(self, *exc):
        self.release()

with RedisLock(r, "settle:advertiser:3"):
    pass  # 執行結算;長任務需在背景執行緒定期呼叫 extend()

# redis-py 也內建 r.lock("name", timeout=30, blocking_timeout=5),原理相同(token + Lua 釋放)

Redlock 與其爭議

單實例鎖的問題:主庫取得鎖後,尚未複製到從庫就當機,從庫升為主庫後鎖不見了,另一個客戶端可以再拿到鎖。Redlock(antirez 提出):使用 N 個(通常 5 個)彼此獨立的 Redis master,客戶端依序向所有節點請求鎖,在有效時間內取得多數(≥ N/2 + 1)才算成功,鎖的有效時間要扣除取鎖耗時。

Martin Kleppmann 的批評(《How to do distributed locking》):

  • 任何基於租約(lease / TTL)的鎖,都可能因為 GC 停頓、行程暫停、網路延遲,讓客戶端在鎖已過期後仍以為自己持有鎖。這不是 Redlock 特有,但 Redlock 無法解決。
  • Redlock 依賴各節點的時鐘大致正確(時鐘跳躍會讓鎖提早過期)。
  • 正確性要求高的場景需要 fencing token:鎖服務每次發放單調遞增的編號,受保護的資源(例如儲存系統)拒絕比已見過更舊的編號。Redlock 沒有提供單調遞增的 token。
  • 結論:若鎖只是為了效率(避免重複工作,偶爾重複也無妨),單一 Redis 鎖就夠了;若是為了正確性,應使用基於共識的系統(ZooKeeper、etcd,提供 zxid / revision 可當 fencing token),並讓下游支援 fencing。

antirez 也發表了回應,認為在合理的系統模型假設下 Redlock 是安全的。面試時能把雙方論點與「效率 vs 正確性」的結論講清楚即可。

面試官想聽到什麼

依序講出:SET NX PX 原子化 → 唯一 token + Lua 比對刪除 → 過期時間怎麼設與看門狗續約 → 主從切換導致鎖遺失 → Redlock 與 Kleppmann 的爭議 → fencing token → 依「效率還是正確性」選擇 Redis 或 etcd / ZooKeeper。再補一句:最好的鎖是不需要鎖——能用資料庫唯一約束、條件更新、冪等設計解決的,就不要引入分散式鎖。

快取模式與一致性 高頻 深入

快取的本質是「用可能過時的資料換取速度」。設計時要先回答:能容忍多久的不一致?快取掛了系統還能撐嗎?

四種常見模式

模式讀寫優點缺點
Cache-aside(旁路快取,最常用)應用程式先查快取,miss 就查 DB 並回填應用程式寫 DB,再刪除快取簡單、快取掛掉仍可運作、只快取被讀取的資料首次讀取 miss;有短暫不一致窗口
Read-through應用程式只查快取,由快取層(函式庫 / 服務)負責 miss 時載入搭配 write-through 或 cache-aside應用程式碼簡潔需要快取層支援載入邏輯
Write-through—寫入快取,由快取層同步寫入 DB,兩者都成功才回應快取永遠有最新資料寫入延遲較高;寫入但不常被讀的資料也佔快取
Write-behind / write-back—只寫快取即回應,快取層非同步批次寫回 DB寫入極快、可合併多次寫入快取故障可能遺失資料;一致性最弱
sequenceDiagram participant App participant Cache as Redis participant DB as MySQL Note over App,DB: 讀取路徑(cache-aside) App->>Cache: GET user:42 Cache-->>App: miss App->>DB: SELECT ... WHERE id = 42 DB-->>App: row App->>Cache: SET user:42 row EX 300 Note over App,DB: 寫入路徑 App->>DB: UPDATE users ... WHERE id = 42 App->>Cache: DEL user:42

為什麼是「刪除」快取,而不是「更新」快取?

  • 併發寫入的順序問題:A 寫 DB = 1、B 寫 DB = 2、B 更新快取 = 2、A 更新快取 = 1 → 快取停在舊值 1,直到過期。刪除是冪等的,沒有順序問題。
  • 懶計算:快取值可能需要 JOIN 多張表計算,若資料寫多讀少,每次寫都重算是浪費;刪除後等下次讀再算。

為什麼「先更新 DB,再刪快取」?

若先刪快取再更新 DB:A 刪快取 → B 讀取 miss、從 DB 讀到舊值並回填快取 → A 更新 DB。結果快取裡是舊值,而且會一直存在到 TTL 過期。因為「讀 DB + 寫快取」通常比「寫 DB」快,這個競爭很容易發生。

先更新 DB 再刪快取仍有一個極端情況:快取剛好過期 → A 讀 DB 拿到舊值 → B 更新 DB 並刪除快取 → A 把舊值寫入快取。這需要「A 的讀 DB + 寫快取」橫跨「B 的寫 DB + 刪快取」整段,而寫 DB 通常比讀慢得多,機率很低。這也是業界(Facebook 的 memcache 論文中也採用 delete-on-write)普遍選擇此順序的原因。

進一步強化一致性

一律設 TTL
最後一道防線:即使出現不一致,最多維持 TTL 時間。
延遲雙刪(delayed double delete)
先刪快取 → 更新 DB → 等待一小段時間(大於一次「讀 DB + 回填」的時間,例如數百毫秒)→ 再刪一次。第二次刪除清掉併發讀回填的舊值。缺點:延遲時間難以精確估計、增加寫入延遲(通常非同步執行第二次刪除)。
刪除失敗重試
「更新 DB 成功、刪快取失敗」也會不一致。把刪除失敗的 key 送進訊息佇列重試。
訂閱 binlog 失效(CDC)
應用程式只寫 DB;由 Canal / Debezium / Maxwell 訂閱 MySQL binlog,解析出變更的列後刪除對應快取 key,失敗可重試。優點:與業務程式碼解耦、不會漏刪(任何寫入 DB 的路徑都會被捕捉,包括手動修資料);缺點:多一套基礎設施、有毫秒到秒級延遲。
版本號 / 時間戳
快取值帶版本,寫入快取時用 Lua 比較版本,只接受更新的版本。
需要強一致時
不要快取,或讀取關鍵資料時直接讀 DB 主庫;或用分散式鎖序列化同一 key 的讀寫(代價高)。
flowchart LR APP["應用程式"] -->|"寫入"| DB[("MySQL")] DB -->|"binlog"| CDC["Canal / Debezium"] CDC --> MQ["Kafka"] MQ --> INV["失效服務
DEL cache key
失敗重試"] INV --> RC[("Redis")] APP -->|"讀取 cache-aside"| RC
面試官想聽到什麼

「怎麼保證快取與資料庫一致?」不要說「可以做到強一致」。正確框架:(1) 快取天生是最終一致;(2) 預設用 cache-aside + 先寫 DB 再刪快取 + TTL;(3) 刪除失敗用 MQ 重試,或改為訂閱 binlog 失效;(4) 若有讀舊值回填的疑慮,加延遲雙刪;(5) 對一致性要求極高的資料(餘額、庫存)不快取或讀主庫。並說明各方案的代價。

快取穿透、擊穿、雪崩與多級快取 高頻

問題定義典型原因解法
穿透(penetration)查詢根本不存在的資料,快取永遠 miss,每次都打到 DB惡意攻擊用隨機 ID、程式 bug(1) 參數校驗;(2) 快取空值(null caching,短 TTL);(3) Bloom filter 預先判斷 key 是否可能存在
擊穿(breakdown)單一熱點 key 過期的瞬間,大量併發請求同時 miss,一起打到 DB熱門活動設定、首頁資料(1) 互斥鎖 / singleflight:只讓一個請求重建,其他等待或回舊值;(2) 邏輯過期:不設實體 TTL,value 內存過期時間,過期時由背景非同步重建、先回舊值;(3) 熱 key 永不過期 + 主動更新
雪崩(avalanche)大量 key 同時過期,或快取服務整個掛掉,流量全部壓到 DB批次載入時設了相同 TTL、Redis 故障(1) TTL 加隨機抖動(jitter);(2) 快取高可用(Sentinel / Cluster);(3) 多級快取;(4) DB 前加限流、熔斷、降級;(5) 預熱
sequenceDiagram participant R1 as 請求 1 participant R2 as 請求 2..N participant SF as singleflight / 互斥鎖 participant C as Redis participant DB as MySQL R1->>C: GET hot_key(miss) R2->>C: GET hot_key(miss) R1->>SF: 取得重建權 R2->>SF: 等待同一個結果 SF->>DB: 只查一次 DB-->>SF: value SF->>C: SET hot_key value EX 300+jitter SF-->>R1: value SF-->>R2: value(共享結果)
import json
import random
import threading
import time
from typing import Any, Callable, Optional

import redis

r = redis.Redis(decode_responses=True)
NULL_SENTINEL = "__NULL__"

class SingleFlight:
    """同一行程內:相同 key 的併發載入只執行一次,其他執行緒等待並共享結果(Go 的 singleflight 概念)。"""
    def __init__(self):
        self._lock = threading.Lock()
        self._calls: dict[str, dict] = {}

    def do(self, key: str, fn: Callable[[], Any]) -> Any:
        with self._lock:
            call = self._calls.get(key)
            if call is None:
                call = {"event": threading.Event(), "result": None, "error": None}
                self._calls[key] = call
                leader = True
            else:
                leader = False
        if not leader:
            call["event"].wait()
            if call["error"]:
                raise call["error"]
            return call["result"]
        try:
            call["result"] = fn()
        except Exception as e:
            call["error"] = e
            raise
        finally:
            with self._lock:
                self._calls.pop(key, None)
            call["event"].set()
        return call["result"]

sf = SingleFlight()

def ttl_with_jitter(base: int) -> int:
    return base + random.randint(0, base // 10)          # 防雪崩:TTL 加 0~10% 隨機

def get_cached(key: str, loader: Callable[[], Optional[dict]], ttl: int = 300) -> Optional[dict]:
    raw = r.get(key)
    if raw is not None:
        return None if raw == NULL_SENTINEL else json.loads(raw)

    def load():
        # 跨行程的互斥:只有拿到 Redis 鎖的實例去查 DB(防擊穿)
        lock_key = f"rebuild:{key}"
        if r.set(lock_key, "1", nx=True, ex=10):
            try:
                value = loader()
                if value is None:                          # 防穿透:快取空值,短 TTL
                    r.set(key, NULL_SENTINEL, ex=60)
                else:
                    r.set(key, json.dumps(value), ex=ttl_with_jitter(ttl))
                return value
            finally:
                r.delete(lock_key)
        # 沒拿到鎖:短暫等待後重讀快取(也可直接回舊值 / 預設值)
        for _ in range(20):
            time.sleep(0.05)
            raw2 = r.get(key)
            if raw2 is not None:
                return None if raw2 == NULL_SENTINEL else json.loads(raw2)
        return loader()                                    # 最後手段:降級直接查

    return sf.do(key, load)                                # 行程內再合併一次
常見陷阱

快取空值要注意:(1) 空值 TTL 要短,否則資料新增後仍讀不到;(2) 攻擊者用大量隨機 key 時,空值本身也會塞爆快取——此時 bloom filter 才是根本解;(3) 新增資料時要記得刪除對應的空值快取。

多級快取與 local cache vs distributed cache

本地快取(in-process)分散式快取(Redis / Memcached)
延遲奈秒~微秒(無網路、無序列化,若存物件)次毫秒~毫秒(網路 RTT + 序列化)
容量受單一行程記憶體限制可擴展到數百 GB 以上
一致性各實例各自一份,彼此不一致;失效需廣播所有實例共享同一份
命中率每個實例各自暖機,總體命中率較低共享,命中率較高
適合極熱、小、可容忍短暫過時的資料(設定、熱 key)使用者層級資料、session、計數
Python 工具functools.lru_cache、cachetools.TTLCacheredis-py、pymemcache

多級快取:L1 本地快取(短 TTL,如 1~10 秒)→ L2 Redis(分鐘級)→ DB。L1 擋掉熱 key 與 Redis 故障時的部分流量(防雪崩),L2 提供共享與容量。L1 的失效可透過 Redis Pub/Sub 廣播「key X 已變更」,或只依賴短 TTL。Redis 6.0 的 client-side caching(CLIENT TRACKING)讓伺服器在 key 變更時主動通知客戶端失效本地副本。

from cachetools import TTLCache
import threading

_l1 = TTLCache(maxsize=10_000, ttl=5)      # 5 秒本地快取
_l1_lock = threading.Lock()                 # cachetools 本身非執行緒安全

def get_campaign_config(campaign_id: int) -> dict:
    key = f"camp:cfg:{campaign_id}"
    with _l1_lock:
        if key in _l1:
            return _l1[key]
    value = get_cached(key, lambda: load_campaign_from_db(campaign_id), ttl=600)  # L2 + DB
    with _l1_lock:
        _l1[key] = value
    return value
Appier 情境

RTB 的延遲預算通常只有約 100ms(含網路),留給特徵查詢的時間可能只有個位數毫秒。以廣告平台常見做法為例:活動設定放 L1 本地快取(全量載入記憶體)、使用者特徵放 Redis / KV store 並用 pipeline 或 MGET 一次取回、查詢設定嚴格逾時(例如 5ms),逾時就用預設值繼續出價或放棄這次競價,而不是讓整個請求逾時。這種「優雅降級」思維在面試中很加分。

MongoDB:文件模型、索引、聚合與複製分片 進階

MongoDB 以 BSON 文件儲存資料(單一文件上限 16MB),集合(collection)中的文件不強制相同結構。預設儲存引擎 WiredTiger 使用 B-tree、文件層級併發控制與壓縮。4.0 起支援 replica set 上的多文件 ACID 交易,4.2 起支援分片叢集上的交易(但成本較高,設計上仍應以單文件原子性為主)。

嵌入(embedding)vs 引用(referencing)

嵌入

把相關資料放在同一個文件:一次讀取拿到全部、單文件寫入天然原子。適合一對少、一起讀取、子資料不會無限增長的情況,例如 campaign 內嵌 targeting 設定與少量 creatives。

引用

存放另一個文件的 _id,需要第二次查詢或 $lookup。適合一對多(大量)、多對多、子資料會無限增長、或需要獨立存取,例如 campaign 與其每日曝光事件。

經驗法則:依存取模式設計;避免無界陣列(unbounded array)——文件會越來越大,可能觸及 16MB 上限並讓每次更新成本變高。時間序列資料可用 bucket pattern(每小時一個文件,內含該小時的事件陣列)或 5.0+ 的 time series collection。

{
  "_id": {"$oid": "6727a1c2e4b0a1b2c3d4e5f6"},
  "advertiser_id": 3,
  "name": "Black Friday 2026",
  "status": "active",
  "daily_budget_micros": 500000000,
  "targeting": {
    "countries": ["TW", "JP"],
    "devices": ["ios", "android"],
    "segments": ["sports_fan", "high_value"]
  },
  "creatives": [
    {"id": 101, "format": "banner_320x50"},
    {"id": 102, "format": "video_15s"}
  ],
  "updated_at": {"$date": "2026-11-03T02:00:00Z"}
}

索引

  • 種類:單欄位、複合索引、multikey(陣列欄位,每個元素一個索引項)、text、2dsphere(地理)、hashed(用於 hashed sharding)、TTL 索引(自動刪除過期文件,適合 session、暫存資料)、部分索引(partial)、wildcard。
  • 複合索引順序遵循 ESR 規則:Equality(等值)→ Sort(排序)→ Range(範圍),與 MySQL 的「等值在前、範圍在後」概念一致。
  • 用 .explain("executionStats") 看 IXSCAN vs COLLSCAN、totalKeysExamined、totalDocsExamined 與 nReturned 的比例。

Aggregation pipeline 範例

from datetime import datetime, timedelta, timezone
from pymongo import ASCENDING, DESCENDING, MongoClient, WriteConcern
from pymongo.read_concern import ReadConcern

client = MongoClient("mongodb://mongo-0,mongo-1,mongo-2/?replicaSet=rs0",
                     maxPoolSize=50, w="majority", readPreference="secondaryPreferred")
db = client.ads

# 索引:ESR → campaign_id(等值)、ts(排序 / 範圍)
db.events.create_index([("campaign_id", ASCENDING), ("ts", DESCENDING)])
db.sessions.create_index("expire_at", expireAfterSeconds=0)        # TTL 索引

# 每個活動昨天的曝光、點擊、CTR,取前 10
start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=1)
end = start + timedelta(days=1)
pipeline = [
    {"$match": {"ts": {"$gte": start, "$lt": end}}},              # 先過濾,盡量放最前面以利用索引
    {"$group": {
        "_id": "$campaign_id",
        "impressions": {"$sum": {"$cond": [{"$eq": ["$type", "imp"]}, 1, 0]}},
        "clicks": {"$sum": {"$cond": [{"$eq": ["$type", "click"]}, 1, 0]}},
    }},
    {"$addFields": {"ctr": {"$cond": [
        {"$gt": ["$impressions", 0]},
        {"$divide": ["$clicks", "$impressions"]},
        None]}}},
    {"$sort": {"ctr": -1}},
    {"$limit": 10},
    {"$lookup": {"from": "campaigns", "localField": "_id",           # 類似 LEFT JOIN
                 "foreignField": "_id", "as": "campaign"}},
    {"$unwind": "$campaign"},
    {"$project": {"_id": 0, "campaign_id": "$_id", "name": "$campaign.name",
                  "impressions": 1, "clicks": 1, "ctr": 1}},
]
for row in db.events.aggregate(pipeline, allowDiskUse=True):
    print(row)

# 單文件原子更新:頻次計數 +1,不存在就建立
db.freq.update_one({"_id": "u42:c7:20261103"},
                   {"$inc": {"count": 1}, "$setOnInsert": {"expire_at": end + timedelta(days=1)}},
                   upsert=True)

# 指定 write concern / read concern
orders = db.get_collection("billing",
                           write_concern=WriteConcern(w="majority", j=True, wtimeout=5000),
                           read_concern=ReadConcern("majority"))

Replica set 與 write / read concern

Replica set:一個 primary 接收寫入,secondaries 透過複製 oplog 同步;primary 失聯時,其餘成員以類 Raft 協定選出新 primary(需要多數票,所以建議奇數個成員,至少 3 個)。

設定選項意義
Write concern w1 / "majority"(5.0+ 多數情況預設)/ 數字寫入被多少節點確認才回應。majority 保證 failover 後不會被回滾
Write concern jtrue / false是否等寫入 on-disk journal
Read concernlocal(預設)/ available / majority / linearizable / snapshotlocal 可能讀到之後被回滾的資料;majority 只讀已被多數確認的資料;linearizable 最強但只能在 primary 讀單文件
Read preferenceprimary / primaryPreferred / secondary / secondaryPreferred / nearest從哪個節點讀;讀 secondary 可能讀到舊資料

想要 read-your-writes:用 causally consistent session,搭配 majority read / write concern。

分片(sharding)

架構:mongos(查詢路由)+ config servers(儲存 metadata,本身是 replica set)+ 多個 shard(各自是 replica set)。資料依 shard key 切成 chunk(範圍),由 balancer 在 shard 間搬移以平衡。

  • Ranged sharding:範圍查詢有效率,但單調遞增的 key(時間戳、ObjectId)會讓所有寫入集中到最後一個 chunk(熱點)。
  • Hashed sharding:寫入均勻,但範圍查詢要廣播到所有 shard。
  • 查詢帶 shard key → targeted query(只打一個 shard);不帶 → scatter-gather。
  • 好的 shard key:高基數、分布均勻、不單調、符合主要查詢模式。常用複合 shard key 如 {advertiser_id: 1, _id: 1}。5.0+ 支援 reshard,但仍是重量級操作。
面試官想聽到什麼

「MongoDB 和 MySQL 怎麼選?」→ MongoDB 適合 schema 多變、以聚合根(一整份文件)為單位讀寫的資料,如活動設定、使用者 profile、內容管理;MySQL 適合關聯多、需要複雜交易與 JOIN 的資料,如帳務。MongoDB 的強項是開發彈性與內建分片,但也要提「沒有 schema 不代表不需要設計」:要用 JSON Schema validation 或應用層模型約束,否則資料品質會失控。

Cassandra、LSM tree 與 DynamoDB 深入

LSM tree vs B+ tree

LSM tree(Log-Structured Merge tree)是 Cassandra、ScyllaDB、HBase、RocksDB、LevelDB 的核心。想法:把所有寫入轉成順序寫。

flowchart LR W["寫入"] --> CL["Commit log
(WAL,順序追加)"] W --> MT["Memtable
(記憶體中的有序結構)"] MT -->|"滿了 flush"| S0["SSTable L0
(不可變、有序檔)"] S0 -->|"compaction 合併"| S1["SSTable L1"] S1 -->|"compaction"| S2["SSTable L2 ..."] RD["讀取"] --> MT RD --> BF["各 SSTable 的
Bloom filter"] BF -->|"可能存在才讀"| S0 BF --> S1
  1. 寫:先追加到 commit log(持久性),再寫入記憶體中的 memtable(例如 skiplist),就可以回應。沒有隨機寫、沒有讀取舊頁。
  2. Flush:memtable 滿了就整個寫成磁碟上一個不可變(immutable)、按 key 排序的 SSTable 檔。
  3. 讀:依序查 memtable → 由新到舊的 SSTable;每個 SSTable 有 bloom filter(快速排除不含此 key 的檔案)與稀疏索引。
  4. 更新與刪除:不修改舊檔,而是寫入新版本或墓碑(tombstone),讀取時以最新時間戳為準。
  5. Compaction:背景合併多個 SSTable,丟棄舊版本與已過寬限期的墓碑,減少讀取時要查的檔案數。
B+ tree(InnoDB、WiredTiger)LSM tree(Cassandra、RocksDB)
寫入原地更新頁,隨機 I/O;改幾 bytes 也要寫整頁(加上 redo、doublewrite)順序寫,寫入吞吐高
寫放大來自整頁寫入來自 compaction 反覆重寫資料(leveled compaction 尤其明顯)
讀放大低,固定走樹高較高,可能需查多個 SSTable(bloom filter 緩解)
空間放大頁內碎片舊版本與墓碑在 compaction 前持續佔空間
適合讀多、需要範圍查詢與交易寫多、時間序列、事件資料

Compaction 策略取捨:Size-Tiered(STCS)寫放大低、讀與空間放大高;Leveled(LCS)讀放大低、寫放大高,適合讀多;Time-Window(TWCS)按時間視窗分組,整個視窗過期時可直接刪除整個檔案,最適合帶 TTL 的時間序列。

Cassandra 資料模型與 partition key 設計

Cassandra 是無主架構:所有節點對等,以一致性雜湊(含 vnodes)把 partition key 映射到環上,資料複製 RF 份。Primary key = partition key + clustering columns:partition key 決定資料在哪些節點,clustering columns 決定 partition 內的排序。

-- CQL:查詢「某使用者最近的廣告事件」→ 以查詢設計表(query-first)
CREATE TABLE user_events (
  user_id    text,
  day        date,             -- 加入日期作為 partition key 的一部分,避免單一 partition 無限增長
  ts         timeuuid,
  campaign_id bigint,
  event_type text,
  PRIMARY KEY ((user_id, day), ts)
) WITH CLUSTERING ORDER BY (ts DESC)
  AND default_time_to_live = 2592000          -- 30 天
  AND compaction = {'class': 'TimeWindowCompactionStrategy',
                    'compaction_window_unit': 'DAYS', 'compaction_window_size': 1};

SELECT * FROM user_events WHERE user_id = 'u42' AND day = '2026-11-03' LIMIT 50;
-- 不帶完整 partition key 的查詢需要 ALLOW FILTERING(全叢集掃描,線上禁用)
  • Partition 不能太大(常見建議控制在約 100MB 以內):大 partition 造成讀取慢、compaction 與修復壓力、節點熱點。用「時間分桶」((user_id, day))切小。
  • 分布要均勻:避免以低基數欄位(國家、狀態)為 partition key。
  • 反正規化、一個查詢一張表:同一份資料依不同查詢寫入多張表。
  • 墓碑陷阱:大量刪除或把欄位設為 null 會產生墓碑,讀取時要掃過它們,過多會導致查詢逾時(tombstone_failure_threshold)。佇列型用法(寫入後馬上刪除)是 Cassandra 反模式。

可調一致性(tunable consistency)

每個請求可指定一致性等級:ONE、QUORUM(RF/2 向下取整 + 1)、LOCAL_QUORUM(只在本機房取多數,跨機房常用)、ALL 等。規則:R + W > RF 時,讀寫的副本集合必定重疊,能讀到最新寫入。例如 RF = 3,讀寫都用 QUORUM(2)→ 2 + 2 > 3。追求低延遲可用 W = ONE、R = ONE,換取最終一致。修復機制:hinted handoff(暫存給故障節點的寫入)、read repair、定期 anti-entropy repair(Merkle tree 比對)。衝突解決採 last-write-wins(以時間戳為準),所以時鐘同步很重要。

實戰提示

Cassandra 的計數器(counter)型別與輕量交易(LWT,IF NOT EXISTS,基於 Paxos)都有額外成本與限制;高頻計數多半改用 Redis 或在串流層預聚合。ScyllaDB 與 Cassandra 協定相容,以 C++ 與 shard-per-core 架構降低尾延遲,在 AdTech 低延遲特徵查詢中常被採用。

DynamoDB 單表設計概念

DynamoDB 是 AWS 全託管的 KV / document 資料庫。主鍵為 partition key(簡單主鍵)或 partition key + sort key(複合主鍵)。查詢只能高效地「依 partition key 取得,並依 sort key 範圍篩選」(Query),否則只能 Scan 全表。單一項目上限 400KB;單一實體 partition 有吞吐上限(官方文件為每秒 3000 RCU / 1000 WCU),所以 partition key 必須分散。

單表設計(single-table design):因為沒有 JOIN,把多種實體放進同一張表,用通用的 PK / SK 欄位與前綴「預先 JOIN」,讓一次 Query 就拿回一個存取模式需要的所有資料:

PKSK屬性存取模式
ADV#3PROFILEname, email取得廣告主資料
ADV#3CAMP#7name, budget, status列出廣告主的所有活動:PK = ADV#3 AND begins_with(SK, 'CAMP#')
CAMP#7DAY#2026-11-03imps, clicks, spend活動某段期間的每日統計:SK BETWEEN 'DAY#2026-11-01' AND 'DAY#2026-11-03'
USER#u42FC#CAMP#7#2026-11-03count, ttl頻次控制計數(搭配 TTL 屬性自動過期)

其他存取模式用 GSI(Global Secondary Index),例如以 status + updated_at 建 GSI 找出所有 active 活動(GSI 是非同步更新,只支援最終一致讀)。缺點:模型很難理解與演進、新增存取模式可能需要回填資料。原則:先列出所有存取模式,再設計鍵。

Bloom filter 原理與 Python 實作 進階

Bloom filter 是一種空間效率極高的機率型資料結構,回答「這個元素可能存在」或「一定不存在」。沒有 false negative,只有 false positive。

  • 結構:一個 m 位元的位元陣列 + k 個獨立雜湊函數。
  • 加入:對元素算 k 個雜湊值,把對應的 k 個位元設為 1。
  • 查詢:k 個位元全為 1 → 可能存在;任一為 0 → 一定不存在。
  • 誤判率:p ≈ (1 − e−kn/m)k;給定 n 與目標 p,最佳 m = −n·ln p / (ln 2)²、k = (m/n)·ln 2。p = 1% 時每個元素約需 9.6 bits、k ≈ 7。一億個元素、1% 誤判率約需 120MB,比存原始 key 小得多。
  • 不支援刪除(把位元清 0 可能影響其他元素)。需要刪除用 Counting Bloom filter(每格改為計數器)或 Cuckoo filter。元素數超過設計容量時誤判率急升,可用 Scalable Bloom filter。
  • 應用:快取穿透防護、LSM tree 的 SSTable 過濾、爬蟲 URL 去重、推薦系統「已曝光過的內容」過濾、Chrome 舊版惡意網址檢查。
import hashlib
import math

class BloomFilter:
    def __init__(self, capacity: int, error_rate: float = 0.01):
        self.m = math.ceil(-capacity * math.log(error_rate) / (math.log(2) ** 2))  # 位元數
        self.k = max(1, round(self.m / capacity * math.log(2)))                  # 雜湊函數數
        self.bits = bytearray((self.m + 7) // 8)

    def _positions(self, item: str):
        # Kirsch–Mitzenmacher 技巧:用兩個雜湊值組合出 k 個:h_i = h1 + i * h2 (mod m)
        digest = hashlib.blake2b(item.encode(), digest_size=16).digest()
        h1 = int.from_bytes(digest[:8], "little")
        h2 = int.from_bytes(digest[8:], "little") | 1     # 確保為奇數,避免週期過短
        return ((h1 + i * h2) % self.m for i in range(self.k))

    def add(self, item: str) -> None:
        for pos in self._positions(item):
            self.bits[pos >> 3] |= 1 << (pos & 7)

    def __contains__(self, item: str) -> bool:
        return all(self.bits[pos >> 3] & (1 << (pos & 7)) for pos in self._positions(item))

if __name__ == "__main__":
    bf = BloomFilter(capacity=100_000, error_rate=0.01)
    print(f"m={bf.m} bits ({bf.m / 8 / 1024:.1f} KiB), k={bf.k}")   # 約 117 KiB, k=7
    for i in range(100_000):
        bf.add(f"campaign:{i}")
    assert all(f"campaign:{i}" in bf for i in range(100_000))      # 無 false negative
    fp = sum(f"other:{i}" in bf for i in range(100_000)) / 100_000
    print(f"observed false positive rate = {fp:.4f}")              # 約 0.01
# 線上通常直接用 Redis 模組(RedisBloom;Redis 8 起內建於 Redis Open Source)
BF.RESERVE bf:campaign_ids 0.01 10000000
BF.ADD bf:campaign_ids campaign:42
BF.EXISTS bf:campaign_ids campaign:99999999    # 0 → 一定不存在,直接回 404,不查 DB
# 沒有模組時也可用 SETBIT / GETBIT 在 Redis bitmap 上自行實作(上面 _positions 的位置即 offset)
面試官想聽到什麼

能推導「為什麼只有 false positive」、知道 m 與 k 如何依容量與誤判率決定、知道不能刪除與替代方案,並能連結到實際用途(快取穿透、LSM)。追問「bloom filter 資料要怎麼更新?」→ 新增資料時同步 BF.ADD;刪除的資料只能等定期重建(從 DB 全量重建後原子切換 key)。

Python redis-py 實戰:連線池、Pipeline、限流、頻次控制 高頻

連線池與 pipeline

import redis

# 一個行程共用一個連線池(redis.Redis 本身執行緒安全,內部從池中借連線)
pool = redis.ConnectionPool(
    host="redis.internal", port=6379, db=0,
    max_connections=50,
    socket_timeout=0.05,           # 50ms:熱路徑要設短逾時,寧可降級也不要卡住
    socket_connect_timeout=0.2,
    health_check_interval=30,
    decode_responses=True,
)
r = redis.Redis(connection_pool=pool)
# Redis Cluster:from redis.cluster import RedisCluster; rc = RedisCluster(host=..., port=...)
# asyncio:import redis.asyncio as aioredis

# 批次取多位使用者的特徵:N 次 RTT → 1 次
def get_features(user_ids: list[str]) -> dict[str, dict]:
    pipe = r.pipeline(transaction=False)          # 不需要 MULTI/EXEC 時關掉 transaction
    for uid in user_ids:
        pipe.hmget(f"feat:{uid}", "segments", "ctr_7d", "last_seen")
    results = pipe.execute()
    return {uid: dict(zip(("segments", "ctr_7d", "last_seen"), vals))
            for uid, vals in zip(user_ids, results)}

# WATCH + MULTI 樂觀鎖(CAS):只有在餘額足夠時扣款
def deduct(key: str, amount: int) -> bool:
    with r.pipeline() as pipe:
        while True:
            try:
                pipe.watch(key)
                balance = int(pipe.get(key) or 0)
                if balance < amount:
                    pipe.unwatch()
                    return False
                pipe.multi()
                pipe.decrby(key, amount)
                pipe.execute()                    # key 被別人改過會拋 WatchError
                return True
            except redis.WatchError:
                continue                          # 重試(實務上應限制次數)

滑動視窗限流(ZSet + Lua)

固定視窗(INCR + EXPIRE)簡單但在視窗邊界可能放行 2 倍流量;滑動視窗日誌用 ZSet 記錄每次請求的時間戳,精確但記憶體與請求數成正比。整段邏輯用 Lua 保證原子性。

import time
import uuid

SLIDING_WINDOW_LUA = """
local key    = KEYS[1]
local now    = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit  = tonumber(ARGV[3])
local member = ARGV[4]
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)   -- 移除視窗外的舊紀錄
local count = redis.call('ZCARD', key)
if count < limit then
  redis.call('ZADD', key, now, member)
  redis.call('PEXPIRE', key, window)
  return {1, limit - count - 1}
end
return {0, 0}
"""
sliding_window = r.register_script(SLIDING_WINDOW_LUA)

def allow(client_id: str, limit: int = 100, window_ms: int = 60_000) -> tuple[bool, int]:
    now_ms = int(time.time() * 1000)
    allowed, remaining = sliding_window(
        keys=[f"rl:{client_id}"],
        args=[now_ms, window_ms, limit, f"{now_ms}-{uuid.uuid4().hex[:8]}"],  # member 需唯一
    )
    return bool(allowed), int(remaining)

# 用法(例如 API middleware):
# ok, remaining = allow(api_key)
# if not ok: return 429 Too Many Requests, header Retry-After / X-RateLimit-Remaining
實戰提示

時間戳以應用伺服器時間計算,多台機器時鐘不一致會有誤差;可改在 Lua 中用 redis.call('TIME') 取 Redis 伺服器時間(Redis 5+ 預設以效果複製 script,可安全使用非確定性命令)。高 QPS 時 ZSet 日誌成本高,可改用滑動視窗計數器(目前視窗計數 + 前一視窗計數 × 重疊比例)或 token bucket(在 Lua 中記錄 tokens 與上次補充時間)。

Appier 情境:頻次控制(frequency capping)

廣告主常要求「同一使用者每天最多看到某活動 N 次」。出價前要檢查、曝光後要累加,而且是極高 QPS。

FREQ_CAP_LUA = """
-- KEYS[1] = fc:{uid}:20261103   ARGV[1] = campaign_id   ARGV[2] = cap   ARGV[3] = ttl 秒
local current = tonumber(redis.call('HGET', KEYS[1], ARGV[1]) or '0')
if current >= tonumber(ARGV[2]) then
  return -1
end
local n = redis.call('HINCRBY', KEYS[1], ARGV[1], 1)
if n == 1 then
  redis.call('EXPIRE', KEYS[1], tonumber(ARGV[3]))
end
return n
"""
freq_cap = r.register_script(FREQ_CAP_LUA)

def fc_key(uid: str, day: str) -> str:
    return f"fc:{{{uid}}}:{day}"       # hash tag {uid}:同一使用者的 key 落在同一 slot

def can_serve(uid: str, day: str, campaign_ids: list[int], caps: dict[int, int]) -> list[int]:
    """出價前:一次取回此使用者今天所有活動的次數,過濾掉已達上限者(只讀,不累加)。"""
    counts = r.hmget(fc_key(uid, day), [str(c) for c in campaign_ids])
    return [c for c, n in zip(campaign_ids, counts) if int(n or 0) < caps[c]]

def record_impression(uid: str, day: str, campaign_id: int, cap: int) -> bool:
    """實際曝光(收到 win notice / impression beacon)後才累加。"""
    n = freq_cap(keys=[fc_key(uid, day)], args=[campaign_id, cap, 2 * 86400])
    return n != -1
  • Key 設計:每位使用者每天一個 hash(欄位 = campaign_id),比「每位使用者 × 每活動一個 string key」省下大量 key 物件開銷,小 hash 走 listpack 編碼更省記憶體。Redis 7.4+ 也支援 hash 欄位層級的過期(HEXPIRE)。
  • 先檢查、後累加:出價時只讀(大部分競價不會贏,不該累加);贏標並確認曝光後才 HINCRBY。兩者之間有時間差,高併發下可能少量超過上限,業務上通常可接受。
  • 容量估算:例如 1 億使用者,每人每天平均 5 個活動欄位,每個小 hash 粗估一兩百 bytes → 數十 GB 等級,需要 Redis Cluster 分片;多區域部署時各區獨立計數再非同步合併,接受最終一致。
  • 降級:Redis 逾時就當作「未達上限」或「直接不出價」,取決於廣告主合約的嚴格程度。

Appier 情境:使用者 profile / feature store 低延遲查詢

以廣告平台常見做法為例:離線(Spark)每日 / 每小時計算使用者特徵與分群,串流(Flink)更新近即時特徵(最近 1 小時點擊數),兩者寫入線上 KV,供出價與推薦模型在請求時讀取。

import json

def bulk_load_features(rows, version: str, batch: int = 1000) -> None:
    """批次寫入新版本特徵:寫到帶版本的 key,全部完成後再切換指標,避免讀到一半新一半舊。"""
    pipe = r.pipeline(transaction=False)
    for i, (uid, feats) in enumerate(rows, 1):
        key = f"feat:{version}:{uid}"
        pipe.hset(key, mapping={k: json.dumps(v) for k, v in feats.items()})
        pipe.expire(key, 3 * 86400)               # 舊版本自動過期
        if i % batch == 0:
            pipe.execute()
    pipe.execute()
    r.set("feat:current_version", version)        # 原子切換

def read_features(uid: str) -> dict:
    version = r.get("feat:current_version")       # 實務上放本地快取幾秒,避免成為 hot key
    raw = r.hgetall(f"feat:{version}:{uid}")
    return {k: json.loads(v) for k, v in raw.items()}
Appier 情境:面試時的完整敘述

「使用者特徵查詢在 RTB 熱路徑上,P99 要在數毫秒內。我會:(1) 用 user_id 為 key 的 hash 存特徵,只取模型需要的欄位(HMGET);(2) 一個請求的多次查詢用 pipeline 合併;(3) 用 Redis Cluster 分片並以 replica 分擔讀取;(4) 設定極短逾時與預設特徵作為降級;(5) 資料量超過記憶體成本可接受的範圍時,冷資料改放 SSD 型 KV(Aerospike / ScyllaDB / DynamoDB),Redis 只放熱資料;(6) 批次載入使用版本化 key 與原子切換;(7) 監控命中率、延遲分佈、記憶體與 big key。」

面試自測題

SQL 與 NoSQL 如何選擇?請舉廣告系統中的例子。

依存取模式、一致性需求、規模與維運能力決定,而不是「哪個比較快」。需要跨多筆資料的交易與複雜查詢(活動設定、廣告主帳務)用 MySQL;依 key 點查、次毫秒延遲(使用者特徵、頻次計數、session)用 Redis;海量寫入、時間序列、多機房(使用者事件歷史)用 Cassandra / ScyllaDB / DynamoDB;schema 多變、以文件為單位存取(活動素材、設定)可用 MongoDB;億級事件聚合報表用 ClickHouse / BigQuery;全文搜尋用 Elasticsearch。實務上是多種儲存並用,並明確定義 source of truth 與同步方式(CDC、批次)。

Redis 為什麼單執行緒還這麼快?6.0 的多執行緒改了什麼?

資料在記憶體中,大部分命令微秒內完成,瓶頸是網路而非 CPU;用 epoll 做 I/O 多工,一個執行緒服務大量連線;單執行緒沒有鎖競爭與 context switch,命令天然原子;底層資料結構緊湊高效。6.0 的 I/O threads 只把 socket 讀寫與協定解析多執行緒化,命令執行仍是單執行緒,因此原子性語意不變。代價是任何慢命令(KEYS、大 key 操作、長 Lua)會阻塞所有客戶端。要利用多核就跑多個實例或用 Cluster。

ZSet 底層是什麼?為什麼用 skiplist 而不是紅黑樹?

元素少(預設 ≤ 128 且成員 ≤ 64 bytes)時用 listpack;超過後用 skiplist + hashtable:hashtable 提供 O(1) 的 member → score,skiplist 依 score 排序支援 O(log N) 插入刪除與範圍查詢。選 skiplist 的理由:範圍查詢只要找到起點沿底層鏈結串列走即可;實作簡單、沒有旋轉;以 p = 1/4 調整後平均指標數少於平衡樹;每層記錄 span 可在 O(log N) 求排名(ZRANK)。

Redis 的過期 key 是怎麼刪除的?記憶體滿了會怎樣?

惰性刪除(存取時檢查)加上定期刪除(每秒 hz 次,從有 TTL 的 key 隨機取樣,過期比例高就繼續,有時間上限)。仍殘留的由淘汰策略兜底:maxmemory-policy 預設 noeviction(寫入報錯),快取常用 allkeys-lru 或 allkeys-lfu;volatile-* 只淘汰有 TTL 的 key。Redis 的 LRU 是近似的:隨機取樣 maxmemory-samples 個 key 放入候選池,淘汰最久未用者;LFU 用 8-bit 對數計數器並隨時間衰減,能抵抗一次性掃描污染。

RDB 與 AOF 的差異?如何選擇 fsync 策略?

RDB 是 fork 子行程利用 COW 產生的時間點快照,檔案小、恢復快,但會遺失上次快照後的資料,fork 在大記憶體時有停頓。AOF 記錄每個寫命令,appendfsync always 最安全但慢、everysec(預設)最多丟約 1 秒、no 交給 OS。AOF 需要 rewrite 壓縮,7.0 改為 multi-part AOF。混合持久化(aof-use-rdb-preamble,5.0 起預設開)以 RDB 為 base 加上增量 AOF。純快取可只開 RDB 或不開;重要資料用 AOF everysec + 定期 RDB 異地備份。另外要提複製是非同步的,failover 也可能遺失資料。

Redis Cluster 如何分片?MOVED 與 ASK 有什麼差別?為什麼是 16384 個 slot?

slot = CRC16(key) mod 16384,每個 master 負責部分 slot,節點以 gossip 交換狀態,客戶端快取 slot 對照表直連節點。MOVED 表示 slot 已永久遷移,客戶端要更新對照表;ASK 表示 slot 正在遷移、該 key 已在目標節點,客戶端只對這次請求先送 ASKING 再重送,不更新對照表。16384 的原因:心跳封包要帶 slot bitmap,16384 bits 為 2KB,65536 會變 8KB;叢集建議不超過約 1000 節點,16384 已足夠。多 key 操作需要同一 slot,可用 hash tag {...} 讓相關 key 落在一起,但要小心資料傾斜。

Pipeline、MULTI/EXEC、Lua script 有什麼差別?Redis 交易會回滾嗎?

Pipeline 只是把多個命令一次送出以減少 RTT,沒有原子性。MULTI/EXEC 讓排隊的命令依序執行、不被其他客戶端插入,但不支援回滾:排隊階段的語法錯誤會讓整個交易被拒絕,執行階段錯誤(如 WRONGTYPE)只影響該命令,其餘照常執行;也無法依中間結果做判斷,需搭配 WATCH 實作 CAS。Lua script 在伺服器端原子執行且可包含條件邏輯,適合「檢查後再修改」(限流、釋放鎖、頻次控制),但執行太久會阻塞整個 Redis。Cluster 下交易與 script 的 key 都必須在同一 slot。

如何用 Redis 實作分散式鎖?有哪些坑?

取鎖用 SET key token NX PX ttl 一個原子命令;token 為唯一值;釋放時用 Lua 比對 token 相同才 DEL,避免鎖過期後誤刪別人的鎖;任務可能超過 TTL 時用看門狗續約。坑:(1) SETNX 與 EXPIRE 分開執行會留下死鎖;(2) 主從非同步複製下,主庫掛掉可能使鎖遺失;(3) GC 停頓或網路延遲可能讓客戶端在鎖過期後仍以為持有鎖。Redlock 用多個獨立 master 取多數,但 Kleppmann 指出它依賴時鐘、且無 fencing token。結論:為效率可用單 Redis 鎖;為正確性用 etcd / ZooKeeper 並讓資源端檢查 fencing token,或用 DB 唯一約束與冪等設計避免需要鎖。

快取與資料庫如何保持一致?為什麼先更新 DB 再刪快取?

使用 cache-aside:讀 miss 回填;寫入時先更新 DB 再刪快取。刪除而非更新,是為了避免併發寫入的順序問題並延遲計算。若先刪快取再更新 DB,併發讀會在 DB 更新前把舊值回填,機率很高;先更新 DB 再刪快取只在「讀 DB + 回填」橫跨整個「寫 DB + 刪快取」時才出錯,機率低。強化手段:所有 key 設 TTL、刪除失敗送 MQ 重試、延遲雙刪、訂閱 binlog(Canal / Debezium)統一失效、帶版本號寫入。快取本質是最終一致,強一致需求的資料不快取或讀主庫。

說明快取穿透、擊穿、雪崩的差別與解法。

穿透:查詢不存在的資料,快取永遠 miss 打到 DB;解法是參數校驗、快取空值(短 TTL)、bloom filter。擊穿:單一熱 key 過期瞬間大量併發同時重建;解法是互斥鎖 / singleflight 只讓一個請求重建、邏輯過期(先回舊值、背景重建)、熱 key 不過期並主動更新。雪崩:大量 key 同時過期或 Redis 整體故障;解法是 TTL 加隨機抖動、快取高可用、多級快取(本地 L1)、DB 前限流熔斷降級、預熱。回答時要能講出每種方案的副作用(空值佔空間、鎖等待延遲、邏輯過期會短暫回舊資料)。

本地快取與分散式快取各有什麼優缺點?什麼時候用多級快取?

本地快取無網路與序列化開銷、延遲極低,但容量受限、各實例之間不一致、命中率因各自暖機而較低。分散式快取共享、容量大、一致性較好,但有網路 RTT 與單點熱 key 風險。多級快取(L1 本地短 TTL → L2 Redis → DB)適合極熱、可容忍短暫過時的資料,例如活動設定;L1 擋掉 hot key 與 Redis 故障的流量。L1 失效可用 Pub/Sub 廣播、Redis 6 的 client-side caching(CLIENT TRACKING),或只依賴短 TTL。

什麼是 big key 和 hot key?如何發現與處理?

Big key 是 value 很大或集合元素很多的 key,讀寫會佔用網路與主執行緒、同步刪除會阻塞、Cluster 中造成記憶體傾斜。用 redis-cli --bigkeys / --memkeys、MEMORY USAGE、離線分析 RDB 發現;處理方式是拆分成多個小 key、只取需要的欄位、用 HSCAN 漸進讀取、用 UNLINK 非同步刪除。Hot key 是 QPS 異常集中的 key,會讓單一節點過載;用 --hotkeys(需 LFU)或客戶端統計發現;處理方式是本地快取、把 key 複製成多份分散到不同 slot、讀取分散到 replica、寫入在應用端先聚合。

MongoDB 什麼時候嵌入、什麼時候引用?write concern majority 有什麼意義?

一對少、總是一起讀取、子資料不會無限增長時嵌入,可一次讀取並享有單文件原子性;一對大量、多對多、子資料無界增長或需獨立存取時引用(或用 bucket pattern)。避免無界陣列,文件上限 16MB。w: majority 表示寫入要被多數成員確認才回應,保證 failover 後不會被回滾;搭配 j: true 確保寫入 journal。read concern majority 只讀已被多數確認的資料;讀 secondary 可能讀到舊資料,需要 read-your-writes 時用 causally consistent session。

LSM tree 與 B+ tree 的差異?什麼是寫放大、讀放大?

LSM 把寫入轉成順序寫:commit log + memtable,滿了 flush 成不可變的有序 SSTable,背景 compaction 合併;讀取需查 memtable 與多個 SSTable,用 bloom filter 排除。B+ tree 原地更新頁、讀取固定走樹高。寫放大是實際寫入磁碟的量相對於使用者寫入量的倍數:B+ tree 來自整頁寫入,LSM 來自 compaction 反覆重寫。讀放大是一次讀取需要讀的資料量或檔案數:LSM 較高。空間放大:LSM 在 compaction 前保留舊版本與墓碑。所以 LSM 適合寫多(事件、時間序列),B+ tree 適合讀多與範圍查詢、交易。Compaction 策略(STCS / LCS / TWCS)是在三種放大之間取捨。

Cassandra 的 partition key 怎麼設計?QUORUM 讀寫為什麼能讀到最新資料?

以查詢設計表:partition key 必須是查詢一定帶的條件、分布均勻、高基數;單一 partition 不能無限增長(加入時間分桶,如 (user_id, day)),常見建議控制在約 100MB 以內;clustering columns 決定 partition 內排序。避免大量刪除產生墓碑與佇列型用法。一致性:RF = 3 時 QUORUM = 2,讀寫都用 QUORUM 則 R + W = 4 > 3,讀與寫的副本集合必然有交集,能讀到最新寫入(衝突以 last-write-wins 解決,依賴時鐘)。跨機房常用 LOCAL_QUORUM 以避免跨區延遲。

Bloom filter 為什麼只有 false positive?參數怎麼決定?

加入元素時把 k 個雜湊位置設為 1;查詢時只要有一個位置是 0,該元素一定沒加入過(因為加入過的話那位一定是 1),所以沒有 false negative;但所有位置都是 1 可能是其他元素造成的,所以有 false positive。給定元素數 n 與目標誤判率 p:m = −n ln p / (ln 2)²、k = (m/n) ln 2;p = 1% 約每元素 9.6 bits、k ≈ 7。不支援刪除,需要時用 counting bloom filter 或 cuckoo filter;超過容量誤判率會急升,需要重建或用 scalable 版本。用途:防快取穿透、LSM 讀取過濾、去重。

用 Redis 設計 API 限流,有哪些演算法?如何保證原子性?

固定視窗計數(INCR + EXPIRE,簡單但邊界可能放行 2 倍)、滑動視窗日誌(ZSet 存每次請求時間戳,ZREMRANGEBYSCORE 清舊再 ZCARD,精確但記憶體與請求量成正比)、滑動視窗計數器(目前與前一視窗加權,近似且省記憶體)、token bucket / leaky bucket(允許突發、平滑流量)。「讀取計數 → 判斷 → 寫入」必須原子,否則併發下會超量,所以用 Lua script 封裝整段邏輯;Cluster 下以使用者或 API key 為 key 自然分散。另需考慮 Redis 故障時要 fail-open 還是 fail-closed、時鐘來源(用 Redis TIME)、回傳 429 與 Retry-After。

如何用 Redis 實作廣告的頻次控制(frequency capping)?

每位使用者每天一個 hash:fc:{uid}:YYYYMMDD,欄位為 campaign_id、值為次數,TTL 稍長於一天。出價前以 HMGET 一次讀取候選活動的次數並過濾已達上限者(只讀);確認曝光後用 Lua 原子地「檢查未達上限才 HINCRBY、首次設定 EXPIRE」。hash tag 讓同一使用者的 key 在同一 slot。取捨:讀與累加之間有時間差,可能少量超出上限;多區域各自計數最終一致;Redis 逾時的降級策略(不出價或忽略上限)依合約決定;容量依使用者數 × 活動數估算並用 Cluster 分片,小 hash 的 listpack 編碼能省記憶體。