<?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>Caffeine on Tarragon</title><link>https://tarrragon.github.io/blog/tags/caffeine/</link><description>Recent content in Caffeine on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Tue, 16 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/caffeine/index.xml" rel="self" type="application/rss+xml"/><item><title>Caffeine + Redis 兩層 cache：搭起來很容易，跨實例失效才是全部的問題</title><link>https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/caffeine/two-tier-cache-invalidation/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/caffeine/two-tier-cache-invalidation/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/caffeine/" data-link-title="Caffeine" data-link-desc="JVM process-local cache、Window TinyLFU、Guava Cache 後繼">Caffeine&lt;/a> overview 的 implementation-layer deep article。選型層（Caffeine vs Redis、process-local 的定位）見 overview；本文只處理「決定用 L1 Caffeine + L2 Redis 後，跨實例一致性怎麼處理」。API 以 &lt;a href="https://github.com/ben-manes/caffeine/wiki">Caffeine wiki&lt;/a> 為準、最後檢查日 2026-06-16。&lt;/p>&lt;/blockquote>
&lt;h2 id="兩層-cache-搭起來容易難的在後面">兩層 cache 搭起來容易，難的在後面&lt;/h2>
&lt;p>L1 Caffeine + L2 Redis 的兩層 cache，讀寫路徑三十行 Java 就寫完：讀的時候先查 L1（process-local、奈秒級），miss 再查 L2（Redis、毫秒級），再 miss 才回源。它擋掉了大部分 Redis 的網路往返，對「每個請求重複讀同一份小資料」的場景效果立竿見影。&lt;/p>
&lt;p>真正的難度不在搭兩層，在「每個 JVM 實例有自己的 L1 副本」這個事實。假設有 10 個 application 實例，就有 10 份獨立的 Caffeine cache。實例 A 更新了某個 user 的資料、寫進 L2 Redis，但實例 B、C、D&amp;hellip; 的 L1 還握著舊值——它們不知道資料變了。下一個打到實例 B 的請求，L1 命中，回的是舊值。Redis 是對的，但讀不到 Redis，因為 L1 先攔截了。&lt;/p>
&lt;p>這就是兩層 cache 的核心問題：L1 的速度來自「不跟任何人協調」，而一致性恰恰需要協調。本文聚焦這個矛盾——兩層讀寫路徑只是背景，跨實例 invalidation 才是全部的工程量。&lt;/p>
&lt;h2 id="核心概念l1-的-stale-從哪裡來">核心概念：L1 的 stale 從哪裡來&lt;/h2>
&lt;p>兩層 cache 的一致性問題，根源是 L1 的三個特性：&lt;/p>
&lt;p>&lt;strong>L1 是 per-instance 的私有副本&lt;/strong>。Caffeine 活在 JVM heap 內，每個實例一份。這是它快的原因（無網路、無序列化），也是它難一致的原因（無法被其他實例直接更新或清除）。L2 Redis 是共享的，所以 L2 一致相對容易；L1 才是 stale 的來源。&lt;/p>
&lt;p>&lt;strong>寫入只更新本地 L1 + 共享 L2&lt;/strong>。實例 A 處理一個更新：寫 L2 Redis（所有實例可見）+ 更新或清除自己的 L1。但 A 沒有辦法直接碰 B 的 L1——B 的 L1 還是舊的，直到它自己過期或被通知。&lt;/p>
&lt;p>&lt;strong>沒有通知機制，L1 只能靠 TTL 自然過期&lt;/strong>。如果不做任何跨實例協調，L1 的 stale window 就等於 L1 的 TTL。把 L1 TTL 設短（幾秒到幾十秒）是最簡單的「容忍 stale」策略——犧牲一點新鮮度換掉協調的複雜度。需要更快失效就得主動廣播。&lt;/p>
&lt;p>跨實例失效的標準解法是用 L2 Redis 的 pub/sub 當廣播通道：任一實例更新資料時，往一個 channel 發一條「key X 失效了」的訊息，所有實例訂閱這個 channel、收到就清掉自己 L1 對應的 entry。這把「各自為政的 L1」連成一個能協同失效的網。&lt;/p>
&lt;h2 id="配置兩層讀寫--pubsub-失效的程式碼">配置：兩層讀寫 + pub/sub 失效的程式碼&lt;/h2>
&lt;p>兩層讀取路徑（L1 → L2 → origin）：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-java" data-lang="java">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">public&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">User&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nf">getUser&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">String&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c1">// L1：Caffeine、奈秒級、命中就回&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">User&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">u&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">l1&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">getIfPresent&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">if&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">u&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">!=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">null&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">return&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">u&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c1">// L1 miss → L2 Redis、毫秒級&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">String&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">json&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">redis&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;user:&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">+&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">if&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">json&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">!=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">null&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">u&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">deserialize&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">json&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">l1&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">put&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">u&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">// 回填 L1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">return&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">u&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c1">// L2 miss → 回源 + 雙層回填&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">u&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">userRepository&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">findById&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">redis&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">setex&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;user:&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">+&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">300&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">serialize&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">u&lt;/span>&lt;span class="p">));&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">// L2 TTL 5 分鐘&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">l1&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">put&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">u&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">// L1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">return&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">u&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>跨實例失效（寫入時往 Redis pub/sub 廣播、所有實例清 L1）：&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/02-cache-redis/vendors/caffeine/" data-link-title="Caffeine" data-link-desc="JVM process-local cache、Window TinyLFU、Guava Cache 後繼">Caffeine</a> overview 的 implementation-layer deep article。選型層（Caffeine vs Redis、process-local 的定位）見 overview；本文只處理「決定用 L1 Caffeine + L2 Redis 後，跨實例一致性怎麼處理」。API 以 <a href="https://github.com/ben-manes/caffeine/wiki">Caffeine wiki</a> 為準、最後檢查日 2026-06-16。</p></blockquote>
<h2 id="兩層-cache-搭起來容易難的在後面">兩層 cache 搭起來容易，難的在後面</h2>
<p>L1 Caffeine + L2 Redis 的兩層 cache，讀寫路徑三十行 Java 就寫完：讀的時候先查 L1（process-local、奈秒級），miss 再查 L2（Redis、毫秒級），再 miss 才回源。它擋掉了大部分 Redis 的網路往返，對「每個請求重複讀同一份小資料」的場景效果立竿見影。</p>
<p>真正的難度不在搭兩層，在「每個 JVM 實例有自己的 L1 副本」這個事實。假設有 10 個 application 實例，就有 10 份獨立的 Caffeine cache。實例 A 更新了某個 user 的資料、寫進 L2 Redis，但實例 B、C、D&hellip; 的 L1 還握著舊值——它們不知道資料變了。下一個打到實例 B 的請求，L1 命中，回的是舊值。Redis 是對的，但讀不到 Redis，因為 L1 先攔截了。</p>
<p>這就是兩層 cache 的核心問題：L1 的速度來自「不跟任何人協調」，而一致性恰恰需要協調。本文聚焦這個矛盾——兩層讀寫路徑只是背景，跨實例 invalidation 才是全部的工程量。</p>
<h2 id="核心概念l1-的-stale-從哪裡來">核心概念：L1 的 stale 從哪裡來</h2>
<p>兩層 cache 的一致性問題，根源是 L1 的三個特性：</p>
<p><strong>L1 是 per-instance 的私有副本</strong>。Caffeine 活在 JVM heap 內，每個實例一份。這是它快的原因（無網路、無序列化），也是它難一致的原因（無法被其他實例直接更新或清除）。L2 Redis 是共享的，所以 L2 一致相對容易；L1 才是 stale 的來源。</p>
<p><strong>寫入只更新本地 L1 + 共享 L2</strong>。實例 A 處理一個更新：寫 L2 Redis（所有實例可見）+ 更新或清除自己的 L1。但 A 沒有辦法直接碰 B 的 L1——B 的 L1 還是舊的，直到它自己過期或被通知。</p>
<p><strong>沒有通知機制，L1 只能靠 TTL 自然過期</strong>。如果不做任何跨實例協調，L1 的 stale window 就等於 L1 的 TTL。把 L1 TTL 設短（幾秒到幾十秒）是最簡單的「容忍 stale」策略——犧牲一點新鮮度換掉協調的複雜度。需要更快失效就得主動廣播。</p>
<p>跨實例失效的標準解法是用 L2 Redis 的 pub/sub 當廣播通道：任一實例更新資料時，往一個 channel 發一條「key X 失效了」的訊息，所有實例訂閱這個 channel、收到就清掉自己 L1 對應的 entry。這把「各自為政的 L1」連成一個能協同失效的網。</p>
<h2 id="配置兩層讀寫--pubsub-失效的程式碼">配置：兩層讀寫 + pub/sub 失效的程式碼</h2>
<p>兩層讀取路徑（L1 → L2 → origin）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">public</span><span class="w"> </span><span class="n">User</span><span class="w"> </span><span class="nf">getUser</span><span class="p">(</span><span class="n">String</span><span class="w"> </span><span class="n">id</span><span class="p">)</span><span class="w"> </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="c1">// L1：Caffeine、奈秒級、命中就回</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">    </span><span class="n">User</span><span class="w"> </span><span class="n">u</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">l1</span><span class="p">.</span><span class="na">getIfPresent</span><span class="p">(</span><span class="n">id</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="p">(</span><span class="n">u</span><span class="w"> </span><span class="o">!=</span><span class="w"> </span><span class="kc">null</span><span class="p">)</span><span class="w"> </span><span class="k">return</span><span class="w"> </span><span class="n">u</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">    </span><span class="c1">// L1 miss → L2 Redis、毫秒級</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">    </span><span class="n">String</span><span class="w"> </span><span class="n">json</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">redis</span><span class="p">.</span><span class="na">get</span><span class="p">(</span><span class="s">&#34;user:&#34;</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="n">id</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="p">(</span><span class="n">json</span><span class="w"> </span><span class="o">!=</span><span class="w"> </span><span class="kc">null</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">        </span><span class="n">u</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">deserialize</span><span class="p">(</span><span class="n">json</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">        </span><span class="n">l1</span><span class="p">.</span><span class="na">put</span><span class="p">(</span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">u</span><span class="p">);</span><span class="w">                 </span><span class="c1">// 回填 L1</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">        </span><span class="k">return</span><span class="w"> </span><span class="n">u</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">    </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w">    </span><span class="c1">// L2 miss → 回源 + 雙層回填</span><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w">    </span><span class="n">u</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">userRepository</span><span class="p">.</span><span class="na">findById</span><span class="p">(</span><span class="n">id</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w">    </span><span class="n">redis</span><span class="p">.</span><span class="na">setex</span><span class="p">(</span><span class="s">&#34;user:&#34;</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">300</span><span class="p">,</span><span class="w"> </span><span class="n">serialize</span><span class="p">(</span><span class="n">u</span><span class="p">));</span><span class="w">  </span><span class="c1">// L2 TTL 5 分鐘</span><span class="w">
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="w">    </span><span class="n">l1</span><span class="p">.</span><span class="na">put</span><span class="p">(</span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">u</span><span class="p">);</span><span class="w">                     </span><span class="c1">// L1</span><span class="w">
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="w">    </span><span class="k">return</span><span class="w"> </span><span class="n">u</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="w"></span><span class="p">}</span></span></span></code></pre></div><p>跨實例失效（寫入時往 Redis pub/sub 廣播、所有實例清 L1）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// L1 設短 TTL 當保險（廣播漏掉時的上界）</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w"></span><span class="n">Cache</span><span class="o">&lt;</span><span class="n">String</span><span class="p">,</span><span class="w"> </span><span class="n">User</span><span class="o">&gt;</span><span class="w"> </span><span class="n">l1</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Caffeine</span><span class="p">.</span><span class="na">newBuilder</span><span class="p">()</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">    </span><span class="p">.</span><span class="na">maximumSize</span><span class="p">(</span><span class="n">10_000</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">    </span><span class="p">.</span><span class="na">expireAfterWrite</span><span class="p">(</span><span class="n">Duration</span><span class="p">.</span><span class="na">ofSeconds</span><span class="p">(</span><span class="n">30</span><span class="p">))</span><span class="w">  </span><span class="c1">// 廣播失效之外的兜底</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">    </span><span class="p">.</span><span class="na">build</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></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w"></span><span class="c1">// 寫入：更新 L2 + 廣播失效</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w"></span><span class="kd">public</span><span class="w"> </span><span class="kt">void</span><span class="w"> </span><span class="nf">updateUser</span><span class="p">(</span><span class="n">User</span><span class="w"> </span><span class="n">u</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">    </span><span class="n">userRepository</span><span class="p">.</span><span class="na">save</span><span class="p">(</span><span class="n">u</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">    </span><span class="n">redis</span><span class="p">.</span><span class="na">setex</span><span class="p">(</span><span class="s">&#34;user:&#34;</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="n">u</span><span class="p">.</span><span class="na">id</span><span class="p">(),</span><span class="w"> </span><span class="n">300</span><span class="p">,</span><span class="w"> </span><span class="n">serialize</span><span class="p">(</span><span class="n">u</span><span class="p">));</span><span class="w">  </span><span class="c1">// 更新 L2（TTL 對齊讀路徑的 300s）</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">    </span><span class="n">redis</span><span class="p">.</span><span class="na">publish</span><span class="p">(</span><span class="s">&#34;cache:invalidate&#34;</span><span class="p">,</span><span class="w"> </span><span class="n">u</span><span class="p">.</span><span class="na">id</span><span class="p">());</span><span class="w">   </span><span class="c1">// 廣播給所有實例</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">    </span><span class="n">l1</span><span class="p">.</span><span class="na">invalidate</span><span class="p">(</span><span class="n">u</span><span class="p">.</span><span class="na">id</span><span class="p">());</span><span class="w">                        </span><span class="c1">// 清自己的 L1</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w"></span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w"></span><span class="c1">// 每個實例啟動時訂閱、收到就清本地 L1</span><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w"></span><span class="n">redis</span><span class="p">.</span><span class="na">subscribe</span><span class="p">(</span><span class="s">&#34;cache:invalidate&#34;</span><span class="p">,</span><span class="w"> </span><span class="n">message</span><span class="w"> </span><span class="o">-&gt;</span><span class="w"> </span><span class="n">l1</span><span class="p">.</span><span class="na">invalidate</span><span class="p">(</span><span class="n">message</span><span class="p">));</span></span></span></code></pre></div><p>關鍵：L1 的短 TTL 是廣播機制的兜底——即使某個實例漏掉一條 pub/sub 訊息（pub/sub 是 fire-and-forget、訂閱者離線會錯過），L1 最多 stale 到 TTL 過期。廣播負責「快」，TTL 負責「最終」。</p>
<h2 id="production-故障演練">Production 故障演練</h2>
<h3 id="case-1更新後其他實例持續回舊值">Case 1：更新後其他實例持續回舊值</h3>
<p><strong>徵兆</strong>：使用者改了資料、自己刷新看到新值（打到處理寫入的實例），但同事看到的還是舊值（打到別的實例），且持續好幾分鐘。</p>
<p><strong>根因</strong>：只更新了寫入實例的 L1 與 L2，沒有跨實例廣播。其他實例的 L1 還握著舊值、攔截了讀取、根本沒查到已更新的 L2。stale window 等於 L1 TTL（如果 TTL 設很長就是好幾分鐘）。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>加 Redis pub/sub 廣播失效，寫入時通知所有實例清 L1</li>
<li>廣播之外把 L1 TTL 設短當兜底（幾秒到幾十秒），縮短漏訊息時的 stale 上界</li>
<li>強一致需求的資料根本不該進 L1——L1 的本質就是「容忍一個 stale window 換速度」</li>
<li>對應 <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</a> 的新鮮度邊界判斷</li>
</ol>
<h3 id="case-2pubsub-漏訊息個別實例-l1-卡舊值">Case 2：pub/sub 漏訊息、個別實例 L1 卡舊值</h3>
<p><strong>徵兆</strong>：多數實例更新後正常，但偶爾某個實例持續回舊值，直到重啟或 TTL 過期。</p>
<p><strong>根因</strong>：Redis pub/sub 是 fire-and-forget——訂閱者在訊息發出的瞬間若斷線（網路抖動、GC pause、重連中），就永久錯過那條失效訊息。沒有兜底的話，那個實例的 L1 會一直 stale 到 TTL。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>L1 TTL 設短是必要兜底，不要依賴 pub/sub 100% 送達（它不保證）</li>
<li>需要可靠失效用 Redis Streams（有 consumer group + 重放）取代 pub/sub，代價是複雜度</li>
<li>監控各實例的 L1 命中率與 stale 投訴，個別實例異常代表漏訊息</li>
<li>接受 pub/sub 的 at-most-once 語意，用 TTL 補足最終一致</li>
</ol>
<h3 id="case-3l1-太大撐爆-heapfull-gc-風暴">Case 3：L1 太大撐爆 heap、Full GC 風暴</h3>
<p><strong>徵兆</strong>：加了 L1 後 application 的 GC 時間變長、偶發 Full GC 導致請求暫停（STW），延遲尖刺。</p>
<p><strong>根因</strong>：Caffeine 預設 on-heap，L1 的 <code>maximumSize</code> 設太大、cache 的物件佔據大量 heap，增加 GC 掃描與回收壓力。大物件 + 大容量直接推高 old gen 佔用。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><code>maximumSize</code> 對齊 heap 預算，用 <code>recordStats()</code> 看實際記憶體佔用</li>
<li>用 <code>maximumWeight</code> + weigher 按物件實際大小限制（不只筆數），避免大物件撐爆</li>
<li>L1 只放「小、熱、重複讀」的資料，大物件留 L2 Redis（off-heap 視角）</li>
<li>監控 GC 時間與 old gen 佔用，L1 容量是可調的 GC 旋鈕</li>
</ol>
<h3 id="case-4l1-快取了不該快取的-per-user-大物件">Case 4：L1 快取了不該快取的 per-user 大物件</h3>
<p><strong>徵兆</strong>：L1 命中率偏低、heap 壓力大、效果不如預期。</p>
<p><strong>根因</strong>：把 per-user 的大物件或低重複率的資料放 L1。L1 的價值在「少量資料被大量重複讀」（如設定檔、熱門商品、權限表），per-user 資料每個 user 一份、重複率低、塞滿 L1 又命中率低。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>L1 只放高重複率的共享熱資料（config、feature flag、熱門 item、權限）</li>
<li>per-user 低重複資料放 L2 Redis 就好，不要進 L1</li>
<li>用 <code>recordStats()</code> 的 hit rate 驗證——L1 命中率低代表放錯資料</li>
<li>對應 <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.4 cache data shape</a> 的存取形狀判斷</li>
</ol>
<h3 id="case-5refreshafterwrite-與-expireafterwrite-混淆行為不如預期">Case 5：refreshAfterWrite 與 expireAfterWrite 混淆、行為不如預期</h3>
<p><strong>徵兆</strong>：以為設了自動刷新、結果到期還是 miss 阻塞回源；或以為會過期、結果一直回舊值。</p>
<p><strong>根因</strong>：<code>expireAfterWrite</code>（到期 entry 失效、下次讀 miss + 阻塞載入）跟 <code>refreshAfterWrite</code>（到期後第一個讀觸發背景刷新、舊值立即回、不阻塞）語意不同，混用導致行為不符預期。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>要「到期就不可用」用 <code>expireAfterWrite</code>；要「到期背景刷新、舊值先頂」用 <code>refreshAfterWrite</code></li>
<li>兩者可組合：<code>refreshAfterWrite</code> 短 + <code>expireAfterWrite</code> 長，得到「背景刷新 + 最終過期」</li>
<li><code>refreshAfterWrite</code> 避免 <a href="/blog/backend/02-cache-redis/cases/failure-cache-stampede-rollout-regression/" data-link-title="2.C9 反例：快取切換引發 Stampede 回歸" data-link-desc="快取策略切換若缺乏保護，會導致回源壓力與錯誤率連鎖上升。">stampede</a>（舊值先服務、單一背景刷新），適合熱 key</li>
<li>用 <code>LoadingCache</code> 的 <code>build(key -&gt; load)</code> 配 refresh，行為以官方 wiki 為準</li>
</ol>
<h2 id="capacity--cost-邊界">Capacity / cost 邊界</h2>
<p>兩層 cache 的容量判讀，核心在 L1 命中率、stale window 與 GC：</p>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>健康區間</th>
          <th>警戒與動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>L1 hit rate</td>
          <td>高（放對高重複資料）</td>
          <td>低 → 放錯資料（per-user 大物件）、改放 L2</td>
      </tr>
      <tr>
          <td>L1 stale window</td>
          <td>≤ L1 TTL（廣播正常更短）</td>
          <td>過長 → TTL 太長或廣播沒做</td>
      </tr>
      <tr>
          <td>GC 時間 / old gen 佔用</td>
          <td>穩定、無 Full GC 風暴</td>
          <td>升高 → L1 太大、降 maximumSize / maximumWeight</td>
      </tr>
      <tr>
          <td>pub/sub 失效送達率</td>
          <td>高（但不保證 100%）</td>
          <td>漏訊息 → TTL 兜底、或改 Streams</td>
      </tr>
      <tr>
          <td>L1 vs L2 命中分層</td>
          <td>L1 擋大部分、L2 擋 L1 miss</td>
          <td>L1 命中低 → 兩層沒分工好</td>
      </tr>
  </tbody>
</table>
<p>撞牆後的路由判斷：</p>
<ul>
<li><strong>需要強一致 / 不能容忍任何 stale</strong>：L1 process-local 本質有 stale window，不該放這類資料。強一致只用 <a href="/blog/backend/02-cache-redis/vendors/redis/" data-link-title="Redis" data-link-desc="OSS in-memory data structure store、cache 主流">Redis / Valkey</a> 共享層（甚至直接回源）。</li>
<li><strong>L1 容量需求超過 heap</strong>：on-heap Caffeine 撐不住，用 off-heap 方案（Ehcache off-heap tier）或把資料留 L2 Redis。</li>
<li><strong>可靠失效（不能漏）</strong>：pub/sub 是 at-most-once，要可靠用 Redis Streams 的 consumer group，代價是複雜度。</li>
<li><strong>非 JVM 服務</strong>：Caffeine 綁 JVM，其他語言用對應的 process-local cache（Go ristretto、Rust moka），兩層架構的思路相同。</li>
</ul>
<h2 id="整合--下一步">整合 / 下一步</h2>
<p>兩層 cache 的工程量集中在跨實例一致性，它跟多個議題交織：</p>
<ul>
<li><strong>跟 <a href="/blog/backend/02-cache-redis/vendors/caffeine/" data-link-title="Caffeine" data-link-desc="JVM process-local cache、Window TinyLFU、Guava Cache 後繼">Caffeine overview</a></strong>：overview 點到「跨實例 invalidation 是固有限制」、本文展開 pub/sub 廣播 + TTL 兜底的具體解法。</li>
<li><strong>跟 <a href="/blog/backend/02-cache-redis/vendors/redis/connection-pipeline-latency/" data-link-title="Redis 連線與 pipeline：RTT 稅、連線池與一次往返打包多命令" data-link-desc="Redis 單命令通常微秒級執行，但 application 端量到的延遲是毫秒級——差距全在網路往返（RTT）。pipelining 的本質不是『批次發命令』，是把 N 次 RTT 壓成 1 次。本文展開 RTT 會計、連線池配置、pipeline 與 MULTI 的差異、5 個把連線與往返寫成延遲與正確性問題的 production 踩坑，以及連線模型撞牆的邊界">Redis connection / pipeline</a></strong>：L1 的價值正是消除 L2 Redis 的 RTT 稅，兩層 cache 是 RTT 優化的極致（L1 命中連網路都省）。</li>
<li><strong>跟 <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.6 high concurrency</a></strong>：hot key 的兩層解法（local cache + Redis）就是這個架構，L1 擋掉打在單一熱 key 的洪峰。</li>
<li><strong>跟 <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 提供配對引擎所需的次毫秒延遲快取層">Tinder</a></strong>：每次互動查多個 cache 的服務，L1 Caffeine 可擋掉重複讀、降低 L2（ElastiCache）的壓力與 RTT——但 per-user 配對資料重複率低、要判斷哪些放得進 L1。</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li>上游 vendor 頁：<a href="/blog/backend/02-cache-redis/vendors/caffeine/" data-link-title="Caffeine" data-link-desc="JVM process-local cache、Window TinyLFU、Guava Cache 後繼">Caffeine</a></li>
<li>L2 對照：<a href="/blog/backend/02-cache-redis/vendors/redis/memory-eviction-tuning/" data-link-title="Redis 記憶體與淘汰調校：maxmemory-policy、LFU 與碎片化的實戰判讀" data-link-desc="Redis 的記憶體是一條會在半夜爆掉的曲線：maxmemory 設多少、policy 選 LRU 還 LFU、碎片化什麼時候開始吃掉 30% RAM、OOM 時 noeviction 怎麼讓寫入全部失敗。本文展開 Redis 記憶體會計模型、eviction policy 的選型判讀、5 個把記憶體配置寫成 production 事故的踩坑，以及單機記憶體撞牆後該往 cluster 還是 DragonflyDB 走的邊界">Redis 記憶體與淘汰</a>、<a href="/blog/backend/02-cache-redis/vendors/redis/connection-pipeline-latency/" data-link-title="Redis 連線與 pipeline：RTT 稅、連線池與一次往返打包多命令" data-link-desc="Redis 單命令通常微秒級執行，但 application 端量到的延遲是毫秒級——差距全在網路往返（RTT）。pipelining 的本質不是『批次發命令』，是把 N 次 RTT 壓成 1 次。本文展開 RTT 會計、連線池配置、pipeline 與 MULTI 的差異、5 個把連線與往返寫成延遲與正確性問題的 production 踩坑，以及連線模型撞牆的邊界">Redis 連線 / pipeline</a></li>
<li>上游概念：<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.6 high concurrency</a>、<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></li>
<li>Methodology：<a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章寫作方法論</a></li>
</ul>
]]></content:encoded></item></channel></rss>