分布式锁到底该怎么落地
聊聊分布式锁是干什么用的、为什么单机锁在集群环境下会失效,再用 Redis 手撸一版加锁解锁的完整流程,顺便把过期时间、误删锁、可重入这几个坑都踩一遍给你看。
分布式锁到底该怎么落地
最近排查一个线上问题,两台机器同时跑了一个定时任务,把同一批订单处理了两遍。
查了半天,发现代码里加的是 Java 自带的 synchronized。
单机上这把锁好使得很,一到多实例部署就直接失效。
这就是我们今天要聊的东西:分布式锁。
为什么单机锁不够用
synchronized、ReentrantLock 这些锁,锁的是同一个 JVM 进程里的线程。
它们靠的是内存里的一个标志位,大家抢的是同一块内存。
可一旦服务部署了多个实例,每个实例都是独立的进程,内存互相看不见。
A 机器上加的锁,B 机器完全不知道,该抢照样抢。
打个比方,单机锁像是一个房间里几个人抢一把椅子,谁先坐上谁赢。
分布式场景下,是好几个房间的人都想坐这把椅子,但椅子只有一把,还得放在大家都能看到、都能摸到的地方。
那这个“大家都能看到的地方”,就得找一个多个进程都能访问到的外部存储。
Redis、ZooKeeper、MySQL 都能干这个事,我们今天主要讲 Redis,因为它够快,用得也最广。
分布式锁的价值和意义
先说清楚为什么要费这个劲,而不是干脆不加锁。
- 保证数据一致性:多个实例同时改一份数据(比如库存扣减),没锁的话很容易出现脏读脏写。
- 避免重复执行:定时任务、消息消费这种场景,多实例部署时经常需要“只让一个人干活”。
- 控制资源访问:比如秒杀场景,某个热点资源需要串行处理,避免被同时冲垮。
说白了,分布式锁解决的核心问题就一个:在多进程/多机器的环境下,让某段代码在同一时刻只有一个执行者。
它不是万能药,用不好还会引入新问题,这个我们后面讲注意事项的时候细说。
用 Redis 实现一把最简单的锁
最直接的想法:利用 Redis 的 SET key value NX 命令。
NX 表示只有 key 不存在时才设置成功,这天然就是“抢锁”的语义。
// 老王的加锁函数,用 SET NX 抢一把锁
func TryLock(client *redis.Client, key string, value string, expire time.Duration) (bool, error) {
// SetNX 加了过期时间,避免加锁的服务挂了之后锁一直不释放
ok, err := client.SetNX(context.Background(), key, value, expire).Result()
if err != nil {
return false, err
}
return ok, nil
}
注意这里我直接用了 SetNX 加过期时间的组合命令,而不是先 SETNX 再单独 EXPIRE。
为什么?因为两条命令不是原子的,如果加锁成功后进程刚好挂了,EXPIRE 没执行上,这把锁就变成死锁,谁都抢不到了。
这是我们踩过的第一个坑:加锁和设置过期时间必须是一个原子操作。
解锁:不能随便删
加锁容易,解锁才是真正容易翻车的地方。
最容易犯的错误是这么写:
// 错误示范!千万别这么写
func UnlockWrong(client *redis.Client, key string) error {
return client.Del(context.Background(), key).Err()
}
这样写会出什么问题?
设想一下:A 服务加锁成功,业务逻辑跑得有点慢,锁到期自动释放了。
这时候 B 服务抢到了锁,正在干活。
A 服务这时候业务逻辑跑完了,调用解锁,直接把 B 的锁给删了。
结果 C 服务又抢到了锁,跟 B 同时在跑,又变成了我们最开始想避免的场面。
所以解锁前,必须先确认这把锁是不是自己加的。
怎么确认?加锁的时候塞一个唯一标识进去(比如 UUID 或者请求 ID),解锁时先比对再删除。
比对和删除这两步也得是原子操作,不然又会有类似的竞态问题。
这时候就得用 Lua 脚本了,Redis 执行 Lua 脚本本身是原子的:
// 解锁用的 Lua 脚本:先判断值是否匹配,匹配才删除
const unlockScript = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`
func Unlock(client *redis.Client, key string, value string) (bool, error) {
result, err := client.Eval(context.Background(), unlockScript, []string{key}, value).Result()
if err != nil {
return false, err
}
// result 是 1 表示删除成功,是自己的锁;0 表示锁不是自己的,或者已经过期了
return result.(int64) == 1, nil
}
这样一来,就算锁过期被别人抢走了,我们自己也不会误删别人的锁。
是不是感觉稳多了?
锁续期:业务跑太久怎么办
还有个问题:过期时间到底设多久合适?
设短了,业务还没跑完锁就没了,等于没加锁。
设长了,万一进程真的挂了,其他人要等很久才能抢到锁。
这个两难的问题,业界的解法叫“锁续期”,也就是常说的 Watchdog(看门狗)机制。
思路很简单:加锁成功后,起一个后台协程,定期检查业务是否还在跑,跑着就给锁“续命”,重新设置过期时间。
// 简化版的看门狗,实际项目建议用 redsync 这类成熟库
func WatchDog(ctx context.Context, client *redis.Client, key, value string, expire time.Duration) {
ticker := time.NewTicker(expire / 3) // 一般在过期时间的 1/3 时刻续期一次
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return // 业务跑完了,看门狗也该下班了
case <-ticker.C:
// 续期同样得判断锁是不是自己的,逻辑跟解锁类似,这里省略
client.Expire(context.Background(), key, expire)
}
}
}
Redisson 这种成熟的客户端库已经把看门狗机制封装好了,实际项目里我个人习惯直接用现成的库,没必要自己重复造轮子。
手写这一版主要是为了搞懂原理,真上生产环境还是建议用 redsync 或者 Redisson(Java 生态)。
注意事项:这些坑我们都得知道
聊了半天实现,最后总结一下用分布式锁容易踩的坑。
- 单点故障风险:如果只依赖单个 Redis 实例,Redis 挂了锁服务就全挂了。生产环境建议用 Redis Cluster 或者 Sentinel 做高可用。
- 主从切换的锁丢失问题:主从异步复制场景下,主节点写入锁成功但还没同步到从节点就宕机了,新主节点上这把锁就凭空消失了,其他实例又能抢到锁,造成锁失效。这也是为什么会有 Redlock 算法,用多个独立的 Redis 节点同时加锁来提高可靠性。
- 网络分区/GC 停顿:业务代码卡了一下(比如触发了一次 Full GC),锁过期了但业务代码还在跑,等它醒过来的时候,锁早被别人拿走了。这种情况看门狗机制能缓解,但没法根治,本质上分布式锁只能做到“大概率互斥”,没有百分百的保证。
- 锁的粒度别贪大:能锁一条数据就别锁一整张表,粒度越粗,并发能力越差,等于白白浪费了分布式系统的优势。
- 可重入的需求:如果同一个线程需要多次获取同一把锁(比如递归调用),普通的 SETNX 锁会把自己也挡在外面,得在 value 里记录持有者信息和重入次数,或者直接用 Redisson 这类支持可重入的客户端。
小结
分布式锁看起来是个“抢 key”的小把戏,其实里面藏了不少细节:加锁要原子、解锁要认锁、过期时间要能续、极端情况下还得接受它不是 100% 可靠这个事实。
如果业务对一致性要求没那么变态,Redis 分布式锁配合看门狗基本能覆盖大部分场景。
如果是金融级别的强一致性要求,可能就要考虑 ZooKeeper 这类基于共识算法的方案了,或者干脆从业务设计上避免争抢(比如用数据库唯一索引兜底)。
PS:不知道为什么,很多教程只讲 SETNX 就完事了,压根不提解锁误删和主从切换的坑。或许是觉得这些属于“进阶内容”,但我觉得这几个坑才是分布式锁真正难的地方。
那就先讲到这里,自行体验一下加锁解锁的完整流程吧。
