Mysql数据库乐观锁与悲观锁示例详解

 更新时间:2026年04月02日 09:55:41   作者:zhoupenghui168  
乐观锁和悲观锁是并发控制的一种机制,用于多线程或多进程环境下对共享资源的访问管理,以防止数据不一致或竞态条件,这篇文章主要介绍了Mysql数据库乐观锁与悲观锁的相关资料,需要的朋友可以参考下

乐观锁悲观锁是两种常见的并发控制机制,用于解决多用户同时操作同一数据时的一致性问题

一、悲观锁(Pessimistic Locking)

1. 原理

  • 假设:并发冲突很可能发生,因此在读取数据时就加锁,防止其他事务修改。
  • 适用于写操作频繁、冲突概率高的场景。

2. MySQL 中的实现

通过 SELECT ... FOR UPDATE 或 SELECT ... LOCK IN SHARE MODE(8.0 后推荐用 FOR SHARE)实现行级锁(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": "转账成功"})
}

✅ 优点:强一致性,避免脏读/丢失更新
❌ 缺点:性能差(锁等待)、易死锁、降低并发

二、乐观锁(Optimistic Locking)

1. 原理

  • 假设:并发冲突很少发生,因此不加锁,只在更新时检查数据是否被他人修改。
  • 通常通过 版本号(version)字段 或 时间戳 实现。

2. MySQL 中的实现

表结构需包含 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,说明数据已被他人修改,需重试或报错。

3. Gin + GORM 示例(乐观锁)

GORM 内置支持乐观锁(需使用 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
    }
    // 业务逻辑:扣减库存
    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": "数据库错误"})
        return
    }
    if result.RowsAffected == 0 {
        // 乐观锁失败:版本不匹配
        c.JSON(409, gin.H{"error": "库存已被其他请求修改,请重试"})
        return
    }
    c.JSON(200, gin.H{"msg": "扣减成功", "stock": product.Stock})
}

✅ 优点:高并发、无锁、性能好
❌ 缺点:冲突时需重试、不适合高频写冲突场景

三、乐观锁 vs 悲观锁 对比

特性悲观锁乐观锁
并发性能低(串行化)高(无锁)
一致性保障强(事务隔离)最终一致(需处理冲突)
适用场景写多读少、冲突频繁读多写少、冲突较少
实现复杂度简单(SQL 加锁)需版本字段 + 重试逻辑
死锁风险
典型应用银行转账、订单支付商品库存、点赞、评论计数

四、在 Gin 项目中的选型建议

场景推荐锁类型说明
转账、资金结算悲观锁强一致性要求高,不能出错
秒杀、抢购库存扣减乐观锁 + 重试 或 Redis 预减库存高并发下悲观锁性能差
用户资料编辑乐观锁冲突少,体验好
订单状态变更(如支付)悲观锁 或 状态机校验防止重复支付/状态错乱

💡 高并发场景(如秒杀)通常不直接依赖数据库锁,而是:

  • 使用 Redis 预减库存 + 队列异步落库
  • 结合 Lua 脚本保证原子性
  • 数据库仅做最终一致性校验

五、GORM 乐观锁注意事项

  • 字段名必须为 Version(类型 uint32 或 int
  • GORM 在 Save() 或 Update() 时自动添加 WHERE version = ? 并递增
  • 若使用 Updates(map),需手动包含 version 字段

六.总结

  • 悲观锁:适合强一致性、低并发写场景,用 FOR UPDATE + 事务。
  • 乐观锁:适合高并发、冲突少场景,用 version 字段 + 重试机制。
  • 在 Gin + GORM 项目中,根据业务特性选择合适方案,必要时结合缓存(Redis)提升性能。

实际项目中,混合使用也很常见:核心资金用悲观锁,普通业务用乐观锁。

七.Redis 预减库存 + 消息队列异步落库

在高并发场景(如秒杀、抢购)中,直接操作数据库扣减库存极易导致性能瓶颈、超卖甚至系统崩溃。因此,业界普遍采用 “Redis 预减库存 + 消息队列异步落库” 的架构来兼顾 高性能、一致性与可靠性

1、整体架构图

用户请求
    │
    ▼
[ Gin Web 服务 ] ←─┐
    │              │
    ▼              │
[ Redis 预减库存 ] │ ←─ 库存校验 & 原子扣减(Lua 脚本)
    │              │
    ▼              │
[ 发送消息到 MQ ] ─┘ → [ Kafka / RabbitMQ / RocketMQ ]
    │
    ▼
[ 异步消费服务 ]
    │
    ▼
[ MySQL 落库 ] ←─ 订单创建、库存最终扣减、记录日志
    │
    ▼
[ 返回结果给用户(可延迟)]

✅ 核心思想

  • 快速响应:Redis 操作毫秒级,用户几乎无等待
  • 削峰填谷:MQ 缓冲瞬时高并发
  • 最终一致:异步确保数据持久化

2、核心步骤详解

步骤 1:初始化库存到 Redis

  • 系统启动或活动开始前,将商品库存同步到 Redis。
  • 使用 String 类型 或 Hash 存储,如 stock:product:1001 = 100
// 初始化库存(管理后台或定时任务调用)
redisClient.Set(ctx, "stock:product:1001", 100, 0)

步骤 2:用户请求秒杀接口(Gin Handler)

  1. 参数校验(用户 ID、商品 ID)
  2. 防重放:检查是否已下单(可用 Redis Set user:1001:product:1001
  3. Lua 脚本原子扣减库存
    • 若库存 > 0,则 DECR 并返回成功
    • 否则返回“库存不足”
  4. 发送消息到 MQ(仅当 Redis 扣减成功)

⚠️ 关键:Redis 扣减必须是原子操作,防止超卖!

步骤 3:Lua 脚本实现原子预减库存

-- 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") // 假设已鉴权
    productID := c.Param("product_id")
    // 构造 Redis Key
    stockKey := fmt.Sprintf("stock:product:%s", productID)
    userProductKey := fmt.Sprintf("seckill:user:%s:product:%s", userID, productID)
    // 执行 Lua 脚本
    result, err := redisClient.Eval(
        ctx,
        luaScript,           // 上述 Lua 脚本内容
        []string{stockKey},
        userID,
    ).Result()
    if err != nil {
        c.JSON(500, gin.H{"error": "系统繁忙"})
        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
    }
    // 成功!发送消息到 MQ(异步落库)
    msg := SeckillMessage{
        UserID:    userID,
        ProductID: productID,
        Timestamp: time.Now(),
    }
    // 序列化并发送到 Kafka / RabbitMQ
    if err := mqProducer.Send("seckill_queue", msg); err != nil {
        // 注意:此处即使 MQ 发送失败,Redis 已扣减,需有补偿机制!
        log.Printf("MQ send failed: %v", err)
        // 可考虑回滚 Redis(复杂),或依赖后续对账
    }
    // 立即返回用户“抢购成功,请等待订单生成”
    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. 创建订单
        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("订单创建成功: %v", order.ID)
    }
}

🔒 兜底校验很重要!防止 Redis 与 MySQL 数据不一致(如 Redis 重启未同步)。

3、关键设计点与注意事项

问题解决方案
Redis 与 MySQL 数据不一致异步消费时做 MySQL 库存二次校验;定期对账补偿
MQ 消息丢失使用可靠消息(Kafka 副本、RabbitMQ 持久化 + ACK)
重复消费消费端幂等(如订单表加唯一索引 (user_id, product_id)
Redis 宕机高可用部署(Redis Cluster / Sentinel)
超卖Lua 脚本保证原子性 + MySQL 兜底校验
用户重复提交Redis 记录 user:product 防重键(带 TTL)

4、扩展:失败补偿与对账

  • 定时对账任务:每天对比 Redis 初始库存、Redis 当前库存、MySQL 已售数量,发现差异则告警或自动修复。
  • 死信队列(DLQ):处理多次失败的消息,人工介入。
  • 前端轮询/WebSocket:告知用户“订单已生成”,提升体验。

5、总结

✅ 优势

  • 高并发:Redis 承载 10w+ QPS
  • 防超卖:Lua 原子操作
  • 系统解耦:MQ 异步削峰
  • 最终一致:异步落库 + 兜底校验

❌ 复杂度

  • 需维护 Redis + MQ + 对账系统
  • 调试和监控难度增加

📌 适用场景:秒杀、抢购、限量发放等高并发、低转化率业务

到此这篇关于Mysql数据库乐观锁与悲观锁示例详解的文章就介绍到这了,更多相关Mysql乐观锁与悲观锁内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

相关文章

  • 利用SQL注入漏洞登录后台的实现方法

    利用SQL注入漏洞登录后台的实现方法

    工作需要,得好好补习下关于WEB安全方面的相关知识,故撰此文,权当总结,别无它意。读这篇文章,我假设读者有过写SQL语句的经历,或者能看得懂SQL语句
    2012-01-01
  • MySQL表的增删改查及查询表中数据的三种方式(入门必掌握)

    MySQL表的增删改查及查询表中数据的三种方式(入门必掌握)

    本文讲解SQL的DML(增删改查)和DQL(查询)操作,涵盖创建student表、数据插入、更新、删除及查询方法如条件筛选、排序、分页和别名使用,通过案例给大家介绍的非常详细,感兴趣的朋友跟随小编一起看看吧
    2025-07-07
  • MySQL实例精讲单行函数以及字符数学日期流程控制

    MySQL实例精讲单行函数以及字符数学日期流程控制

    SQL函数即数据库的内置函数,可以运用在SQL语句中实现特定的功能。SQL单行函数对于每一行数据进行计算后得到一行输出结果。SQL单行函数根据数据类型分为字符函数、数字函数、日期函数、转换函数,另外还有一些别的函数
    2021-10-10
  • Mysql数据库介绍及mysql显示命令

    Mysql数据库介绍及mysql显示命令

    这篇文章主要介绍了Mysql数据库介绍及mysql显示命令 的相关资料,需要的朋友可以参考下
    2016-04-04
  • 详解MySQL的慢查询日志和错误日志

    详解MySQL的慢查询日志和错误日志

    这篇文章主要详细介绍了MySQL的慢查询日志和错误日志,文中通过代码示例讲解的非常详细,对大家学习和了解MySQL的慢查询日志和错误日志有一定的帮助,需要的朋友可以参考下
    2024-04-04
  • MySQL 如何使用事务

    MySQL 如何使用事务

    这篇文章主要介绍了MySQL 如何使用事务,帮助大家更好的理解和学习MySQL数据库,感兴趣的朋友可以了解下
    2020-09-09
  • MySQL安装与配置:如何重置MySQL登录密码(windows环境)

    MySQL安装与配置:如何重置MySQL登录密码(windows环境)

    这篇文章主要介绍了MySQL安装与配置:如何重置MySQL登录密码(windows环境),具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教
    2023-12-12
  • MySQL中随机生成固定长度字符串的方法

    MySQL中随机生成固定长度字符串的方法

    在MySQL中有时需要随机生成数字或字符串,随机生产数字可直接使用rand()函数,但是要随机生成字符串就比较麻烦。
    2010-12-12
  • mysql8.0.23 msi安装超详细教程

    mysql8.0.23 msi安装超详细教程

    这篇文章主要介绍了mysql8.0.23 msi安装超详细教程,本文给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友可以参考下
    2021-03-03
  • MySQL 5.6 & 5.7最优配置文件模板(my.ini)

    MySQL 5.6 & 5.7最优配置文件模板(my.ini)

    这篇文章主要介绍了MySQL 5.6 & 5.7最优配置文件模板(my.ini),需要的朋友可以参考下
    2016-07-07

最新评论