<?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>State-Externalization on Tarragon</title><link>https://tarrragon.github.io/blog/tags/state-externalization/</link><description>Recent content in State-Externalization on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Fri, 03 Jul 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/state-externalization/index.xml" rel="self" type="application/rss+xml"/><item><title>Stateless 設計原則</title><link>https://tarrragon.github.io/blog/devops/02-horizontal-scaling/stateless-design/</link><pubDate>Fri, 03 Jul 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/devops/02-horizontal-scaling/stateless-design/</guid><description>&lt;p>Stateless 設計原則是讓每個實例都不保存「只有我這台知道」的狀態，這樣任何實例都能處理任何請求。它是 &lt;a href="https://tarrragon.github.io/blog/devops/01-load-balancing/scaling-prerequisite/" data-link-title="LB 是水平擴展的前提" data-link-desc="想靠加實例分攤流量卻發現擴不動時，釐清水平擴展的兩個前提——流量要分得進新實例（LB）、任何實例要能接任何請求（無狀態）">水平擴展的前提&lt;/a>——負載平衡能把新實例接進來，但實例之間若有本機獨佔的狀態，流量分過去也服務不了。前一章確立了「無狀態是前提」，這一章講怎麼真的做到：什麼會破壞它、隱藏的狀態在哪、以及做不到完全無狀態的那些例外怎麼辦。&lt;/p>
&lt;p>無狀態的定義很精確：處理一個請求時，不依賴前一個請求留在本機記憶體或本機磁碟的資料。每個請求要嘛自帶所需的一切、要嘛從共享的外部儲存讀。判斷一個服務是不是無狀態，有一個乾淨的測試：隨機停掉一台實例、把它的流量重新分配到其他台，如果用戶完全無感，就是無狀態；如果有些用戶的資料不見了（購物車空了、上傳中斷、連線斷掉），那台實例上有本機獨佔的狀態。&lt;/p>
&lt;h2 id="破壞無狀態的常見寫法">破壞無狀態的常見寫法&lt;/h2>
&lt;p>本機狀態很少是故意留的，多半是幾種常見寫法不知不覺帶進來的。把這些列出來，才知道要外置什麼：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>本機 session&lt;/strong>：把登入後的 session 存在實例的記憶體裡。這是最典型的——那個用戶的後續請求只能回到這台，換一台就等於沒登入。&lt;/li>
&lt;li>&lt;strong>上傳暫存&lt;/strong>：分段上傳的檔案先存本機磁碟再組合。上傳到一半換實例，前面的分段在別台、找不到。&lt;/li>
&lt;li>&lt;strong>本機快取&lt;/strong>：把計算結果快取在進程記憶體。功能上不算錯（快取失效重算就好），但會讓不同實例的快取不一致、命中率隨實例數稀釋。&lt;/li>
&lt;li>&lt;strong>WebSocket 或長連線&lt;/strong>：連線本身綁在某台實例上。連線的狀態（訂閱了什麼、在哪個房間）留在那台，實例掛了連線得重建。&lt;/li>
&lt;li>&lt;strong>本機定時任務&lt;/strong>：在每個實例上都跑同一個 cron。多實例時這個 job 會被執行多次——這是無狀態的一個真例外，下面單獨談。&lt;/li>
&lt;li>&lt;strong>跨請求的記憶體狀態&lt;/strong>：任何「這次請求改了一個全域變數、下次請求會讀到」的設計，都把狀態綁死在單一實例上。&lt;/li>
&lt;/ul>
&lt;h2 id="隱式狀態比顯式的更難抓">隱式狀態比顯式的更難抓&lt;/h2>
&lt;p>上面那些是顯式的狀態，比較容易發現。更難抓的是隱式狀態——那些不長得像「狀態」、但實際上綁在某台實例上的資料。在途的資料流（一個還沒處理完的 streaming 請求）、TLS 的 session resumption（重用前一次握手的參數）、限流器的計數狀態（這台記得某個 IP 打了幾次）、以及連線的預熱狀態（這台跟資料庫的連線池已經熱好了）。這些在單實例時完全無感，一旦水平擴展，「這台記得、那台不記得」的落差就會冒出來——限流在每台各算各的、預熱在新實例上還沒完成。抓隱式狀態要用前面那個測試：真的停一台、看有沒有狀態只有它記得。&lt;/p>
&lt;h2 id="外置狀態讓實例對等">外置狀態，讓實例對等&lt;/h2>
&lt;p>做到無狀態的辦法是把狀態從實例本地移到共享的外部儲存。本站 collector 是個乾淨的例子：collector 實例不在記憶體保存任何查詢狀態，所有持久化的資料都在 PostgreSQL，所以任何一個 collector 接收的事件，都能被任何一個 dashboard 查到。實例之間沒有需要協調的狀態，負載平衡用 round-robin 或 least-connections 隨意分配、不需要 sticky session——因為 collector 不保存 session 狀態，哪台接都一樣。&lt;/p>
&lt;p>實例並非真的一點記憶體狀態都沒有。collector 有一個固定容量的背壓 buffer（一個 channel），這是一種留在本機的 in-memory 狀態。但這種狀態是易失的緩衝、不是需要持久的業務狀態——事件要回了 202 才算收下，buffer 滿了就回 429，所以實例 crash 掉這段 buffer 不影響資料正確性。無狀態不是「零記憶體狀態」，是「沒有 crash 掉會遺失業務正確性的本機狀態」。這個區分很重要：易失的緩衝可以留在本機，需要對帳、需要持久的狀態才必須外置。&lt;/p>
&lt;h2 id="定時任務是無狀態的例外">定時任務是無狀態的例外&lt;/h2>
&lt;p>有一種工作不能讓每個實例各跑一份：定時任務。降採樣、清理、對帳這類 job，如果每台實例都跑，就會被執行 N 次——重複扣款、重複清理、對帳算錯。這是無狀態設計裡一個真正的例外：實例本身無狀態、可以任意增減，但這個 job 必須跨實例互斥、只由一台執行。&lt;/p>
&lt;p>處理的辦法是把「誰來跑」這個決定也外置。用 PostgreSQL 的 advisory lock 或外部的分散式鎖，讓要跑 job 的實例先搶鎖、搶到的才跑、其他的跳過。這樣實例仍然對等（誰搶到誰跑，不指定特定一台），但 job 保證只執行一次。水平擴展一個有定時任務的服務時，這是最容易漏掉的一步——擴展前 job 每天跑一次，擴到三台後突然每天跑三次。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>Session 該怎麼處理（sticky、外部 store、還是無狀態 token）→ &lt;a href="https://tarrragon.github.io/blog/devops/02-horizontal-scaling/session-handling/" data-link-title="Session 處理" data-link-desc="多實例下要決定用戶登入狀態怎麼放時，比較 sticky session、外部 session store、無狀態 token 三種途徑，以及剛寫完就要讀到的 session 一致性怎麼保證">Session 處理&lt;/a>&lt;/li>
&lt;li>外置的狀態放哪種共享儲存 → &lt;a href="https://tarrragon.github.io/blog/devops/02-horizontal-scaling/shared-storage-selection/" data-link-title="Shared storage 選型" data-link-desc="把外置的狀態放進共享儲存時，按存取型態與狀態性質選 DB、KV、物件儲存，並處理多實例共享一個 DB 帶來的讀路徑與連線瓶頸">Shared storage 選型&lt;/a>&lt;/li>
&lt;li>無狀態是水平擴展的前提，前提本身 → &lt;a href="https://tarrragon.github.io/blog/devops/01-load-balancing/scaling-prerequisite/" data-link-title="LB 是水平擴展的前提" data-link-desc="想靠加實例分攤流量卻發現擴不動時，釐清水平擴展的兩個前提——流量要分得進新實例（LB）、任何實例要能接任何請求（無狀態）">LB 是水平擴展的前提&lt;/a>&lt;/li>
&lt;li>Collector 多實例的完整 stateless 設計 → &lt;a href="https://tarrragon.github.io/blog/monitoring/04-collector/" data-link-title="模組四：Collector 設計" data-link-desc="收 → 驗 → 存 → 查 → 觸發的完整鏈路 — Go 單一 binary、可插拔 Storage Backend、rule engine">Monitoring Collector&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>Stateless 設計原則是讓每個實例都不保存「只有我這台知道」的狀態，這樣任何實例都能處理任何請求。它是 <a href="/blog/devops/01-load-balancing/scaling-prerequisite/" data-link-title="LB 是水平擴展的前提" data-link-desc="想靠加實例分攤流量卻發現擴不動時，釐清水平擴展的兩個前提——流量要分得進新實例（LB）、任何實例要能接任何請求（無狀態）">水平擴展的前提</a>——負載平衡能把新實例接進來，但實例之間若有本機獨佔的狀態，流量分過去也服務不了。前一章確立了「無狀態是前提」，這一章講怎麼真的做到：什麼會破壞它、隱藏的狀態在哪、以及做不到完全無狀態的那些例外怎麼辦。</p>
<p>無狀態的定義很精確：處理一個請求時，不依賴前一個請求留在本機記憶體或本機磁碟的資料。每個請求要嘛自帶所需的一切、要嘛從共享的外部儲存讀。判斷一個服務是不是無狀態，有一個乾淨的測試：隨機停掉一台實例、把它的流量重新分配到其他台，如果用戶完全無感，就是無狀態；如果有些用戶的資料不見了（購物車空了、上傳中斷、連線斷掉），那台實例上有本機獨佔的狀態。</p>
<h2 id="破壞無狀態的常見寫法">破壞無狀態的常見寫法</h2>
<p>本機狀態很少是故意留的，多半是幾種常見寫法不知不覺帶進來的。把這些列出來，才知道要外置什麼：</p>
<ul>
<li><strong>本機 session</strong>：把登入後的 session 存在實例的記憶體裡。這是最典型的——那個用戶的後續請求只能回到這台，換一台就等於沒登入。</li>
<li><strong>上傳暫存</strong>：分段上傳的檔案先存本機磁碟再組合。上傳到一半換實例，前面的分段在別台、找不到。</li>
<li><strong>本機快取</strong>：把計算結果快取在進程記憶體。功能上不算錯（快取失效重算就好），但會讓不同實例的快取不一致、命中率隨實例數稀釋。</li>
<li><strong>WebSocket 或長連線</strong>：連線本身綁在某台實例上。連線的狀態（訂閱了什麼、在哪個房間）留在那台，實例掛了連線得重建。</li>
<li><strong>本機定時任務</strong>：在每個實例上都跑同一個 cron。多實例時這個 job 會被執行多次——這是無狀態的一個真例外，下面單獨談。</li>
<li><strong>跨請求的記憶體狀態</strong>：任何「這次請求改了一個全域變數、下次請求會讀到」的設計，都把狀態綁死在單一實例上。</li>
</ul>
<h2 id="隱式狀態比顯式的更難抓">隱式狀態比顯式的更難抓</h2>
<p>上面那些是顯式的狀態，比較容易發現。更難抓的是隱式狀態——那些不長得像「狀態」、但實際上綁在某台實例上的資料。在途的資料流（一個還沒處理完的 streaming 請求）、TLS 的 session resumption（重用前一次握手的參數）、限流器的計數狀態（這台記得某個 IP 打了幾次）、以及連線的預熱狀態（這台跟資料庫的連線池已經熱好了）。這些在單實例時完全無感，一旦水平擴展，「這台記得、那台不記得」的落差就會冒出來——限流在每台各算各的、預熱在新實例上還沒完成。抓隱式狀態要用前面那個測試：真的停一台、看有沒有狀態只有它記得。</p>
<h2 id="外置狀態讓實例對等">外置狀態，讓實例對等</h2>
<p>做到無狀態的辦法是把狀態從實例本地移到共享的外部儲存。本站 collector 是個乾淨的例子：collector 實例不在記憶體保存任何查詢狀態，所有持久化的資料都在 PostgreSQL，所以任何一個 collector 接收的事件，都能被任何一個 dashboard 查到。實例之間沒有需要協調的狀態，負載平衡用 round-robin 或 least-connections 隨意分配、不需要 sticky session——因為 collector 不保存 session 狀態，哪台接都一樣。</p>
<p>實例並非真的一點記憶體狀態都沒有。collector 有一個固定容量的背壓 buffer（一個 channel），這是一種留在本機的 in-memory 狀態。但這種狀態是易失的緩衝、不是需要持久的業務狀態——事件要回了 202 才算收下，buffer 滿了就回 429，所以實例 crash 掉這段 buffer 不影響資料正確性。無狀態不是「零記憶體狀態」，是「沒有 crash 掉會遺失業務正確性的本機狀態」。這個區分很重要：易失的緩衝可以留在本機，需要對帳、需要持久的狀態才必須外置。</p>
<h2 id="定時任務是無狀態的例外">定時任務是無狀態的例外</h2>
<p>有一種工作不能讓每個實例各跑一份：定時任務。降採樣、清理、對帳這類 job，如果每台實例都跑，就會被執行 N 次——重複扣款、重複清理、對帳算錯。這是無狀態設計裡一個真正的例外：實例本身無狀態、可以任意增減，但這個 job 必須跨實例互斥、只由一台執行。</p>
<p>處理的辦法是把「誰來跑」這個決定也外置。用 PostgreSQL 的 advisory lock 或外部的分散式鎖，讓要跑 job 的實例先搶鎖、搶到的才跑、其他的跳過。這樣實例仍然對等（誰搶到誰跑，不指定特定一台），但 job 保證只執行一次。水平擴展一個有定時任務的服務時，這是最容易漏掉的一步——擴展前 job 每天跑一次，擴到三台後突然每天跑三次。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>Session 該怎麼處理（sticky、外部 store、還是無狀態 token）→ <a href="/blog/devops/02-horizontal-scaling/session-handling/" data-link-title="Session 處理" data-link-desc="多實例下要決定用戶登入狀態怎麼放時，比較 sticky session、外部 session store、無狀態 token 三種途徑，以及剛寫完就要讀到的 session 一致性怎麼保證">Session 處理</a></li>
<li>外置的狀態放哪種共享儲存 → <a href="/blog/devops/02-horizontal-scaling/shared-storage-selection/" data-link-title="Shared storage 選型" data-link-desc="把外置的狀態放進共享儲存時，按存取型態與狀態性質選 DB、KV、物件儲存，並處理多實例共享一個 DB 帶來的讀路徑與連線瓶頸">Shared storage 選型</a></li>
<li>無狀態是水平擴展的前提，前提本身 → <a href="/blog/devops/01-load-balancing/scaling-prerequisite/" data-link-title="LB 是水平擴展的前提" data-link-desc="想靠加實例分攤流量卻發現擴不動時，釐清水平擴展的兩個前提——流量要分得進新實例（LB）、任何實例要能接任何請求（無狀態）">LB 是水平擴展的前提</a></li>
<li>Collector 多實例的完整 stateless 設計 → <a href="/blog/monitoring/04-collector/" data-link-title="模組四：Collector 設計" data-link-desc="收 → 驗 → 存 → 查 → 觸發的完整鏈路 — Go 單一 binary、可插拔 Storage Backend、rule engine">Monitoring Collector</a></li>
</ul>
]]></content:encoded></item></channel></rss>