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

来源:这里教程网 时间:2026-04-02 16:16:30 作者:
一、悲观锁(Pessimistic Locking)1. 原理2. MySQL 中的实现3. Gin + GORM 示例(悲观锁)二、乐观锁(Optimistic Locking)1. 原理2. MySQL 中的实现3. Gin + GORM 示例(乐观锁)三、乐观锁 vs 悲观锁 对比四、在 Gin 项目中的选型建议五、GORM 乐观锁注意事项六.总结七.Redis 预减库存 + 消息队列异步落库1、整体架构图2、核心步骤详解步骤 1:初始化库存到 Redis步骤 2:用户请求秒杀接口(Gin Handler)步骤 3:Lua 脚本实现原子预减库存步骤 4:Gin 处理秒杀请求(Go 代码)步骤 5:异步消费服务(Worker)3、关键设计点与注意事项4、扩展:失败补偿与对账5、总结

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

一、悲观锁(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 或 intGORM 在 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)

    参数校验(用户 ID、商品 ID)防重放:检查是否已下单(可用 Redis Set user:1001:product:1001Lua 脚本原子扣减库存若库存 > 0,则 DECR 并返回成功否则返回“库存不足”发送消息到 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数据库乐观锁与悲观锁示例详解的文章就介绍到这了,

相关推荐

热文推荐