不能直接用os.openfile+syscall.flock做分布式锁,因为flock是本地锁,跨机器无效;nfs等共享文件系统不可靠;需用etcd(cas+租约)或redis(set nx px+lua删锁)等外部协调服务。

为什么不能直接用 os.OpenFile + syscall.Flock 做分布式锁
因为 syscall.Flock 是本地文件锁,只对同一台机器上的进程有效。跨机器部署时,两个节点同时打开同一个 NFS 文件,Flock 完全不互斥——它根本不知道对方的存在。你看到“加锁成功”,只是本机内核认为没冲突,和分布式无关。
真正需要的是带租约(lease)、有超时、支持多节点协调的机制。文件系统本身不具备原子性协调能力,必须借助外部一致服务(如 etcd、Redis)或自己实现基于文件的强一致性协议(比如通过写入带时间戳+节点 ID 的临时文件 + 定期心跳 + 清理逻辑),但后者极难做到正确。
- 不要试图用
/tmp/lockfile加Flock实现跨节点锁 - NFSv4 虽然支持
flock,但多数生产环境 NFS 配置不开启或未正确同步锁状态,行为不可靠 - 如果硬要用文件系统,唯一可行路径是:所有节点共享一个支持原子
mkdir的存储(如 ext4 上的本地磁盘 + 共享挂载 + 严格单点写入控制),但这已脱离“通用”范畴
用 etcd 实现 DistributedLock 接口最稳的姿势
etcd 的 CompareAndSwap(CAS)和租约(Lease)天然适合做分布式锁。Go 生态里 go.etcd.io/etcd/client/v3 提供了开箱即用的 clientv3.Concurrency.Mutex,但它封装太深,不易定制超时、重试、debug 日志等。更推荐直接用 clientv3.Txn 手写锁逻辑,可控性强。
核心逻辑三步:1)申请租约;2)用租约 ID 作为 value,尝试 Put 锁 key(条件:key 不存在);3)失败则监听 key 删除事件,再重试。
// 简化版加锁逻辑(省略错误处理和上下文控制)
leaseResp, err := cli.Grant(ctx, 10) // 10秒租约
if err != nil { return err }
txn := cli.Txn(ctx)
txn.If(clientv3.Compare(clientv3.CreateRevision("lock/myjob"), "=", 0)).
Then(clientv3.OpPut("lock/myjob", leaseResp.ID.String(), clientv3.WithLease(leaseResp.ID))).
Else(clientv3.OpGet("lock/myjob"))
- 必须用
CreateRevision == 0判断 key 是否首次创建,不能用Version == 0(已被删除后重建的 key 版本会归零) - 租约续期要单独 goroutine 调用
KeepAlive,否则锁会在租约到期后自动释放 - 解锁只需
clientv3.OpDelete,不用管租约——etcd 会自动清理关联租约
Redis 方案要注意 SET NX PX 和 Lua 原子删除
Redis 的 SET key val NX PX 30000 可以完成加锁,但解锁不能简单 DEL——必须确保只删自己设的值,否则出现误删。标准解法是用 Lua 脚本做原子校验删除:
Go 配置库,使用 spf13/viper — 分层优先级(flag > env >file > KV > default),提供 BindPFlag/BindPFlags、SetEnvPrefix + SetEnvKeyReplace 等功能。
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
在 Go 中用 redis.Client.Eval 调用,ARGV[1] 填入随机 UUID(锁标识),避免多个客户端共用同一连接时值混淆。
- 不要用
GET + DEL两步操作,中间可能被其他客户端抢占 - Redis 故障转移时,若主从复制延迟,可能出现短暂双主加锁——需配合 Redlock 或退回到 etcd
- 使用
github.com/go-redis/redis/v8时,SetNX方法返回 bool,但不带过期时间参数,得用Set并传redis.SetArgs{XX: false, PX: 30000}
定义通用接口时,别把 TryLock 和 Lock 混成一个方法
业务场景差异很大:定时任务需要阻塞等待锁(Lock),而 HTTP 请求通常只能接受有限等待(TryLock)。强行合并会导致调用方必须传 timeout=0 或 context.Background(),语义模糊且易出错。
建议接口至少包含:
type DistributedLock interface {
Lock(ctx context.Context) error // 阻塞直到获取或 ctx cancel
TryLock(ctx context.Context) (bool, error) // 立即返回是否成功
Unlock(ctx context.Context) error
}
-
Lock内部应支持指数退避重试,而不是死循环time.Sleep -
TryLock的返回值bool比error更直观——失败不一定是错误,可能是“当前无锁可用” - 所有方法都接收
context.Context,以便统一控制超时和取消,不要在内部硬编码time.Second * 5
真正麻烦的不是加锁逻辑,而是锁失效边界:网络分区时租约无法续期、GC 导致 goroutine 卡住错过心跳、etcd leader 切换期间事务丢失……这些没法靠接口抽象掉,得在具体实现里留好 debug hook 和 metrics 上报点。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!










