数据库安全并发控制详解:乐观锁 vs 悲观锁 vs 原子操作

一文搞懂库存扣减的三种姿势,再也不怕超卖

前言

电商系统里,库存扣减是最常见的并发场景。

两个用户同时下单,库存只剩 1 件,谁能买到?

这就是并发控制要解决的问题。

今天用最精简的方式,讲清楚三种方案:原子操作、悲观锁、乐观锁


一、超卖是怎么发生的?

先看一段错误的代码:

go

go 复制代码
// ❌ 先查后改 ------ 经典错误
func DeductStock(productID uint) {
    var p Product
    db.First(&p, productID)    // 1. 查询库存:10
    p.Stock = p.Stock - 1      // 2. 计算:9
    db.Save(&p)                // 3. 保存:9
}

并发执行时

时刻 用户A 用户B 库存
T1 查询 → 10 10
T2 查询 → 10 10
T3 计算 → 9 10
T4 计算 → 9 10
T5 保存 → 9 9
T6 保存 → 9 9 ❌

卖了 2 件,库存只减了 1 件 → 超卖!


二、方案一:原子操作 ⭐⭐⭐⭐⭐

一句话解释

把"读-算-写"合并成一条 SQL,数据库保证不可分割。

代码实现

go

less 复制代码
// ✅ 一条 SQL 搞定
db.Model(&Product{}).
    Where("id = ? AND stock >= 1", productID).
    Update("stock", gorm.Expr("stock - 1"))

实际生成的 SQL

sql

ini 复制代码
UPDATE products 
SET stock = stock - 1 
WHERE id = 1 AND stock >= 1;

优缺点

优点 缺点
代码最简洁 只能处理简单加减
性能最高 复杂业务逻辑不适用
天然原子性 -

使用场景

✅ 适合 99% 的库存扣减场景,优先选择!


三、方案二:悲观锁 🔒

一句话解释

"先锁住再改,别人必须排队等。"

代码实现

go

go 复制代码
// ✅ 悲观锁实现
db.Transaction(func(tx *gorm.DB) error {
    var p Product
    
    // SELECT ... FOR UPDATE ------ 行锁
    tx.Clauses(clause.Locking{Strength: "UPDATE"}).
        Where("id = ?", productID).
        First(&p)
    
    if p.Stock < 1 {
        return errors.New("库存不足")
    }
    
    p.Stock -= 1
    tx.Save(&p)
    return nil
})

原理图解

sql

sql 复制代码
-- 事务1:先锁住
BEGIN;
SELECT * FROM products WHERE id = 1 FOR UPDATE;  -- 加锁
-- 此时事务2的查询会被阻塞
UPDATE products SET stock = 9 WHERE id = 1;
COMMIT;  -- 释放锁,事务2才能继续

优缺点

优点 缺点
绝对安全,不会超卖 性能差,高并发下排队
实现直观 可能死锁
适合高冲突场景 占用数据库连接时间长

使用场景

✅ 秒杀、抢购等高冲突场景


四、方案三:乐观锁 🕊️

一句话解释

"带版本号更新,对不上就重试。"

代码实现

go

go 复制代码
type Product struct {
    ID      uint
    Stock   int
    Version int  // 版本号字段
}

func DeductStock(productID uint) error {
    for retry := 0; retry < 3; retry++ {
        var p Product
        db.First(&p, productID)
        oldVersion := p.Version
        
        result := db.Model(&Product{}).
            Where("id = ? AND version = ? AND stock >= 1", 
                  productID, oldVersion).
            Updates(map[string]interface{}{
                "stock":   gorm.Expr("stock - 1"),
                "version": gorm.Expr("version + 1"),
            })
        
        if result.RowsAffected > 0 {
            return nil  // 成功
        }
        // 版本号变了,重试
        time.Sleep(time.Millisecond * 10)
    }
    return errors.New("更新失败,请重试")
}

原理图解

text

ini 复制代码
用户A: 查询(version=1) → 更新(where version=1) → 成功(version=2)
用户B: 查询(version=1) → 更新(where version=1) → 失败 → 重试

优缺点

优点 缺点
性能好,无锁等待 需要写重试逻辑
不会死锁 冲突严重时性能下降

使用场景

✅ 低冲突的普通商品


五、一图看懂选哪个

text

markdown 复制代码
你的业务场景是什么?
│
├── 简单扣库存(无复杂业务)
│   └── ✅ 原子操作 ------ 最推荐!
│
├── 秒杀/抢购(高并发冲突)
│   └── ✅ 悲观锁 + 事务
│
├── 普通商品(低冲突)
│   └── ✅ 乐观锁
│
└── 复杂业务(多表操作)
    └── ✅ 悲观锁 + 事务

六、终极对比表

维度 原子操作 悲观锁 乐观锁
一句话 一条SQL搞定 先锁再改 版本号校验
并发安全
性能 ⭐⭐⭐⭐⭐ ⭐⭐ ⭐⭐⭐⭐
代码量
适用场景 大多数场景 秒杀抢购 普通商品
需要重试
死锁风险

七、生产级代码(直接复制用)

go

go 复制代码
// ✅ 最推荐的方案:原子操作 + 事务
func DeductStock(productID uint, quantity int) error {
    return db.Transaction(func(tx *gorm.DB) error {
        // 原子扣减,同时检查库存
        result := tx.Model(&Product{}).
            Where("id = ? AND stock >= ?", productID, quantity).
            Update("stock", gorm.Expr("stock - ?", quantity))
        
        if result.RowsAffected == 0 {
            return errors.New("库存不足")
        }
        
        // 创建订单...
        // 记录流水...
        return nil
    })
}

八、总结

记住四句话:

  1. 先查后改 = 超卖风险 ❌
  2. 原子操作 = 简单高效,首选 ✅
  3. 悲观锁 = 先锁再改,适合高并发 🔒
  4. 乐观锁 = 版本重试,适合低冲突 🕊️

没有银弹,根据业务场景选择!


推荐阅读


相关推荐
IManiy1 小时前
总结之Vibe Coding:了解后端
后端
神奇小汤圆1 小时前
全网最全 Claude Code 命令指南:会话、权限、扩展、自动化全搞定!从新手到大神,这一篇就够了
后端
2601_961875241 小时前
花生十三公考课程|网课|视频
数据库·windows·git·svn·eclipse·github
神奇小汤圆2 小时前
从0开始,在国内用上Claude Code的终极保姆教程来了。
后端
饼饼饼2 小时前
React19 新手指南:JSX 没那么难,用好这几条规则就够了
前端·javascript·react.js
想吃火锅10052 小时前
【前端手撕】new
前端
l1t2 小时前
DeepSeek总结的parquet Variant “碎形化“技术
数据库·parquet
砍材农夫2 小时前
物联网实战|Spring Boot + Netty 搭建 MQTT 消息路由与流转层
java·spring boot·后端·物联网·spring
小小小小宇2 小时前
AI大背景下端到端界面测试
前端