租约过期后的两个持有者
结算任务每分钟扫一批待结算订单,集群里有 6 个实例,靠一把 30 秒租约的锁保证同一时刻只有一个实例在跑。某个实例在拿到锁后发生了一次 40 秒的进程暂停:可能是 GC、可能是宿主机换页、也可能只是网络中断后重连。锁服务在 30 秒时让租约过期,另一个实例随即拿到锁并开始结算同一批订单。暂停结束后,第一个实例并不知道自己的租约已经失效,继续把内存里算好的结果写回数据库。两边的写入交错,同一笔订单被结算了两次。
错误直觉是“续约失败就退出,所以不会有两个持有者”。问题在于续约和业务写入是两个并发动作:业务线程可能已经带着旧租约走到写库前一步,续约线程才发现 ErrLeaseLost;即使业务线程每个循环都检查一次租约,检查与写入之间仍然存在窗口。锁服务只能告诉你“此刻谁持有租约”,它无法阻止一个已经暂停的进程在恢复后继续执行。
站内 分布式锁的实现与选型 已经比较过 Redis、etcd 与 ZooKeeper 的获取与释放流程,本节不再重复 API 用法,而是把“租约过期后如何保证存储不被陈旧持有者改写”写成可运行的代码,并明确 Redis 能提供什么、不能提供什么。
租约只保证租约期内的互斥
把锁拆成两个独立的部分,问题会清楚很多。
第一部分是租约服务:它在某个时刻只把 key 授予一个客户端,并为每次授予发一个单调递增的 token。租约保证的是“在租约有效期内,不会有两个客户端同时认为自己持有锁”。它不保证租约过期后旧客户端立刻停止,因为进程暂停、网络延迟和时钟误差都不受它控制。
第二部分是受保护的存储:每次写入都带上 token,存储记住见过的最大 token,拒绝更小的 token。这样即使旧持有者在暂停后恢复,它的写入也会被拒绝。两条不变量合起来:
- 不变量 A:任意时刻最多一个客户端持有未过期的租约。
- 不变量 B:存储接受的写入,其 token 不小于该 key 上已经出现过的最大 token。
只有 A,系统在暂停场景下会出现双写;只有 B,没有互斥但至少不会让陈旧写入覆盖新写入。两者都有,才能同时获得“平时只有一个写者”和“异常时旧写者无效”。
不变量 B 的实现有一个容易忽略的前提:比较 token 和写入值必须在存储端原子完成。应用先 GET max_token、比较、再 SET,中间仍可能插入一个更高 token 的写入,等于没有 fence。数据库里对应的是 UPDATE ... WHERE token <= ? 或带条件的行锁,Redis 里对应一段 Lua 脚本。
可运行的租约与 fencing token
下面的实现把租约管理器和受保护存储放在同一个包里。Manager 负责授予租约和递增 token;FenceStore 在写入时检查 token。测试用注入的时钟推进过期,不依赖真实 sleep。
// file: internal/platform/lock/lease.go
// tier: L2
// verify: go test ./lock/ -count=1
package lock
import (
"errors"
"fmt"
"sync"
"time"
)
var (
// ErrLocked means another live lease owns the key.
ErrLocked = errors.New("lock: key already leased")
// ErrLeaseLost means the caller no longer owns a live lease.
ErrLeaseLost = errors.New("lock: lease lost")
// ErrStaleToken means a write arrived from an older fencing token.
ErrStaleToken = errors.New("lock: stale fencing token")
)
// Token is a monotonically increasing fencing token issued at acquisition.
type Token int64
// Clock is the wall clock, injectable for tests.
type Clock interface {
Now() time.Time
}
// Manager issues leases and fencing tokens. It stands in for etcd or Redis;
// the safety argument below does not depend on which one is used.
type Manager struct {
mu sync.Mutex
clock Clock
leases map[string]lease
nextTok Token
}
type lease struct {
token Token
expiresAt time.Time
}
// NewManager returns an empty lease manager.
func NewManager(clock Clock) *Manager {
return &Manager{clock: clock, leases: make(map[string]lease)}
}
// Acquire grants a lease for key and returns its fencing token.
func (m *Manager) Acquire(key string, ttl time.Duration) (Token, error) {
m.mu.Lock()
defer m.mu.Unlock()
now := m.clock.Now()
if cur, ok := m.leases[key]; ok && now.Before(cur.expiresAt) {
return 0, fmt.Errorf("lock: acquire %q held by token %d: %w", key, cur.token, ErrLocked)
}
m.nextTok++
l := lease{token: m.nextTok, expiresAt: now.Add(ttl)}
m.leases[key] = l
return l.token, nil
}
// Renew extends a live lease. A lease that already expired cannot be revived.
func (m *Manager) Renew(key string, token Token, ttl time.Duration) error {
m.mu.Lock()
defer m.mu.Unlock()
cur, ok := m.leases[key]
if !ok || cur.token != token || !m.clock.Now().Before(cur.expiresAt) {
return fmt.Errorf("lock: renew %q token %d: %w", key, token, ErrLeaseLost)
}
cur.expiresAt = m.clock.Now().Add(ttl)
m.leases[key] = cur
return nil
}
// Release drops a lease only if token still owns it.
func (m *Manager) Release(key string, token Token) error {
m.mu.Lock()
defer m.mu.Unlock()
cur, ok := m.leases[key]
if !ok || cur.token != token {
return fmt.Errorf("lock: release %q token %d: %w", key, token, ErrLeaseLost)
}
delete(m.leases, key)
return nil
}
// FenceStore is the resource that the lease protects. It remembers the
// highest token it has seen per key and rejects anything older.
type FenceStore struct {
mu sync.Mutex
maxToken map[string]Token
values map[string]string
rejected int
}
// NewFenceStore returns an empty store.
func NewFenceStore() *FenceStore {
return &FenceStore{maxToken: make(map[string]Token), values: make(map[string]string)}
}
// Write accepts value only when token is not behind the fence.
func (s *FenceStore) Write(key string, token Token, value string) error {
s.mu.Lock()
defer s.mu.Unlock()
if max, ok := s.maxToken[key]; ok && token < max {
s.rejected++
return fmt.Errorf("lock: write %q token %d behind fence %d: %w", key, token, max, ErrStaleToken)
}
if max, ok := s.maxToken[key]; !ok || token > max {
s.maxToken[key] = token
}
s.values[key] = value
return nil
}
// Value returns the stored value.
func (s *FenceStore) Value(key string) string {
s.mu.Lock()
defer s.mu.Unlock()
return s.values[key]
}
// Rejected reports how many stale writes were refused.
func (s *FenceStore) Rejected() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.rejected
}
// NaiveStore is the counterexample: it stores values without checking tokens,
// which is what a lease-only design degrades to after a pause.
type NaiveStore struct {
mu sync.Mutex
value string
}
// Write overwrites the value unconditionally.
func (s *NaiveStore) Write(_ Token, value string) {
s.mu.Lock()
defer s.mu.Unlock()
s.value = value
}
// Value returns the stored value.
func (s *NaiveStore) Value() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.value
}
Manager 的 token 是进程内单调的,这对单实例测试足够,但生产上 token 必须跨实例、跨锁服务主从切换单调。用 etcd 时可以用事务的 revision;用 Redis 时如果主从切换丢了 INCR 的计数,新主可能发出更小的 token,fence 就出现倒退。这一点在用 Redis 做锁服务时经常被忽略。
FenceStore.Write 允许 token 等于当前最大值,这是为了同一个持有者可以多次写入;一旦出现更大的 token,旧 token 的所有后续写入都会被拒绝。拒绝不是“丢弃后继续跑”,调用方必须停止当前工作并重新获取租约。
续约不是保命符
Renew 只在租约仍然有效时延长过期时间;已经过期的租约不能复活。这个语义很重要:如果允许复活,一个暂停很久的客户端恢复后可以把租约抢回来,而在这期间另一个客户端已经完成了工作,token 也更高,双方会陷入“谁先恢复谁持有”的混乱。
租约 TTL 的选择常被误解为“要比最长 GC 还长”。最长 GC 没有上界,任何固定 TTL 都可能被超过;TTL 的作用是限定故障恢复时间,即持有者崩溃后多久其他实例可以接管。陈旧写入由 fencing token 挡住,而不是由 TTL 挡住。把 TTL 设得很大只会延长故障恢复时间,并不能消除双写窗口。
续约循环要满足三个条件。第一,续约失败后立即取消业务上下文,而不是继续重试;第二,取消要传播到正在执行的写操作,但不要假设写操作一定来得及停止;第三,续约线程与业务线程的生命周期分离,业务完成时主动停止续约,避免租约被无意义地续下去。
本机实测:过期、陈旧写入与反例
在独立 module 中运行,Go 1.26.0、darwin/arm64,使用注入时钟,测试不依赖真实等待:
$ go test ./lock/ -v -count=1
=== RUN TestLeaseExpiresAndNewHolderGetsToken
lease_test.go:49: expired lease replaced: token_a=1 token_b=2
--- PASS: TestLeaseExpiresAndNewHolderGetsToken (0.00s)
=== RUN TestFenceRejectsExpiredHolder
lease_test.go:72: stale write rejected: token_a=1 token_b=2 rejected=1 value=reserved-by-b
--- PASS: TestFenceRejectsExpiredHolder (0.00s)
=== RUN TestWithoutFenceExpiredHolderOverwrites
lease_test.go:88: without fence: final value=reserved-by-a-late (stale holder won)
--- PASS: TestWithoutFenceExpiredHolderOverwrites (0.00s)
=== RUN TestRenewBeforeExpiryExtends
--- PASS: TestRenewBeforeExpiryExtends (0.00s)
=== RUN TestRenewAfterExpiryFails
--- PASS: TestRenewAfterExpiryFails (0.00s)
=== RUN TestConcurrentAcquireSingleHolder
--- PASS: TestConcurrentAcquireSingleHolder (0.00s)
PASS
ok archlab/lock 0.263s
$ go test ./lock/ -race -count=1
ok archlab/lock 1.594s
读数:A 拿到 token 1,30ms 租约过期后 B 拿到 token 2,证明租约确实被重新授予;B 写入后 A 再用 token 1 写入,返回 ErrStaleToken,存储里仍是 reserved-by-b,拒绝计数为 1。对照组换成不检查 token 的 NaiveStore,A 的迟到写入把值改成了 reserved-by-a-late,这正是没有 fencing 时会发生的事故。续约测试验证了有效期内续约可以延长租约、过期后续约返回 ErrLeaseLost 且旧 token 无法释放新租约。50 个并发获取只有一个成功。
这些结果是内存模型上的行为验证,不是 Redis 或 etcd 的实测,也没有模拟网络分区。它证明的是协议层的两条不变量,而不是某个锁服务实现的可靠性。
Redis 的边界
Redis 的单实例锁通常写成 SET key value NX PX ttl,释放时用 Lua 比较 value 再删除。这个方案在单实例、不发生主从切换、客户端不长时间暂停时是有效的;一旦出现下面任一情况,安全性就不再成立:
| 场景 | 会发生什么 | fencing 能否补救 |
|---|---|---|
| 主从异步复制,主在授予锁后宕机 | 锁未复制到从库,新主把同一 key 再授予别人 | 能,前提是存储检查 token |
| 客户端在获取锁后长时间暂停 | 租约过期,别人拿到新 token | 能,旧 token 被存储拒绝 |
| 客户端时钟大幅前跳 | 本地判断租约未过期,服务端已过期 | 能,但客户端会继续做无效工作 |
| 释放锁时不校验 value | 删除了别人的锁 | 不能,这是实现 bug |
| Redlock 多数派获取后客户端暂停 | 租约仍会过期,另一个客户端可获取 | 能,Redlock 本身不提供 fence |
Redlock 的争议点常被简化成“Redis 不可靠”,更准确的说法是:Redlock 试图在异步复制的多个 master 上提高获取阶段的容错,但它仍然是一个带 TTL 的租约,仍然假设时钟漂移有界,也没有让受保护资源拒绝旧持有者。对于“同一时刻只有一个写者”的强需求,单靠任何租约都不够,必须把 token 带到存储端。
反过来,如果存储端已经有不变量和条件更新,锁可以退化成性能优化:锁减少冲突和重试,正确性由存储的版本号或条件写保证。这正是下一节要展开的内容。选型时先问“存储能不能拒绝陈旧写入”,再问“锁服务用哪个”,顺序不能反。
选型判据
| 需求 | 推荐机制 | 是否依赖锁 | 关键检查 |
|---|---|---|---|
| 防止定时任务重复触发 | 幂等键 + 唯一约束 | 否 | 副作用与键写入同事务 |
| 降低并发冲突、减少重试 | 短租约锁 | 是,仅性能 | 锁失效后业务仍正确 |
| 保护不可幂等的外部操作 | 共识租约 + fencing token | 是 | 外部资源校验 token |
| 严格单写者、强互斥 | etcd/ZooKeeper 租约 + 存储 fence | 是 | token 跨故障单调 |
| 无额外组件 | 数据库行租约 + 版本条件 | 部分 | 行锁事务边界 |
“外部资源校验 token”是最容易被跳过的一步。支付网关、对象存储、消息队列通常不支持 fencing token;这时要么把不可幂等操作改成幂等(用业务唯一键),要么把写入口收敛到一个能校验 token 的内部服务,不能假设锁服务能替你挡住旧持有者。
常见错误与边界
第一,释放锁不校验持有者。DEL key 会把别人的锁删掉,正确做法是比较 value 或 token 后再删;在 Redis 中用 Lua 把比较和删除合成原子操作。
第二,续约协程在业务结束后还在跑。业务已经完成、锁已经释放,续约线程又用旧 token 续约失败后重试,日志里全是噪声;更糟的是它可能把已经释放的锁重新建立,阻塞其他实例。
第三,把 TTL 当成业务超时。业务超时应该由上下文控制,TTL 控制的是故障接管时间;两者混在一起会导致“业务还在跑但锁已经过期”或“业务早已超时但锁还占着”。
第四,认为 Redlock 消除了主从切换风险。Redlock 提高的是获取阶段的容错,不改变“租约过期后旧持有者仍可能写”的事实,也不提供 fencing。
第五,token 只打在日志里。fencing token 必须随每一次写请求传到存储,并由存储原子校验;只在应用层比较、或者只在日志里记录,等于没有 fence。
小结
租约锁提供的是有限时间内的互斥,不是无限期的所有权。进程暂停、网络中断和主从切换都会让旧持有者在租约过期后继续行动;fencing token 让存储拒绝更小的 token,从而把“谁持有锁”变成“谁持有最新的写权限”。本机测试复现了 token 1 被 token 2 拒绝,以及没有 fence 时迟到写入覆盖新值的反例,但没有验证真实 Redis 的复制延迟。锁失效既然无法完全避免,下一步就不是继续加固锁,而是让存储在锁失效时仍然正确,这需要版本号、条件更新与幂等。
阅读导航:上一节:分布式 ID:Snowflake、号段与时钟回拨 · 下一节:锁失效后的正确性:从互斥到幂等 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。