在分布式系统中,多个服务实例同时访问共享资源是常见场景。比如秒杀活动中扣减库存、定时任务的执行、订单状态的更新等,都需要一种机制来确保同一时刻只有一个实例能操作。这就是分布式锁要解决的问题。
结合我的实际项目经验,这篇文章来聊聊Go语言中实现分布式锁的几种主流方案,选对工具,少走弯路。
为什么需要分布式锁?
单机环境下,我们可以用sync.Mutex轻松实现互斥锁。但在分布式系统中,多个服务实例运行在不同的机器上,内存不共享,本地锁就失效了。
举个例子:用户A在节点1上修改订单状态,用户B同时在节点2上也想修改同一订单。如果没有分布式锁,两个节点同时操作,数据就乱套了。
分布式锁的核心要求:
- 互斥性:同一时刻只有一个客户端能持有锁
- 防死锁:锁必须有过期机制,避免客户端崩溃后锁无法释放
- 高可用:锁服务本身要稳定可靠
方案一:Redis分布式锁
Redis是最常用的分布式锁实现方案,核心原理是利用SETNX命令(Set if Not eXists)的原子性。
工作原理:
- 客户端尝试设置一个key,如果key不存在则设置成功,获得锁
- 设置过期时间,防止客户端崩溃后锁无法释放
- 释放锁时,需要验证锁的持有者,避免误删其他客户端的锁
优点:性能高,实现简单,Redis部署维护方便
缺点:在Redis主从切换时可能出现锁丢失问题;需要手动处理锁续期
适用场景:高并发、对可靠性要求不是极端苛刻的场景
推荐开源库:github.com/go-redsync/redsync/v4
client := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
rs := redsync.New(goredis.NewPool(client))
mutex := rs.NewMutex("my-lock", redsync.WithExpiry(10*time.Second))
if err := mutex.Lock(); err != nil { return }
defer mutex.Unlock()
方案二:ZooKeeper分布式锁
ZooKeeper是Apache旗下的分布式协调服务,天生适合做分布式锁。
工作原理:
- 创建一个持久节点作为锁的根目录
- 每个客户端在根目录下创建临时有序节点
- 节点序号最小的客户端获得锁
- 其他客户端监听比自己序号小的前一个节点
- 前一个节点删除(锁释放)时,当前客户端被唤醒
优点:可靠性极高,支持公平锁(按请求顺序获取),会话断开自动释放锁
缺点:性能相对较低,ZooKeeper集群部署和维护较复杂
适用场景:对可靠性要求高、并发度适中的场景,如配置中心、选主场景
推荐开源库:github.com/go-zookeeper/zk
conn, _, _ := zk.Connect([]string{"127.0.0.1:2181"}, time.Second*5)
lock := zk.NewLock(conn, "/my-lock", zk.WorldACL(zk.PermAll))
if err := lock.Lock(); err != nil { return }
defer lock.Unlock()
方案三:etcd分布式锁
etcd是CoreOS开发的分布式键值存储,Kubernetes就是基于etcd构建的。
工作原理:
etcd提供了concurrency包,开箱即用的分布式锁实现:
- 创建一个租约(Lease),设置TTL
- 创建锁对应的key,绑定租约
- 如果key不存在则创建成功,获得锁
- 租约到期自动释放锁,也支持手动续期
优点:性能好、可靠性高、支持租约机制、部署相对简单
缺点:相比Redis实现稍重,需要依赖etcd集群
适用场景:云原生环境,特别是已经使用etcd的项目(如Kubernetes生态)
推荐开源库:go.etcd.io/etcd/client/v3/concurrency
cli, _ := clientv3.New(clientv3.Config{Endpoints: []string{"localhost:2379"}})
session, _ := concurrency.NewSession(cli, concurrency.WithTTL(10))
mutex := concurrency.NewMutex(session, "/my-lock/")
mutex.Lock(context.Background())
defer mutex.Unlock(context.Background())
// 执行业务逻辑
方案四:数据库分布式锁
如果不想引入额外组件,用现有数据库也能实现分布式锁。
工作原理:
- 创建一张锁表,设置唯一索引
- 获取锁时插入一条记录,插入成功则获得锁
- 释放锁时删除该记录
- 可以增加过期时间字段,定期清理过期锁
优点:实现简单,无需额外组件,适合已有数据库的场景
缺点:性能较低,数据库压力大,需要处理死锁和过期清理
适用场景:并发度低、对性能要求不高的场景
推荐开源库:无(实现简单,通常自行实现)
// 获取锁:插入记录,唯一索引冲突则失败
_, err := db.Exec("INSERT INTO locks(lock_key, owner) VALUES(?, ?)", key, owner)
// 释放锁:删除记录
db.Exec("DELETE FROM locks WHERE lock_key = ? AND owner = ?", key, owner)
如何选择?
| 方案 | 性能 | 可靠性 | 部署难度 | 推荐场景 |
|---|---|---|---|---|
| Redis | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | 低 | 高并发、秒杀、抢红包 |
| ZooKeeper | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | 高 | 选主、配置中心、元数据管理 |
| etcd | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | 中 | 云原生、K8s生态 |
| 数据库 | ⭐⭐ | ⭐⭐⭐ | 低 | 低并发、快速验证 |
实践建议
1. 设置合理的过期时间
过期时间太短,任务没执行完锁就释放了;太长,客户端崩溃后其他客户端要等很久。建议根据业务最长执行时间设置,并实现锁续期机制。
2. 释放锁要验证持有者
释放锁时,务必确认锁是自己持有的,否则可能误删其他客户端的锁。Redis可以用Lua脚本保证原子性。
3. 做好降级和容错
分布式锁服务可能不可用,业务代码要有降级策略,比如本地锁兜底、返回错误让用户重试等。
写在最后
分布式锁没有银弹,选型时需要权衡性能、可靠性、部署成本。对于大多数互联网应用,Redis分布式锁是首选;对可靠性要求极高的场景,ZooKeeper或etcd更合适;快速验证或低并发场景,数据库锁也能胜任。
记住:技术选型的本质是权衡,没有最好的方案,只有最适合的方案。