Mysql數(shù)據(jù)庫樂觀鎖與悲觀鎖示例詳解
樂觀鎖與悲觀鎖是兩種常見的并發(fā)控制機制,用于解決多用戶同時操作同一數(shù)據(jù)時的一致性問題
一、悲觀鎖(Pessimistic Locking)
1. 原理
- 假設:并發(fā)沖突很可能發(fā)生,因此在讀取數(shù)據(jù)時就加鎖,防止其他事務修改。
- 適用于寫操作頻繁、沖突概率高的場景。
2. MySQL 中的實現(xiàn)
通過
SELECT ... FOR UPDATE或SELECT ... LOCK IN SHARE MODE(8.0 后推薦用FOR SHARE)實現(xiàn)行級鎖(InnoDB 引擎)。
-- 排他鎖(寫鎖):其他事務不能讀(除非快照讀)、不能寫 SELECT * FROM accounts WHERE id = 1 FOR UPDATE; -- 共享鎖(讀鎖):允許多個事務讀,但阻止寫 SELECT * FROM accounts WHERE id = 1 FOR SHARE;
?? 必須在事務中使用,否則鎖會立即釋放。
3. Gin + GORM 示例(悲觀鎖)
func TransferHandler(c *gin.Context) {
tx := db.Begin()
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
var fromAccount Account
// 悲觀鎖:鎖定 from 賬戶
if err := tx.Set("gorm:query_option", "FOR UPDATE").
Where("id = ?", 1).First(&fromAccount).Error; err != nil {
tx.Rollback()
c.JSON(400, gin.H{"error": "賬戶不存在"})
return
}
var toAccount Account
if err := tx.Set("gorm:query_option", "FOR UPDATE").
Where("id = ?", 2).First(&toAccount).Error; err != nil {
tx.Rollback()
c.JSON(400, gin.H{"error": "目標賬戶不存在"})
return
}
if fromAccount.Balance < 100 {
tx.Rollback()
c.JSON(400, gin.H{"error": "余額不足"})
return
}
fromAccount.Balance -= 100
toAccount.Balance += 100
tx.Save(&fromAccount)
tx.Save(&toAccount)
tx.Commit()
c.JSON(200, gin.H{"msg": "轉(zhuǎn)賬成功"})
}? 優(yōu)點:強一致性,避免臟讀/丟失更新
? 缺點:性能差(鎖等待)、易死鎖、降低并發(fā)
二、樂觀鎖(Optimistic Locking)
1. 原理
- 假設:并發(fā)沖突很少發(fā)生,因此不加鎖,只在更新時檢查數(shù)據(jù)是否被他人修改。
- 通常通過 版本號(version)字段 或 時間戳 實現(xiàn)。
2. MySQL 中的實現(xiàn)
表結(jié)構(gòu)需包含
version字段(整型):
CREATE TABLE products (
id INT PRIMARY KEY,
name VARCHAR(100),
stock INT,
version INT DEFAULT 0
);更新時帶上版本號條件:
UPDATE products SET stock = stock - 1, version = version + 1 WHERE id = 1 AND version = 5; -- 只有 version 未變才更新
如果返回
affected_rows == 0,說明數(shù)據(jù)已被他人修改,需重試或報錯。
3. Gin + GORM 示例(樂觀鎖)
GORM 內(nèi)置支持樂觀鎖(需使用
gorm.DeletedAt同包下的Version字段):
type Product struct {
ID uint `gorm:"primarykey"`
Name string
Stock int
Version uint32 // GORM 自動識別為樂觀鎖字段
}
func ReduceStock(c *gin.Context) {
var product Product
id := c.Param("id")
// 第一次讀取
if err := db.First(&product, id).Error != nil {
c.JSON(404, gin.H{"error": "商品不存在"})
return
}
// 業(yè)務邏輯:扣減庫存
if product.Stock <= 0 {
c.JSON(400, gin.H{"error": "庫存不足"})
return
}
// 嘗試更新(GORM 自動在 WHERE 中加入 version 條件)
product.Stock--
result := db.Save(&product)
if result.Error != nil {
c.JSON(500, gin.H{"error": "數(shù)據(jù)庫錯誤"})
return
}
if result.RowsAffected == 0 {
// 樂觀鎖失?。喊姹静黄ヅ?
c.JSON(409, gin.H{"error": "庫存已被其他請求修改,請重試"})
return
}
c.JSON(200, gin.H{"msg": "扣減成功", "stock": product.Stock})
}? 優(yōu)點:高并發(fā)、無鎖、性能好
? 缺點:沖突時需重試、不適合高頻寫沖突場景
三、樂觀鎖 vs 悲觀鎖 對比
| 特性 | 悲觀鎖 | 樂觀鎖 |
|---|---|---|
| 并發(fā)性能 | 低(串行化) | 高(無鎖) |
| 一致性保障 | 強(事務隔離) | 最終一致(需處理沖突) |
| 適用場景 | 寫多讀少、沖突頻繁 | 讀多寫少、沖突較少 |
| 實現(xiàn)復雜度 | 簡單(SQL 加鎖) | 需版本字段 + 重試邏輯 |
| 死鎖風險 | 有 | 無 |
| 典型應用 | 銀行轉(zhuǎn)賬、訂單支付 | 商品庫存、點贊、評論計數(shù) |
四、在 Gin 項目中的選型建議
| 場景 | 推薦鎖類型 | 說明 |
|---|---|---|
| 轉(zhuǎn)賬、資金結(jié)算 | 悲觀鎖 | 強一致性要求高,不能出錯 |
| 秒殺、搶購庫存扣減 | 樂觀鎖 + 重試 或 Redis 預減庫存 | 高并發(fā)下悲觀鎖性能差 |
| 用戶資料編輯 | 樂觀鎖 | 沖突少,體驗好 |
| 訂單狀態(tài)變更(如支付) | 悲觀鎖 或 狀態(tài)機校驗 | 防止重復支付/狀態(tài)錯亂 |
?? 高并發(fā)場景(如秒殺)通常不直接依賴數(shù)據(jù)庫鎖,而是:
- 使用 Redis 預減庫存 + 隊列異步落庫
- 結(jié)合 Lua 腳本保證原子性
- 數(shù)據(jù)庫僅做最終一致性校驗
五、GORM 樂觀鎖注意事項
- 字段名必須為
Version(類型uint32或int) - GORM 在
Save()或Update()時自動添加WHERE version = ?并遞增 - 若使用
Updates(map),需手動包含version字段
六.總結(jié)
- 悲觀鎖:適合強一致性、低并發(fā)寫場景,用
FOR UPDATE+ 事務。 - 樂觀鎖:適合高并發(fā)、沖突少場景,用
version字段 + 重試機制。 - 在 Gin + GORM 項目中,根據(jù)業(yè)務特性選擇合適方案,必要時結(jié)合緩存(Redis)提升性能。
實際項目中,混合使用也很常見:核心資金用悲觀鎖,普通業(yè)務用樂觀鎖。
七.Redis 預減庫存 + 消息隊列異步落庫
在高并發(fā)場景(如秒殺、搶購)中,直接操作數(shù)據(jù)庫扣減庫存極易導致性能瓶頸、超賣甚至系統(tǒng)崩潰。因此,業(yè)界普遍采用 “Redis 預減庫存 + 消息隊列異步落庫” 的架構(gòu)來兼顧 高性能、一致性與可靠性
1、整體架構(gòu)圖
用戶請求
│
▼
[ Gin Web 服務 ] ←─┐
│ │
▼ │
[ Redis 預減庫存 ] │ ←─ 庫存校驗 & 原子扣減(Lua 腳本)
│ │
▼ │
[ 發(fā)送消息到 MQ ] ─┘ → [ Kafka / RabbitMQ / RocketMQ ]
│
▼
[ 異步消費服務 ]
│
▼
[ MySQL 落庫 ] ←─ 訂單創(chuàng)建、庫存最終扣減、記錄日志
│
▼
[ 返回結(jié)果給用戶(可延遲)]? 核心思想:
- 快速響應:Redis 操作毫秒級,用戶幾乎無等待
- 削峰填谷:MQ 緩沖瞬時高并發(fā)
- 最終一致:異步確保數(shù)據(jù)持久化
2、核心步驟詳解
步驟 1:初始化庫存到 Redis
- 系統(tǒng)啟動或活動開始前,將商品庫存同步到 Redis。
- 使用 String 類型 或 Hash 存儲,如
stock:product:1001 = 100
// 初始化庫存(管理后臺或定時任務調(diào)用) redisClient.Set(ctx, "stock:product:1001", 100, 0)
步驟 2:用戶請求秒殺接口(Gin Handler)
- 參數(shù)校驗(用戶 ID、商品 ID)
- 防重放:檢查是否已下單(可用 Redis Set
user:1001:product:1001) - Lua 腳本原子扣減庫存
- 若庫存 > 0,則
DECR并返回成功 - 否則返回“庫存不足”
- 若庫存 > 0,則
- 發(fā)送消息到 MQ(僅當 Redis 扣減成功)
?? 關(guān)鍵:Redis 扣減必須是原子操作,防止超賣!
步驟 3:Lua 腳本實現(xiàn)原子預減庫存
-- stock_decrease.lua
local key = KEYS[1]
local userId = ARGV[1]
-- 1. 檢查是否已搶購(防重)
if redis.call("EXISTS", "seckill:user:" .. userId .. ":product:" .. string.match(key, ":(%d+)$")) == 1 then
return -2 -- 已參與
end
-- 2. 獲取當前庫存
local stock = tonumber(redis.call("GET", key))
if not stock or stock <= 0 then
return -1 -- 庫存不足
end
-- 3. 扣減庫存
redis.call("DECR", key)
-- 4. 記錄用戶已參與(防重,TTL 可選)
redis.call("SET", "seckill:user:" .. userId .. ":product:" .. string.match(key, ":(%d+)$"), "1", "EX", 3600)
return stock - 1返回值含義:
-2:已搶過-1:庫存不足>=0:剩余庫存,表示成功
步驟 4:Gin 處理秒殺請求(Go 代碼)
// main.go 或 handler/seckill.go
func SeckillHandler(c *gin.Context) {
userID := c.GetString("user_id") // 假設已鑒權(quán)
productID := c.Param("product_id")
// 構(gòu)造 Redis Key
stockKey := fmt.Sprintf("stock:product:%s", productID)
userProductKey := fmt.Sprintf("seckill:user:%s:product:%s", userID, productID)
// 執(zhí)行 Lua 腳本
result, err := redisClient.Eval(
ctx,
luaScript, // 上述 Lua 腳本內(nèi)容
[]string{stockKey},
userID,
).Result()
if err != nil {
c.JSON(500, gin.H{"error": "系統(tǒng)繁忙"})
return
}
switch ret := result.(type) {
case int64:
if ret == -1 {
c.JSON(400, gin.H{"error": "庫存不足"})
return
}
if ret == -2 {
c.JSON(400, gin.H{"error": "您已參與過本次秒殺"})
return
}
default:
c.JSON(500, gin.H{"error": "未知錯誤"})
return
}
// 成功!發(fā)送消息到 MQ(異步落庫)
msg := SeckillMessage{
UserID: userID,
ProductID: productID,
Timestamp: time.Now(),
}
// 序列化并發(fā)送到 Kafka / RabbitMQ
if err := mqProducer.Send("seckill_queue", msg); err != nil {
// 注意:此處即使 MQ 發(fā)送失敗,Redis 已扣減,需有補償機制!
log.Printf("MQ send failed: %v", err)
// 可考慮回滾 Redis(復雜),或依賴后續(xù)對賬
}
// 立即返回用戶“搶購成功,請等待訂單生成”
c.JSON(200, gin.H{
"msg": "搶購成功!正在生成訂單...",
"queue_status": "processing",
})
}步驟 5:異步消費服務(Worker)
// worker/seckill_worker.go
func StartSeckillWorker() {
for msg := range mqConsumer.Subscribe("seckill_queue") {
var seckillMsg SeckillMessage
if err := json.Unmarshal(msg, &seckillMsg); err != nil {
continue
}
// 開啟事務,落庫
tx := db.Begin()
defer tx.Rollback()
// 1. 再次校驗(兜底):MySQL 中庫存是否足夠?
var product Product
if err := tx.Where("id = ? AND stock > 0", seckillMsg.ProductID).First(&product).Error; err != nil {
log.Printf("MySQL 庫存不足或商品不存在: %v", seckillMsg)
continue // 丟棄消息 or DLQ
}
// 2. 創(chuàng)建訂單
order := Order{
UserID: seckillMsg.UserID,
ProductID: seckillMsg.ProductID,
Status: "created",
}
if err := tx.Create(&order).Error != nil {
continue
}
// 3. 扣減 MySQL 庫存
if err := tx.Model(&Product{}).
Where("id = ? AND stock = ?", seckillMsg.ProductID, product.Stock).
Update("stock", gorm.Expr("stock - 1")).Error; err != nil {
continue
}
tx.Commit()
log.Printf("訂單創(chuàng)建成功: %v", order.ID)
}
}?? 兜底校驗很重要!防止 Redis 與 MySQL 數(shù)據(jù)不一致(如 Redis 重啟未同步)。
3、關(guān)鍵設計點與注意事項
| 問題 | 解決方案 |
|---|---|
| Redis 與 MySQL 數(shù)據(jù)不一致 | 異步消費時做 MySQL 庫存二次校驗;定期對賬補償 |
| MQ 消息丟失 | 使用可靠消息(Kafka 副本、RabbitMQ 持久化 + ACK) |
| 重復消費 | 消費端冪等(如訂單表加唯一索引 (user_id, product_id)) |
| Redis 宕機 | 高可用部署(Redis Cluster / Sentinel) |
| 超賣 | Lua 腳本保證原子性 + MySQL 兜底校驗 |
| 用戶重復提交 | Redis 記錄 user:product 防重鍵(帶 TTL) |
4、擴展:失敗補償與對賬
- 定時對賬任務:每天對比 Redis 初始庫存、Redis 當前庫存、MySQL 已售數(shù)量,發(fā)現(xiàn)差異則告警或自動修復。
- 死信隊列(DLQ):處理多次失敗的消息,人工介入。
- 前端輪詢/WebSocket:告知用戶“訂單已生成”,提升體驗。
5、總結(jié)
? 優(yōu)勢:
- 高并發(fā):Redis 承載 10w+ QPS
- 防超賣:Lua 原子操作
- 系統(tǒng)解耦:MQ 異步削峰
- 最終一致:異步落庫 + 兜底校驗
? 復雜度:
- 需維護 Redis + MQ + 對賬系統(tǒng)
- 調(diào)試和監(jiān)控難度增加
?? 適用場景:秒殺、搶購、限量發(fā)放等高并發(fā)、低轉(zhuǎn)化率業(yè)務
到此這篇關(guān)于Mysql數(shù)據(jù)庫樂觀鎖與悲觀鎖示例詳解的文章就介紹到這了,更多相關(guān)Mysql樂觀鎖與悲觀鎖內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
mysql?復制行數(shù)據(jù)命令經(jīng)驗分享(Mysql?復制一條數(shù)據(jù))
有時候表里有一行已經(jīng)添加好的數(shù)據(jù),想要多復制幾百條用來測試比如要分頁測試等,需要多條數(shù)據(jù),但是有時候數(shù)據(jù)表字段太多了,有幾十個,一個一個手寫那是不可能的2023-09-09
使用mysql語句查看數(shù)據(jù)庫表所占容量空間大小
這篇文章主要給大家介紹了關(guān)于如何使用mysql語句查看數(shù)據(jù)庫表所占容量空間大小的相關(guān)資料,如何在MySQL數(shù)據(jù)庫管理中查詢數(shù)據(jù)庫、表、索引的容量大小是經(jīng)常遇到的需求,需要的朋友可以參考下2023-08-08
查詢數(shù)據(jù)庫空間(mysql和oracle)
本文通過代碼示例詳細介紹了如何查詢MySQL數(shù)據(jù)空間和Oracle數(shù)據(jù)空間,具有一定的參考價值,感興趣的小伙伴可以參考閱讀2023-04-04
MySQL之union和union all的使用及區(qū)別說明
這篇文章主要介紹了MySQL之union和union all的使用及區(qū)別說明,具有很好的參考價值,希望對大家有所幫助,如有錯誤或未考慮完全的地方,望不吝賜教2024-04-04

