祝
你中樂透頭獎!
System.out.println("Congratulations! You won the Lottery Jackpot!");本文延續上一篇「防止 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 的架構下,依賴箭頭的方向發生了「反轉」: ...
Robert C. Martin 對這個原則的定義是:Clients should not be forced to depend upon interface methods that they do not use.(不應強迫客戶端依賴它不使用的方法)。 這裡的「客戶端」不是指使用者介面的終端用戶,而是指在程式碼中使用某個介面的類別或模組;當一個介面提供了十個方法,但某個實作類別只需要其中三個,ISP 說這樣的設計是有問題的。 與 OOP 的關係:抽象(Abstraction) 介面(Interface)是 OOP 抽象機制的體現,抽象的目的是「只暴露必要的能力,隱藏不必要的細節」;一個設計良好的介面應該像是一份精確的「能力聲明」:「持有這個介面的物件,保證能做 X、Y、Z。」 然而,若一個介面同時聲明了十幾個能力,而這些能力並非總是同時需要,這個介面就失去了精準性;實作者不得不提供所有能力的實作,即使其中有些對它毫無意義;呼叫端也無法從介面名稱判斷「這個物件究竟擅長做什麼」,因為它什麼都做。 ISP 的本質是對抽象品質的要求:介面應精簡、專一,只描述一個「角色(role)」。 「肥大介面」的危害 假設系統有一個 IWorker 介面: 1public interface IWorker 2{ 3 void Work(); 4 void Eat(); 5 void TakeBreak(); 6 void ReceiveSalary(); 7} 對於人類員工來說,這四個方法都有意義;但如果在系統中引入了「工業機器人」(自動化設備),它同樣需要實作 IWorker(因為它也需要執行工作),但機器人不吃飯、不休息、也不領薪水,這時機器人類別就被迫實作它根本不支援的方法: 1public class IndustrialRobot : IWorker 2{ 3 public void Work() { Console.WriteLine("機器人執行工序中..."); } 4 5 // 機器人不需要這些,但被強迫實作 6 public void Eat() => throw new NotSupportedException("機器人不需要進食"); 7 public void TakeBreak() => throw new NotSupportedException("機器人不需要休息"); 8 public void ReceiveSalary() => throw new NotSupportedException("機器人不領薪水"); 9} 這個設計同時違反了 ISP 和 LSP:IndustrialRobot 宣稱自己是一個 IWorker,但呼叫 Eat() 會在執行期間報錯,呼叫端如果不特別防範就會有潛在風險。 ...
Barbara Liskov 提出的定義是: If S is a subtype of T, then objects of type T in a program may be replaced with objects of type S without altering any of the desirable properties of that program(若 S 是 T 的子型別,則程式中使用 T 物件的地方可以使用 S 物件來替換,而不改變程式的任何預期屬性)。 Barbara Liskov 是麻省理工學院(MIT)的電腦科學教授,這個原則由他在 1987 年的一篇論文中首次提出,後來被 Robert C. Martin 納入 SOLID 體系。 用更白話的方式說:凡是可以使用父類別物件的地方,換成任何子類別的物件,程式應該要能正確執行,結果也應該要符合預期;如果換了子類別之後行為變了,那就是違反了 LSP。 與 OOP 的關係:繼承(Inheritance) 繼承是 OOP 中強大但也容易被濫用的機制,把繼承當作「程式碼複用」的工具:「子類別可以繼承父類別的方法,這樣就不用重寫了。」這個想法本身沒有錯,但它遺漏了繼承更深層的語義。 在物件導向系統中,繼承不只是程式碼的共享機制,它同時建立了一種型別關係(is-a relationship);當寫了 class Dog : Animal 時,不只是讓 Dog 複用了 Animal 的程式碼,同時宣告了「Dog 是一種 Animal」;這個宣告有一個重要的隱含意義:在任何需要 Animal 的場合,都可以放入一隻 Dog,而程式的行為不應改變。 ...
Bertrand Meyer 在 1988 年提出、後經 Robert C. Martin 推廣的定義是:Software entities should be open for extension, but closed for modification(軟體實體應該對擴展開放,對修改封閉)。 「對擴展開放」意味著當需求出現新的變化時,我們可以為系統加入新的行為;「對修改封閉」意味著加入新行為時,我們不需要改動已經存在且正常運作的程式碼;這兩個要求乍聽矛盾,既然不能改動既有程式碼,又怎麼增加新行為呢?OCP 的概念是:透過多型與抽象,讓新行為以「新增類別」的方式注入系統,而非以「修改現有邏輯」的方式侵入系統。 與 OOP 的關係:多型(Polymorphism)與抽象(Abstraction) OCP 的實現幾乎完全仰賴 OOP 的多型機制,想理解這個關係要先從多型的本質說起;多型讓我們可以寫出這樣的程式碼: 1// 呼叫端只知道 shape 是某種 Shape,不知道它具體是哪一種 2void DrawShape(IShape shape) 3{ 4 shape.Draw(); // 執行時才決定呼叫哪個 Draw() 5} DrawShape 這個方法依賴的是抽象(IShape 介面),而不是任何具體的形狀類別;未來若要新增「六邊形」,只需要新增一個實作 IShape 的 Hexagon 類別,不需要改動 DrawShape;這就是 OCP 的運作原理:抽象是穩定的,具體實作是可以擴展的。 換言之,OCP 是多型概念重要的應用場景:用「新增類別」取代「修改現有邏輯」,讓已通過測試的程式碼保持穩定。 「修改」的代價 為什麼要這麼費心避免修改既有程式碼?每次修改都有潛在的風險: ...
Robert C. Martin 對這個原則的定義是:A class should have only one reason to change(一個類別應該只有一個改變的理由)。 乍看之下這句話的意思相當直觀:一個類別只做一件事,但「改變的理由」這個措辭或許比表面上更深刻,也是 SRP 常被誤解的地方。 與 OOP 的關係:封裝(Encapsulation) 封裝是 OOP 的第一個基石,它的目的是將資料與操作資料的行為包裝在一起,並對外隱藏實作細節;這讓類別可以像一個「黑盒子」,使用者只需要知道「它能做什麼」,不需要知道「它是怎麼做的」。 然而,封裝只解決了「如何包裝」的問題,沒有回答「應該把哪些東西包在一起」;一個把所有功能都塞進同一個類別的設計,在語法上是合法的封裝,但在設計上卻是災難性的。 SRP 為封裝提供了邊界判斷的標準:同一個職責(responsibility)的程式碼才應該被封裝在同一個類別裡;換句話說,類別的邊界不應由技術上的便利性決定(這幾個 method 放在一起比較方便),而應由職責的邊界來決定(這幾個 method 服務於同一個目的、同一個改變來源)。 也就是說,SRP 是封裝的昇華:不只把資料藏起來,更要讓「職責」成為劃分類別邊界的判斷依據。 「改變的理由」的含義 Uncle Bob 在後來的詮釋中進一步說明:一個模組應該對一個且只對一個 actor(行為者) 負責。 這裡的 actor 不是指使用者介面上的角色,而是指會要求這個模組發生改變的利害關係人(stakeholder),可能是一個部門、一個業務單位、一個外部系統,或任何一個有能力提出需求變更的人或團隊。 用一個具體例子來說明:假設有一個 Employee 類別,它提供三個方法: CalculatePay():計算薪資,財務部門關心這個邏輯。 ReportHours():回報工時,HR 部門關心這個邏輯。 Save():儲存員工資料,DBA 或資料庫團隊關心這個邏輯。 三個方法,三個不同的 actor;財務部門可能要求調整薪資計算的演算法,HR 部門可能要求修改工時的統計方式,資料庫團隊可能要求更換 ORM 框架或調整儲存格式;這三類需求彼此獨立,但因為它們全都落在同一個 Employee 類別裡,任何一方的修改都有可能意外影響其他兩方,即使改動本身只涉及其中一個方法。 這就是「有多個改變的理由」的意涵:一個類別因為服務多個 actor,所以有多個獨立的、互不相關的原因可能促使它發生改變。 程式碼範例 違反 SRP:三種職責擠在一個類別 以下是一個很常見的反模式,在實際專案中幾乎隨處可見: 1public class ReportService 2{ 3 private readonly string _connectionString; 4 5 public ReportService(string connectionString) 6 { 7 _connectionString = connectionString; 8 } 9 10 // 職責一:取得業務資料(屬於業務邏輯層) 11 // actor:業務分析師、後端工程師 12 public List<Order> GetOrders() 13 { 14 using var conn = new SqlConnection(_connectionString); 15 // 查詢並回傳訂單清單... 16 return new List<Order>(); 17 } 18 19 // 職責二:格式化報表(屬於呈現層) 20 // actor:前端工程師、設計師 21 public string FormatAsHtml(List<Order> orders) 22 { 23 var sb = new StringBuilder(); 24 sb.Append("<table><thead><tr><th>ID</th><th>Total</th></tr></thead><tbody>"); 25 foreach (var o in orders) 26 sb.Append($"<tr><td>{o.Id}</td><td>{o.Total:C}</td></tr>"); 27 sb.Append("</tbody></table>"); 28 return sb.ToString(); 29 } 30 31 // 職責三:傳送通知(屬於基礎設施層) 32 // actor:維運團隊、產品經理 33 public void SendByEmail(string recipient, string content) 34 { 35 var client = new SmtpClient("smtp.example.com"); 36 client.Send("[email protected]", recipient, "Weekly Report", content); 37 } 38} 這個設計的問題不只是「感覺不太對」,而是會帶來非常具體的麻煩: ...