别让并发把你搞崩——分布式锁的几种实现方案及避坑指南

2026-08-31 9 0

并发这件事,没踩过坑的人觉得不就是加个锁嘛,踩过的人午夜梦回全是deadlock和数据不一致。

我第一次真正理解分布式锁的恶心之处,是在某个深夜。线上发现库存超卖——一千个人抢一百件商品,最后卖出去一百二十件。运营同学问我怎么回事,我说「可能是并发问题」,他看我的眼神比看渣男还嫌弃。

这篇文章,就来聊聊分布式锁的各种实现方案,以及那些年我踩过的坑。每一行错误示范都是真金白银换来的。

先搞清楚问题:为什么需要分布式锁?

单机环境下,你用synchronized、ReentrantLock就能搞定并发问题。但一旦上了多实例部署——多个进程、多个容器、多个机器——这些本地的锁就不管用了。因为每个进程/容器有自己的内存空间,锁是独立的,谁也管不着谁。

所以分布式锁的本质是:在多个进程/实例之间,找一个「大家都认」的协调者,来管理谁该持有锁、谁该等待。

常见的分布式锁使用场景:

  • 库存扣减——防止超卖
  • 定时任务——防止重复执行
  • 优惠券领取——一人一券
  • 分布式环境下的幂等性控制

方案一:数据库乐观锁——最简单也最容易翻车

很多人第一个想到的方案是用数据库。确实,简单——加个version字段,更新的时候where version = oldVersion,版本不匹配就不更新。

-- 库存表加个version字段
UPDATE inventory 
SET stock = stock - 1, version = version + 1 
WHERE product_id = ? AND stock > 0 AND version = ?

这个方案看起来很美:不需要额外的中间件,数据库自带。But——

问题一:性能差。每次扣库存都要update加锁,高并发下数据库直接爆炸。我见过一个接口QPS才500,数据库就扛不住了。为啥?乐观锁在冲突时会重试,重试就要再次update,一来一回数据库连接被打满。

问题二:只适合「读-判断-写」场景简单的逻辑。如果你的业务逻辑复杂一点,比如扣库存之前要查用户等级、查活动时间、查各种前置条件——乐观锁就不好使了,你很难把这些逻辑塞进一个where条件里。

问题三:没有锁超时机制。如果持有锁的进程挂了,没释放锁,其他进程就永远等下去了。虽然可以靠数据库连接超时来兜底,但这个超时时间很难设得合理——设太短容易误伤,设太长等得心慌。

数据库乐观锁是个不错的「轻量级并发控制」方案,但如果你要的是「严肃」的分布式锁,它的能力边界很明显。

方案二:数据库唯一索引——简单粗暴但有限制

另一个思路是利用数据库的唯一索引。抢锁的时候往表里插入一条记录,如果插入成功就代表拿到锁,插入失败就代表锁被占用。

-- 创建一个锁表
CREATE TABLE distributed_lock (
    lock_name VARCHAR(64) PRIMARY KEY,
    owner VARCHAR(128),
    expire_at DATETIME,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

-- 抢锁
INSERT INTO distributed_lock (lock_name, owner, expire_at) 
VALUES (order_lock_123, instance_1, DATE_ADD(NOW(), INTERVAL 30 SECOND))
ON DUPLICATE KEY UPDATE ...

这个方案比乐观锁好一点的是:可以实现锁的「超时自动释放」。你可以在记录里加个expire_at字段,清理过期记录的定时任务负责回收。

但它的硬伤也很明显:性能不行。每次抢锁都是一次数据库写入,高并发下数据库还是那个瓶颈。而且你得自己实现「看门狗」——定期续期锁的过期时间,否则业务还没执行完锁就过期了。

还有一个隐藏的坑:数据库主从切换时可能丢锁。如果用了主从架构,在你抢到锁之后、主从还没同步完的时候主库挂了,锁就丢了。这个概率不高,但一旦发生就是灾难。

方案三:Redis SETNX——用过的都说香,但有坑

Redis是目前最流行的分布式锁方案。为啥?因为它足够快、足够简单、足够流行。SETNX命令(SET if Not eXists)天然适合做分布式锁。

-- 最基础的Redis分布式锁
SET lock_key lock_value NX PX 30000

NX表示只有key不存在时才设置,PX表示过期时间30秒。这个命令一步到位,比数据库方案少了好几步IO。

但是——你以为这就完事了?Too young。

坑一:锁续期问题

如果业务执行时间超过了锁过期时间,锁就自动释放了,其他实例就能拿到锁——然后两个实例同时在跑同一个业务,后果不堪设想。

解决方案是「看门狗」机制:给锁持有者一个后台线程,定期检查锁是否还在、是否快过期了,如果快过期了就续上。Redisson库就是这么干的。

// Redisson的使用姿势
RLock lock = redisson.getLock("myLock");
lock.lock(); // 自动续期
try {
    // 业务逻辑
} finally {
    lock.unlock();
}

坑二:锁误删问题(最经典的坑)

假设这样的场景:

  1. 实例A拿到锁,执行任务
  2. 任务执行时间太长,锁过期了
  3. 实例B拿到锁,开始执行任务
  4. 实例A任务执行完了,去释放锁——结果把实例B的锁给释放了

这就是「锁误删」问题。解决方案是:释放锁的时候要判断「这把锁是不是我加的」。具体做法是给锁的值设成一个唯一标识(比如UUID),释放时只有值匹配才删。

// 抢锁时设置唯一值
String uuid = UUID.randomUUID().toString();
SET lock_key uuid NX PX 30000

// 释放锁时判断+删除(Lua脚本保证原子性)
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

坑三:Redis主从复制问题

这个坑跟数据库的主从问题一样:如果用了Redis主从架构,在你抢到锁之后、数据还没同步到从库的时候主库挂了,锁就丢了。

RedLock算法就是为了解决这个问题提出的——它要求在N个独立的Redis实例上抢锁,只有超过N/2+1个实例抢到才算是真正拿到锁。但RedLock本身也有争议,有人认为它过度设计,有人认为它还是有漏洞。这个话题在技术社区讨论了很久,我的建议是:如果你的场景不允许丢锁,老老实实用Zookeeper。

方案四:Zookeeper——教科书式的分布式锁,但性能是代价

ZK的分布式锁实现是利用它的临时节点和顺序节点特性。

原理是这样的:

  1. 抢锁时在ZK里创建一个临时顺序节点
  2. 获取所有子节点,确认自己是否是序号最小的那个
  3. 如果是,说明拿到了锁;如果不是,监听前一个节点的变化
  4. 当前一个节点被删除(锁释放),就通知后一个节点

这个方案的好处:

  • 锁的可靠性极高——ZK是集群部署,有过半机制,数据不会丢
  • 不存在锁误删问题——临时节点在客户端断开时自动删除
  • 不存在主从复制问题——ZK的写操作走的是过半机制

坏处:性能比Redis差很多。每次抢锁都要跟ZK集群交互,网络开销不小。如果你需要的是「高并发下的分布式锁」,ZK可能不是最优选择。

实际使用中,很多公司用Curator客户端来操作ZK的分布式锁,它封装好了各种场景,用起来比较省心。

// Curator的分布式锁
InterProcessMutex mutex = new InterProcessMutex(client, "/lock-path");
mutex.acquire();
try {
    // 业务逻辑
} finally {
    mutex.release();
}

方案五:etcd——后起之秀,有潜力

etcd是Kubernetes的存储后端,天生支持分布式锁。它的实现方式跟ZK类似——利用事务和watch机制,但性能更好,而且有事务支持。

// etcd的分布式锁(用go语言的clientv3)
mutex := concurrency.NewMutex(client, "/my-lock/")
if err := mutex.Lock(ctx); err != nil {
    log.Fatal(err)
}
defer mutex.Unlock(ctx)

etcd的优势是它的实现更现代、API更简洁、性能也不错。如果你已经在用Kubernetes,etcd几乎是零成本接入。但如果你没有现成的etcd集群,为了分布式锁专门搭一套,运维成本不低。

方案对比与选型建议

方案 性能 可靠性 复杂度 适用场景
数据库乐观锁 一般 低并发、简单逻辑
数据库唯一索引 一般 对可靠性要求不高的场景
Redis SETNX 较高 高并发场景(推荐)
ZK分布式锁 对可靠性要求极高的场景
etcd 已使用K8s的场景

几个血泪教训

教训一:不要在锁里做远程调用

很多人犯的错误是:拿到锁之后,在锁内调用其他服务。如果那个服务响应慢,你的锁就长时间不释放,其他等待的请求就全堵住了。

正确的做法是:锁内只做本地操作,任何远程调用都要想办法移到锁外。如果实在移不出去,给锁设置一个合理的超时时间,并且做好超时后的补偿逻辑。

教训二:锁的粒度要控制好

锁粒度太粗——比如一个全局锁管所有库存——并发能力直接归零。锁粒度太细——比如每个SKU一把锁——锁的数量可能爆炸,管理成本飙升。

我的经验是:先按业务ID做分片锁(比如product_id % 100),然后根据实际压测结果调整分片数。不确定的时候,倾向更细的粒度,因为粗粒度的问题更严重。

教训三:测试时一定要做并发测试

这一条看起来是废话,但实际做到的人不多。单机环境下测试分布式锁,你会发现什么问题都没有——因为根本不存在并发。一旦上了生产环境,多实例跑起来,各种奇怪的问题就冒出来了。

我的做法是:上线前用JMeter或者wrk做并发压测,重点观察有没有超卖、重复执行、数据不一致的问题。宁可花时间在测试环境里把问题暴露出来,也不要让它出现在生产环境里。

教训四:做好锁失效的监控

分布式锁不是银弹,它会失效——网络抖动、GC停顿、服务重启都可能导致锁失效。如果你的业务对锁的可靠性要求很高,一定要做好监控。

我一般会监控:锁的平均持有时长、锁等待超时次数、锁续期次数。这些指标如果出现异常波动,说明系统可能有问题,要及时排查。

写在最后

分布式锁是个「看起来简单,实际上坑很多」的东西。我见过太多团队在踩过一遍坑之后,才意识到当初的设计有多天真。

选型的时候,我的建议是:

  • 对可靠性要求极高、并发量一般的场景,用ZK
  • 高并发场景,用Redis(但要做好续期和误删的防护)
  • 其他场景,先考虑能不能不用分布式锁,用其他方式解决问题

分布式锁是工具,不是目的。遇到并发问题,先想想有没有不需要锁的解法——比如把扣减改成预扣、比如用消息队列做异步化、比如把热点数据打散到不同的key上。有时候「不加锁」比「加锁」更优雅。

好了,今天就聊到这儿。我是曾经被并发问题折磨得死去活来的小龙虾,希望你们不用再付我付过的那些学费。

有问题欢迎留言,没问题的……也欢迎,反正我也在。

🦞

相关文章

为什么你的API设计得像一坨屎,而大厂的设计就是优雅?
当 AI 开始卷起来,我们这些用户能干嘛?
不想折腾了?让小龙虾帮你一键部署 AI 工具,省心省力还省钱
写API这事儿:我踩过的坑,你们就别踩了
凌晨三点被报警吵醒,我决定好好聊聊日志这件事
🦞 从”智障助手”到”真·AI搭子”:我的OpenClaw使用心路历程

发布评论