<?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>模組二：水平擴展 on Tarragon</title><link>https://tarrragon.github.io/blog/operations/02-horizontal-scaling/</link><description>Recent content in 模組二：水平擴展 on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Sat, 20 Jun 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/operations/02-horizontal-scaling/index.xml" rel="self" type="application/rss+xml"/><item><title>Stateless 設計原則</title><link>https://tarrragon.github.io/blog/operations/02-horizontal-scaling/stateless-design/</link><pubDate>Fri, 03 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/operations/02-horizontal-scaling/stateless-design/</guid><description>&lt;p>Stateless 設計原則是讓每個實例都不保存「只有我這台知道」的狀態，這樣任何實例都能處理任何請求。它是 &lt;a href="https://tarrragon.github.io/blog/operations/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/operations/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/operations/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/operations/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/operations/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/operations/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/operations/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/operations/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><item><title>Session 處理</title><link>https://tarrragon.github.io/blog/operations/02-horizontal-scaling/session-handling/</link><pubDate>Fri, 03 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/operations/02-horizontal-scaling/session-handling/</guid><description>&lt;p>Session 處理有三種途徑，各自把「用戶登入狀態放哪」解成不同形狀：綁在某台實例上（sticky）、放進共享的外部儲存（session store）、或根本不存在伺服器端（無狀態 token）。這三種對水平擴展的友善度差很多——sticky 最省事但破壞無狀態，session store 讓實例對等但多一個共享依賴，無狀態 token 徹底無狀態但撤銷困難。選哪一種，決定了水平擴展時 session 這塊會不會變成綁手綁腳的地方。&lt;/p>
&lt;h2 id="sticky-session綁實例最省事也最受限">Sticky session：綁實例，最省事也最受限&lt;/h2>
&lt;p>Sticky session 把同一個用戶的 session 綁定到某台實例，session 資料就存在那台的記憶體裡。它最省事——不用改應用、不用外部依賴，登入狀態放本機就好。代價是它直接破壞無狀態：那台實例掛了、被縮容、或被重啟，綁在上面的 session 全部消失，那些用戶要重新登入。負載也會不均，因為熱門 session 集中在某幾台。sticky 的完整取捨在 &lt;a href="https://tarrragon.github.io/blog/operations/01-load-balancing/load-balancing-algorithms/" data-link-title="負載分散演算法" data-link-desc="在 round-robin、least-connections、IP hash、consistent hash 之間選負載分散演算法時，用請求成本是否均勻、實例是否同質、需不需要親和性當判準">負載分散演算法&lt;/a> 的黏著段講過，這裡的重點是：它是三種途徑裡對水平擴展最不友善的，通常是暫時的過渡、不是目標狀態。&lt;/p>
&lt;h2 id="外部-session-store實例對等但-session-是-hot-row">外部 session store：實例對等，但 session 是 hot row&lt;/h2>
&lt;p>外部 session store 把 session 從實例本地移到一個共享的儲存，每個實例都無狀態、任何實例都能讀到任何用戶的 session。這讓實例真正對等，是水平擴展下 session 的主流做法。（歷史上還有第四種做法：把 session 在所有實例之間互相複製，讓每台都持有全部 session。它早被外部 store 取代，因為複製流量隨實例數平方成長、擴到一定規模就撐不住——外部 store 用「一份共享」取代「每台一份」，正是為了避開這個。）但這裡有一個選型陷阱：session 是典型的高頻更新資料（每個請求可能都在刷新它的過期時間），放進 SQL 資料庫當一般資料表，會在那幾行 session 上撞出嚴重的鎖競爭——它是典型的 hot row 場景。&lt;/p>
&lt;p>所以 session store 通常選鍵值儲存或快取（如 Redis、DynamoDB 這類支援原子操作的）、而不是 SQL。它們的資料模型正好適合「用一個 key 快速讀寫一個 session、高頻更新、不需要跨行交易」，避開了 SQL 在 hot row 上的鎖競爭。這條選型判斷延伸到 &lt;a href="https://tarrragon.github.io/blog/operations/02-horizontal-scaling/shared-storage-selection/" data-link-title="Shared storage 選型" data-link-desc="把外置的狀態放進共享儲存時，按存取型態與狀態性質選 DB、KV、物件儲存，並處理多實例共享一個 DB 帶來的讀路徑與連線瓶頸">Shared storage 選型&lt;/a>——高頻的鍵值狀態跟結構化的查詢狀態，適合的儲存不一樣。&lt;/p>
&lt;h2 id="無狀態-token不存伺服器端但撤銷困難">無狀態 token：不存伺服器端，但撤銷困難&lt;/h2>
&lt;p>無狀態 token（如 JWT）把 session 資料簽進 token 本身，發給客戶端隨每個請求帶回來，伺服器端完全不存 session。這是最徹底的無狀態——任何實例收到請求，驗證 token 簽章就知道用戶是誰，不必查任何共享儲存，連 session store 這個依賴都省了。&lt;/p>
&lt;p>徹底無狀態的代價在撤銷跟大小。撤銷困難是最關鍵的：token 一旦簽發，在它過期之前都有效，伺服器端沒有一個「登出」按鈕能立刻讓它失效——因為伺服器根本不存它的狀態。要提前撤銷（用戶登出、帳號被停用）就得額外維護一個撤銷清單，那又把無狀態的好處吃掉一部分。大小是另一個代價：token 隨每個請求傳輸，塞太多資料會讓每個請求都變重，所以 token 只適合放少量、非敏感的識別資訊，不能當通用的 session 資料容器。無狀態 token 適合「短期有效、不需要即時撤銷」的場景，需要即時登出、需要存較多 session 資料的，還是走 session store。&lt;/p>
&lt;h2 id="session-一致性剛寫完要讀得到">Session 一致性：剛寫完要讀得到&lt;/h2>
&lt;p>三種途徑之外，session 還有一個一致性問題會在水平擴展、讀寫分離後浮現：剛寫完的資料，馬上讀要讀得到（read-after-write）。當讀路徑走了資料庫的唯讀副本、而副本有複製延遲時，一個用戶剛更新完 session（或剛下單、剛改了餘額），下一個請求若打到副本，可能讀到還沒同步過來的舊資料。&lt;/p>
&lt;p>解法是選擇性地路由，而不是把所有讀都無差別送回主庫：用一個 session token 標記「這個 session 剛寫過」，在複製延遲的那個時間窗內（幾秒），把它的讀強制走主庫；過了窗、或本來就容忍稍舊資料的讀（看別人的公開資料、看報表），走副本就好。session 一致性因此是按查詢分類的——不可容忍舊資料的（剛寫完查自己、餘額確認）走主庫，容忍的走副本。這個補丁要花多少工，其實取決於底層的複製延遲有多大，而那是 &lt;a href="https://tarrragon.github.io/blog/operations/02-horizontal-scaling/shared-storage-selection/" data-link-title="Shared storage 選型" data-link-desc="把外置的狀態放進共享儲存時，按存取型態與狀態性質選 DB、KV、物件儲存，並處理多實例共享一個 DB 帶來的讀路徑與連線瓶頸">Shared storage 選型&lt;/a> 的 replication 架構決定的。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>高頻鍵值 vs 結構化查詢 vs 大檔，各放哪種共享儲存 → &lt;a href="https://tarrragon.github.io/blog/operations/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>把 session 從實例本地移出去，是無狀態設計的一部分 → &lt;a href="https://tarrragon.github.io/blog/operations/02-horizontal-scaling/stateless-design/" data-link-title="Stateless 設計原則" data-link-desc="要把服務改成能水平擴展的無狀態設計時，釐清什麼算「本機狀態」、哪些常見寫法會破壞無狀態、隱式狀態怎麼抓、以及定時 job 這種無狀態例外怎麼處理">Stateless 設計原則&lt;/a>&lt;/li>
&lt;li>sticky session 的黏著代價與演算法 → &lt;a href="https://tarrragon.github.io/blog/operations/01-load-balancing/load-balancing-algorithms/" data-link-title="負載分散演算法" data-link-desc="在 round-robin、least-connections、IP hash、consistent hash 之間選負載分散演算法時，用請求成本是否均勻、實例是否同質、需不需要親和性當判準">負載分散演算法&lt;/a>&lt;/li>
&lt;li>狀態放哪決定了之後，憑證靠什麼帶進每個請求（cookie 自動附上 vs 請求標頭明確附上）→ &lt;a href="https://tarrragon.github.io/blog/backend/07-security-data-protection/credential-transport-in-request/" data-link-title="7.36 憑證在請求中怎麼帶：附上的決定由誰做" data-link-desc="登入之後要決定憑證放 cookie 還是每次寫進 header 時，用來判斷兩種帶法各自要求攻擊者先做到什麼">7.36 憑證在請求中怎麼帶&lt;/a>&lt;/li>
&lt;li>外洩當下撤不撤得掉，由這個決定決定，而它在事件發生時補建不起來 → &lt;a href="https://tarrragon.github.io/blog/backend/07-security-data-protection/credential-breach-response/" data-link-title="7.37 密碼外洩之後：範圍判不出來的時候怎麼定處置" data-link-desc="帳號憑證外洩而查不出哪些帳號被讀走時，用來分層定重設範圍、排撤銷順序與判斷通知門檻">7.37 密碼外洩之後&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>Session 處理有三種途徑，各自把「用戶登入狀態放哪」解成不同形狀：綁在某台實例上（sticky）、放進共享的外部儲存（session store）、或根本不存在伺服器端（無狀態 token）。這三種對水平擴展的友善度差很多——sticky 最省事但破壞無狀態，session store 讓實例對等但多一個共享依賴，無狀態 token 徹底無狀態但撤銷困難。選哪一種，決定了水平擴展時 session 這塊會不會變成綁手綁腳的地方。</p>
<h2 id="sticky-session綁實例最省事也最受限">Sticky session：綁實例，最省事也最受限</h2>
<p>Sticky session 把同一個用戶的 session 綁定到某台實例，session 資料就存在那台的記憶體裡。它最省事——不用改應用、不用外部依賴，登入狀態放本機就好。代價是它直接破壞無狀態：那台實例掛了、被縮容、或被重啟，綁在上面的 session 全部消失，那些用戶要重新登入。負載也會不均，因為熱門 session 集中在某幾台。sticky 的完整取捨在 <a href="/blog/operations/01-load-balancing/load-balancing-algorithms/" data-link-title="負載分散演算法" data-link-desc="在 round-robin、least-connections、IP hash、consistent hash 之間選負載分散演算法時，用請求成本是否均勻、實例是否同質、需不需要親和性當判準">負載分散演算法</a> 的黏著段講過，這裡的重點是：它是三種途徑裡對水平擴展最不友善的，通常是暫時的過渡、不是目標狀態。</p>
<h2 id="外部-session-store實例對等但-session-是-hot-row">外部 session store：實例對等，但 session 是 hot row</h2>
<p>外部 session store 把 session 從實例本地移到一個共享的儲存，每個實例都無狀態、任何實例都能讀到任何用戶的 session。這讓實例真正對等，是水平擴展下 session 的主流做法。（歷史上還有第四種做法：把 session 在所有實例之間互相複製，讓每台都持有全部 session。它早被外部 store 取代，因為複製流量隨實例數平方成長、擴到一定規模就撐不住——外部 store 用「一份共享」取代「每台一份」，正是為了避開這個。）但這裡有一個選型陷阱：session 是典型的高頻更新資料（每個請求可能都在刷新它的過期時間），放進 SQL 資料庫當一般資料表，會在那幾行 session 上撞出嚴重的鎖競爭——它是典型的 hot row 場景。</p>
<p>所以 session store 通常選鍵值儲存或快取（如 Redis、DynamoDB 這類支援原子操作的）、而不是 SQL。它們的資料模型正好適合「用一個 key 快速讀寫一個 session、高頻更新、不需要跨行交易」，避開了 SQL 在 hot row 上的鎖競爭。這條選型判斷延伸到 <a href="/blog/operations/02-horizontal-scaling/shared-storage-selection/" data-link-title="Shared storage 選型" data-link-desc="把外置的狀態放進共享儲存時，按存取型態與狀態性質選 DB、KV、物件儲存，並處理多實例共享一個 DB 帶來的讀路徑與連線瓶頸">Shared storage 選型</a>——高頻的鍵值狀態跟結構化的查詢狀態，適合的儲存不一樣。</p>
<h2 id="無狀態-token不存伺服器端但撤銷困難">無狀態 token：不存伺服器端，但撤銷困難</h2>
<p>無狀態 token（如 JWT）把 session 資料簽進 token 本身，發給客戶端隨每個請求帶回來，伺服器端完全不存 session。這是最徹底的無狀態——任何實例收到請求，驗證 token 簽章就知道用戶是誰，不必查任何共享儲存，連 session store 這個依賴都省了。</p>
<p>徹底無狀態的代價在撤銷跟大小。撤銷困難是最關鍵的：token 一旦簽發，在它過期之前都有效，伺服器端沒有一個「登出」按鈕能立刻讓它失效——因為伺服器根本不存它的狀態。要提前撤銷（用戶登出、帳號被停用）就得額外維護一個撤銷清單，那又把無狀態的好處吃掉一部分。大小是另一個代價：token 隨每個請求傳輸，塞太多資料會讓每個請求都變重，所以 token 只適合放少量、非敏感的識別資訊，不能當通用的 session 資料容器。無狀態 token 適合「短期有效、不需要即時撤銷」的場景，需要即時登出、需要存較多 session 資料的，還是走 session store。</p>
<h2 id="session-一致性剛寫完要讀得到">Session 一致性：剛寫完要讀得到</h2>
<p>三種途徑之外，session 還有一個一致性問題會在水平擴展、讀寫分離後浮現：剛寫完的資料，馬上讀要讀得到（read-after-write）。當讀路徑走了資料庫的唯讀副本、而副本有複製延遲時，一個用戶剛更新完 session（或剛下單、剛改了餘額），下一個請求若打到副本，可能讀到還沒同步過來的舊資料。</p>
<p>解法是選擇性地路由，而不是把所有讀都無差別送回主庫：用一個 session token 標記「這個 session 剛寫過」，在複製延遲的那個時間窗內（幾秒），把它的讀強制走主庫；過了窗、或本來就容忍稍舊資料的讀（看別人的公開資料、看報表），走副本就好。session 一致性因此是按查詢分類的——不可容忍舊資料的（剛寫完查自己、餘額確認）走主庫，容忍的走副本。這個補丁要花多少工，其實取決於底層的複製延遲有多大，而那是 <a href="/blog/operations/02-horizontal-scaling/shared-storage-selection/" data-link-title="Shared storage 選型" data-link-desc="把外置的狀態放進共享儲存時，按存取型態與狀態性質選 DB、KV、物件儲存，並處理多實例共享一個 DB 帶來的讀路徑與連線瓶頸">Shared storage 選型</a> 的 replication 架構決定的。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>高頻鍵值 vs 結構化查詢 vs 大檔，各放哪種共享儲存 → <a href="/blog/operations/02-horizontal-scaling/shared-storage-selection/" data-link-title="Shared storage 選型" data-link-desc="把外置的狀態放進共享儲存時，按存取型態與狀態性質選 DB、KV、物件儲存，並處理多實例共享一個 DB 帶來的讀路徑與連線瓶頸">Shared storage 選型</a></li>
<li>把 session 從實例本地移出去，是無狀態設計的一部分 → <a href="/blog/operations/02-horizontal-scaling/stateless-design/" data-link-title="Stateless 設計原則" data-link-desc="要把服務改成能水平擴展的無狀態設計時，釐清什麼算「本機狀態」、哪些常見寫法會破壞無狀態、隱式狀態怎麼抓、以及定時 job 這種無狀態例外怎麼處理">Stateless 設計原則</a></li>
<li>sticky session 的黏著代價與演算法 → <a href="/blog/operations/01-load-balancing/load-balancing-algorithms/" data-link-title="負載分散演算法" data-link-desc="在 round-robin、least-connections、IP hash、consistent hash 之間選負載分散演算法時，用請求成本是否均勻、實例是否同質、需不需要親和性當判準">負載分散演算法</a></li>
<li>狀態放哪決定了之後，憑證靠什麼帶進每個請求（cookie 自動附上 vs 請求標頭明確附上）→ <a href="/blog/backend/07-security-data-protection/credential-transport-in-request/" data-link-title="7.36 憑證在請求中怎麼帶：附上的決定由誰做" data-link-desc="登入之後要決定憑證放 cookie 還是每次寫進 header 時，用來判斷兩種帶法各自要求攻擊者先做到什麼">7.36 憑證在請求中怎麼帶</a></li>
<li>外洩當下撤不撤得掉，由這個決定決定，而它在事件發生時補建不起來 → <a href="/blog/backend/07-security-data-protection/credential-breach-response/" data-link-title="7.37 密碼外洩之後：範圍判不出來的時候怎麼定處置" data-link-desc="帳號憑證外洩而查不出哪些帳號被讀走時，用來分層定重設範圍、排撤銷順序與判斷通知門檻">7.37 密碼外洩之後</a></li>
</ul>
]]></content:encoded></item><item><title>Shared storage 選型</title><link>https://tarrragon.github.io/blog/operations/02-horizontal-scaling/shared-storage-selection/</link><pubDate>Fri, 03 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/operations/02-horizontal-scaling/shared-storage-selection/</guid><description>&lt;p>外置的狀態放哪，是 shared storage 選型要決定的。選型沿兩條軸展開：這份狀態的存取型態（是結構化查詢、高頻鍵值、還是大檔案），以及它的性質（是不能丟的權威狀態、還是可以重建的衍生狀態）。存取型態決定放哪一類儲存，狀態性質決定要不要備份與持久保證——兩條軸都對上，共享狀態才放得穩。&lt;/p>
&lt;h2 id="先分清權威狀態與衍生狀態">先分清權威狀態與衍生狀態&lt;/h2>
&lt;p>放進共享儲存的狀態，先判斷它是權威的（canonical）還是衍生的（derived）。權威狀態是唯一真相來源——影響交易、權限、對帳的資料，錯了不能重建，只能從它自己回復，所以要備份、要 audit。衍生狀態是從權威狀態算出來的——快取、搜尋索引、報表，錯了或丟了可以砍掉重建，不需要昂貴的持久保證。&lt;/p>
&lt;p>這個區分直接改變選型。一個購物車若是正式的交易狀態（下單的依據），它是權威的，要放能持久、能備份的儲存；若只是「暫時記住用戶點過什麼」的方便快取，它是衍生的，放一個失效可接受的快取就好。判反方向的代價是兩種：把權威狀態放進會被清掉的快取（丟資料）、或把衍生狀態塞進要嚴格備份的儲存（付不必要的成本）。&lt;/p>
&lt;h2 id="按存取型態選儲存類型">按存取型態選儲存類型&lt;/h2>
&lt;p>存取型態決定放哪一類儲存，三類常見的共享狀態各有適合的落點：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>結構化、要查詢的狀態&lt;/strong> → 共享的關聯式資料庫。本站 collector 的水平擴展就靠這個——多個 collector 實例寫入同一個 PostgreSQL，任何實例接收的事件都能被任何 dashboard 查到。這裡有一個關鍵限制：不是所有資料庫都能當共享儲存。collector 的 SQLite 後端不支援水平擴展，因為每個實例有各自的 SQLite 檔案、無法合併查詢，且單檔案模型無法跨主機存取——這也排除了「把 SQLite 檔放 NFS 給多台共享」這種看似省事的做法。要共享，就要用本身支援多連線並行、能跨主機的資料庫。&lt;/li>
&lt;li>&lt;strong>高頻的鍵值狀態&lt;/strong> → 鍵值儲存或快取。session、計數器、限流狀態這類高頻讀寫、不需要跨行交易的狀態，放 SQL 會撞 hot row 的鎖競爭，放 Redis、DynamoDB 這類鍵值儲存才對——這條在 &lt;a href="https://tarrragon.github.io/blog/operations/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;strong>大檔案、不可變的內容&lt;/strong> → 物件儲存。上傳的檔案、靜態資源這類大而不常改的內容，放物件儲存（如 S3）比塞進資料庫合適——資料庫不擅長存大二進位、物件儲存正是為此設計，且本身就跨主機共享，解掉了「上傳暫存放本機、換實例就找不到」的無狀態破口。&lt;/li>
&lt;/ul>
&lt;h2 id="共享一個資料庫的讀路徑">共享一個資料庫的讀路徑&lt;/h2>
&lt;p>多實例共享同一個資料庫時，寫入集中在主庫、但讀取可以擴展——這是讀寫分離。寫走主庫（primary），讀走唯讀副本（replica），讀流量超過主庫吞吐時，加副本就能擴讀路徑。副本有複製延遲要納入考量：同一可用區內的串流複製通常低於 100 毫秒，跨可用區到秒級，跨區域可能到秒至分鐘級且不保證 read-after-write——需要剛寫就讀到的查詢要強制走主庫，這正是 &lt;a href="https://tarrragon.github.io/blog/operations/02-horizontal-scaling/session-handling/" data-link-title="Session 處理" data-link-desc="多實例下要決定用戶登入狀態怎麼放時，比較 sticky session、外部 session store、無狀態 token 三種途徑，以及剛寫完就要讀到的 session 一致性怎麼保證">Session 處理&lt;/a> 那條一致性補丁。&lt;/p>
&lt;p>副本不是無限加的。傳統資料庫的副本要自己重放主庫的日誌、吃 CPU 與磁碟，每加一個副本都增加主庫的複製負擔，所以有數量上限（PostgreSQL 通常 3 到 5 個）。運算與儲存分離的架構（如 Aurora）讓副本讀同一份分散式儲存、加副本不增加主庫的寫入負擔、延遲降到毫秒級、上限也高（Aurora 最多 15 個），代價是綁定特定供應商。讀路徑能擴到多寬，是這個架構決定的，選型時要先算清楚。&lt;/p>
&lt;h2 id="共享一個資料庫的連線瓶頸">共享一個資料庫的連線瓶頸&lt;/h2>
&lt;p>水平擴展一個共享資料庫的服務，最先撞到的瓶頸常常是連線，不是 CPU 或磁碟。關聯式資料庫每條連線都吃記憶體、吃一個 process 或 thread，上限通常在幾千條；而水平擴展會讓連線數線性放大——每台實例開一個連線池（例如 30 到 50 條），擴到很多台，總連線數就爆了。50 台每台 30 條就是 1500 條，直接被資料庫以「連線太多」拒絕。&lt;/p>
&lt;p>解法是加一層連線池中介（如 pgBouncer）做多工：讓大量的應用端連線共用少量的資料庫端連線。1500 條應用連線經過中介，可以多工到 200 條資料庫連線。多實例共享同一個資料庫時，這層中介幾乎是必要的——跳過它直連，連線會在擴展的某個規模突然爆掉。連線池、交易邊界、讀寫分離的完整資料庫側設計，在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/high-concurrency-access/" data-link-title="1.1 高併發下的 SQL 讀寫邊界" data-link-desc="說明高併發服務如何共用資料庫 client、控制 transaction、管理 connection pool、避免資料庫成為瓶頸">backend 高併發存取&lt;/a> 展開，這裡要確立的是：共享資料庫是水平擴展的落點，但它的連線與讀路徑有各自的天花板，選型時要一起算。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>高頻的 session 狀態為什麼要放 KV 而非 SQL → &lt;a href="https://tarrragon.github.io/blog/operations/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/operations/02-horizontal-scaling/stateless-design/" data-link-title="Stateless 設計原則" data-link-desc="要把服務改成能水平擴展的無狀態設計時，釐清什麼算「本機狀態」、哪些常見寫法會破壞無狀態、隱式狀態怎麼抓、以及定時 job 這種無狀態例外怎麼處理">Stateless 設計原則&lt;/a>&lt;/li>
&lt;li>連線池、讀寫分離、交易邊界的資料庫側深入 → &lt;a href="https://tarrragon.github.io/blog/backend/01-database/high-concurrency-access/" data-link-title="1.1 高併發下的 SQL 讀寫邊界" data-link-desc="說明高併發服務如何共用資料庫 client、控制 transaction、管理 connection pool、避免資料庫成為瓶頸">backend 高併發存取&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>外置的狀態放哪，是 shared storage 選型要決定的。選型沿兩條軸展開：這份狀態的存取型態（是結構化查詢、高頻鍵值、還是大檔案），以及它的性質（是不能丟的權威狀態、還是可以重建的衍生狀態）。存取型態決定放哪一類儲存，狀態性質決定要不要備份與持久保證——兩條軸都對上，共享狀態才放得穩。</p>
<h2 id="先分清權威狀態與衍生狀態">先分清權威狀態與衍生狀態</h2>
<p>放進共享儲存的狀態，先判斷它是權威的（canonical）還是衍生的（derived）。權威狀態是唯一真相來源——影響交易、權限、對帳的資料，錯了不能重建，只能從它自己回復，所以要備份、要 audit。衍生狀態是從權威狀態算出來的——快取、搜尋索引、報表，錯了或丟了可以砍掉重建，不需要昂貴的持久保證。</p>
<p>這個區分直接改變選型。一個購物車若是正式的交易狀態（下單的依據），它是權威的，要放能持久、能備份的儲存；若只是「暫時記住用戶點過什麼」的方便快取，它是衍生的，放一個失效可接受的快取就好。判反方向的代價是兩種：把權威狀態放進會被清掉的快取（丟資料）、或把衍生狀態塞進要嚴格備份的儲存（付不必要的成本）。</p>
<h2 id="按存取型態選儲存類型">按存取型態選儲存類型</h2>
<p>存取型態決定放哪一類儲存，三類常見的共享狀態各有適合的落點：</p>
<ul>
<li><strong>結構化、要查詢的狀態</strong> → 共享的關聯式資料庫。本站 collector 的水平擴展就靠這個——多個 collector 實例寫入同一個 PostgreSQL，任何實例接收的事件都能被任何 dashboard 查到。這裡有一個關鍵限制：不是所有資料庫都能當共享儲存。collector 的 SQLite 後端不支援水平擴展，因為每個實例有各自的 SQLite 檔案、無法合併查詢，且單檔案模型無法跨主機存取——這也排除了「把 SQLite 檔放 NFS 給多台共享」這種看似省事的做法。要共享，就要用本身支援多連線並行、能跨主機的資料庫。</li>
<li><strong>高頻的鍵值狀態</strong> → 鍵值儲存或快取。session、計數器、限流狀態這類高頻讀寫、不需要跨行交易的狀態，放 SQL 會撞 hot row 的鎖競爭，放 Redis、DynamoDB 這類鍵值儲存才對——這條在 <a href="/blog/operations/02-horizontal-scaling/session-handling/" data-link-title="Session 處理" data-link-desc="多實例下要決定用戶登入狀態怎麼放時，比較 sticky session、外部 session store、無狀態 token 三種途徑，以及剛寫完就要讀到的 session 一致性怎麼保證">Session 處理</a> 展開過。</li>
<li><strong>大檔案、不可變的內容</strong> → 物件儲存。上傳的檔案、靜態資源這類大而不常改的內容，放物件儲存（如 S3）比塞進資料庫合適——資料庫不擅長存大二進位、物件儲存正是為此設計，且本身就跨主機共享，解掉了「上傳暫存放本機、換實例就找不到」的無狀態破口。</li>
</ul>
<h2 id="共享一個資料庫的讀路徑">共享一個資料庫的讀路徑</h2>
<p>多實例共享同一個資料庫時，寫入集中在主庫、但讀取可以擴展——這是讀寫分離。寫走主庫（primary），讀走唯讀副本（replica），讀流量超過主庫吞吐時，加副本就能擴讀路徑。副本有複製延遲要納入考量：同一可用區內的串流複製通常低於 100 毫秒，跨可用區到秒級，跨區域可能到秒至分鐘級且不保證 read-after-write——需要剛寫就讀到的查詢要強制走主庫，這正是 <a href="/blog/operations/02-horizontal-scaling/session-handling/" data-link-title="Session 處理" data-link-desc="多實例下要決定用戶登入狀態怎麼放時，比較 sticky session、外部 session store、無狀態 token 三種途徑，以及剛寫完就要讀到的 session 一致性怎麼保證">Session 處理</a> 那條一致性補丁。</p>
<p>副本不是無限加的。傳統資料庫的副本要自己重放主庫的日誌、吃 CPU 與磁碟，每加一個副本都增加主庫的複製負擔，所以有數量上限（PostgreSQL 通常 3 到 5 個）。運算與儲存分離的架構（如 Aurora）讓副本讀同一份分散式儲存、加副本不增加主庫的寫入負擔、延遲降到毫秒級、上限也高（Aurora 最多 15 個），代價是綁定特定供應商。讀路徑能擴到多寬，是這個架構決定的，選型時要先算清楚。</p>
<h2 id="共享一個資料庫的連線瓶頸">共享一個資料庫的連線瓶頸</h2>
<p>水平擴展一個共享資料庫的服務，最先撞到的瓶頸常常是連線，不是 CPU 或磁碟。關聯式資料庫每條連線都吃記憶體、吃一個 process 或 thread，上限通常在幾千條；而水平擴展會讓連線數線性放大——每台實例開一個連線池（例如 30 到 50 條），擴到很多台，總連線數就爆了。50 台每台 30 條就是 1500 條，直接被資料庫以「連線太多」拒絕。</p>
<p>解法是加一層連線池中介（如 pgBouncer）做多工：讓大量的應用端連線共用少量的資料庫端連線。1500 條應用連線經過中介，可以多工到 200 條資料庫連線。多實例共享同一個資料庫時，這層中介幾乎是必要的——跳過它直連，連線會在擴展的某個規模突然爆掉。連線池、交易邊界、讀寫分離的完整資料庫側設計，在 <a href="/blog/backend/01-database/high-concurrency-access/" data-link-title="1.1 高併發下的 SQL 讀寫邊界" data-link-desc="說明高併發服務如何共用資料庫 client、控制 transaction、管理 connection pool、避免資料庫成為瓶頸">backend 高併發存取</a> 展開，這裡要確立的是：共享資料庫是水平擴展的落點，但它的連線與讀路徑有各自的天花板，選型時要一起算。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>高頻的 session 狀態為什麼要放 KV 而非 SQL → <a href="/blog/operations/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/operations/02-horizontal-scaling/stateless-design/" data-link-title="Stateless 設計原則" data-link-desc="要把服務改成能水平擴展的無狀態設計時，釐清什麼算「本機狀態」、哪些常見寫法會破壞無狀態、隱式狀態怎麼抓、以及定時 job 這種無狀態例外怎麼處理">Stateless 設計原則</a></li>
<li>連線池、讀寫分離、交易邊界的資料庫側深入 → <a href="/blog/backend/01-database/high-concurrency-access/" data-link-title="1.1 高併發下的 SQL 讀寫邊界" data-link-desc="說明高併發服務如何共用資料庫 client、控制 transaction、管理 connection pool、避免資料庫成為瓶頸">backend 高併發存取</a></li>
</ul>
]]></content:encoded></item><item><title>擴展的觸發與縮回</title><link>https://tarrragon.github.io/blog/operations/02-horizontal-scaling/scaling-triggers/</link><pubDate>Fri, 03 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/operations/02-horizontal-scaling/scaling-triggers/</guid><description>&lt;p>擴展的觸發與縮回，一個決定何時加實例、一個決定何時減實例，兩者的難度不對稱。擴容相對簡單——加一台機器、等它就緒、開始分流。縮回難得多，因為減一台實例前，要先把它身上的流量與在途工作安全移走，硬砍會中斷正在處理的請求。這一章講擴縮這個閉環，重點放在縮回這個常被忽略、也更容易出事的方向。什麼訊號代表系統飽和、該不該擴，是 &lt;a href="https://tarrragon.github.io/blog/operations/05-capacity-planning/scaling-inflection-point/" data-link-title="規模拐點判斷" data-link-desc="判斷什麼訊號代表該擴容、什麼代表可縮容、以及該往垂直還水平擴時，用飽和曲線的三段區間、膝點的早期訊號與 ramp-up 方法回來讀">規模拐點判斷&lt;/a> 的主題，這裡承接的是「確認要擴之後，這個擴縮怎麼運作」。&lt;/p>
&lt;h2 id="擴展前先窮盡低成本手段">擴展前先窮盡低成本手段&lt;/h2>
&lt;p>加實例是有成本的手段，不該是遇到壓力的第一反應。一個健康的擴展決策，會先窮盡零成本或低成本的手段，確認真的不夠了才擴機。以讀取變慢為例，加副本或加機器之前，先做過補索引、讓儀表板改讀預聚合的摘要表、把降採樣 job 調到避開高峰時段——這些預聚合類的手段常常是「加機器前」最有效的降載，做完可能就不需要擴了。擴展的觸發原則是按觀察到的真實瓶頸行動、不按預測搶跑，且在擴機之前先把便宜的優化用盡。&lt;/p>
&lt;h2 id="觸發訊號分層各層對應不同動作">觸發訊號分層，各層對應不同動作&lt;/h2>
&lt;p>擴展的觸發不是單一訊號、單一動作，成熟的系統分層應對。本站 collector 的 ingestion 就分四層防線，每層有各自的觸發條件與動作：源頭的 SDK 在超過粒度時自動降取樣、單機的背壓與限流在寫入接近滿載時擋、水平擴展在單機 CPU 或連線飽和時加實例、佇列解耦在突發流量超過整個 collector 群的即時處理能力時插入緩衝。訊號從源頭到基礎設施逐層升級，先用便宜的層擋，擋不住才動到貴的層。&lt;/p>
&lt;p>共享儲存的擴展也有量化的觸發訊號。collector 的 SQLite 後端撞到「&lt;code>database is locked&lt;/code> 每分鐘出現一次以上」或「聚合查詢超過 3 秒」，就是該換 PostgreSQL 的訊號；PostgreSQL 撐到每秒數萬筆持續寫入或需要自動降採樣，就是該換時間序列資料庫的訊號。這些訊號的共通原則還是那條：按觀察到的瓶頸切換，不按預測提前重構。&lt;/p>
&lt;h2 id="縮回要先-drain不能硬砍">縮回要先 drain，不能硬砍&lt;/h2>
&lt;p>縮回的核心難點是實例身上還有活。減一台實例前，要走跟關閉服務同一套收束流程：先把它從負載平衡的目標裡摘掉（停止送新流量）、等它手上的在途請求處理完、再真正終止。這跟 &lt;a href="https://tarrragon.github.io/blog/operations/04-service-health/graceful-shutdown/" data-link-title="Graceful shutdown" data-link-desc="設計服務收到停止信號後的收束流程時，釐清 SIGTERM 到 SIGKILL 的 grace period、退場的固定順序、以及不同 workload 的 drain 窗口要留多長">模組四 graceful shutdown&lt;/a> 是同一套機制——縮容其實就是一次有計畫的實例退場，退場的固定順序（摘流量、drain、終止）在那裡展開。硬砍一台正在處理請求的實例，那些請求全部中斷，用戶端看到的就是一批莫名其妙的失敗。&lt;/p>
&lt;p>縮回還有兩個容易出事的地方。一是縮太快造成容量不足——流量剛回落就急著縮，結果下一波又上來、新實例還沒起好。二是縮縮擴擴的抖動，訊號在門檻上下跳、實例反覆增減，這靠冷卻時間（cool-down）壓住：擴或縮之後強制等一段時間再判斷，不讓它對每個瞬間波動都反應。擴縮該選哪個訊號、冷卻時間怎麼配，在 &lt;a href="https://tarrragon.github.io/blog/operations/05-capacity-planning/scaling-inflection-point/" data-link-title="規模拐點判斷" data-link-desc="判斷什麼訊號代表該擴容、什麼代表可縮容、以及該往垂直還水平擴時，用飽和曲線的三段區間、膝點的早期訊號與 ramp-up 方法回來讀">規模拐點判斷&lt;/a> 的擴縮訊號段有完整對照。&lt;/p>
&lt;p>有一種源頭的縮回不靠減實例，而靠降載。collector 的背壓 buffer 滿了就回 429 加一個 &lt;code>Retry-After&lt;/code>，SDK 收到 429 自動把取樣率從 1.0 降到 0.5、再降到 0.1；等連續成功幾十次，再逐步回升到 1.0。這是一個閉環的自動降載與恢復——不加機器、直接在源頭把進來的量壓下去，撐過尖峰再放回來。對短暫的高峰，這種源頭降載比擴實例划算得多。&lt;/p>
&lt;h2 id="該擴還是該解耦">該擴、還是該解耦&lt;/h2>
&lt;p>擴展到某個點會遇到一個判斷：繼續加實例、還是改變架構。當 collector 群已經水平擴展、仍無法即時消化突發流量時，繼續加實例的邊際效益在下降，這時該考慮插入一個佇列解耦——collector 簡化成「接收、驗證、寫進佇列、回 202」，後面的 worker 按自己的速度消化積壓，把「即時處理」換成「保證不丟、慢慢處理」。但這個決策有反向的一面：如果只是短暫的高峰，佇列的維護成本可能高於它的收益，這時回到源頭用動態取樣降量更划算。佇列解耦的完整設計在 &lt;a href="https://tarrragon.github.io/blog/operations/07-burst-traffic/" data-link-title="模組七：突發流量應對" data-link-desc="行銷活動或新聞曝光帶來 10x-100x 流量時怎麼撐 — 突發分類、降級策略、queue 緩衝、規模分級應對">模組七 突發流量&lt;/a> 展開，這裡的判斷點是：加實例、源頭降載、佇列解耦是三個不同成本的選項，按尖峰是短暫還是持續來選。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>什麼訊號代表系統進入飽和、該擴容 → &lt;a href="https://tarrragon.github.io/blog/operations/05-capacity-planning/scaling-inflection-point/" data-link-title="規模拐點判斷" data-link-desc="判斷什麼訊號代表該擴容、什麼代表可縮容、以及該往垂直還水平擴時，用飽和曲線的三段區間、膝點的早期訊號與 ramp-up 方法回來讀">規模拐點判斷&lt;/a>&lt;/li>
&lt;li>縮回的實例退場順序、drain 怎麼做 → &lt;a href="https://tarrragon.github.io/blog/operations/04-service-health/graceful-shutdown/" data-link-title="Graceful shutdown" data-link-desc="設計服務收到停止信號後的收束流程時，釐清 SIGTERM 到 SIGKILL 的 grace period、退場的固定順序、以及不同 workload 的 drain 窗口要留多長">模組四 Graceful shutdown&lt;/a>&lt;/li>
&lt;li>佇列解耦怎麼接、突發流量的完整應對 → &lt;a href="https://tarrragon.github.io/blog/operations/07-burst-traffic/" data-link-title="模組七：突發流量應對" data-link-desc="行銷活動或新聞曝光帶來 10x-100x 流量時怎麼撐 — 突發分類、降級策略、queue 緩衝、規模分級應對">模組七 突發流量&lt;/a>&lt;/li>
&lt;li>垂直還是水平——擴的方向怎麼選 → &lt;a href="https://tarrragon.github.io/blog/operations/02-horizontal-scaling/vertical-vs-horizontal/" data-link-title="垂直與水平擴展的判斷" data-link-desc="決定該加 CPU 還是加實例時，用「這個元件能不能做成無狀態」當判斷樞紐，並知道有狀態的節點該用垂直撐、讀路徑用副本擴、撐不住才分片">垂直與水平擴展的判斷&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>擴展的觸發與縮回，一個決定何時加實例、一個決定何時減實例，兩者的難度不對稱。擴容相對簡單——加一台機器、等它就緒、開始分流。縮回難得多，因為減一台實例前，要先把它身上的流量與在途工作安全移走，硬砍會中斷正在處理的請求。這一章講擴縮這個閉環，重點放在縮回這個常被忽略、也更容易出事的方向。什麼訊號代表系統飽和、該不該擴，是 <a href="/blog/operations/05-capacity-planning/scaling-inflection-point/" data-link-title="規模拐點判斷" data-link-desc="判斷什麼訊號代表該擴容、什麼代表可縮容、以及該往垂直還水平擴時，用飽和曲線的三段區間、膝點的早期訊號與 ramp-up 方法回來讀">規模拐點判斷</a> 的主題，這裡承接的是「確認要擴之後，這個擴縮怎麼運作」。</p>
<h2 id="擴展前先窮盡低成本手段">擴展前先窮盡低成本手段</h2>
<p>加實例是有成本的手段，不該是遇到壓力的第一反應。一個健康的擴展決策，會先窮盡零成本或低成本的手段，確認真的不夠了才擴機。以讀取變慢為例，加副本或加機器之前，先做過補索引、讓儀表板改讀預聚合的摘要表、把降採樣 job 調到避開高峰時段——這些預聚合類的手段常常是「加機器前」最有效的降載，做完可能就不需要擴了。擴展的觸發原則是按觀察到的真實瓶頸行動、不按預測搶跑，且在擴機之前先把便宜的優化用盡。</p>
<h2 id="觸發訊號分層各層對應不同動作">觸發訊號分層，各層對應不同動作</h2>
<p>擴展的觸發不是單一訊號、單一動作，成熟的系統分層應對。本站 collector 的 ingestion 就分四層防線，每層有各自的觸發條件與動作：源頭的 SDK 在超過粒度時自動降取樣、單機的背壓與限流在寫入接近滿載時擋、水平擴展在單機 CPU 或連線飽和時加實例、佇列解耦在突發流量超過整個 collector 群的即時處理能力時插入緩衝。訊號從源頭到基礎設施逐層升級，先用便宜的層擋，擋不住才動到貴的層。</p>
<p>共享儲存的擴展也有量化的觸發訊號。collector 的 SQLite 後端撞到「<code>database is locked</code> 每分鐘出現一次以上」或「聚合查詢超過 3 秒」，就是該換 PostgreSQL 的訊號；PostgreSQL 撐到每秒數萬筆持續寫入或需要自動降採樣，就是該換時間序列資料庫的訊號。這些訊號的共通原則還是那條：按觀察到的瓶頸切換，不按預測提前重構。</p>
<h2 id="縮回要先-drain不能硬砍">縮回要先 drain，不能硬砍</h2>
<p>縮回的核心難點是實例身上還有活。減一台實例前，要走跟關閉服務同一套收束流程：先把它從負載平衡的目標裡摘掉（停止送新流量）、等它手上的在途請求處理完、再真正終止。這跟 <a href="/blog/operations/04-service-health/graceful-shutdown/" data-link-title="Graceful shutdown" data-link-desc="設計服務收到停止信號後的收束流程時，釐清 SIGTERM 到 SIGKILL 的 grace period、退場的固定順序、以及不同 workload 的 drain 窗口要留多長">模組四 graceful shutdown</a> 是同一套機制——縮容其實就是一次有計畫的實例退場，退場的固定順序（摘流量、drain、終止）在那裡展開。硬砍一台正在處理請求的實例，那些請求全部中斷，用戶端看到的就是一批莫名其妙的失敗。</p>
<p>縮回還有兩個容易出事的地方。一是縮太快造成容量不足——流量剛回落就急著縮，結果下一波又上來、新實例還沒起好。二是縮縮擴擴的抖動，訊號在門檻上下跳、實例反覆增減，這靠冷卻時間（cool-down）壓住：擴或縮之後強制等一段時間再判斷，不讓它對每個瞬間波動都反應。擴縮該選哪個訊號、冷卻時間怎麼配，在 <a href="/blog/operations/05-capacity-planning/scaling-inflection-point/" data-link-title="規模拐點判斷" data-link-desc="判斷什麼訊號代表該擴容、什麼代表可縮容、以及該往垂直還水平擴時，用飽和曲線的三段區間、膝點的早期訊號與 ramp-up 方法回來讀">規模拐點判斷</a> 的擴縮訊號段有完整對照。</p>
<p>有一種源頭的縮回不靠減實例，而靠降載。collector 的背壓 buffer 滿了就回 429 加一個 <code>Retry-After</code>，SDK 收到 429 自動把取樣率從 1.0 降到 0.5、再降到 0.1；等連續成功幾十次，再逐步回升到 1.0。這是一個閉環的自動降載與恢復——不加機器、直接在源頭把進來的量壓下去，撐過尖峰再放回來。對短暫的高峰，這種源頭降載比擴實例划算得多。</p>
<h2 id="該擴還是該解耦">該擴、還是該解耦</h2>
<p>擴展到某個點會遇到一個判斷：繼續加實例、還是改變架構。當 collector 群已經水平擴展、仍無法即時消化突發流量時，繼續加實例的邊際效益在下降，這時該考慮插入一個佇列解耦——collector 簡化成「接收、驗證、寫進佇列、回 202」，後面的 worker 按自己的速度消化積壓，把「即時處理」換成「保證不丟、慢慢處理」。但這個決策有反向的一面：如果只是短暫的高峰，佇列的維護成本可能高於它的收益，這時回到源頭用動態取樣降量更划算。佇列解耦的完整設計在 <a href="/blog/operations/07-burst-traffic/" data-link-title="模組七：突發流量應對" data-link-desc="行銷活動或新聞曝光帶來 10x-100x 流量時怎麼撐 — 突發分類、降級策略、queue 緩衝、規模分級應對">模組七 突發流量</a> 展開，這裡的判斷點是：加實例、源頭降載、佇列解耦是三個不同成本的選項，按尖峰是短暫還是持續來選。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>什麼訊號代表系統進入飽和、該擴容 → <a href="/blog/operations/05-capacity-planning/scaling-inflection-point/" data-link-title="規模拐點判斷" data-link-desc="判斷什麼訊號代表該擴容、什麼代表可縮容、以及該往垂直還水平擴時，用飽和曲線的三段區間、膝點的早期訊號與 ramp-up 方法回來讀">規模拐點判斷</a></li>
<li>縮回的實例退場順序、drain 怎麼做 → <a href="/blog/operations/04-service-health/graceful-shutdown/" data-link-title="Graceful shutdown" data-link-desc="設計服務收到停止信號後的收束流程時，釐清 SIGTERM 到 SIGKILL 的 grace period、退場的固定順序、以及不同 workload 的 drain 窗口要留多長">模組四 Graceful shutdown</a></li>
<li>佇列解耦怎麼接、突發流量的完整應對 → <a href="/blog/operations/07-burst-traffic/" data-link-title="模組七：突發流量應對" data-link-desc="行銷活動或新聞曝光帶來 10x-100x 流量時怎麼撐 — 突發分類、降級策略、queue 緩衝、規模分級應對">模組七 突發流量</a></li>
<li>垂直還是水平——擴的方向怎麼選 → <a href="/blog/operations/02-horizontal-scaling/vertical-vs-horizontal/" data-link-title="垂直與水平擴展的判斷" data-link-desc="決定該加 CPU 還是加實例時，用「這個元件能不能做成無狀態」當判斷樞紐，並知道有狀態的節點該用垂直撐、讀路徑用副本擴、撐不住才分片">垂直與水平擴展的判斷</a></li>
</ul>
]]></content:encoded></item><item><title>垂直與水平擴展的判斷</title><link>https://tarrragon.github.io/blog/operations/02-horizontal-scaling/vertical-vs-horizontal/</link><pubDate>Fri, 03 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/operations/02-horizontal-scaling/vertical-vs-horizontal/</guid><description>&lt;p>垂直擴展跟水平擴展的判斷樞紐是一個問題：這個元件能不能做成無狀態。能做成無狀態的，加實例（水平）幾乎可以無腦複製；做不成、或改造成本太高的，只能換更大的機器（垂直）先撐。這個模組前面幾章一直在講怎麼做到無狀態，正是因為無狀態與否是這個決策的關鍵——一個服務落在哪一邊，決定了它該往哪個方向擴。&lt;/p>
&lt;p>兩種擴展的機制、各自的物理與成本天花板，在 &lt;a href="https://tarrragon.github.io/blog/operations/05-capacity-planning/scaling-inflection-point/" data-link-title="規模拐點判斷" data-link-desc="判斷什麼訊號代表該擴容、什麼代表可縮容、以及該往垂直還水平擴時，用飽和曲線的三段區間、膝點的早期訊號與 ramp-up 方法回來讀">規模拐點判斷&lt;/a> 的垂直對水平段展開過（垂直有兩道牆——換更大的機器會撞到物理規格上限、以及成本在高階機型非線性飆升；水平則有協調與連線放大成本）。這一章不重述那些機制，聚焦在「怎麼用無狀態與否做這個判斷」。&lt;/p>
&lt;h2 id="先問能不能無狀態">先問能不能無狀態&lt;/h2>
&lt;p>判斷從一個問題開始：這個要擴的元件是無狀態的、還是有狀態的。無狀態的部分（API server、worker）水平擴展幾乎無腦——每個實例對等、加一台就多一台的容量，這個模組前面幾章的設計就是為了讓服務落在這一邊。有狀態的部分（資料庫、快取、session store）不能靠複製水平擴展，因為狀態不對等——不能開兩台資料庫主庫同時寫、期待它們自己一致。&lt;/p>
&lt;p>把這個前提判反，是水平擴展最常見的撞牆方式：用水平擴展的策略去動一個有狀態的服務，加了實例卻發現狀態沒有跟著分攤，QPS 沒漲、甚至因為協調成本更慢。判斷的第一步永遠是先分清楚：這個元件是無狀態的（水平的候選）、還是有狀態的（要另一套策略）。&lt;/p>
&lt;h2 id="有狀態的節點垂直撐讀路徑用副本擴">有狀態的節點：垂直撐，讀路徑用副本擴&lt;/h2>
&lt;p>有狀態的節點（典型是資料庫主庫）擴展走另一條路。寫入的部分靠垂直撐——換更大的機器,不改程式、立即見效，適合這種不能水平複製的關鍵節點。但垂直有天花板，且有狀態的關鍵節點常常比機器規格更早撞牆（交易型資料庫主庫的真實上限往往卡在架構因素、不是規格不夠），這條在拐點判斷那章講過。&lt;/p>
&lt;p>垂直撐主庫的同時，讀路徑可以水平擴——加唯讀副本分攤讀流量，這是 &lt;a href="https://tarrragon.github.io/blog/operations/02-horizontal-scaling/shared-storage-selection/" data-link-title="Shared storage 選型" data-link-desc="把外置的狀態放進共享儲存時，按存取型態與狀態性質選 DB、KV、物件儲存，並處理多實例共享一個 DB 帶來的讀路徑與連線瓶頸">Shared storage 選型&lt;/a> 的讀寫分離。所以一個有狀態的資料庫，實際的擴展是混合的：寫路徑垂直撐、讀路徑用副本水平擴。垂直撐到頂、讀副本也不夠時，最後一步是分片（sharding）——把資料按某個鍵拆到多台，每台只管一部分，這才是有狀態服務真正的水平擴展，但它要重新設計資料的切分方式，成本最高、留到前面手段都用盡才動。&lt;/p>
&lt;h2 id="混合是常態不是二選一">混合是常態，不是二選一&lt;/h2>
&lt;p>真實系統極少是純垂直或純水平，而是按層混合：無狀態的應用層水平擴（加實例）、有狀態的資料層垂直撐加讀副本（撐不住再分片）。這對應一個擴展框架的三個方向——複製（把無狀態的元件多開幾份）、功能拆分（把不同功能拆成獨立服務、各自按自己的需求擴）、資料分片（把有狀態的資料按鍵拆開），常常同時在動、不是選一個。這個框架（AKF Scale Cube）的完整拆解在 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/scaling-axes/" data-link-title="9.13 擴展軸與 Stateless 前提" data-link-desc="整理垂直 / 水平擴展取捨、stateless vs stateful 前提、auto scaling 操作模型與兩種擴展的 hidden cost">backend 擴展軸&lt;/a>。&lt;/p>
&lt;p>判斷的收斂是這樣：先問這一層能不能無狀態。能，就水平複製，這是最便宜的擴展。不能，先看改造成無狀態的成本值不值得；不值得或改不動，就垂直撐這個節點、讀路徑用副本水平擴、撐到極限再分片。每一步都是在「這個元件的狀態能不能被分攤」這條線上做選擇——這也是為什麼水平擴展這個模組，從頭到尾都在講無狀態。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>垂直的兩道牆、水平的隱性成本、擴展框架的完整機制 → &lt;a href="https://tarrragon.github.io/blog/operations/05-capacity-planning/scaling-inflection-point/" data-link-title="規模拐點判斷" data-link-desc="判斷什麼訊號代表該擴容、什麼代表可縮容、以及該往垂直還水平擴時，用飽和曲線的三段區間、膝點的早期訊號與 ramp-up 方法回來讀">規模拐點判斷&lt;/a>&lt;/li>
&lt;li>怎麼把一個服務改造成無狀態 → &lt;a href="https://tarrragon.github.io/blog/operations/02-horizontal-scaling/stateless-design/" data-link-title="Stateless 設計原則" data-link-desc="要把服務改成能水平擴展的無狀態設計時，釐清什麼算「本機狀態」、哪些常見寫法會破壞無狀態、隱式狀態怎麼抓、以及定時 job 這種無狀態例外怎麼處理">Stateless 設計原則&lt;/a>&lt;/li>
&lt;li>讀寫分離、讀副本、分片的儲存側設計 → &lt;a href="https://tarrragon.github.io/blog/operations/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/operations/02-horizontal-scaling/scaling-triggers/" data-link-title="擴展的觸發與縮回" data-link-desc="設計自動擴縮時，釐清擴展前要先窮盡哪些低成本手段、觸發訊號怎麼分層、以及縮回為什麼比擴容難、要先 drain 才能縮">擴展的觸發與縮回&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>垂直擴展跟水平擴展的判斷樞紐是一個問題：這個元件能不能做成無狀態。能做成無狀態的，加實例（水平）幾乎可以無腦複製；做不成、或改造成本太高的，只能換更大的機器（垂直）先撐。這個模組前面幾章一直在講怎麼做到無狀態，正是因為無狀態與否是這個決策的關鍵——一個服務落在哪一邊，決定了它該往哪個方向擴。</p>
<p>兩種擴展的機制、各自的物理與成本天花板，在 <a href="/blog/operations/05-capacity-planning/scaling-inflection-point/" data-link-title="規模拐點判斷" data-link-desc="判斷什麼訊號代表該擴容、什麼代表可縮容、以及該往垂直還水平擴時，用飽和曲線的三段區間、膝點的早期訊號與 ramp-up 方法回來讀">規模拐點判斷</a> 的垂直對水平段展開過（垂直有兩道牆——換更大的機器會撞到物理規格上限、以及成本在高階機型非線性飆升；水平則有協調與連線放大成本）。這一章不重述那些機制，聚焦在「怎麼用無狀態與否做這個判斷」。</p>
<h2 id="先問能不能無狀態">先問能不能無狀態</h2>
<p>判斷從一個問題開始：這個要擴的元件是無狀態的、還是有狀態的。無狀態的部分（API server、worker）水平擴展幾乎無腦——每個實例對等、加一台就多一台的容量，這個模組前面幾章的設計就是為了讓服務落在這一邊。有狀態的部分（資料庫、快取、session store）不能靠複製水平擴展，因為狀態不對等——不能開兩台資料庫主庫同時寫、期待它們自己一致。</p>
<p>把這個前提判反，是水平擴展最常見的撞牆方式：用水平擴展的策略去動一個有狀態的服務，加了實例卻發現狀態沒有跟著分攤，QPS 沒漲、甚至因為協調成本更慢。判斷的第一步永遠是先分清楚：這個元件是無狀態的（水平的候選）、還是有狀態的（要另一套策略）。</p>
<h2 id="有狀態的節點垂直撐讀路徑用副本擴">有狀態的節點：垂直撐，讀路徑用副本擴</h2>
<p>有狀態的節點（典型是資料庫主庫）擴展走另一條路。寫入的部分靠垂直撐——換更大的機器,不改程式、立即見效，適合這種不能水平複製的關鍵節點。但垂直有天花板，且有狀態的關鍵節點常常比機器規格更早撞牆（交易型資料庫主庫的真實上限往往卡在架構因素、不是規格不夠），這條在拐點判斷那章講過。</p>
<p>垂直撐主庫的同時，讀路徑可以水平擴——加唯讀副本分攤讀流量，這是 <a href="/blog/operations/02-horizontal-scaling/shared-storage-selection/" data-link-title="Shared storage 選型" data-link-desc="把外置的狀態放進共享儲存時，按存取型態與狀態性質選 DB、KV、物件儲存，並處理多實例共享一個 DB 帶來的讀路徑與連線瓶頸">Shared storage 選型</a> 的讀寫分離。所以一個有狀態的資料庫，實際的擴展是混合的：寫路徑垂直撐、讀路徑用副本水平擴。垂直撐到頂、讀副本也不夠時，最後一步是分片（sharding）——把資料按某個鍵拆到多台，每台只管一部分，這才是有狀態服務真正的水平擴展，但它要重新設計資料的切分方式，成本最高、留到前面手段都用盡才動。</p>
<h2 id="混合是常態不是二選一">混合是常態，不是二選一</h2>
<p>真實系統極少是純垂直或純水平，而是按層混合：無狀態的應用層水平擴（加實例）、有狀態的資料層垂直撐加讀副本（撐不住再分片）。這對應一個擴展框架的三個方向——複製（把無狀態的元件多開幾份）、功能拆分（把不同功能拆成獨立服務、各自按自己的需求擴）、資料分片（把有狀態的資料按鍵拆開），常常同時在動、不是選一個。這個框架（AKF Scale Cube）的完整拆解在 <a href="/blog/backend/09-performance-capacity/scaling-axes/" data-link-title="9.13 擴展軸與 Stateless 前提" data-link-desc="整理垂直 / 水平擴展取捨、stateless vs stateful 前提、auto scaling 操作模型與兩種擴展的 hidden cost">backend 擴展軸</a>。</p>
<p>判斷的收斂是這樣：先問這一層能不能無狀態。能，就水平複製，這是最便宜的擴展。不能，先看改造成無狀態的成本值不值得；不值得或改不動，就垂直撐這個節點、讀路徑用副本水平擴、撐到極限再分片。每一步都是在「這個元件的狀態能不能被分攤」這條線上做選擇——這也是為什麼水平擴展這個模組，從頭到尾都在講無狀態。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>垂直的兩道牆、水平的隱性成本、擴展框架的完整機制 → <a href="/blog/operations/05-capacity-planning/scaling-inflection-point/" data-link-title="規模拐點判斷" data-link-desc="判斷什麼訊號代表該擴容、什麼代表可縮容、以及該往垂直還水平擴時，用飽和曲線的三段區間、膝點的早期訊號與 ramp-up 方法回來讀">規模拐點判斷</a></li>
<li>怎麼把一個服務改造成無狀態 → <a href="/blog/operations/02-horizontal-scaling/stateless-design/" data-link-title="Stateless 設計原則" data-link-desc="要把服務改成能水平擴展的無狀態設計時，釐清什麼算「本機狀態」、哪些常見寫法會破壞無狀態、隱式狀態怎麼抓、以及定時 job 這種無狀態例外怎麼處理">Stateless 設計原則</a></li>
<li>讀寫分離、讀副本、分片的儲存側設計 → <a href="/blog/operations/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/operations/02-horizontal-scaling/scaling-triggers/" data-link-title="擴展的觸發與縮回" data-link-desc="設計自動擴縮時，釐清擴展前要先窮盡哪些低成本手段、觸發訊號怎麼分層、以及縮回為什麼比擴容難、要先 drain 才能縮">擴展的觸發與縮回</a></li>
</ul>
]]></content:encoded></item></channel></rss>