数据库安全并发控制详解:乐观锁 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. 乐观锁 = 版本重试,适合低冲突 🕊️

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


推荐阅读


相关推荐
yume_sibai11 小时前
Element Plus 导航与反馈组件完全指南(15个核心组件)
前端·elementui·交互
岁月宁静11 小时前
三、《从零手撸 Agent》 · system prompt 与核心参数:调好你的旋钮
后端·python·agent
数智启示录11 小时前
Apache Kafka 同 Key 仍会乱序:从分区 Offset 到业务可见的四道边界 【Kafka 合集】
大数据·数据库·经验分享·分布式·面试·kafka·apache
烬羽11 小时前
给 Agent 一个检索式记忆:把对话历史存进 Milvus,该记的都记得
javascript·数据库·agent
bcbnb11 小时前
Flutter-Notebook代码混淆:Android与iOS平台安全配置
后端·ios
星云API技术支持11 小时前
企业微信二次开发外部群:群聊数据与联系人信息如何建立关联
数据库·php·企业微信
我不是程序员三三11 小时前
桌面管理软件哪个最好?从一次上线翻车事故,拆解选型评估与配置落地全流程
数据库
泡海椒11 小时前
SpringBoot 集成 JQuick-Curl:Spring 环境完整接入教程,第三方接口调用别再到处手写模板类
后端
落木萧萧82511 小时前
MyBatis 关联查询的四种写法,为什么最后都变回了手写 XML
java·数据库·后端
cui_hao_nan11 小时前
Spring AOP 自调用、代理与 private 方法引发的诡异 NPE
java·后端·spring·ai开发