祝
你中樂透頭獎!
System.out.println("Congratulations! You won the Lottery Jackpot!");用街口掃碼結帳,確認付款,畫面顯示成功,整個過程不到兩秒,而且幾乎是即時扣款;這和信用卡的「三秒體驗、兩天資金流」截然不同,電子支付的即時感不是介面設計的錯覺,而是底層架構決定的;電子支付平台同時扮演發帳戶、處理交易、直接對商家結算三個角色,整條鏈路由單一平台控制,不需要在多個金融機構之間等待批次處理。 這種架構稱為 Closed Loop(閉環),對應信用卡的 Open Loop(開放迴路)四方模型,兩者在交易觸發方式、狀態機設計、Webhook 處理、對帳複雜度上都有根本差異;本文聚焦在電子支付的完整生命週期,拆解三種主要的付款模式,說明每種模式的狀態流轉,並補充 Webhook 可靠性設計的工程細節。 核心架構特點 電子支付平台在一個生態系中同時扮演三個角色:自己發行帳戶或錢包給用戶(等同 Issuer)、自建清算和風控系統(等同 Card Network)、直接與商家簽約收取服務費(等同 Acquirer);這三個角色的整合,帶來了幾個和信用卡截然不同的特性。 授權與扣款通常合一:信用卡是先授權(凍結額度)、再 Capture(正式請款)的兩步流程;電子支付的錢包模式通常是確認付款後直接扣款,不存在分開的 Capture 步驟。 資金到帳更快:信用卡走 T+1 到 T+2 的批次結算;電子支付的平台內部帳務操作在秒內完成,商家的平台帳戶餘額立即更新,撥款至銀行帳戶視平台週期而定,通常是 T+0 到 T+1。 全鏈路數據自己掌握:信用卡的消費資料分散在各 Issuer,Card Network 只看到交易流;電子支付平台能看到用戶在哪家店、買了什麼、什麼時間、搭配什麼優惠,這個數據優勢在精準行銷和反詐欺上非常關鍵。 不存在 Interchange:電子支付是閉環系統,發帳戶和收單是同一家平台,不需要 Interchange 這個跨機構的費用補貼機制,費用結構相對透明。 三種主要付款模式 電子支付的「電子支付」四個字包含了三種底層完全不同的付款方式,每種模式的交易流程和狀態機設計都不一樣。 模式一:錢包餘額付款(如街口錢包、全支付) 用戶預先儲值到平台錢包,付款時直接扣減餘額,這是速度快且架構簡單的模式。 交易流程 Step 1:用戶確認付款 Step 2:電支平台驗證用戶身份(PIN / 生物辨識) Step 3:平台內部扣減用戶錢包餘額(帳務分錄) Step 4:平台更新商家的收款帳戶餘額(帳務分錄) Step 5:回傳付款成功通知(Webhook)給商家系統 Step 6:平台依週期結算,將商家餘額撥款至銀行帳戶 Step 2 到 Step 5 是平台內部的帳務操作,不需要外部銀行參與,速度極快(通常在一秒內完成)。 帳務邏輯(複式記帳) 借:用戶錢包帳戶 NT$500 貸:商家收款帳戶 NT$500 平台內部維持帳務平衡,所有的錢都存放在平台向主管機關申報的備付金帳戶裡,帳務分錄只是改變了餘額的歸屬,不涉及實際的銀行資金移動。 狀態機 INITIATED → PAID → SETTLED → REFUNDED(部分或全額) ↘ FAILED 這個模式沒有 AUTHORIZED 和 CAPTURED 兩個中間狀態,因為授權與扣款是同一個動作。 ...
在電商網站結帳,輸入信用卡號,按下確認,畫面顯示「付款成功」,整個過程大概三秒;但是在這三秒裡,請求穿越了至少五個不同機構的系統,觸發了一連串訊息交換、風控判斷、資料寫入,最後才換來那個綠色勾勾。 而那筆錢,則要再等一到兩個工作天,才會真的從客戶的帳戶移動到商家的帳戶,這個「三秒體驗、兩天資金流」的落差,是支付生命週期的核心,理解它有助於設計出正確的訂單狀態機、對帳邏輯、退款流程。 本文將以信用卡交易(以四方模型為例),完整拆解從授權到結算的每個步驟,並說明 Void、Refund、Chargeback 三種特殊情況的處理方式。 三個階段,五個角色 信用卡交易分三個截然不同的階段,每個階段的時間跨度和參與者都不一樣。 授權(Authorization):幾秒內完成,目的是確認卡片有效、額度足夠,此時資金並未移動,只是凍結對應額度。 清算(Clearing):以當日批次處理,商家正式請款、各方確認金額,資金仍未實際流動。 結算(Settlement):在 T+1 或 T+2 工作天完成,這才是資金在銀行間真正移動的時刻。 參與者共有五個:持卡人(Cardholder)、商家(Merchant)、收單行(Acquirer)、卡組織(Card Network)、發卡行(Issuer)。 階段一:授權(Authorization) 目標:確認這張卡可不可以用、有沒有足夠額度。 完整流程 持卡人在結帳頁面輸入卡號、有效期、CVV,按下確認後: Step 1:商家側加密並送出 POS 機或支付閘道(Payment Gateway)將卡片資訊加密,組成授權請求,送給收單行(Acquirer);線上交易的格式通常是 HTTPS + JSON;實體刷卡走 ISO 8583 訊息格式。 Step 2:Acquirer 路由 收單行接收請求,解析卡號前六到八碼(BIN / IIN),判斷這張卡屬於哪個卡組織(Visa / Mastercard),再將授權請求轉送到對應的 Card Network。 Step 3:Card Network 轉發 Visa 或 Mastercard 依 BIN 找到發卡行,將授權請求轉發給 Issuer。 Step 4:Issuer 審核 這是整個流程中判斷最複雜的一步,發卡行要在短時間內完成: 額度檢查:可用額度是否足夠? 風控判斷:地區是否異常?金額是否超過消費習慣?近期是否有可疑交易? 身份驗證:是否需要觸發 3DS2(線上交易的持卡人驗證)? 卡片狀態:是否已掛失、過期、凍結? Step 5:回傳結果 Issuer 回傳核准碼(Approval Code)或拒絕原因碼(Response Code),沿著原路反向傳回給商家,整個來回通常要在極短時間內完成。 授權成功後的狀態 訂單狀態此時應為 AUTHORIZED,系統需儲存: ...
上一篇拆解了四方模型和三方模型的角色結構、資金流向、費用差異;這些概念如果只停在「知道」的層次,對工程師來說價值有限,重要的是理解這兩個模型如何具體影響要寫的程式碼。 本文接續上一篇,說明 API 整合、對帳邏輯、狀態機設計、Chargeback 處理這四個工程面向的差異,最後整理面試中最常見的問法和建議的回答方向。 API 整合:不要假設一致性 四方的 Visa 和三方的 Amex,授權 API 長得不相同: 1// 錯誤做法:針對每家寫死邏輯 2if (cardType == "VISA") { 3 var request = new VisaAuthRequest { ... }; 4 // Visa 特定欄位... 5} else if (cardType == "AMEX") { 6 var request = new AmexAuthRequest { ... }; 7 // Amex 完全不同的欄位... 8} 9 10// 正確做法:抽象 Gateway 介面 11public interface IPaymentGateway { 12 Task<AuthResult> AuthorizeAsync(AuthRequest request); 13 Task<CaptureResult> CaptureAsync(string authCode, decimal amount); 14 Task<RefundResult> RefundAsync(string transactionId, decimal amount); 15 Task<VoidResult> VoidAsync(string authCode); 16} 17 18public class VisaGateway : IPaymentGateway { ... } 19public class AmexGateway : IPaymentGateway { ... } 20public class TapPayGateway : IPaymentGateway { ... } // 本地閉環 這個介面抽象讓你日後新增支付方式時,只需要新增一個實作類別,不動核心邏輯。實務上通常會再搭配一個簡單的工廠(Factory)依卡種或商家設定選擇對應的實作: ...
後端工程師在接觸支付系統時,第一關常常卡在「業務語言」上,技術或許沒問題,但和 PM、業務、甚至面試官討論時對不上頻率;四方模型(Four-Party Model)與三方模型(Three-Party Model)是支付產業的基礎架構分類,理解這兩者不只是業務知識,甚至直接影響: 系統設計決策(要不要抽象 Gateway 介面?對帳邏輯怎麼設計?) API 整合策略(Visa 和 Amex 的授權流程根本不一樣) 面試表現(支付公司面試幾乎必問) 本文從角色結構、資金流向、費用分配到工程師視角的實作影響,拆解這兩個模型。 四方模型(Four-Party Model / Open Loop) 五個角色,四段關係 「四方」這個名字略有誤導,實際上有五個實體,只是因為有「四段核心商業關係」才這樣命名: 持卡人(Cardholder):就是你我這樣的消費者,核心職責是發起交易、承擔帳單。 發卡行(Issuer):常見的例子是玉山、台新、花旗,核心職責是核發信用卡、進行授權審核、承擔壞帳風險。 卡組織(Card Network):也就是 Visa、Mastercard,核心職責是負責交易路由、制定清算規則、訂定 Interchange 費率。 收單行(Acquirer):常見的例子是聯合信用卡中心,核心職責是代商家接收款項、撥款給商家。 商家(Merchant):全聯、momo、各電商都是這個角色,核心職責是提供商品或服務、繳交手續費。 Issuer 和 Acquirer 是完全獨立的兩家機構,彼此之間透過 Card Network 溝通;這就是「Open Loop(開放迴路)」名稱的由來,任何 Issuer 發的卡,理論上可以在任何 Acquirer 服務的商家刷卡。 一筆交易的完整資金流 以「用玉山 Visa 在全聯刷 NT$1,000」為例: 階段一:授權(即時) 持卡人 → POS 機(全聯) → 聯合信用卡中心(Acquirer) → Visa 網路(Card Network)→ 玉山銀行(Issuer) 玉山確認額度夠、風控通過,回傳 Approval Code;此時只是凍結額度,資金未動。 階段二:清算(當日批次) 全聯日終把所有刷卡記錄批次送給收單行,收單行整理後送給 Visa,Visa 計算各方應付金額(含 Interchange)。 階段三:結算(T+1 或 T+2) 玉山(Issuer)→ 扣除 Interchange,撥款給 Visa Visa → 扣除 Network fee,撥款給聯合信用卡中心(Acquirer) 聯合信用卡中心 → 扣除 Acquirer Markup,淨額撥給全聯帳戶 最後全聯帳戶實際入帳 NT$985(假設 MDR 1.5%)。 ...
本文延續上一篇「防止 Shopify 線上商店與實體門市超賣」的討論,探討反向情境:當 Shopify 的 Webhook Event 漏接,導致線上訂單無法即時同步進內部資料庫時,應該如何設計系統來保障資料一致性,以及避免先下單的顧客受到損失。 問題情境 承接上篇的架構背景:線上訂單透過 Shopify Webhook 即時回傳至內部 DB,並有一支 Timer Program 每小時定期補漏。 當 Webhook 漏接時,空窗期內可能發生以下情境: 線上顧客成功下單(Shopify 已收款) → Webhook 發送失敗或漏接 → 內部 DB 不知道這筆訂單的存在 → 內部 DB 庫存仍顯示舊的(較高)數字 同一時間,實體門市售出同一商品 → 扣減內部 DB 庫存 Timer 補跑,終於處理到那筆線上訂單 → 此時庫存已不足 → 先下單的線上顧客反而無法出貨 ❌ 這個問題的核心是:Shopify 與內部 DB 是兩個獨立的系統,Webhook 是主要的同步橋樑,一旦橋樑出現延遲,兩端就會產生資料落差。 解法方向 方案一:縮短 Timer 間隔 最直覺的做法是將 Timer 的同步週期從一小時縮短,例如改為每五分鐘一次,讓系統更快發現未處理的線上訂單並補上;這個方案不需要改動架構,成本最低且能夠立即縮短風險窗口。 然而它存在一個根本性的隱憂:當系統因負荷過高而開始變慢,頻繁的 Timer 仍會不斷觸發新的同步任務,不斷消耗系統資源,反而讓系統雪上加霜,最終可能完全卡死;這在系統設計上是一種 Thundering Herd(驚群效應)的變體,意指用更頻繁的 Polling 來彌補事件驅動的不足,但 Polling 本身在系統壓力大時會成為壓垮駱駝的稻草。 縮短間隔治標不治本,空窗期的長短仍然取決於 Timer 的頻率,只是縮小了問題的規模。 方案二:Timer 補跑時,訂單同步優先於庫存校正 這是一個執行順序的調整;Timer 補跑時應先將所有未同步的線上訂單補進內部 DB,再進行庫存比對與扣減,避免實體銷售的庫存扣減蓋過尚未被認知的線上訂單。 ...
本文源自一次技術面試的討論題目,紀錄了問題的成因分析與逐步推導出的解決方案,可以作為電商系統設計的學習參考:假設公司同時擁有 Shopify 線上商店與實體銷售門市,兩個渠道共用同一套內部資料庫(庫存 DB 與訂單 DB)。 現有架構如下: 線上訂單:Shopify 透過 Webhook Event 即時回傳至訂單資料庫。 補漏機制:由於擔心 Webhook 漏接,系統另有一支 Timer Program,定期(約每小時一次)與 Shopify 比對並同步訂單與庫存。 實體訂單:門市銷售的訂單會寫入內部訂單 DB,但不會同步至 Shopify。 資料庫的語意定義如下: 內部 DB 庫存 = 全渠道實際庫存總量 內部 DB 訂單 = 線上訂單 + 實體訂單(所有訂單) Shopify 庫存 = 僅反映線上銷售,對實體銷售一無所知 問題根源 由於 Timer Program 每小時才同步一次,在同步之前存在一個空窗期: 實體門市售出商品 → 扣減內部 DB 庫存 正確 → Shopify 庫存尚未更新 數字虛高 線上顧客在空窗期內下單 → Shopify 顯示庫存充足 錯誤資訊 → 顧客成功下單 實際已超賣 矛盾在於:內部 DB 才是庫存的 Single Source of Truth,但 Shopify 並不知道實體門市的銷售情況,導致其顯示的庫存數字在空窗期內是虛高的。 ...
前兩篇分別談了樂觀鎖與悲觀鎖的理論,以及購票系統各環節的設計決策,這篇將直接進入程式碼實作,把設計思路轉化成可以實際運行的實作。 資料模型 先定義三個核心 Entity,各自對應不同的鎖策略。 Ticket 代表票種與庫存,Entity 上掛的 RowVersion(對應 PostgreSQL 的 xmin 系統欄位)是樂觀鎖機制,用於後台修改票種名稱、調整票價等一般更新場景。 Order 代表訂單,其中狀態流轉使用樂觀鎖(Optimistic Lock),手動維護 Version 整數欄位,方便在 SQL 層直接用條件更新防止重複付款。 Seat 代表對號座的每一個座位,記錄目前是否被鎖定、被誰鎖定、以及鎖定到什麼時間;它的鎖定狀態由 Redis 的分散式鎖(Distributed Lock)主導,LockedByUserId 和 LockedUntil 則作為資料庫層的備份紀錄,用於查詢座位狀態或在 Redis 異常時做補救。 1// Entities/Ticket.cs 2public class Ticket 3{ 4 public int Id { get; set; } 5 public int EventId { get; set; } 6 public string TicketType { get; set; } = default!; 7 public int AvailableQty { get; set; } 8 9 // EF Core 樂觀鎖:使用 PostgreSQL xmin 系統欄位,免額外欄位 10 // xmin 是每次 row 被更新時自動遞增的系統欄位,完全不需要手動維護 11 [Timestamp] 12 public byte[] RowVersion { get; set; } = default!; 13} 14 15// Entities/Order.cs 16public class Order 17{ 18 public int Id { get; set; } 19 public string UserId { get; set; } = default!; 20 public OrderStatus Status { get; set; } 21 22 // 手動版本號,用於訂單狀態流轉的樂觀鎖 23 public int Version { get; set; } 24} 25 26// Entities/Seat.cs 27public class Seat 28{ 29 public string SeatId { get; set; } = default!; // e.g. "A-12" 30 public int EventId { get; set; } 31 public SeatStatus Status { get; set; } 32 public string? LockedByUserId { get; set; } 33 public DateTime? LockedUntil { get; set; } 34} 35 36public enum OrderStatus { Pending, Paid, Cancelled, Expired } 37public enum SeatStatus { Available, Locked, Sold } 1// Data/AppDbContext.cs 2public class AppDbContext : DbContext 3{ 4 public DbSet<Ticket> Tickets => Set<Ticket>(); 5 public DbSet<Order> Orders => Set<Order>(); 6 public DbSet<Seat> Seats => Set<Seat>(); 7 8 protected override void OnModelCreating(ModelBuilder builder) 9 { 10 // 將 RowVersion 對應到 PostgreSQL 的 xmin 欄位 11 builder.Entity<Ticket>() 12 .Property(t => t.RowVersion) 13 .IsRowVersion() 14 .HasColumnName("xmin") 15 .HasColumnType("xid"); 16 } 17} Redis 層:庫存預扣 單純使用 DECR 可能會有 Race Condition 的風險,在「讀取 → 判斷 → 扣減」三步驟之間可能插入其他操作;使用 Lua Script 則能夠確保 Redis 的原子執行,使得整個 check-and-decrement 變得不可分割;更多詳情可以參考 StackExchange.Redis。 ...
上一篇談了悲觀鎖(Pessimistic Lock)和樂觀鎖(Optimistic Lock)的基本概念,而這篇要把焦點放在一個具體的場景:演唱會購票系統。 為什麼會選這個場景呢?因為它的併發特性非常極端:開賣瞬間可能湧入數十萬個請求,庫存有限、不能超賣、用戶又非常敏感,幾乎把所有併發控制(Concurrency Control)的挑戰都壓縮在一起了,適合用來思考鎖策略的設計。 系統的核心矛盾 購票系統面臨一個根本的矛盾:速度和正確性永遠在對抗。 為了正確性,你想讓每個請求排隊、一個一個處理,但這樣系統會慢到讓人放棄;為了速度,你想讓所有請求並行處理,但這樣很可能超賣或出現資料不一致;而解決這個矛盾的方式,不是選擇其中一邊,而是在不同的環節用不同的策略。 把系統拆成幾個關鍵環節 庫存扣減:這是整個系統最不能出錯的地方 票券超賣是商業損失,無法事後補救;這裡需要的是一致性而不是高效能,寧願讓請求排隊等待,也必須保證每次扣減前看到的都是真實的剩餘數量。 這是悲觀鎖(Pessimistic Lock)典型的使用場景;在 PostgreSQL 裡,用 SELECT ... FOR UPDATE 鎖住那行資料,讓後來的交易(Transaction)必須等待;這個鎖從查詢的當下持有,直到 Transaction 提交(Commit)或回滾(Rollback)才釋放。 代價是明確的:高併發下這裡會成為瓶頸,請求會大量排隊,但在「超賣後果不可逆」的前提下,這個代價值得付。 座位鎖定:互斥資源,必須強佔 對號座的選位,是另一個互斥資源(Exclusive Resource)的場景,用戶 A 選了 A-12,在她付款完成之前,A-12 不能被用戶 B 選走。 這裡的問題不只是資料庫併發,更是跨請求(Request)的狀態持有;用戶進入付款頁面後,這把鎖需要持續存在 10 分鐘,若超時便自動釋放;資料庫鎖的生命週期只存在於一個 Transaction 裡,無法做到跨 Request 持有。 這時可以改用 Redis 的分散式鎖(Distributed Lock):SET NX EX(不存在才設置,並附上過期時間),第一個選到這個座位的請求成功鎖定,後來的請求看到 key 已存在,直接被拒絕;過了 10 分鐘之後,Redis 的 TTL 自動刪除這個 key,座位自動釋放回可選狀態。 庫存預扣:在資料庫前擋掉大多數請求 純粹依賴 PostgreSQL 的 FOR UPDATE,在演唱會開賣瞬間會承受巨大的資料庫壓力。大量請求湧進來,每個都要等待前一個 Transaction 釋放鎖,資料庫連線很快就會耗盡。 這時可以在資料庫前面加一層 Redis 的原子操作(Atomic Operation):先在 Redis 裡做庫存預扣,用 Lua Script 確保「檢查庫存是否足夠」和「扣減庫存」這兩步是符合原子操作且不可分割;因此大多數「票已售罄」的請求,在這一層就被擋掉了,不需要進入資料庫,只有成功預扣的請求才會繼續往下,讓 PostgreSQL 做最終確認。 ...
併發問題的根源,大多來自「多個操作同時想動同一份資料」;當系統只有一個使用者時,這不是問題,然而流量一旦上來,很多我們以為不可能同時發生的事,就會同時發生;面對這個問題,思路分成了兩個截然不同的方向。 悲觀鎖:先鎖再說 悲觀鎖(Pessimistic Lock)的世界觀很直接:衝突一定會發生,所以在操作資料之前就先把它鎖住,其他人等我用完再說;它像一個很謹慎的人,進房間之前先把門鎖上,做完事情才開鎖讓別人進來;在這段期間,任何人想進來都必須等待。 資料庫層面最常見的實作是 SELECT ... FOR UPDATE,在查詢的當下就對那幾行資料加上排他鎖,其他 transaction 如果也想修改同一行,就必須等待前一個 transaction 結束: 1BEGIN; 2 3SELECT available_qty 4FROM tickets 5WHERE event_id = 1 AND ticket_type = 'VIP' 6FOR UPDATE; -- 從這一刻起,這行資料被鎖住 7 8-- 確認有票後才扣減 9UPDATE tickets 10SET available_qty = available_qty - 1 11WHERE event_id = 1 AND ticket_type = 'VIP'; 12 13COMMIT; -- 鎖在這裡釋放 這個做法的代價很明顯:併發效能較低,因為後來的請求都必須排隊等待;而且如果兩個 transaction 互相等待對方釋放鎖,就會發生死鎖(Deadlock);不過在某些場景下,這是正確的選擇,因為有些錯誤是無法事後補救的。 ...
Robert C. Martin 為這個原則提出兩條互相呼應的規則: High-level modules should not import anything from low-level modules. Both should depend on abstractions(高階模組不應依賴低階模組,兩者都應依賴抽象)。 Abstractions should not depend on details. Details should depend on abstractions(抽象不應依賴細節,細節應依賴抽象)。 這兩條規則合在一起,描述的是系統中依賴關係的方向性應該如何安排,要理解它們需要先理解「高階模組」和「低階模組」的差別。 高階模組與低階模組 高階模組(High-level modules):包含應用程式核心業務邏輯的模組,它們描述的是「這個系統要做什麼」:訂單需要被驗證、付款需要被處理、報表需要被產生;這些邏輯代表了系統存在的根本原因,是具有業務價值的部分。 低階模組(Low-level modules):提供具體技術實作的模組,它們描述的是「這件事具體怎麼做」:資料存到 SQL Server、Email 用 SMTP 發送、圖片存到 S3;這些模組是高階模組的工具,本身通常不含業務價值,可以被替換。 在傳統的依賴方向中,高階模組直接使用(依賴)低階模組: OrderService(高階)→ SqlServerRepository(低階) 這意味著業務邏輯層與具體的資料庫技術「綁死」在一起;若今天想把 SQL Server 換成 PostgreSQL,就必須修改 OrderService 這個本來應該只關心業務規則的類別,DIP 要「反轉」的正是這個依賴方向。 與 OOP 的關係:多型(Polymorphism)與抽象(Abstraction) DIP 的實現依賴抽象與多型,具體的做法是在高階模組與低階模組之間插入一層抽象(介面),讓兩者都依賴這個介面,而非彼此直接依賴。 傳統方向(高耦合): OrderService ─────────────────→ SqlServerRepository (高階) (低階,具體實作) DIP 方向(低耦合): OrderService ──→ IOrderRepository ←── SqlServerRepository (高階) (抽象介面) (低階,具體實作) 在 DIP 的架構下,依賴箭頭的方向發生了「反轉」: ...