<?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/tags/%E7%A7%92%E6%AE%BA/</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>Thu, 15 Oct 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/%E7%A7%92%E6%AE%BA/index.xml" rel="self" type="application/rss+xml"/><item><title>9.0.1 售票搶購的負載路徑：熱點列與連線池、CDN 合併回源、排隊放行的速率、資料庫認領與加入 Redis 閘門的條件、付款期限的條件更新</title><link>https://tarrragon.github.io/blog/backend/09-performance-capacity/ticketing-load-path/</link><pubDate>Thu, 15 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/09-performance-capacity/ticketing-load-path/</guid><description>&lt;p>這篇以 &lt;a href="https://tarrragon.github.io/blog/backend/case-systems/" data-link-title="Backend 範例系統：短網址、售票搶購、電商 checkout 與交易撮合的功能、需求與規模假設" data-link-desc="Backend 教材幾篇以具體系統走完整條請求路徑的文章共用的四個範例系統：每個系統提供的功能與請求種類、不能讓步與可以讓步的需求、規模假設與起點架構，以及四個系統在成功請求的總數是否有上限、延遲是否改變請求的價值、同一個請求被處理兩次的後果上的差別">Backend 範例系統&lt;/a> 裡的售票搶購為例，沿著一張票從使用者打開座位圖到付款記帳的路徑，逐段寫負載超過容量時這一段先耗盡什麼資源、前一段的保護把負載轉到了哪裡、這一段出錯時由哪一段補救，最後寫售票的需求讓哪些做法可以簡化、哪些需求怎麼設計都要付出成本。一次一筆的處理、能提早回應的請求、保護把負載轉到哪裡這幾個概念的通則在 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/load-path-analysis/" data-link-title="9.0 請求路徑上的負載分析：一次一筆的處理、在它之前能回應的請求、多出來的請求怎麼處置、保護把負載轉到哪裡、重送與去重，以及保護元件與依賴故障時的處置" data-link-desc="沿著一個會改變狀態的請求從用戶端到資料庫的路徑分析負載時共用的幾個概念：必須一次只處理一筆的那件事與它的互斥範圍、需要經過它的請求裡結果事先已確定的比例、多出來的請求要排隊、拒絕還是請使用者重新確認、每一段保護把負載轉給了哪個元件（保護元件自己的狀態、連線數、輪詢、下游接收端、外部配額）、佇列與重按帶來的重送怎麼去重、保護元件故障或輸出錯的值時下游讀到什麼，以及依賴變慢、部分失效與恢復時的第二波湧入">9.0 請求路徑上的負載分析&lt;/a>，這一篇寫它們在售票上的樣子；票數、請求量、持鎖時間這類數字是範例系統的假設，軟體的預設值取自官方文件。對照的系統是 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/matching-engine-load-path/" data-link-title="9.0.2 交易撮合的負載路徑：閘道的帳戶限流與明確拒絕、依序日誌的複寫延遲、單一寫入者的撮合引擎、行情的合併送出與重放驗證容量" data-link-desc="以交易撮合為例，沿著一筆委託從入口閘道到成交回報的路徑，寫出撮合之前結果無法確定、處理順序決定成交、延遲改變請求的價值這些性質讓哪些負載保護不能用，閘道、依序日誌、單一寫入者的撮合引擎與行情發布各自先耗盡的資源與出錯時的補救，過載時明確拒絕超量委託與以重放驗證容量的做法，並與售票搶購對照">9.0.2 交易撮合的負載路徑&lt;/a>。&lt;/p>
&lt;h2 id="售票的性質與需求">售票的性質與需求&lt;/h2>
&lt;p>售票系統的負載設計從兩個可以利用的性質與一項無法讓步的需求出發，另有一個業務上的選擇決定了尖峰的形狀。&lt;strong>成功請求的總數有上限&lt;/strong>：成功的購票總數不會超過票數，一個場次五千張票、十萬個帳號搶、每個帳號限購一張時，票賣完那一刻，其餘九萬五千個購票請求的結果已經確定是售完（直到付款期限到期、有票被放回為止），回應它們不必讀資料庫，拒絕它們也不會少賣任何一張票。&lt;strong>延遲不改變請求的價值&lt;/strong>：不劃位、票價固定時，排隊幾分鐘之後買到的仍是同一種票、同一個價格，使用者因此接受排隊；對號入座時延遲改變的是剩下哪些座位可選，動態定價時連價格也跟著變，這兩種情形下這個性質只部分成立。&lt;strong>不超賣，而且付款與放回的結果不能同時生效&lt;/strong>：同一張票不能賣給兩個人，付款成功的訂單一定有票，付款期限到期被放回的票不能同時算成已付款。&lt;/p>
&lt;p>尖峰的形狀來自順序規則。範例系統採先到先得，開賣那一刻所有人同時送出請求，下文的排隊與放行都是為這個尖峰而設；改用抽籤時，請求攤在報名期間，排隊、提早告知買不到、放行速率這幾段都不需要，負載只剩抽籤結果公布之後的付款，抽籤需要報名有成本或身分驗證才擋得住多開帳號，見 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/unplanned-traffic-surge/" data-link-title="9.15 無預警瞬時大流量：流量來源辨識、擴展緩衝、請求優先等級、准入控制、體積型攻擊與退路" data-link-desc="沒有準備窗口的瞬時大流量（突發流量、流量暴增）怎麼承接：依流量形狀分辨真人需求、應用層的濫用流量、體積型攻擊（DDoS），自動擴展的反應時間換算成緩衝，入口標記請求優先等級與負載卸除的順序，等候室與事前註冊這類准入控制，把體積型攻擊交給更大的邊緣網路，以及擴展跟不上時的退路">9.15 無預警瞬時大流量&lt;/a> 的〈准入控制：虛擬等候室（virtual waiting room）、waitlist 與事前註冊〉。&lt;/p>
&lt;p>無法讓步的需求決定了互斥範圍（哪些請求彼此必須輪流處理，見 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/load-path-analysis/" data-link-title="9.0 請求路徑上的負載分析：一次一筆的處理、在它之前能回應的請求、多出來的請求怎麼處置、保護把負載轉到哪裡、重送與去重，以及保護元件與依賴故障時的處置" data-link-desc="沿著一個會改變狀態的請求從用戶端到資料庫的路徑分析負載時共用的幾個概念：必須一次只處理一筆的那件事與它的互斥範圍、需要經過它的請求裡結果事先已確定的比例、多出來的請求要排隊、拒絕還是請使用者重新確認、每一段保護把負載轉給了哪個元件（保護元件自己的狀態、連線數、輪詢、下游接收端、外部配額）、佇列與重按帶來的重送怎麼去重、保護元件故障或輸出錯的值時下游讀到什麼，以及依賴變慢、部分失效與恢復時的第二波湧入">9.0 請求路徑上的負載分析&lt;/a> 的〈一次一筆的處理與它的互斥範圍〉）：同一張票（對號入座時是同一個座位）的認領與訂單狀態的改變，一次只能處理一筆。範圍是一張票，不是整場的庫存；整場的庫存只有在被寫成一個計數欄時，才會讓所有購票請求排成一列，下一節就是這種寫法。&lt;/p>
&lt;h2 id="全部請求更新同一個庫存計數時先耗盡的資源">全部請求更新同一個庫存計數時先耗盡的資源&lt;/h2>
&lt;p>全場庫存寫成一個計數欄、沒有任何保護時，每一個購票請求都在資料庫執行 &lt;code>UPDATE events SET remaining = remaining - 1 WHERE id = $1 AND remaining &amp;gt; 0&lt;/code>。十萬個請求更新的是同一列，資料庫對這一列加的行鎖一次只讓一個交易持有，其餘的交易排隊等鎖。持有鎖的時間是交易從更新到提交的時間，提交時要把交易日誌寫到磁碟，所以這一列每秒能完成的更新數，由單一交易持有鎖的時間決定，不會因為連線池加大或伺服器加多而提高，等鎖的交易越多反而越低（MySQL 的文件寫到，大量執行緒等同一把鎖時，InnoDB 的死鎖偵測本身就會拖慢處理）：假設一筆交易持有鎖 1 毫秒，這一列每秒最多完成一千筆更新，十萬個請求要排一百秒。1 毫秒是單句更新、自動提交時的量級；先讀出庫存、在應用程式裡判斷、再寫回的交易（ORM 在交易裡 &lt;code>SELECT ... FOR UPDATE&lt;/code> 之後才 &lt;code>save()&lt;/code> 的寫法），持鎖時間包含中間幾次網路往返，長一個量級以上，每秒完成的更新數跟著少一個量級。這種同一列被大量更新而排隊的情形叫熱點列（hot row），排隊的機制在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/high-frequency-counting/" data-link-title="1.18 高頻計數的寫入與彙總：熱點列的鎖、只新增的事件、可以重跑的彙總與重送的事件" data-link-desc="熱點列更新的吞吐量上限與它的成因、拆成多列／只新增事件／先在記憶體裡合併的差別、只新增事件搬去哪裡的成本、累加與重算在什麼條件下漏算或重複計算、讓彙總排程互斥的建議鎖與連線池的交互，以及重送事件的冪等寫入、推導事實的存放位置，以及 Redis 承擔即時計數、跨小時不重複數、去重與互斥時各自的邊界">1.18 高頻計數的寫入與彙總&lt;/a> 的〈熱點列：同一列更新的排隊機制〉，分散寫入的做法在同一篇的〈把寫入分開的三種做法：拆成多列、只新增事件、先在記憶體裡合併〉；以庫存計數當熱點列的例子在 &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、避免資料庫成為瓶頸">1.1 高併發下的 SQL 讀寫邊界&lt;/a> 的〈Hot Row / Lock Contention 識別與處理〉。&lt;/p>
&lt;p>等鎖的每一個交易都佔著一條資料庫連線。在穩定的系統裡，同時被佔用的連線數等於請求到達率乘上每條連線被佔用的時間（&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/little-law/" data-link-title="Little&amp;#39;s Law" data-link-desc="說明系統內並發數、到達率與逗留時間三者的數學關係">Little&amp;rsquo;s Law&lt;/a>），所以佔用時間變長，需要的連線數就跟著變多；到達率高於完成率時系統已經不穩定，差額直接累積在等待中：每秒進來一萬個扣庫存的請求（示意）而每秒只完成一千個，等待中的請求每秒增加九千，任何大小的連線池都會在幾秒內用完。連線池用完之後，新的請求在應用程式端等連線，查場次、看座位圖這些與扣庫存無關的請求也用同一個連線池，跟著等。從外面看是整個網站停止回應，而連線池耗盡只是症狀，原因是每條連線被佔用的時間因為等鎖而變長。把連線池加大不會改善，只會讓更多交易排在同一把鎖後面；讓扣庫存與其他請求用不同的連線池（&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/bulkhead/" data-link-title="Bulkhead" data-link-desc="說明 bulkhead 如何用資源分艙限制故障擴散">Bulkhead&lt;/a>），至少讓查場次的請求不受影響。&lt;/p>
&lt;p>這些等待最後變成哪一種結果，取決於每一層等待（行鎖、連線池、呼叫方的逾時）是否設有上限、上限多長。MySQL InnoDB 的行鎖等待預設 50 秒後逾時（逾時時只回滾當下那一句，不是整個交易），PostgreSQL 的 &lt;code>lock_timeout&lt;/code> 預設不設上限；應用程式端等連線的時間由連線池的設定決定。等待的上限短於呼叫方（入口、瀏覽器）的逾時時，到期變成請求失敗；上限長於呼叫方的逾時、或根本沒有上限時，呼叫方先放棄，使用者看到的是無回應。同一個預設值在不同的入口後面落在不同的結果：InnoDB 預設的 50 秒比 Nginx 預設的 &lt;code>proxy_read_timeout&lt;/code>（60 秒）短，請求會先收到資料庫的錯誤；比 CloudFront 回源的回應逾時預設值（30 秒）長，CDN 會先放棄。不論是收到錯誤還是一直沒有回應，使用者都會重新整理，同一個人送出第二、第三個請求，進來的請求率因此高於真實的需求。這種由重試疊加出來的流量叫 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/retry-storm/" data-link-title="Retry Storm" data-link-desc="說明大量重試如何把局部故障放大成系統壓力">Retry Storm&lt;/a>，重試的請求本身又佔住鎖與連線，可以讓服務在開賣的人潮退去之後仍然停在失效狀態。各種結果的判斷標準與成因在 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/overload-outcomes/" data-link-title="9.16 負載超過容量時服務的表現：變慢、請求失敗、無回應、程序終止，尖峰過去或應對切換之後仍持續的停在失效、擺盪與第二波湧入，以及應對選出的降級回應，各自的判斷標準、成因、應對與看得到它的監控層" data-link-desc="負載超過容量（過載）時，服務呈現的結果分成變慢、請求失敗（抵達後被拒與根本沒有抵達）、無回應、程序終止，以及尖峰過去、依賴恢復或應對切換之後仍持續的停在失效、擺盪與第二波湧入；每一種的判斷標準、通常的成因與應對，哪一種先出現由先耗盡的資源與等待是否設有上限決定，影響範圍怎麼隨時間擴大，服務端監控的各層各自看得到哪一種，降級回應怎麼計數，過載在資料上造成的重複寫入、接受後遺失與讀到舊資料，以及描述一次過載要寫出的項目">9.16 負載超過容量時服務的表現&lt;/a> 的〈變慢之後走向哪一種結果：先耗盡的資源與等待是否設有上限〉。&lt;/p>
&lt;p>這種寫法有兩處要改：庫存的寫法（下文〈扣庫存：資料庫逐張認領與加入 Redis 閘門的條件〉把它改成每張票一列），以及抵達資料庫的請求數（下面各段讓它接近真正可能買到票的人數）。各段從使用者那一端往資料庫走，每一段擋下或延後某一類請求：可以由快取回應的讀取、刷票腳本、還沒輪到的購票、確定買不到的購票。&lt;/p>
&lt;h2 id="邊緣可以快取的內容與機器人過濾">邊緣：可以快取的內容與機器人過濾&lt;/h2>
&lt;p>座位圖頁面上的內容依變動的頻率分開處理。場館平面圖、票價、場次資訊在開賣期間不變，CDN 可以快取一整天；各區的剩餘數量每秒都在變。剩餘數量讀到幾秒前的值是可以接受的，因為最後判斷有沒有票的是扣庫存那一步：使用者看到某區還有票、按下去才知道剛賣完，結果是一次多餘的點擊，不是超賣。所以剩餘數量也能交給 CDN，只是快取時間設得短（示意：一秒）。&lt;/p>
&lt;p>快取時間短的時候要確認一件事：快取過期的那一刻，同時在讀的使用者會不會全部回源。CDN 的每個節點各自快取，快取過期時，一個節點上同時要這個頁面的請求，若每一個都送回源站，源站會在同一刻收到大量請求（&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>）。多數 CDN 能把同一個快取鍵同時發生的回源請求合併成一個（request coalescing，CDN 的文件也稱為 request collapsing，應用程式內的同一種做法是 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/singleflight/" data-link-title="Singleflight" data-link-desc="說明相同工作同時發生時如何合併成一次下游請求">Singleflight&lt;/a>），前提是剩餘數量的回應不依 cookie 或登入身分分開快取，所有人共用同一個快取鍵；符合這個前提時，源站每秒收到的剩餘數量請求約等於 CDN 節點數乘以每秒的過期次數，與在線人數無關。源站回應剩餘數量時讀的是一份每秒更新一次的唯讀計數（下文〈扣庫存：資料庫逐張認領與加入 Redis 閘門的條件〉），而不是每個請求都到資料庫去數還沒被認領的票：CDN 合併回源之後源站收到的請求不多，每次都去數也可能負擔得起，而一份每秒更新的計數讓這件事與在線人數完全無關。快取期限的計算方式在 &lt;a href="https://tarrragon.github.io/blog/backend/12-http-caching/freshness-lifetime/" data-link-title="12.2 新鮮度期限的計算：max-age、s-maxage、Expires 與啟發式期限" data-link-desc="快取在不詢問 origin 的情況下可以直接使用一份副本多久：新鮮度期限的取用順序、Age 標頭與副本年齡的計算、沒有明確期限時規範允許的啟發式期限，以及 Chromium、Firefox、Varnish、nginx 在啟發式期限上的差異">12.2 新鮮度期限的計算&lt;/a>，過期之後的回源驗證在 &lt;a href="https://tarrragon.github.io/blog/backend/12-http-caching/revalidation/" data-link-title="12.3 過期副本的驗證：條件請求、304 回應、no-cache 與 no-store" data-link-desc="副本過期之後快取怎麼向 origin 確認它是否仍然有效：ETag 與 Last-Modified 兩種驗證子、條件請求與 304 回應、no-cache、no-store 與 must-revalidate 的語意差別，以及共用快取實作在驗證上的預設行為">12.3 過期副本的驗證&lt;/a>。&lt;/p>
&lt;p>刷票腳本與已知的惡意來源在邊緣以 WAF 的規則與依來源位址的速率限制擋下，擋掉的請求不消耗源站的任何資源。代價是誤判：同一個公司或校園網路後面的真實使用者共用一個對外位址，依位址的限制會把他們一起擋掉；被擋掉的比例依活動而定，沒有可以通用的數字，要從過去場次的紀錄估計。依位址的限制擋得住的只是單一來源的大量請求；刷票有轉售價差這個經濟誘因，刷票者會把流量分散到大量的位址、開大量的帳號，所以「每人限購一張」等於每個帳號限購一張，要搭配事前註冊、身分驗證這類讓開帳號有成本的做法，流量來源的辨識與事前註冊在 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/unplanned-traffic-surge/" data-link-title="9.15 無預警瞬時大流量：流量來源辨識、擴展緩衝、請求優先等級、准入控制、體積型攻擊與退路" data-link-desc="沒有準備窗口的瞬時大流量（突發流量、流量暴增）怎麼承接：依流量形狀分辨真人需求、應用層的濫用流量、體積型攻擊（DDoS），自動擴展的反應時間換算成緩衝，入口標記請求優先等級與負載卸除的順序，等候室與事前註冊這類准入控制，把體積型攻擊交給更大的邊緣網路，以及擴展跟不上時的退路">9.15 無預警瞬時大流量&lt;/a> 的〈流量來源的辨識：真人需求、應用層的濫用流量、體積型攻擊〉。邊緣出錯時的補救在源站：WAF 規則被繞過時，以及 CDN 本身故障、流量改為直接送到源站時，源站仍要有自己的准入控制，不能假設進來的請求都已經過濾過。&lt;/p>
&lt;h2 id="准入排隊與放行的速率">准入：排隊與放行的速率&lt;/h2>
&lt;p>排隊把「什麼時候處理」與「什麼時候進來」分開。站內的兩個案例是兩種形式：&lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/seatgeek-virtual-waiting-room/" data-link-title="9.C16 SeatGeek：DynamoDB &amp;#43; Lambda 打造的虛擬等候室" data-link-desc="SeatGeek 用 DynamoDB 4 張表 &amp;#43; Lambda Bouncer 實作 flash-sale 的准入控制（等候室）、取代第三方 waiting room 服務">9.C16 SeatGeek&lt;/a> 的虛擬等候室在使用者進入購票頁之前就讓他排隊，依事先設定的速率放行，或以同時在購票頁裡的人數為上限；&lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/tixcraft-ticketing-flash-sale-spike/" data-link-title="9.C15 拓元 Tixcraft：售票搶購的瞬間爆量架構" data-link-desc="拓元把售票系統搬到 AWS 後，30 分鐘內把資源擴到 130 倍（一場熱門活動擴到 800 台 EC2），處理 10 萬以上同時在線與每秒 2,500 筆以上訂單；資料庫改用 DynamoDB">9.C15 拓元&lt;/a> 依架構圖推論，是把訂單先寫進可以大幅擴充的儲存，後端用自己處理得了的速度消費。兩種形式給使用者的回應不同：等候室這種形式可以在等候頁顯示使用者的位置（這是一般的做法，SeatGeek 的原文沒寫它的等候頁顯示什麼）；寫入緩衝回應的是「已收到」，使用者可能以為已經下單，而他的請求還在等處理。&lt;/p></description><content:encoded><![CDATA[<p>這篇以 <a href="/blog/backend/case-systems/" data-link-title="Backend 範例系統：短網址、售票搶購、電商 checkout 與交易撮合的功能、需求與規模假設" data-link-desc="Backend 教材幾篇以具體系統走完整條請求路徑的文章共用的四個範例系統：每個系統提供的功能與請求種類、不能讓步與可以讓步的需求、規模假設與起點架構，以及四個系統在成功請求的總數是否有上限、延遲是否改變請求的價值、同一個請求被處理兩次的後果上的差別">Backend 範例系統</a> 裡的售票搶購為例，沿著一張票從使用者打開座位圖到付款記帳的路徑，逐段寫負載超過容量時這一段先耗盡什麼資源、前一段的保護把負載轉到了哪裡、這一段出錯時由哪一段補救，最後寫售票的需求讓哪些做法可以簡化、哪些需求怎麼設計都要付出成本。一次一筆的處理、能提早回應的請求、保護把負載轉到哪裡這幾個概念的通則在 <a href="/blog/backend/09-performance-capacity/load-path-analysis/" data-link-title="9.0 請求路徑上的負載分析：一次一筆的處理、在它之前能回應的請求、多出來的請求怎麼處置、保護把負載轉到哪裡、重送與去重，以及保護元件與依賴故障時的處置" data-link-desc="沿著一個會改變狀態的請求從用戶端到資料庫的路徑分析負載時共用的幾個概念：必須一次只處理一筆的那件事與它的互斥範圍、需要經過它的請求裡結果事先已確定的比例、多出來的請求要排隊、拒絕還是請使用者重新確認、每一段保護把負載轉給了哪個元件（保護元件自己的狀態、連線數、輪詢、下游接收端、外部配額）、佇列與重按帶來的重送怎麼去重、保護元件故障或輸出錯的值時下游讀到什麼，以及依賴變慢、部分失效與恢復時的第二波湧入">9.0 請求路徑上的負載分析</a>，這一篇寫它們在售票上的樣子；票數、請求量、持鎖時間這類數字是範例系統的假設，軟體的預設值取自官方文件。對照的系統是 <a href="/blog/backend/09-performance-capacity/matching-engine-load-path/" data-link-title="9.0.2 交易撮合的負載路徑：閘道的帳戶限流與明確拒絕、依序日誌的複寫延遲、單一寫入者的撮合引擎、行情的合併送出與重放驗證容量" data-link-desc="以交易撮合為例，沿著一筆委託從入口閘道到成交回報的路徑，寫出撮合之前結果無法確定、處理順序決定成交、延遲改變請求的價值這些性質讓哪些負載保護不能用，閘道、依序日誌、單一寫入者的撮合引擎與行情發布各自先耗盡的資源與出錯時的補救，過載時明確拒絕超量委託與以重放驗證容量的做法，並與售票搶購對照">9.0.2 交易撮合的負載路徑</a>。</p>
<h2 id="售票的性質與需求">售票的性質與需求</h2>
<p>售票系統的負載設計從兩個可以利用的性質與一項無法讓步的需求出發，另有一個業務上的選擇決定了尖峰的形狀。<strong>成功請求的總數有上限</strong>：成功的購票總數不會超過票數，一個場次五千張票、十萬個帳號搶、每個帳號限購一張時，票賣完那一刻，其餘九萬五千個購票請求的結果已經確定是售完（直到付款期限到期、有票被放回為止），回應它們不必讀資料庫，拒絕它們也不會少賣任何一張票。<strong>延遲不改變請求的價值</strong>：不劃位、票價固定時，排隊幾分鐘之後買到的仍是同一種票、同一個價格，使用者因此接受排隊；對號入座時延遲改變的是剩下哪些座位可選，動態定價時連價格也跟著變，這兩種情形下這個性質只部分成立。<strong>不超賣，而且付款與放回的結果不能同時生效</strong>：同一張票不能賣給兩個人，付款成功的訂單一定有票，付款期限到期被放回的票不能同時算成已付款。</p>
<p>尖峰的形狀來自順序規則。範例系統採先到先得，開賣那一刻所有人同時送出請求，下文的排隊與放行都是為這個尖峰而設；改用抽籤時，請求攤在報名期間，排隊、提早告知買不到、放行速率這幾段都不需要，負載只剩抽籤結果公布之後的付款，抽籤需要報名有成本或身分驗證才擋得住多開帳號，見 <a href="/blog/backend/09-performance-capacity/unplanned-traffic-surge/" data-link-title="9.15 無預警瞬時大流量：流量來源辨識、擴展緩衝、請求優先等級、准入控制、體積型攻擊與退路" data-link-desc="沒有準備窗口的瞬時大流量（突發流量、流量暴增）怎麼承接：依流量形狀分辨真人需求、應用層的濫用流量、體積型攻擊（DDoS），自動擴展的反應時間換算成緩衝，入口標記請求優先等級與負載卸除的順序，等候室與事前註冊這類准入控制，把體積型攻擊交給更大的邊緣網路，以及擴展跟不上時的退路">9.15 無預警瞬時大流量</a> 的〈准入控制：虛擬等候室（virtual waiting room）、waitlist 與事前註冊〉。</p>
<p>無法讓步的需求決定了互斥範圍（哪些請求彼此必須輪流處理，見 <a href="/blog/backend/09-performance-capacity/load-path-analysis/" data-link-title="9.0 請求路徑上的負載分析：一次一筆的處理、在它之前能回應的請求、多出來的請求怎麼處置、保護把負載轉到哪裡、重送與去重，以及保護元件與依賴故障時的處置" data-link-desc="沿著一個會改變狀態的請求從用戶端到資料庫的路徑分析負載時共用的幾個概念：必須一次只處理一筆的那件事與它的互斥範圍、需要經過它的請求裡結果事先已確定的比例、多出來的請求要排隊、拒絕還是請使用者重新確認、每一段保護把負載轉給了哪個元件（保護元件自己的狀態、連線數、輪詢、下游接收端、外部配額）、佇列與重按帶來的重送怎麼去重、保護元件故障或輸出錯的值時下游讀到什麼，以及依賴變慢、部分失效與恢復時的第二波湧入">9.0 請求路徑上的負載分析</a> 的〈一次一筆的處理與它的互斥範圍〉）：同一張票（對號入座時是同一個座位）的認領與訂單狀態的改變，一次只能處理一筆。範圍是一張票，不是整場的庫存；整場的庫存只有在被寫成一個計數欄時，才會讓所有購票請求排成一列，下一節就是這種寫法。</p>
<h2 id="全部請求更新同一個庫存計數時先耗盡的資源">全部請求更新同一個庫存計數時先耗盡的資源</h2>
<p>全場庫存寫成一個計數欄、沒有任何保護時，每一個購票請求都在資料庫執行 <code>UPDATE events SET remaining = remaining - 1 WHERE id = $1 AND remaining &gt; 0</code>。十萬個請求更新的是同一列，資料庫對這一列加的行鎖一次只讓一個交易持有，其餘的交易排隊等鎖。持有鎖的時間是交易從更新到提交的時間，提交時要把交易日誌寫到磁碟，所以這一列每秒能完成的更新數，由單一交易持有鎖的時間決定，不會因為連線池加大或伺服器加多而提高，等鎖的交易越多反而越低（MySQL 的文件寫到，大量執行緒等同一把鎖時，InnoDB 的死鎖偵測本身就會拖慢處理）：假設一筆交易持有鎖 1 毫秒，這一列每秒最多完成一千筆更新，十萬個請求要排一百秒。1 毫秒是單句更新、自動提交時的量級；先讀出庫存、在應用程式裡判斷、再寫回的交易（ORM 在交易裡 <code>SELECT ... FOR UPDATE</code> 之後才 <code>save()</code> 的寫法），持鎖時間包含中間幾次網路往返，長一個量級以上，每秒完成的更新數跟著少一個量級。這種同一列被大量更新而排隊的情形叫熱點列（hot row），排隊的機制在 <a href="/blog/backend/01-database/high-frequency-counting/" data-link-title="1.18 高頻計數的寫入與彙總：熱點列的鎖、只新增的事件、可以重跑的彙總與重送的事件" data-link-desc="熱點列更新的吞吐量上限與它的成因、拆成多列／只新增事件／先在記憶體裡合併的差別、只新增事件搬去哪裡的成本、累加與重算在什麼條件下漏算或重複計算、讓彙總排程互斥的建議鎖與連線池的交互，以及重送事件的冪等寫入、推導事實的存放位置，以及 Redis 承擔即時計數、跨小時不重複數、去重與互斥時各自的邊界">1.18 高頻計數的寫入與彙總</a> 的〈熱點列：同一列更新的排隊機制〉，分散寫入的做法在同一篇的〈把寫入分開的三種做法：拆成多列、只新增事件、先在記憶體裡合併〉；以庫存計數當熱點列的例子在 <a href="/blog/backend/01-database/high-concurrency-access/" data-link-title="1.1 高併發下的 SQL 讀寫邊界" data-link-desc="說明高併發服務如何共用資料庫 client、控制 transaction、管理 connection pool、避免資料庫成為瓶頸">1.1 高併發下的 SQL 讀寫邊界</a> 的〈Hot Row / Lock Contention 識別與處理〉。</p>
<p>等鎖的每一個交易都佔著一條資料庫連線。在穩定的系統裡，同時被佔用的連線數等於請求到達率乘上每條連線被佔用的時間（<a href="/blog/backend/knowledge-cards/little-law/" data-link-title="Little&#39;s Law" data-link-desc="說明系統內並發數、到達率與逗留時間三者的數學關係">Little&rsquo;s Law</a>），所以佔用時間變長，需要的連線數就跟著變多；到達率高於完成率時系統已經不穩定，差額直接累積在等待中：每秒進來一萬個扣庫存的請求（示意）而每秒只完成一千個，等待中的請求每秒增加九千，任何大小的連線池都會在幾秒內用完。連線池用完之後，新的請求在應用程式端等連線，查場次、看座位圖這些與扣庫存無關的請求也用同一個連線池，跟著等。從外面看是整個網站停止回應，而連線池耗盡只是症狀，原因是每條連線被佔用的時間因為等鎖而變長。把連線池加大不會改善，只會讓更多交易排在同一把鎖後面；讓扣庫存與其他請求用不同的連線池（<a href="/blog/backend/knowledge-cards/bulkhead/" data-link-title="Bulkhead" data-link-desc="說明 bulkhead 如何用資源分艙限制故障擴散">Bulkhead</a>），至少讓查場次的請求不受影響。</p>
<p>這些等待最後變成哪一種結果，取決於每一層等待（行鎖、連線池、呼叫方的逾時）是否設有上限、上限多長。MySQL InnoDB 的行鎖等待預設 50 秒後逾時（逾時時只回滾當下那一句，不是整個交易），PostgreSQL 的 <code>lock_timeout</code> 預設不設上限；應用程式端等連線的時間由連線池的設定決定。等待的上限短於呼叫方（入口、瀏覽器）的逾時時，到期變成請求失敗；上限長於呼叫方的逾時、或根本沒有上限時，呼叫方先放棄，使用者看到的是無回應。同一個預設值在不同的入口後面落在不同的結果：InnoDB 預設的 50 秒比 Nginx 預設的 <code>proxy_read_timeout</code>（60 秒）短，請求會先收到資料庫的錯誤；比 CloudFront 回源的回應逾時預設值（30 秒）長，CDN 會先放棄。不論是收到錯誤還是一直沒有回應，使用者都會重新整理，同一個人送出第二、第三個請求，進來的請求率因此高於真實的需求。這種由重試疊加出來的流量叫 <a href="/blog/backend/knowledge-cards/retry-storm/" data-link-title="Retry Storm" data-link-desc="說明大量重試如何把局部故障放大成系統壓力">Retry Storm</a>，重試的請求本身又佔住鎖與連線，可以讓服務在開賣的人潮退去之後仍然停在失效狀態。各種結果的判斷標準與成因在 <a href="/blog/backend/09-performance-capacity/overload-outcomes/" data-link-title="9.16 負載超過容量時服務的表現：變慢、請求失敗、無回應、程序終止，尖峰過去或應對切換之後仍持續的停在失效、擺盪與第二波湧入，以及應對選出的降級回應，各自的判斷標準、成因、應對與看得到它的監控層" data-link-desc="負載超過容量（過載）時，服務呈現的結果分成變慢、請求失敗（抵達後被拒與根本沒有抵達）、無回應、程序終止，以及尖峰過去、依賴恢復或應對切換之後仍持續的停在失效、擺盪與第二波湧入；每一種的判斷標準、通常的成因與應對，哪一種先出現由先耗盡的資源與等待是否設有上限決定，影響範圍怎麼隨時間擴大，服務端監控的各層各自看得到哪一種，降級回應怎麼計數，過載在資料上造成的重複寫入、接受後遺失與讀到舊資料，以及描述一次過載要寫出的項目">9.16 負載超過容量時服務的表現</a> 的〈變慢之後走向哪一種結果：先耗盡的資源與等待是否設有上限〉。</p>
<p>這種寫法有兩處要改：庫存的寫法（下文〈扣庫存：資料庫逐張認領與加入 Redis 閘門的條件〉把它改成每張票一列），以及抵達資料庫的請求數（下面各段讓它接近真正可能買到票的人數）。各段從使用者那一端往資料庫走，每一段擋下或延後某一類請求：可以由快取回應的讀取、刷票腳本、還沒輪到的購票、確定買不到的購票。</p>
<h2 id="邊緣可以快取的內容與機器人過濾">邊緣：可以快取的內容與機器人過濾</h2>
<p>座位圖頁面上的內容依變動的頻率分開處理。場館平面圖、票價、場次資訊在開賣期間不變，CDN 可以快取一整天；各區的剩餘數量每秒都在變。剩餘數量讀到幾秒前的值是可以接受的，因為最後判斷有沒有票的是扣庫存那一步：使用者看到某區還有票、按下去才知道剛賣完，結果是一次多餘的點擊，不是超賣。所以剩餘數量也能交給 CDN，只是快取時間設得短（示意：一秒）。</p>
<p>快取時間短的時候要確認一件事：快取過期的那一刻，同時在讀的使用者會不會全部回源。CDN 的每個節點各自快取，快取過期時，一個節點上同時要這個頁面的請求，若每一個都送回源站，源站會在同一刻收到大量請求（<a href="/blog/backend/knowledge-cards/cache-stampede/" data-link-title="Cache Stampede" data-link-desc="說明快取同時失效時大量 request 如何壓垮正式來源">Cache Stampede</a>）。多數 CDN 能把同一個快取鍵同時發生的回源請求合併成一個（request coalescing，CDN 的文件也稱為 request collapsing，應用程式內的同一種做法是 <a href="/blog/backend/knowledge-cards/singleflight/" data-link-title="Singleflight" data-link-desc="說明相同工作同時發生時如何合併成一次下游請求">Singleflight</a>），前提是剩餘數量的回應不依 cookie 或登入身分分開快取，所有人共用同一個快取鍵；符合這個前提時，源站每秒收到的剩餘數量請求約等於 CDN 節點數乘以每秒的過期次數，與在線人數無關。源站回應剩餘數量時讀的是一份每秒更新一次的唯讀計數（下文〈扣庫存：資料庫逐張認領與加入 Redis 閘門的條件〉），而不是每個請求都到資料庫去數還沒被認領的票：CDN 合併回源之後源站收到的請求不多，每次都去數也可能負擔得起，而一份每秒更新的計數讓這件事與在線人數完全無關。快取期限的計算方式在 <a href="/blog/backend/12-http-caching/freshness-lifetime/" data-link-title="12.2 新鮮度期限的計算：max-age、s-maxage、Expires 與啟發式期限" data-link-desc="快取在不詢問 origin 的情況下可以直接使用一份副本多久：新鮮度期限的取用順序、Age 標頭與副本年齡的計算、沒有明確期限時規範允許的啟發式期限，以及 Chromium、Firefox、Varnish、nginx 在啟發式期限上的差異">12.2 新鮮度期限的計算</a>，過期之後的回源驗證在 <a href="/blog/backend/12-http-caching/revalidation/" data-link-title="12.3 過期副本的驗證：條件請求、304 回應、no-cache 與 no-store" data-link-desc="副本過期之後快取怎麼向 origin 確認它是否仍然有效：ETag 與 Last-Modified 兩種驗證子、條件請求與 304 回應、no-cache、no-store 與 must-revalidate 的語意差別，以及共用快取實作在驗證上的預設行為">12.3 過期副本的驗證</a>。</p>
<p>刷票腳本與已知的惡意來源在邊緣以 WAF 的規則與依來源位址的速率限制擋下，擋掉的請求不消耗源站的任何資源。代價是誤判：同一個公司或校園網路後面的真實使用者共用一個對外位址，依位址的限制會把他們一起擋掉；被擋掉的比例依活動而定，沒有可以通用的數字，要從過去場次的紀錄估計。依位址的限制擋得住的只是單一來源的大量請求；刷票有轉售價差這個經濟誘因，刷票者會把流量分散到大量的位址、開大量的帳號，所以「每人限購一張」等於每個帳號限購一張，要搭配事前註冊、身分驗證這類讓開帳號有成本的做法，流量來源的辨識與事前註冊在 <a href="/blog/backend/09-performance-capacity/unplanned-traffic-surge/" data-link-title="9.15 無預警瞬時大流量：流量來源辨識、擴展緩衝、請求優先等級、准入控制、體積型攻擊與退路" data-link-desc="沒有準備窗口的瞬時大流量（突發流量、流量暴增）怎麼承接：依流量形狀分辨真人需求、應用層的濫用流量、體積型攻擊（DDoS），自動擴展的反應時間換算成緩衝，入口標記請求優先等級與負載卸除的順序，等候室與事前註冊這類准入控制，把體積型攻擊交給更大的邊緣網路，以及擴展跟不上時的退路">9.15 無預警瞬時大流量</a> 的〈流量來源的辨識：真人需求、應用層的濫用流量、體積型攻擊〉。邊緣出錯時的補救在源站：WAF 規則被繞過時，以及 CDN 本身故障、流量改為直接送到源站時，源站仍要有自己的准入控制，不能假設進來的請求都已經過濾過。</p>
<h2 id="准入排隊與放行的速率">准入：排隊與放行的速率</h2>
<p>排隊把「什麼時候處理」與「什麼時候進來」分開。站內的兩個案例是兩種形式：<a href="/blog/backend/09-performance-capacity/cases/seatgeek-virtual-waiting-room/" data-link-title="9.C16 SeatGeek：DynamoDB &#43; Lambda 打造的虛擬等候室" data-link-desc="SeatGeek 用 DynamoDB 4 張表 &#43; Lambda Bouncer 實作 flash-sale 的准入控制（等候室）、取代第三方 waiting room 服務">9.C16 SeatGeek</a> 的虛擬等候室在使用者進入購票頁之前就讓他排隊，依事先設定的速率放行，或以同時在購票頁裡的人數為上限；<a href="/blog/backend/09-performance-capacity/cases/tixcraft-ticketing-flash-sale-spike/" data-link-title="9.C15 拓元 Tixcraft：售票搶購的瞬間爆量架構" data-link-desc="拓元把售票系統搬到 AWS 後，30 分鐘內把資源擴到 130 倍（一場熱門活動擴到 800 台 EC2），處理 10 萬以上同時在線與每秒 2,500 筆以上訂單；資料庫改用 DynamoDB">9.C15 拓元</a> 依架構圖推論，是把訂單先寫進可以大幅擴充的儲存，後端用自己處理得了的速度消費。兩種形式給使用者的回應不同：等候室這種形式可以在等候頁顯示使用者的位置（這是一般的做法，SeatGeek 的原文沒寫它的等候頁顯示什麼）；寫入緩衝回應的是「已收到」，使用者可能以為已經下單，而他的請求還在等處理。</p>
<p>放行的速率要在業務期望的售完時間與扣庫存那一段的處理量之間取一個數字：放得太慢，票在開賣後很久才賣完；放得太快，放進來的請求超過扣庫存那一段每秒處理得了的量，請求在扣庫存那一段排隊：只用資料庫認領時，認領交易的提交處理量被超過，等待累積在連線池裡，同一個連線池上的查場次請求跟著等，〈全部請求更新同一個庫存計數時先耗盡的資源〉後半的情形重演；加了 Redis 時，先被超過的是 Redis 單一鍵的處理量。這個數字要以扣庫存那一段實測的處理量為準，量法在 <a href="/blog/backend/09-performance-capacity/saturation-discovery/" data-link-title="9.4 Saturation Discovery" data-link-desc="找出 throughput plateau 與 latency knee 的方法">9.4 Saturation Discovery</a>。</p>
<p>排隊的元件自己也承受負載：沒有排隊服務時，使用者以不斷重新整理購票頁的方式確認買不買得到，那些請求落在資料庫；有了排隊服務，它們變成對排隊服務的位置查詢，十萬人每五秒查一次就是每秒兩萬個查詢，讓這個查詢便宜的做法在 <a href="/blog/backend/09-performance-capacity/load-path-analysis/" data-link-title="9.0 請求路徑上的負載分析：一次一筆的處理、在它之前能回應的請求、多出來的請求怎麼處置、保護把負載轉到哪裡、重送與去重，以及保護元件與依賴故障時的處置" data-link-desc="沿著一個會改變狀態的請求從用戶端到資料庫的路徑分析負載時共用的幾個概念：必須一次只處理一筆的那件事與它的互斥範圍、需要經過它的請求裡結果事先已確定的比例、多出來的請求要排隊、拒絕還是請使用者重新確認、每一段保護把負載轉給了哪個元件（保護元件自己的狀態、連線數、輪詢、下游接收端、外部配額）、佇列與重按帶來的重送怎麼去重、保護元件故障或輸出錯的值時下游讀到什麼，以及依賴變慢、部分失效與恢復時的第二波湧入">9.0 請求路徑上的負載分析</a> 的〈每一段保護把負載轉到哪裡〉。SeatGeek 的等候室為每個連上 API Gateway 的訪客記一筆連線資料，等候人數本身就是要規劃的容量（原文沒寫連線協定）。</p>
<p>成功請求的總數有上限，在這一段可以直接利用。排隊的人數遠超過剩餘票數時，排在後面的人幾乎不可能買到，排隊服務可以在回應裡提早告訴他們「排在您前面的人數已超過剩餘票數」，讓他們選擇離開或改排候補。佇列以人數計，因此不必容納十萬人：每人限購一張時，容量是剩餘票數加上一段預留給中途放棄的人；每人限購多張時，先把剩餘票數除以每人平均購買的張數換算成人數，再加上同一段預留。付款期限到期放回的票，由候補的人依序遞補，這是被告知買不到、改排候補的人仍可能等到票的原因。等候室的兩種放行方式（依速率放行或限制同時在購票頁的人數）在 9.15 的〈准入控制：虛擬等候室（virtual waiting room）、waitlist 與事前註冊〉，那一節也寫到發出的進入資格總數可以等於可售票數；等候人數與連線的容量在 <a href="/blog/backend/09-performance-capacity/cases/seatgeek-virtual-waiting-room/" data-link-title="9.C16 SeatGeek：DynamoDB &#43; Lambda 打造的虛擬等候室" data-link-desc="SeatGeek 用 DynamoDB 4 張表 &#43; Lambda Bouncer 實作 flash-sale 的准入控制（等候室）、取代第三方 waiting room 服務">9.C16 SeatGeek</a> 的〈判讀〉。</p>
<p>用訊息佇列當緩衝時（API 收到請求就回「排隊中」，請求放進佇列由後端消費），佇列至少送達一次的語意會讓同一筆購票被處理兩次，所以每一次購票嘗試在進入佇列之前要有一個識別碼（<a href="/blog/backend/knowledge-cards/idempotency-key/" data-link-title="Idempotency Key（冪等鍵）" data-link-desc="同一操作重送時該由誰生成識別碼、存多久、衝突怎麼回——冪等性質的對外契約落地機制">Idempotency Key</a>），扣庫存那一段以它辨認重送；售票要另外加的是「一個帳號同時只能有一筆購票在排隊」這條業務規則，因為使用者自己重新整理產生的是新的購票嘗試，佇列分不出來。重送的成因與去重的通則在 9.0 的〈佇列、使用者重按與回呼帶來的重送與去重〉，佇列的送達語意在 <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>。</p>
<p>排隊服務停掉時，系統要停止放行，而不是把所有人直接放進來。直接放行等於拿掉了這一段的保護，全部請求回到扣庫存與資料庫；停止放行時，使用者看到的是暫停售票，票還在，排隊服務恢復之後照順序繼續。要能照順序繼續，排隊的順序要存在排隊服務的程序以外，SeatGeek 把排隊序號存在 DynamoDB 的表裡。排隊保護的是一個一定處理不了全部請求的資源，所以它故障時選停止放行（fail closed）；這個判斷的依據，以及邊緣速率限制為什麼反過來選全部放行（fail open）、預設值與讀到壞設定的情形，在 9.0 的〈保護元件故障時的輸出：停止放行、全部放行、保守的固定上限與預設值〉；等候室用的是購買的服務時，要在開賣之前查清楚業者在故障時的預設，並確認能不能改成停止放行。</p>
<h2 id="扣庫存資料庫逐張認領與加入-redis-閘門的條件">扣庫存：資料庫逐張認領與加入 Redis 閘門的條件</h2>
<p>扣庫存要互斥的範圍是一張票，所以把庫存從一個計數欄改成每張票一列，互斥就分散到各列上。每一張票事先在資料庫裡建一列，請求挑一列還沒被認領的票寫入自己的訂單，這個動作下文稱為<strong>認領</strong>，以 PostgreSQL 寫是：<code>UPDATE tickets SET order_id = $1 WHERE id = (SELECT id FROM tickets WHERE event_id = $2 AND order_id IS NULL LIMIT 1 FOR UPDATE SKIP LOCKED)</code>。<code>FOR UPDATE SKIP LOCKED</code> 讓子查詢跳過別的交易正在認領的列，兩個交易不會等同一把鎖，各自認領不同的票；<code>order_id IS NULL</code> 讓已經被認領的票不會被選中，票認領完了，請求在這裡找不到票，得到售完。認領與訂單寫入放在同一個資料庫交易裡（以購票嘗試的識別碼做訂單的唯一鍵，重送的請求寫不進第二筆），同一張票不被賣兩次，由這一個交易保證。</p>
<p>對號入座的場次在 <code>tickets</code> 表裡每一列是一個座位，認領時不挑任意一列，而是指定使用者選的那個座位：<code>UPDATE tickets SET order_id = $1 WHERE id = $2 AND order_id IS NULL</code>，更新了零列就是那個座位已經被別人認領；付款期限到期時把那一列的 <code>order_id</code> 清回 NULL，座位就回到可售。另一種常見的設計不建座位表，而是在訂單表上記座位編號，防止同一個座位賣兩次就要靠訂單表上的唯一約束；一般的唯一約束對所有狀態的訂單都生效，到期的訂單也還佔著那個座位編號，座位因此永遠回不到可售，所以這種設計要用只涵蓋有效訂單的部分唯一索引（<code>CREATE UNIQUE INDEX ... WHERE status IN ('pending', 'paid')</code>）。</p>
<p>逐張認領比計數欄快，差別在提交時的磁碟寫入能不能合併：計數欄上，下一個交易要等前一個提交、放開列鎖才能更新，每一次提交各自等一次寫盤；逐張認領的各個交易不共用列，同時提交的交易可以合在一次日誌寫入裡（PostgreSQL 文件稱為 group commit）。1.18 在筆電上的 Docker 量過兩種寫法：同一列更新每秒約 650 到 900 筆，不共用列的寫入每秒約一萬一千到一萬五千筆，逐張認領落在不共用列的那個量級（環境與數字見 <a href="/blog/backend/01-database/high-frequency-counting/" data-link-title="1.18 高頻計數的寫入與彙總：熱點列的鎖、只新增的事件、可以重跑的彙總與重送的事件" data-link-desc="熱點列更新的吞吐量上限與它的成因、拆成多列／只新增事件／先在記憶體裡合併的差別、只新增事件搬去哪裡的成本、累加與重算在什麼條件下漏算或重複計算、讓彙總排程互斥的建議鎖與連線池的交互，以及重送事件的冪等寫入、推導事實的存放位置，以及 Redis 承擔即時計數、跨小時不重複數、去重與互斥時各自的邊界">1.18 高頻計數的寫入與彙總</a> 的〈熱點列：同一列更新的排隊機制〉，自己的資料庫要自己量）。認領的子查詢要有只涵蓋 <code>order_id IS NULL</code> 的部分索引，否則越接近售完，它要掃過的已認領列越多。有排隊放行時，抵達資料庫的購票請求就是放行的速率：放行速率設在每秒 50 筆時（示意），五千張票約一百秒放完，逐張認領處理得了，資料庫一個儲存就同時負責扣庫存與不超賣，不必在兩個儲存之間對帳。範例系統有排隊放行，屬於這一種。座位圖與排隊服務要讀的剩餘數量，由一個背景工作每秒從資料庫數一次還沒被認領的票、把結果放在應用程式的記憶體或快取裡供讀取；這份唯讀的計數落後一秒，不參與扣減，所以不必另設扣庫存的儲存。</p>
<p>加入 Redis 扣庫存的理由只有一個：系統前面沒有排隊放行，開賣的十萬個請求直接打到扣庫存這一段，需要一道閘門在資料庫之前擋掉售完之後的那九萬五千個。Redis 提高不了售完的速度：在 Redis 扣減成功的請求，仍要逐一到資料庫認領，售完速度的上限仍是資料庫認領的處理量，而且這約等於票數的請求會在開賣的幾秒內一起湧到資料庫，資料庫的認領要在這幾秒內處理完這一批。座位圖只需要便宜的剩餘數量時，上一段那份唯讀計數就夠，不必讓 Redis 參與扣減。Redis 以單一執行緒依序執行指令，一段 Lua 腳本把「讀剩餘數量、大於零就減一」兩個動作放在同一次執行裡，執行期間不會有其他指令插進來，所以兩個請求不會同時讀到「剩一張」；每一筆不等磁碟寫入（Redis 預設只定時做快照，開了 AOF 也預設每秒才同步一次磁碟），也沒有鎖的取得與釋放，每秒處理的數量比資料庫高出許多。Redis 在這裡是一道擋掉多數請求的閘門：在 Redis 扣減成功的請求，才到資料庫做上面那個認領，資料庫仍然是不超賣的保證。Redis 原子操作的種類與分散式鎖的限制在 <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>。</p>
<p>加入 Redis 之後，負載移到 Redis，而 Redis 有自己先耗盡的資源。<strong>連線數</strong>：同時連到 Redis 的連線數，是應用程式這一側同時在執行的程序數乘以每個程序各開的連線數，算法、持久連線與連線代理的差別在 <a href="/blog/backend/09-performance-capacity/load-path-analysis/" data-link-title="9.0 請求路徑上的負載分析：一次一筆的處理、在它之前能回應的請求、多出來的請求怎麼處置、保護把負載轉到哪裡、重送與去重，以及保護元件與依賴故障時的處置" data-link-desc="沿著一個會改變狀態的請求從用戶端到資料庫的路徑分析負載時共用的幾個概念：必須一次只處理一筆的那件事與它的互斥範圍、需要經過它的請求裡結果事先已確定的比例、多出來的請求要排隊、拒絕還是請使用者重新確認、每一段保護把負載轉給了哪個元件（保護元件自己的狀態、連線數、輪詢、下游接收端、外部配額）、佇列與重按帶來的重送怎麼去重、保護元件故障或輸出錯的值時下游讀到什麼，以及依賴變慢、部分失效與恢復時的第二波湧入">9.0 請求路徑上的負載分析</a> 的〈每一段保護把負載轉到哪裡〉；售票特別要算的是開賣前為了接住流量把主機擴到幾十台、以及幾個場次同時開賣共用同一組 Redis 時，總數可能超過 <code>maxclients</code> 預設的一萬。<strong>單一鍵的處理量</strong>：全場的剩餘數量是一個鍵，而一個鍵只存在 Redis 叢集的一個分片上，加分片不會提高這個鍵的處理量，這是 <a href="/blog/backend/knowledge-cards/hot-key/" data-link-title="Hot Key" data-link-desc="說明單一 key 承受大量讀寫時如何形成容量瓶頸">Hot Key</a>；Redis 裡的計數又回到了一個計數欄的形狀，只是這個計數欄每秒處理得了的量高得多。不夠時可以把庫存拆成多個鍵（五千張票拆成十個各五百張的鍵），代價是有的鍵扣完了、別的鍵還有票，請求要換一個鍵再試；拆鍵影響的只是閘門的處理量，不超賣仍由資料庫的認領保證。對號入座的場次每個座位各是一個鍵，以「座位鍵不存在才寫入、並設保留期限」的寫法（<code>SET seat:A12 &lt;訂單&gt; NX EX 600</code>，<code>EX 600</code> 是 600 秒的保留期限，對應範例系統十分鐘的付款期限）讓先寫入的人保留那個座位。<strong>腳本執行時間</strong>：Lua 腳本執行時 Redis 不處理任何其他指令，腳本跑超過 <code>busy-reply-threshold</code>（預設 5 秒）之後，其他用戶端的指令收到的是 <code>BUSY</code> 錯誤，腳本本身並不會被終止，而已經執行過寫入的腳本也不能以 <code>SCRIPT KILL</code> 停下；所以腳本要短，不在裡面做大範圍的查詢或迴圈。</p>
<p>Redis 出錯時，可能讓已經扣掉的剩餘數量回升。Redis 的複寫是非同步的，主節點回應「扣成功」之後，這個寫入不一定已經送到副本；主節點故障、副本被提升為新的主節點時，最後幾筆扣減會遺失，Redis 當機重啟時最後一段還沒寫到磁碟的扣減也會遺失。新的剩餘數量比實際多，Redis 會多放一些請求去資料庫，而資料庫的認領在票認領完時讓它們得到售完，所以不會超賣。</p>
<h2 id="記帳與付款期限">記帳與付款期限</h2>
<p>只用資料庫認領時，扣庫存與訂單寫入在同一個交易裡，交易失敗時兩者一起不生效，這一段接下來寫的補償與對帳都不需要。加入 Redis 之後，Redis 扣成功、資料庫的交易卻失敗時，這張票既沒有賣出去也沒有回到可售。Redis 與資料庫是兩個儲存，沒有一個交易能同時包住兩者，所以寫入的順序與失敗時的補償要明確：先在 Redis 扣，再在資料庫交易裡認領並寫入訂單，資料庫的交易失敗時把 Redis 的數量加回去。補償本身也可能沒執行，例如程序在 Redis 扣減之後、資料庫交易之前當機（使用者這時收到錯誤或逾時，多半會再試一次），所以還要一個定期的對帳工作：比對 Redis 的剩餘數量與資料庫裡還沒被認領的票數，以資料庫為準修正 Redis。兩邊的數字在處理中的請求（Redis 已扣、資料庫還沒提交）存在時本來就對不上，所以對帳要在一段時間沒有新的扣減之後執行，或者兩者擇一，改成逐筆比對：Redis 每次扣減時記下購票嘗試的識別碼，對帳找出記了識別碼而資料庫沒有對應訂單、且已超過處理時限的那幾筆。補償沒有執行時造成的是少賣，票在對帳之後回到可售，符合範例系統對少賣的要求：開賣後短暫沒有全部賣出可以接受，只要之後回收得回來。對帳的做法在 <a href="/blog/backend/knowledge-cards/data-reconciliation/" data-link-title="Data Reconciliation" data-link-desc="說明多個資料來源不一致時如何比對、修復與留下證據">Data Reconciliation</a>，跨儲存的補償在 <a href="/blog/backend/knowledge-cards/saga/" data-link-title="Saga" data-link-desc="處理跨服務分散事務的補償型 transaction 序列、用最終一致換 ACID atomic">Saga</a>。</p>
<p>付款交給第三方金流。付款的請求數也約等於票數，而集中在開賣後的前幾分鐘，金流業者對每個商家有自己的速率限制，超過時回應錯誤或變慢。付款的呼叫要用自己的連線池與逾時，不能和購票流量共用。拓元的架構圖上，付款層是獨立的一組機器、直連金流的伺服器；這樣切開的作用是讓選位與下單的流量不會塞住付款（作用是推論，案例頁沒寫拓元的理由）。金流業者停掉時，訂單停在待付款，付款期限照常計時，金流恢復時大量訂單已經過期，使用者付不了款。處理的方式有兩種，各有代價：付款期限設得比金流業者常見的中斷時間長，票被保留得更久，放回與重新賣出都跟著延後；在金流故障期間暫停計時，要先判定金流故障，判定本身會誤判（金流只是變慢時暫停了、或已經停了卻沒暫停）。依賴方停掉時的處理在 <a href="/blog/backend/08-incident-response/vendor-dependency-incident/" data-link-title="8.15 Vendor / 第三方依賴事故處理" data-link-desc="依賴方掛掉、自己無 control 時的決策模型">8.15 Vendor / 第三方依賴事故處理</a>。</p>
<p>付款期限到期要放回票，而付款回呼與到期工作可能同時處理同一筆訂單：使用者在範例系統十分鐘的付款期限快到時付款，金流的回呼與到期的排程工作同時送到。兩者都要寫成有條件的更新，讓其中只有一個成立：到期工作執行 <code>UPDATE orders SET status = 'expired' WHERE id = $1 AND status = 'pending'</code>，更新了一列才把票放回；付款回呼執行 <code>UPDATE orders SET status = 'paid', payment_id = $2 WHERE id = $1 AND status = 'pending'</code>。排程服務與金流回呼都可能把同一個動作執行兩次（排程服務在少數情況下會重送任務，金流業者在沒收到回應時會重送回呼，Stripe 的文件寫明同一個事件可能送達不只一次），所以付款回呼更新了零列時，再讀一次訂單的狀態，會是下面三種之一：已經是 <code>paid</code> 而且付款識別碼相同，是同一筆付款的重送，回呼不必再做任何事；已經是 <code>paid</code> 而付款識別碼不同，是使用者對同一筆訂單付了第二次款，後到的那一筆要退款；已經是 <code>expired</code>，是到期之後才到的付款，這筆款項也要退款。到期工作更新零列只有兩種成因，訂單已經付款，或前一次執行已經放回過票，兩種都不必再做任何事，所以票不會被放回兩次。</p>
<p>付款期限也會讓放回集中發生。五千張票在開賣後約一百秒內被認領（示意），它們的十分鐘付款期限就集中在十分鐘之後的那一百秒裡到期，沒付款的票一次放回，候補的人同時被通知、同時回來購票，形成第二次的尖峰；排隊與放行速率在這時要再用一次，不能在第一次售完之後就關掉。尖峰之後再一次湧入的情形在 <a href="/blog/backend/09-performance-capacity/overload-outcomes/" data-link-title="9.16 負載超過容量時服務的表現：變慢、請求失敗、無回應、程序終止，尖峰過去或應對切換之後仍持續的停在失效、擺盪與第二波湧入，以及應對選出的降級回應，各自的判斷標準、成因、應對與看得到它的監控層" data-link-desc="負載超過容量（過載）時，服務呈現的結果分成變慢、請求失敗（抵達後被拒與根本沒有抵達）、無回應、程序終止，以及尖峰過去、依賴恢復或應對切換之後仍持續的停在失效、擺盪與第二波湧入；每一種的判斷標準、通常的成因與應對，哪一種先出現由先耗盡的資源與等待是否設有上限決定，影響範圍怎麼隨時間擴大，服務端監控的各層各自看得到哪一種，降級回應怎麼計數，過載在資料上造成的重複寫入、接受後遺失與讀到舊資料，以及描述一次過載要寫出的項目">9.16 負載超過容量時服務的表現</a> 的〈尖峰過去或應對切換之後仍持續的情形：停在失效、擺盪與第二波湧入〉。</p>
<h2 id="售票需求帶來的簡化以及無法簡化的部分">售票需求帶來的簡化，以及無法簡化的部分</h2>
<p>成功請求的總數有上限，讓排隊服務可以提早告訴後面的人買不到，讓資料庫只需要處理約等於票數的認領；放行速率攤開之後，資料庫逐張認領處理得了，一個儲存就同時負責扣庫存與不超賣；Redis 只在前面沒有排隊放行時才值得當閘門加入，而加入的代價是補償與對帳。延遲不改變請求的價值（不劃位、固定票價時），讓放行速率可以設在後端實測的處理量，而不是開賣當下的請求量。座位圖的剩餘數量允許落後，讓它可以交給 CDN 與一個便宜的讀取來源。</p>
<p>對號入座時，延遲不改變價值只部分成立：排隊與逐張認領照樣可用，而排得越後面能選的座位越少，排隊的順序規則（先到先得或抽籤）因此直接決定誰拿到好座位，是業務上要公布的規則。</p>
<p>無法簡化的是同一張票的互斥：庫存可以從一個計數欄拆成每張票一列，前面可以加一道 Redis 閘門，而同一張票的互斥最後都由資料庫的認領與條件更新保證。抵達資料庫的請求，從開賣第一分鐘全部種類加起來每秒數萬個（範例系統的假設），降到總數約等於票數、以放行速率每秒數十個（示意）進來，而每張票的互斥始終存在。跨段的交接也無法簡化：每一個可能重送的地方（佇列、排程、金流回呼、使用者重按）都要有識別碼、條件更新或業務規則，任何一段漏掉，就是一張票賣兩次或一筆款扣兩次。</p>
<p>這套做法成立，依賴的是成功請求的總數有上限、延遲不改變請求的價值，以及先到先得的順序規則。成功請求的總數沒有上限時（庫存充足的電商 checkout），擋掉的每一個請求都是少一筆訂單，提早回應結果這個做法不存在；延遲改變請求的價值時（連續撮合的交易所），排隊本身就讓請求失去下單時的意義，過載時要明確拒絕；改用抽籤時，開賣瞬間的尖峰不存在，排隊與放行那幾段都不需要。交易撮合的負載路徑在 <a href="/blog/backend/09-performance-capacity/matching-engine-load-path/" data-link-title="9.0.2 交易撮合的負載路徑：閘道的帳戶限流與明確拒絕、依序日誌的複寫延遲、單一寫入者的撮合引擎、行情的合併送出與重放驗證容量" data-link-desc="以交易撮合為例，沿著一筆委託從入口閘道到成交回報的路徑，寫出撮合之前結果無法確定、處理順序決定成交、延遲改變請求的價值這些性質讓哪些負載保護不能用，閘道、依序日誌、單一寫入者的撮合引擎與行情發布各自先耗盡的資源與出錯時的補救，過載時明確拒絕超量委託與以重放驗證容量的做法，並與售票搶購對照">9.0.2 交易撮合的負載路徑</a>，四個範例系統在成功請求的總數與延遲這兩個性質上的位置在 <a href="/blog/backend/case-systems/" data-link-title="Backend 範例系統：短網址、售票搶購、電商 checkout 與交易撮合的功能、需求與規模假設" data-link-desc="Backend 教材幾篇以具體系統走完整條請求路徑的文章共用的四個範例系統：每個系統提供的功能與請求種類、不能讓步與可以讓步的需求、規模假設與起點架構，以及四個系統在成功請求的總數是否有上限、延遲是否改變請求的價值、同一個請求被處理兩次的後果上的差別">Backend 範例系統</a> 的〈四個系統在請求性質上的差別〉。</p>
]]></content:encoded></item></channel></rss>