一、quota > 0 确保并发安全的原因解析
这个问题涉及了数据库层面的原子性+行锁+条件更新三重保护机制。核心代码如下:
func (s *UserStore) DecrementQuota(userID int64) (int64, error) {
result := s.db.Exec("UPDATE user SET quota = quota - 1 WHERE id = ? AND quota > 0", userID)
return result.RowsAffected, result.Error
}
1. 第一重保障:UPDATE 语句的原子性
在 MySQL InnoDB 引擎中,单条 UPDATE 语句是原子的——读取旧值->计算新值->写回数据这套操作要么全部成功,要么全部失败。这就意味着 SET quota = quota – 1 的读取和写入是在同一个原子操作中完成的:
时间线:
┌──────────────────────────────────────────────┐
│ 数据库内部: │
│ 1. 锁住该行 WHERE id = ? │
│ 2. 读取当前 quota 值 │
│ 3. 检查 quota > 0 │
│ 4. 执行 quota = quota - 1 │
│ 5. 解锁该行 │
│ 以上步骤不可分割 │
└──────────────────────────────────────────────┘
2. 第二重保障:WHERE 条件作为乐观锁
乐观锁认为不会出现竞争,所有的请求都可以执行,所以不会主动加锁。同时 WHERE quota > 0 将“检查配额”和“扣减配额”的流程收纳在同一个原子操作中,以返回的影响行数为判断基准查看是否存在冲突。
3. 第三重保障:行锁保证串行化
当多个并发请求同时执行同一条 UPDATE 语句时,InnoDB 内部会自动对该行加排他锁。流程如下:
场景:quota = 1,两个请求同时到达
Request A: UPDATE user SET quota = quota - 1 WHERE id = 1 AND quota > 0
Request B: UPDATE user SET quota = quota - 1 WHERE id = 1 AND quota > 0
时间顺序:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
T1: Request A 获取行锁 ✓
T2: Request B 等待行锁...(阻塞)
T3: Request A 检查 quota(1) > 0 → true → 执行 quota = 0,RowsAffected = 1
T4: Request A 释放行锁
T5: Request B 获取行锁 ✓
T6: Request B 检查 quota(0) > 0 → false → 不更新,RowsAffected = 0
T7: Request B 释放行锁
结果:A 成功,B 返回 0(配额不足)✅ 无超扣!
返回的结果 RowsAffected 是受到影响的行数,调用方可以根据该返回值判断是否执行成功。当 RowsAffected = 1 时说明配额充足,扣减成功;当 RowsAffected = 0 时说明配额不足,未执行扣减操作。
总结如下:
| 机制 | 作用 |
| 单条 UPDATE 原子性 | 读+写不可分割 |
| WHERE quota > 0 | 条件更新,检查和修改在同一原子步骤中执行 |
| InnoDB 行锁 | 多个并发请求对同一行 UPDATE 语句的串行化处理 |
| RowsAffected | 向调用方反馈实际调用结果 |
这是一个非常经典且高效的 “Compare And Swap” (CAS)模式在关系型数据库中的实现,无需显式使用 SELECT … FOR UPDATE 或事务就能保证并发安全。

二、显式采用 SELECT … FOR UPDATE 如何实现
SELECT … FOR UPDATE 采用的悲观锁思想,认为一定会出现冲突,所以一开始就锁定行,再检查和修改。实现方式如下:
// DecrementQuotaWithPessimisticLock 使用 SELECT...FOR UPDATE 实现悲观锁扣减配额
func (s *UserStore) DecrementQuotaWithPessimisticLock(userID int64) error {
// 开启事务
tx := s.db.Begin()
var user model.User
// 1. 查询并加排他锁(FOR UPDATE),其他事务的读写操作都会阻塞等待
err := tx.Scopes(NotDeleted).
Where("id = ?", userID).
First(&user).Error
if err != nil {
tx.Rollback()
return err
}
// 2. 在持有行锁的情况下检查配额
if user.Quota <= 0 {
tx.Rollback()
// 返回自定义错误表示配额不足
return errors.New("insufficient quota")
}
// 3. 执行扣减(此时仍持有行锁)
err = tx.Model(&model.User{}).
Where("id = ?", userID).
Update("quota", gorm.Expr("quota - 1")).Error
if err != nil {
tx.Rollback()
return err
}
// 4. 提交事务(释放锁)
return tx.Commit().Error
}
可以看到本来乐观锁一条语句就可以完成的逻辑,悲观锁就需要事务+分步多操作实现,业务逻辑显得更复杂。
三、什么时候需要用到 SELECT … FOR UPDATE ?
当业务逻辑无法依靠单个 WHERE 表达时,悲观锁就可以使用了。可以看到本例方法中只需要 quota > 0 这一个条件判断需求,那么如果需要检查多个条件,同时还有其它业务逻辑多步执行,该如何处理呢?
例子如下:
// 示例:复杂的扣减逻辑——需要同时检查多个条件、记录日志等
func (s *UserStore) ComplexDeduct(userID int64, amount int) error {
tx := s.db.Begin()
var user model.User
tx.Where("id = ?", userID).First(&user) // FOR UPDATE
// 复杂的业务判断
if user.Quota < amount {
return errors.New("配额不足")
}
if user.UserRole == "vip" && amount > 100 {
return errors.New("VIP 用户单次上限 100")
}
if time.Since(user.VIPTime) > 30*24*time.Hour {
return errors.New("VIP 已过期")
}
// 多步操作
tx.Model(&user).Update("quota", user.Quota - amount)
tx.Create(&QuotaLog{UserID: userID, Amount: -amount})
tx.Create(&UsageRecord{UserID: userID, Action: "deduct"})
return tx.Commit().Error
}
在这个例子中,判断不再是 quota > 0 这种简单的一次判断,而是结合 amount 参数涉及 3 个条件判断,并且还包含更新、创建多步操作,使得业务更复杂,这时就需要悲观锁+事务的协作处理。

四、乐观锁和悲观锁的对比
┌─────────────────────────────────────────────────────────────────────┐
│ 乐观锁(Optimistic Locking) │
│ │
│ "我相信冲突不会发生,直接操作;提交时再检查是否有冲突" │
│ │
│ ┌──────┐ ┌──────┐ │
│ │ 请求A │ ──并行─→│ 直接 │ → 提交时检查 → ✅ 成功 │
│ └──────┘ │ 操作 │ │
│ └──────┘ │
│ ┌──────┐ ┌──────┐ │
│ │ 请求B │ ──并行─→│ 直接 │ → 提交时检查 → ❌ 冲突/重试 │
│ └──────┘ │ 操作 │ │
│ └──────┘ │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ 悲观锁(Pessimistic Locking) │
│ │
│ "我相信冲突一定会发生,所以我先锁住资源,用完再释放" │
│ │
│ ┌──────┐ 加锁 ┌──────┐ 解锁 ┌──────┐ │
│ │ 请求A │ ──────────→ │ 持有 │ ──────────→ │ 释放 │ │
│ └──────┘ │ 资源 │ └──────┘ │
│ └──────┘ │
│ ┌──────┐ ┌──────┐ │
│ │ 请求B │ ───── 等待...阻塞...等待解锁 ─────────→ │ 获取 │ │
│ └──────┘ └──────┘ │
└─────────────────────────────────────────────────────────────────────┘
本次例子在高并发场景下执行过程如下:
═══ 乐观锁(WHERE 条件更新)═══
Request 1: UPDATE ... WHERE quota>0 → quota=0, RowsAffected=1 ✅
Request 2: UPDATE ... WHERE quota>0 → 不满足条件, RowsAffected=0 ❌
Request 3: UPDATE ... WHERE quota>0 → 不满足条件, RowsAffected=0 ❌
...
Request 10: UPDATE ... WHERE quota>0 → 不满足条件, RowsAffected=0 ❌
特点:
✅ 所有请求几乎同时返回,无等待
✅ 总耗时 ≈ 一次 SQL 执行时间
❌ 只有 1 个成功,9 个立即失败
═══ 悲观锁(SELECT FOR UPDATE)═══
时间线 →
T1: Request 1: SELECT FOR UPDATE → 获取行锁 ✓ [R2~R10 排队中 ⏳]
T2: Request 1: 判断 quota(1)>0 → true
T3: Request 1: UPDATE quota=0
T4: Request 1: COMMIT → 释放锁
T5: Request 2: SELECT FOR UPDATE → 获取行锁 ✓ [R3~R10 继续等待 ⏳]
T6: Request 2: 判断 quota(0)>0 → false
T7: Request 2: ROLLBACK → 释放锁
T8: Request 3: SELECT FOR UPDATE → 获取行锁 ✓ [R4~R10 继续等待 ⏳]
...(依次类推,串行执行)
特点:
✅ 严格按序执行,逻辑清晰
❌ 请求 2~10 都要经历"获取锁→判断→失败"的流程
❌ 总耗时 ≈ 10 × 单次事务时间(远高于乐观锁)
⚠️:虽然乐观锁的思想是不加锁,好像所有请求同时执行,但是实际上 InnoDB 依旧采用行锁(排他锁)的形式将并发请求串行化,只不过执行速度非常快,比悲观锁拿锁释放锁这些额外开销小得多,造成了一种并行执行请求的错觉。
这里还想在延伸一下关于乐观锁的使用。正如前文提到,乐观锁适用查询条件简单且很少的情况。本次例子的乐观锁是 quota > 0 。除此之外,还可以使用版本号或者时间戳的方式实现乐观锁,举例如下:
// ═══ 方式一:条件更新(本项目使用的方式)════
UPDATE user SET quota = quota - 1
WHERE id = ? AND quota > 0
// ═══ 方式二:版本号(Version)════
UPDATE user SET quota = quota - 1, version = version + 1
WHERE id = ? AND version = ? // 用 version 字段做 CAS
// ═══ 方式三:时间戳校验═══
UPDATE user SET quota = quota - 1
WHERE id = ? AND update_time = '2026-04-24 10:00:00' // 用 update_time 字段做 CAS

No responses yet