<?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>Back-End on 愷的大冒險 Kai's Adventure</title><link>https://kaiadv.pages.dev/categories/back-end/</link><description>Recent content in Back-End on 愷的大冒險 Kai's Adventure</description><generator>Hugo</generator><language>zh-tw</language><lastBuildDate>Mon, 01 Jun 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://kaiadv.pages.dev/categories/back-end/index.xml" rel="self" type="application/rss+xml"/><item><title>用 ASP.NET + Redis + PostgreSQL 實作購票系統的併發控制</title><link>https://kaiadv.pages.dev/posts/20260601/</link><pubDate>Mon, 01 Jun 2026 00:00:00 +0800</pubDate><guid>https://kaiadv.pages.dev/posts/20260601/</guid><description>&lt;p&gt;前兩篇分別談了樂觀鎖與悲觀鎖的理論，以及購票系統各環節的設計決策，這篇將直接進入程式碼實作，把設計思路轉化成可以實際運行的實作。&lt;/p&gt;
&lt;hr&gt;
&lt;h1 id="資料模型"&gt;資料模型&lt;/h1&gt;
&lt;p&gt;先定義三個核心 Entity，各自對應不同的鎖策略。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Ticket&lt;/code&gt; 代表票種與庫存，Entity 上掛的 &lt;code&gt;RowVersion&lt;/code&gt;（對應 PostgreSQL 的 &lt;code&gt;xmin&lt;/code&gt; 系統欄位）是&lt;a href="https://learn.microsoft.com/en-us/ef/core/saving/concurrency?tabs=data-annotations#optimistic-concurrency"&gt;樂觀鎖機制&lt;/a&gt;，用於後台修改票種名稱、調整票價等一般更新場景。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Order&lt;/code&gt; 代表訂單，其中狀態流轉使用樂觀鎖（Optimistic Lock），手動維護 &lt;code&gt;Version&lt;/code&gt; 整數欄位，方便在 SQL 層直接用條件更新防止重複付款。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Seat&lt;/code&gt; 代表對號座的每一個座位，記錄目前是否被鎖定、被誰鎖定、以及鎖定到什麼時間；它的鎖定狀態由 Redis 的分散式鎖（Distributed Lock）主導，&lt;code&gt;LockedByUserId&lt;/code&gt; 和 &lt;code&gt;LockedUntil&lt;/code&gt; 則作為資料庫層的備份紀錄，用於查詢座位狀態或在 Redis 異常時做補救。&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-csharp" data-lang="csharp"&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt; 1&lt;/span&gt;&lt;span&gt;&lt;span style="color:#8b949e;font-style:italic"&gt;// Entities/Ticket.cs&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt; 2&lt;/span&gt;&lt;span&gt;&lt;span style="color:#ff7b72"&gt;public&lt;/span&gt; &lt;span style="color:#ff7b72"&gt;class&lt;/span&gt; &lt;span style="color:#f0883e;font-weight:bold"&gt;Ticket&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt; 3&lt;/span&gt;&lt;span&gt;{
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt; 4&lt;/span&gt;&lt;span&gt; &lt;span style="color:#ff7b72"&gt;public&lt;/span&gt; &lt;span style="color:#ff7b72"&gt;int&lt;/span&gt; Id { &lt;span style="color:#ff7b72"&gt;get&lt;/span&gt;; &lt;span style="color:#ff7b72"&gt;set&lt;/span&gt;; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt; 5&lt;/span&gt;&lt;span&gt; &lt;span style="color:#ff7b72"&gt;public&lt;/span&gt; &lt;span style="color:#ff7b72"&gt;int&lt;/span&gt; EventId { &lt;span style="color:#ff7b72"&gt;get&lt;/span&gt;; &lt;span style="color:#ff7b72"&gt;set&lt;/span&gt;; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt; 6&lt;/span&gt;&lt;span&gt; &lt;span style="color:#ff7b72"&gt;public&lt;/span&gt; &lt;span style="color:#ff7b72"&gt;string&lt;/span&gt; TicketType { &lt;span style="color:#ff7b72"&gt;get&lt;/span&gt;; &lt;span style="color:#ff7b72"&gt;set&lt;/span&gt;; } = &lt;span style="color:#ff7b72"&gt;default&lt;/span&gt;!;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt; 7&lt;/span&gt;&lt;span&gt; &lt;span style="color:#ff7b72"&gt;public&lt;/span&gt; &lt;span style="color:#ff7b72"&gt;int&lt;/span&gt; AvailableQty { &lt;span style="color:#ff7b72"&gt;get&lt;/span&gt;; &lt;span style="color:#ff7b72"&gt;set&lt;/span&gt;; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt; 8&lt;/span&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt; 9&lt;/span&gt;&lt;span&gt; &lt;span style="color:#8b949e;font-style:italic"&gt;// EF Core 樂觀鎖：使用 PostgreSQL xmin 系統欄位，免額外欄位&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt;10&lt;/span&gt;&lt;span&gt; &lt;span style="color:#8b949e;font-style:italic"&gt;// xmin 是每次 row 被更新時自動遞增的系統欄位，完全不需要手動維護&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt;11&lt;/span&gt;&lt;span&gt; [Timestamp]
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt;12&lt;/span&gt;&lt;span&gt; &lt;span style="color:#ff7b72"&gt;public&lt;/span&gt; &lt;span style="color:#ff7b72"&gt;byte&lt;/span&gt;[] RowVersion { &lt;span style="color:#ff7b72"&gt;get&lt;/span&gt;; &lt;span style="color:#ff7b72"&gt;set&lt;/span&gt;; } = &lt;span style="color:#ff7b72"&gt;default&lt;/span&gt;!;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt;13&lt;/span&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt;14&lt;/span&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt;15&lt;/span&gt;&lt;span&gt;&lt;span style="color:#8b949e;font-style:italic"&gt;// Entities/Order.cs&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt;16&lt;/span&gt;&lt;span&gt;&lt;span style="color:#ff7b72"&gt;public&lt;/span&gt; &lt;span style="color:#ff7b72"&gt;class&lt;/span&gt; &lt;span style="color:#f0883e;font-weight:bold"&gt;Order&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt;17&lt;/span&gt;&lt;span&gt;{
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt;18&lt;/span&gt;&lt;span&gt; &lt;span style="color:#ff7b72"&gt;public&lt;/span&gt; &lt;span style="color:#ff7b72"&gt;int&lt;/span&gt; Id { &lt;span style="color:#ff7b72"&gt;get&lt;/span&gt;; &lt;span style="color:#ff7b72"&gt;set&lt;/span&gt;; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt;19&lt;/span&gt;&lt;span&gt; &lt;span style="color:#ff7b72"&gt;public&lt;/span&gt; &lt;span style="color:#ff7b72"&gt;string&lt;/span&gt; UserId { &lt;span style="color:#ff7b72"&gt;get&lt;/span&gt;; &lt;span style="color:#ff7b72"&gt;set&lt;/span&gt;; } = &lt;span style="color:#ff7b72"&gt;default&lt;/span&gt;!;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt;20&lt;/span&gt;&lt;span&gt; &lt;span style="color:#ff7b72"&gt;public&lt;/span&gt; OrderStatus Status { &lt;span style="color:#ff7b72"&gt;get&lt;/span&gt;; &lt;span style="color:#ff7b72"&gt;set&lt;/span&gt;; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt;21&lt;/span&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt;22&lt;/span&gt;&lt;span&gt; &lt;span style="color:#8b949e;font-style:italic"&gt;// 手動版本號，用於訂單狀態流轉的樂觀鎖&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt;23&lt;/span&gt;&lt;span&gt; &lt;span style="color:#ff7b72"&gt;public&lt;/span&gt; &lt;span style="color:#ff7b72"&gt;int&lt;/span&gt; Version { &lt;span style="color:#ff7b72"&gt;get&lt;/span&gt;; &lt;span style="color:#ff7b72"&gt;set&lt;/span&gt;; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt;24&lt;/span&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt;25&lt;/span&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt;26&lt;/span&gt;&lt;span&gt;&lt;span style="color:#8b949e;font-style:italic"&gt;// Entities/Seat.cs&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt;27&lt;/span&gt;&lt;span&gt;&lt;span style="color:#ff7b72"&gt;public&lt;/span&gt; &lt;span style="color:#ff7b72"&gt;class&lt;/span&gt; &lt;span style="color:#f0883e;font-weight:bold"&gt;Seat&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt;28&lt;/span&gt;&lt;span&gt;{
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt;29&lt;/span&gt;&lt;span&gt; &lt;span style="color:#ff7b72"&gt;public&lt;/span&gt; &lt;span style="color:#ff7b72"&gt;string&lt;/span&gt; SeatId { &lt;span style="color:#ff7b72"&gt;get&lt;/span&gt;; &lt;span style="color:#ff7b72"&gt;set&lt;/span&gt;; } = &lt;span style="color:#ff7b72"&gt;default&lt;/span&gt;!; &lt;span style="color:#8b949e;font-style:italic"&gt;// e.g. &amp;#34;A-12&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt;30&lt;/span&gt;&lt;span&gt; &lt;span style="color:#ff7b72"&gt;public&lt;/span&gt; &lt;span style="color:#ff7b72"&gt;int&lt;/span&gt; EventId { &lt;span style="color:#ff7b72"&gt;get&lt;/span&gt;; &lt;span style="color:#ff7b72"&gt;set&lt;/span&gt;; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt;31&lt;/span&gt;&lt;span&gt; &lt;span style="color:#ff7b72"&gt;public&lt;/span&gt; SeatStatus Status { &lt;span style="color:#ff7b72"&gt;get&lt;/span&gt;; &lt;span style="color:#ff7b72"&gt;set&lt;/span&gt;; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt;32&lt;/span&gt;&lt;span&gt; &lt;span style="color:#ff7b72"&gt;public&lt;/span&gt; &lt;span style="color:#ff7b72"&gt;string?&lt;/span&gt; LockedByUserId { &lt;span style="color:#ff7b72"&gt;get&lt;/span&gt;; &lt;span style="color:#ff7b72"&gt;set&lt;/span&gt;; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt;33&lt;/span&gt;&lt;span&gt; &lt;span style="color:#ff7b72"&gt;public&lt;/span&gt; DateTime? LockedUntil { &lt;span style="color:#ff7b72"&gt;get&lt;/span&gt;; &lt;span style="color:#ff7b72"&gt;set&lt;/span&gt;; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt;34&lt;/span&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt;35&lt;/span&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt;36&lt;/span&gt;&lt;span&gt;&lt;span style="color:#ff7b72"&gt;public&lt;/span&gt; &lt;span style="color:#ff7b72"&gt;enum&lt;/span&gt; OrderStatus { Pending, Paid, Cancelled, Expired }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt;37&lt;/span&gt;&lt;span&gt;&lt;span style="color:#ff7b72"&gt;public&lt;/span&gt; &lt;span style="color:#ff7b72"&gt;enum&lt;/span&gt; SeatStatus { Available, Locked, Sold }
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-csharp" data-lang="csharp"&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt; 1&lt;/span&gt;&lt;span&gt;&lt;span style="color:#8b949e;font-style:italic"&gt;// Data/AppDbContext.cs&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt; 2&lt;/span&gt;&lt;span&gt;&lt;span style="color:#ff7b72"&gt;public&lt;/span&gt; &lt;span style="color:#ff7b72"&gt;class&lt;/span&gt; &lt;span style="color:#f0883e;font-weight:bold"&gt;AppDbContext&lt;/span&gt; : DbContext
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt; 3&lt;/span&gt;&lt;span&gt;{
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt; 4&lt;/span&gt;&lt;span&gt; &lt;span style="color:#ff7b72"&gt;public&lt;/span&gt; DbSet&amp;lt;Ticket&amp;gt; Tickets =&amp;gt; Set&amp;lt;Ticket&amp;gt;();
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt; 5&lt;/span&gt;&lt;span&gt; &lt;span style="color:#ff7b72"&gt;public&lt;/span&gt; DbSet&amp;lt;Order&amp;gt; Orders =&amp;gt; Set&amp;lt;Order&amp;gt;();
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt; 6&lt;/span&gt;&lt;span&gt; &lt;span style="color:#ff7b72"&gt;public&lt;/span&gt; DbSet&amp;lt;Seat&amp;gt; Seats =&amp;gt; Set&amp;lt;Seat&amp;gt;();
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt; 7&lt;/span&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt; 8&lt;/span&gt;&lt;span&gt; &lt;span style="color:#ff7b72"&gt;protected&lt;/span&gt; &lt;span style="color:#ff7b72"&gt;override&lt;/span&gt; &lt;span style="color:#ff7b72"&gt;void&lt;/span&gt; OnModelCreating(ModelBuilder builder)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt; 9&lt;/span&gt;&lt;span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt;10&lt;/span&gt;&lt;span&gt; &lt;span style="color:#8b949e;font-style:italic"&gt;// 將 RowVersion 對應到 PostgreSQL 的 xmin 欄位&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt;11&lt;/span&gt;&lt;span&gt; builder.Entity&amp;lt;Ticket&amp;gt;()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt;12&lt;/span&gt;&lt;span&gt; .Property(t =&amp;gt; t.RowVersion)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt;13&lt;/span&gt;&lt;span&gt; .IsRowVersion()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt;14&lt;/span&gt;&lt;span&gt; .HasColumnName(&lt;span style="color:#a5d6ff"&gt;&amp;#34;xmin&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt;15&lt;/span&gt;&lt;span&gt; .HasColumnType(&lt;span style="color:#a5d6ff"&gt;&amp;#34;xid&amp;#34;&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt;16&lt;/span&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt;17&lt;/span&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;hr&gt;
&lt;h1 id="redis-層庫存預扣"&gt;Redis 層：庫存預扣&lt;/h1&gt;
&lt;p&gt;單純使用 &lt;code&gt;DECR&lt;/code&gt; 可能會有 Race Condition 的風險，在「讀取 → 判斷 → 扣減」三步驟之間可能插入其他操作；使用 &lt;a href="https://redis.io/docs/latest/develop/programmability/eval-intro/"&gt;Lua Script&lt;/a&gt; 則能夠確保 Redis 的原子執行，使得整個 check-and-decrement 變得不可分割。&lt;/p&gt;</description></item><item><title>為什麼購票系統需要兩種不同的鎖</title><link>https://kaiadv.pages.dev/posts/20260531/</link><pubDate>Sun, 31 May 2026 00:00:00 +0800</pubDate><guid>https://kaiadv.pages.dev/posts/20260531/</guid><description>&lt;p&gt;上一篇談了悲觀鎖（Pessimistic Lock）和樂觀鎖（Optimistic Lock）的基本概念，而這篇要把焦點放在一個具體的場景：演唱會購票系統。&lt;/p&gt;
&lt;p&gt;為什麼會選這個場景呢？因為它的併發特性非常極端：開賣瞬間可能湧入數十萬個請求，庫存有限、不能超賣、用戶又非常敏感，幾乎把所有併發控制（Concurrency Control）的挑戰都壓縮在一起了，適合用來思考鎖策略的設計。&lt;/p&gt;
&lt;hr&gt;
&lt;h1 id="系統的核心矛盾"&gt;系統的核心矛盾&lt;/h1&gt;
&lt;p&gt;購票系統面臨一個根本的矛盾：速度和正確性永遠在對抗。&lt;/p&gt;
&lt;p&gt;為了正確性，你想讓每個請求排隊、一個一個處理，但這樣系統會慢到讓人放棄；為了速度，你想讓所有請求並行處理，但這樣很可能超賣或出現資料不一致；而解決這個矛盾的方式，不是選擇其中一邊，而是在不同的環節用不同的策略。&lt;/p&gt;
&lt;hr&gt;
&lt;h1 id="把系統拆成幾個關鍵環節"&gt;把系統拆成幾個關鍵環節&lt;/h1&gt;
&lt;h2 id="庫存扣減這是整個系統最不能出錯的地方"&gt;庫存扣減：這是整個系統最不能出錯的地方&lt;/h2&gt;
&lt;p&gt;票券超賣是商業損失，無法事後補救；這裡需要的是一致性而不是高效能，寧願讓請求排隊等待，也必須保證每次扣減前看到的都是真實的剩餘數量。&lt;/p&gt;
&lt;p&gt;這是悲觀鎖（Pessimistic Lock）典型的使用場景；在 PostgreSQL 裡，用 &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt; 鎖住那行資料，讓後來的交易（Transaction）必須等待；這個鎖從查詢的當下持有，直到 Transaction 提交（Commit）或回滾（Rollback）才釋放。&lt;/p&gt;
&lt;p&gt;代價是明確的：高併發下這裡會成為瓶頸，請求會大量排隊，但在「超賣後果不可逆」的前提下，這個代價值得付。&lt;/p&gt;
&lt;h2 id="座位鎖定互斥資源必須強佔"&gt;座位鎖定：互斥資源，必須強佔&lt;/h2&gt;
&lt;p&gt;對號座的選位，是另一個互斥資源（Exclusive Resource）的場景，用戶 A 選了 A-12，在她付款完成之前，A-12 不能被用戶 B 選走。&lt;/p&gt;
&lt;p&gt;這裡的問題不只是資料庫併發，更是跨請求（Request）的狀態持有；用戶進入付款頁面後，這把鎖需要持續存在 10 分鐘，若超時便自動釋放；資料庫鎖的生命週期只存在於一個 Transaction 裡，無法做到跨 Request 持有。&lt;/p&gt;
&lt;p&gt;這時可以改用 &lt;a href="https://redis.io/docs/latest/develop/clients/patterns/distributed-locks/"&gt;Redis 的分散式鎖（Distributed Lock）&lt;/a&gt;：&lt;code&gt;SET NX EX&lt;/code&gt;（不存在才設置，並附上過期時間），第一個選到這個座位的請求成功鎖定，後來的請求看到 key 已存在，直接被拒絕；過了 10 分鐘之後，Redis 的 TTL 自動刪除這個 key，座位自動釋放回可選狀態。&lt;/p&gt;
&lt;h2 id="庫存預扣在資料庫前擋掉大多數請求"&gt;庫存預扣：在資料庫前擋掉大多數請求&lt;/h2&gt;
&lt;p&gt;純粹依賴 PostgreSQL 的 &lt;code&gt;FOR UPDATE&lt;/code&gt;，在演唱會開賣瞬間會承受巨大的資料庫壓力。大量請求湧進來，每個都要等待前一個 Transaction 釋放鎖，資料庫連線很快就會耗盡。&lt;/p&gt;
&lt;p&gt;這時可以在資料庫前面加一層 Redis 的原子操作（Atomic Operation）：先在 Redis 裡做庫存預扣，用 &lt;a href="https://redis.io/docs/latest/develop/programmability/eval-intro/"&gt;Lua Script&lt;/a&gt; 確保「檢查庫存是否足夠」和「扣減庫存」這兩步是符合原子操作且不可分割；因此大多數「票已售罄」的請求，在這一層就被擋掉了，不需要進入資料庫，只有成功預扣的請求才會繼續往下，讓 PostgreSQL 做最終確認。&lt;/p&gt;</description></item><item><title>樂觀鎖與悲觀鎖：併發的控制</title><link>https://kaiadv.pages.dev/posts/20260530/</link><pubDate>Sat, 30 May 2026 00:00:00 +0800</pubDate><guid>https://kaiadv.pages.dev/posts/20260530/</guid><description>&lt;p&gt;併發問題的根源，大多來自「多個操作同時想動同一份資料」；當系統只有一個使用者時，這不是問題，然而流量一旦上來，很多我們以為不可能同時發生的事，就會同時發生；面對這個問題，思路分成了兩個截然不同的方向。&lt;/p&gt;
&lt;hr&gt;
&lt;h1 id="悲觀鎖先鎖再說"&gt;悲觀鎖：先鎖再說&lt;/h1&gt;
&lt;p&gt;悲觀鎖（Pessimistic Lock）的世界觀很直接：衝突一定會發生，所以在操作資料之前就先把它鎖住，其他人等我用完再說；它像一個很謹慎的人，進房間之前先把門鎖上，做完事情才開鎖讓別人進來；在這段期間，任何人想進來都必須等待。&lt;/p&gt;
&lt;p&gt;資料庫層面最常見的實作是 &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt;，在查詢的當下就對那幾行資料加上排他鎖，其他 transaction 如果也想修改同一行，就必須等待前一個 transaction 結束：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt; 1&lt;/span&gt;&lt;span&gt;&lt;span style="color:#ff7b72"&gt;BEGIN&lt;/span&gt;;&lt;span style="color:#6e7681"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt; 2&lt;/span&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt; 3&lt;/span&gt;&lt;span&gt;&lt;span style="color:#ff7b72"&gt;SELECT&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;available_qty&lt;span style="color:#6e7681"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt; 4&lt;/span&gt;&lt;span&gt;&lt;span style="color:#ff7b72"&gt;FROM&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;tickets&lt;span style="color:#6e7681"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt; 5&lt;/span&gt;&lt;span&gt;&lt;span style="color:#ff7b72"&gt;WHERE&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;event_id&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72;font-weight:bold"&gt;=&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#a5d6ff"&gt;1&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;AND&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;ticket_type&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72;font-weight:bold"&gt;=&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#a5d6ff"&gt;&amp;#39;VIP&amp;#39;&lt;/span&gt;&lt;span style="color:#6e7681"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt; 6&lt;/span&gt;&lt;span&gt;&lt;span style="color:#ff7b72"&gt;FOR&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;UPDATE&lt;/span&gt;;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#8b949e;font-style:italic"&gt;-- 從這一刻起，這行資料被鎖住
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt; 7&lt;/span&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt; 8&lt;/span&gt;&lt;span&gt;&lt;span style="color:#8b949e;font-style:italic"&gt;-- 確認有票後才扣減
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt; 9&lt;/span&gt;&lt;span&gt;&lt;span style="color:#ff7b72"&gt;UPDATE&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;tickets&lt;span style="color:#6e7681"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt;10&lt;/span&gt;&lt;span&gt;&lt;span style="color:#ff7b72"&gt;SET&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;available_qty&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72;font-weight:bold"&gt;=&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;available_qty&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72;font-weight:bold"&gt;-&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#a5d6ff"&gt;1&lt;/span&gt;&lt;span style="color:#6e7681"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt;11&lt;/span&gt;&lt;span&gt;&lt;span style="color:#ff7b72"&gt;WHERE&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;event_id&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72;font-weight:bold"&gt;=&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#a5d6ff"&gt;1&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;AND&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;ticket_type&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72;font-weight:bold"&gt;=&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#a5d6ff"&gt;&amp;#39;VIP&amp;#39;&lt;/span&gt;;&lt;span style="color:#6e7681"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt;12&lt;/span&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681"&gt;13&lt;/span&gt;&lt;span&gt;&lt;span style="color:#ff7b72"&gt;COMMIT&lt;/span&gt;;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#8b949e;font-style:italic"&gt;-- 鎖在這裡釋放
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;這個做法的代價很明顯：併發效能較低，因為後來的請求都必須排隊等待；而且如果兩個 transaction 互相等待對方釋放鎖，就會發生死鎖（Deadlock）；不過在某些場景下，這是正確的選擇，因為有些錯誤是無法事後補救的。&lt;/p&gt;</description></item></channel></rss>