《后端架构设计·分布式与数据一致性》10.2 分布式锁:租约、fencing token 与 Redis 边界

结算任务在租约过期后仍继续写库存,是分布式锁最常见的生产事故。本节用可运行的 Go 租约管理器与 fencing token 存储复现两个持有者同时写入,实测陈旧 token 被拒绝、无 fencing 时迟到写入覆盖新值,并给出 Redis 主从切换、Redlock 与进程暂停下的安全边界。

租约过期后的两个持有者

结算任务每分钟扫一批待结算订单,集群里有 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、号段与时钟回拨 · 下一节:锁失效后的正确性:从互斥到幂等 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查