乐观锁与悲观锁是两种常见的并发控制机制,用于解决多用户同时操作同一数据时的一致性问题
一、悲观锁(Pessimistic Locking)
1. 原理
- 假设:并发冲突很可能发生,因此在读取数据时就加锁,防止其他事务修改。
- 适用于写操作频繁、冲突概率高的场景。
MySQL 乐观锁与悲观锁是解决并发数据一致性的核心机制。悲观锁通过 FOR UPDATE 加锁保证强一致性但性能较低,适用于转账等高频冲突场景;乐观锁利用版本号在更新时校验,适合读多写少的高并发场景。文章结合 Gin + GORM 提供了具体代码实现,并对比了两者在性能、死锁风险等方面的差异。针对秒杀等高并发场景,建议采用 Redis 预减库存配合 Lua 脚本原子操作,再通过消息队列异步落库至 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;
⚠️ 必须在事务中使用,否则锁会立即释放。
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": "转账成功"})
}
✅ 优点:强一致性,避免脏读/丢失更新 ❌ 缺点:性能差(锁等待)、易死锁、降低并发
表结构需包含
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,说明数据已被他人修改,需重试或报错。
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})
}
✅ 优点:高并发、无锁、性能好 ❌ 缺点:冲突时需重试、不适合高频写冲突场景
| 特性 | 悲观锁 | 乐观锁 |
|---|---|---|
| 并发性能 | 低(串行化) | 高(无锁) |
| 一致性保障 | 强(事务隔离) | 最终一致(需处理冲突) |
| 适用场景 | 写多读少、冲突频繁 | 读多写少、冲突较少 |
| 实现复杂度 | 简单(SQL 加锁) | 需版本字段 + 重试逻辑 |
| 死锁风险 | 有 | 无 |
| 典型应用 | 银行转账、订单支付 | 商品库存、点赞、评论计数 |
| 场景 | 推荐锁类型 | 说明 |
|---|---|---|
| 转账、资金结算 | 悲观锁 | 强一致性要求高,不能出错 |
| 秒杀、抢购库存扣减 | 乐观锁 + 重试 或 Redis 预减库存 | 高并发下悲观锁性能差 |
| 用户资料编辑 | 乐观锁 | 冲突少,体验好 |
| 订单状态变更(如支付) | 悲观锁 或 状态机校验 | 防止重复支付/状态错乱 |
💡 高并发场景(如秒杀)通常不直接依赖数据库锁,而是:使用 Redis 预减库存 + 队列异步落库结合 Lua 脚本保证原子性数据库仅做最终一致性校验
Version(类型 uint32 或 int)Save() 或 Update() 时自动添加 WHERE version = ? 并递增Updates(map),需手动包含 version 字段FOR UPDATE + 事务。version 字段 + 重试机制。实际项目中,混合使用也很常见:核心资金用悲观锁,普通业务用乐观锁。
在高并发场景(如秒杀、抢购)中,直接操作数据库扣减库存极易导致性能瓶颈、超卖甚至系统崩溃。因此,业界普遍采用 'Redis 预减库存 + 消息队列异步落库' 的架构来兼顾 高性能、一致性与可靠性
用户请求 │ ▼ [ Gin Web 服务 ] ←─┐ │ │ ▼ │ [ Redis 预减库存 ] │ ←─ 库存校验 & 原子扣减(Lua 脚本) │ │ ▼ │ [ 发送消息到 MQ ] ─┘ → [ Kafka / RabbitMQ / RocketMQ ] │ ▼ [ 异步消费服务 ] │ ▼ [ MySQL 落库 ] ←─ 订单创建、库存最终扣减、记录日志 │ ▼ [ 返回结果给用户(可延迟)]
✅ 核心思想:快速响应:Redis 操作毫秒级,用户几乎无等待削峰填谷:MQ 缓冲瞬时高并发最终一致:异步确保数据持久化
stock:product:1001 = 100// 初始化库存(管理后台或定时任务调用)
redisClient.Set(ctx, "stock:product:1001", 100, 0)
user:1001:product:1001)DECR 并返回成功⚠️ 关键:Redis 扣减必须是原子操作,防止超卖!
-- 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:剩余库存,表示成功
// 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",
})
}
// 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 重启未同步)。
| 问题 | 解决方案 |
|---|---|
| Redis 与 MySQL 数据不一致 | 异步消费时做 MySQL 库存二次校验;定期对账补偿 |
| MQ 消息丢失 | 使用可靠消息(Kafka 副本、RabbitMQ 持久化 + ACK) |
| 重复消费 | 消费端幂等(如订单表加唯一索引 (user_id, product_id)) |
| Redis 宕机 | 高可用部署(Redis Cluster / Sentinel) |
| 超卖 | Lua 脚本保证原子性 + MySQL 兜底校验 |
| 用户重复提交 | Redis 记录 user:product 防重键(带 TTL) |
✅ 优势:
❌ 复杂度:
📌 适用场景:秒杀、抢购、限量发放等高并发、低转化率业务

微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online