門票系統為什么總“炸”?并發、鎖、競態一次說清
在在線電影、演唱會與體育賽事全面數字化的時代,“搶票”已經不再只是點擊按鈕這么簡單。 你所看到的“售罄僅需 1 秒”,背后往往是成千上萬的請求同時涌向服務器,而座位庫存的數量卻遠少于這些訪問量。短短幾毫秒內的差異,就可能導致:
- 有些用戶重復搶到相同的座位
- 多人同時占用同一資源
- 庫存被扣成負數
- 訂單錯亂甚至系統癱瘓
這些問題的核心,都源于高并發下的共享資源競爭。
為了構建一個真正“抗壓”的票務系統,本篇文章將從底層出發,以 Spring Boot + MySQL 為基礎,拆解 并發、鎖、事務、座位競態背后的機制,并結合完整的代碼,帶你逐步構建一個具備:
- 防超賣
- 隔離鎖
- 可控一致性
- 高并發安全
- 良好用戶體驗
的現代票務系統。
行業痛點與問題模型
在票務、電商秒殺、在線預約等需要“搶占式資源分配”的業務領域,幾乎所有系統都會遇到同一類結構性難題:資源有限、訪問高峰集中、寫入沖突頻繁、響應時延敏感。這些特性疊加之后,任何微小的邏輯瑕疵都可能引發大面積錯單、超賣、庫存負數、訂單狀態失真等嚴重事故。
為了更清晰地描述問題,我們將行業常見痛點抽象為三個典型模型。
① 并發寫沖突模型:多個用戶同時修改同一條記錄
最典型場景: 多個用戶同時搶購同一張票,每個線程都讀取到相同的庫存值,然后幾乎同時嘗試減庫存。
如果庫存是 1,而有 100 個請求同時到達:
- 所有人讀取到的都是
remain = 1 - 每個人都認為“庫存足夠”
- 大量線程同時開始減庫存
- 結果:庫存被更新多次,系統出現超賣
問題本質:讀取與更新之間存在時間窗口,任何線程都可能插隊,造成不可預測的狀態跳變。
② 業務校驗延遲模型:流程中多個步驟依賴共享信息
大部分票務邏輯都不是一句 SQL 能解決的 —— 通常需要:
- 判斷活動是否可售
- 判斷庫存是否足夠
- 判斷用戶是否重復購買
- 生成訂單
- 扣減庫存
- 寫入日志
這些步驟一旦拆散到多個操作,就引入了天然風險:任意校驗步驟之間的時間差,都可能讓另一個線程插入并改變全局狀態。
你看到的是“邏輯嚴謹的流程”; 系統看到的是“多個時間不一致的快照”。
③ 分布式場景下的擴散性風險:多個節點同時操作同一資源
現代部署常常采用多實例、多容器、多服務節點并發處理同一個活動。
這意味著:
- 不是一個應用線程在搶資源
- 而是幾十甚至上百個節點同時讀取相同數據
這些節點之間無法共享線程鎖,每個節點的處理速度不同; 這會把原本簡單的沖突擴大為 分布式級別的競態,問題更難定位、更難復現。
Race Condition 原理
在高并發業務中,最難纏、最隱蔽、最容易被忽略的系統問題,就是 Race Condition(競態條件)。它不像語法錯誤會直接報錯,也不像異常會立刻中斷流程,而是以一種“偶發、隨機、不可復現”的方式悄悄破壞數據一致性。
要構建真實可控的搶票/扣庫存系統,理解競態的底層原理是第一步。
什么是 Race Condition?
如果要用一句話描述:
多個線程在沒有明確順序約束的情況下操作同一份數據時,最終結果依賴于“哪一個線程先執行”,而這個先后順序是不可預測的,這就是競態條件。
換句話說:
- 代碼邏輯看起來沒有問題
- SQL 似乎也正確
- 流程能跑通
- 但一旦進入高并發,結果就完全取決于隨機的執行順序
這就是 Race Condition 的可怕之處。
本質:狀態的改變被“同時爭奪”
我們把資源狀態抽象為一個 timeline(時間線):
T0 -------- T1 -------- T2 -------- T3
| | | |
| | | |
讀取 校驗 修改 提交假設兩個線程都在操作同一資源:
- T1:讀取狀態
- T2:根據讀取結果決定是否操作
- T3:更新數據并提交
問題就在于:
線程 A 和線程 B 的 T0–T3 之間沒有任何保證順序的機制。
也就是說系統實際發生的可能是:
線程 A:讀取
線程 B:讀取
線程 B:更新
線程 A:更新在這種插隊情況下,線程 A 用已經過期的狀態執行邏輯,導致兩次更新都被提交,觸發“狀態失真”。
這就是最典型的超賣。
競態產生的原因:三大根因
① 狀態讀取與操作之間存在時間窗口
所有業務校驗都基于當前狀態:
- 當前庫存是多少?
- 用戶是否已經買過?
- 活動是否還有容量?
但在讀取狀態與更新狀態之間,任何線程都可能改變這個狀態。
只要存在這個“間隙”,競態就可能發生。
② 多步驟鏈路被拆散,缺乏原子性
例如創建訂單:
- 查詢庫存
- 判斷是否可售
- 判斷用戶是否重復購買
- 減庫存
- 寫訂單
只要不是一次性執行的原子操作,中間任何一個步驟都可能被其他線程打斷。
業務越復雜,出現競態的概率越高。
③ 多節點系統放大了每一次并發寫入
在分布式架構下:
- 多個應用實例
- 多個線程池
- 多個數據庫連接
- 多個服務節點同時執行邏輯
每個節點都覺得自己“正在處理”,但實際上:
它們都在同時爭奪同一行數據。
單機下偶發的問題,會在分布式場景中成倍放大。
競態不是 bug,是自然現象
很多開發者第一次遇到 Race Condition 會想:
“我代碼哪里錯了?”
但實際上:
- 競態不是錯誤
- 它也不是異常
- 它是系統在并發條件下的“正常表現”
真正的問題是:
程序沒有設計機制來約束并發訪問順序。
換句話說—— 沒有鎖、沒有版本號、沒有隔離機制的系統,是必然會出現競態的。
為什么測試環境永遠無法復現?
因為 Race Condition 依賴:
- CPU 搶占順序
- 上下文切換時機
- 多線程競爭激烈程度
- IO 與網絡抖動
- 多機之間的時鐘偏移
- 數據庫事務調度策略
這些都具有隨機性。
在真實高并發場景下,系統有數千個請求在同一秒進入; 但你本地測試時,可能一次只有 3~5 個線程在跑。
所以:
測試環境很難觸發競態 生產環境幾乎必定觸發競態
這就是為什么超賣往往只在大促活動當天才暴露。
競態無法通過“寫更嚴謹的代碼”解決
常見抗不住的做法:
- “多寫幾層業務判斷”
- “前端也檢查一下庫存”
- “寫更多 if else”
- “每個 SQL 都加個 where 條件”
這些做法本質上不具備原子性,仍然逃不過并發插隊。
為了真正解決競態,我們需要:
- 對數據庫結構重新建模
- 選對加鎖策略(悲觀鎖 / 樂觀鎖 / 分布式鎖)
- 縮小并發競爭窗口
- 重構業務流程避免時間差
- 在邊界處引入強一致性保護
數據庫結構
無論搶票系統、秒殺服務還是庫存分配平臺,數據庫結構都是控制并發行為的“定海神針”。一旦表結構設計得不合理,后續無論你怎么加鎖、怎么優化業務流程,都很難真正規避競態問題。 因此,在討論鎖之前,必須先回答一個更底層的問題:
數據應該如何建模,才能讓并發訪問可控、可約束、可證明?
本章將從庫存建模、狀態建模、并發建模三條主線,重構一個適合高并發搶占式業務的數據庫結構。
模型一:庫存與配額結構(Stock / Quota Model)
最容易引發競態的字段通常只有一個:
remain_stock如果只把庫存做成一個單獨字段,看似很簡單,但實際上:
- 它會被所有流程同時讀取
- 它在整個業務周期中持續被更新
- 每次更新都可能有多個線程競爭
因此一個好的庫存結構應該滿足:
- 支持原子操作(減少一步步的讀取 → 校驗 → 更新)
- 支持鎖定(悲觀/樂觀均可)
- 支持預扣(用于并發削峰)
- 支持隔離多個活動
關鍵字段解釋:
字段 | 用途 |
| 當前剩余庫存,所有并發寫入的焦點 |
| 樂觀鎖標記,避免臟寫 |
| 是否開啟售賣,減少無意義并發 |
| 減少“未開始就大量請求”的無效訪問 |
通過 version 字段,可以把庫存更新改為:
UPDATE ticket_event
SET remain_stock = remain_stock - 1,
version = version + 1
WHERE event_id = ?
AND remain_stock > 0
AND version = ?這類結構天然適配樂觀鎖機制 避免了“讀取與更新分離”導致的競態窗口
模型二:訂單與狀態機結構(Order / Status Model)
高并發下的訂單表必須保證:
- 狀態可追蹤
- 可防止重復下單
- 可防止多線程重復寫入
- 有狀態機支持狀態轉換的邏輯閉環
關鍵點:唯一索引
UNIQUE KEY u_user_event (user_id, event_id)這個設計能自動阻斷:
- 并發重復下單
- 重放攻擊
- 脫離鎖機制的重復寫入
同時也能減少業務層的判斷分支。
訂單狀態的推薦狀態轉移:
pending → locked → paid → finished
↑
└── canceled拆分狀態的最大好處:
能在數據庫層面看到流程處于哪一步,避免事務中做太多事,也降低鎖的持有時間。
模型三:并發控制結構(Concurrency Control Fields)
為了讓系統中的并發行為可控,可觀察,我們需要引入“輔助字段”,讓每次搶占式操作都變得可追蹤、可驗證。
推薦增加的字段:
字段 | 用途 |
| 樂觀鎖用途,限制并發寫入 |
| 悲觀鎖占用標識,可定位鎖被誰持有 |
| 預扣庫存的過期釋放時間 |
| 操作鏈路追蹤信息(便于回溯) |
這些字段不一定都要放入同一張表,但在高并發系統中非常有價值。
三種鎖方案
在理解競態成因與數據庫結構之后,萬變不離其宗的關鍵問題只有一個:
當多個線程同時操作同一份資源時,我們如何規定它們的“先后順序”?
鎖機制(Locking Strategy)就是解決這個根本問題的工具。 但鎖從來不是只有一種形式,它們在成本、控制力與吞吐量之間各有取舍。
本章將從“適用場景 → 原理 → 實現方式 → 優缺點”四個維度,同時重寫并強化三大典型鎖模型。
悲觀鎖(Pessimistic Lock)
核心思想:
假設別人一定會來競爭,所以先把門鎖住,再處理事情。
在高沖突場景下,悲觀鎖能確保最穩定的結果,因為它直接阻塞所有其他線程,直到當前操作完成。
① 使用場景
- 庫存極度緊張(庫存遠小于請求量)
- 數據更新競爭激烈
- 多個線程可能同時修改同一行數據
- 對一致性要求極高、不允許任何錯單
適合例如:
- 搶票最后幾張
- 限量秒殺
- “只剩 1 個名額”類業務
② 實現方式
典型 SQL:
SELECT *
FROM ticket_event
WHERE event_id = ?
FOR UPDATE;執行效果:
- 數據庫對這行記錄加“排他鎖”
- 只有當前事務能讀寫
- 其他事務必須等待
之后執行庫存減少:
UPDATE ticket_event
SET remain_stock = remain_stock - 1
WHERE event_id = ?;整個過程在同一個事務中完成。
③ 優點
- 絕對可靠:天然杜絕超賣
- 邏輯簡單:不需額外字段/版本號
- 一致性最強
④ 缺點
- 吞吐量大幅下降
- 所有請求排隊,形成“串行化”
- 越多人搶同一資源,等待越長
- 一個慢查詢可能阻塞全部線程
適用于“要正確,不要快”的業務時段,而非全量場景。
樂觀鎖(Optimistic Lock)
核心思想:
假設沖突不是常態,大家可以同時讀;但在寫入時確認一下狀態是否被別人改過。
它不是阻塞,而是拒絕無效更新。
① 使用場景
- 并發量大,但沖突頻率沒有那么夸張
- 數據行更新不算極度頻繁
- 希望提升吞吐量
- 可以接受局部失敗重試(搶票失敗即可)
適用于:
- 常態售賣
- 中高并發但不是極端搶購
- 大部分請求不會命中同一庫存點
② 實現方式
常用 version 字段:
UPDATE ticket_event
SET remain_stock = remain_stock - 1,
version = version + 1
WHERE event_id = ?
AND remain_stock > 0
AND version = ?;如果兩個線程競爭:
- 線程 A 把 version 從 10 → 11
- 線程 B 的 SQL WHERE version = 10 不再成立,更新失敗
線程 B 會收到 “0 行受影響”,自然搶不到票。
無需 locking,無需等待。
③ 優點
- 并發能力最強
- 不會阻塞請求
- 失敗立即返回,系統不積壓
- 與緩存、削峰組件兼容性強
④ 缺點
- 無法絕對避免沖突,只能防止“臟寫”
- 需要業務層處理失敗重試或失敗返回
- version 字段需要每次維護
樂觀鎖的本質是快失敗,適合互聯網流量特征。
分布式鎖(Distributed Lock)
核心思想:
鎖不是加在數據庫上,而是通過 Redis、Etcd 等外部系統協調整個分布式環境的先后順序。
在多應用實例、多機房、多節點同時處理請求的場景下,悲觀鎖/樂觀鎖無法橫跨節點調度; 因此必須引入第三方鎖服務。
① 使用場景
- 多個服務節點競爭同一資源
- 秒殺系統部署為多實例
- 分布式任務調度
- 多機資源控制
例如:
- A 節點與 B 節點同時處理 event_id = 101
- 只能讓一個節點進入庫存邏輯
② 實現方式
以 Redis 為例:
SETNX lock:event:101 <requestId>
EXPIRE lock:event:101 3 -- 避免死鎖如果成功獲得鎖 → 執行業務 如果失敗 → 立即返回或排隊
釋放鎖:
DEL lock:event:101 -- 需保證是自己設置的鎖高級方案:
- RedLock(分布式 Redis)
- Etcd 分布式租約
- Zookeeper 分布式鎖
③ 優點
- 適用于多節點競爭,數據庫無法解決的問題
- 鎖粒度可拆分為:
按 event
按 seat
按用戶
- 可實現跨項目、跨服務統一并發控制
- 性能高(Redis 寫入極快)
④ 缺點
- 增加系統復雜性
- 鎖失效、續約、漂移需要精心處理
- 依賴外部系統(Redis 掛則業務受影響)
- 實現不當會造成“假鎖”、“鎖被提前釋放”
適合需要在分布式環境中進行全局串行化的業務。
完整業務流程
在理解了競態成因、數據庫結構以及三類鎖方案之后,我們將完整串聯整個搶票業務的實際流程。 重點在于 鎖策略如何融入業務邏輯,以及各環節的處理順序和異常處理。
業務場景梳理
假設我們要實現一個搶票系統:
- 每場活動有固定庫存
remain_stock - 用戶高并發請求
- 系統需保證:
不超賣
高吞吐
用戶體驗友好(失敗可提示重試)
核心步驟
整個流程可以分為五個核心步驟:
- 請求接入
- 獲取鎖 / 檢查庫存
- 庫存扣減
- 記錄訂單
- 釋放鎖 & 異常處理
步驟拆解
Step 1:請求接入
- 用戶請求到達 Web 層
- 請求進入流控/限流組件(可選)
- 記錄日志或排隊信息
@RequestMapping("/buy")
public ResponseEntity<String> buyTicket(@RequestParam Long eventId, @RequestParam Long userId){
// 1. 流控與日志
if(!rateLimiter.tryAcquire()) {
return ResponseEntity.status(429).body("請稍后再試");
}Step 2:獲取鎖 / 檢查庫存
根據策略選擇不同鎖:
悲觀鎖:
TicketEvent event = ticketEventRepository.findByIdForUpdate(eventId);
if(event.getRemainStock() <= 0){
return ResponseEntity.badRequest().body("已售罄");
}樂觀鎖:
TicketEvent event = ticketEventRepository.findById(eventId);
if(event.getRemainStock() <= 0){
return ResponseEntity.badRequest().body("已售罄");
}
boolean updated = ticketEventRepository.updateStockIfVersionMatches(eventId, event.getVersion());
if(!updated){
return ResponseEntity.status(409).body("搶票失敗,請重試");
}分布式鎖(Redis 示例):
String lockKey = "lock:event:" + eventId;
if(!redisLock.tryLock(lockKey, 3)){
return ResponseEntity.status(429).body("系統繁忙,請稍后再試");
}關鍵點:鎖的獲取是整個庫存扣減的前置條件。
Step 3:庫存扣減
- 庫存扣減可以在同一事務內完成
- 悲觀鎖已阻塞其他線程
- 樂觀鎖需通過 version 判斷
- 分布式鎖確保跨實例順序
event.setRemainStock(event.getRemainStock() - 1);
ticketEventRepository.save(event);注意:庫存扣減必須在鎖定范圍內執行,否則可能出現超賣。
Step 4:生成訂單
- 生成唯一訂單號
- 寫入數據庫(可異步入隊處理提高吞吐)
- 返回訂單信息給用戶
Order order = new Order(userId, eventId, generateOrderId());
orderRepository.save(order);異步隊列(Kafka、RabbitMQ)可用于解耦庫存與訂單寫入,減輕數據庫壓力。
Step 5:釋放鎖 & 異常處理
- 悲觀鎖:事務提交自動釋放
- 樂觀鎖:無需顯式釋放
- 分布式鎖:顯式釋放,注意防止死鎖
redisLock.unlock(lockKey, requestId);異常處理:
- 扣減失敗 → 提示重試
- 訂單生成失敗 → 回滾庫存(可使用事務或補償機制)
- 鎖未釋放 → 設置 TTL 防止僵死
流程圖示意
用戶請求 → 流控限流 → 獲取鎖(悲觀/樂觀/分布式)
↓
校驗庫存
↓
扣減庫存
↓
生成訂單
↓
釋放鎖 / 異?;貪L
↓
返回結果給用戶核心優化點
- 分布式場景下使用 Redis/RedLock 保證全局順序
- 樂觀鎖適用于大多數常規場景,減少數據庫阻塞
- 高并發時結合緩存、隊列削峰
- 異常處理必須覆蓋庫存回滾、鎖釋放、訂單狀態同步
通過這種方式,整個系統既保證 一致性,又有 較高吞吐量,同時兼顧用戶體驗。
性能、可擴展性 + 總結
在完成了業務流程設計之后,我們來分析搶票系統的 性能瓶頸、可擴展策略,并對整體方案進行總結。
性能分析
搶票系統的核心壓力主要來自 高并發的庫存扣減與訂單寫入。 常見性能瓶頸包括:
瓶頸點 | 原因 | 優化策略 |
數據庫鎖爭用 | 高并發下悲觀鎖容易阻塞 | 使用樂觀鎖 + 重試機制 |
寫入壓力 | 訂單表頻繁寫入 | 異步入隊、批量寫入、緩存寫回 |
分布式一致性 | 多實例搶同一庫存 | 使用 Redis 分布式鎖或 RedLock |
高并發請求 | Web 層壓力大 | 流控、限流、降級、CDN 靜態頁面緩存 |
通過合理選擇鎖策略、異步隊列、緩存和流控,可以大幅緩解壓力。
可擴展性策略
水平擴展
- Web 層:增加實例,負載均衡
- 緩存層:Redis Cluster 支持高并發鎖
- 隊列層:Kafka、RabbitMQ 分區處理,支持多消費者
數據庫優化
- 庫存表拆分:按活動/日期分表,減少熱點
- 讀寫分離:查詢走從庫,寫操作通過主庫
- 緩存庫存:利用 Redis 緩存熱點庫存,減少數據庫壓力
異步化與削峰
- 高并發請求先寫入隊列
- 異步處理庫存扣減與訂單生成
- 配合延遲隊列處理失敗或回滾
總結
通過本篇拆解,我們實現了從理論到實踐的完整指導:
- 行業痛點與問題模型
- 并發搶購容易導致超賣、性能瓶頸
- Race Condition 原理
- 并發讀寫的沖突機制,理解根因是設計優化關鍵
- 數據庫結構設計
- 訂單表 + 活動庫存表 + 版本號字段,為鎖策略提供基礎
- 三類鎖方案對比
- 悲觀鎖:簡單、安全,但吞吐受限
- 樂觀鎖:高性能,適合大多數場景
- 分布式鎖:多實例環境保證全局一致性
- 完整業務流程
- 鎖策略嵌入庫存扣減,訂單生成異步化,異?;貪L機制
- 性能與可擴展性
- 水平擴展、緩存、異步隊列、讀寫分離等多策略結合
- 實現高并發、高可用、高一致性
技術亮點
- 靈活選擇鎖策略,適配不同并發場景
- 異步隊列和緩存結合,削峰填谷
- 全流程異常處理,確保數據一致性
- 模塊化設計,支持水平擴展與微服務拆分
通過本套方案,即使在百萬級請求并發下,也能有效避免超賣,同時保持系統響應速度和用戶體驗。




























