分布式锁到底该怎么落地

聊聊分布式锁是干什么用的、为什么单机锁在集群环境下会失效,再用 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 就完事了,压根不提解锁误删和主从切换的坑。或许是觉得这些属于“进阶内容”,但我觉得这几个坑才是分布式锁真正难的地方。

那就先讲到这里,自行体验一下加锁解锁的完整流程吧。

更多推荐

章节目录