Mysql数据库乐观锁与悲观锁示例详解
作者:zhoupenghui168
乐观锁与悲观锁是两种常见的并发控制机制,用于解决多用户同时操作同一数据时的一致性问题
一、悲观锁(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)
- 参数校验(用户 ID、商品 ID)
- 防重放:检查是否已下单(可用 Redis Set
user:1001:product:1001) - Lua 脚本原子扣减库存
- 若库存 > 0,则
DECR并返回成功 - 否则返回“库存不足”
- 若库存 > 0,则
- 发送消息到 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乐观锁与悲观锁内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!
