flock不能用作分布式锁,因其是单机内核级劝告锁,在nfs等共享存储上不保证跨节点一致性,多节点调用可能同时成功;真正可行的是基于原子写+时间戳的租约式文件锁方案。

为什么不能直接用 os.OpenFile 加 flock 做分布式锁
因为 flock 是内核级的 advisory 锁,只在单机文件系统生效,NFS、Ceph、S3 等共享存储不保证一致性,多个节点调用 flock 可能同时成功。Golang 标准库的 os.File 无法跨机器协同,这不是代码写得对不对的问题,是底层语义就不支持。
syscall.Flock 在分布式场景下会失效的具体表现
常见错误现象:本地测试一切正常,一上 K8s 多副本或跨 VM 就出现双写、任务重复执行;syscall.Flock 返回 nil,但其他节点也拿到锁;日志里看不到报错,逻辑却乱了。
- 即使挂载同一块 NFS 卷,Linux 内核对
flock的实现不跨主机同步状态 -
syscall.Flock不会阻塞等待,也不会自动重试,需手动轮询,但轮询间隔和超时难控制 - 锁文件被意外删除后,
flock不会自动释放(因为没进程持有),导致死锁
真正可用的替代方案:基于原子写 + 时间戳的文件锁
核心思路是放弃依赖内核锁,改用「文件存在性 + 内容校验 + 过期时间」三重判断。本质是把锁降级为“租约”,靠应用层逻辑保障互斥。
- 用
os.WriteFile(或ioutil.WriteFile)配合os.O_CREATE | os.O_EXCL标志尝试创建锁文件,失败说明已被占 - 锁文件内容必须包含持有者标识(如 hostname + pid)和过期时间(Unix 时间戳),例如:
{"holder":"node-1:1234","expire":1717023456} - 每次获取锁前先读取现有锁文件,检查是否过期;过期则尝试用
os.Rename原子替换(注意:不是覆盖写,是 rename 覆盖) - 持有锁期间需定期刷新过期时间(通过重新 write + rename),否则可能被其他节点抢占
示例关键片段:
data := []byte(`{"holder":"` + hostname + `:` + strconv.Itoa(os.Getpid()) + `","expire":` + strconv.FormatInt(time.Now().Add(10*time.Second).Unix(), 10) + `}`)
err := os.WriteFile(lockPath, data, 0644)
if err != nil && os.IsExist(err) {
// 文件已存在,读取并检查过期
if !isLockExpired(lockPath) {
return false, errors.New("lock held by another process")
}
// 尝试原子替换
tmpPath := lockPath + ".tmp"
os.WriteFile(tmpPath, data, 0644)
os.Rename(tmpPath, lockPath)
}
生产环境必须处理的边界问题
文件锁不是“开箱即用”的分布式原语,很多细节不处理就会出事:
- 锁路径必须落在所有节点可访问且**强一致**的存储上(如 etcd-backed volume、AWS EBS 多挂载需额外协调,不推荐 NFS)
- 没有自动续期机制的话,长任务容易丢锁;加续期要避免 ABA 问题(比如用单调递增的 lease ID)
- 节点宕机后锁不会自动释放,得靠过期时间兜底,所以过期时间不能设太长(否则故障恢复慢),也不能太短(否则误抢)
- 不要用
os.Remove主动删锁文件——其他节点可能正读到一半,应让过期逻辑自然淘汰
真正复杂的点不在加锁,而在判断“谁该释放”“什么时候算失效”“怎么防止惊群”。文件系统本身不提供分布式共识,所有逻辑都得自己补全。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











