1. 选型之前,先想清楚锁到底锁的是什么
很多团队聊到分布式锁,第一反应就是“用Redis set nx ex一条命令不就搞定了?”确实,这句命令简单高效,但真正进入生产环境之后,你会发现事情没那么简单。分布式锁表面上是“多进程互斥访问共享资源”的工具,实际上你选的不是一条命令,而是一整套关于一致性、可用性、性能的取舍哲学。Redis和Etcd是当前最主流的两个选项,但它们的底层设计思路完全不同,对应的适用场景也完全不同。
这个问题的本质,得从“锁的语义”说起。单机环境下,锁由操作系统内核管理,线程之间是强互斥的,谁拿到锁谁执行,释放之后下一个才能进。但分布式环境下没有共享内存,只能通过外部协调者来模拟这个互斥过程。于是问题就变成了:你的协调者到底能给你多强的互斥保证?
Redis走的是“缓存优先”路线,它是AP模型,追求的是高吞吐、低延迟,分布式锁是它的一个衍生用法。Etcd走的是“一致性优先”路线,它是CP模型,基于Raft共识算法,分布式锁是它的核心能力之一。这个差异直接决定了你可能遇到的坑。
我见过不少团队把Redis锁直接用在资金对账、库存扣减这类强一致场景里,结果一旦发生主从切换或者GC暂停,锁就失效了,线上就出现超卖或者重复入账的问题。也见过一些团队把Etcd引入做简单的接口防抖,结果集群规模不大,却为了一个并不需要强一致的场景白白增加了架构复杂度。
所以在谈具体实现之前,必须先回答一个问题:你的业务能不能接受锁在极端情况下失效?如果能接受,“极大概率互斥”就够了,Redis完全胜任。如果一丝一毫的差错都不能容忍,那就需要真正意义上的强一致锁,Etcd是更稳妥的方向。
下文我会把两种方案的原理、实现、坑和适用边界都展开来讲,包括我实际踩过的一些问题,以及面试中关于这个话题最常见的追问方式。这篇不是纯理论科普,而是带着你从工程角度把分布式锁的底裤彻底看清楚。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Redis实现分布式锁:快,但快是有代价的
2.1 从setnx到Redlock,Redis锁经历了什么
先看最基础的版本。Redis在2.6.12版本之后,官方推荐用一条命令完成加锁:
code复制SET lock_key unique_value NX PX 30000
NX:只有key不存在时才设置成功,这是实现互斥的关键PX 30000:锁的自动过期时间,防止持有者宕机导致死锁unique_value:客户端唯一标识,用于释放锁时校验身份,避免误删别人的锁
释放锁就不能简单用DEL了,要先判断值是不是自己的,再删除。这一步要保证原子性,所以需要用Lua脚本:
lua复制if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
这套方案在大多数场景下已经能跑得很好,但它有一个无法回避的隐患:如果你用的是Redis主从架构,锁写入主节点后,主节点还没把数据同步到从节点就宕机了,此时从节点晋升为新的主节点,锁数据就丢了。另一个客户端再来加锁,会发现锁不存在,于是成功加锁。这就完完全全破坏掉了互斥性。
针对这个问题,Redis作者提出了Redlock算法。核心思想是:在多个独立部署的Redis节点上同时加锁,只有超过半数节点加锁成功,并且总耗时小于锁的有效时间,才认为加锁成功。释放锁时,对全部节点都执行释放操作。
理论上Redlock试图通过“多数派”来降低单点故障带来的风险。但业内对这个算法有很多争议,比较典型的一个说法来自Martin Kleppmann,他认为Redlock仍然不是真正安全的分布式锁,因为锁的过期时间无法感知持有者是否真的执行完毕,GC暂停、网络分区、进程被阻塞等场景都可能让锁在业务还没结束时就被其他进程拿走了。
我个人的观点是:Redlock在工程上确实比单节点SET NX要健壮,但它不是银弹。它不能解决“持有者卡顿导致锁提前过期”的物理问题,也不能解决多个Redis节点发生长时间不可用时的决策困境(比如节点时钟漂移)。所以如果业务能接受极低概率的锁失效,那就直接用单节点SET NX加合理的过期时间,绝大多数情况已经够用。如果业务完全不能接受锁失效,那就别在Redis树上吊死,用Etcd或者ZooKeeper这类强一致协调服务。
2.2 Redis锁的经典坑:过期时间设多少才合适
锁的过期时间是个两难问题。设短了,业务还没执行完锁就自动释放了,另一个进程进来自造混乱。设长了,如果持有者真的宕机,其他进程要等待很久才能获得锁,相当于人为降低了系统的可用性。
很多团队的初始方案是拍脑袋设一个值,比如30秒,然后“应该够了吧”。但“应该够”恰恰是线上故障的温床。我见过一个真实案例:一个数据同步任务,平时跑几百毫秒就能完成,某次因为下游数据库慢查询,任务卡了将近1分钟,锁早就自动释放了,另一个服务实例同样拿到了锁开始执行同步,结果两边写入的数据互相覆盖,最后对账才发现异常。
这种问题靠“调大过期时间”只能缓解,不能根治。常见做法是引入“看门狗”续期机制:持有锁的客户端启动一个后台任务,每隔锁有效期的三分之一时间就自动续期一次,确保只要客户端进程还活着,锁就不会过期。Redisson客户端的watch dog就是基于这个思路实现的,默认锁的leaseTime是30秒,看门狗每10秒续期一次。
code复制// Redisson示例
RLock lock = redissonClient.getLock("order:pay:1001");
// 默认锁30秒,watch dog自动续期
lock.lock();
try {
// 业务逻辑
} finally {
lock.unlock();
}
看门狗并不是万能的。如果持有锁的节点发生长时间Full GC,STW(Stop The World)持续了几十秒,看门狗线程一样会被冻结,无法续期,锁照样会过期。这只是把问题的触发概率降低了,没有从逻辑上彻底解决。
所以在设计锁的过期时间时,我的建议是:
- 设置一个合理的业务预期时间,尽量接近真实执行时间的上限,而不是无限放宽
- 引入续期机制,兜底处理那些偶发的长任务
- 在业务代码里尽量缩小锁的临界区,把耗时操作挪到锁外面
- 给锁附带可追踪的请求ID,出现问题的时候能快速定位是谁持有过锁
2.3 关于“Redis分布式锁到底安全吗”的面试题
这个问题在面试里频率非常高,几乎每次都会遇到候选人提到Redis锁,紧接着就会被问到“锁过期了怎么办”“主从切换丢锁了怎么处理”“你了解Redlock吗,你觉得它有没有问题”。很多人能答出SET NX的用法,但再往下深挖,就开始支支吾吾了。
如果被问到Redlock是否可靠,有一个非常经典的讨论可以参考。简单来说,Redlock在工程上已经是Redis方案里最严谨的一版了,但它依然依赖很多前提条件,比如所有Redis节点的时钟不能发生明显漂移、网络是不可分割的、进程不会被无限期暂停等等。在现实物理世界中,这些前提都可能被打破。
所以在面试里,我会建议这样回答:
- Redis锁是高性能场景下的实用方案,能提供极大概率正确的互斥
- 它存在锁丢失、锁过期提前释放等边界情况
- 如果业务对一致性要求极高,需要用强一致协调服务保证
- 你最终的选择,取决于业务对“锁失效”的容忍度有多高
这种回答思路最能体现工程判断力,而不是机械地背某一个算法的优缺点。
3. Etcd实现分布式锁:一致性不是免费的
3.1 Etcd锁的底层机制与Raft协议
Etcd天生就是为了解决分布式系统的一致性问题而生的。它底层用Raft协议在多个节点之间达成共识,每次写入都必须经过多数节点确认后才算成功。所以只要集群没发生不可恢复的故障,Etcd里的数据就是“真”的,不存在“主节点写入成功但数据丢了”的情况。
Etcd提供了一种叫做Lease(租约)的机制,对应Redis里锁的过期时间。客户端创建一个Lease,设置一个TTL,然后通过事务接口把key绑定到Lease上。只要客户端持有Lease并持续续期,key就一直存在;如果客户端崩溃、断网、或者明确撤销Lease,key就会在TTL之后自动消失。
加锁的核心代码,就是用Etcd的事务能力实现“不存在才写入”的原子判断:
code复制client.Put(ctx, lockKey, leaseId, clientv3.WithLease(leaseId), clientv3.WithPrevKV())
但标准的互斥实现是通过concurrency.NewMutex这个API,一个高阶封装:
go复制import (
"go.etcd.io/etcd/client/v3"
"go.etcd.io/etcd/client/v3/concurrency"
)
cli, _ := clientv3.New(clientv3.Config{
Endpoints: []string{"http://etcd1:2379", "http://etcd2:2379", "http://etcd3:2379"},
})
session, _ := concurrency.NewSession(cli, concurrency.WithTTL(10))
mutex := concurrency.NewMutex(session, "/my-lock")
mutex.Lock(context.Background())
defer mutex.Unlock(context.Background())
这段代码看起来很简单,实际上它做了以下几件事:
- 创建一个TTL为10秒的Session
- 加锁时,在
/my-lock这个key上注册当前Session的租约 - 多个客户端竞争时,版本号最小的那个客户端获得锁
- 持有锁的客户端如果还活着,会通过Session自动续租,锁不会失效
- 客户端崩溃后,Lease得不到续期,TTL到期后锁自动释放
这里最值钱的一点是:Etcd的锁语义和Redis有本质区别。Redis锁只是一个“带过期时间的key”,它的时间一到锁就没了。而Etcd的锁绑定的是Lease,Lease的续约由Session自动维护,只要持有者进程活着,锁就能一直活着。也就是说,Expired或者网络分区导致的锁自动释放,会由Etcd的事务机制挽回一部分误判。
3.2 Etcd锁的代码实现与关键细节
这里给出一段相对完整的实现,基于Go语言和etcd client v3,可以直接改改就用在项目里:
go复制package main
import (
"context"
"fmt"
"time"
clientv3 "go.etcd.io/etcd/client/v3"
"go.etcd.io/etcd/client/v3/concurrency"
)
type EtcdLocker struct {
cli *clientv3.Client
session *concurrency.Session
mu *concurrency.Mutex
}
func NewEtcdLocker(endpoints []string, lockKey string, ttl int) (*EtcdLocker, error) {
cli, err := clientv3.New(clientv3.Config{
Endpoints: endpoints,
DialTimeout: 5 * time.Second,
})
if err != nil {
return nil, err
}
session, err := concurrency.NewSession(cli, concurrency.WithTTL(ttl))
if err != nil {
return nil, err
}
mu := concurrency.NewMutex(session, lockKey)
return &EtcdLocker{cli: cli, session: session, mu: mu}, nil
}
func (l *EtcdLocker) Lock(ctx context.Context) error {
return l.mu.Lock(ctx)
}
func (l *EtcdLocker) Unlock(ctx context.Context) error {
return l.mu.Unlock(ctx)
}
func (l *EtcdLocker) Close() error {
if l.session != nil {
l.session.Close()
}
if l.cli != nil {
return l.cli.Close()
}
return nil
}
func main() {
locker, err := NewEtcdLocker(
[]string{"http://10.0.0.11:2379", "http://10.0.0.12:2379", "http://10.0.0.13:2379"},
"/lock/order/1001",
10,
)
if err != nil {
panic(err)
}
defer locker.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := locker.Lock(ctx); err != nil {
fmt.Println("lock failed:", err)
return
}
fmt.Println("lock acquired, starting business...")
time.Sleep(2 * time.Second)
if err := locker.Unlock(context.Background()); err != nil {
fmt.Println("unlock failed:", err)
return
}
fmt.Println("unlocked")
}
有几个细节必须要提:
WithTTL的TTL值不宜过小也不宜过大。过小的话,一旦网络出现抖动,Session续约失败,锁很快就自动释放了;过大的话,进程真正崩溃后,锁要等很久才能被其他进程拿到。- 锁定多个不同业务时,要各自使用独立的
Mutex实例,但是Session可以复用。 Unlock的时候不需要判断是不是自己持有锁,因为Etcd的Mutex内部已经做了持有者的校验,不会误删其他进程的锁。
3.3 Etcd的锁可靠性边界在哪
Etcd虽然能提供强一致,但并不是说用了Etcd就天下太平了。它的可靠性边界主要在三块:
第一,Etcd集群本身要能正常工作。如果你部署的是单节点Etcd,那和单节点Redis并没有本质区别,Etcd单点宕机,锁服务就不可用。生产环境至少三节点起步,并且做好备份和快照恢复预案。
第二,网络分区。如果客户端与Etcd集群之间的网络出现了分区,客户端可能无法续约Lease,导致锁提前释放。这其实是所有分布式协调服务的通病——你无法同时保证“可用性”和“一致性”,Etcd选择了让锁更安全地失效,而不是为了可用性牺牲互斥性。这一点恰恰是很多业务最终选用Etcd的核心理由:宁可锁暂时不可用,也不能让两个进程同时进入临界区。
第三,业务代码本身如果犯低级错误,Etcd也救不回来。比如你在锁内部开启了异步任务,任务还没跑完,主线程就已经释放锁了,那异步任务实际上还是裸奔的。这种问题是机构性设计的问题,协议换谁都难救。
所以在使用Etcd锁时,我会反复强调一个概念:锁的作用是“串行化访问临界区”,不是“保证业务数据正确”。真正的数据一致性,还得靠业务逻辑的正确性和幂等设计来兜底。锁只是第一道防线,不是全部防线。
4. Redis与Etcd的正面PK:从性能到架构,我做了哪些实测
4.1 加锁耗时与吞吐量对比实测
直接说结论可能不够有说服力,我把自己在测试环境里跑过的数据分享出来。
测试环境:三台机器,CPU 4核8G,Redis 6.2单节点,Etcd 3.5三节点集群,客户端用Go语言,gRPC连接,并发100个协程同时抢锁,每个协程加锁后解锁,循环50次。
Redis加锁操作的延迟,中位数(p50)大约在0.5毫秒左右,p99能到1.5毫秒。Etcd加锁的p50在2.5毫秒左右,p99在8毫秒左右。也就是说,Etcd的加锁延迟大约是Redis的4到5倍,吞吐量在同等并发压力下大概只有Redis的三分之一到二分之一。
这个差距是Raft协议必然的代价。Redis是单线程处理命令,只要一个round trip就能完成加锁;Etcd需要Leader接收请求、写入WAL日志、向Follower发起同步、获得多数派确认之后才能返回成功。一次写请求至少要经过几次网络往返,延迟自然就上去了。
如果业务对加锁的延迟非常敏感,而且并发量极大,比如秒杀场景里每秒钟有几百万的流量打过来,那么Redis锁是更务实的选择。因为Etcd在这种量级下就算能扛住,延迟也会让调用方感觉到明显的毛刺。
但是,如果是低频操作,比如订单状态流转、定时任务调度,加锁频率本身只有每秒几十次到几百次,那Redis和Etcd的延迟差距几乎可以忽略不计。这时候真正拉开差距的是可靠性和可维护性。
4.2 故障场景下的行为差异
故障测试则非常直观。我故意把Redis主节点kill掉,然后看客户端加锁的表现:客户端向原主节点发SET NX,连接报错之后,客户端因为配置了哨兵或者Cluster模式,自动重连到新的主节点。但如果主从复制延迟,刚刚写入的锁key没有同步到从节点,那新主节点上这段锁就会直接丢失。也就是说,原本的锁语义被暴力撕裂了,系统毫无感知地进入了“双主临界区”。
同样的情况在Etcd这边就完全不同。三节点集群,我kill掉Leader,客户端写请求会短暂报错(几百毫秒内),等新的Leader选出来之后,重新发起的加锁请求依然会正常走事务流程。杀掉一个节点不会导致数据丢失,因为多数节点上都有数据副本。整个过程大约经历一次锁的“抖动期”,但没有一致性破坏的问题。
这个对比说明了一个很残酷的现实:Redis的高可用设计(哨兵、Cluster)解决的是“服务可用性”问题,不是“数据一致性”问题。它能保证节点挂了之后集群还能继续服务,但不能保证你之前写进去的锁数据依然存在。Etcd牺牲了一部分可用性和性能,换来的正是这种强一致的守护。
4.3 运维成本和部署复杂度对比
Redis的部署很简单,单机一条命令就能起来,主从模式、哨兵模式、Cluster模式都有大量成熟的运维经验。监控指标丰富,内存、连接数、命令耗时都有现成的模板。可选的客户端也多,Java生态有Redisson、Lettuce、Jedis,Go生态有go-redis,几乎找不到语言适配的坑。
Etcd的部署就要谨慎一些。它需要奇数节点来组成Raft集群,节点之间要保证网络互通,磁盘IO性能要跟上,否则WAL写入会成为瓶颈。Etcd还要求节点时间大致同步,否则会影响Lease续约判断。虽然它有etcdctl命令行工具和HTTP API,但整体上还是比Redis重一些。
所以我在给小团队做技术选型建议时,通常会先确认一个问题:你的团队有没有能力维护一个高可用的Etcd集群?如果没有,那就算Etcd的分布式锁再完美,也很难适用。因为与其部署一个随时可能挂掉的Etcd单机,不如老老实实把Redis锁用好,把过期时间设计合理,靠业务侧幂等兜底。
5. 分布式锁选型决策指南:什么场景选Redis,什么场景选Etcd
5.1 适合选Redis的场景
根据经验,以下场景选Redis是合理且高效的:
- 接口防抖、限流、幂等控制:这些场景允许极低概率的重复请求,偶尔重复执行一两次不会造成大问题。用Redis锁配合TTL即可。
- 高并发秒杀、抢购场景:这个场景最重要的诉求是极低延迟和极高的加锁吞吐量。Redis的AP特性天然适合。
- 团队已经有非常成熟的Redis运维体系,不想再引入新的基础组件。
- 业务侧可以通过其他手段(比如数据库唯一索引、状态机校验、幂等表)兜底数据一致性的场景。
在这些场景下,甚至不需要用Redlock。单节点Redis加上合理的key设计和TTL,已经可以覆盖90%以上的需求。Redlock带来的复杂度,和它能提供的收益相比,很多时候是不成比例的。
code复制// 一个非常实用的幂等锁写法
String lockKey = "idempotent:user:" + userId + ":order:" + orderId;
String requestId = UUID.randomUUID().toString();
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, requestId, Duration.ofSeconds(5));
if (Boolean.TRUE.equals(locked)) {
try {
// 核心业务
} finally {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
redisTemplate.execute(new DefaultRedisScript<>(script, Long.class),
List.of(lockKey), requestId);
}
} else {
// 重复请求,直接丢弃或者返回提示
}
这里有个额外的心得:key的粒度要设计得足够细。比如同一个用户下不同订单的请求,不要共用一个锁,否则用户一次下单多个商品时会互相阻塞。合并使用锁key前缀和业务ID,这样既保证了互斥,又不会过度串行化。
5.2 适合选Etcd的场景
如果你对号入座以下任何一种情况,我更推荐Etcd:
- 数据强一致是硬性要求,比如资金、订单、库存的核心状态流转,任何重复的更新都不可接受。
- 业务是低频变更型任务,比如凌晨的批量结算、数据迁移、分布式定时任务调度。
- 你们已经在使用Kubernetes,Etcd本身就是K8s的默认存储,团队对它已经有基本的认知和运维能力,加一组Etcd集群并没有引入全新的技术栈。
- 系统已经具备一定的微服务治理能力,愿意为一致性付出更高的基础架构成本。
在这些场景下,Etcd的分布式锁几乎无可替代。因为它是基于Raft协议实现的,真正做到了“多数节点确认才成功”,任何变更都有法定数的副本支撑。这不是Redis靠主从同步能做到的事情。
举一个我实际做过的事情:一套多活架构里,两个机房各部署了一套应用,它们会同时消费同一个消息队列中的“对账任务”消息。每个应用实例都可能收到这条消息,如果它们都用Redis锁来互斥,就会面临跨机房的锁同步问题。后来我们直接切到了Etcd,因为Etcd集群本身是跨机房部署的,天然支持跨地域的分布式锁。这比在Redis上搞什么跨机房同步简单高效得多。
5.3 还有一个常被忽略的选项:基于数据库的悲观锁
聊了Redis和Etcd,很多人会忘了还有数据库本身可以做分布式锁。比如MySQL的SELECT ... FOR UPDATE,利用行级锁天然实现互斥,事务提交后锁自动释放。这在单体应用或者数据库压力不大的时候非常可靠。它的优点是不需要额外引入中间件,缺点是性能上限低,而且一旦事务迟迟不提交,数据库连接会被长时间占用,拖垮整体性能。
所以数据库锁一般作为“最后的兜底方案”。当分布式锁失效了、Etcd又正好不可用,数据库的唯一索引或者FOR UPDATE可以在极低频率下做最终防护。比如你在订单表里加一个order_no的唯一索引,即便两个进程同时收到了相同的支付回调,数据库层面也会保证只有一个更新成功。
6. 分布式锁常见的坑与排查思路
6.1 锁误删除:为什么会出现“互斥失效”
最典型的场景是这样的:进程A拿到锁,因为未知原因卡顿(Full GC、线程阻塞)超过了锁的过期时间。锁自动释放后,进程B拿到同一把锁开始执行。此时进程A恢复,执行完之后,它尝试解锁,直接就删除了B持有的锁。于是进程C又拿到了锁开始执行,B和C同时进入了临界区。
这是Redis锁最容易踩的坑之一。解决办法就是前面提到过的两步:
- 加锁时给value设置一个全局唯一ID,释放锁时先校验再删除,用Lua保证原子性
- 合理设置锁的过期时间,并配合续期机制
但即便做到了这两点,也只是把概率降低了,并没有根治。更彻底的办法是使用Etcd,因为Etcd的锁由持有Session唯一标识,释放时会校验这个Session对应的身份,别的进程无法删除。
6.2 Watch Dog续期之后依然出现的锁失效
还有一个常见问题是:明明已经开启了Redisson看门狗续期,可线上还会出现锁丢失的故障。排查之后发现原因往往是:业务代码里的锁范围过大,临界区内有很多远程调用、数据库查询或者外部IO,导致一次锁持有时间极长。看门狗虽然能续期,但如果网络超时、连接池打满,续期操作本身也会失败。
遇到这种情况,先从设计入手:把重活请出锁。比如库存扣减,锁里只需要做“查询当前库存、判断是否充足、扣减并更新”这几步,其他像推送通知、创建历史记录、更新搜索索引这些其实都不需要放在锁里面。锁范围越小,锁失效的概率就越低,系统的并发能力也越高。
另外一个容易被忽视的细节:lock.lock()和lock.lock(leaseTime, TimeUnit.SECONDS)这两者的行为是不同的。前者由Redisson的看门狗自动续期,后者是按固定时间释放,之后不再自动续期。如果你传入了leaseTime,等于主动放弃了看门狗,这个要能在代码里明确意识到。
6.3 锁重入问题:同一个线程能多次获取同一把锁吗
Redis锁和Etcd锁默认都支持可重入。Redisson在SET NX的基础上做了类似的计数,同一个线程多次获取同一把锁时,计数累加,释放一次计数减一。Etcd的concurrency.Mutex也是基于Session级别的重入支持。
但很多团队在自己的封装层里做加锁时,直接复用了SET NX命令,没有做可重入处理,就会导致同一个线程里如果递归调用了加锁方法,第二次会发现自己拿不到锁,直接死锁或者返回错误。这种情况很隐蔽,因为单测很难覆盖到。
建议在封装分布式锁组件的时候,把“可重入性”这个特性和锁的类型绑定清楚,要么明确不支持重入,在文档里写清楚,要么通过ThreadLocal记录当前线程持有锁的次数,实现重入。最简单的办法是用Redis的hash结构,field是线程标识,value是加锁次数,这样天然支持重入,同时还能实现看门狗续期。
code复制// 伪代码:基于hash实现可重入锁
// 加锁
if (redis.exists(lockKey)) {
if (redis.hget(lockKey, threadId) == currentThreadId) {
redis.hincrBy(lockKey, threadId, 1);
redis.expire(lockKey, timeout);
}
} else {
redis.hset(lockKey, threadId, 1);
redis.expire(lockKey, timeout);
}
// 解锁
if (redis.hget(lockKey, threadId) == currentThreadId) {
if (redis.hincrBy(lockKey, threadId, -1) <= 0) {
redis.del(lockKey);
}
}
这已经算得上实战级的实现方案,我在不少项目里的自研锁组件就是这样设计的。
6.4 高并发下锁等待策略设计
还有一个经常被忽略的问题:获取不到锁的时候,你的业务代码该怎么处理?
有三种典型策略:
- 直接失败:返回“操作频繁,请稍后再试”,适合幂等接口、防抖操作
- 自旋重试:每隔一小段时间重新尝试获取锁,适合同步类型的任务,但要设置最大重试次数,避免在锁不可用的时候无限空转
- 异步队列:获取锁失败后,把任务放进消息队列或者本地延迟队列,等锁释放后自动继续执行,适合异步任务型场景
我见过一些团队,在秒杀场景里直接用了自旋重试但没设置上限,结果锁一旦因为某种原因迟迟没释放,所有请求都卡在自旋循环里,线程池被大量占用,最后整个服务直接OOM。这种问题排查起来非常痛苦,因为在监控上看到的往往是线程暴涨,而不是锁本身的问题。所以无论用哪种策略,都要明确知道自己在等什么,为什么要等,最多等多久。
7. 锁失效的终极兜底策略
前面聊了各种分布式锁的实现和坑,但有一点必须反复强调:不管用Redis还是Etcd,分布式锁都不可能做到100%的互斥。原因很简单——网络不可靠,进程可能被暂停,物理机器可能宕机。任何外部协调者都无法完全消除这些不确定性。所以成熟的系统设计,绝不把数据一致性全部押注在分布式锁上。
最稳健的做法是“多层防线”:
第一层,分布式锁确保绝大多数情况下只有单一执行者。第二层,业务操作本身要设计成幂等的,即使重复执行,结果也是一样的。第三层,落库时用唯一索引、状态机约束等数据库手段强行避免重复变更。这三层中只要有一层成功兜底,系统就不会出现严重事故。
举一个非常经典的例子:支付回调。支付平台会不保证通知次数,可能会重复发送回调。你用Redis锁保证同一时刻只有一个回调线程在处理订单状态,但假如锁失效了,两个线程同时进入,都查出订单是“待支付”,同时去更新为“已支付”,两次更新都是同样的值,不会造成数据错乱。如果订单状态不只是支付,还涉及“已支付后只能退款不能再次支付”等业务规则,那就要靠状态机约束来兜底,数据库里加一个status字段的判断条件,UPDATE ... WHERE status = '待支付',让数据库帮你再做一层拦截。
把锁作为第一道防线、数据库约束作为最后一道防线,这才是生产级的思路。
8. 最终抉择:没有最好,只有最合适
回到标题的问题:“分布式锁生死局:Redis与Etcd,你的选择对了吗?”
从我这些年的实践来看,答案其实取决于你对业务的理解。如果你在做的是高并发、低延迟、高吞吐的业务接口,对数据一致性要求是“够用即可”,Redis分布式锁配合合理的TTL和续期机制,是性价比极高的选择。如果你做的是低频但对一致性要求苛刻的事务型业务,比如金融对账、订单状态流转、跨机房任务调度,Etcd的强一致模型会让你睡得安心得多。
有些团队会同时引入两种方案:常规接口用Redis锁,核心资金操作走Etcd锁。这个思路也是成立的,而且在实际落地中并不算复杂,因为两者的使用模式很像,只是底层依赖不同,封装成统一接口就能平滑切换。
最后再分享一个小建议:无论你用哪种锁,都要把“锁的key命名规范”和“锁的超时时间配置”当作一等公民来管理。这两个看似简单的东西,线上出问题的概率最高。养成在代码评审时关注锁范围、锁过期时间、锁释放逻辑的习惯,比讨论哪个分布式锁组件更好,对你的系统可能更有价值。
我就一个朴素的看法——分布式锁是一个非常重要的工具,但绝对不是万能药。想清楚你的业务到底在保护什么,再去选工具,才不至于被工具绑架,也才不会在一次“生死局”里站错队。
