库存扣减的“锁”事:悲观锁、乐观锁、分布式锁

概述

从秒杀高并发切入,按锁的作用范围梳理单机 Go 锁、MySQL 行锁/版本锁、Redis/ZK/etcd 分布式锁;说明进程内锁跨机无效、数据库锁易阻塞、分布式锁解决跨节点互斥,并给出分层选型思路。

并发请求下的锁

对库存扣减的高并发问题,分别从 Api 接口、Mysql、分布式锁 3 个维度来阐述和解决问题。 在秒杀、限时抢购这类业务场景中,库存扣减是典型的热点写操作,短时间会涌入大量并发请求。如果处理不当,极易出现超卖(库存负数)、少卖、数据不一致、数据库压力突增、接口雪崩等线上故障。

一、单机Go语言:互斥锁、读写锁

单体服务或者并发较小的时候可以在代码内使用加锁的方式进行加锁,Go 的锁属于进程内锁 ,只保护当前 Go 程序内部的 goroutine,跨进程、跨机器无效。 实现原理就是当客户端发起一个http请求时,GMP调度模型对请求进行处理,先拿一把锁锁住,按照锁的顺序处理请求,下面是实现的代码实例。 1、sync.Mutex 互斥锁: 同一时间只允许一个 goroutine 获取锁,读写互斥,适合写多、读写比例均衡场景。

go 复制代码
package main
​
import (
    "fmt"
    "sync"
)
​
var (
    balance int
    mu      sync.Mutex
    wg      sync.WaitGroup
)
​
func add() {
    defer wg.Done()
    mu.Lock()
    defer mu.Unlock()
    balance += 1
}
​
func main() {
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go add()
    }
    wg.Wait()
    fmt.Println(balance)
}

注意:defer Unlock() 防止异常死锁;禁止在锁内做 IO、慢查询,会阻塞所有 goroutine。

2、sync.RWMutex 读写锁: 读共享、写互斥:多个读 goroutine 可以同时拿到读锁;写锁排他。 适合读多写少场景(配置读取、缓存查询)

go 复制代码
var rwMu sync.RWMutex
​
// 读
func read() {
    rwMu.RLock()
    defer rwMu.RUnlock()
    fmt.Println(balance)
}
​
// 写
func write() {
    rwMu.Lock()
    defer rwMu.Unlock()
    balance++
}

补充:原子操作 sync/atomic,针对简单数值加减,比锁更快,但仅支持基础类型,复杂逻辑无法使用。

二、MySQL 层:利用数据库原生能力,保证扣减原子性

MySQL 是最终数据落地的载体,优先用好数据库自身机制,很多场景不需要额外引入分布式锁 就能解决超卖。 1、悲观锁(select ... for update)

sql 复制代码
begin;
select stock from goods where id = goodsId for update;
update goods set stock = stock -1 where id = goodsId;
commit;

适合少量并发、强一致性场景;缺点是锁等待时间长,高并发下大量阻塞,容易超时、连接打满,秒杀场景不推荐优先使用。

坑点:for update Where条件的列中如果不加索引会升级为表锁,整表锁住,直接导致服务不可用。

悲观锁 在PHP语言中的实现(thinkphp6.0代码实例):

php 复制代码
<?php
namespace app\controller;

use think\facade\Db;

class LockDemo
{
    // 悲观锁:扣库存 FOR UPDATE
    public function pessimistic()
    {
        Db::startTrans();
        try {
            // forUpdate() 等价 SELECT ... FOR UPDATE
            $goods = Db::table('goods')
                ->where('id', 1)
                ->forUpdate()
                ->find();

            if (!$goods || $goods['stock'] <= 0) {
                throw new \Exception('库存不足');
            }

            // 扣减库存
            Db::table('goods')
                ->where('id', 1)
                ->update([
                    'stock' => $goods['stock'] - 1
                ]);

            Db::commit();
            return '扣减成功';
        } catch (\Exception $e) {
            Db::rollback();
            return '失败:' . $e->getMessage();
        }
    }

    // 共享锁 LOCK IN SHARE MODE (forShare)
    public function shareLock()
    {
        Db::startTrans();
        try {
            $goods = Db::table('goods')
                ->where('id',1)
                ->forShare()
                ->find();
            // 共享锁:其他事务可以读,不能改,本事务提交后释放锁
            Db::commit();
            return $goods;
        }catch (\Exception $e){
            Db::rollback();
            return $e->getMessage();
        }
    }
}

Go语言中悲观锁的实现( gorm )

go 复制代码
// PessimisticLock 悲观锁扣库存
import (
	"fmt"
	"gorm.io/gorm"
	"gorm.io/gorm/clause"
)

func PessimisticLock(db *gorm.DB) error {
	tx := db.Begin()
	if tx.Error != nil {
		return tx.Error
	}

	var g Goods
	// Clauses(clause.Locking{Strength:"UPDATE"}) => SELECT ... FOR UPDATE
	if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&g, 1).Error; err != nil {
		tx.Rollback()
		return err
	}

	if g.Stock <= 0 {
		tx.Rollback()
		return fmt.Errorf("库存不足")
	}

	g.Stock -= 1
	if err := tx.Save(&g).Error; err != nil {
		tx.Rollback()
		return err
	}
	return tx.Commit().Error
}

共享锁(S 锁 / LOCK IN SHARE MODE): 先读取数据,后续要依赖这个数据做校验,不希望中途被别人改掉,但允许别人同时读 排他锁(X 锁 / FOR UPDATE): 查询之后马上就要修改这条记录,防止并发修改,适用场景:库存扣减、订单状态变更,防止超卖,这就是我们上一段代码里扣库存用的。

2、乐观锁(版本号机制)

增加 version 版本字段,更新时携带版本校验:

sql 复制代码
update goods set stock=stock-1, version=version+1 
where id=#{goodsId} and stock>0 and version=#{oldVersion};

不加行锁,并发冲突时更新失败,业务捕获重试;适合读多写少场景。缺点:并发极高时大量请求不断重试,CPU 压力大,容易大量失败,用户体验差。 给 stock 字段设置无符号 unsigned,从数据库底层禁止库存为负数,作为最后一道防线,防止代码 bug 导致负数库存。 thinkphp6.0代码实例:

php 复制代码
<?php
namespace app\controller;

use app\model\Goods;
use think\facade\Db;

class LockDemo
{
    /**
     * 乐观锁扣库存 + 自动重试
     * @param int $id 商品ID
     * @param int $retryMax 最大重试次数
     * @param int $sleepMs 重试间隔毫秒
     * @return string
     * @throws \Exception
     */
    public function optimisticWithRetry(int $id = 1, int $retryMax = 3, int $sleepMs = 50)
    {
        $retryCount = 0;

        while ($retryCount < $retryMax) {
            Db::startTrans();
            try {
                /** @var Goods $goods */
                $goods = Goods::find($id);
                if (!$goods) {
                    throw new \Exception("商品不存在");
                }
                if ($goods->stock <= 0) {
                    throw new \Exception("库存不足");
                }

                $goods->stock -= 1;
                $ret = $goods->save();

                if ($ret) {
                    Db::commit();
                    return "成功,重试次数:{$retryCount}";
                }

                // 走到这里:影响行数0 → 乐观锁冲突,本次事务回滚,进入下一次循环重试
                Db::rollback();
                $retryCount++;
                usleep($sleepMs * 1000);
                continue;

            } catch (\Exception $e) {
                Db::rollback();
                throw $e;
            }
        }

        // 超过最大重试,失败
        throw new \Exception("乐观锁多次抢占失败,已重试{$retryMax}次,请稍后再试");
    }
}

Go语言中乐观锁的实现 ( gorm )

go 复制代码
package main

import (
	"errors"
	"fmt"
	"time"

	"gorm.io/gorm"
)

type Goods struct {
	ID      uint   `gorm:"primaryKey"`
	Name    string `gorm:"size:100"`
	Stock   int
	Version int // gorm内置乐观锁,字段名固定Version
}

// OptimisticLockWithRetry 乐观锁扣库存,带重试
func OptimisticLockWithRetry(db *gorm.DB, id uint, maxRetry int, sleepMs time.Duration) error {
	retryCnt := 0
	for retryCnt < maxRetry {
		tx := db.Begin()
		if tx.Error != nil {
			return tx.Error
		}

		var g Goods
		if err := tx.First(&g, id).Error; err != nil {
			tx.Rollback()
			return fmt.Errorf("查询商品失败: %w", err)
		}

		if g.Stock <= 0 {
			tx.Rollback()
			return errors.New("库存不足")
		}

		g.Stock -= 1
		err := tx.Save(&g).Error
		if err == nil {
			// 更新成功,提交
			return tx.Commit().Error
		}

		// 判断:是乐观锁冲突才重试,其他错误直接返回
		tx.Rollback()
		if errors.Is(err, gorm.ErrRecordNotFound) {
			retryCnt++
			time.Sleep(sleepMs * time.Millisecond)
			continue
		}
		// 别的异常,不重试
		return fmt.Errorf("更新失败: %w", err)
	}

	return fmt.Errorf("乐观锁冲突,重试%d次全部失败", maxRetry)
}

二、分布式锁

redis 分布式锁

如果你的服务是分布式、微服务,服务中部署了多台节点,怎么处理并发的问题,这个时候分布式锁就登场了。 分布式锁是基于 Redis 实现分布式锁的库,底层不是简单 SETNX,很多人容易混淆。 原生 SETNX 的巨大缺陷(也是为什么redis分布式锁 不用裸 SETNX):

  • SETNX 和 EXPIRE 是两条独立命令,不是原子。SETNX 成功之后、还没执行 expire,服务宕机 → key 永久存在,死锁。
  • 释放锁直接 DEL 也不安全:A 的锁超时了,B 拿到锁,A 执行完直接 DEL,把 B 的锁删掉。-

防止死锁:(致命死锁风险)

  • 先 SETNX lock 1,再 EXPIRE lock 30;如果 SETNX 成功之后、EXPIRE 之前进程宕机 / 网络断了 → key 永久存在,死锁。

SETNX 无法区分锁持有者,容易误删别人的锁

  • 直接 DEL lock 释放锁:业务超时锁自动过期、别的客户端拿到锁之后,原客户端跑完直接 DEL,删掉别人的锁。安全释放必须:判断 value(唯一 token)匹配才删,Lua 脚本原子校验 + 删除,裸 SETNX 没有这个能力。

单机 SETNX 锁,主从切换会丢锁

  • 单机 / 主从架构下:master 拿到锁,还没同步到 slave,master 宕机、slave 升主 → 锁消失,其他客户端可以再次抢到锁,锁失效,这是红锁 RedLock 要解决的核心问题。 Redsync 是 RedLock 的 Go 实现:向多个独立 Redis 主节点并行加锁,超过半数成功才算拿到锁,不是依赖单节点 SETNXRedis。

补充说明 现代单机安全锁:SET uuid NX PX ttl(底层还是 NX 语义,但不是单独 SETNX 命令) Redsync(RedLock):多个独立节点,每个节点内部用 SET ... NX PX,再做多数派判断,不是直接调用 SETNX

redsync(Go)完整示例代码:

go 复制代码
package main
​
import (
    "context"
    "fmt"
    "time"
​
    goredislib "github.com/redis/go-redis/v9"
    "github.com/go-redsync/redsync/v4"
    "github.com/go-redsync/redsync/v4/redis/goredis/v9"
)
​
func main() {
    // ========== 1. 构造多个独立Redis节点(红锁要求独立master,不要主从) ==========
    // 示例这里写3个节点,生产一般5个独立节点
    redisAddrs := []string{
        "127.0.0.1:6379",
        "127.0.0.1:6380",
        "127.0.0.1:6381",
    }
​
    var pools []redsync.Pool
    for _, addr := range redisAddrs {
        client := goredislib.NewClient(&goredislib.Options{
            Addr: addr,
        })
        pool := goredis.NewPool(client)
        pools = append(pools, pool)
    }
​
    // ========== 2. 创建redsync实例 ==========
    rs := redsync.New(pools...)
​
    // ========== 3. 创建互斥锁 ==========
    mutexName := "order:lock:10001"
    mutex := rs.NewMutex(
        mutexName,
        redsync.WithExpiry(8*time.Second), &nbsp; &nbsp;// 锁有效期
        redsync.WithTries(6), &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; // 抢锁重试次数
        redsync.WithRetryDelay(300*time.Millisecond), // 重试间隔
    )
​
    ctx := context.Background()
​
    // ========== 4. 加锁 ==========
    if err := mutex.LockContext(ctx); err != nil {
        fmt.Printf("获取分布式锁失败: %v\n", err)
        return
    }
    fmt.Println("✅ 获取红锁成功")
​
    // 业务临界区
    func() {
        defer func() {
            // ========== 5. 释放锁 ==========
            if ok, err := mutex.UnlockContext(ctx); !ok || err != nil {
                fmt.Printf("释放锁失败: ok=%v, err=%v\n", ok, err)
            } else {
                fmt.Println("✅ 锁释放成功")
            }
        }()
​
        fmt.Println("执行业务逻辑......")
        time.Sleep(3 * time.Second)
    }()
}

关键说明

  1. Redsync 不是集群锁,要求多个完全独立、无主从关系的 Redis Master;
  2. 加锁逻辑:并发向所有节点执行 SET random-token NX PX ttl;超过半数成功,并且总耗时 < 锁过期时间 → 加锁成功;
  3. 解锁:向全部节点执行 Lua 脚本,校验 token 一致才删除;
  4. 注意:RedLock 有理论争议(时钟漂移、GC 停顿问题),适合你清楚它的取舍再上生产,不是万能锁。

Zookeeper 分布式锁

ZK 的锁是 CP 模型(强一致) ,和 Redis 锁(AP)底层思路完全不一样,这也是它不容易出现 "锁丢失" 的根本原因。Redis 锁靠 SET NX + TTL(在内存写标记,依赖超时);ZK 锁靠 临时有序节点 + Watch 监听(ZAB 协议保证一致性,靠会话失效释放) 1、ZK 核心基础概念 ZAB 一致性协议:写操作过半节点成功才返回成功,保证所有节点最终一致; 1.临时节点(Ephemeral):

  • 和客户端会话绑定,不是手动删除;
  • 客户端断开、进程宕机、网络断连 → ZK 自动删除临时节点;
  • 天然解决:宕机不释放锁这个 Redis 很头疼的问题,不需要 TTL 超时兜底;

2.有序节点(Sequential) :创建节点时 ZK 自动在后缀追加递增序号,比如 lock-0000000001、lock-0000000002; 3.Watcher(事件监听):订阅某个节点,节点删除 / 变更时服务端主动推送事件给客户端。

注意:ZK 没有 "自动过期时间" 给锁,释放是会话断开自动删临时节点。


2、主流实现:有序临时节点锁 路径约定:所有抢锁的客户端都在同一个父节点下创建临时有序节点,比如 /dist_lock/order_lock 作为父目录。 抢锁流程客户端 A、B、C 同时连接 ZK,在 /dist_lock/order_lock下创建 EPHEMERAL_SEQUENTIAL(临时有序) 节点:

  • A:/dist_lock/order_lock/lock-0000000001
  • B:/dist_lock/order_lock/lock-0000000002
  • C:/dist_lock/order_lock/lock-0000000003
  1. 创建成功后,列出父目录下所有子节点,从小到大排序;
  2. 判断: 自己的节点是不是序号最小的
    • 是最小 → 获取锁成功,执行业务;
    • 不是最小 → 找到排在自己前面那一个节点,给它注册 Watcher;
  3. 等待:B 监听 lock-0000000001;C 监听 lock-0000000002;
  4. 释放锁:
    • 正常释放:客户端业务跑完,主动删除自己的节点;
    • 异常宕机:会话断开,ZK 自动删除临时节点;
  5. 节点被删除 → Watch 触发通知后面等待的客户端;被唤醒的客户端重新拉取列表判断自己是不是最小,循环这个逻辑。

这是公平锁:按创建顺序排队,先来先得,不会惊群效应(每个客户端只监听前一个节点,不是监听全部)。


3、ZK 锁的坑

  1. 会话超时自动丢锁 Go 程序长时间 GC 停顿、网络抖动,心跳发不出去 → session 过期 → 临时节点删掉 → 锁被释放,业务还在跑,出现并发。 对策:session timeout 设置合理(一般 10~30s);长耗时业务要拆分,不要锁内做超长阻塞逻辑。
  2. ZK 集群本身不可用 ZK 必须过半节点存活才能提供服务;集群分裂(脑裂)、过半宕机 → 锁服务直接不可用,不是 "还能继续跑只是不安全",是直接拿不到锁。 Redis 单主从是 AP,主挂了哨兵还能切,服务可用但锁不安全;ZK 是 CP,优先保证一致,不可用就拒绝服务。
  3. 性能瓶颈 ZK 不适合超高并发、短锁高频争抢场景(秒杀),写请求要过半确认,延迟比 Redis 高很多;适合:订单状态、审批、资源抢占、定时任务互斥这类低争抢、强一致场景。
  4. 不要把大业务逻辑放在锁里面 锁持有时间越长,会话超时风险越高。

etcd分布式锁

etcd 分布式锁是 CP 强一致性锁 ,基于 Raft 协议,Go 云原生生态首选,etcd/client/v3/concurrency.Mutex 是官方标准实现,K8s 内部大量在用。 1、etcd 依赖的底层能力 Raft 一致性协议 写请求必须集群过半节点成功才返回 OK,保证所有节点数据一致;集群脑裂、不足半数存活 → 拒绝写入(优先一致性,牺牲可用性)。 Lease 租约(核心,对标 ZK 的 Session)

  • 租约有 TTL,客户端持续发 KeepAlive 续约;
  • 客户端宕机、网络断、进程卡住无法续约 → 租约到期,etcd 自动删除绑定这个租约的所有 key;
  • 和 ZK session 非常像,但模型不一样:ZK 是会话绑定临时节点;etcd 是 key 绑定 Lease。
  • Lease 是依附于 key,不是锁本身自带;一个租约可以绑定多个 key。

Revision(全局版本号) etcd 每一次修改(put/delete)都会生成一个全局单调递增的 revision,整个集群唯一有序,这是实现排队公平锁的关键。 Watch 监听 监听 key 前缀,key 删除 / 变更时 etcd 主动推送事件,避免轮询。 Compare-and-Swap(事务 Txn) etcd 原生支持原子事务,多条件比较 + 执行,保证抢锁逻辑原子化,无竞态。


concurrency.Mutex 官方锁实现原理: 包路径:go.etcd.io/etcd/client/v3/concurrency 底层是 前缀 key + revision 排序 + Watch 前驱 key,公平锁,和 Curator InterProcessMutex(ZK 公平锁)思路高度相似。

抢锁流程 假设锁前缀:/dist-lock/order-lock/

  1. 客户端先创建一个 Lease(设置 TTL,比如 10s),启动后台 KeepAlive 持续续约;
  2. 用事务 Txn 在锁前缀下 put 一个 key: key:/dist-lock/order-lock/<uuid>,value 随便,绑定上面的 Lease; put 成功后拿到这个 key 对应的 revision;
  3. 列出 /dist-lock/order-lock/ 下所有子 key,按 revision 从小到大排序;
  4. 判断: 自己的 key 是不是 revision 最小的
    • ✅ 是最小:拿到锁,执行业务;
    • ❌ 不是最小:找到revision 比自己小的、离自己最近的前驱 key,给这个前驱 key 注册 Watch;
  5. 阻塞等待 Watch 事件:前驱 key 被删除(锁释放),唤醒当前客户端;
  6. 唤醒后,重新拉取列表判断自己是不是最小,循环判断。

释放锁两种场景

  1. 主动释放(正常) :调用 mutex.Unlock() → 删除自己的锁 key;删除后 Watch 通知后继等待节点。
  2. 异常释放(宕机 / 断网):客户端无法续约 Lease → Lease 过期 → etcd 自动删除锁 key,锁释放。

重点:不是等 Lease 到期才释放锁,正常解锁是直接删 key,租约提前回收

分布式锁方案对比

项目 etcdconcurrency.Mutex ZKCuratorInterProcessMutex Redis锁/Redsync
一致性 CP(Raft) CP(ZAB) AP
释放机制 Lease 租约,后台续约 Session 会话心跳 TTL + 看门狗续期
公平锁 ✅ 原生支持(前驱 watch) ✅ 原生支持 ❌ 原生不支持
时钟依赖 不依赖 不依赖 RedLock 强依赖时钟
惊群 无 无 容易有
误删防护 自带 revision 校验 节点归属天然隔离 需要 lua 脚本校验随机 value
性能 中等 中等 高
生态 Go / K8s Java 传统中间件 全语言
风险点 续约中断锁丢失 session 超时锁丢失 主从异步丢锁
相关推荐
qq_白羊座1 小时前
口头说的 JSON 和 MySQL 里的 JSON
数据库·mysql·json
花间相见3 小时前
【计算基础|MySQL11】事务与隔离级别
sql·mysql·adb·架构
eleven_4u3 小时前
MySQL引擎层是如何工作的
数据库·mysql
xianyuCcCcCCCcc4 小时前
Ceph 分布式存储:集群配置与存储池管理
运维·分布式·ceph
计算机毕设定制辅导-无忧学长5 小时前
《基于微信小程序的个人智能刷题助手的设计与实现》
java·spring boot·mysql·微信小程序·uniapp·个人智能刷题助手系统
geovindu5 小时前
sql:Managing Complex Joins and Subqueries in SQL using mysql 9.0
大数据·数据库·数据仓库·sql·mysql·数据库开发·数据库架构
Sayuanni%35 小时前
Redis(初阶)
数据库·redis·缓存
瑞克-桑切斯5 小时前
MySQL部署手册(5.7.20/8.0)
数据库·mysql
啵啵啵鱼5 小时前
九、JDBC 操作 MySQL 数据库
android·数据库·mysql