Go 分布式锁实战:Redis Redlock 和 etcd 的实现差异与坑
Go 分布式锁实战:Redis Redlock 和 etcd 的实现差异与坑
一、一个库存超卖事故引发的思考
电商大促期间,库存扣减的并发请求同时打到两台服务节点。业务方使用的是单节点 Redis 实现的分布式锁,主从切换的那一秒,锁失效了。结果:1000 件库存卖了 1003 件,客服电话打爆。
分布式锁看起来简单,上锁、干活、解锁。但在真实的生产环境中,网络抖动、进程 GC、时钟偏移,每个环节都可能让锁变成摆设。
在选择方案之前,先明确分布式锁需要满足的条件:
- 互斥性:同一时刻只有一个客户端持有锁
- 防死锁:持有锁的客户端崩溃后,锁能自动释放
- 容错性:锁服务部分节点故障时,系统仍能工作
二、Redlock 和 etcd 的架构原理
两种方案的本质差异:
Redlock是多 Redis 实例上的主动加锁过程。客户端依次向 N 个独立的 Redis 节点申请锁,如果在大多数节点(N/2+1)上获取成功且总耗时小于锁的有效期,才算加锁成功。
etcd基于 Raft 共识协议。写操作由 Leader 发起、多数派确认后才返回成功。本质是借助共识算法的线性一致性来保证锁的互斥性。
三、两种方案的 Go 实现
Redlock 实现
package lock import ( "context" "crypto/rand" "encoding/hex" "fmt" "time" "github.com/go-redsync/redsync/v4" "github.com/go-redsync/redsync/v4/redis/goredis/v9" goredislib "github.com/redis/go-redis/v9" ) // RedlockManager Redlock 分布式锁管理器 type RedlockManager struct { rs *redsync.Redsync } // NewRedlockManager 创建 Redlock 管理器,传入多个 Redis 实例 func NewRedlockManager(addrs []string) *RedlockManager { var pools []redsync.Pool for _, addr := range addrs { client := goredislib.NewClient(&goredislib.Options{ Addr: addr, DialTimeout: 200 * time.Millisecond, // 连接超时,不能太长 ReadTimeout: 100 * time.Millisecond, WriteTimeout: 100 * time.Millisecond, }) pools = append(pools, goredis.NewPool(client)) } return &RedlockManager{rs: redsync.New(pools...)} } // Lock 获取分布式锁,返回锁句柄和错误 // name: 锁名称 // ttl: 锁失效时间,建议 8-30 秒 func (rm *RedlockManager) Lock(ctx context.Context, name string, ttl time.Duration) (*LockHandle, error) { // 生成随机值作为锁持有者标识 value := make([]byte, 16) if _, err := rand.Read(value); err != nil { return nil, fmt.Errorf("生成随机值失败: %w", err) } mutex := rm.rs.NewMutex( name, redsync.WithExpiry(ttl), redsync.WithTries(3), // 重试 3 次 redsync.WithRetryDelay(50*time.Millisecond), redsync.WithValue(hex.EncodeToString(value)), ) if err := mutex.LockContext(ctx); err != nil { return nil, fmt.Errorf("获取锁失败: %w", err) } return &LockHandle{mutex: mutex, name: name, ttl: ttl}, nil } // LockHandle 锁句柄 type LockHandle struct { mutex *redsync.Mutex name string ttl time.Duration } // Unlock 释放锁 func (lh *LockHandle) Unlock(ctx context.Context) error { if _, err := lh.mutex.UnlockContext(ctx); err != nil { return fmt.Errorf("释放锁失败: %w", err) } return nil } // Extend 续约锁的有效期(长任务需要) func (lh *LockHandle) Extend(ctx context.Context) error { if _, err := lh.mutex.ExtendContext(ctx); err != nil { return fmt.Errorf("续约锁失败: %w", err) } return nil }etcd 实现
package lock import ( "context" "fmt" "time" clientv3 "go.etcd.io/etcd/client/v3" "go.etcd.io/etcd/client/v3/concurrency" ) // EtcdLockManager etcd 分布式锁管理器 type EtcdLockManager struct { client *clientv3.Client } // NewEtcdLockManager 创建 etcd 锁管理器 func NewEtcdLockManager(endpoints []string) (*EtcdLockManager, error) { client, err := clientv3.New(clientv3.Config{ Endpoints: endpoints, DialTimeout: 5 * time.Second, }) if err != nil { return nil, fmt.Errorf("连接 etcd 失败: %w", err) } return &EtcdLockManager{client: client}, nil } // Lock 获取 etcd 分布式锁,通过 Session 机制自动续约 func (elm *EtcdLockManager) Lock(ctx context.Context, name string, ttl int) (*EtcdLockHandle, error) { // 创建 Session,ttl 内未续约则自动释放 session, err := concurrency.NewSession(elm.client, concurrency.WithTTL(ttl)) if err != nil { return nil, fmt.Errorf("创建 Session 失败: %w", err) } // 在 Session 上创建 Mutex mutex := concurrency.NewMutex(session, fmt.Sprintf("/locks/%s", name)) if err := mutex.Lock(ctx); err != nil { session.Close() // 加锁失败时清理 Session return nil, fmt.Errorf("获取锁失败: %w", err) } return &EtcdLockHandle{ mutex: mutex, session: session, }, nil } // EtcdLockHandle etcd 锁句柄 type EtcdLockHandle struct { mutex *concurrency.Mutex session *concurrency.Session } // Unlock 释放锁 func (elh *EtcdLockHandle) Unlock(ctx context.Context) error { if err := elh.mutex.Unlock(ctx); err != nil { return fmt.Errorf("释放锁失败: %w", err) } return elh.session.Close() }四、边界分析与选型 Trade-offs
Redlock 的已知问题:
- 时钟偏移风险:如果某个 Redis 节点的时钟出现跳跃,锁的过期时间可能缩短
- GC 暂停:Go 程序的 GC STW 时间如果超过锁 TTL,会导致锁误释放
- 主从切换:哨兵模式下主节点宕机,已获取的锁数据可能丢失
规避策略:
- TTL 设置为预期执行时间的 2~3 倍
- 单次操作超过锁有效期时,主动续约
- 业务层做乐观锁兜底(数据库 version 字段)
etcd 的适用边界:
- etcd 的可靠性和一致性优于 Redlock,但吞吐量远低于 Redis
- etcd 适合锁竞争低、一致性要求高的场景(如配置变更、Leader 选举)
- 不适合高并发的业务锁(如库存扣减)
选型速查表:
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 库存扣减等高并发锁 | Redlock | 高吞吐 |
| 分布式任务调度 | etcd | 强一致 + 自动续约 |
| 配置变更互斥 | etcd | 可 Watch 变更事件 |
| 短时业务锁(<1s) | 单 Redis | 够用,避免过设计 |
五、总结
Redlock 和 etcd 解决的是不同维度的问题:
- Redlock追求性能和吞吐,代价是边界条件下的安全性
- etcd追求一致性和可靠性,代价是写入性能
实际选型时不要迷信"高可用"方案。一个库存扣减场景用 Redlock 就够了,加上数据库乐观锁做兜底。只有涉及数据正确性不可逆的场景(资金转账、配置变更)才需要 etcd 的强一致保证。
补充一个生产教训:Go 里使用 Redlock 时,确保GOMEMLIMIT设置合理。如果节点 OOM 导致 GC 频繁,STW 时间超过锁 TTL 的概率会大幅增加。