<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>模組二：快取與 Redis on Tarragon</title><link>https://tarrragon.github.io/blog/backend/02-cache-redis/</link><description>Recent content in 模組二：快取與 Redis on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Wed, 22 Apr 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/backend/02-cache-redis/index.xml" rel="self" type="application/rss+xml"/><item><title>2.1 高併發下的 Redis 讀寫邊界</title><link>https://tarrragon.github.io/blog/backend/02-cache-redis/high-concurrency-access/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/02-cache-redis/high-concurrency-access/</guid><description>&lt;p>Redis 在後端服務裡常扮演 cache、session、counter、dedup、presence 或輕量協調層。它通常比 SQL 更適合高併發短操作，但前提是 client、連線池、pipeline 與 key 設計都受控。高併發下的 Redis 仍然會遇到 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/hot-key/" data-link-title="Hot Key" data-link-desc="說明單一 key 承受大量讀寫時如何形成容量瓶頸">hot key&lt;/a>、快取穿透、stampede、過大 pipeline 與不當鎖設計。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>理解為什麼 Redis client 應該共用&lt;/li>
&lt;li>分辨單鍵操作、pipeline、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction&lt;/a> 與 Lua 的邊界&lt;/li>
&lt;li>了解高併發下的 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/cache-stampede/" data-link-title="Cache Stampede" data-link-desc="說明快取同時失效時大量 request 如何壓垮正式來源">cache stampede&lt;/a> 與 hot key 問題&lt;/li>
&lt;li>用 &lt;code>context&lt;/code> 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout&lt;/a> 保護 Redis 呼叫&lt;/li>
&lt;li>把 Redis 用在適合的資料角色，並保留正式狀態來源&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察redis-呼叫大多是短網路-io">【觀察】Redis 呼叫大多是短網路 I/O&lt;/h2>
&lt;p>應用端對 Redis 的操作通常是短小但頻繁的網路請求。這代表真正影響效能的往往是 RTT、連線重用、批次送出與 key 設計。&lt;/p>
&lt;p>所以高併發時，重點是控制 Redis 邊界：&lt;/p>
&lt;ul>
&lt;li>用同一個 client 共用連線池&lt;/li>
&lt;li>對獨立操作使用合理的 pipeline&lt;/li>
&lt;li>熱門資料要避免集中到單一 key&lt;/li>
&lt;/ul>
&lt;h2 id="判讀client-共用比每次建立更重要">【判讀】client 共用比每次建立更重要&lt;/h2>
&lt;p>Redis client 的核心設計通常就是讓應用共用同一個實例。每個 request 都 new client，會把連線管理成本、握手成本與資源回收問題全部放大。&lt;/p>
&lt;p>高併發服務通常會採用：&lt;/p>
&lt;ul>
&lt;li>process 啟動時建立一個 Redis client&lt;/li>
&lt;li>request handler、worker、service layer 共用它&lt;/li>
&lt;li>所有操作都帶 &lt;code>context&lt;/code>&lt;/li>
&lt;li>timeout 與取消由上層傳入&lt;/li>
&lt;/ul>
&lt;h2 id="策略pipeline-用來節省-rtt">【策略】pipeline 用來節省 RTT&lt;/h2>
&lt;p>pipeline 的價值是把多個獨立命令一次送出，減少往返次數。它很適合：&lt;/p>
&lt;ul>
&lt;li>多個彼此獨立的讀取&lt;/li>
&lt;li>批次寫入&lt;/li>
&lt;li>一次更新多個 cache key&lt;/li>
&lt;/ul>
&lt;p>pipeline 的核心限制是批次大小仍要受控。太大的 pipeline 會帶來：&lt;/p>
&lt;ul>
&lt;li>內存壓力&lt;/li>
&lt;li>回應延遲變大&lt;/li>
&lt;li>單次失敗影響更多操作&lt;/li>
&lt;/ul>
&lt;h2 id="判讀原子性需求要分清楚">【判讀】原子性需求要分清楚&lt;/h2>
&lt;p>Redis 的很多操作本身就可以很快，但原子性與一致性需要額外設計。當需求需要多個資料變更形成同一個結果時，才應該考慮：&lt;/p>
&lt;ul>
&lt;li>單鍵原子操作&lt;/li>
&lt;li>transaction&lt;/li>
&lt;li>Lua script&lt;/li>
&lt;li>由上層做去重或補償&lt;/li>
&lt;/ul>
&lt;p>transaction 應服務明確的一致性需求，cache 寫入也應維持輔助狀態定位。Redis 很常是輔助狀態，真正的 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">source of truth&lt;/a> 通常還是在 SQL 或 domain store。&lt;/p>
&lt;h2 id="策略cache-stampede-與-hot-key-要先處理">【策略】cache stampede 與 hot key 要先處理&lt;/h2>
&lt;p>高併發快取最常見的兩個問題，是大量 goroutine 同時 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/cache-hit-miss/" data-link-title="Cache Hit / Miss" data-link-desc="說明快取命中與未命中如何影響讀取成本與下游壓力">miss&lt;/a> 同一筆資料，以及大量流量打到同一個 key。&lt;/p>
&lt;h3 id="cache-stampede">cache stampede&lt;/h3>
&lt;p>當 cache miss 發生時，如果每個 request 都直接回源查 DB，會把後端放大成更大的壓力。常見的處理方式包括：&lt;/p>
&lt;ul>
&lt;li>設定合理 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/ttl/" data-link-title="TTL" data-link-desc="說明資料過期時間如何影響快取新鮮度、成本與一致性">TTL&lt;/a>&lt;/li>
&lt;li>加 single-flight 類型的去重&lt;/li>
&lt;li>讓部分請求等待同一批重建結果&lt;/li>
&lt;li>對重建失敗設退避或短暫保護&lt;/li>
&lt;/ul>
&lt;h3 id="hot-key">hot key&lt;/h3>
&lt;p>如果某些 key 過度熱門，壓力會集中到 Redis 甚至單一 shard。處理方式通常是：&lt;/p>
&lt;ul>
&lt;li>拆 key 或拆資料粒度&lt;/li>
&lt;li>讓讀取走多層 cache&lt;/li>
&lt;li>降低單點依賴&lt;/li>
&lt;li>在應用端做短暫本地快取或節流&lt;/li>
&lt;/ul>
&lt;h2 id="cache-在規模化服務的角色光譜主寫於-_index">Cache 在規模化服務的角色光譜（主寫於 _index）&lt;/h2>
&lt;p>Cache 在規模化服務的角色從「DB 補救」逐步轉變到「主要服務面」再到「資料平面」、是橫跨整個 02 模組的入門 frame。完整光譜跟判讀條件主寫於 &lt;a href="https://tarrragon.github.io/blog/backend/02-cache-redis/" data-link-title="模組二：快取與 Redis" data-link-desc="整理快取策略、Redis 資料型別與分散式狀態輔助能力">模組入口的「規模化下 cache 的角色光譜」段&lt;/a>；本章從 &lt;em>高併發讀寫&lt;/em> 角度補充：當 cache 已落在「主要服務面」或「資料平面」角色、cache lookup 是 critical path、容量規劃跟 stampede 防護要按本章「Cache 容量規劃跟 DB 不一樣」段執行。&lt;/p></description><content:encoded><![CDATA[<p>Redis 在後端服務裡常扮演 cache、session、counter、dedup、presence 或輕量協調層。它通常比 SQL 更適合高併發短操作，但前提是 client、連線池、pipeline 與 key 設計都受控。高併發下的 Redis 仍然會遇到 <a href="/blog/backend/knowledge-cards/hot-key/" data-link-title="Hot Key" data-link-desc="說明單一 key 承受大量讀寫時如何形成容量瓶頸">hot key</a>、快取穿透、stampede、過大 pipeline 與不當鎖設計。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>理解為什麼 Redis client 應該共用</li>
<li>分辨單鍵操作、pipeline、<a href="/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction</a> 與 Lua 的邊界</li>
<li>了解高併發下的 <a href="/blog/backend/knowledge-cards/cache-stampede/" data-link-title="Cache Stampede" data-link-desc="說明快取同時失效時大量 request 如何壓垮正式來源">cache stampede</a> 與 hot key 問題</li>
<li>用 <code>context</code> 與 <a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a> 保護 Redis 呼叫</li>
<li>把 Redis 用在適合的資料角色，並保留正式狀態來源</li>
</ol>
<hr>
<h2 id="觀察redis-呼叫大多是短網路-io">【觀察】Redis 呼叫大多是短網路 I/O</h2>
<p>應用端對 Redis 的操作通常是短小但頻繁的網路請求。這代表真正影響效能的往往是 RTT、連線重用、批次送出與 key 設計。</p>
<p>所以高併發時，重點是控制 Redis 邊界：</p>
<ul>
<li>用同一個 client 共用連線池</li>
<li>對獨立操作使用合理的 pipeline</li>
<li>熱門資料要避免集中到單一 key</li>
</ul>
<h2 id="判讀client-共用比每次建立更重要">【判讀】client 共用比每次建立更重要</h2>
<p>Redis client 的核心設計通常就是讓應用共用同一個實例。每個 request 都 new client，會把連線管理成本、握手成本與資源回收問題全部放大。</p>
<p>高併發服務通常會採用：</p>
<ul>
<li>process 啟動時建立一個 Redis client</li>
<li>request handler、worker、service layer 共用它</li>
<li>所有操作都帶 <code>context</code></li>
<li>timeout 與取消由上層傳入</li>
</ul>
<h2 id="策略pipeline-用來節省-rtt">【策略】pipeline 用來節省 RTT</h2>
<p>pipeline 的價值是把多個獨立命令一次送出，減少往返次數。它很適合：</p>
<ul>
<li>多個彼此獨立的讀取</li>
<li>批次寫入</li>
<li>一次更新多個 cache key</li>
</ul>
<p>pipeline 的核心限制是批次大小仍要受控。太大的 pipeline 會帶來：</p>
<ul>
<li>內存壓力</li>
<li>回應延遲變大</li>
<li>單次失敗影響更多操作</li>
</ul>
<h2 id="判讀原子性需求要分清楚">【判讀】原子性需求要分清楚</h2>
<p>Redis 的很多操作本身就可以很快，但原子性與一致性需要額外設計。當需求需要多個資料變更形成同一個結果時，才應該考慮：</p>
<ul>
<li>單鍵原子操作</li>
<li>transaction</li>
<li>Lua script</li>
<li>由上層做去重或補償</li>
</ul>
<p>transaction 應服務明確的一致性需求，cache 寫入也應維持輔助狀態定位。Redis 很常是輔助狀態，真正的 <a href="/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">source of truth</a> 通常還是在 SQL 或 domain store。</p>
<h2 id="策略cache-stampede-與-hot-key-要先處理">【策略】cache stampede 與 hot key 要先處理</h2>
<p>高併發快取最常見的兩個問題，是大量 goroutine 同時 <a href="/blog/backend/knowledge-cards/cache-hit-miss/" data-link-title="Cache Hit / Miss" data-link-desc="說明快取命中與未命中如何影響讀取成本與下游壓力">miss</a> 同一筆資料，以及大量流量打到同一個 key。</p>
<h3 id="cache-stampede">cache stampede</h3>
<p>當 cache miss 發生時，如果每個 request 都直接回源查 DB，會把後端放大成更大的壓力。常見的處理方式包括：</p>
<ul>
<li>設定合理 <a href="/blog/backend/knowledge-cards/ttl/" data-link-title="TTL" data-link-desc="說明資料過期時間如何影響快取新鮮度、成本與一致性">TTL</a></li>
<li>加 single-flight 類型的去重</li>
<li>讓部分請求等待同一批重建結果</li>
<li>對重建失敗設退避或短暫保護</li>
</ul>
<h3 id="hot-key">hot key</h3>
<p>如果某些 key 過度熱門，壓力會集中到 Redis 甚至單一 shard。處理方式通常是：</p>
<ul>
<li>拆 key 或拆資料粒度</li>
<li>讓讀取走多層 cache</li>
<li>降低單點依賴</li>
<li>在應用端做短暫本地快取或節流</li>
</ul>
<h2 id="cache-在規模化服務的角色光譜主寫於-_index">Cache 在規模化服務的角色光譜（主寫於 _index）</h2>
<p>Cache 在規模化服務的角色從「DB 補救」逐步轉變到「主要服務面」再到「資料平面」、是橫跨整個 02 模組的入門 frame。完整光譜跟判讀條件主寫於 <a href="/blog/backend/02-cache-redis/" data-link-title="模組二：快取與 Redis" data-link-desc="整理快取策略、Redis 資料型別與分散式狀態輔助能力">模組入口的「規模化下 cache 的角色光譜」段</a>；本章從 <em>高併發讀寫</em> 角度補充：當 cache 已落在「主要服務面」或「資料平面」角色、cache lookup 是 critical path、容量規劃跟 stampede 防護要按本章「Cache 容量規劃跟 DB 不一樣」段執行。</p>
<p>對應 <a href="/blog/backend/09-performance-capacity/cases/tinder-elasticache-valkey-matching/" data-link-title="9.C6 Tinder：ElastiCache for Valkey 撐 4700 萬月活的配對引擎" data-link-desc="Tinder 用 Amazon ElastiCache for Valkey 提供配對引擎所需的次毫秒延遲快取層">9.C6 Tinder ElastiCache</a> — 4700 萬 MAU 配對引擎、每次滑動查多個 cache（用戶 profile、距離、偏好過濾、推薦池）、cache lookup 屬 critical path。詳細 cache vs persistent store 取捨見 <a href="/blog/backend/02-cache-redis/cache-copy-freshness-boundary/" data-link-title="2.7 Cache Copy Boundary 與 Freshness" data-link-desc="說明快取何時只是可重建副本，何時會影響交易、權限或配額正確性。">2.7 cache copy boundary</a>。</p>
<h2 id="cache-容量規劃跟-db-不一樣">Cache 容量規劃跟 DB 不一樣</h2>
<p>容量規劃基準在 cache 跟 DB 有本質差異：DB 容量受 <em>total dataset size</em> 影響（要存所有資料）；cache 容量受 <em>working set size</em> 影響（只存熱資料）。兩者的擴容邏輯、成本曲線、評估指標都不同、不能套用相同規劃模板。</p>
<p>對應 <a href="/blog/backend/09-performance-capacity/cases/tinder-elasticache-valkey-matching/" data-link-title="9.C6 Tinder：ElastiCache for Valkey 撐 4700 萬月活的配對引擎" data-link-desc="Tinder 用 Amazon ElastiCache for Valkey 提供配對引擎所需的次毫秒延遲快取層">9.C6 Tinder</a> — 47M MAU sustained growth、容量規劃變成「每月線性擴容 X%」的長期決策、不是峰值規劃。對應 <a href="/blog/backend/02-cache-redis/cases/meta-cachelib-kangaroo-tiered-cache/" data-link-title="2.C4 Meta：CacheLib / Kangaroo 分層快取" data-link-desc="快取從 DRAM-only 轉向分層快取架構的實務案例。">2.C4 Meta CacheLib / Kangaroo</a> — 當熱資料超過 DRAM 經濟範圍、單層 cache 同時遇到成本跟命中率瓶頸、要分層（DRAM + flash、詳見 <a href="/blog/backend/02-cache-redis/ttl-eviction/" data-link-title="2.3 TTL 與 eviction" data-link-desc="整理過期策略、容量控制與熱點資料">2.3 ttl-eviction 分層快取段</a>）。</p>
<p><strong>Cache 容量規劃的三個維度</strong>：</p>
<ul>
<li><strong>Working set size</strong>：熱資料大小決定 cache 需要多少 RAM。監控指標是 <em>hot key 分布</em> 跟 <em>resident set growth</em>。working set 估算方式因 workload 不同、要靠實測得出。</li>
<li><strong>命中率目標</strong>：命中率目標決定 cache 大小的成長曲線。90% / 95% / 99% 對應不同 cache 大小、每加一個 9 需要的 cache size 通常顯著增加（具體倍數依 access pattern 分布、Zipfian 分布越平倍數越高）。</li>
<li><strong>回源 budget</strong>：cache miss 後 origin（DB / 重算）能承受多少 QPS、決定 cache 命中率下限。命中率掉幾個 percentage point 可能讓 origin QPS 翻數倍、容量規劃要按命中率敏感度反推 origin headroom。</li>
</ul>
<p><strong>判讀重點</strong>：cache 命中率變化是 <em>業務變化訊號</em>、可能是新功能影響 access pattern（推薦演算法改、查詢條件擴大、tenant 結構變化）、應先看業務側、再考慮加 cache capacity。</p>
<h2 id="redis-規模化的單執行緒邊界">Redis 規模化的單執行緒邊界</h2>
<p>Redis command 執行至今仍 single-threaded、單實例 command 吞吐受 CPU 單核限制。6.0+ 起可開啟 I/O thread 提升 I/O 吞吐、但 command 執行仍序列化。規模化服務遇到這個邊界時、四個選項各自適合不同壓力：</p>
<p><strong>1. 拆 cluster（應用層分散 key）</strong>：Redis Cluster 自帶分片、適合 key 數量多、單 key 不熱的場景。每 shard 仍 single-threaded、但總吞吐線性擴展。典型壓力是「KV 種類多、每種 key 不算熱、整體流量大」、跟 Tinder 47M MAU 同類 — 用戶 profile 跨大量 key 分散、每個 key 流量不極端、cluster 切片足夠。</p>
<p><strong>2. Redis 6.0+ I/O thread</strong>：保留 Redis protocol、I/O 處理 multi-threaded、command 執行仍 single-threaded。提升 read-heavy 場景吞吐、實測倍數依 workload 跟 thread 數而定。適合「主要瓶頸在 I/O syscall 不在 command CPU」的場景、是低改動量的階段性升級、不換 broker。</p>
<p><strong>3. <a href="/blog/backend/02-cache-redis/vendors/keydb/" data-link-title="KeyDB" data-link-desc="Redis multi-threaded fork、active-replication、Snap 採用">KeyDB</a> / <a href="/blog/backend/02-cache-redis/vendors/dragonflydb/" data-link-title="DragonflyDB" data-link-desc="高效能 Redis / Memcached 相容替代、多核架構">Dragonfly</a>（multi-threaded fork）</strong>：command 執行也 multi-threaded。對應 <a href="/blog/backend/09-performance-capacity/cases/snap-gcp-keydb-cross-cloud/" data-link-title="9.C35 Snap：GCP &#43; KeyDB 在 multi-cloud 架構下的低延遲快取" data-link-desc="Snap 用 GCP 上的 KeyDB cluster 減少跨 cloud cache 延遲、用 TPU 訓練廣告推薦模型">9.C35 Snap KeyDB</a> — Snap 採用 KeyDB 在 GCP 上替代原生 Redis、9.C35 判讀段提出「單實例 throughput 提升 5-10x」（屬案例 derived 推論、實測倍數依 workload）。適合「單 key 極熱、cluster 切不開、需要單實例多執行緒撐單 partition」的壓力。代價是 vendor lock-in、fork 治理走向不確定（KeyDB 公司被收購後策略未明）。</p>
<p><strong>4. Memcached（multi-threaded、功能少）</strong>：純 KV 不支援複雜資料結構（hash / sorted set / stream）、適合「資料形狀單純、要 multi-threaded」的 cache-only 場景。如果 application 不需要 Redis 的進階資料結構、Memcached 通常單實例吞吐更高、運維更簡單。</p>
<p><strong>規模化常用組合</strong>：ElastiCache for Redis 7.1 在 r7g.4xlarge 上的 <a href="https://aws.amazon.com/blogs/database/achieve-over-500-million-requests-per-second-per-cluster-with-amazon-elasticache-for-redis-7-1/">AWS 公布上限</a>（單節點百萬級 RPS、單 cluster 5 億 RPS）+ Cluster 模式 + 應用層 connection multiplexing。實際配置依工作量跟成本邊界決定、不是「規模化必然全配滿」。對應 <a href="/blog/backend/09-performance-capacity/cases/tinder-elasticache-valkey-matching/" data-link-title="9.C6 Tinder：ElastiCache for Valkey 撐 4700 萬月活的配對引擎" data-link-desc="Tinder 用 Amazon ElastiCache for Valkey 提供配對引擎所需的次毫秒延遲快取層">9.C6 Tinder</a> 的設計方向。</p>
<p>判讀順序：先確認瓶頸是不是單實例 command 吞吐（CPU 單核滿載 vs 整體 RAM / network 是否還有 headroom）、再選方案。應用層 key 分布不均（hot key）跟 single-threaded 限制是兩個獨立議題、混在一起會誤選方案。</p>
<h2 id="執行把-redis-用在對的角色">【執行】把 Redis 用在對的角色</h2>
<p>Redis 在高併發場景常見角色有：</p>
<ul>
<li>cache</li>
<li>session store</li>
<li>counter / <a href="/blog/backend/knowledge-cards/rate-limit/" data-link-title="Rate Limit" data-link-desc="說明限流如何保護服務入口、下游依賴與租戶公平性">rate limit</a></li>
<li>presence / online state</li>
<li>dedup / <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a> key</li>
<li>lightweight <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> / stream</li>
</ul>
<p>每一種角色都有不同容錯方式。counter、presence 和 cache 的失敗語意各自不同，因此需要依資料角色選擇處理策略。</p>
<h2 id="策略分散式-lock-要謹慎使用">【策略】分散式 lock 要謹慎使用</h2>
<p>Redis 常被拿來做 distributed lock，但這類機制要非常清楚 lease、過期、持有者與失效風險。高併發下最怕的是鎖住之後沒有安全釋放，或以為鎖保證了完整業務一致性。</p>
<p>原則上：</p>
<ul>
<li>鎖應該短</li>
<li>鎖持有者要可辨識</li>
<li>鎖過期要可接受</li>
<li>業務上若能不用分散式鎖，通常應優先考慮更簡單的設計</li>
</ul>
<h2 id="延伸語言端仍然要負責限流與取消">【延伸】語言端仍然要負責限流與取消</h2>
<p>Redis 很快，但應用端仍然要設計邊界。語言端應使用 timeout、cancellation、<a href="/blog/backend/knowledge-cards/worker-pool/" data-link-title="Worker Pool" data-link-desc="說明一組 worker 如何限制同時處理量並保護下游資源">worker pool</a>、rate limit 或 <a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a> 把壓力收斂起來；否則排隊等待 Redis 回應的工作會越堆越多。</p>
<h2 id="跨語言適配評估">跨語言適配評估</h2>
<p>Redis 高併發邊界會受語言 runtime 影響。Thread-based runtime 要管理 client pool 與 blocking command；async runtime 要確認 Redis client 不會阻塞 event loop；輕量 task runtime 要限制同時呼叫 Redis 的工作數量。動態語言要特別控制 cache value schema 與序列化格式；強型別語言要避免把內部型別直接當成跨服務 cache <a href="/blog/backend/knowledge-cards/contract/" data-link-title="Boundary Contract" data-link-desc="說明跨邊界約定如何維持相容與可驗證">contract</a>。</p>
<h2 id="案例對照">案例對照</h2>
<table>
  <thead>
      <tr>
          <th>案例</th>
          <th>高併發 cache 場景重點</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/backend/09-performance-capacity/cases/tinder-elasticache-valkey-matching/" data-link-title="9.C6 Tinder：ElastiCache for Valkey 撐 4700 萬月活的配對引擎" data-link-desc="Tinder 用 Amazon ElastiCache for Valkey 提供配對引擎所需的次毫秒延遲快取層">9.C6 Tinder ElastiCache</a></td>
          <td>47M MAU 配對引擎、cache 是主要服務面、sustained growth 成本曲線</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/09-performance-capacity/cases/tubi-elasticache-ml-feature-store/" data-link-title="9.C25 Tubi：從 ScyllaDB 遷到 ElastiCache、ML feature store 達 sub-10ms p99" data-link-desc="Tubi 把 ML 推薦的 feature store 從 ScyllaDB 遷到 ElastiCache for Redis、99 百分位延遲降到 10ms 以下">9.C25 Tubi feature store</a></td>
          <td>ML inference 之前 feature lookup、p99 &lt; 10ms 是業務 KPI</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/09-performance-capacity/cases/snap-gcp-keydb-cross-cloud/" data-link-title="9.C35 Snap：GCP &#43; KeyDB 在 multi-cloud 架構下的低延遲快取" data-link-desc="Snap 用 GCP 上的 KeyDB cluster 減少跨 cloud cache 延遲、用 TPU 訓練廣告推薦模型">9.C35 Snap KeyDB</a></td>
          <td>KeyDB multi-threaded fork、跨 cloud 部署</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/02-cache-redis/cases/meta-tao-social-graph-cache-evolution/" data-link-title="2.C8 Meta：TAO 社交圖快取演進" data-link-desc="社交圖查詢在規模化下如何把快取做成資料層能力。">2.C8 Meta TAO</a></td>
          <td>cache 成為資料層能力、社交圖查詢的快取治理</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/02-cache-redis/cases/netflix-evcache-global-cache-layer/" data-link-title="2.C6 Netflix：EVCache 全域快取層" data-link-desc="快取從本地層演進為跨區分散式能力的案例。">2.C6 Netflix EVCache</a></td>
          <td>跨區分散式 cache、平台層基礎設施</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/02-cache-redis/cases/meta-mcrouter-global-cache-routing/" data-link-title="2.C2 Meta：mcrouter 與跨區快取路由" data-link-desc="快取從單點最佳化演進到分散式路由層的案例。">2.C2 Meta mcrouter</a></td>
          <td>client 散落邏輯收斂到路由層、跨叢集 cache 路由</td>
      </tr>
  </tbody>
</table>
<p>這六個案例可以分成兩群讀。<strong>規模化容量群</strong>（Tinder、Tubi、Snap）的共同訊號是「sustained growth 下 cache 變主要服務面、容量規劃跟單實例邊界要重新設計」、本章「Cache 容量規劃跟 DB 不一樣」跟「Redis 規模化的單執行緒邊界」段直接對應；<strong>跨區資料平面群</strong>（Meta TAO、Netflix EVCache、Meta mcrouter）的共同訊號是「cache 變成跨區資料層、需要路由治理跟一致性窗口」、詳細展開在 <a href="/blog/backend/02-cache-redis/cache-copy-freshness-boundary/" data-link-title="2.7 Cache Copy Boundary 與 Freshness" data-link-desc="說明快取何時只是可重建副本，何時會影響交易、權限或配額正確性。">2.7 cache copy boundary 的跨區一致性窗口</a> 跟 <a href="/blog/backend/02-cache-redis/cache-data-shape-access-pattern/" data-link-title="2.8 Cache Data Shape 與 Access Pattern" data-link-desc="說明 cache value、key space、資料結構與存取型態如何反映服務語意。">2.8 cache data shape</a>。兩群讀法切入點不同、本章先處理前者的高併發 / 容量議題、後者跨章節讀。</p>
<h2 id="小結">小結</h2>
<p>高併發服務處理 Redis 的核心原則：client 共用、操作要短、pipeline 要有節制、熱點 key 要設計、cache miss 要防 stampede、鎖要保守使用。</p>
<p><strong>規模化補充</strong>：cache 角色變化（DB 補救 → 主要服務面 → 資料平面）主寫於 <a href="/blog/backend/02-cache-redis/" data-link-title="模組二：快取與 Redis" data-link-desc="整理快取策略、Redis 資料型別與分散式狀態輔助能力">_index 規模化下 cache 的角色光譜</a>、本章在角色已落「主要服務面」或「資料平面」時提供高併發判讀。Redis 規模化的單執行緒邊界有四個選項（cluster / I/O thread / KeyDB 等 fork / Memcached）、判讀順序是先確認瓶頸再選方案。</p>
]]></content:encoded></item><item><title>2.2 cache aside 與失效策略</title><link>https://tarrragon.github.io/blog/backend/02-cache-redis/cache-aside/</link><pubDate>Thu, 23 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/02-cache-redis/cache-aside/</guid><description>&lt;p>旁路快取（cache aside）的核心責任是把讀取加速與正式狀態分離。資料庫維持 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">source of truth&lt;/a>，快取維持可重建副本；兩者透過失效策略與新鮮度窗口對齊。&lt;/p>
&lt;h2 id="基本流程">基本流程&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/cache-aside/" data-link-title="Cache Aside" data-link-desc="說明 application 如何在讀取時自行管理快取與正式資料來源">cache aside&lt;/a> 的讀路徑是「先讀 cache，miss 後回源，再回填 cache」；寫路徑是「先寫 source of truth，再做 cache invalidation 或版本更新」。這個流程讓正式狀態維持單一責任，同時讓熱門讀取獲得低延遲。&lt;/p>
&lt;p>實務上要先定義 freshness window。每個資料類型可容忍的不新鮮時間不同：商品介紹可接受秒級延遲，價格、庫存、權限與配額則需要更短窗口或即時失效。&lt;/p>
&lt;h2 id="失效策略">失效策略&lt;/h2>
&lt;p>失效策略的責任是控制 cache 和 source of truth 之間的偏差。常見做法有三類：&lt;/p>
&lt;ol>
&lt;li>事件驅動失效：寫入成功後推事件刪 key 或更新版本，適合正確性要求高的資料。&lt;/li>
&lt;li>TTL 失效：以時間上限控制資料壽命，適合可短暫不新鮮的資料。&lt;/li>
&lt;li>混合策略：事件失效為主、TTL 為保底，適合多來源寫入或跨區快取。&lt;/li>
&lt;/ol>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/stale-data/" data-link-title="Stale Data" data-link-desc="說明過期資料在快取、replica 與衍生資料中的產品影響">stale data&lt;/a> 是快取系統的常態成本，視為例外事件會導致設計盲區。設計時要先定義可接受的 stale 形式，再設計對應補償與回退路徑。&lt;/p>
&lt;h3 id="應用層--邊緣層-invalidation-pipeline">應用層 + 邊緣層 Invalidation Pipeline&lt;/h3>
&lt;p>當系統同時用應用層快取（Redis、本機 cache）跟邊緣層快取（&lt;a href="https://tarrragon.github.io/blog/backend/05-deployment-platform/edge-cdn-static-distribution/" data-link-title="5.9 邊緣分發與靜態資源（CDN / Origin Protection）" data-link-desc="整理 CDN 與 edge cache 在部署平台中的責任邊界、origin protection、purge 與 invalidation 策略">CDN&lt;/a>）時、失效策略要把兩層當「一條 pipeline」設計、不能各自獨立 purge。兩層失效的物理特性差異：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>層級&lt;/th>
 &lt;th>Purge 控制&lt;/th>
 &lt;th>Purge 延遲&lt;/th>
 &lt;th>失敗代價&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>應用層 cache&lt;/td>
 &lt;td>自家 cluster 內、application 控制&lt;/td>
 &lt;td>毫秒 - 秒級（cache cluster 內傳播）&lt;/td>
 &lt;td>Cluster 內 stale、用戶感受立即修正&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>CDN edge&lt;/td>
 &lt;td>Vendor API 控制、全球節點同步&lt;/td>
 &lt;td>秒 - 分鐘級（傳統 origin pull）或 150ms 級（push-based）&lt;/td>
 &lt;td>全球節點 stale、回填到應用層污染快取&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>正確順序是「先應用層、再 CDN」：&lt;/p>
&lt;ol>
&lt;li>業務寫入完成、source of truth 更新&lt;/li>
&lt;li>Purge 應用層 cache（毫秒級完成）&lt;/li>
&lt;li>Purge CDN（秒級到分鐘級）&lt;/li>
&lt;li>等 CDN purge 完成的 ack（或設等待窗口）&lt;/li>
&lt;/ol>
&lt;p>順序顛倒會出事 — 若先 purge CDN、CDN 全球節點 miss 後到 origin 拉資料、若 origin 應用層還是舊 cache、CDN 會把舊資料回填到全球節點、stale 被「重新永久化」一個 TTL 週期。&lt;/p>
&lt;p>實務上的權衡是「CDN purge ack 是否要等」。等了會讓 write API latency 升高到秒級、不等則必須接受短暫雙層不一致。價格 / 庫存類資料適合「短 TTL + 等 purge ack」、blog 文章類適合「長 TTL + 不等 ack」。詳見 &lt;a href="https://tarrragon.github.io/blog/backend/05-deployment-platform/edge-cdn-static-distribution/" data-link-title="5.9 邊緣分發與靜態資源（CDN / Origin Protection）" data-link-desc="整理 CDN 與 edge cache 在部署平台中的責任邊界、origin protection、purge 與 invalidation 策略">5.9 邊緣分發與靜態資源&lt;/a> 的 purge 操作模型。&lt;/p>
&lt;h2 id="cache-aside-vs-write-through-的選擇">Cache aside vs write-through 的選擇&lt;/h2>
&lt;p>選 cache 模式由 &lt;em>miss 成本&lt;/em> 跟 &lt;em>寫入頻率&lt;/em> 的取捨決定。Cache aside、write-through、write-behind 三種主流模式各自適合不同業務壓力。&lt;/p>
&lt;p>&lt;strong>Cache aside&lt;/strong>（read-through）：寫入只動 source-of-truth、讀取 miss 時才填 cache。適合寫入頻率低於讀取、cache 可以重建、寫入失敗時 cache 保持不污染的場景。常見於商品詳情、推薦列表、設定值這類 read-heavy 資料、業務代價是 cache miss 時用戶等待回源、可接受。&lt;/p></description><content:encoded><![CDATA[<p>旁路快取（cache aside）的核心責任是把讀取加速與正式狀態分離。資料庫維持 <a href="/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">source of truth</a>，快取維持可重建副本；兩者透過失效策略與新鮮度窗口對齊。</p>
<h2 id="基本流程">基本流程</h2>
<p><a href="/blog/backend/knowledge-cards/cache-aside/" data-link-title="Cache Aside" data-link-desc="說明 application 如何在讀取時自行管理快取與正式資料來源">cache aside</a> 的讀路徑是「先讀 cache，miss 後回源，再回填 cache」；寫路徑是「先寫 source of truth，再做 cache invalidation 或版本更新」。這個流程讓正式狀態維持單一責任，同時讓熱門讀取獲得低延遲。</p>
<p>實務上要先定義 freshness window。每個資料類型可容忍的不新鮮時間不同：商品介紹可接受秒級延遲，價格、庫存、權限與配額則需要更短窗口或即時失效。</p>
<h2 id="失效策略">失效策略</h2>
<p>失效策略的責任是控制 cache 和 source of truth 之間的偏差。常見做法有三類：</p>
<ol>
<li>事件驅動失效：寫入成功後推事件刪 key 或更新版本，適合正確性要求高的資料。</li>
<li>TTL 失效：以時間上限控制資料壽命，適合可短暫不新鮮的資料。</li>
<li>混合策略：事件失效為主、TTL 為保底，適合多來源寫入或跨區快取。</li>
</ol>
<p><a href="/blog/backend/knowledge-cards/stale-data/" data-link-title="Stale Data" data-link-desc="說明過期資料在快取、replica 與衍生資料中的產品影響">stale data</a> 是快取系統的常態成本，視為例外事件會導致設計盲區。設計時要先定義可接受的 stale 形式，再設計對應補償與回退路徑。</p>
<h3 id="應用層--邊緣層-invalidation-pipeline">應用層 + 邊緣層 Invalidation Pipeline</h3>
<p>當系統同時用應用層快取（Redis、本機 cache）跟邊緣層快取（<a href="/blog/backend/05-deployment-platform/edge-cdn-static-distribution/" data-link-title="5.9 邊緣分發與靜態資源（CDN / Origin Protection）" data-link-desc="整理 CDN 與 edge cache 在部署平台中的責任邊界、origin protection、purge 與 invalidation 策略">CDN</a>）時、失效策略要把兩層當「一條 pipeline」設計、不能各自獨立 purge。兩層失效的物理特性差異：</p>
<table>
  <thead>
      <tr>
          <th>層級</th>
          <th>Purge 控制</th>
          <th>Purge 延遲</th>
          <th>失敗代價</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>應用層 cache</td>
          <td>自家 cluster 內、application 控制</td>
          <td>毫秒 - 秒級（cache cluster 內傳播）</td>
          <td>Cluster 內 stale、用戶感受立即修正</td>
      </tr>
      <tr>
          <td>CDN edge</td>
          <td>Vendor API 控制、全球節點同步</td>
          <td>秒 - 分鐘級（傳統 origin pull）或 150ms 級（push-based）</td>
          <td>全球節點 stale、回填到應用層污染快取</td>
      </tr>
  </tbody>
</table>
<p>正確順序是「先應用層、再 CDN」：</p>
<ol>
<li>業務寫入完成、source of truth 更新</li>
<li>Purge 應用層 cache（毫秒級完成）</li>
<li>Purge CDN（秒級到分鐘級）</li>
<li>等 CDN purge 完成的 ack（或設等待窗口）</li>
</ol>
<p>順序顛倒會出事 — 若先 purge CDN、CDN 全球節點 miss 後到 origin 拉資料、若 origin 應用層還是舊 cache、CDN 會把舊資料回填到全球節點、stale 被「重新永久化」一個 TTL 週期。</p>
<p>實務上的權衡是「CDN purge ack 是否要等」。等了會讓 write API latency 升高到秒級、不等則必須接受短暫雙層不一致。價格 / 庫存類資料適合「短 TTL + 等 purge ack」、blog 文章類適合「長 TTL + 不等 ack」。詳見 <a href="/blog/backend/05-deployment-platform/edge-cdn-static-distribution/" data-link-title="5.9 邊緣分發與靜態資源（CDN / Origin Protection）" data-link-desc="整理 CDN 與 edge cache 在部署平台中的責任邊界、origin protection、purge 與 invalidation 策略">5.9 邊緣分發與靜態資源</a> 的 purge 操作模型。</p>
<h2 id="cache-aside-vs-write-through-的選擇">Cache aside vs write-through 的選擇</h2>
<p>選 cache 模式由 <em>miss 成本</em> 跟 <em>寫入頻率</em> 的取捨決定。Cache aside、write-through、write-behind 三種主流模式各自適合不同業務壓力。</p>
<p><strong>Cache aside</strong>（read-through）：寫入只動 source-of-truth、讀取 miss 時才填 cache。適合寫入頻率低於讀取、cache 可以重建、寫入失敗時 cache 保持不污染的場景。常見於商品詳情、推薦列表、設定值這類 read-heavy 資料、業務代價是 cache miss 時用戶等待回源、可接受。</p>
<p><strong>Write-through</strong>：寫入同時動 source-of-truth + cache、保證 cache 永遠最新。對應 <a href="/blog/backend/02-cache-redis/cases/shopify-write-through-cache-at-scale/" data-link-title="2.C5 Shopify：Write-through Cache 在高讀流量的實作" data-link-desc="read-heavy 服務如何轉向 write-through 快取模型。">2.C5 Shopify Write-through Cache</a> — Shopify 在 Shop App 後端的 read-heavy 路徑用 write-through 降低 cache miss 風險、改善熱門資料讀取穩定性。適合場景：cache miss 成本很高（回源慢或會壓垮 origin）、寫入流量可控、資料更新時間可預測。典型應用包括熱門商品的庫存 / 價格、用戶 session、需要避免讀路徑抖動的場景。</p>
<p><strong>Write-behind</strong>（async）：寫入只動 cache、async 同步到 source-of-truth。適合寫入頻率極高、source-of-truth 跟不上、可接受 cache crash 丟失少量資料的場景。常見於 counter、rate limit、metrics aggregation 這類 <em>吞吐優先、可接受短暫不持久</em> 的資料。代價是 cache crash 會丟最近 N 秒寫入、要確認業務代價可承受。</p>
<p>判讀順序：先看 read/write 比例（read-heavy 偏 cache aside / write-through、write-extreme 偏 write-behind）、再看 miss 成本（miss 貴選 write-through、miss 便宜選 cache aside）、最後看持久性需求（不可丟選 write-through、可丟選 write-behind）。</p>
<h2 id="cache-模式選擇的判讀順序">Cache 模式選擇的判讀順序</h2>
<p>當「重算成本」「資料一致性」「持久性」三個維度互相衝突、選擇優先序：</p>
<ol>
<li><strong>持久性必須</strong>（不可丟、無法重建）→ 必須選 write-through 或 persistent store + cache、不能選 write-behind 或純 cache aside</li>
<li><strong>持久性可接受失損</strong> + <strong>一致性嚴格</strong>（餘額、權限類）→ write-through 同步更新、確保 cache 不 stale</li>
<li><strong>持久性可接受失損</strong> + <strong>一致性可放寬</strong> + <strong>重算貴</strong> → cache aside + 較長 TTL、減少回源</li>
<li><strong>持久性可接受失損</strong> + <strong>一致性可放寬</strong> + <strong>重算便宜</strong> → cache aside + 短 TTL 或 write-behind</li>
</ol>
<p>例如 ML feature store 場景（<a href="/blog/backend/09-performance-capacity/cases/tubi-elasticache-ml-feature-store/" data-link-title="9.C25 Tubi：從 ScyllaDB 遷到 ElastiCache、ML feature store 達 sub-10ms p99" data-link-desc="Tubi 把 ML 推薦的 feature store 從 ScyllaDB 遷到 ElastiCache for Redis、99 百分位延遲降到 10ms 以下">9.C25 Tubi</a>）— 持久性可接受失損（feature 可重算）、一致性可放寬（推薦演算法）、重算便宜（feature engineering pipeline 跑得到）— 落在第 4 類、Tubi 把 feature store 從 ScyllaDB 遷到 ElastiCache 是合理取捨。p99 落在 ElastiCache 的 &lt; 10ms 範圍（先前 ScyllaDB-based 架構為 ML inference 路徑的延遲瓶頸、案例未公開 ScyllaDB 端具體延遲數字）。</p>
<p>判讀重點：cache 的本質是用 miss 風險換取 latency；資料若無法重建、需採 persistent store 並接受 latency 成本；資料若可重建但一致性嚴格、可用 cache 但要 write-through 確保即時收斂。詳見 <a href="/blog/backend/02-cache-redis/cache-copy-freshness-boundary/" data-link-title="2.7 Cache Copy Boundary 與 Freshness" data-link-desc="說明快取何時只是可重建副本，何時會影響交易、權限或配額正確性。">2.7 cache copy boundary</a> 的「Cache vs Persistent Store 取捨」段。</p>
<h2 id="判讀訊號與回源保護">判讀訊號與回源保護</h2>
<p>cache 命中下降時，來源系統會承受瞬間回源壓力。回源保護需要和失效策略一起設計：</p>
<table>
  <thead>
      <tr>
          <th>風險訊號</th>
          <th>判讀重點</th>
          <th>對應動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>hit ratio 下降且 origin QPS 快速上升</td>
          <td>大量 key 同時過期或失效策略失準</td>
          <td>分散 TTL、分批失效、啟用 <a href="/blog/backend/knowledge-cards/cache-warmup/" data-link-title="Cache Warmup" data-link-desc="說明服務啟動或活動前如何預先建立快取資料">cache warmup</a></td>
      </tr>
      <tr>
          <td>熱門 key miss 後延遲與錯誤率同步上升</td>
          <td>單 key 造成 stampede</td>
          <td>啟用 request coalescing、局部預熱、限流回源</td>
      </tr>
      <tr>
          <td>cache 層延遲穩定但業務錯誤增加</td>
          <td>值語意過期或序列化版本漂移</td>
          <td>補 key version 與 schema migration</td>
      </tr>
      <tr>
          <td>eviction rate 升高且 value size 變大</td>
          <td>容量策略與資料形狀不匹配</td>
          <td>重配記憶體策略、調整 value 拆分</td>
      </tr>
  </tbody>
</table>
<p><a href="/blog/backend/knowledge-cards/cache-stampede/" data-link-title="Cache Stampede" data-link-desc="說明快取同時失效時大量 request 如何壓垮正式來源">cache stampede</a> 與 <a href="/blog/backend/knowledge-cards/thundering-herd/" data-link-title="Thundering Herd" data-link-desc="說明大量工作同時被喚醒或同時競爭資源時的尖峰風險">thundering herd</a> 都是回源保護議題；重點是把來源系統視為有限資源，讓 miss 風險可控。</p>
<h2 id="服務情境">服務情境</h2>
<p>商品詳情頁是典型 cache aside 場景。頁面讀取需要組合商品主檔、價格、庫存與行銷標籤。主檔可用較長 TTL 與背景更新，價格與庫存則用事件失效與較短 TTL，讓讀取延遲與正確性維持平衡。</p>
<p>當促銷開始時，大量熱門商品同時被讀取。這時 cache 策略的重點從命中率轉到來源保護與新鮮度控制：是否能限制回源尖峰、是否能快速修正錯誤資料、是否能在事故時降級。</p>
<h2 id="常見誤區">常見誤區</h2>
<p>把命中率當作唯一目標，會忽略資料語意與失敗代價。命中率高不代表結果正確，尤其在價格、權限、配額類資料。</p>
<p>把 cache 當成正式資料來源，會讓資料修復與稽核變複雜。快取系統適合承擔讀取加速，不適合承擔正式狀態的最終判定。</p>
<h2 id="案例回寫">案例回寫</h2>
<p>cache aside 的失效風險可用 <a href="/blog/backend/02-cache-redis/cases/failure-cache-stampede-rollout-regression/" data-link-title="2.C9 反例：快取切換引發 Stampede 回歸" data-link-desc="快取策略切換若缺乏保護，會導致回源壓力與錯誤率連鎖上升。">2.C9 反例</a> 做回寫。先看事件中的失效節奏：是大批 key 同時過期、失效順序錯置，還是熱點 key 回源放大，再對照本章的 freshness window、回源保護與容量策略。
這個案例主要支撐的是「失效節奏與回源壓力」判讀，不直接支撐分散式鎖租約或 queue replay；若是互斥控制或重播問題，應轉到 2.4 或 3.x。</p>
<p>命中率看似正常但業務錯誤上升時，先回到本章檢查值語意與 key 版本化，再把量測缺口接到 <a href="/blog/backend/04-observability/telemetry-data-quality/" data-link-title="4.17 Telemetry Data Quality" data-link-desc="把 missing signal、schema drift、sampling bias 與 timestamp skew 變成資料品質問題">4.17 Telemetry Data Quality</a>。</p>
<h2 id="跨模組路由">跨模組路由</h2>
<p>cache aside 的設計會直接影響觀測、驗證與事故處理。</p>
<ol>
<li>與 01 的交接：source of truth 與查詢壓力回到 <a href="/blog/backend/01-database/high-concurrency-access/" data-link-title="1.1 高併發下的 SQL 讀寫邊界" data-link-desc="說明高併發服務如何共用資料庫 client、控制 transaction、管理 connection pool、避免資料庫成為瓶頸">1.1 高併發讀寫邊界</a>。</li>
<li>與 04 的交接：hit ratio、origin QPS、<a href="/blog/backend/knowledge-cards/stale-read/" data-link-title="Stale Read" data-link-desc="讀取到落後於最新寫入版本的舊資料">stale read</a> 與 eviction 進入 <a href="/blog/backend/04-observability/telemetry-data-quality/" data-link-title="4.17 Telemetry Data Quality" data-link-desc="把 missing signal、schema drift、sampling bias 與 timestamp skew 變成資料品質問題">Telemetry Data Quality</a>。</li>
<li>與 06 的交接：回源保護與壓測邊界進入 <a href="/blog/backend/06-reliability/experiment-safety-boundary/" data-link-title="6.20 Experiment Safety Boundary" data-link-desc="定義 chaos、load test、DR drill 的 [blast radius](/backend/knowledge-cards/blast-radius/)、停止條件與權限約束">Experiment Safety Boundary</a>。</li>
<li>與 08 的交接：失效策略誤配與 stampede 事故回寫 <a href="/blog/backend/08-incident-response/incident-evidence-write-back/" data-link-title="8.22 Incident Evidence Write-back" data-link-desc="把事故證據、決策與復盤結論回寫到 observability、reliability 與 runbook">Incident Evidence Write-back</a>。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<p><strong>規模成長路線下一站 → <a href="/blog/backend/05-deployment-platform/edge-cdn-static-distribution/" data-link-title="5.9 邊緣分發與靜態資源（CDN / Origin Protection）" data-link-desc="整理 CDN 與 edge cache 在部署平台中的責任邊界、origin protection、purge 與 invalidation 策略">5.9 邊緣分發與靜態資源</a></strong>：應用層快取上面還有 CDN 邊緣層、兩層失效時序要對齊（先 purge 應用層、再 purge 邊緣層、避免邊緣回填到應用層舊資料）。</p>
<p>其他延伸方向：</p>
<ul>
<li>進一步處理 TTL、容量與淘汰策略 → <a href="/blog/backend/02-cache-redis/ttl-eviction/" data-link-title="2.3 TTL 與 eviction" data-link-desc="整理過期策略、容量控制與熱點資料">2.3 TTL 與 eviction</a></li>
<li>快取策略在真實事件中的失敗與修復 → <a href="/blog/backend/02-cache-redis/cases/failure-cache-stampede-rollout-regression/" data-link-title="2.C9 反例：快取切換引發 Stampede 回歸" data-link-desc="快取策略切換若缺乏保護，會導致回源壓力與錯誤率連鎖上升。">2.C9 反例</a></li>
</ul>
]]></content:encoded></item><item><title>2.3 TTL 與 eviction</title><link>https://tarrragon.github.io/blog/backend/02-cache-redis/ttl-eviction/</link><pubDate>Thu, 23 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/02-cache-redis/ttl-eviction/</guid><description>&lt;p>存活時間與淘汰策略（TTL and eviction）的核心責任是把快取資源分配成可預期策略。TTL 決定資料可存活多久，eviction 決定容量壓力下誰先被移除；兩者共同定義快取的新鮮度、命中率與回源風險。&lt;/p>
&lt;h2 id="ttl-是新鮮度預算">TTL 是新鮮度預算&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/ttl/" data-link-title="TTL" data-link-desc="說明資料過期時間如何影響快取新鮮度、成本與一致性">TTL&lt;/a> 是資料類型的新鮮度預算，用單一時間常數理解它會漏掉關鍵差異。商品描述、推薦列表、活動文案可容忍較長 TTL；價格、庫存、配額、權限則需要更短 TTL 或事件失效。&lt;/p>
&lt;p>TTL 設計要連到業務代價。可容忍舊資料的欄位可用長 TTL 降回源壓力；不可容忍錯誤結果的欄位要搭配事件失效與版本控制，讓 TTL 只作為保底機制。&lt;/p>
&lt;h2 id="eviction-是容量分流策略">eviction 是容量分流策略&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/eviction/" data-link-title="Eviction" data-link-desc="說明快取容量不足時哪些資料會被淘汰，以及淘汰如何影響服務">eviction&lt;/a> 的責任是當記憶體不足時，優先保留最有價值資料。常見策略如 LRU、LFU、TTL-based eviction，各自偏好不同存取型態。&lt;/p>
&lt;p>策略選擇重點在流量形狀，演算法名稱是次要的：高重複讀取場景偏向保留高頻資料；大量一次性讀取場景需要避免短期噪音擠掉核心 key。快取層若同時承載多種資料，應分 key space 或分叢集管理，避免策略互相干擾。&lt;/p>
&lt;h2 id="hot--cold-data-的容量節奏">hot / cold data 的容量節奏&lt;/h2>
&lt;p>hot data 與 cold data 的差異不只在存取次數，也在回源成本與業務風險。熱資料 miss 會直接放大來源壓力，冷資料 miss 多半只影響單次延遲。容量規劃要先保護熱資料，再決定冷資料淘汰節奏。&lt;/p>
&lt;p>在促銷或重大活動期間，流量分布常快速改變。TTL 與 eviction 需要具備活動模式：預熱核心 key、分散過期時間、限制單批失效，讓來源系統不被同時回源壓垮。&lt;/p>
&lt;h2 id="分層快取的容量跟成本曲線">分層快取的容量跟成本曲線&lt;/h2>
&lt;p>當熱資料集合超過 DRAM 經濟範圍、單層快取會同時遇到成本跟命中率瓶頸、要把 cache 結構擴展到分層管理。&lt;/p>
&lt;p>對應 &lt;a href="https://tarrragon.github.io/blog/backend/02-cache-redis/cases/meta-cachelib-kangaroo-tiered-cache/" data-link-title="2.C4 Meta：CacheLib / Kangaroo 分層快取" data-link-desc="快取從 DRAM-only 轉向分層快取架構的實務案例。">2.C4 Meta CacheLib / Kangaroo&lt;/a> — Meta 把快取結構從 DRAM-only 擴展到 DRAM + flash 分層、改善容量跟成本平衡。當「全部熱資料塞 DRAM」變太貴、把次熱資料推到 flash、保留 DRAM 給最熱的子集。&lt;/p>
&lt;p>&lt;strong>分層快取的相對特性&lt;/strong>（具體 size / latency / cost 視硬體配置跟業務 workload）：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>L1 (DRAM)&lt;/strong>：容量最小、延遲最低、單位成本最高、放最熱的子集 — Meta CacheLib 用這層保留熱度最高的 working set&lt;/li>
&lt;li>&lt;strong>L2 (flash / NVMe)&lt;/strong>：容量比 L1 大、延遲比 L1 高、單位成本比 L1 低 — Meta Kangaroo 在這層處理次熱資料&lt;/li>
&lt;li>&lt;strong>L3 (持久 KV)&lt;/strong>：容量最大、延遲最高、單位成本最低、放冷資料跟 fallback&lt;/li>
&lt;/ul>
&lt;p>落層策略要看 &lt;em>資料熱度分布&lt;/em>。Zipfian 分布（80/20 法則）下、L1 放最熱 20% 就能命中大部分；如果分布更平、要把 L1 擴大或接受更低命中率。具體 L1 / L2 大小比例要實測 workload 才能定。&lt;/p>
&lt;p>對應 &lt;a href="https://tarrragon.github.io/blog/backend/02-cache-redis/cases/cloudflare-cache-reserve-tiered-storage/" data-link-title="2.C7 Cloudflare：Cache Reserve 分層儲存快取" data-link-desc="邊緣快取延伸到持久層以降低回源壓力的案例。">2.C7 Cloudflare Cache Reserve&lt;/a> — edge cache 跟 persistent reserve 的分層、長尾資料用 reserve 接住、降低 origin 回源。這是 &lt;em>同類設計思維&lt;/em> 在 CDN 場景的應用、但分層語意不同（edge cache 是地理分散的、Meta 分層是垂直記憶體 / flash 層）— 兩者都用「冷熱分離降低總成本」、實作機制差異需依場景區分。&lt;/p>
&lt;p>&lt;strong>Eviction 跟回補延遲要納入共同指標&lt;/strong>：分層 cache 的訊號不只看 L1 命中率、要看 L1 evict 到 L2 的速率、L2 回補到 L1 的延遲、L3 回源到 L2 的尾巴延遲。混合 metric 才能判斷分層策略是否健康。&lt;/p>
&lt;p>判讀重點：分層 cache 屬規模觸發的設計、要從 working set 大小判斷。Working set 在 DRAM 經濟範圍內、單層即可；working set 顯著超過 DRAM 容量、需分層讓 DRAM 集中放最熱子集、其餘走 flash 或更下層。&lt;/p></description><content:encoded><![CDATA[<p>存活時間與淘汰策略（TTL and eviction）的核心責任是把快取資源分配成可預期策略。TTL 決定資料可存活多久，eviction 決定容量壓力下誰先被移除；兩者共同定義快取的新鮮度、命中率與回源風險。</p>
<h2 id="ttl-是新鮮度預算">TTL 是新鮮度預算</h2>
<p><a href="/blog/backend/knowledge-cards/ttl/" data-link-title="TTL" data-link-desc="說明資料過期時間如何影響快取新鮮度、成本與一致性">TTL</a> 是資料類型的新鮮度預算，用單一時間常數理解它會漏掉關鍵差異。商品描述、推薦列表、活動文案可容忍較長 TTL；價格、庫存、配額、權限則需要更短 TTL 或事件失效。</p>
<p>TTL 設計要連到業務代價。可容忍舊資料的欄位可用長 TTL 降回源壓力；不可容忍錯誤結果的欄位要搭配事件失效與版本控制，讓 TTL 只作為保底機制。</p>
<h2 id="eviction-是容量分流策略">eviction 是容量分流策略</h2>
<p><a href="/blog/backend/knowledge-cards/eviction/" data-link-title="Eviction" data-link-desc="說明快取容量不足時哪些資料會被淘汰，以及淘汰如何影響服務">eviction</a> 的責任是當記憶體不足時，優先保留最有價值資料。常見策略如 LRU、LFU、TTL-based eviction，各自偏好不同存取型態。</p>
<p>策略選擇重點在流量形狀，演算法名稱是次要的：高重複讀取場景偏向保留高頻資料；大量一次性讀取場景需要避免短期噪音擠掉核心 key。快取層若同時承載多種資料，應分 key space 或分叢集管理，避免策略互相干擾。</p>
<h2 id="hot--cold-data-的容量節奏">hot / cold data 的容量節奏</h2>
<p>hot data 與 cold data 的差異不只在存取次數，也在回源成本與業務風險。熱資料 miss 會直接放大來源壓力，冷資料 miss 多半只影響單次延遲。容量規劃要先保護熱資料，再決定冷資料淘汰節奏。</p>
<p>在促銷或重大活動期間，流量分布常快速改變。TTL 與 eviction 需要具備活動模式：預熱核心 key、分散過期時間、限制單批失效，讓來源系統不被同時回源壓垮。</p>
<h2 id="分層快取的容量跟成本曲線">分層快取的容量跟成本曲線</h2>
<p>當熱資料集合超過 DRAM 經濟範圍、單層快取會同時遇到成本跟命中率瓶頸、要把 cache 結構擴展到分層管理。</p>
<p>對應 <a href="/blog/backend/02-cache-redis/cases/meta-cachelib-kangaroo-tiered-cache/" data-link-title="2.C4 Meta：CacheLib / Kangaroo 分層快取" data-link-desc="快取從 DRAM-only 轉向分層快取架構的實務案例。">2.C4 Meta CacheLib / Kangaroo</a> — Meta 把快取結構從 DRAM-only 擴展到 DRAM + flash 分層、改善容量跟成本平衡。當「全部熱資料塞 DRAM」變太貴、把次熱資料推到 flash、保留 DRAM 給最熱的子集。</p>
<p><strong>分層快取的相對特性</strong>（具體 size / latency / cost 視硬體配置跟業務 workload）：</p>
<ul>
<li><strong>L1 (DRAM)</strong>：容量最小、延遲最低、單位成本最高、放最熱的子集 — Meta CacheLib 用這層保留熱度最高的 working set</li>
<li><strong>L2 (flash / NVMe)</strong>：容量比 L1 大、延遲比 L1 高、單位成本比 L1 低 — Meta Kangaroo 在這層處理次熱資料</li>
<li><strong>L3 (持久 KV)</strong>：容量最大、延遲最高、單位成本最低、放冷資料跟 fallback</li>
</ul>
<p>落層策略要看 <em>資料熱度分布</em>。Zipfian 分布（80/20 法則）下、L1 放最熱 20% 就能命中大部分；如果分布更平、要把 L1 擴大或接受更低命中率。具體 L1 / L2 大小比例要實測 workload 才能定。</p>
<p>對應 <a href="/blog/backend/02-cache-redis/cases/cloudflare-cache-reserve-tiered-storage/" data-link-title="2.C7 Cloudflare：Cache Reserve 分層儲存快取" data-link-desc="邊緣快取延伸到持久層以降低回源壓力的案例。">2.C7 Cloudflare Cache Reserve</a> — edge cache 跟 persistent reserve 的分層、長尾資料用 reserve 接住、降低 origin 回源。這是 <em>同類設計思維</em> 在 CDN 場景的應用、但分層語意不同（edge cache 是地理分散的、Meta 分層是垂直記憶體 / flash 層）— 兩者都用「冷熱分離降低總成本」、實作機制差異需依場景區分。</p>
<p><strong>Eviction 跟回補延遲要納入共同指標</strong>：分層 cache 的訊號不只看 L1 命中率、要看 L1 evict 到 L2 的速率、L2 回補到 L1 的延遲、L3 回源到 L2 的尾巴延遲。混合 metric 才能判斷分層策略是否健康。</p>
<p>判讀重點：分層 cache 屬規模觸發的設計、要從 working set 大小判斷。Working set 在 DRAM 經濟範圍內、單層即可；working set 顯著超過 DRAM 容量、需分層讓 DRAM 集中放最熱子集、其餘走 flash 或更下層。</p>
<h2 id="判讀訊號">判讀訊號</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>判讀重點</th>
          <th>對應動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>eviction rate 持續上升</td>
          <td>容量不足或 key/value 體積失控</td>
          <td>調整策略、拆分 key space、補容量</td>
      </tr>
      <tr>
          <td>hit rate 下降且 origin QPS 同步上升</td>
          <td>TTL 設定過短或過期同步爆發</td>
          <td>拉長部分 TTL、加入 jitter、分批更新</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/stale-read/" data-link-title="Stale Read" data-link-desc="讀取到落後於最新寫入版本的舊資料">stale read</a> 事件上升</td>
          <td>TTL 過長或失效機制不足</td>
          <td>縮短關鍵欄位 TTL、補事件失效</td>
      </tr>
      <tr>
          <td>熱門 key 在尖峰時段頻繁 miss</td>
          <td>熱資料未被優先保留</td>
          <td>預熱 hot set、調整 eviction 權重</td>
      </tr>
      <tr>
          <td>記憶體穩定但業務錯誤增加</td>
          <td>值語意失真，非容量問題</td>
          <td>檢查序列化版本、補新鮮度監控與驗證</td>
      </tr>
  </tbody>
</table>
<h2 id="常見誤區">常見誤區</h2>
<p>把 TTL 統一設定成同一數值，會掩蓋資料語意差異。快取策略應反映資料的重要性與可容忍延遲，而不是單一預設。</p>
<p>把 eviction 視為平台預設值即可，也常導致壓力失真。策略與流量形狀不對齊時，命中率看似可接受，來源系統仍可能在尖峰被回源壓垮。</p>
<h2 id="案例回寫">案例回寫</h2>
<p>TTL/eviction 的容量節奏可用 <a href="/blog/backend/02-cache-redis/cases/failure-cache-stampede-rollout-regression/" data-link-title="2.C9 反例：快取切換引發 Stampede 回歸" data-link-desc="快取策略切換若缺乏保護，會導致回源壓力與錯誤率連鎖上升。">2.C9 反例</a> 回寫。先看事件中的過期同步與回源尖峰，再回到本章檢查 TTL 分布、淘汰策略與熱資料保護是否同時成立。
這個案例主要支撐的是「容量淘汰與過期波形」判讀，不直接支撐資料庫交易切分或部署切流策略；若事件核心在交易提交或 rollout 批次，應轉到 1.3 或 5.2。</p>
<p>當 eviction 上升但命中率未明顯下降時，先補 value size 與 key 分布監控，再把量測定義回寫到 <a href="/blog/backend/04-observability/telemetry-data-quality/" data-link-title="4.17 Telemetry Data Quality" data-link-desc="把 missing signal、schema drift、sampling bias 與 timestamp skew 變成資料品質問題">4.17 Telemetry Data Quality</a>。</p>
<h2 id="跨模組路由">跨模組路由</h2>
<p>TTL 與 eviction 設計會直接影響觀測、驗證與事故處理。</p>
<ol>
<li>與 2.2 的交接：讀寫失效流程落在 <a href="/blog/backend/02-cache-redis/cache-aside/" data-link-title="2.2 cache aside 與失效策略" data-link-desc="整理 read-through 思路、cache miss 與 invalidation">cache aside</a>。</li>
<li>與 4.17 的交接：新鮮度與容量訊號進入 <a href="/blog/backend/04-observability/telemetry-data-quality/" data-link-title="4.17 Telemetry Data Quality" data-link-desc="把 missing signal、schema drift、sampling bias 與 timestamp skew 變成資料品質問題">Telemetry Data Quality</a>。</li>
<li>與 6.20 的交接：尖峰演練與停損邊界進入 <a href="/blog/backend/06-reliability/experiment-safety-boundary/" data-link-title="6.20 Experiment Safety Boundary" data-link-desc="定義 chaos、load test、DR drill 的 [blast radius](/backend/knowledge-cards/blast-radius/)、停止條件與權限約束">Experiment Safety Boundary</a>。</li>
<li>與 8.22 的交接：容量失配與快取事故教訓回寫 <a href="/blog/backend/08-incident-response/incident-evidence-write-back/" data-link-title="8.22 Incident Evidence Write-back" data-link-desc="把事故證據、決策與復盤結論回寫到 observability、reliability 與 runbook">Incident Evidence Write-back</a>。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<p>要把 TTL/eviction 放進失效流程，接著讀 <a href="/blog/backend/02-cache-redis/cache-aside/" data-link-title="2.2 cache aside 與失效策略" data-link-desc="整理 read-through 思路、cache miss 與 invalidation">2.2 cache aside 與失效策略</a>。要看容量與策略失配案例，接著讀 <a href="/blog/backend/02-cache-redis/cases/failure-cache-stampede-rollout-regression/" data-link-title="2.C9 反例：快取切換引發 Stampede 回歸" data-link-desc="快取策略切換若缺乏保護，會導致回源壓力與錯誤率連鎖上升。">2.C9 反例</a>。</p>
]]></content:encoded></item><item><title>2.4 distributed lock 與租約</title><link>https://tarrragon.github.io/blog/backend/02-cache-redis/distributed-lock/</link><pubDate>Thu, 23 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/02-cache-redis/distributed-lock/</guid><description>&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/distributed-lock/" data-link-title="Distributed Lock" data-link-desc="跨機器跨 process 的互斥鎖、用 lease 機制處理 holder 失效">分散式鎖（distributed lock）&lt;/a>的核心責任是協調跨節點互斥，避免同一資源被重複處理。它解的是協調一致性問題；正式狀態一致性仍由交易邊界或版本控制承擔。&lt;/p>
&lt;h2 id="鎖與租約">鎖與租約&lt;/h2>
&lt;p>分散式鎖通常採租約語意：持鎖者在租約有效期內擁有操作權，租約到期後鎖自動釋放、需重新競爭。租約的存在是為了處理「持鎖者掛掉但沒釋放鎖」這個分散式系統無法避免的情況——沒有租約，一個 crash 的節點會讓鎖永遠卡住。代價是引入時鐘漂移、網路延遲與續租失敗這幾個新風險。&lt;/p>
&lt;p>在 Redis 上，取鎖是一個原子命令：&lt;code>SET lock:order:42 &amp;lt;token&amp;gt; NX PX 30000&lt;/code>。&lt;code>NX&lt;/code> 保證只有 key 不存在時才寫入，這讓「檢查鎖是否被持有」與「取得鎖」變成單一原子操作，避免兩個節點同時判斷「沒人持鎖」後都寫入。&lt;code>PX 30000&lt;/code> 設定 30 秒租約，持鎖者 crash 時鎖會在租約到期後自動消失。&lt;code>&amp;lt;token&amp;gt;&lt;/code> 是每個持鎖者產生的唯一隨機值，它的作用在釋放階段才顯現。&lt;/p>
&lt;p>釋放鎖不能用單純的 &lt;code>DEL lock:order:42&lt;/code>，因為這會誤刪別人的鎖。考慮這個時序：節點 A 取得鎖、處理超過 30 秒、租約到期自動釋放、節點 B 取得同一把鎖；此時 A 終於處理完、執行 &lt;code>DEL&lt;/code>，刪掉的是 B 的鎖。正確的釋放是「比對 token 相同才刪」，而這個 check-and-delete 必須原子，用 Lua script 達成：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-lua" data-lang="lua">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kr">if&lt;/span> &lt;span class="n">redis.call&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;GET&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">KEYS&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">])&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="n">ARGV&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="kr">then&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="kr">return&lt;/span> &lt;span class="n">redis.call&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;DEL&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">KEYS&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="kr">else&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="kr">return&lt;/span> &lt;span class="mi">0&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="kr">end&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>ARGV[1]&lt;/code> 帶入持鎖者自己的 token，只有 token 吻合才刪。這把「釋放鎖」從一個盲目的刪除，變成「確認我仍是持鎖者後才釋放」的條件操作。&lt;/p>
&lt;p>租約長度要對著任務耗時分布校準，而非拍一個固定值：租約要明顯長於正常任務的 P99 耗時，避免工作還沒做完租約就過期、引發雙持鎖；但也不能長到讓 crash 的持鎖者把鎖卡住太久。兩個方向夾出一個區間，長尾工作再用 watchdog 補足。&lt;/p>
&lt;p>續租策略要明確：何時續租、續租失敗如何降級。長時間工作會用 watchdog 在租約過半（約 T/2）時用 &lt;code>PEXPIRE&lt;/code> 延長租約，讓鎖跟著工作存活；但 watchdog 也意味著鎖可能被無限延長，需要設一個絕對上限（例如業務超時的數倍）避免一個卡住的工作永久佔用鎖。若只依賴「拿到鎖就安全」的假設、不處理續租失敗，異常時容易產生重複副作用。&lt;/p>
&lt;h2 id="split-brain-與-fencing">split brain 與 fencing&lt;/h2>
&lt;p>split brain 常見於網路分割或 process 暫停（GC stop-the-world、容器被搶占）後恢復。核心問題是租約：節點 A 取得鎖後發生一次長 GC 暫停，暫停期間租約到期、鎖被節點 B 取得，A 從暫停中醒來時仍「以為」自己持有鎖，於是 A 與 B 同時對下游寫入，互斥語意失效。這是基於租約的鎖在自身層無法消除的時序窗口，解法要往下游推——讓擁有正式狀態的那一層成為最終仲裁者，而非期望鎖本身堵住這個窗口。&lt;/p>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/fencing-token/" data-link-title="Fencing Token" data-link-desc="說明用單調遞增的 token 讓下游拒絕過期持鎖者的寫入，把互斥正確性下沉到資料層">fencing token&lt;/a> 的責任是把這個問題推到下游解決：每次取鎖時發一個單調遞增的 token，持鎖者對下游的每個寫入都帶上這個 token，下游記住「見過的最大 token」並拒絕比它小的寫入。回到上面的時序，A 帶 token 33、B 帶 token 34，當 A 醒來用 token 33 寫入時，下游已經接受過 34，於是拒絕 33。token 的單調遞增可以用 Redis 原子計數器（&lt;code>INCR fence:order:42&lt;/code>）或鎖服務自己維護的自增序號實作，關鍵是取鎖動作本身要保證拿到的序號嚴格遞增。fencing token 讓下游成為仲裁者，鎖只負責減少競爭、不再是唯一的正確性保證。&lt;/p>
&lt;p>若下游無法驗證 fencing token（例如下游是不支援條件寫入的第三方 API），distributed lock 的保護能力會明顯下降——它只能降低衝突機率，無法消除雙寫。這時更穩定的做法是改成資料版本控制或條件更新（&lt;code>WATCH&lt;/code>/&lt;code>MULTI&lt;/code> 的樂觀鎖、資料庫的 &lt;code>UPDATE ... WHERE version = ?&lt;/code>），把互斥下沉到擁有正式狀態的那一層。&lt;/p>
&lt;h2 id="redlock-與單節點的取捨">Redlock 與單節點的取捨&lt;/h2>
&lt;p>單節點 Redis 鎖有一個可用性缺口：持鎖期間 Redis 主節點故障、failover 到還沒同步該鎖的副本時，新主節點上這把鎖不存在，另一個節點能立刻取得，造成雙持鎖。Redis 作者提出的 Redlock 演算法用多個獨立 master（通常 5 個）解這個問題：向所有節點取鎖，取得多數（3/5）且總耗時在租約內才算成功，藉冗餘避免單點 failover 造成的鎖遺失。&lt;/p>
&lt;p>Redlock 是否真的更安全有公開爭論。Martin Kleppmann 的批評指出，Redlock 依賴各節點時鐘不發生大幅跳動，而 GC 暫停與時鐘校正這類事件仍會讓持鎖者醒來時鎖已失效；更進一步，若 NTP 時鐘跳躍發生在取鎖過程中，各節點對租約是否有效的判斷本身就可能出錯，Redlock 賴以成立的多數決計數因此無法可靠排除雙持鎖。也就是說，Redlock 提升了「鎖不會因單節點故障而遺失」的可用性，但沒有解決「持鎖者暫停導致的 split brain」，後者仍需 fencing token。判讀因此落在：鎖只是效率優化（偶爾雙跑代價可接受）時，單節點 Redis 鎖足夠且運維簡單；鎖牽涉正確性（雙跑會造成金錢或資料損壞）時，無論單節點還是 Redlock 都不足以單獨成立，必須有 fencing token 或下游條件寫入兜底。&lt;/p>
&lt;h2 id="何時使用何時轉向">何時使用、何時轉向&lt;/h2>
&lt;p>distributed lock 在「偶爾失效的代價可控」的場景是一個效率優化工具，降低重複工作的機率而非保證互斥。符合這個特徵的場景包括排程任務避免重複執行、單資源批次工作協調、短期臨界區互斥。以 cron job 為例，偶爾被兩個節點同時觸發時，若任務本身 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotent&lt;/a>，重複執行只是浪費資源而非產生錯誤結果，鎖把這類浪費的機率壓低就足夠。&lt;/p>
&lt;p>讀取路徑上避免 cache miss 風暴的 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/singleflight/" data-link-title="Singleflight" data-link-desc="說明相同工作同時發生時如何合併成一次下游請求">single-flight&lt;/a> 互斥也屬這一類，但租約特性不同：熱門 key 失效時用一把短鎖讓單一請求回源重建快取、其餘請求等結果，偶爾多跑一次回源的代價可控。它的鎖租約通常很短（一次回源的時間），競爭集中在少數熱門 key，與批次任務的長租約、低競爭剖面相反，校準時要分開看。&lt;/p>
&lt;p>高價值交易資料更新則相反，優先使用資料庫交易與唯一約束，將鎖作為輔助而非核心一致性機制。扣款、出貨、配額扣減這類操作，正確性不能依賴「鎖沒失效」這個無法保證的前提，而要靠資料層的唯一約束或版本檢查讓重複操作在最後一刻被擋下。&lt;/p></description><content:encoded><![CDATA[<p><a href="/blog/backend/knowledge-cards/distributed-lock/" data-link-title="Distributed Lock" data-link-desc="跨機器跨 process 的互斥鎖、用 lease 機制處理 holder 失效">分散式鎖（distributed lock）</a>的核心責任是協調跨節點互斥，避免同一資源被重複處理。它解的是協調一致性問題；正式狀態一致性仍由交易邊界或版本控制承擔。</p>
<h2 id="鎖與租約">鎖與租約</h2>
<p>分散式鎖通常採租約語意：持鎖者在租約有效期內擁有操作權，租約到期後鎖自動釋放、需重新競爭。租約的存在是為了處理「持鎖者掛掉但沒釋放鎖」這個分散式系統無法避免的情況——沒有租約，一個 crash 的節點會讓鎖永遠卡住。代價是引入時鐘漂移、網路延遲與續租失敗這幾個新風險。</p>
<p>在 Redis 上，取鎖是一個原子命令：<code>SET lock:order:42 &lt;token&gt; NX PX 30000</code>。<code>NX</code> 保證只有 key 不存在時才寫入，這讓「檢查鎖是否被持有」與「取得鎖」變成單一原子操作，避免兩個節點同時判斷「沒人持鎖」後都寫入。<code>PX 30000</code> 設定 30 秒租約，持鎖者 crash 時鎖會在租約到期後自動消失。<code>&lt;token&gt;</code> 是每個持鎖者產生的唯一隨機值，它的作用在釋放階段才顯現。</p>
<p>釋放鎖不能用單純的 <code>DEL lock:order:42</code>，因為這會誤刪別人的鎖。考慮這個時序：節點 A 取得鎖、處理超過 30 秒、租約到期自動釋放、節點 B 取得同一把鎖；此時 A 終於處理完、執行 <code>DEL</code>，刪掉的是 B 的鎖。正確的釋放是「比對 token 相同才刪」，而這個 check-and-delete 必須原子，用 Lua script 達成：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-lua" data-lang="lua"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">if</span> <span class="n">redis.call</span><span class="p">(</span><span class="s2">&#34;GET&#34;</span><span class="p">,</span> <span class="n">KEYS</span><span class="p">[</span><span class="mi">1</span><span class="p">])</span> <span class="o">==</span> <span class="n">ARGV</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span> <span class="kr">then</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kr">return</span> <span class="n">redis.call</span><span class="p">(</span><span class="s2">&#34;DEL&#34;</span><span class="p">,</span> <span class="n">KEYS</span><span class="p">[</span><span class="mi">1</span><span class="p">])</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="kr">else</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="kr">return</span> <span class="mi">0</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="kr">end</span></span></span></code></pre></div><p><code>ARGV[1]</code> 帶入持鎖者自己的 token，只有 token 吻合才刪。這把「釋放鎖」從一個盲目的刪除，變成「確認我仍是持鎖者後才釋放」的條件操作。</p>
<p>租約長度要對著任務耗時分布校準，而非拍一個固定值：租約要明顯長於正常任務的 P99 耗時，避免工作還沒做完租約就過期、引發雙持鎖；但也不能長到讓 crash 的持鎖者把鎖卡住太久。兩個方向夾出一個區間，長尾工作再用 watchdog 補足。</p>
<p>續租策略要明確：何時續租、續租失敗如何降級。長時間工作會用 watchdog 在租約過半（約 T/2）時用 <code>PEXPIRE</code> 延長租約，讓鎖跟著工作存活；但 watchdog 也意味著鎖可能被無限延長，需要設一個絕對上限（例如業務超時的數倍）避免一個卡住的工作永久佔用鎖。若只依賴「拿到鎖就安全」的假設、不處理續租失敗，異常時容易產生重複副作用。</p>
<h2 id="split-brain-與-fencing">split brain 與 fencing</h2>
<p>split brain 常見於網路分割或 process 暫停（GC stop-the-world、容器被搶占）後恢復。核心問題是租約：節點 A 取得鎖後發生一次長 GC 暫停，暫停期間租約到期、鎖被節點 B 取得，A 從暫停中醒來時仍「以為」自己持有鎖，於是 A 與 B 同時對下游寫入，互斥語意失效。這是基於租約的鎖在自身層無法消除的時序窗口，解法要往下游推——讓擁有正式狀態的那一層成為最終仲裁者，而非期望鎖本身堵住這個窗口。</p>
<p><a href="/blog/backend/knowledge-cards/fencing-token/" data-link-title="Fencing Token" data-link-desc="說明用單調遞增的 token 讓下游拒絕過期持鎖者的寫入，把互斥正確性下沉到資料層">fencing token</a> 的責任是把這個問題推到下游解決：每次取鎖時發一個單調遞增的 token，持鎖者對下游的每個寫入都帶上這個 token，下游記住「見過的最大 token」並拒絕比它小的寫入。回到上面的時序，A 帶 token 33、B 帶 token 34，當 A 醒來用 token 33 寫入時，下游已經接受過 34，於是拒絕 33。token 的單調遞增可以用 Redis 原子計數器（<code>INCR fence:order:42</code>）或鎖服務自己維護的自增序號實作，關鍵是取鎖動作本身要保證拿到的序號嚴格遞增。fencing token 讓下游成為仲裁者，鎖只負責減少競爭、不再是唯一的正確性保證。</p>
<p>若下游無法驗證 fencing token（例如下游是不支援條件寫入的第三方 API），distributed lock 的保護能力會明顯下降——它只能降低衝突機率，無法消除雙寫。這時更穩定的做法是改成資料版本控制或條件更新（<code>WATCH</code>/<code>MULTI</code> 的樂觀鎖、資料庫的 <code>UPDATE ... WHERE version = ?</code>），把互斥下沉到擁有正式狀態的那一層。</p>
<h2 id="redlock-與單節點的取捨">Redlock 與單節點的取捨</h2>
<p>單節點 Redis 鎖有一個可用性缺口：持鎖期間 Redis 主節點故障、failover 到還沒同步該鎖的副本時，新主節點上這把鎖不存在，另一個節點能立刻取得，造成雙持鎖。Redis 作者提出的 Redlock 演算法用多個獨立 master（通常 5 個）解這個問題：向所有節點取鎖，取得多數（3/5）且總耗時在租約內才算成功，藉冗餘避免單點 failover 造成的鎖遺失。</p>
<p>Redlock 是否真的更安全有公開爭論。Martin Kleppmann 的批評指出，Redlock 依賴各節點時鐘不發生大幅跳動，而 GC 暫停與時鐘校正這類事件仍會讓持鎖者醒來時鎖已失效；更進一步，若 NTP 時鐘跳躍發生在取鎖過程中，各節點對租約是否有效的判斷本身就可能出錯，Redlock 賴以成立的多數決計數因此無法可靠排除雙持鎖。也就是說，Redlock 提升了「鎖不會因單節點故障而遺失」的可用性，但沒有解決「持鎖者暫停導致的 split brain」，後者仍需 fencing token。判讀因此落在：鎖只是效率優化（偶爾雙跑代價可接受）時，單節點 Redis 鎖足夠且運維簡單；鎖牽涉正確性（雙跑會造成金錢或資料損壞）時，無論單節點還是 Redlock 都不足以單獨成立，必須有 fencing token 或下游條件寫入兜底。</p>
<h2 id="何時使用何時轉向">何時使用、何時轉向</h2>
<p>distributed lock 在「偶爾失效的代價可控」的場景是一個效率優化工具，降低重複工作的機率而非保證互斥。符合這個特徵的場景包括排程任務避免重複執行、單資源批次工作協調、短期臨界區互斥。以 cron job 為例，偶爾被兩個節點同時觸發時，若任務本身 <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotent</a>，重複執行只是浪費資源而非產生錯誤結果，鎖把這類浪費的機率壓低就足夠。</p>
<p>讀取路徑上避免 cache miss 風暴的 <a href="/blog/backend/knowledge-cards/singleflight/" data-link-title="Singleflight" data-link-desc="說明相同工作同時發生時如何合併成一次下游請求">single-flight</a> 互斥也屬這一類，但租約特性不同：熱門 key 失效時用一把短鎖讓單一請求回源重建快取、其餘請求等結果，偶爾多跑一次回源的代價可控。它的鎖租約通常很短（一次回源的時間），競爭集中在少數熱門 key，與批次任務的長租約、低競爭剖面相反，校準時要分開看。</p>
<p>高價值交易資料更新則相反，優先使用資料庫交易與唯一約束，將鎖作為輔助而非核心一致性機制。扣款、出貨、配額扣減這類操作，正確性不能依賴「鎖沒失效」這個無法保證的前提，而要靠資料層的唯一約束或版本檢查讓重複操作在最後一刻被擋下。</p>
<p>當鎖競爭成為常態、租約續租頻繁失敗、鎖持有時間與業務耗時高度耦合時，代表模型需要轉向分片、隊列化或版本檢查。鎖競爭高通常是粒度設計問題：把單一全域鎖換成依資源分片的細粒度鎖（<code>lock:order:42</code> 而非 <code>lock:orders</code>），讓不相關的資源互不阻塞。若工作本身就是序列化處理一批項目，改用 <a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">message queue</a> 的單一 consumer 語意，比用鎖模擬序列更穩定。</p>
<h2 id="判讀訊號">判讀訊號</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>判讀重點</th>
          <th>對應動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>鎖等待時間持續拉長</td>
          <td>臨界區過大或熱點資源集中</td>
          <td>縮小臨界區、拆分資源粒度</td>
      </tr>
      <tr>
          <td>續租失敗與重入衝突同時上升</td>
          <td>租約時間與工作耗時不匹配</td>
          <td>重設租約、加入 fencing token</td>
      </tr>
      <tr>
          <td>相同任務重複執行率上升</td>
          <td>鎖語意失效或持鎖者判定漂移</td>
          <td>檢查時鐘與網路、補下游去重</td>
      </tr>
      <tr>
          <td>網路抖動時 split brain 事件增加</td>
          <td>鎖系統與下游防護未對位</td>
          <td>補下游版本檢查、限制高風險操作</td>
      </tr>
      <tr>
          <td>鎖系統穩定但業務仍不一致</td>
          <td>問題層級在資料一致性而非協調層</td>
          <td>回到 transaction/constraint 設計</td>
      </tr>
  </tbody>
</table>
<h2 id="常見誤區">常見誤區</h2>
<p>把分散式鎖當作通用一致性解法，會讓錯誤責任落在錯誤層級。最常見的具體形狀是「用鎖保護寫入、但讀取路徑不過鎖」：寫入互斥成立了，讀取卻仍可能讀到未提交或 stale 的值，不一致沒有被鎖擋住。鎖負責互斥協調，資料正確性要由資料模型與交易邊界保護，讀寫兩端要納入同一套一致性設計、而非只鎖寫端。</p>
<p>用單純的 <code>DEL</code> 釋放鎖，是最容易在程式碼裡漏掉的一個錯誤。租約到期後鎖可能已被別人取得，盲目 <code>DEL</code> 會誤刪他人的鎖、讓互斥瓦解。釋放一律要走 token 比對的條件刪除。</p>
<p>把 Redlock 或多節點鎖當成正確性保證，是第二個誤區。多節點冗餘提升的是「鎖不會因單點故障遺失」的可用性，不是「持鎖者暫停不會造成雙寫」的正確性。需要正確性時，fencing token 或下游條件寫入才是真正的防線，鎖只是減少競爭。</p>
<p>把租約時間固定為常數，也會在流量波動下放大風險。租約太短會在正常工作未完成時就過期、引發雙持鎖；太長則讓 crash 的持鎖者長時間卡住鎖。租約策略需要和任務耗時分布與錯誤模型一起校準，長尾工作要靠 watchdog 續租而非把租約一律設大。</p>
<h2 id="情境回寫">情境回寫</h2>
<p>分散式鎖失效回寫到真實服務時，最常見的形狀是排程任務的重複執行。一個跨多節點部署的對帳 job 用 distributed lock 確保同一批次只有一個節點處理；當持鎖節點發生長暫停、租約到期被另一節點接手，而暫停節點醒來後仍繼續寫入時，同一批對帳被執行兩次。回寫時先判讀鎖失效來自時序漂移、網路分割還是續租策略，再決定防線往哪裡補。</p>
<p>這個形狀支撐的是「互斥語意在異常下失效」的判讀。若任務本身 idempotent，重複執行只是浪費資源；若會產生重複副作用（重複出帳、重複通知），正確性不能靠鎖，要靠下游的 fencing token 或唯一約束。高風險路徑可接到 <a href="/blog/backend/06-reliability/experiment-safety-boundary/" data-link-title="6.20 Experiment Safety Boundary" data-link-desc="定義 chaos、load test、DR drill 的 [blast radius](/backend/knowledge-cards/blast-radius/)、停止條件與權限約束">6.20 Experiment Safety Boundary</a> 做故障演練。</p>
<h2 id="跨模組路由">跨模組路由</h2>
<ol>
<li>與 2.2 的交接：鎖搭配失效策略回到 <a href="/blog/backend/02-cache-redis/cache-aside/" data-link-title="2.2 cache aside 與失效策略" data-link-desc="整理 read-through 思路、cache miss 與 invalidation">cache aside 與失效策略</a>。</li>
<li>與 1.3 的交接：高價值資料一致性回到 <a href="/blog/backend/01-database/transaction-boundary/" data-link-title="1.3 Transaction 與一致性邊界" data-link-desc="交易邊界、isolation level、retry 策略、distributed transaction（2PC、Saga）與跨 region 強一致取捨">transaction 與一致性邊界</a>。</li>
<li>與 6.20 的交接：鎖失效演練與停損條件回到 <a href="/blog/backend/06-reliability/experiment-safety-boundary/" data-link-title="6.20 Experiment Safety Boundary" data-link-desc="定義 chaos、load test、DR drill 的 [blast radius](/backend/knowledge-cards/blast-radius/)、停止條件與權限約束">Experiment Safety Boundary</a>。</li>
<li>與 8.19 的交接：鎖衝突與回退判斷回到 <a href="/blog/backend/08-incident-response/incident-decision-log/" data-link-title="8.19 Incident Decision Log" data-link-desc="把事中假設、決策、證據、回退條件與責任人留下可復盤紀錄">Incident Decision Log</a>。</li>
<li>與 2.6 的交接：split brain 與鎖失效的弱點可從威脅建模角度重新盤點，回到 <a href="/blog/backend/02-cache-redis/attacker-view-cache-risks/" data-link-title="2.6 快取威脅建模（Threat Modeling）" data-link-desc="從快取污染、一致性偏移與流量放大風險，盤點 cache/redis 的主要弱點">快取威脅建模</a>。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<p>要看快取層一致性與容量壓力，接著讀 <a href="/blog/backend/02-cache-redis/ttl-eviction/" data-link-title="2.3 TTL 與 eviction" data-link-desc="整理過期策略、容量控制與熱點資料">2.3 TTL 與 eviction</a>。要看鎖語意在事故裡的擴散方式，接著讀 <a href="/blog/backend/02-cache-redis/cases/failure-cache-stampede-rollout-regression/" data-link-title="2.C9 反例：快取切換引發 Stampede 回歸" data-link-desc="快取策略切換若缺乏保護，會導致回源壓力與錯誤率連鎖上升。">2.C9 反例</a>。</p>
]]></content:encoded></item><item><title>2.5 presence store 與即時狀態</title><link>https://tarrragon.github.io/blog/backend/02-cache-redis/presence-store/</link><pubDate>Thu, 23 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/02-cache-redis/presence-store/</guid><description>&lt;p>在線狀態儲存（presence store）的核心責任是維持短生命週期狀態的可查詢性，例如線上狀態、連線節點、最後活動時間。它屬於即時協調層，與正式帳務資料分層治理。&lt;/p>
&lt;h2 id="狀態模型">狀態模型&lt;/h2>
&lt;p>presence 模型通常包含 &lt;code>subject&lt;/code>、&lt;code>node&lt;/code>、&lt;code>last_seen_at&lt;/code>、&lt;code>ttl&lt;/code>。主體可能是使用者、裝置、連線或工作者。模型設計重點是查詢責任：需要查單一主體是否在線、查群組在線清單，還是查節點負載分布。&lt;/p>
&lt;p>presence 資料具備高變動、短保留特性。設計時應避免把正式業務欄位混入 presence store，讓它保持可快速更新與快速過期。&lt;/p>
&lt;h2 id="heartbeat-與-expiration">heartbeat 與 expiration&lt;/h2>
&lt;p>heartbeat 的責任是維持活性訊號，expiration 的責任是清理失效狀態。heartbeat 間隔太長會放大誤判離線，太短會增加寫入壓力。expiration 視窗要和網路抖動容忍度一起設計。&lt;/p>
&lt;p>穩定做法是定義「可接受延遲在線」窗口，而不是追求絕對即時。presence 判讀通常是近即時近似，不是強一致保證。&lt;/p>
&lt;h2 id="cross-node-query">cross-node query&lt;/h2>
&lt;p>跨節點查詢要先明確一致性需求。聊天室在線名單可容忍短暫不一致；調度系統節點可用性則需要更保守窗口與校驗策略。查詢層應同時提供快取讀取與回源校正路徑，避免單一路徑失真。&lt;/p>
&lt;p>在多區部署中，presence 常採區域內優先、跨區聚合延遲同步。這樣能降低廣域寫入成本，同時保留可接受的全域可見性。&lt;/p>
&lt;h2 id="cleanup-策略">cleanup 策略&lt;/h2>
&lt;p>cleanup 的責任是避免殭屍狀態堆積。定期掃描、lazy cleanup、事件驅動清理可混合使用。清理策略要與業務容忍度對齊：社交場景可容忍秒級延遲清除，調度場景則需更快收斂。&lt;/p>
&lt;h2 id="判讀訊號">判讀訊號&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>訊號&lt;/th>
 &lt;th>判讀重點&lt;/th>
 &lt;th>對應動作&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>在線數異常下降但流量未下降&lt;/td>
 &lt;td>heartbeat 發送或寫入路徑中斷&lt;/td>
 &lt;td>檢查 producer 路徑、降級為回源校驗&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>離線判斷延遲明顯增加&lt;/td>
 &lt;td>expiration 視窗過長或清理積壓&lt;/td>
 &lt;td>調整 TTL、提高 cleanup 頻率&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>跨節點查詢結果波動大&lt;/td>
 &lt;td>多節點寫入競態與聚合窗口不一致&lt;/td>
 &lt;td>收斂聚合邏輯、加入版本時間戳&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>節點重啟後出現大量殭屍在線&lt;/td>
 &lt;td>清理與重建流程未對齊&lt;/td>
 &lt;td>啟動全量重整、補啟動時同步清理&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>高峰時段 presence 查詢延遲拉高&lt;/td>
 &lt;td>熱 key 集中與查詢模式不匹配&lt;/td>
 &lt;td>分散 key、按群組分片、加查詢快取&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="常見誤區">常見誤區&lt;/h2>
&lt;p>把在線狀態儲存當正式狀態來源，會讓一致性與修復成本快速上升。presence 模型適合即時協調，最終業務判定仍由正式資料層承擔。&lt;/p>
&lt;p>把 heartbeat 當固定頻率任務，也會造成高峰寫入抖動。頻率應該與線上人數與連線型態一起調整。&lt;/p>
&lt;h2 id="案例回寫">案例回寫&lt;/h2>
&lt;p>presence 模型可用 &lt;a href="https://tarrragon.github.io/blog/backend/02-cache-redis/cases/meta-mcrouter-global-cache-routing/" data-link-title="2.C2 Meta：mcrouter 與跨區快取路由" data-link-desc="快取從單點最佳化演進到分散式路由層的案例。">2.C2 Meta：mcrouter 跨區路由&lt;/a> 回寫。先看跨區路由如何影響在線可見性，再回到本章檢查 heartbeat 視窗、跨節點聚合與清理節奏是否一致。
這個案例主要支撐的是「跨區可見性與狀態新鮮度」判讀，不直接支撐 lock 租約或 queue 語意；若問題是互斥衝突或重播邊界，應轉到 2.4 或 3.x。&lt;/p>
&lt;p>若區域內在線正常、跨區可見性延遲偏大，先調整跨區同步策略與 fallback 壽命，再把影響評估接到 &lt;a href="https://tarrragon.github.io/blog/backend/08-incident-response/customer-impact-assessment/" data-link-title="8.20 Customer Impact Assessment" data-link-desc="把受影響用戶、功能、區域、金額、SLO 與補償判斷串成影響評估模型">8.20 Customer Impact Assessment&lt;/a>。&lt;/p>
&lt;h2 id="跨模組路由">跨模組路由&lt;/h2>
&lt;ol>
&lt;li>與 2.3 的交接：保留與清理策略回到 &lt;a href="https://tarrragon.github.io/blog/backend/02-cache-redis/ttl-eviction/" data-link-title="2.3 TTL 與 eviction" data-link-desc="整理過期策略、容量控制與熱點資料">TTL 與 eviction&lt;/a>。&lt;/li>
&lt;li>與 4.17 的交接：presence 資料品質與延遲偏差回到 &lt;a href="https://tarrragon.github.io/blog/backend/04-observability/telemetry-data-quality/" data-link-title="4.17 Telemetry Data Quality" data-link-desc="把 missing signal、schema drift、sampling bias 與 timestamp skew 變成資料品質問題">Telemetry Data Quality&lt;/a>。&lt;/li>
&lt;li>與 6.22 的交接：穩態定義與高峰演練回到 &lt;a href="https://tarrragon.github.io/blog/backend/06-reliability/steady-state-definition/" data-link-title="6.22 Steady State Definition" data-link-desc="在 chaos 與 failover 前先定義系統應維持的穩定狀態與可接受退化">Steady State Definition&lt;/a>。&lt;/li>
&lt;li>與 8.20 的交接：即時狀態誤判造成客戶影響回到 &lt;a href="https://tarrragon.github.io/blog/backend/08-incident-response/customer-impact-assessment/" data-link-title="8.20 Customer Impact Assessment" data-link-desc="把受影響用戶、功能、區域、金額、SLO 與補償判斷串成影響評估模型">Customer Impact Assessment&lt;/a>。&lt;/li>
&lt;li>與 2.10 的交接：presence 狀態變更如何即時廣播給其他節點回到 &lt;a href="https://tarrragon.github.io/blog/backend/02-cache-redis/pub-sub/" data-link-title="2.10 Pub/Sub 與即時 fan-out" data-link-desc="說明 Redis Pub/Sub 的即時廣播責任、at-most-once 邊界，以及何時升級到 Streams 或正式 message queue">Pub/Sub 與即時 fan-out&lt;/a>。&lt;/li>
&lt;/ol>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>要看快取層一致性與失效策略，接著讀 &lt;a href="https://tarrragon.github.io/blog/backend/02-cache-redis/cache-aside/" data-link-title="2.2 cache aside 與失效策略" data-link-desc="整理 read-through 思路、cache miss 與 invalidation">2.2 cache aside 與失效策略&lt;/a>。要看 presence 狀態變更如何即時扇出給其他節點，接著讀 &lt;a href="https://tarrragon.github.io/blog/backend/02-cache-redis/pub-sub/" data-link-title="2.10 Pub/Sub 與即時 fan-out" data-link-desc="說明 Redis Pub/Sub 的即時廣播責任、at-most-once 邊界，以及何時升級到 Streams 或正式 message queue">2.10 Pub/Sub 與即時 fan-out&lt;/a>。要看跨規模 presence 路由案例，接著讀 &lt;a href="https://tarrragon.github.io/blog/backend/02-cache-redis/cases/meta-mcrouter-global-cache-routing/" data-link-title="2.C2 Meta：mcrouter 與跨區快取路由" data-link-desc="快取從單點最佳化演進到分散式路由層的案例。">2.C2 Meta：mcrouter 跨區路由&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>在線狀態儲存（presence store）的核心責任是維持短生命週期狀態的可查詢性，例如線上狀態、連線節點、最後活動時間。它屬於即時協調層，與正式帳務資料分層治理。</p>
<h2 id="狀態模型">狀態模型</h2>
<p>presence 模型通常包含 <code>subject</code>、<code>node</code>、<code>last_seen_at</code>、<code>ttl</code>。主體可能是使用者、裝置、連線或工作者。模型設計重點是查詢責任：需要查單一主體是否在線、查群組在線清單，還是查節點負載分布。</p>
<p>presence 資料具備高變動、短保留特性。設計時應避免把正式業務欄位混入 presence store，讓它保持可快速更新與快速過期。</p>
<h2 id="heartbeat-與-expiration">heartbeat 與 expiration</h2>
<p>heartbeat 的責任是維持活性訊號，expiration 的責任是清理失效狀態。heartbeat 間隔太長會放大誤判離線，太短會增加寫入壓力。expiration 視窗要和網路抖動容忍度一起設計。</p>
<p>穩定做法是定義「可接受延遲在線」窗口，而不是追求絕對即時。presence 判讀通常是近即時近似，不是強一致保證。</p>
<h2 id="cross-node-query">cross-node query</h2>
<p>跨節點查詢要先明確一致性需求。聊天室在線名單可容忍短暫不一致；調度系統節點可用性則需要更保守窗口與校驗策略。查詢層應同時提供快取讀取與回源校正路徑，避免單一路徑失真。</p>
<p>在多區部署中，presence 常採區域內優先、跨區聚合延遲同步。這樣能降低廣域寫入成本，同時保留可接受的全域可見性。</p>
<h2 id="cleanup-策略">cleanup 策略</h2>
<p>cleanup 的責任是避免殭屍狀態堆積。定期掃描、lazy cleanup、事件驅動清理可混合使用。清理策略要與業務容忍度對齊：社交場景可容忍秒級延遲清除，調度場景則需更快收斂。</p>
<h2 id="判讀訊號">判讀訊號</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>判讀重點</th>
          <th>對應動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>在線數異常下降但流量未下降</td>
          <td>heartbeat 發送或寫入路徑中斷</td>
          <td>檢查 producer 路徑、降級為回源校驗</td>
      </tr>
      <tr>
          <td>離線判斷延遲明顯增加</td>
          <td>expiration 視窗過長或清理積壓</td>
          <td>調整 TTL、提高 cleanup 頻率</td>
      </tr>
      <tr>
          <td>跨節點查詢結果波動大</td>
          <td>多節點寫入競態與聚合窗口不一致</td>
          <td>收斂聚合邏輯、加入版本時間戳</td>
      </tr>
      <tr>
          <td>節點重啟後出現大量殭屍在線</td>
          <td>清理與重建流程未對齊</td>
          <td>啟動全量重整、補啟動時同步清理</td>
      </tr>
      <tr>
          <td>高峰時段 presence 查詢延遲拉高</td>
          <td>熱 key 集中與查詢模式不匹配</td>
          <td>分散 key、按群組分片、加查詢快取</td>
      </tr>
  </tbody>
</table>
<h2 id="常見誤區">常見誤區</h2>
<p>把在線狀態儲存當正式狀態來源，會讓一致性與修復成本快速上升。presence 模型適合即時協調，最終業務判定仍由正式資料層承擔。</p>
<p>把 heartbeat 當固定頻率任務，也會造成高峰寫入抖動。頻率應該與線上人數與連線型態一起調整。</p>
<h2 id="案例回寫">案例回寫</h2>
<p>presence 模型可用 <a href="/blog/backend/02-cache-redis/cases/meta-mcrouter-global-cache-routing/" data-link-title="2.C2 Meta：mcrouter 與跨區快取路由" data-link-desc="快取從單點最佳化演進到分散式路由層的案例。">2.C2 Meta：mcrouter 跨區路由</a> 回寫。先看跨區路由如何影響在線可見性，再回到本章檢查 heartbeat 視窗、跨節點聚合與清理節奏是否一致。
這個案例主要支撐的是「跨區可見性與狀態新鮮度」判讀，不直接支撐 lock 租約或 queue 語意；若問題是互斥衝突或重播邊界，應轉到 2.4 或 3.x。</p>
<p>若區域內在線正常、跨區可見性延遲偏大，先調整跨區同步策略與 fallback 壽命，再把影響評估接到 <a href="/blog/backend/08-incident-response/customer-impact-assessment/" data-link-title="8.20 Customer Impact Assessment" data-link-desc="把受影響用戶、功能、區域、金額、SLO 與補償判斷串成影響評估模型">8.20 Customer Impact Assessment</a>。</p>
<h2 id="跨模組路由">跨模組路由</h2>
<ol>
<li>與 2.3 的交接：保留與清理策略回到 <a href="/blog/backend/02-cache-redis/ttl-eviction/" data-link-title="2.3 TTL 與 eviction" data-link-desc="整理過期策略、容量控制與熱點資料">TTL 與 eviction</a>。</li>
<li>與 4.17 的交接：presence 資料品質與延遲偏差回到 <a href="/blog/backend/04-observability/telemetry-data-quality/" data-link-title="4.17 Telemetry Data Quality" data-link-desc="把 missing signal、schema drift、sampling bias 與 timestamp skew 變成資料品質問題">Telemetry Data Quality</a>。</li>
<li>與 6.22 的交接：穩態定義與高峰演練回到 <a href="/blog/backend/06-reliability/steady-state-definition/" data-link-title="6.22 Steady State Definition" data-link-desc="在 chaos 與 failover 前先定義系統應維持的穩定狀態與可接受退化">Steady State Definition</a>。</li>
<li>與 8.20 的交接：即時狀態誤判造成客戶影響回到 <a href="/blog/backend/08-incident-response/customer-impact-assessment/" data-link-title="8.20 Customer Impact Assessment" data-link-desc="把受影響用戶、功能、區域、金額、SLO 與補償判斷串成影響評估模型">Customer Impact Assessment</a>。</li>
<li>與 2.10 的交接：presence 狀態變更如何即時廣播給其他節點回到 <a href="/blog/backend/02-cache-redis/pub-sub/" data-link-title="2.10 Pub/Sub 與即時 fan-out" data-link-desc="說明 Redis Pub/Sub 的即時廣播責任、at-most-once 邊界，以及何時升級到 Streams 或正式 message queue">Pub/Sub 與即時 fan-out</a>。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<p>要看快取層一致性與失效策略，接著讀 <a href="/blog/backend/02-cache-redis/cache-aside/" data-link-title="2.2 cache aside 與失效策略" data-link-desc="整理 read-through 思路、cache miss 與 invalidation">2.2 cache aside 與失效策略</a>。要看 presence 狀態變更如何即時扇出給其他節點，接著讀 <a href="/blog/backend/02-cache-redis/pub-sub/" data-link-title="2.10 Pub/Sub 與即時 fan-out" data-link-desc="說明 Redis Pub/Sub 的即時廣播責任、at-most-once 邊界，以及何時升級到 Streams 或正式 message queue">2.10 Pub/Sub 與即時 fan-out</a>。要看跨規模 presence 路由案例，接著讀 <a href="/blog/backend/02-cache-redis/cases/meta-mcrouter-global-cache-routing/" data-link-title="2.C2 Meta：mcrouter 與跨區快取路由" data-link-desc="快取從單點最佳化演進到分散式路由層的案例。">2.C2 Meta：mcrouter 跨區路由</a>。</p>
]]></content:encoded></item><item><title>2.6 快取威脅建模（Threat Modeling）</title><link>https://tarrragon.github.io/blog/backend/02-cache-redis/attacker-view-cache-risks/</link><pubDate>Fri, 24 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/02-cache-redis/attacker-view-cache-risks/</guid><description>&lt;p>快取層威脅建模的判讀目標是確認「資料是否可被污染、可被放大、可被錯用」。快取的存在是為了用副本換取讀取效率，盤點要問的就是這個副本能不能被攻擊者操弄：寫進錯誤內容、被放大成回源洪水、或被當成正式狀態誤信。只看效能設計快取，常把一致性與安全邊界留到事故後才補。&lt;/p>
&lt;h2 id="哪些快取場景要先做弱點盤點">哪些快取場景要先做弱點盤點&lt;/h2>
&lt;p>快取弱點會在特定條件下快速放大，這些條件出現時值得在設計階段就做一次盤點，而不是等流量打上來才發現。&lt;/p>
&lt;p>熱門資料高度集中是第一個訊號。當少數 key 承載大部分讀取，形成 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/hot-key/" data-link-title="Hot Key" data-link-desc="說明單一 key 承受大量讀寫時如何形成容量瓶頸">hot key&lt;/a> 時，這些 key 的失效或污染影響面被流量放大：一個被污染的熱門商品價格，會在 TTL 週期內被數百萬次請求讀到。攻擊者只要能影響一個熱門 key，就能用快取的扇出能力把單點錯誤放大成大規模事故。&lt;/p>
&lt;p>同一份資料被多個系統共用是第二個訊號。當一份快取資料同時被 API、搜尋與報表讀取，污染這份資料的後果會跨系統擴散，而各系統對資料正確性的假設不同——API 可能容忍短暫陳舊，計費報表不能。共用快取讓「誰負責驗證這份資料」變得模糊。&lt;/p>
&lt;p>失效策略依賴多服務協作是第三個訊號。當快取失效需要多個服務按順序執行才成立時，任何一個環節被延遲或繞過，都會留下一個 stale 窗口。攻擊者可以針對這個協作鏈的最弱環節，製造可預測的不一致窗口。&lt;/p>
&lt;p>匯出、權限摘要或價格資料大量走快取是第四個訊號。這幾類資料的錯誤直接對應到金錢、越權或合規後果，一旦快取成為它們的讀取路徑，快取的一致性強度就決定了這些高風險判斷的正確性。&lt;/p>
&lt;h2 id="快取弱點的檢查順序">快取弱點的檢查順序&lt;/h2>
&lt;p>弱點盤點依「資料責任 → 失效面 → 放大面 → 污染面」的順序展開，每一層對應一個攻擊者會利用的弱點。&lt;/p>
&lt;p>第一層看資料責任，先區分 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">source of truth&lt;/a> 與快取副本。攻擊者最想找的是「被當成正式狀態的快取副本」——如果某個權限判斷直接信任快取值而不回源驗證，污染這個快取值就等於提權。檢查的方法是追每個快取讀取點，確認它讀到的是可重建的副本，還是被誤用成不該被快取的正式判斷依據。&lt;/p>
&lt;p>第二層看失效面，檢查 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/cache-invalidation/" data-link-title="Cache Invalidation" data-link-desc="說明快取資料何時更新、刪除或重建，以及失效策略如何影響一致性">cache invalidation&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/ttl/" data-link-title="TTL" data-link-desc="說明資料過期時間如何影響快取新鮮度、成本與一致性">ttl&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/eviction/" data-link-title="Eviction" data-link-desc="說明快取容量不足時哪些資料會被淘汰，以及淘汰如何影響服務">eviction&lt;/a> 規則是否一致。失效面的弱點是「stale 窗口可被預測或延長」：若失效只靠廣播通知而沒有 TTL 兜底，攻擊者讓廣播漏送就能讓某節點長期持有舊值；若 TTL 設得過長，污染或過期資料的影響期就被拉長。&lt;/p>
&lt;p>第三層看放大面，檢查 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/cache-stampede/" data-link-title="Cache Stampede" data-link-desc="說明快取同時失效時大量 request 如何壓垮正式來源">cache stampede&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/thundering-herd/" data-link-title="Thundering Herd" data-link-desc="說明大量工作同時被喚醒或同時競爭資源時的尖峰風險">thundering herd&lt;/a> 與回源壓力保護。放大面的攻擊是 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/cache-penetration/" data-link-title="Cache Penetration" data-link-desc="說明查詢必定不存在的 key 繞過快取直接打向 origin 的弱點與防護">cache penetration&lt;/a>：攻擊者枚舉大量必定不存在的 key（不連續的 id、構造的非法 slug），這些查詢全部 miss 並穿透到資料庫，把快取的保護作用繞過、直接打垮 origin。防線是對不存在的 key 也做短期 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/negative-cache/" data-link-title="Negative Cache" data-link-desc="說明把「查無此 key」的結果也快取一小段時間，擋掉重複穿透的防護與代價">negative cache&lt;/a>（把「查無此 key」這個結果也快取一小段時間，擋掉重複穿透），以及對回源路徑加 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/rate-limit/" data-link-title="Rate Limit" data-link-desc="說明限流如何保護服務入口、下游依賴與租戶公平性">rate limit&lt;/a> 與單飛（&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/singleflight/" data-link-title="Singleflight" data-link-desc="說明相同工作同時發生時如何合併成一次下游請求">single-flight&lt;/a>，讓同一 key 的並發回源只有一個真正打到資料庫、其餘等結果）保護。negative cache 自身有代價：真實資料建立後要等 negative 項過期才會被命中，TTL 要夠短，避免新上架資料被「查無」結果短暫遮擋。&lt;/p>
&lt;p>第四層看污染面，檢查 key 設計、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/tenant-boundary/" data-link-title="Tenant Boundary" data-link-desc="說明多租戶系統如何隔離不同客戶或組織的資料與資源">租戶隔離&lt;/a>與欄位遮罩是否防止快取污染與越權讀取。污染面最常見的弱點是 key 命名沒有把租戶或權限維度編進去：若兩個租戶的資料共用同一個快取 key，一個租戶就能讀到另一個租戶的快取值。key 設計要讓隔離維度成為 key 的一部分，而非依賴應用層在讀取後才過濾。&lt;/p>
&lt;p>第五層看推斷面，檢查 cache 命中與否是否會透過回應時間洩漏資訊。cache hit 與 miss 的延遲差異本身是一個 side-channel：攻擊者用一批查詢的回應時間分布，可以推斷某個帳號、商品或 slug 是否存在，即使回應內容本身有做存在性遮罩。對帳號存在性、未上架商品這類敏感判斷，要讓 hit 與 miss 的可觀察延遲一致（例如 miss 也走一段固定延遲），或把存在性判斷的快取與對外查詢路徑分離。這層在敏感資料才需要盤點，一般可重建副本不受此威脅。&lt;/p>
&lt;h2 id="快取弱點先表現為資料錯誤而非停機">快取弱點先表現為資料錯誤而非停機&lt;/h2>
&lt;p>快取事故的判讀重點是它的早期症狀先表現為資料錯誤，而停機往往是後續才浮現的次級症狀。價格、庫存、權限摘要若短時間錯誤，系統照常回應請求、監控的可用性指標一切正常，但回應的內容是錯的，直接造成客訴與營運損失。這種「服務還活著但說錯話」的故障比停機更難被即時發現，因為它不觸發可用性告警。&lt;/p>
&lt;p>回源壓力缺少保護時，快取問題還會反向擴散。原本快取是用來保護資料庫的，但當 stampede 或 penetration 讓大量請求同時穿透，快取從保護層變成放大層，把一個快取層的問題擴散成資料庫與下游服務的連鎖過載。弱點盤點因此要把「快取失效時，壓力會打到哪裡」當成必答問題。&lt;/p>
&lt;h2 id="低延遲與一致性強度的取捨">低延遲與一致性強度的取捨&lt;/h2>
&lt;p>快取命中率越高，延遲與成本越好，同時一致性風險也越高。按資料重要性分層是平衡這個張力最穩定的做法，比對所有資料套同一套快取策略更能讓高風險資料拿到該有的一致性強度。&lt;/p>
&lt;p>高風險資料採較短生命週期與強驗證。價格、權限、餘額這類錯誤代價高的資料，用較短 TTL（典型在數秒到數分鐘量級）縮小污染與陳舊的影響窗口，並在關鍵判斷點保留回源驗證，讓快取只承擔加速、不承擔最終正確性。低風險資料採較寬鬆策略以保留效能收益。商品描述、頭像、靜態文案這類偶爾陳舊無實質後果的資料，用較長 TTL（數十分鐘到數小時）換取更高命中率。具體秒數沒有通用值，依該資料的陳舊容忍度決定；分層的判準是「這份資料錯誤幾分鐘的代價是什麼」，代價高的往一致性傾斜，代價低的往效能傾斜。&lt;/p>
&lt;h2 id="進入實作前要先定義的最低控制面">進入實作前要先定義的最低控制面&lt;/h2>
&lt;p>弱點盤點的產出是一組進入實作前必須先定義清楚的控制面，缺少其中任何一項，後續的快取設計都是在未定義的安全假設上往前蓋。每一項控制面同時是一個診斷工具：可以用「若沒有它會看到什麼現象」反過來判斷現有系統是否缺這道防線。&lt;/p>
&lt;p>快取資料分級與可接受陳舊窗口要先定義，這決定每類資料的 TTL 與是否需要回源驗證。沒有分級，所有資料會被同一套策略對待，高風險資料的陳舊窗口被低風險策略放寬。未定義的早期訊號是「所有 key 用同一個預設 TTL」「說不清哪些資料錯了會出事」。&lt;/p>
&lt;p>失效策略與回源保護規則要先定義，這決定 stale 窗口的上界與回源洪水的防線。失效要明確是廣播、TTL 還是事件驅動，並確認廣播類失效一定有 TTL 兜底；回源要明確 negative cache、single-flight 與 rate limit 的配置。未定義的早期訊號是「失效靠廣播但沒有 TTL 兜底」「miss 尖峰會直接打到資料庫」。&lt;/p>
&lt;p>key 命名、租戶隔離與敏感欄位限制要先定義，這決定污染與越權讀取的防線。隔離維度要編進 key、敏感欄位要在寫入快取前就遮罩，而非依賴讀取後過濾。未定義的早期訊號是「快取 key 不含租戶 id」「敏感欄位整包序列化進 cache」。&lt;/p>
&lt;p>快取異常時的降級與回復流程要先定義，這決定事故發生時系統往哪個方向退。降級要明確是「快取失效時回源並接受延遲上升」還是「直接拒絕並保護資料庫」，並預先演練回復路徑，避免事故當下才設計。未定義的早期訊號是「沒人能回答快取掛掉時系統會怎樣」。&lt;/p>
&lt;h2 id="判讀訊號">判讀訊號&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>訊號&lt;/th>
 &lt;th>判讀重點&lt;/th>
 &lt;th>對應動作&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>大量查詢不存在的 key、回源 QPS 飆&lt;/td>
 &lt;td>cache penetration 繞過快取保護&lt;/td>
 &lt;td>對不存在的 key 加 negative cache、回源加 rate limit&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>兩租戶讀到彼此的快取資料&lt;/td>
 &lt;td>key 未把租戶維度編入、隔離失效&lt;/td>
 &lt;td>把隔離維度納入 key、敏感欄位寫入前遮罩&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>可用性指標正常但客訴資料錯誤&lt;/td>
 &lt;td>快取污染或陳舊，故障不觸發可用性告警&lt;/td>
 &lt;td>補資料正確性監控、關鍵判斷點回源驗證&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>單一熱門 key 失效造成回源尖峰&lt;/td>
 &lt;td>hot key 失效面被流量放大&lt;/td>
 &lt;td>對熱門 key 加 single-flight、錯開 TTL&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>權限或價格判斷直接信任快取值&lt;/td>
 &lt;td>快取副本被誤用成正式狀態&lt;/td>
 &lt;td>關鍵判斷回源驗證，快取只承擔加速&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>可用性正常但資料錯誤之所以難察覺，是因為可用性監控設計上只看系統有沒有正常回應，不掃回應內容對不對，污染或陳舊因此可以在告警全程靜默的情況下持續數小時。這類問題要靠資料正確性監控而非可用性監控才能發現，弱點盤點要確認高風險資料有對應的正確性檢查，而不是只看 latency 與 error rate。&lt;/p></description><content:encoded><![CDATA[<p>快取層威脅建模的判讀目標是確認「資料是否可被污染、可被放大、可被錯用」。快取的存在是為了用副本換取讀取效率，盤點要問的就是這個副本能不能被攻擊者操弄：寫進錯誤內容、被放大成回源洪水、或被當成正式狀態誤信。只看效能設計快取，常把一致性與安全邊界留到事故後才補。</p>
<h2 id="哪些快取場景要先做弱點盤點">哪些快取場景要先做弱點盤點</h2>
<p>快取弱點會在特定條件下快速放大，這些條件出現時值得在設計階段就做一次盤點，而不是等流量打上來才發現。</p>
<p>熱門資料高度集中是第一個訊號。當少數 key 承載大部分讀取，形成 <a href="/blog/backend/knowledge-cards/hot-key/" data-link-title="Hot Key" data-link-desc="說明單一 key 承受大量讀寫時如何形成容量瓶頸">hot key</a> 時，這些 key 的失效或污染影響面被流量放大：一個被污染的熱門商品價格，會在 TTL 週期內被數百萬次請求讀到。攻擊者只要能影響一個熱門 key，就能用快取的扇出能力把單點錯誤放大成大規模事故。</p>
<p>同一份資料被多個系統共用是第二個訊號。當一份快取資料同時被 API、搜尋與報表讀取，污染這份資料的後果會跨系統擴散，而各系統對資料正確性的假設不同——API 可能容忍短暫陳舊，計費報表不能。共用快取讓「誰負責驗證這份資料」變得模糊。</p>
<p>失效策略依賴多服務協作是第三個訊號。當快取失效需要多個服務按順序執行才成立時，任何一個環節被延遲或繞過，都會留下一個 stale 窗口。攻擊者可以針對這個協作鏈的最弱環節，製造可預測的不一致窗口。</p>
<p>匯出、權限摘要或價格資料大量走快取是第四個訊號。這幾類資料的錯誤直接對應到金錢、越權或合規後果，一旦快取成為它們的讀取路徑，快取的一致性強度就決定了這些高風險判斷的正確性。</p>
<h2 id="快取弱點的檢查順序">快取弱點的檢查順序</h2>
<p>弱點盤點依「資料責任 → 失效面 → 放大面 → 污染面」的順序展開，每一層對應一個攻擊者會利用的弱點。</p>
<p>第一層看資料責任，先區分 <a href="/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">source of truth</a> 與快取副本。攻擊者最想找的是「被當成正式狀態的快取副本」——如果某個權限判斷直接信任快取值而不回源驗證，污染這個快取值就等於提權。檢查的方法是追每個快取讀取點，確認它讀到的是可重建的副本，還是被誤用成不該被快取的正式判斷依據。</p>
<p>第二層看失效面，檢查 <a href="/blog/backend/knowledge-cards/cache-invalidation/" data-link-title="Cache Invalidation" data-link-desc="說明快取資料何時更新、刪除或重建，以及失效策略如何影響一致性">cache invalidation</a>、<a href="/blog/backend/knowledge-cards/ttl/" data-link-title="TTL" data-link-desc="說明資料過期時間如何影響快取新鮮度、成本與一致性">ttl</a> 與 <a href="/blog/backend/knowledge-cards/eviction/" data-link-title="Eviction" data-link-desc="說明快取容量不足時哪些資料會被淘汰，以及淘汰如何影響服務">eviction</a> 規則是否一致。失效面的弱點是「stale 窗口可被預測或延長」：若失效只靠廣播通知而沒有 TTL 兜底，攻擊者讓廣播漏送就能讓某節點長期持有舊值；若 TTL 設得過長，污染或過期資料的影響期就被拉長。</p>
<p>第三層看放大面，檢查 <a href="/blog/backend/knowledge-cards/cache-stampede/" data-link-title="Cache Stampede" data-link-desc="說明快取同時失效時大量 request 如何壓垮正式來源">cache stampede</a>、<a href="/blog/backend/knowledge-cards/thundering-herd/" data-link-title="Thundering Herd" data-link-desc="說明大量工作同時被喚醒或同時競爭資源時的尖峰風險">thundering herd</a> 與回源壓力保護。放大面的攻擊是 <a href="/blog/backend/knowledge-cards/cache-penetration/" data-link-title="Cache Penetration" data-link-desc="說明查詢必定不存在的 key 繞過快取直接打向 origin 的弱點與防護">cache penetration</a>：攻擊者枚舉大量必定不存在的 key（不連續的 id、構造的非法 slug），這些查詢全部 miss 並穿透到資料庫，把快取的保護作用繞過、直接打垮 origin。防線是對不存在的 key 也做短期 <a href="/blog/backend/knowledge-cards/negative-cache/" data-link-title="Negative Cache" data-link-desc="說明把「查無此 key」的結果也快取一小段時間，擋掉重複穿透的防護與代價">negative cache</a>（把「查無此 key」這個結果也快取一小段時間，擋掉重複穿透），以及對回源路徑加 <a href="/blog/backend/knowledge-cards/rate-limit/" data-link-title="Rate Limit" data-link-desc="說明限流如何保護服務入口、下游依賴與租戶公平性">rate limit</a> 與單飛（<a href="/blog/backend/knowledge-cards/singleflight/" data-link-title="Singleflight" data-link-desc="說明相同工作同時發生時如何合併成一次下游請求">single-flight</a>，讓同一 key 的並發回源只有一個真正打到資料庫、其餘等結果）保護。negative cache 自身有代價：真實資料建立後要等 negative 項過期才會被命中，TTL 要夠短，避免新上架資料被「查無」結果短暫遮擋。</p>
<p>第四層看污染面，檢查 key 設計、<a href="/blog/backend/knowledge-cards/tenant-boundary/" data-link-title="Tenant Boundary" data-link-desc="說明多租戶系統如何隔離不同客戶或組織的資料與資源">租戶隔離</a>與欄位遮罩是否防止快取污染與越權讀取。污染面最常見的弱點是 key 命名沒有把租戶或權限維度編進去：若兩個租戶的資料共用同一個快取 key，一個租戶就能讀到另一個租戶的快取值。key 設計要讓隔離維度成為 key 的一部分，而非依賴應用層在讀取後才過濾。</p>
<p>第五層看推斷面，檢查 cache 命中與否是否會透過回應時間洩漏資訊。cache hit 與 miss 的延遲差異本身是一個 side-channel：攻擊者用一批查詢的回應時間分布，可以推斷某個帳號、商品或 slug 是否存在，即使回應內容本身有做存在性遮罩。對帳號存在性、未上架商品這類敏感判斷，要讓 hit 與 miss 的可觀察延遲一致（例如 miss 也走一段固定延遲），或把存在性判斷的快取與對外查詢路徑分離。這層在敏感資料才需要盤點，一般可重建副本不受此威脅。</p>
<h2 id="快取弱點先表現為資料錯誤而非停機">快取弱點先表現為資料錯誤而非停機</h2>
<p>快取事故的判讀重點是它的早期症狀先表現為資料錯誤，而停機往往是後續才浮現的次級症狀。價格、庫存、權限摘要若短時間錯誤，系統照常回應請求、監控的可用性指標一切正常，但回應的內容是錯的，直接造成客訴與營運損失。這種「服務還活著但說錯話」的故障比停機更難被即時發現，因為它不觸發可用性告警。</p>
<p>回源壓力缺少保護時，快取問題還會反向擴散。原本快取是用來保護資料庫的，但當 stampede 或 penetration 讓大量請求同時穿透，快取從保護層變成放大層，把一個快取層的問題擴散成資料庫與下游服務的連鎖過載。弱點盤點因此要把「快取失效時，壓力會打到哪裡」當成必答問題。</p>
<h2 id="低延遲與一致性強度的取捨">低延遲與一致性強度的取捨</h2>
<p>快取命中率越高，延遲與成本越好，同時一致性風險也越高。按資料重要性分層是平衡這個張力最穩定的做法，比對所有資料套同一套快取策略更能讓高風險資料拿到該有的一致性強度。</p>
<p>高風險資料採較短生命週期與強驗證。價格、權限、餘額這類錯誤代價高的資料，用較短 TTL（典型在數秒到數分鐘量級）縮小污染與陳舊的影響窗口，並在關鍵判斷點保留回源驗證，讓快取只承擔加速、不承擔最終正確性。低風險資料採較寬鬆策略以保留效能收益。商品描述、頭像、靜態文案這類偶爾陳舊無實質後果的資料，用較長 TTL（數十分鐘到數小時）換取更高命中率。具體秒數沒有通用值，依該資料的陳舊容忍度決定；分層的判準是「這份資料錯誤幾分鐘的代價是什麼」，代價高的往一致性傾斜，代價低的往效能傾斜。</p>
<h2 id="進入實作前要先定義的最低控制面">進入實作前要先定義的最低控制面</h2>
<p>弱點盤點的產出是一組進入實作前必須先定義清楚的控制面，缺少其中任何一項，後續的快取設計都是在未定義的安全假設上往前蓋。每一項控制面同時是一個診斷工具：可以用「若沒有它會看到什麼現象」反過來判斷現有系統是否缺這道防線。</p>
<p>快取資料分級與可接受陳舊窗口要先定義，這決定每類資料的 TTL 與是否需要回源驗證。沒有分級，所有資料會被同一套策略對待，高風險資料的陳舊窗口被低風險策略放寬。未定義的早期訊號是「所有 key 用同一個預設 TTL」「說不清哪些資料錯了會出事」。</p>
<p>失效策略與回源保護規則要先定義，這決定 stale 窗口的上界與回源洪水的防線。失效要明確是廣播、TTL 還是事件驅動，並確認廣播類失效一定有 TTL 兜底；回源要明確 negative cache、single-flight 與 rate limit 的配置。未定義的早期訊號是「失效靠廣播但沒有 TTL 兜底」「miss 尖峰會直接打到資料庫」。</p>
<p>key 命名、租戶隔離與敏感欄位限制要先定義，這決定污染與越權讀取的防線。隔離維度要編進 key、敏感欄位要在寫入快取前就遮罩，而非依賴讀取後過濾。未定義的早期訊號是「快取 key 不含租戶 id」「敏感欄位整包序列化進 cache」。</p>
<p>快取異常時的降級與回復流程要先定義，這決定事故發生時系統往哪個方向退。降級要明確是「快取失效時回源並接受延遲上升」還是「直接拒絕並保護資料庫」，並預先演練回復路徑，避免事故當下才設計。未定義的早期訊號是「沒人能回答快取掛掉時系統會怎樣」。</p>
<h2 id="判讀訊號">判讀訊號</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>判讀重點</th>
          <th>對應動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>大量查詢不存在的 key、回源 QPS 飆</td>
          <td>cache penetration 繞過快取保護</td>
          <td>對不存在的 key 加 negative cache、回源加 rate limit</td>
      </tr>
      <tr>
          <td>兩租戶讀到彼此的快取資料</td>
          <td>key 未把租戶維度編入、隔離失效</td>
          <td>把隔離維度納入 key、敏感欄位寫入前遮罩</td>
      </tr>
      <tr>
          <td>可用性指標正常但客訴資料錯誤</td>
          <td>快取污染或陳舊，故障不觸發可用性告警</td>
          <td>補資料正確性監控、關鍵判斷點回源驗證</td>
      </tr>
      <tr>
          <td>單一熱門 key 失效造成回源尖峰</td>
          <td>hot key 失效面被流量放大</td>
          <td>對熱門 key 加 single-flight、錯開 TTL</td>
      </tr>
      <tr>
          <td>權限或價格判斷直接信任快取值</td>
          <td>快取副本被誤用成正式狀態</td>
          <td>關鍵判斷回源驗證，快取只承擔加速</td>
      </tr>
  </tbody>
</table>
<p>可用性正常但資料錯誤之所以難察覺，是因為可用性監控設計上只看系統有沒有正常回應，不掃回應內容對不對，污染或陳舊因此可以在告警全程靜默的情況下持續數小時。這類問題要靠資料正確性監控而非可用性監控才能發現，弱點盤點要確認高風險資料有對應的正確性檢查，而不是只看 latency 與 error rate。</p>
<h2 id="常見誤區">常見誤區</h2>
<p>把快取副本當成正式狀態信任，是最危險的誤區。權限、餘額、配額這類判斷若直接信任快取值而不回源驗證，污染快取就等於繞過業務規則。快取承擔加速，正式判斷的正確性要由 source of truth 保證。</p>
<p>只用廣播做失效而不設 TTL 兜底，是第二個誤區。廣播是 at-most-once，總有漏送可能，缺 TTL 時一次漏送就讓某節點長期持有污染或陳舊資料。TTL 是讓失效失敗的影響有上界的保險。</p>
<p>把租戶隔離放在讀取後過濾，是第三個誤區。若快取 key 不含租戶維度、靠應用層讀出後再過濾，任何一個漏掉過濾的讀取路徑都會洩漏跨租戶資料。隔離要編進 key，讓不同租戶在儲存層就不共用快取項。</p>
<h2 id="案例回寫">案例回寫</h2>
<p>快取放大面的弱點盤點可用 <a href="/blog/backend/02-cache-redis/cases/failure-cache-stampede-rollout-regression/" data-link-title="2.C9 反例：快取切換引發 Stampede 回歸" data-link-desc="快取策略切換若缺乏保護，會導致回源壓力與錯誤率連鎖上升。">2.C9 反例：stampede rollout regression</a> 回寫。該案例的回源洪水來自部署回歸而非惡意攻擊，放大後果相似——大量請求同時 miss、穿透到 origin、把快取從保護層變成放大層——但觸發源不同：2.C9 是意外的部署回歸，攻擊場景則是刻意查詢大量不存在的 key。後果相似讓防護有共通部分（single-flight、回源 rate limit），觸發源不同則讓防線各有重點：部署回歸重在發布保護與 warmup，惡意穿透重在 negative cache 與請求來源限制。回寫時要保留「失效時壓力打到哪裡、單飛與 rate limit 是否就位」的判讀，把它從事後復盤前移到設計階段的弱點盤點。</p>
<p>這個案例主要支撐放大面與回源保護的判讀，不直接支撐污染面或租戶隔離；若根因是跨租戶資料洩漏或快取被當正式狀態，應回到 key 設計與 source of truth 邊界，而非回源保護。</p>
<h2 id="跨模組路由">跨模組路由</h2>
<ol>
<li>與 2.2 的交接：失效面的策略與 TTL 兜底回到 <a href="/blog/backend/02-cache-redis/cache-aside/" data-link-title="2.2 cache aside 與失效策略" data-link-desc="整理 read-through 思路、cache miss 與 invalidation">cache aside 與失效策略</a>。</li>
<li>與 2.7 的交接：快取副本與正式狀態的邊界回到 <a href="/blog/backend/02-cache-redis/cache-copy-freshness-boundary/" data-link-title="2.7 Cache Copy Boundary 與 Freshness" data-link-desc="說明快取何時只是可重建副本，何時會影響交易、權限或配額正確性。">Cache Copy Boundary 與 Freshness</a>。</li>
<li>與 2.1 的交接：hot key 與 stampede 的放大面保護回到 <a href="/blog/backend/02-cache-redis/high-concurrency-access/" data-link-title="2.1 高併發下的 Redis 讀寫邊界" data-link-desc="說明高併發服務如何共用 Redis client、控制 pipeline 與避免 cache stampede">高併發下的 Redis 讀寫邊界</a>。</li>
<li>與 6.20 的交接：弱點盤點後的故障演練與停損條件回到 <a href="/blog/backend/06-reliability/experiment-safety-boundary/" data-link-title="6.20 Experiment Safety Boundary" data-link-desc="定義 chaos、load test、DR drill 的 [blast radius](/backend/knowledge-cards/blast-radius/)、停止條件與權限約束">Experiment Safety Boundary</a>。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<p>要看快取副本與正式狀態的界線如何劃分，接著讀 <a href="/blog/backend/02-cache-redis/cache-copy-freshness-boundary/" data-link-title="2.7 Cache Copy Boundary 與 Freshness" data-link-desc="說明快取何時只是可重建副本，何時會影響交易、權限或配額正確性。">2.7 Cache Copy Boundary 與 Freshness</a>。要看放大面的回源保護如何在實作中成立，接著讀 <a href="/blog/backend/02-cache-redis/cache-migration-stampede-rollback/" data-link-title="2.9 Cache Migration 與 Stampede Rollback（實作示範）" data-link-desc="以商品詳情與價格快取示範 cache migration 如何交付 evidence package、release gate 與 incident decision log。">2.9 Cache Migration 與 Stampede Rollback 實作示範</a>。</p>
]]></content:encoded></item><item><title>2.7 Cache Copy Boundary 與 Freshness</title><link>https://tarrragon.github.io/blog/backend/02-cache-redis/cache-copy-freshness-boundary/</link><pubDate>Mon, 11 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/02-cache-redis/cache-copy-freshness-boundary/</guid><description>&lt;p>Cache copy boundary 與 freshness 的核心責任是定義快取副本能承擔什麼判斷，以及它可以不新鮮多久。進入 Redis、Valkey、Memcached 或其他快取服務前，讀者需要先理解快取同時是加速層，也是 source of truth 與讀取壓力之間的風險邊界。&lt;/p>
&lt;h2 id="cache-copy-boundary">Cache Copy Boundary&lt;/h2>
&lt;p>Cache copy boundary 的責任是把可重建副本和正式狀態分開。&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">source of truth&lt;/a> 承擔最終判斷，cache 承擔低延遲讀取與來源保護。&lt;/p>
&lt;p>商品描述、公開設定、推薦摘要通常是可重建副本。價格、庫存、權限、配額與付款狀態雖然可以被快取，但它們的錯誤會直接影響交易或安全判斷，因此 freshness 與 invalidation 要更嚴格。&lt;/p>
&lt;h2 id="freshness">Freshness&lt;/h2>
&lt;p>Freshness 的責任是定義資料可接受的 stale window。不同欄位需要不同 window，TTL 策略要跟欄位風險分層對齊。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>資料類型&lt;/th>
 &lt;th>可接受 stale&lt;/th>
 &lt;th>判斷重點&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>商品描述&lt;/td>
 &lt;td>秒到分鐘級&lt;/td>
 &lt;td>主要影響體驗&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>推薦清單&lt;/td>
 &lt;td>秒到分鐘級&lt;/td>
 &lt;td>主要影響排序與轉換率&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>價格&lt;/td>
 &lt;td>秒級或事件失效&lt;/td>
 &lt;td>影響交易正確性&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>庫存&lt;/td>
 &lt;td>秒級或即時查詢&lt;/td>
 &lt;td>影響超賣與履約&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>權限&lt;/td>
 &lt;td>極短或強制失效&lt;/td>
 &lt;td>影響資料外洩與越權&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>配額&lt;/td>
 &lt;td>極短或原子更新&lt;/td>
 &lt;td>影響濫用與計費&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/stale-data/" data-link-title="Stale Data" data-link-desc="說明過期資料在快取、replica 與衍生資料中的產品影響">stale data&lt;/a> 本身是快取常態成本，定義 stale 代價能讓團隊選擇對應保護。可接受 stale 的資料可用 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/ttl/" data-link-title="TTL" data-link-desc="說明資料過期時間如何影響快取新鮮度、成本與一致性">TTL&lt;/a> 管理，高代價 stale 的資料需要事件失效、版本化 key 或回源確認。&lt;/p>
&lt;p>商品描述與推薦清單偏向體驗資料，短暫 stale 的主要代價是使用者看到較舊內容。價格與庫存偏向交易資料，stale 會改變付款、履約或客服判斷。權限與配額偏向控制資料，stale 會放大越權、濫用或計費風險。這些差異決定快取策略要分欄位設計，並以服務層邊界統一交接。&lt;/p>
&lt;h2 id="invalidation">Invalidation&lt;/h2>
&lt;p>Invalidation 的責任是讓快取副本在正式狀態變更後收斂。常見模型包含刪除 key、更新 key、版本化 key、事件驅動失效與 TTL 保底。&lt;/p>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/cache-invalidation/" data-link-title="Cache Invalidation" data-link-desc="說明快取資料何時更新、刪除或重建，以及失效策略如何影響一致性">cache invalidation&lt;/a> 要和資料責任對齊。價格類資料適合事件驅動失效加短 TTL；商品描述可以長 TTL 加背景刷新；權限類資料要能在撤權後快速失效。&lt;/p>
&lt;h3 id="cache-不一致的主要來源點">Cache 不一致的主要來源點&lt;/h3>
&lt;p>規模化 cache 的不一致主要由 &lt;em>topology 變動事件&lt;/em> 觸發、不是 TTL 設定。對應 &lt;a href="https://tarrragon.github.io/blog/backend/02-cache-redis/cases/meta-cache-consistency-upgrade/" data-link-title="2.C1 Meta：Cache Consistency 升級" data-link-desc="快取 invalidation 一致性如何從常見錯誤演進到高可信治理。">2.C1 Meta Cache Consistency Upgrade&lt;/a> — 案例指出 promotion、shard move、故障恢復是三類主要事件來源、傳統 invalidation 在大規模系統難以維持穩定。&lt;/p>
&lt;p>&lt;strong>三類事件的典型機制&lt;/strong>（具體實作依 cluster 設計而異）：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Promotion / failover&lt;/strong>：primary 切到 replica 過程中、寫入順序可能跨節點不一致、replica 變 primary 後可能讀到舊資料&lt;/li>
&lt;li>&lt;strong>Shard move / rebalance&lt;/strong>：cluster topology 變更時、部分 key 在搬遷窗口內可能讀到舊 shard 的副本&lt;/li>
&lt;li>&lt;strong>故障恢復&lt;/strong>：節點重啟後、cache 從 backing store 重建、跟 application 寫入的新值可能交錯&lt;/li>
&lt;/ul>
&lt;p>在這些事件中、cache 拓樸隨著事件改變、需要追蹤 mutation 收斂、不只清 key。Meta 的解法是把 &lt;em>mutation tracing&lt;/em> 制度化、追蹤每次資料變動是否在所有 cache 副本都收斂。&lt;/p>
&lt;h3 id="mutation-tracing-跟一致性指標">Mutation tracing 跟一致性指標&lt;/h3>
&lt;p>Mutation tracing 是 &lt;em>資料變動到所有 cache 副本收斂的時間軸&lt;/em> 追蹤、跟一般 cache hit rate 屬不同維度。常見的工程實踐指標（屬 case-derived 推論、非 Meta case 直接揭露具體 SLO）：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Inconsistency window&lt;/strong>：從 source-of-truth 寫入到所有 cache 副本反映的耗時（平均 / p99）&lt;/li>
&lt;li>&lt;strong>Inconsistency rate&lt;/strong>：query 取到 stale 副本的比例&lt;/li>
&lt;li>&lt;strong>Inconsistency duration distribution&lt;/strong>：stale 持續時間的分布（看長尾才能識別事故風險、平均值會掩蓋）&lt;/li>
&lt;/ul>
&lt;p>這些指標要接到告警跟回退條件、用法接近一般 SLO（例：inconsistency window p99 超過 &lt;em>服務可接受 stale window&lt;/em> 觸發保護動作）。具體門檻依業務型態定 — 付款 / 庫存 / 權限類資料的容忍可能在秒級、商品描述可能在分鐘級。&lt;/p></description><content:encoded><![CDATA[<p>Cache copy boundary 與 freshness 的核心責任是定義快取副本能承擔什麼判斷，以及它可以不新鮮多久。進入 Redis、Valkey、Memcached 或其他快取服務前，讀者需要先理解快取同時是加速層，也是 source of truth 與讀取壓力之間的風險邊界。</p>
<h2 id="cache-copy-boundary">Cache Copy Boundary</h2>
<p>Cache copy boundary 的責任是把可重建副本和正式狀態分開。<a href="/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">source of truth</a> 承擔最終判斷，cache 承擔低延遲讀取與來源保護。</p>
<p>商品描述、公開設定、推薦摘要通常是可重建副本。價格、庫存、權限、配額與付款狀態雖然可以被快取，但它們的錯誤會直接影響交易或安全判斷，因此 freshness 與 invalidation 要更嚴格。</p>
<h2 id="freshness">Freshness</h2>
<p>Freshness 的責任是定義資料可接受的 stale window。不同欄位需要不同 window，TTL 策略要跟欄位風險分層對齊。</p>
<table>
  <thead>
      <tr>
          <th>資料類型</th>
          <th>可接受 stale</th>
          <th>判斷重點</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>商品描述</td>
          <td>秒到分鐘級</td>
          <td>主要影響體驗</td>
      </tr>
      <tr>
          <td>推薦清單</td>
          <td>秒到分鐘級</td>
          <td>主要影響排序與轉換率</td>
      </tr>
      <tr>
          <td>價格</td>
          <td>秒級或事件失效</td>
          <td>影響交易正確性</td>
      </tr>
      <tr>
          <td>庫存</td>
          <td>秒級或即時查詢</td>
          <td>影響超賣與履約</td>
      </tr>
      <tr>
          <td>權限</td>
          <td>極短或強制失效</td>
          <td>影響資料外洩與越權</td>
      </tr>
      <tr>
          <td>配額</td>
          <td>極短或原子更新</td>
          <td>影響濫用與計費</td>
      </tr>
  </tbody>
</table>
<p><a href="/blog/backend/knowledge-cards/stale-data/" data-link-title="Stale Data" data-link-desc="說明過期資料在快取、replica 與衍生資料中的產品影響">stale data</a> 本身是快取常態成本，定義 stale 代價能讓團隊選擇對應保護。可接受 stale 的資料可用 <a href="/blog/backend/knowledge-cards/ttl/" data-link-title="TTL" data-link-desc="說明資料過期時間如何影響快取新鮮度、成本與一致性">TTL</a> 管理，高代價 stale 的資料需要事件失效、版本化 key 或回源確認。</p>
<p>商品描述與推薦清單偏向體驗資料，短暫 stale 的主要代價是使用者看到較舊內容。價格與庫存偏向交易資料，stale 會改變付款、履約或客服判斷。權限與配額偏向控制資料，stale 會放大越權、濫用或計費風險。這些差異決定快取策略要分欄位設計，並以服務層邊界統一交接。</p>
<h2 id="invalidation">Invalidation</h2>
<p>Invalidation 的責任是讓快取副本在正式狀態變更後收斂。常見模型包含刪除 key、更新 key、版本化 key、事件驅動失效與 TTL 保底。</p>
<p><a href="/blog/backend/knowledge-cards/cache-invalidation/" data-link-title="Cache Invalidation" data-link-desc="說明快取資料何時更新、刪除或重建，以及失效策略如何影響一致性">cache invalidation</a> 要和資料責任對齊。價格類資料適合事件驅動失效加短 TTL；商品描述可以長 TTL 加背景刷新；權限類資料要能在撤權後快速失效。</p>
<h3 id="cache-不一致的主要來源點">Cache 不一致的主要來源點</h3>
<p>規模化 cache 的不一致主要由 <em>topology 變動事件</em> 觸發、不是 TTL 設定。對應 <a href="/blog/backend/02-cache-redis/cases/meta-cache-consistency-upgrade/" data-link-title="2.C1 Meta：Cache Consistency 升級" data-link-desc="快取 invalidation 一致性如何從常見錯誤演進到高可信治理。">2.C1 Meta Cache Consistency Upgrade</a> — 案例指出 promotion、shard move、故障恢復是三類主要事件來源、傳統 invalidation 在大規模系統難以維持穩定。</p>
<p><strong>三類事件的典型機制</strong>（具體實作依 cluster 設計而異）：</p>
<ul>
<li><strong>Promotion / failover</strong>：primary 切到 replica 過程中、寫入順序可能跨節點不一致、replica 變 primary 後可能讀到舊資料</li>
<li><strong>Shard move / rebalance</strong>：cluster topology 變更時、部分 key 在搬遷窗口內可能讀到舊 shard 的副本</li>
<li><strong>故障恢復</strong>：節點重啟後、cache 從 backing store 重建、跟 application 寫入的新值可能交錯</li>
</ul>
<p>在這些事件中、cache 拓樸隨著事件改變、需要追蹤 mutation 收斂、不只清 key。Meta 的解法是把 <em>mutation tracing</em> 制度化、追蹤每次資料變動是否在所有 cache 副本都收斂。</p>
<h3 id="mutation-tracing-跟一致性指標">Mutation tracing 跟一致性指標</h3>
<p>Mutation tracing 是 <em>資料變動到所有 cache 副本收斂的時間軸</em> 追蹤、跟一般 cache hit rate 屬不同維度。常見的工程實踐指標（屬 case-derived 推論、非 Meta case 直接揭露具體 SLO）：</p>
<ul>
<li><strong>Inconsistency window</strong>：從 source-of-truth 寫入到所有 cache 副本反映的耗時（平均 / p99）</li>
<li><strong>Inconsistency rate</strong>：query 取到 stale 副本的比例</li>
<li><strong>Inconsistency duration distribution</strong>：stale 持續時間的分布（看長尾才能識別事故風險、平均值會掩蓋）</li>
</ul>
<p>這些指標要接到告警跟回退條件、用法接近一般 SLO（例：inconsistency window p99 超過 <em>服務可接受 stale window</em> 觸發保護動作）。具體門檻依業務型態定 — 付款 / 庫存 / 權限類資料的容忍可能在秒級、商品描述可能在分鐘級。</p>
<p>當 inconsistency window 突然拉長、可能是 invalidation pipeline 卡住或 cache topology 變更中、應觸發保護動作（停止寫入、降級到回源、或回退近期變更）。</p>
<p>對應 <a href="/blog/backend/04-observability/telemetry-data-quality/" data-link-title="4.17 Telemetry Data Quality" data-link-desc="把 missing signal、schema drift、sampling bias 與 timestamp skew 變成資料品質問題">4.17 telemetry data quality</a> — cache 一致性指標屬於 <em>資料品質指標</em>、要進 evidence chain、跟效能指標分開追蹤。</p>
<h2 id="origin-protection">Origin Protection</h2>
<p>Origin protection 的責任是避免 cache miss 把壓力集中打回資料庫或下游服務。快取越接近高流量路徑，越要把 miss 視為需要治理的事件。</p>
<p>保護策略包含：</p>
<ol>
<li><a href="/blog/backend/knowledge-cards/cache-warmup/" data-link-title="Cache Warmup" data-link-desc="說明服務啟動或活動前如何預先建立快取資料">cache warmup</a> 先建立熱門資料覆蓋。</li>
<li><a href="/blog/backend/knowledge-cards/singleflight/" data-link-title="Singleflight" data-link-desc="說明相同工作同時發生時如何合併成一次下游請求">singleflight</a> 或 request coalescing 合併同 key 回源。</li>
<li>對回源設 rate limit、timeout 與 fallback。</li>
<li>對短暫找不到的結果使用短 TTL negative cache。</li>
</ol>
<p>這些策略的共同目標是優先保護正式狀態來源，再提升命中率與延遲表現。</p>
<h2 id="跨區一致性窗口">跨區一致性窗口</h2>
<p>當 cache 跨多 region 部署、一致性問題從「副本 vs source-of-truth」變成「副本 vs 副本」。同一個用戶在不同 region 看到 cache 內容差異、可能影響業務邏輯（庫存超賣、配額超用、權限延遲）。規模化的 cache 把跨區一致性窗口跟區域容錯設計納入同一模型、不是分開治理（對應 <a href="/blog/backend/02-cache-redis/cases/netflix-evcache-global-cache-layer/" data-link-title="2.C6 Netflix：EVCache 全域快取層" data-link-desc="快取從本地層演進為跨區分散式能力的案例。">2.C6 Netflix EVCache</a> 跟 <a href="/blog/backend/02-cache-redis/cases/meta-mcrouter-global-cache-routing/" data-link-title="2.C2 Meta：mcrouter 與跨區快取路由" data-link-desc="快取從單點最佳化演進到分散式路由層的案例。">2.C2 Meta mcrouter</a>）。</p>
<p><strong>Strong sync</strong> 採每次寫入同步到所有 region、延遲高、可靠性高。適合付款 / 庫存 / 權限類資料 — 庫存超賣的代價是業務直接損失（賣出實際沒有的商品）、權限不一致的代價是越權或拒服務、付款延遲一致的代價是重複扣款。這些代價高到值得付跨 region <a href="/blog/backend/knowledge-cards/quorum/" data-link-title="Quorum" data-link-desc="分散式系統以多數節點同意作為提交或讀取有效性的門檻">quorum</a> 的 latency 成本。失敗代價路徑：跨 region quorum 不可達時 → 寫入失敗 → 用戶看到操作失敗、業務不繼續寫錯資料。</p>
<p><strong>Async with bounded staleness</strong> 寫入主 region、其他 region 在 N 秒內收斂、多數場景夠用、要明確 stale window。適合 B2B SaaS、社群動態、推薦資料 — 用戶可以接受短暫看到舊版內容、但長時間 stale 會影響體驗。失敗代價路徑：跨 region 同步 lag 增長 → 用戶看到不同版本內容 → 累積到 stale window 上限時觸發 alert 跟保護。</p>
<p><strong>Per-region cache</strong> 每 region 各自獨立、不跨區同步、靠 backing store 收斂。適合本地用戶為主的資料（區域電商、本地內容平台） — 同一用戶極少跨 region、跨區一致性需求低、為了少數情境付跨區同步成本不划算。失敗代價路徑：跨 region 操作的用戶看到 region 之間不一致 → 業務側手動補償或要求用戶重試。</p>
<p>判讀重點：選哪種跨區一致性跟「同一用戶會不會跨 region 操作」直接相關。全球漫遊用戶（旅遊、跨國商務）要更強的同步；本地用戶為主的服務可以 per-region。</p>
<h3 id="跨-cloud-部署的資料引力">跨 cloud 部署的資料引力</h3>
<p>當 application 跟 cache 不在同一 cloud / region、每次 cache lookup 吃跨網路 latency（視 region pair 而定、9.C35 觀察值為 5-30ms）。對「每次互動查多個 cache」的服務、5ms × 10 lookup = 50ms 額外延遲、用戶感受明顯。</p>
<p>對應 <a href="/blog/backend/09-performance-capacity/cases/snap-gcp-keydb-cross-cloud/" data-link-title="9.C35 Snap：GCP &#43; KeyDB 在 multi-cloud 架構下的低延遲快取" data-link-desc="Snap 用 GCP 上的 KeyDB cluster 減少跨 cloud cache 延遲、用 TPU 訓練廣告推薦模型">9.C35 Snap KeyDB cross-cloud</a> — Snap 把 KeyDB cache 放在 GCP 上、減少跨 cloud cache lookup latency。資料引力原則：data 在哪、cache 跟著去、跨 cloud 走 batch sync 降頻、應用與 cache 共置主資料 cloud。</p>
<p><strong>Multi-cloud cache 部署原則</strong>：</p>
<ul>
<li><strong>同 cloud 內</strong>：cache + application + DB 都在同一 cloud、cache lookup 在 ms 級內</li>
<li><strong>跨 cloud 採 batch sync 降頻</strong>：低頻、高延遲容忍的資料同步（每小時 / 每天）、應用本地讀 cache</li>
<li><strong>應用與 cache 共置主資料 cloud</strong>：高頻、低延遲容忍的路徑跟主資料同 cloud、避免跨 cloud RTT</li>
</ul>
<p>判讀重點：multi-cloud 架構的 cache 設計要先確定 data 主要在哪個 cloud、其他 cloud 的 application 要靠 batch sync 拿資料。Snap 從 zero-day 就在 GCP、近年走 multi-cloud 時、把 KeyDB 留在 GCP（data 一直在的地方）、避免反向部署引發的隱性 latency。違反這原則會踩到用戶層難以 debug 的延遲瓶頸。</p>
<h2 id="選型前判準">選型前判準</h2>
<p>快取服務選型前要先回答四個問題：</p>
<ol>
<li>快取值是可重建副本，還是被拿來做正式判斷。</li>
<li>每種值的 freshness window 是多久。</li>
<li>miss 時來源系統能承受多少回源 QPS。</li>
<li>錯誤資料要如何失效、降級與回寫事故證據。</li>
</ol>
<p>這些問題先回答後，才進入 Redis data structure、Memcached 設計、Valkey 相容性或 managed cache 的討論。</p>
<h2 id="實體服務討論承接點">實體服務討論承接點</h2>
<p>實體快取服務文章要承接本篇的 copy boundary 與 freshness。Redis、Valkey、Memcached、DragonflyDB 或 managed cache 的比較，應先問它們如何支援 key 失效、TTL、eviction、warmup、回源保護與觀測訊號，再進入 command 或部署細節。</p>
<p>若服務需要嚴格 freshness，後續文章要比較事件失效、版本化 key、原子更新與 fallback 能力。若服務主要面對高讀取壓力，後續文章要比較連線模型、hot key 保護、memory policy 與 cluster/sharding 行為。若服務需要事故回退，後續文章要比較 key migration、dual read、metrics 與 <a href="/blog/backend/knowledge-cards/rollback-window/" data-link-title="Rollback Window" data-link-desc="說明變更進入 production 後還能用哪種方式回退或改路線的時間與條件">rollback window</a>。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>要進一步處理讀寫流程，接著讀 <a href="/blog/backend/02-cache-redis/cache-aside/" data-link-title="2.2 cache aside 與失效策略" data-link-desc="整理 read-through 思路、cache miss 與 invalidation">2.2 cache aside 與失效策略</a>。要把 freshness 放進 rollout 與停損，接著讀 <a href="/blog/backend/02-cache-redis/cache-migration-stampede-rollback/" data-link-title="2.9 Cache Migration 與 Stampede Rollback（實作示範）" data-link-desc="以商品詳情與價格快取示範 cache migration 如何交付 evidence package、release gate 與 incident decision log。">2.9 Cache Migration 與 Stampede Rollback</a>。</p>
]]></content:encoded></item><item><title>2.8 Cache Data Shape 與 Access Pattern</title><link>https://tarrragon.github.io/blog/backend/02-cache-redis/cache-data-shape-access-pattern/</link><pubDate>Mon, 11 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/02-cache-redis/cache-data-shape-access-pattern/</guid><description>&lt;p>Cache data shape 與 access pattern 的核心責任是讓快取資料結構反映服務語意。進入 Redis command 或特定快取服務前，讀者需要先知道 key、value、hash、set、sorted set、stream 與多層 cache 各自適合承擔哪種讀取責任。&lt;/p>
&lt;h2 id="key-space">Key Space&lt;/h2>
&lt;p>Key space 的責任是定義快取資料如何被定位、分組、失效與遷移。key 命名要包含資料責任、版本、租戶或區域等必要維度，讓失效與回退可控。&lt;/p>
&lt;p>常見 key 維度包含：&lt;/p>
&lt;ol>
&lt;li>資料類型，例如 &lt;code>product&lt;/code>、&lt;code>user-permission&lt;/code>、&lt;code>quota&lt;/code>。&lt;/li>
&lt;li>版本，例如 &lt;code>v1&lt;/code>、&lt;code>v2&lt;/code>。&lt;/li>
&lt;li>租戶或區域，例如 tenant、region、locale。&lt;/li>
&lt;li>實體識別，例如 product id、user id。&lt;/li>
&lt;/ol>
&lt;p>key 缺少版本時，cache migration 會變成破壞性替換。key 缺少租戶或區域時，失效範圍會被放大。&lt;/p>
&lt;h2 id="value-shape">Value Shape&lt;/h2>
&lt;p>Value shape 的責任是定義快取值的語意與演進方式。完整 JSON blob 適合一次讀取完整資料，但欄位更新與版本相容成本高；hash 適合欄位局部更新，但需要明確欄位責任；set 與 sorted set 適合集合與排名；counter 適合限流或計數。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>資料形狀&lt;/th>
 &lt;th>適合場景&lt;/th>
 &lt;th>主要風險&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>string / blob&lt;/td>
 &lt;td>商品詳情、設定快照&lt;/td>
 &lt;td>schema 變更容易破壞相容&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>hash&lt;/td>
 &lt;td>使用者摘要、商品局部欄位&lt;/td>
 &lt;td>欄位責任不清會變成半正式狀態&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>set&lt;/td>
 &lt;td>membership、權限集合&lt;/td>
 &lt;td>stale membership 可能造成越權&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>sorted set&lt;/td>
 &lt;td>排名、時間排序、優先級&lt;/td>
 &lt;td>score 語意錯誤會造成排序漂移&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>counter&lt;/td>
 &lt;td>rate limit、配額&lt;/td>
 &lt;td>原子性與過期窗口要對齊&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>stream&lt;/td>
 &lt;td>輕量事件流&lt;/td>
 &lt;td>容易和正式 message queue 責任混淆&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>資料形狀的本質是服務責任選擇，Redis 語法是落地方式。&lt;/p>
&lt;p>&lt;code>string / blob&lt;/code> 的判讀重點是整包資料是否需要一起讀取與一起失效。&lt;code>hash&lt;/code> 的判讀重點是欄位是否真的能獨立更新。&lt;code>set&lt;/code> 與 &lt;code>sorted set&lt;/code> 的判讀重點是 membership 或排序錯誤會造成什麼後果。&lt;code>counter&lt;/code> 的判讀重點是原子性與過期窗口。&lt;code>stream&lt;/code> 的判讀重點是這條路徑是否已經接近 message queue 責任。&lt;/p>
&lt;h2 id="access-pattern">Access Pattern&lt;/h2>
&lt;p>Access pattern 的責任是定義快取面對的讀寫節奏。高讀低寫、熱點讀取、短期活動尖峰、租戶隔離與跨區讀取，都會影響 key 設計與容量策略。&lt;/p>
&lt;p>高讀低寫適合長 TTL 與背景刷新；熱點讀取需要 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/hot-key/" data-link-title="Hot Key" data-link-desc="說明單一 key 承受大量讀寫時如何形成容量瓶頸">hot key&lt;/a> 保護；短期尖峰需要 warmup 與分散過期；多租戶場景需要避免單租戶 key 壓垮共享 cache。&lt;/p>
&lt;h2 id="multi-layer-cache">Multi-layer Cache&lt;/h2>
&lt;p>多層快取的責任是分散延遲與來源壓力。常見層次包含 process local cache、distributed cache、CDN 或 search/read model。每一層都需要定義 freshness、失效來源與 fallback。&lt;/p>
&lt;p>多層 cache 的主要風險是 stale 疊加。local cache stale、distributed cache stale 與 CDN stale 缺少共同失效策略時，讀者看到的錯誤會很難追。&lt;/p>
&lt;h3 id="ml-feature-store-的多層-cache-設計模式">ML feature store 的多層 cache 設計模式&lt;/h3>
&lt;p>ML inference 場景的 feature lookup 是多層 cache 的典型應用。&lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/tubi-elasticache-ml-feature-store/" data-link-title="9.C25 Tubi：從 ScyllaDB 遷到 ElastiCache、ML feature store 達 sub-10ms p99" data-link-desc="Tubi 把 ML 推薦的 feature store 從 ScyllaDB 遷到 ElastiCache for Redis、99 百分位延遲降到 10ms 以下">9.C25 Tubi feature store&lt;/a> 的策略段提出 &lt;em>可重用做法&lt;/em>：用 L1 in-process cache + L2 distributed cache + L3 持久 store 三層。Tubi 實做的是把 feature store 從 ScyllaDB 遷到 ElastiCache（屬於 L2 層的選擇）、p99 &amp;lt; 10ms；三層架構是策略段推導出的通用設計、不一定 Tubi 完整實做。&lt;/p></description><content:encoded><![CDATA[<p>Cache data shape 與 access pattern 的核心責任是讓快取資料結構反映服務語意。進入 Redis command 或特定快取服務前，讀者需要先知道 key、value、hash、set、sorted set、stream 與多層 cache 各自適合承擔哪種讀取責任。</p>
<h2 id="key-space">Key Space</h2>
<p>Key space 的責任是定義快取資料如何被定位、分組、失效與遷移。key 命名要包含資料責任、版本、租戶或區域等必要維度，讓失效與回退可控。</p>
<p>常見 key 維度包含：</p>
<ol>
<li>資料類型，例如 <code>product</code>、<code>user-permission</code>、<code>quota</code>。</li>
<li>版本，例如 <code>v1</code>、<code>v2</code>。</li>
<li>租戶或區域，例如 tenant、region、locale。</li>
<li>實體識別，例如 product id、user id。</li>
</ol>
<p>key 缺少版本時，cache migration 會變成破壞性替換。key 缺少租戶或區域時，失效範圍會被放大。</p>
<h2 id="value-shape">Value Shape</h2>
<p>Value shape 的責任是定義快取值的語意與演進方式。完整 JSON blob 適合一次讀取完整資料，但欄位更新與版本相容成本高；hash 適合欄位局部更新，但需要明確欄位責任；set 與 sorted set 適合集合與排名；counter 適合限流或計數。</p>
<table>
  <thead>
      <tr>
          <th>資料形狀</th>
          <th>適合場景</th>
          <th>主要風險</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>string / blob</td>
          <td>商品詳情、設定快照</td>
          <td>schema 變更容易破壞相容</td>
      </tr>
      <tr>
          <td>hash</td>
          <td>使用者摘要、商品局部欄位</td>
          <td>欄位責任不清會變成半正式狀態</td>
      </tr>
      <tr>
          <td>set</td>
          <td>membership、權限集合</td>
          <td>stale membership 可能造成越權</td>
      </tr>
      <tr>
          <td>sorted set</td>
          <td>排名、時間排序、優先級</td>
          <td>score 語意錯誤會造成排序漂移</td>
      </tr>
      <tr>
          <td>counter</td>
          <td>rate limit、配額</td>
          <td>原子性與過期窗口要對齊</td>
      </tr>
      <tr>
          <td>stream</td>
          <td>輕量事件流</td>
          <td>容易和正式 message queue 責任混淆</td>
      </tr>
  </tbody>
</table>
<p>資料形狀的本質是服務責任選擇，Redis 語法是落地方式。</p>
<p><code>string / blob</code> 的判讀重點是整包資料是否需要一起讀取與一起失效。<code>hash</code> 的判讀重點是欄位是否真的能獨立更新。<code>set</code> 與 <code>sorted set</code> 的判讀重點是 membership 或排序錯誤會造成什麼後果。<code>counter</code> 的判讀重點是原子性與過期窗口。<code>stream</code> 的判讀重點是這條路徑是否已經接近 message queue 責任。</p>
<h2 id="access-pattern">Access Pattern</h2>
<p>Access pattern 的責任是定義快取面對的讀寫節奏。高讀低寫、熱點讀取、短期活動尖峰、租戶隔離與跨區讀取，都會影響 key 設計與容量策略。</p>
<p>高讀低寫適合長 TTL 與背景刷新；熱點讀取需要 <a href="/blog/backend/knowledge-cards/hot-key/" data-link-title="Hot Key" data-link-desc="說明單一 key 承受大量讀寫時如何形成容量瓶頸">hot key</a> 保護；短期尖峰需要 warmup 與分散過期；多租戶場景需要避免單租戶 key 壓垮共享 cache。</p>
<h2 id="multi-layer-cache">Multi-layer Cache</h2>
<p>多層快取的責任是分散延遲與來源壓力。常見層次包含 process local cache、distributed cache、CDN 或 search/read model。每一層都需要定義 freshness、失效來源與 fallback。</p>
<p>多層 cache 的主要風險是 stale 疊加。local cache stale、distributed cache stale 與 CDN stale 缺少共同失效策略時，讀者看到的錯誤會很難追。</p>
<h3 id="ml-feature-store-的多層-cache-設計模式">ML feature store 的多層 cache 設計模式</h3>
<p>ML inference 場景的 feature lookup 是多層 cache 的典型應用。<a href="/blog/backend/09-performance-capacity/cases/tubi-elasticache-ml-feature-store/" data-link-title="9.C25 Tubi：從 ScyllaDB 遷到 ElastiCache、ML feature store 達 sub-10ms p99" data-link-desc="Tubi 把 ML 推薦的 feature store 從 ScyllaDB 遷到 ElastiCache for Redis、99 百分位延遲降到 10ms 以下">9.C25 Tubi feature store</a> 的策略段提出 <em>可重用做法</em>：用 L1 in-process cache + L2 distributed cache + L3 持久 store 三層。Tubi 實做的是把 feature store 從 ScyllaDB 遷到 ElastiCache（屬於 L2 層的選擇）、p99 &lt; 10ms；三層架構是策略段推導出的通用設計、不一定 Tubi 完整實做。</p>
<p><strong>通用三層模式</strong>（推導自 9.C25 策略段、實際分層深度視 workload）：</p>
<ul>
<li><strong>L1：in-process cache</strong>：跟 application 同一 process、避免 network hop、適合最熱的少量 features</li>
<li><strong>L2：distributed cache</strong>（ElastiCache / Memcached）：跨 application instance 共享、能擴容、Tubi 在這層用 ElastiCache 達 p99 &lt; 10ms</li>
<li><strong>L3：持久 store</strong>（ScyllaDB / DynamoDB / S3 + Parquet）：全量資料、cache miss 時的 fallback</li>
</ul>
<p>判讀重點：每層的 latency budget 跟 stale window 都應依 workload 跟業務容忍度設定。相對序列是 L1 stale window 最嚴、L2 中等、L3 為 source-of-truth 或可重算來源。三層 stale 若無共同失效策略、業務代價會落到 <em>推薦結果不穩定</em>、用戶看到不同 session 推不同內容。</p>
<h3 id="跨-cloud-部署的資料引力路由見-27">跨 cloud 部署的資料引力（路由：見 2.7）</h3>
<p>跨 cloud cache 部署的 <em>資料引力</em> 原則跟 <em>跨區一致性</em> 議題密切相關、主寫場域是 <a href="/blog/backend/02-cache-redis/cache-copy-freshness-boundary/" data-link-title="2.7 Cache Copy Boundary 與 Freshness" data-link-desc="說明快取何時只是可重建副本，何時會影響交易、權限或配額正確性。">2.7 cache copy boundary 的跨區一致性窗口</a>。本章從 <em>data shape / access pattern</em> 角度補充：當 cache value 包含跨 region 共享的業務資料時、access pattern 自然偏向 <em>同 cloud read</em> + <em>跨 cloud batch sync</em>、不適合即時跨 cloud lookup。詳見 9.C35 Snap KeyDB 案例。</p>
<h2 id="選型前判準">選型前判準</h2>
<p>快取資料形狀選型前要先回答：</p>
<ol>
<li>讀取是單 key、批次 key、集合、排序還是計數。</li>
<li>寫入是整體替換、局部更新、追加還是原子遞增。</li>
<li>失效是單 key、群組、版本、租戶還是全域。</li>
<li>資料結構是否會讓快取承擔正式狀態責任。</li>
</ol>
<p>這些問題決定後續要比較 Redis data type、Memcached blob、CDN cache 或應用端 local cache。</p>
<h2 id="實體服務討論承接點">實體服務討論承接點</h2>
<p>實體快取服務文章要承接本篇的 data shape 與 access pattern。Redis/Valkey 的 hash、set、sorted set、stream 能表達多種資料形狀；Memcached 偏向簡單 key/value blob；CDN 與 local cache 則承擔不同層次的讀取加速。比較服務時要先問 access pattern，再問語法。</p>
<p>若讀取是單 key 或 blob，後續文章要比較 serialization、value size、TTL 與 eviction。若讀取是集合、排名或計數，後續文章要比較資料結構、原子性與容量行為。若讀取跨多層 cache，後續文章要比較失效傳播、stale 疊加與 observability。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>要處理 TTL 與容量策略，接著讀 <a href="/blog/backend/02-cache-redis/ttl-eviction/" data-link-title="2.3 TTL 與 eviction" data-link-desc="整理過期策略、容量控制與熱點資料">2.3 TTL 與 eviction</a>。要看選定形狀後各型別的操作語意、原子性與記憶體曲線，接著讀 <a href="/blog/backend/02-cache-redis/redis-data-types/" data-link-title="2.11 Redis data types 實作" data-link-desc="說明 sorted set、bitmap、HyperLogLog、counter 與 hash 各自承擔的服務語意、容量行為與原子性邊界">2.11 Redis data types 實作</a>。要處理 presence 類即時狀態，接著讀 <a href="/blog/backend/02-cache-redis/presence-store/" data-link-title="2.5 presence store 與即時狀態" data-link-desc="整理線上狀態、跨節點查詢與過期清理">2.5 presence store 與即時狀態</a>。</p>
]]></content:encoded></item><item><title>2.9 Cache Migration 與 Stampede Rollback（實作示範）</title><link>https://tarrragon.github.io/blog/backend/02-cache-redis/cache-migration-stampede-rollback/</link><pubDate>Mon, 11 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/02-cache-redis/cache-migration-stampede-rollback/</guid><description>&lt;p>Cache migration 與 stampede rollback 的核心責任是讓快取副本在格式、鍵名與覆蓋範圍演進時，仍能保護 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">source of truth&lt;/a> 不被回源流量打穿。這篇以商品詳情與價格快取為例，示範如何把 key schema 演進、freshness 控制、warmup、放行與停損交給可交接 artifact。&lt;/p>
&lt;h2 id="服務路徑與失敗代價">服務路徑與失敗代價&lt;/h2>
&lt;p>這條路徑是 &lt;code>product-page -&amp;gt; cache -&amp;gt; product-db/pricing-service&lt;/code>。商品頁會同時讀取描述、價格、庫存與促銷標籤，快取需要在低延遲與正確性間平衡。&lt;/p>
&lt;p>這篇示範的變更是把舊 key &lt;code>product:{id}&lt;/code> 演進到版本化 key &lt;code>product:v2:{region}:{id}&lt;/code>。演進動機是支援區域價格與促銷欄位拆分，避免舊序列化格式在多區域路徑下持續膨脹。&lt;/p>
&lt;p>失敗代價分三層：描述欄位 stale 主要影響體驗，價格 stale 直接影響交易正確性，回源尖峰會擠壓正式狀態查詢容量。這三層要分別設 freshness、gate 與 rollback 條件。&lt;/p>
&lt;h2 id="key-schema-與相容窗口">Key Schema 與相容窗口&lt;/h2>
&lt;p>Key schema 的責任是讓新舊值可共存，不讓切換變成一次性替換。這條路徑採 &lt;code>dual-read&lt;/code> 再 &lt;code>dual-write&lt;/code> 再 &lt;code>single-read-v2&lt;/code>：&lt;/p>
&lt;ol>
&lt;li>讀取先查 &lt;code>v2&lt;/code>，miss 再查舊 key，最後才回源。&lt;/li>
&lt;li>回填期間新舊 key 同時寫入，保留可回退窗口。&lt;/li>
&lt;li>&lt;code>v2&lt;/code> 命中穩定後，關閉舊 key 寫入，保留舊 key 讀 fallback 一段時間。&lt;/li>
&lt;/ol>
&lt;p>相容窗口的重點是讀語意一致。舊 key 與新 key 的值結構不同時，要先有轉換層，避免同一商品在不同 API path 回傳不同語意。&lt;/p>
&lt;h2 id="freshness-window-與資料分級">Freshness Window 與資料分級&lt;/h2>
&lt;p>Freshness window 的責任是把 stale 代價寫成可執行規則，而不是只寫全域 TTL。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>資料欄位&lt;/th>
 &lt;th>freshness window&lt;/th>
 &lt;th>原因&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>商品描述&lt;/td>
 &lt;td>5-15 分鐘&lt;/td>
 &lt;td>體驗導向，短時間 stale 可接受&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>促銷標籤&lt;/td>
 &lt;td>1-3 分鐘&lt;/td>
 &lt;td>促銷切換頻繁，錯誤會影響轉換率&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>庫存可售狀態&lt;/td>
 &lt;td>10-30 秒&lt;/td>
 &lt;td>超賣風險高，需接近即時&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>價格與幣別&lt;/td>
 &lt;td>5-15 秒&lt;/td>
 &lt;td>交易正確性高風險，需短 TTL 並搭配事件失效&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>失敗回源保護值&lt;/td>
 &lt;td>3-10 秒&lt;/td>
 &lt;td>下游暫時異常時保護來源，避免反覆 miss 放大回源壓力&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/ttl/" data-link-title="TTL" data-link-desc="說明資料過期時間如何影響快取新鮮度、成本與一致性">TTL&lt;/a> 與事件失效要同時存在。TTL 控上限，事件失效控即時性；只用其一都會造成隱性風險。&lt;/p>
&lt;h2 id="warmup-與回源保護">Warmup 與回源保護&lt;/h2>
&lt;p>Warmup 的責任是先建立新 key 的可服務覆蓋率，再擴大流量。這條路徑採分批 warmup：&lt;code>region -&amp;gt; category -&amp;gt; hot key list -&amp;gt; 全量&lt;/code>。&lt;/p>
&lt;p>Warmup completion 的判讀訊號：&lt;/p>
&lt;ol>
&lt;li>&lt;code>v2&lt;/code> 命中率在目標區間連續穩定。&lt;/li>
&lt;li>origin QPS 未突破上限。&lt;/li>
&lt;li>熱門 key 的 miss 尖峰已被抹平。&lt;/li>
&lt;/ol>
&lt;p>回源保護策略：&lt;/p>
&lt;ol>
&lt;li>以 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/singleflight/" data-link-title="Singleflight" data-link-desc="說明相同工作同時發生時如何合併成一次下游請求">singleflight&lt;/a> 合併同 key 同時 miss。&lt;/li>
&lt;li>對回源查詢設 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/rate-limit/" data-link-title="Rate Limit" data-link-desc="說明限流如何保護服務入口、下游依賴與租戶公平性">rate limit&lt;/a> 與超時。&lt;/li>
&lt;li>回源失敗時寫入短 TTL 降級值，避免瞬時重試風暴。&lt;/li>
&lt;li>針對熱門 key 在切換前做預熱與分散過期。&lt;/li>
&lt;/ol>
&lt;h3 id="cache-切換引發-stampede-的真實事故結構">Cache 切換引發 stampede 的真實事故結構&lt;/h3>
&lt;p>對應 &lt;a href="https://tarrragon.github.io/blog/backend/02-cache-redis/cases/failure-cache-stampede-rollout-regression/" data-link-title="2.C9 反例：快取切換引發 Stampede 回歸" data-link-desc="快取策略切換若缺乏保護，會導致回源壓力與錯誤率連鎖上升。">2.C9 反例：Cache Stampede Rollout Regression&lt;/a> — 看似低風險的 cache key 或 TTL 切換、若回源保護不足、會讓熱門資料同時 miss。事故結構屬「讀取路徑同時失去緩衝」的系統性失敗、不只是單一 key 問題。&lt;/p>
&lt;p>切換引發 stampede 的三個放大機制會 &lt;em>疊加&lt;/em>、不是獨立失效。在 read-heavy 規模化服務（如 Tinder 47M MAU、Tubi feature store）這類場景、典型疊加順序：重試放大先觸發 → 下游放大跟進 → 應用層放大終結：&lt;/p></description><content:encoded><![CDATA[<p>Cache migration 與 stampede rollback 的核心責任是讓快取副本在格式、鍵名與覆蓋範圍演進時，仍能保護 <a href="/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">source of truth</a> 不被回源流量打穿。這篇以商品詳情與價格快取為例，示範如何把 key schema 演進、freshness 控制、warmup、放行與停損交給可交接 artifact。</p>
<h2 id="服務路徑與失敗代價">服務路徑與失敗代價</h2>
<p>這條路徑是 <code>product-page -&gt; cache -&gt; product-db/pricing-service</code>。商品頁會同時讀取描述、價格、庫存與促銷標籤，快取需要在低延遲與正確性間平衡。</p>
<p>這篇示範的變更是把舊 key <code>product:{id}</code> 演進到版本化 key <code>product:v2:{region}:{id}</code>。演進動機是支援區域價格與促銷欄位拆分，避免舊序列化格式在多區域路徑下持續膨脹。</p>
<p>失敗代價分三層：描述欄位 stale 主要影響體驗，價格 stale 直接影響交易正確性，回源尖峰會擠壓正式狀態查詢容量。這三層要分別設 freshness、gate 與 rollback 條件。</p>
<h2 id="key-schema-與相容窗口">Key Schema 與相容窗口</h2>
<p>Key schema 的責任是讓新舊值可共存，不讓切換變成一次性替換。這條路徑採 <code>dual-read</code> 再 <code>dual-write</code> 再 <code>single-read-v2</code>：</p>
<ol>
<li>讀取先查 <code>v2</code>，miss 再查舊 key，最後才回源。</li>
<li>回填期間新舊 key 同時寫入，保留可回退窗口。</li>
<li><code>v2</code> 命中穩定後，關閉舊 key 寫入，保留舊 key 讀 fallback 一段時間。</li>
</ol>
<p>相容窗口的重點是讀語意一致。舊 key 與新 key 的值結構不同時，要先有轉換層，避免同一商品在不同 API path 回傳不同語意。</p>
<h2 id="freshness-window-與資料分級">Freshness Window 與資料分級</h2>
<p>Freshness window 的責任是把 stale 代價寫成可執行規則，而不是只寫全域 TTL。</p>
<table>
  <thead>
      <tr>
          <th>資料欄位</th>
          <th>freshness window</th>
          <th>原因</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>商品描述</td>
          <td>5-15 分鐘</td>
          <td>體驗導向，短時間 stale 可接受</td>
      </tr>
      <tr>
          <td>促銷標籤</td>
          <td>1-3 分鐘</td>
          <td>促銷切換頻繁，錯誤會影響轉換率</td>
      </tr>
      <tr>
          <td>庫存可售狀態</td>
          <td>10-30 秒</td>
          <td>超賣風險高，需接近即時</td>
      </tr>
      <tr>
          <td>價格與幣別</td>
          <td>5-15 秒</td>
          <td>交易正確性高風險，需短 TTL 並搭配事件失效</td>
      </tr>
      <tr>
          <td>失敗回源保護值</td>
          <td>3-10 秒</td>
          <td>下游暫時異常時保護來源，避免反覆 miss 放大回源壓力</td>
      </tr>
  </tbody>
</table>
<p><a href="/blog/backend/knowledge-cards/ttl/" data-link-title="TTL" data-link-desc="說明資料過期時間如何影響快取新鮮度、成本與一致性">TTL</a> 與事件失效要同時存在。TTL 控上限，事件失效控即時性；只用其一都會造成隱性風險。</p>
<h2 id="warmup-與回源保護">Warmup 與回源保護</h2>
<p>Warmup 的責任是先建立新 key 的可服務覆蓋率，再擴大流量。這條路徑採分批 warmup：<code>region -&gt; category -&gt; hot key list -&gt; 全量</code>。</p>
<p>Warmup completion 的判讀訊號：</p>
<ol>
<li><code>v2</code> 命中率在目標區間連續穩定。</li>
<li>origin QPS 未突破上限。</li>
<li>熱門 key 的 miss 尖峰已被抹平。</li>
</ol>
<p>回源保護策略：</p>
<ol>
<li>以 <a href="/blog/backend/knowledge-cards/singleflight/" data-link-title="Singleflight" data-link-desc="說明相同工作同時發生時如何合併成一次下游請求">singleflight</a> 合併同 key 同時 miss。</li>
<li>對回源查詢設 <a href="/blog/backend/knowledge-cards/rate-limit/" data-link-title="Rate Limit" data-link-desc="說明限流如何保護服務入口、下游依賴與租戶公平性">rate limit</a> 與超時。</li>
<li>回源失敗時寫入短 TTL 降級值，避免瞬時重試風暴。</li>
<li>針對熱門 key 在切換前做預熱與分散過期。</li>
</ol>
<h3 id="cache-切換引發-stampede-的真實事故結構">Cache 切換引發 stampede 的真實事故結構</h3>
<p>對應 <a href="/blog/backend/02-cache-redis/cases/failure-cache-stampede-rollout-regression/" data-link-title="2.C9 反例：快取切換引發 Stampede 回歸" data-link-desc="快取策略切換若缺乏保護，會導致回源壓力與錯誤率連鎖上升。">2.C9 反例：Cache Stampede Rollout Regression</a> — 看似低風險的 cache key 或 TTL 切換、若回源保護不足、會讓熱門資料同時 miss。事故結構屬「讀取路徑同時失去緩衝」的系統性失敗、不只是單一 key 問題。</p>
<p>切換引發 stampede 的三個放大機制會 <em>疊加</em>、不是獨立失效。在 read-heavy 規模化服務（如 Tinder 47M MAU、Tubi feature store）這類場景、典型疊加順序：重試放大先觸發 → 下游放大跟進 → 應用層放大終結：</p>
<ul>
<li><strong>重試放大</strong>：用戶請求 miss、應用層或 client SDK 內建重試、每次重試又 miss、單一用戶請求變多次 origin QPS</li>
<li><strong>下游放大</strong>：cache miss 同時打到 DB、DB 變慢、應用對 cache 設的 timeout 又觸發新 miss、回到 DB 更慢、形成正向循環</li>
<li><strong>應用層放大</strong>：等待 cache 的 request 堆積、application thread / connection pool 滿、新請求被拒、被拒的請求觸發更多重試</li>
</ul>
<p>判讀重點：stampede 的早期訊號通常出現在下游 origin（DB QPS 突然超 baseline 數倍）跟 application（latency p99 拉高、request queue length 增加）、不一定先在 cache 層看到。cache hit rate 顯示異常時、事故通常已在中後段。</p>
<h3 id="切換順序決定-stampede-風險">切換順序決定 stampede 風險</h3>
<p>對應 <a href="/blog/backend/02-cache-redis/cases/contrast-cache-strategy-by-scale/" data-link-title="2.C10 對照：規模差異下的快取策略" data-link-desc="同一快取策略在小中大型服務下會產生不同風險。">2.C10 對照：規模差異下的快取策略</a> — 切換順序（先改 key 結構 vs 先改 TTL）會決定是否出現 stampede 連鎖反應、特別在中型服務同時承受活動流量跟版本切換時。</p>
<p><strong>安全切換順序</strong>（dual-read 模式、每步停損點不同）：</p>
<ol>
<li><strong>新 key 寫入啟用</strong>：應用層同時寫舊 key + 新 key、讀路徑不變。停損點是「寫入失敗率」、若雙寫失敗率超基線、回退停止啟用。</li>
<li><strong>新 key 命中觀察</strong>：讀路徑加入 v2 first / fallback to v1 邏輯、v2 命中率隨自然回填爬升。停損點是「v2 hit rate 爬升曲線」、若曲線停滯、表示 warmup 沒擴散到熱資料、要先 manual warmup。</li>
<li><strong>舊 key 命中率穩定下降</strong>：表示新 key 自然 warmup 完成、可進入下一階段。停損點是「舊 key hit rate 是否真的降到目標」、不能只看 v2 hit rate。</li>
<li><strong>舊 key 寫入停止</strong>：只寫 v2、舊 key 自然 TTL 過期。停損點是「v2 唯一寫入是否穩定」、若出現 v2 寫入失敗、回退到雙寫。</li>
<li><strong>舊 key 讀 fallback 移除</strong>：完全切到 v2 only。停損點是「v2 hit rate 是否已達切換前舊 key 水位」、否則 fallback 移除後直接回源。</li>
</ol>
<p><strong>應該注意的反模式</strong>（會引發 stampede）：</p>
<ul>
<li>應先 warmup 新 key 再刪除舊 key、避免所有讀立即 miss</li>
<li>應拆維度切換（key OR TTL OR 序列化各自獨立）、避免多變化疊加讓 debug 困難</li>
<li>應先在低流量 region 試跑、再擴大到全量、避免事故時無回退時間</li>
</ul>
<p>判讀順序：每次切換只動 <em>一個維度</em>（key OR TTL OR 序列化）、先在低流量 region / tenant 試跑、命中率穩定後再擴大。在 Shopify 序列化遷移（<a href="/blog/backend/02-cache-redis/cases/shopify-cache-serialization-migration/" data-link-title="2.C3 Shopify：快取序列化格式遷移" data-link-desc="快取 payload 從 Marshal 轉 MessagePack 的遷移策略。">2.C3</a>）類場景、停損 KPI 是「新格式編碼成功率」+「舊格式 fallback 觸發率」；在 Tinder 類 schema 變化頻繁場景、停損 KPI 是「v2 cache hit rate 是否在預估 warmup 時間內達標」。對應 <a href="/blog/backend/09-performance-capacity/cases/zomato-tidb-to-dynamodb-migration/" data-link-title="9.C20 Zomato：從 TiDB 遷移到 DynamoDB、吞吐 4 倍、延遲降 90%、成本減 50%" data-link-desc="Zomato 帳單系統從 TiDB 遷移到 DynamoDB、吞吐 2K→8K RPM、延遲降 90%、成本減 50%">9.C20 Zomato</a> 跟 <a href="/blog/backend/01-database/schema-migration-rollout-evidence/" data-link-title="1.7 Schema Migration Rollout 證據（Schema Migration Rollout Evidence）實作示範" data-link-desc="以訂單付款狀態欄位演進示範 schema migration 如何產出 evidence、release gate 與 incident decision log。">1.7 Schema Migration Rollout Evidence</a> 的同類 expand-contract 思維。</p>
<h3 id="schema-變更引發的隱性-cache-invalidation路由見-27">Schema 變更引發的隱性 cache invalidation（路由：見 2.7）</h3>
<p>Cache invalidation <em>模型</em> 主寫於 <a href="/blog/backend/02-cache-redis/cache-copy-freshness-boundary/" data-link-title="2.7 Cache Copy Boundary 與 Freshness" data-link-desc="說明快取何時只是可重建副本，何時會影響交易、權限或配額正確性。">2.7 cache copy boundary 的 Invalidation 段</a>；本章從 migration <em>實作步驟</em> 角度補充：schema migration 是 cache stampede 的隱藏觸發點。<a href="/blog/backend/09-performance-capacity/cases/tinder-elasticache-valkey-matching/" data-link-title="9.C6 Tinder：ElastiCache for Valkey 撐 4700 萬月活的配對引擎" data-link-desc="Tinder 用 Amazon ElastiCache for Valkey 提供配對引擎所需的次毫秒延遲快取層">9.C6 Tinder</a> 案例的警惕段提出 <em>風險推測</em>：「configurable matching」業務邏輯複雜、快取資料的 schema 變化頻繁、一個 schema 變更可能引發 cache invalidation 風險。</p>
<p>Schema 變化讓 cache 失效的三種模式（屬工程實踐推導、非案例直接揭露）：</p>
<ul>
<li><strong>欄位重命名 / 刪除</strong>：舊 cache value 反序列化失敗、application 視為 miss、全部回源</li>
<li><strong>type 變更</strong>（int → string、enum 增 case）：反序列化可能成功但語意錯、業務邏輯踩錯</li>
<li><strong>序列化格式換</strong>（Marshal → MessagePack）：舊格式無法用新 decoder 讀、對應 <a href="/blog/backend/02-cache-redis/cases/shopify-cache-serialization-migration/" data-link-title="2.C3 Shopify：快取序列化格式遷移" data-link-desc="快取 payload 從 Marshal 轉 MessagePack 的遷移策略。">2.C3 Shopify</a> 的雙軌策略</li>
</ul>
<p><strong>Migration 實作步驟</strong>（按優先序）：</p>
<ol>
<li><strong>Schema migration 前盤點 cache key</strong>（最先）：哪些 cache 包含這個 schema 的資料、估算 invalid 範圍。沒這步無法估算 warmup 計畫規模。</li>
<li><strong>大規模 schema migration 配 cache warmup 計畫</strong>：預先 warmup、避免用戶觸發 cache miss。warmup 計畫主寫於本章的「Warmup 與回源保護」段。</li>
<li><strong>新欄位用 versioned key</strong>（同步進行）：<code>product:v2:{id}</code> 跟 <code>product:v1:{id}</code> 並存、避免雙寫干擾。對應 <a href="/blog/backend/02-cache-redis/cases/shopify-cache-serialization-migration/" data-link-title="2.C3 Shopify：快取序列化格式遷移" data-link-desc="快取 payload 從 Marshal 轉 MessagePack 的遷移策略。">2.C3 Shopify 雙軌策略</a>。</li>
<li><strong>降級 fallback</strong>（最後保險）：cache miss 後 origin 也準備好被打、避免假設「cache hit rate 永遠維持高水位」。對應本章「回源保護策略」段。</li>
</ol>
<p>判讀重點：四步應同步落地、缺一個就會在 migration 期間踩 stampede。一致性 invalidation 模型回到 <a href="/blog/backend/02-cache-redis/cache-copy-freshness-boundary/" data-link-title="2.7 Cache Copy Boundary 與 Freshness" data-link-desc="說明快取何時只是可重建副本，何時會影響交易、權限或配額正確性。">2.7</a>。</p>
<h2 id="rollout--cutover--rollback">Rollout / Cutover / Rollback</h2>
<p>Rollout 的責任是把快取切換拆成可停損批次，不把風險一次放大。</p>
<table>
  <thead>
      <tr>
          <th>階段</th>
          <th>判讀重點</th>
          <th>停損動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Dual read</td>
          <td><code>v2</code> miss 是否快速收斂</td>
          <td>維持舊 key 讀 fallback，暫停擴批</td>
      </tr>
      <tr>
          <td>Dual write</td>
          <td>新舊值語意是否一致</td>
          <td>停新格式寫入，保留舊格式</td>
      </tr>
      <tr>
          <td>Single read on <code>v2</code></td>
          <td>origin QPS 是否受控、價格 stale 是否達門檻</td>
          <td>回退到 dual read，恢復舊 key 讀路徑</td>
      </tr>
      <tr>
          <td>Contract old key</td>
          <td>舊 key 是否仍被依賴</td>
          <td>停 contract，延長相容窗口</td>
      </tr>
  </tbody>
</table>
<p>Rollback 不是只「切回舊 key」。若新格式已經被下游依賴，回退時要同時保留新舊讀寫相容，避免第二次不一致。</p>
<h2 id="evidence-package">Evidence Package</h2>
<p>快取 migration evidence 的責任是證明「效能提升」沒有交換成「來源壓力失控」或「交易資料錯誤」。</p>
<table>
  <thead>
      <tr>
          <th>欄位</th>
          <th>內容</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Source</td>
          <td>cache metrics、origin metrics、query logs、warmup job logs</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/time-range/" data-link-title="Time Range" data-link-desc="說明證據、查詢與事故判讀如何用時間窗保留可回放上下文">Time range</a></td>
          <td>每個 rollout batch 的觀察窗口</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/query-link/" data-link-title="Query Link" data-link-desc="說明證據包如何保存可重跑查詢入口，而不是只保留截圖或口頭結論">Query link</a></td>
          <td>hit/miss、origin QPS、<a href="/blog/backend/knowledge-cards/stale-read/" data-link-title="Stale Read" data-link-desc="讀取到落後於最新寫入版本的舊資料">stale read</a>、eviction、latency 分布</td>
      </tr>
      <tr>
          <td>Owner</td>
          <td>cache owner、product owner、pricing owner</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/data-quality/" data-link-title="Data Quality" data-link-desc="說明證據欄位如何標示 completeness、freshness、sampling 與資料限制">Data quality</a></td>
          <td>指標延遲、抽樣覆蓋率、分區漏報</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/confidence/" data-link-title="Confidence" data-link-desc="說明證據包如何標示 confirmed、suspected 或 needs follow-up 的判讀信心">Confidence</a></td>
          <td>confirmed / suspected / needs follow-up</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/known-gap/" data-link-title="Known Gap" data-link-desc="說明證據包如何明確保存已知缺口，避免下游高估證據完整性">Known gap</a></td>
          <td>未涵蓋低流量區域、尚未演練的促銷尖峰窗口</td>
      </tr>
  </tbody>
</table>
<p>這份 evidence 要對齊 <a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">4.20 Observability Evidence Package</a>。</p>
<h2 id="release-gate">Release Gate</h2>
<p>Release gate 的責任是決定是否放行下一批切換，而不是只報告觀測結果。</p>
<table>
  <thead>
      <tr>
          <th>Gate 欄位</th>
          <th>最小內容</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/gate-decision/" data-link-title="Gate Decision" data-link-desc="說明 release gate 如何把證據轉成放行、暫停、回退或補證據的決策">Gate decision</a></td>
          <td>放行下一批、維持當前批、回退到 dual read</td>
      </tr>
      <tr>
          <td>Checks</td>
          <td><code>v2</code> 命中率、origin QPS ceiling、stale price ratio</td>
      </tr>
      <tr>
          <td>Stop condition</td>
          <td>回源尖峰、價格 stale 超門檻、熱門 key miss 反彈</td>
      </tr>
      <tr>
          <td>Rollback window</td>
          <td>舊 key fallback 可維持時間、舊格式寫入可恢復時間</td>
      </tr>
      <tr>
          <td>Owner</td>
          <td>cache on-call、pricing on-call</td>
      </tr>
  </tbody>
</table>
<p>這組欄位要對齊 <a href="/blog/backend/06-reliability/release-gate/" data-link-title="6.8 Release Gate 與變更節奏" data-link-desc="把驗證、migration、相容性納入放行判準">6.8 Release Gate</a> 與 <a href="/blog/backend/06-reliability/experiment-safety-boundary/" data-link-title="6.20 Experiment Safety Boundary" data-link-desc="定義 chaos、load test、DR drill 的 [blast radius](/backend/knowledge-cards/blast-radius/)、停止條件與權限約束">6.20 Experiment Safety Boundary</a>。</p>
<h2 id="incident-decision-log">Incident Decision Log</h2>
<p>切換過程中的停用新 key、延長 TTL、凍結 invalidation、回退讀路徑都屬於事故決策。每筆決策都要留在 <a href="/blog/backend/08-incident-response/incident-decision-log/" data-link-title="8.19 Incident Decision Log" data-link-desc="把事中假設、決策、證據、回退條件與責任人留下可復盤紀錄">8.19 Incident Decision Log</a>。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nt">incident_decision</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w">  </span><span class="nt">timestamp</span><span class="p">:</span><span class="w"> </span><span class="ld">2026-05-11T11:42:00Z</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">  </span><span class="nt">decision</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;rollback to dual-read and freeze v2-only rollout&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">  </span><span class="nt">context</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;origin QPS exceeded ceiling and stale price ratio increased in TW region&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">  </span><span class="nt">evidence</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">    </span>- <span class="nt">query</span><span class="p">:</span><span class="w"> </span><span class="l">cache_v2_origin_qps_region_tw</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">    </span>- <span class="nt">query</span><span class="p">:</span><span class="w"> </span><span class="l">stale_price_ratio_by_region</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">  </span><span class="nt">owner</span><span class="p">:</span><span class="w"> </span><span class="l">cache-incident-commander</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">  </span><span class="nt">expected_effect</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;reduce origin pressure and restore price freshness baseline&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">  </span><span class="nt">rollback_condition</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;origin qps or stale ratio does not recover within 15 minutes&#34;</span></span></span></code></pre></div><h2 id="case-write-back-與邊界">Case Write-back 與邊界</h2>
<p>這篇回寫重點對齊 <a href="/blog/backend/02-cache-redis/cases/shopify-cache-serialization-migration/" data-link-title="2.C3 Shopify：快取序列化格式遷移" data-link-desc="快取 payload 從 Marshal 轉 MessagePack 的遷移策略。">2.C3 Shopify：Cache Serialization Migration</a> 與 <a href="/blog/backend/02-cache-redis/cases/failure-cache-stampede-rollout-regression/" data-link-title="2.C9 反例：快取切換引發 Stampede 回歸" data-link-desc="快取策略切換若缺乏保護，會導致回源壓力與錯誤率連鎖上升。">2.C9 反例</a>：前者看格式演進與相容窗口，後者看回源尖峰與停損節奏。</p>
<p>這篇不處理分散式鎖正確性、queue replay 或資料庫正式狀態切換。若核心風險在互斥語意、事件重播或資料 schema，路由到 <a href="/blog/backend/02-cache-redis/distributed-lock/" data-link-title="2.4 distributed lock 與租約" data-link-desc="整理鎖語意、租約風險與適用場景">2.4 distributed lock</a>、<a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計與去重</a> 或 <a href="/blog/backend/01-database/schema-migration-rollout-evidence/" data-link-title="1.7 Schema Migration Rollout 證據（Schema Migration Rollout Evidence）實作示範" data-link-desc="以訂單付款狀態欄位演進示範 schema migration 如何產出 evidence、release gate 與 incident decision log。">1.7 Schema Migration Rollout 證據</a>。</p>
]]></content:encoded></item><item><title>2.10 Pub/Sub 與即時 fan-out</title><link>https://tarrragon.github.io/blog/backend/02-cache-redis/pub-sub/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/02-cache-redis/pub-sub/</guid><description>&lt;p>Redis &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/pub-sub/" data-link-title="Pub/Sub" data-link-desc="說明 publish-subscribe 如何把事件即時分發給多個訂閱者">Pub/Sub&lt;/a> 的核心責任是把一則訊息即時推送給當下所有訂閱者，讓跨節點的狀態變更可以在同一瞬間擴散。它承擔的是「現在發生的事，立刻讓所有人知道」，正式的可靠投遞與重播責任由 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">message queue&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">Redis Streams&lt;/a> 承擔。把這條邊界放在最前面，是因為 Pub/Sub 的多數事故都來自把它當成可靠訊息系統使用。&lt;/p>
&lt;h2 id="at-most-once訊息只送給此刻在線的訂閱者">at-most-once：訊息只送給此刻在線的訂閱者&lt;/h2>
&lt;p>訊息&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/delivery-semantics/" data-link-title="Delivery Semantics" data-link-desc="說明事件投遞語意如何定義遺失、重複、順序與補償策略">投遞語意&lt;/a>有三種：at-most-once（最多送一次、可能漏）、at-least-once（至少送一次、可能重複）、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/exactly-once/" data-link-title="Exactly-Once" data-link-desc="說明訊息剛好被處理一次的語意承諾、它的代價，以及多數時候該用的替代路">exactly-once&lt;/a>（剛好一次、最難實作）。Pub/Sub 採 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/duplicate-delivery/" data-link-title="Duplicate Delivery" data-link-desc="說明同一個訊息被處理多次時如何保持結果穩定">at-most-once&lt;/a>，用「可能漏」換取低延遲與無狀態，後兩種語意由 Streams 或 message queue 承擔。具體來說：&lt;code>PUBLISH&lt;/code> 把訊息送給發布當下已經 &lt;code>SUBSCRIBE&lt;/code> 該 channel 的連線，沒有訂閱者就直接丟棄，訊息不寫入任何持久結構。訂閱者離線、重連、或處理速度跟不上時，那段時間的訊息不會補送。&lt;/p>
&lt;p>這個語意決定了 Pub/Sub 適合承擔什麼。可以接受「偶爾漏一則、下一則狀態會蓋過來」的場景，Pub/Sub 的低延遲與簡單模型是優勢；要求「每一則都不能掉」的場景，例如訂單事件、扣款通知、稽核軌跡，這些責任屬於 durable queue，不該放在 Pub/Sub。&lt;/p>
&lt;p>判讀的關鍵問題是：漏掉一則訊息的代價是什麼。presence 狀態廣播漏一則，下次 heartbeat 會修正；cache invalidation 廣播漏一則，該節點會保留 stale 副本直到 TTL 到期，代價是短暫不一致；扣款事件漏一則，代價是金額錯誤且無法自動修復。前兩者落在 Pub/Sub 的能力範圍，第三者越界。&lt;/p>
&lt;h2 id="適用場景狀態變更的即時扇出">適用場景：狀態變更的即時扇出&lt;/h2>
&lt;p>Pub/Sub 的典型用途是把一個節點上發生的狀態變更，即時扇出給其他節點。這類場景的共同特徵是「最終狀態會自我修正」，所以單則訊息可丟。&lt;/p>
&lt;p>fan-out 有兩種語意要先分清，因為它們決定能不能用 Pub/Sub。一種是全量 fan-out：每個訂閱者都收到同一則訊息的完整副本，適合「所有節點都要知道這件事」的廣播（presence、cache invalidation、config reload）。另一種是分攤 fan-out：同一則訊息只交給一個 consumer 處理、多個 consumer 之間分攤負載，適合「這件工作只要有一個人做」的任務分派。Pub/Sub 只提供全量 fan-out——&lt;code>PUBLISH&lt;/code> 把訊息送給所有訂閱者，沒有「只給其中一個」的語意。需要分攤 fan-out 時要轉 Redis Streams 的 consumer group（&lt;code>XREADGROUP&lt;/code> 讓一則訊息只有一個 consumer 拿到），這條邊界在本章末的升級段展開。&lt;/p>
&lt;p>presence 變更廣播是最直接的應用。&lt;a href="https://tarrragon.github.io/blog/backend/02-cache-redis/presence-store/" data-link-title="2.5 presence store 與即時狀態" data-link-desc="整理線上狀態、跨節點查詢與過期清理">2.5 presence store&lt;/a> 的 cross-node query 回答「現在誰在線」，但當某個使用者上線或離線時，其他節點需要被即時通知才能推播給好友列表。presence key 寫入時同步 &lt;code>PUBLISH&lt;/code> 一則 &lt;code>user:online&lt;/code> 訊息，訂閱該 channel 的節點立刻更新本地視圖。漏一則的代價是某個好友的線上狀態延遲幾秒，下次狀態同步會補正，落在可接受範圍。&lt;/p>
&lt;p>cache invalidation 扇出是第二類應用。當一個節點更新了 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">source of truth&lt;/a> 並失效了自己的本地 cache，其他持有同一份 process-local cache 的節點需要被通知一起失效。&lt;code>PUBLISH cache:invalidate product:123&lt;/code> 讓所有節點丟棄該 key 的本地副本。這條路徑要跟 &lt;a href="https://tarrragon.github.io/blog/backend/02-cache-redis/cache-aside/" data-link-title="2.2 cache aside 與失效策略" data-link-desc="整理 read-through 思路、cache miss 與 invalidation">2.2 cache aside&lt;/a> 的失效策略對齊：Pub/Sub 負責「通知」，實際失效仍由各節點執行，且因為 at-most-once，必須有 TTL 作為兜底，避免廣播漏送讓某節點永久持有 stale 副本。&lt;/p>
&lt;p>即時配置熱刷新是第三類。feature flag、限流閾值、路由表這類低頻變更的配置，更新時 &lt;code>PUBLISH config:reload&lt;/code>，各節點收到後重新拉取最新配置。低頻特性讓 at-most-once 風險很低，而即時性比輪詢配置中心更省資源。&lt;/p>
&lt;h2 id="subscribe-的連線模型">SUBSCRIBE 的連線模型&lt;/h2>
&lt;p>訂閱會把連線切換進專用模式：一旦 &lt;code>SUBSCRIBE&lt;/code>，該連線只能再執行 &lt;code>SUBSCRIBE&lt;/code>、&lt;code>UNSUBSCRIBE&lt;/code>、&lt;code>PING&lt;/code> 與訂閱相關命令，不能在同一條連線上跑 &lt;code>GET&lt;/code>、&lt;code>SET&lt;/code> 等一般命令。原因是訂閱連線進入了等待推送的狀態，伺服器隨時可能把訊息推過來，與請求應答式命令的時序會衝突。&lt;/p>
&lt;p>這個模型的工程含義是：訂閱要用獨立的連線，不能跟一般讀寫共用同一個 client。共用連線池的應用要為 Pub/Sub 保留專門的訂閱連線，避免訂閱模式污染了拿來做 cache 讀寫的連線。這條限制跟 &lt;a href="https://tarrragon.github.io/blog/backend/02-cache-redis/high-concurrency-access/" data-link-title="2.1 高併發下的 Redis 讀寫邊界" data-link-desc="說明高併發服務如何共用 Redis client、控制 pipeline 與避免 cache stampede">2.1 高併發讀寫邊界&lt;/a> 的連線管理直接相關：訂閱連線是長連線、數量應該受控，與短命的請求應答連線分開計量。&lt;/p>
&lt;p>訂閱連線斷線重連時，要重新 &lt;code>SUBSCRIBE&lt;/code> 所有 channel，且要意識到斷線期間的訊息已經永久丟失。可靠性敏感的設計會在重連後主動拉一次全量狀態，用一次 reconciliation 補上廣播漏掉的窗口。&lt;/p>
&lt;h2 id="cluster-下的-fan-out-與-sharded-pubsub">cluster 下的 fan-out 與 sharded Pub/Sub&lt;/h2>
&lt;p>在單節點與傳統 cluster 中，&lt;code>PUBLISH&lt;/code> 的訊息會傳播到 cluster 內所有節點，確保任何節點上的訂閱者都能收到。這個全傳播模型保證了廣播的完整性，但代價是每則訊息都要在節點間擴散，高頻發布時會佔用 cluster 內部頻寬。&lt;/p></description><content:encoded><![CDATA[<p>Redis <a href="/blog/backend/knowledge-cards/pub-sub/" data-link-title="Pub/Sub" data-link-desc="說明 publish-subscribe 如何把事件即時分發給多個訂閱者">Pub/Sub</a> 的核心責任是把一則訊息即時推送給當下所有訂閱者，讓跨節點的狀態變更可以在同一瞬間擴散。它承擔的是「現在發生的事，立刻讓所有人知道」，正式的可靠投遞與重播責任由 <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">message queue</a> 與 <a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">Redis Streams</a> 承擔。把這條邊界放在最前面，是因為 Pub/Sub 的多數事故都來自把它當成可靠訊息系統使用。</p>
<h2 id="at-most-once訊息只送給此刻在線的訂閱者">at-most-once：訊息只送給此刻在線的訂閱者</h2>
<p>訊息<a href="/blog/backend/knowledge-cards/delivery-semantics/" data-link-title="Delivery Semantics" data-link-desc="說明事件投遞語意如何定義遺失、重複、順序與補償策略">投遞語意</a>有三種：at-most-once（最多送一次、可能漏）、at-least-once（至少送一次、可能重複）、<a href="/blog/backend/knowledge-cards/exactly-once/" data-link-title="Exactly-Once" data-link-desc="說明訊息剛好被處理一次的語意承諾、它的代價，以及多數時候該用的替代路">exactly-once</a>（剛好一次、最難實作）。Pub/Sub 採 <a href="/blog/backend/knowledge-cards/duplicate-delivery/" data-link-title="Duplicate Delivery" data-link-desc="說明同一個訊息被處理多次時如何保持結果穩定">at-most-once</a>，用「可能漏」換取低延遲與無狀態，後兩種語意由 Streams 或 message queue 承擔。具體來說：<code>PUBLISH</code> 把訊息送給發布當下已經 <code>SUBSCRIBE</code> 該 channel 的連線，沒有訂閱者就直接丟棄，訊息不寫入任何持久結構。訂閱者離線、重連、或處理速度跟不上時，那段時間的訊息不會補送。</p>
<p>這個語意決定了 Pub/Sub 適合承擔什麼。可以接受「偶爾漏一則、下一則狀態會蓋過來」的場景，Pub/Sub 的低延遲與簡單模型是優勢；要求「每一則都不能掉」的場景，例如訂單事件、扣款通知、稽核軌跡，這些責任屬於 durable queue，不該放在 Pub/Sub。</p>
<p>判讀的關鍵問題是：漏掉一則訊息的代價是什麼。presence 狀態廣播漏一則，下次 heartbeat 會修正；cache invalidation 廣播漏一則，該節點會保留 stale 副本直到 TTL 到期，代價是短暫不一致；扣款事件漏一則，代價是金額錯誤且無法自動修復。前兩者落在 Pub/Sub 的能力範圍，第三者越界。</p>
<h2 id="適用場景狀態變更的即時扇出">適用場景：狀態變更的即時扇出</h2>
<p>Pub/Sub 的典型用途是把一個節點上發生的狀態變更，即時扇出給其他節點。這類場景的共同特徵是「最終狀態會自我修正」，所以單則訊息可丟。</p>
<p>fan-out 有兩種語意要先分清，因為它們決定能不能用 Pub/Sub。一種是全量 fan-out：每個訂閱者都收到同一則訊息的完整副本，適合「所有節點都要知道這件事」的廣播（presence、cache invalidation、config reload）。另一種是分攤 fan-out：同一則訊息只交給一個 consumer 處理、多個 consumer 之間分攤負載，適合「這件工作只要有一個人做」的任務分派。Pub/Sub 只提供全量 fan-out——<code>PUBLISH</code> 把訊息送給所有訂閱者，沒有「只給其中一個」的語意。需要分攤 fan-out 時要轉 Redis Streams 的 consumer group（<code>XREADGROUP</code> 讓一則訊息只有一個 consumer 拿到），這條邊界在本章末的升級段展開。</p>
<p>presence 變更廣播是最直接的應用。<a href="/blog/backend/02-cache-redis/presence-store/" data-link-title="2.5 presence store 與即時狀態" data-link-desc="整理線上狀態、跨節點查詢與過期清理">2.5 presence store</a> 的 cross-node query 回答「現在誰在線」，但當某個使用者上線或離線時，其他節點需要被即時通知才能推播給好友列表。presence key 寫入時同步 <code>PUBLISH</code> 一則 <code>user:online</code> 訊息，訂閱該 channel 的節點立刻更新本地視圖。漏一則的代價是某個好友的線上狀態延遲幾秒，下次狀態同步會補正，落在可接受範圍。</p>
<p>cache invalidation 扇出是第二類應用。當一個節點更新了 <a href="/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">source of truth</a> 並失效了自己的本地 cache，其他持有同一份 process-local cache 的節點需要被通知一起失效。<code>PUBLISH cache:invalidate product:123</code> 讓所有節點丟棄該 key 的本地副本。這條路徑要跟 <a href="/blog/backend/02-cache-redis/cache-aside/" data-link-title="2.2 cache aside 與失效策略" data-link-desc="整理 read-through 思路、cache miss 與 invalidation">2.2 cache aside</a> 的失效策略對齊：Pub/Sub 負責「通知」，實際失效仍由各節點執行，且因為 at-most-once，必須有 TTL 作為兜底，避免廣播漏送讓某節點永久持有 stale 副本。</p>
<p>即時配置熱刷新是第三類。feature flag、限流閾值、路由表這類低頻變更的配置，更新時 <code>PUBLISH config:reload</code>，各節點收到後重新拉取最新配置。低頻特性讓 at-most-once 風險很低，而即時性比輪詢配置中心更省資源。</p>
<h2 id="subscribe-的連線模型">SUBSCRIBE 的連線模型</h2>
<p>訂閱會把連線切換進專用模式：一旦 <code>SUBSCRIBE</code>，該連線只能再執行 <code>SUBSCRIBE</code>、<code>UNSUBSCRIBE</code>、<code>PING</code> 與訂閱相關命令，不能在同一條連線上跑 <code>GET</code>、<code>SET</code> 等一般命令。原因是訂閱連線進入了等待推送的狀態，伺服器隨時可能把訊息推過來，與請求應答式命令的時序會衝突。</p>
<p>這個模型的工程含義是：訂閱要用獨立的連線，不能跟一般讀寫共用同一個 client。共用連線池的應用要為 Pub/Sub 保留專門的訂閱連線，避免訂閱模式污染了拿來做 cache 讀寫的連線。這條限制跟 <a href="/blog/backend/02-cache-redis/high-concurrency-access/" data-link-title="2.1 高併發下的 Redis 讀寫邊界" data-link-desc="說明高併發服務如何共用 Redis client、控制 pipeline 與避免 cache stampede">2.1 高併發讀寫邊界</a> 的連線管理直接相關：訂閱連線是長連線、數量應該受控，與短命的請求應答連線分開計量。</p>
<p>訂閱連線斷線重連時，要重新 <code>SUBSCRIBE</code> 所有 channel，且要意識到斷線期間的訊息已經永久丟失。可靠性敏感的設計會在重連後主動拉一次全量狀態，用一次 reconciliation 補上廣播漏掉的窗口。</p>
<h2 id="cluster-下的-fan-out-與-sharded-pubsub">cluster 下的 fan-out 與 sharded Pub/Sub</h2>
<p>在單節點與傳統 cluster 中，<code>PUBLISH</code> 的訊息會傳播到 cluster 內所有節點，確保任何節點上的訂閱者都能收到。這個全傳播模型保證了廣播的完整性，但代價是每則訊息都要在節點間擴散，高頻發布時會佔用 cluster 內部頻寬。</p>
<p>sharded Pub/Sub（<code>SPUBLISH</code> / <code>SSUBSCRIBE</code>）把這個成本收斂：sharded channel 的訊息只在負責該 channel slot 的分片內傳播，不擴散到整個 cluster。代價是訂閱者必須連到正確的分片才能收到。判讀條件是發布頻率與 cluster 規模：低頻廣播用一般 Pub/Sub 換取部署簡單；高頻發布且 cluster 節點多時，sharded Pub/Sub 避免內部頻寬被廣播流量吃掉。<code>PUBSUB SHARDNUMSUB</code> 可以查某 shard channel 的訂閱者數，用來判讀扇出是否落在預期分片。</p>
<h2 id="keyspace-notifications把-key-事件變成廣播源">keyspace notifications：把 key 事件變成廣播源</h2>
<p>keyspace notifications 讓 Redis 在 key 發生變更（寫入、刪除、過期）時自動 <code>PUBLISH</code> 一則事件，訂閱者不必輪詢就能知道某個 key 變了。開啟後，<code>SET</code>、<code>DEL</code>、TTL 過期都會發出對應 channel 的訊息。</p>
<p>這個能力把 presence cleanup 變得更即時。<a href="/blog/backend/02-cache-redis/presence-store/" data-link-title="2.5 presence store 與即時狀態" data-link-desc="整理線上狀態、跨節點查詢與過期清理">2.5 presence store</a> 的 cleanup 策略依賴 TTL 過期讓離線狀態消失，但「過期了」這件事本身可以透過 <code>__keyevent@0__:expired</code> 事件廣播出去，讓其他節點即時得知某連線下線，而不必等到下次查詢才發現。</p>
<p>keyspace notifications 同樣採 at-most-once 語意，且過期事件的觸發時機與 Redis 的惰性過期機制有關：key 在被存取或背景掃描到時才真正過期並發出事件。延遲量級取決於 key 下次被存取的時機與背景掃描週期（active expiry 預設每秒約執行 10 輪、每輪抽樣部分過期 key），最差情況下事件可能延遲數秒到數分鐘。需要精確過期時序的設計，仍要保留主動查詢路徑作為依據。</p>
<h2 id="何時從-pubsub-升級">何時從 Pub/Sub 升級</h2>
<p>Pub/Sub 的邊界訊號出現時，責任應該往 <a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">Redis Streams</a> 或正式 <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">message queue</a> 移動。判準是 durable 與 replayable 這兩個 Pub/Sub 不提供的能力。</p>
<table>
  <thead>
      <tr>
          <th>需求訊號</th>
          <th>Pub/Sub 的限制</th>
          <th>該轉向的能力</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>訂閱者離線期間的訊息不能丟</td>
          <td>at-most-once、不持久化</td>
          <td>Redis Streams 的 <a href="/blog/backend/knowledge-cards/message-persistence/" data-link-title="Message Persistence" data-link-desc="說明訊息是否落盤保存，以及 broker 重啟後能否恢復">persistence</a> 與 consumer group</td>
      </tr>
      <tr>
          <td>需要重播歷史訊息</td>
          <td>訊息發布後即丟棄、無法回放</td>
          <td>Streams 的 ID 範圍讀取、message queue 的 replay</td>
      </tr>
      <tr>
          <td>需要確認訊息已被處理</td>
          <td>沒有 ack 機制</td>
          <td>Streams 的 <code>XACK</code>、queue 的 acknowledgement</td>
      </tr>
      <tr>
          <td>消費者失效時訊息要被接手</td>
          <td>訊息隨連線丟失</td>
          <td>Streams consumer group 的 pending list 與 claiming</td>
      </tr>
      <tr>
          <td>需要消費者群組分攤負載</td>
          <td>每個訂閱者都收到全部訊息</td>
          <td>Streams <code>XREADGROUP</code> 的單一 owner 語意</td>
      </tr>
  </tbody>
</table>
<p>Redis Streams 是介於 Pub/Sub 與重量級 broker 之間的選項：它持久化訊息、支援 consumer group 與 ack，又仍在 Redis 內，遷移成本低於引入 Kafka 或 RabbitMQ。Streams 與正式 message queue 的選型、consumer 設計、replay 邊界屬於 <a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">模組三 message queue</a> 的責任，本章只負責標出「何時該離開 Pub/Sub」這條邊界。</p>
<h2 id="判讀訊號">判讀訊號</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>判讀重點</th>
          <th>對應動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>訂閱者抱怨偶爾漏訊息</td>
          <td>at-most-once 在重連窗口丟訊息</td>
          <td>重連後補一次全量 reconciliation，或轉 Streams</td>
      </tr>
      <tr>
          <td>cluster 內部頻寬被廣播流量吃掉</td>
          <td>一般 Pub/Sub 全節點傳播成本過高</td>
          <td>改 sharded Pub/Sub、收斂傳播範圍</td>
      </tr>
      <tr>
          <td>訂閱連線數量隨流量無上限成長</td>
          <td>訂閱連線與一般讀寫連線混用</td>
          <td>分離訂閱連線池、獨立計量</td>
      </tr>
      <tr>
          <td>廣播漏送導致某節點長期 stale</td>
          <td>只靠 Pub/Sub 通知失效、缺 TTL 兜底</td>
          <td>補 TTL 作為失效兜底，廣播只當加速</td>
      </tr>
      <tr>
          <td>訂閱者跟不上發布、訊息靜默丟棄</td>
          <td>Pub/Sub 無 backpressure、發布方看不到消費積壓</td>
          <td>改 Streams（pending list 可量積壓）或限發布速率</td>
      </tr>
      <tr>
          <td>開始需要「這則處理了沒」的確認</td>
          <td>Pub/Sub 無 ack、責任已越界</td>
          <td>轉 Redis Streams 或正式 message queue</td>
      </tr>
  </tbody>
</table>
<p>訂閱者抱怨漏訊息時，先確認這是不是 at-most-once 的預期行為而非 bug。Pub/Sub 在訂閱者重連窗口丟訊息是設計而非故障，正確的修法是判斷這個場景能不能接受丟；能接受就保留 Pub/Sub 並補 reconciliation，不能接受就轉向 durable 方案。</p>
<p>廣播漏送導致長期 stale 之所以難防，是因為 cache invalidation 廣播在多數時候成功，讓人把失效當成可靠，直到某次漏送讓一個節點持有錯誤價格或權限數小時而沒有任何報錯。TTL 兜底的意義就是把「廣播失敗」的最壞影響限制在一個 TTL 週期內，把 Pub/Sub 定位成「加速失效」而非「保證失效」。</p>
<h2 id="常見誤區">常見誤區</h2>
<p>把 Pub/Sub 當成可靠訊息系統，是最常見也代價最大的誤區。Pub/Sub 沒有持久化、沒有 ack、沒有重播，這些是它換取低延遲與簡單模型的設計取捨。需要這些能力時，正確做法是換工具，而不是在 Pub/Sub 外圍補一層補丁去模擬可靠投遞。</p>
<p>把訂閱連線跟一般讀寫連線共用，是第二個誤區。訂閱會讓連線進入專用模式，混用會讓 cache 讀寫命令在該連線上失敗或行為異常。訂閱連線要獨立管理。</p>
<p>只靠 Pub/Sub 廣播做 cache invalidation 而沒有 TTL 兜底，是第三個誤區。廣播的 at-most-once 特性意味著總有漏送的可能，TTL 是讓漏送影響有上界的保險。</p>
<h2 id="情境回寫">情境回寫</h2>
<p>Pub/Sub 的即時扇出語意，回寫到真實服務時最常見的形狀是多節點即時狀態同步。一個多區域部署的即時通訊服務，使用者上線狀態由所在區域的節點寫入，其他區域的節點需要即時得知才能更新好友列表的線上指示。這條路徑用 Pub/Sub 廣播狀態變更，回寫時要保留「跨區傳播有延遲窗口、單則訊息可丟、靠後續 heartbeat 收斂」的判讀，而非把它當成可靠投遞。</p>
<p>這個形狀支撐的是「即時廣播 + 最終狀態收斂」的判讀。若根因是訊息不能丟（狀態變更會觸發扣款、稽核或計費），應回到 <a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">模組三 message queue</a> 的 durable 方案；模組三的 fan-out 案例（如 Twitch EventSub 用 SNS + SQS 扇出給第三方）記錄了 durable 扇出的設計，可在需要持久化與重播時對照。</p>
<h2 id="跨模組路由">跨模組路由</h2>
<ol>
<li>與 2.5 的交接：presence 狀態變更的廣播回到 <a href="/blog/backend/02-cache-redis/presence-store/" data-link-title="2.5 presence store 與即時狀態" data-link-desc="整理線上狀態、跨節點查詢與過期清理">presence store 與即時狀態</a>。</li>
<li>與 2.2 的交接：cache invalidation 扇出與 TTL 兜底回到 <a href="/blog/backend/02-cache-redis/cache-aside/" data-link-title="2.2 cache aside 與失效策略" data-link-desc="整理 read-through 思路、cache miss 與 invalidation">cache aside 與失效策略</a>。</li>
<li>與 2.1 的交接：訂閱連線管理與一般讀寫連線分離回到 <a href="/blog/backend/02-cache-redis/high-concurrency-access/" data-link-title="2.1 高併發下的 Redis 讀寫邊界" data-link-desc="說明高併發服務如何共用 Redis client、控制 pipeline 與避免 cache stampede">高併發下的 Redis 讀寫邊界</a>。</li>
<li>與模組三的交接：需要持久化、ack 與重播時轉向 <a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">message queue</a> 與 Redis Streams。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<p>要看即時狀態本身如何建模與清理，回到 <a href="/blog/backend/02-cache-redis/presence-store/" data-link-title="2.5 presence store 與即時狀態" data-link-desc="整理線上狀態、跨節點查詢與過期清理">2.5 presence store 與即時狀態</a>。要看廣播訊息升級成 durable 投遞後的 consumer 設計與重播邊界，接著讀 <a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">模組三 message queue</a>。</p>
]]></content:encoded></item><item><title>2.11 Redis data types 實作</title><link>https://tarrragon.github.io/blog/backend/02-cache-redis/redis-data-types/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/02-cache-redis/redis-data-types/</guid><description>&lt;p>Redis data types 的核心責任是把服務語意映射到適合的內建結構，讓讀寫操作的複雜度、原子性與記憶體成本由結構本身保證。選對型別，排行榜更新是一次 O(log N) 操作；選錯型別，同一個需求要拉回整包資料在應用端重算再寫回。本章承接 &lt;a href="https://tarrragon.github.io/blog/backend/02-cache-redis/cache-data-shape-access-pattern/" data-link-title="2.8 Cache Data Shape 與 Access Pattern" data-link-desc="說明 cache value、key space、資料結構與存取型態如何反映服務語意。">2.8 cache data shape&lt;/a> 的形狀選型，往下談每個型別的實作判讀與容量行為。&lt;/p>
&lt;h2 id="與-28-的分工">與 2.8 的分工&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/02-cache-redis/cache-data-shape-access-pattern/" data-link-title="2.8 Cache Data Shape 與 Access Pattern" data-link-desc="說明 cache value、key space、資料結構與存取型態如何反映服務語意。">2.8&lt;/a> 回答「這份資料是單 key、集合、排序還是計數」這層形狀選型，本章回答「選定形狀後，這個型別的操作語意、原子性與記憶體曲線是什麼」。形狀選型決定方向，型別實作決定它在真實流量下的成本與正確性邊界。兩章分工互補：2.8 判斷形狀，本章確認該型別能不能撐住預期的存取節奏。本章涵蓋 sorted set、bitmap、HyperLogLog、counter 與 hash 這五個快取場景最常用的型別；list 與 stream 的責任偏向佇列與事件流，由 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">模組三 message queue&lt;/a> 涵蓋，geo 這類空間型別不在本章範圍。&lt;/p>
&lt;h2 id="sorted-set排行榜與時間線">sorted set：排行榜與時間線&lt;/h2>
&lt;p>sorted set 的責任是維護一組帶 score 的成員，並讓「依 score 排序取範圍」成為一次操作。它適合排行榜、時間線、優先佇列這類「要排序、要取 top-N、要查排名」的場景。&lt;/p>
&lt;p>排行榜是最直接的應用。&lt;code>ZADD leaderboard 5000 player:42&lt;/code> 寫入或更新分數，&lt;code>ZREVRANGE leaderboard 0 9 WITHSCORES&lt;/code> 取前十名，&lt;code>ZREVRANK leaderboard player:42&lt;/code> 查某玩家的排名。每個操作都是 O(log N)，不需要把整個排行榜拉到應用端排序。分數變動用 &lt;code>ZINCRBY&lt;/code> 原子遞增，避免「讀分數、加分、寫回」的競態。&lt;/p>
&lt;p>時間線是第二類應用。把訊息或事件的時間戳當 score，&lt;code>ZADD timeline &amp;lt;timestamp&amp;gt; &amp;lt;event-id&amp;gt;&lt;/code>，就能用 &lt;code>ZRANGEBYSCORE&lt;/code> 取某個時間窗口的事件，或用 &lt;code>ZREVRANGE&lt;/code> 取最新 N 則。這個用法要注意容量：時間線會持續增長，需要搭配 &lt;code>ZREMRANGEBYRANK&lt;/code> 或 &lt;code>ZREMRANGEBYSCORE&lt;/code> 定期裁剪舊資料，否則 key 會無限膨脹。&lt;/p>
&lt;p>sorted set 的判讀重點是 score 語意的正確性。score 是排序的唯一依據，score 設計錯誤會造成排序漂移：用浮點數當 score 時要注意精度，相同 score 的成員按字典序排列，需要穩定排序時要把 tie-break 維度編進 score 或成員名。容量上，sorted set 內部同時維護一個支援 O(1) 查找的 hash 與一個支援 O(log N) 排序的跳躍表（skiplist），兩份索引讓查找與排序都快，但每個成員要在兩個結構各存一份，記憶體成本高於單純的 set，成員數很大的排行榜要評估記憶體佔用。&lt;/p>
&lt;h2 id="bitmap布林狀態的省記憶體表示">bitmap：布林狀態的省記憶體表示&lt;/h2>
&lt;p>bitmap 的責任是用單一 bit 表示每個實體的布林狀態，讓「大量實體的是否」用極小記憶體承載。它建構在 string 上、以 bit 操作存取，適合日活躍標記、功能開關位、簽到記錄這類「每個 id 對應一個是否」的場景。&lt;/p>
&lt;p>日活躍使用者追蹤是典型應用。用日期當 key、使用者 id 當 offset，&lt;code>SETBIT active:20260616 &amp;lt;user-id&amp;gt; 1&lt;/code> 標記某使用者當天活躍，&lt;code>BITCOUNT active:20260616&lt;/code> 算當天活躍總數。一千萬個使用者只需要約 1.2 MB（一千萬 bit），相比為每個使用者存一筆記錄，記憶體成本低一到兩個數量級。多天的留存分析用 &lt;code>BITOP AND&lt;/code> 把多天的 bitmap 做交集，算出連續活躍的使用者。&lt;/p>
&lt;p>bitmap 的判讀重點是 offset 的密度。bitmap 的記憶體取決於最大 offset 而非實際設置的 bit 數：如果 user id 是稀疏的大整數（例如雪花 id），直接當 offset 會撐爆記憶體，需要先把 id 映射成稠密的連續整數。offset 稠密時 bitmap 極省空間，稀疏時反而浪費，這條判讀決定 bitmap 能不能用。&lt;/p>
&lt;h2 id="hyperloglog基數估計">HyperLogLog：基數估計&lt;/h2>
&lt;p>HyperLogLog 的責任是用固定的小記憶體估算一個集合的不重複元素數量，代價是放棄精確值換取近乎常數的空間。它適合 UV 統計、不重複事件計數這類「只要不重複的數量、不需要知道具體是誰」的場景。&lt;/p>
&lt;p>獨立訪客（UV）統計是典型應用。&lt;code>PFADD uv:20260616 &amp;lt;user-id&amp;gt;&lt;/code> 把訪客加入估計，&lt;code>PFCOUNT uv:20260616&lt;/code> 取得不重複訪客數的估計值。HyperLogLog 每個 key 的記憶體在 dense 表示下固定在約 12 KB，無論加入一千還是一億個元素都不增長，標準誤差約 0.81%；元素數少時 Redis 用 sparse 編碼、記憶體遠低於 12 KB，超過可配置的閾值（&lt;code>hll-sparse-max-bytes&lt;/code>，預設 3000 bytes）後才切換成 dense 表示。多天 UV 合併用 &lt;code>PFMERGE&lt;/code> 把多個 HLL 合成一個再 count，算出跨天的不重複訪客。&lt;/p></description><content:encoded><![CDATA[<p>Redis data types 的核心責任是把服務語意映射到適合的內建結構，讓讀寫操作的複雜度、原子性與記憶體成本由結構本身保證。選對型別，排行榜更新是一次 O(log N) 操作；選錯型別，同一個需求要拉回整包資料在應用端重算再寫回。本章承接 <a href="/blog/backend/02-cache-redis/cache-data-shape-access-pattern/" data-link-title="2.8 Cache Data Shape 與 Access Pattern" data-link-desc="說明 cache value、key space、資料結構與存取型態如何反映服務語意。">2.8 cache data shape</a> 的形狀選型，往下談每個型別的實作判讀與容量行為。</p>
<h2 id="與-28-的分工">與 2.8 的分工</h2>
<p><a href="/blog/backend/02-cache-redis/cache-data-shape-access-pattern/" data-link-title="2.8 Cache Data Shape 與 Access Pattern" data-link-desc="說明 cache value、key space、資料結構與存取型態如何反映服務語意。">2.8</a> 回答「這份資料是單 key、集合、排序還是計數」這層形狀選型，本章回答「選定形狀後，這個型別的操作語意、原子性與記憶體曲線是什麼」。形狀選型決定方向，型別實作決定它在真實流量下的成本與正確性邊界。兩章分工互補：2.8 判斷形狀，本章確認該型別能不能撐住預期的存取節奏。本章涵蓋 sorted set、bitmap、HyperLogLog、counter 與 hash 這五個快取場景最常用的型別；list 與 stream 的責任偏向佇列與事件流，由 <a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">模組三 message queue</a> 涵蓋，geo 這類空間型別不在本章範圍。</p>
<h2 id="sorted-set排行榜與時間線">sorted set：排行榜與時間線</h2>
<p>sorted set 的責任是維護一組帶 score 的成員，並讓「依 score 排序取範圍」成為一次操作。它適合排行榜、時間線、優先佇列這類「要排序、要取 top-N、要查排名」的場景。</p>
<p>排行榜是最直接的應用。<code>ZADD leaderboard 5000 player:42</code> 寫入或更新分數，<code>ZREVRANGE leaderboard 0 9 WITHSCORES</code> 取前十名，<code>ZREVRANK leaderboard player:42</code> 查某玩家的排名。每個操作都是 O(log N)，不需要把整個排行榜拉到應用端排序。分數變動用 <code>ZINCRBY</code> 原子遞增，避免「讀分數、加分、寫回」的競態。</p>
<p>時間線是第二類應用。把訊息或事件的時間戳當 score，<code>ZADD timeline &lt;timestamp&gt; &lt;event-id&gt;</code>，就能用 <code>ZRANGEBYSCORE</code> 取某個時間窗口的事件，或用 <code>ZREVRANGE</code> 取最新 N 則。這個用法要注意容量：時間線會持續增長，需要搭配 <code>ZREMRANGEBYRANK</code> 或 <code>ZREMRANGEBYSCORE</code> 定期裁剪舊資料，否則 key 會無限膨脹。</p>
<p>sorted set 的判讀重點是 score 語意的正確性。score 是排序的唯一依據，score 設計錯誤會造成排序漂移：用浮點數當 score 時要注意精度，相同 score 的成員按字典序排列，需要穩定排序時要把 tie-break 維度編進 score 或成員名。容量上，sorted set 內部同時維護一個支援 O(1) 查找的 hash 與一個支援 O(log N) 排序的跳躍表（skiplist），兩份索引讓查找與排序都快，但每個成員要在兩個結構各存一份，記憶體成本高於單純的 set，成員數很大的排行榜要評估記憶體佔用。</p>
<h2 id="bitmap布林狀態的省記憶體表示">bitmap：布林狀態的省記憶體表示</h2>
<p>bitmap 的責任是用單一 bit 表示每個實體的布林狀態，讓「大量實體的是否」用極小記憶體承載。它建構在 string 上、以 bit 操作存取，適合日活躍標記、功能開關位、簽到記錄這類「每個 id 對應一個是否」的場景。</p>
<p>日活躍使用者追蹤是典型應用。用日期當 key、使用者 id 當 offset，<code>SETBIT active:20260616 &lt;user-id&gt; 1</code> 標記某使用者當天活躍，<code>BITCOUNT active:20260616</code> 算當天活躍總數。一千萬個使用者只需要約 1.2 MB（一千萬 bit），相比為每個使用者存一筆記錄，記憶體成本低一到兩個數量級。多天的留存分析用 <code>BITOP AND</code> 把多天的 bitmap 做交集，算出連續活躍的使用者。</p>
<p>bitmap 的判讀重點是 offset 的密度。bitmap 的記憶體取決於最大 offset 而非實際設置的 bit 數：如果 user id 是稀疏的大整數（例如雪花 id），直接當 offset 會撐爆記憶體，需要先把 id 映射成稠密的連續整數。offset 稠密時 bitmap 極省空間，稀疏時反而浪費，這條判讀決定 bitmap 能不能用。</p>
<h2 id="hyperloglog基數估計">HyperLogLog：基數估計</h2>
<p>HyperLogLog 的責任是用固定的小記憶體估算一個集合的不重複元素數量，代價是放棄精確值換取近乎常數的空間。它適合 UV 統計、不重複事件計數這類「只要不重複的數量、不需要知道具體是誰」的場景。</p>
<p>獨立訪客（UV）統計是典型應用。<code>PFADD uv:20260616 &lt;user-id&gt;</code> 把訪客加入估計，<code>PFCOUNT uv:20260616</code> 取得不重複訪客數的估計值。HyperLogLog 每個 key 的記憶體在 dense 表示下固定在約 12 KB，無論加入一千還是一億個元素都不增長，標準誤差約 0.81%；元素數少時 Redis 用 sparse 編碼、記憶體遠低於 12 KB，超過可配置的閾值（<code>hll-sparse-max-bytes</code>，預設 3000 bytes）後才切換成 dense 表示。多天 UV 合併用 <code>PFMERGE</code> 把多個 HLL 合成一個再 count，算出跨天的不重複訪客。</p>
<p>HyperLogLog 的判讀重點是「估計值能不能接受」。它回答的是「大約多少不重複」，不能回答「某個特定元素在不在集合裡」，也不能取出集合成員。需要精確去重、或需要判斷成員存在性時，用 set 或 bitmap；只要量級且能容忍百分之一以內的誤差時，HyperLogLog 用固定小記憶體換取巨大的空間節省。把 HLL 的估計值當精確值報給財務或計費，是越界用法。</p>
<h2 id="原子計數器counter">原子計數器：counter</h2>
<p>counter 的責任是提供一個原子遞增的整數，讓並發場景下的計數不需要鎖。它建構在 string 上，<code>INCR</code>、<code>INCRBY</code>、<code>DECR</code> 都是原子操作，適合限流、配額、瀏覽計數這類高並發累加。</p>
<p>限流計數是典型應用，也跟 <a href="/blog/backend/knowledge-cards/rate-limit/" data-link-title="Rate Limit" data-link-desc="說明限流如何保護服務入口、下游依賴與租戶公平性">rate limit</a> 卡片直接相關。固定窗口限流用 <code>INCR rate:&lt;user&gt;:&lt;minute&gt;</code> 累加當前窗口的請求數，第一次寫入時 <code>EXPIRE</code> 設定窗口長度，超過閾值就拒絕。原子性讓多個並發請求的計數不會互相覆蓋，這是用一般 <code>GET</code>/<code>SET</code> 做計數會踩到的競態。</p>
<p>counter 的判讀重點是原子性與過期窗口的對齊。<code>INCR</code> 本身原子，但「INCR 後再 EXPIRE」是兩個操作，若第一次 INCR 成功、EXPIRE 失敗，這個 key 會永不過期變成髒計數。最穩健的做法是用 Lua script 把 INCR 與 EXPIRE 包成一個原子單元；<code>SET key 1 EX &lt;ttl&gt; NX</code> 配合後續 INCR 能減少 EXPIRE 漏掉的機率（窗口第一次寫入時就帶上過期），但這個組合的兩步之間仍非原子，不視為與 Lua script 等效。這條對齊跟 <a href="/blog/backend/02-cache-redis/cache-data-shape-access-pattern/" data-link-title="2.8 Cache Data Shape 與 Access Pattern" data-link-desc="說明 cache value、key space、資料結構與存取型態如何反映服務語意。">2.8 counter 形狀</a> 提到的「原子性與過期窗口要對齊」是同一件事，本章補上具體實作。</p>
<h2 id="hash結構化欄位的局部更新">hash：結構化欄位的局部更新</h2>
<p>hash 的責任是把一個實體的多個欄位存在同一個 key 下，並讓單一欄位可以獨立讀寫。它適合使用者摘要、商品局部欄位這類「整體是一個實體、但欄位會分別更新」的場景。</p>
<p>相比把整個實體序列化成一個 JSON blob，hash 的優勢是局部更新：<code>HSET user:42 last_seen &lt;ts&gt;</code> 只改一個欄位，不需要讀出整包、改一個值、再寫回。這在欄位更新頻繁的場景省下大量序列化成本與競態風險。<code>HGET</code> 取單一欄位、<code>HGETALL</code> 取全部、<code>HINCRBY</code> 對數值欄位原子遞增。</p>
<p>hash 的判讀重點是欄位責任要清楚。hash 讓欄位能獨立更新，但這也讓它容易滑向「半正式狀態」：當不同欄位由不同來源在不同時間更新，整個 hash 的一致性就變得模糊，某些欄位新、某些欄位舊。判讀條件是這些欄位是否真的能獨立成立；如果它們必須一起更新才有意義，blob 的整體替換反而比 hash 的局部更新更安全。</p>
<p>容量上 hash 有一個要注意的轉折：欄位數與欄位值在閾值內時（<code>hash-max-listpack-entries</code> 預設 128 個欄位、<code>hash-max-listpack-value</code> 預設 64 bytes）用緊湊的 listpack 編碼、記憶體很省，超過任一閾值就轉成 hashtable 編碼，記憶體成本明顯上升。設計大 hash 時要確認欄位數落在閾值內，否則會在某個規模點遇到非線性的記憶體增長。</p>
<h2 id="型別選型的容量與原子性判讀">型別選型的容量與原子性判讀</h2>
<p>選型前要把存取語意、原子性需求與記憶體曲線一起考慮，而不是只看「能不能存」。</p>
<table>
  <thead>
      <tr>
          <th>型別</th>
          <th>承擔語意</th>
          <th>原子操作</th>
          <th>記憶體行為</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>sorted set</td>
          <td>排序、排名、時間線</td>
          <td><code>ZINCRBY</code>、範圍操作</td>
          <td>隨成員數線性增長，單成員成本偏高</td>
      </tr>
      <tr>
          <td>bitmap</td>
          <td>大量實體的布林狀態</td>
          <td><code>SETBIT</code>、<code>BITOP</code></td>
          <td>取決於最大 offset，稠密時極省</td>
      </tr>
      <tr>
          <td>HyperLogLog</td>
          <td>不重複數量估計</td>
          <td><code>PFADD</code>、<code>PFMERGE</code></td>
          <td>固定約 12 KB，與元素數無關</td>
      </tr>
      <tr>
          <td>counter</td>
          <td>並發累加計數</td>
          <td><code>INCR</code>、<code>INCRBY</code></td>
          <td>單一整數，極小</td>
      </tr>
      <tr>
          <td>hash</td>
          <td>實體的可獨立更新欄位</td>
          <td><code>HINCRBY</code>、<code>HSET</code> 單欄位</td>
          <td>隨欄位數增長，小 hash 有編碼優化</td>
      </tr>
  </tbody>
</table>
<p>sorted set 與 bitmap 都能做「統計」，但語意不同：sorted set 保留每個成員與其分數、可取明細，bitmap 只保留是否、取不出成員但極省空間。需要明細與排名用 sorted set，只需要聚合數量用 bitmap 或 HLL。</p>
<p>HyperLogLog 與 set 的分界是「要不要精確、要不要成員」。set 精確且可列舉，記憶體隨成員數增長；HLL 估計且不可列舉，記憶體固定。同一個 UV 需求，用 set 在大流量下記憶體會失控，用 HLL 換取固定成本但放棄精確值，選擇取決於誤差容忍度。</p>
<h2 id="常見誤區">常見誤區</h2>
<p>把 sorted set 當成「能排序的 set」而忽略 score 設計，會造成排序漂移。score 是排序的唯一依據，相同 score 按字典序，需要穩定且可預測的排序時要把 tie-break 維度設計進 score。</p>
<p>把 bitmap 用在稀疏 id 上，會讓記憶體被最大 offset 撐爆。bitmap 省記憶體的前提是 offset 稠密，稀疏 id 要先映射成連續整數，或改用其他結構。</p>
<p>把 HyperLogLog 的估計值當精確計數，會在計費、財務這類要求精確的場景出錯。HLL 是有誤差的估計，它的價值在用固定小記憶體換量級判斷，不是替代精確計數。</p>
<p>把多步操作當成原子，會在並發下產生競態。<code>INCR</code> 加 <code>EXPIRE</code>、<code>ZADD</code> 加裁剪都是多個命令，需要原子保證時用 Lua script 或 <code>MULTI</code>/<code>EXEC</code> 包起來。</p>
<h2 id="判讀訊號">判讀訊號</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>判讀重點</th>
          <th>對應動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>排行榜在應用端拉全量排序</td>
          <td>沒用 sorted set 的範圍操作</td>
          <td>改 <code>ZREVRANGE</code> / <code>ZREVRANK</code> 在 Redis 排序</td>
      </tr>
      <tr>
          <td>bitmap key 記憶體異常膨脹</td>
          <td>offset 稀疏、被最大 id 撐大</td>
          <td>把 id 映射成稠密整數，或換結構</td>
      </tr>
      <tr>
          <td>UV 統計記憶體隨流量無上限增長</td>
          <td>用 set 做大基數去重</td>
          <td>容忍誤差時改 HyperLogLog 固定成本</td>
      </tr>
      <tr>
          <td>限流計數出現永不過期的髒 key</td>
          <td>INCR 與 EXPIRE 未原子化</td>
          <td>Lua script 包成原子單元</td>
      </tr>
      <tr>
          <td>hash 欄位新舊不一致、難判讀</td>
          <td>欄位責任不清、滑向半正式狀態</td>
          <td>重新判斷欄位能否獨立，必要時改 blob 整體替換</td>
      </tr>
  </tbody>
</table>
<p>排行榜在應用端拉全量排序是最常見的浪費：明明 sorted set 能 O(log N) 取 top-N，卻把整個集合讀回應用端用程式排序，在成員數大時造成不必要的網路與 CPU 成本。判讀方法是看排序邏輯在哪裡發生，把它推回 Redis 的範圍操作。</p>
<p>limit 計數的髒 key 不產生任何錯誤訊息，因此特別容易被忽略：INCR 成功但 EXPIRE 漏掉，這個 key 不會報錯，只是悄悄永不過期，問題要等到記憶體監控異常或限流誤判時才間接浮現。把 INCR 與 EXPIRE 原子化是最可靠的修法。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>要回到資料形狀的選型判斷，回到 <a href="/blog/backend/02-cache-redis/cache-data-shape-access-pattern/" data-link-title="2.8 Cache Data Shape 與 Access Pattern" data-link-desc="說明 cache value、key space、資料結構與存取型態如何反映服務語意。">2.8 cache data shape 與 access pattern</a>。要看這些型別在高並發下的讀寫邊界與連線管理，接著讀 <a href="/blog/backend/02-cache-redis/high-concurrency-access/" data-link-title="2.1 高併發下的 Redis 讀寫邊界" data-link-desc="說明高併發服務如何共用 Redis client、控制 pipeline 與避免 cache stampede">2.1 高併發下的 Redis 讀寫邊界</a>。要看 stream 型別承擔的事件流責任，接著讀 <a href="/blog/backend/02-cache-redis/pub-sub/" data-link-title="2.10 Pub/Sub 與即時 fan-out" data-link-desc="說明 Redis Pub/Sub 的即時廣播責任、at-most-once 邊界，以及何時升級到 Streams 或正式 message queue">2.10 Pub/Sub 與即時 fan-out</a> 與 <a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">模組三 message queue</a>。</p>
]]></content:encoded></item></channel></rss>