Skip to content

分布式基础组件:锁、ID、任务和会话都要面对“旧请求还活着” ​

租约已经到期,旧进程却从长暂停里恢复;任务已执行,调度器没收到回执;新节点刚领到工作,旧节点还在发送写入。这些问题不能靠“再加一把分布式锁”统一解决。需要分别定义资源所有权、请求身份、重复执行和状态失效的边界。

本文把分布式锁、ID、定时任务和会话放在一起讲,重点是每种组件到底承诺什么,以及应用还要补什么。代码实际演示过期持有者的写入拒绝、雪花 ID 的同毫秒序列与回拨拒绝、同一任务槽位的重复执行去重;所有示例都只是本地模型。

分布式锁:先区分“谁拿到锁”和“谁还能改资源” ​

一个典型租约锁有名字、持有者标识和有效期。有效期能在持有者失联时释放资源,让别人继续工作。但远程锁服务删除一条记录,并不会杀死旧进程,也不会撤销已经发往数据库的请求。

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
sequenceDiagram
    participant A as 旧持有者 A
    participant L as 锁服务
    participant B as 新持有者 B
    participant R as 被保护的资源
    A->>L: 获得租约,token=41
    Note over A: 长暂停,未能续约
    L-->>B: 租约过期后授予 token=42
    B->>R: Write(42, B)
    R-->>B: 接受,记录最高 token=42
    A->>R: 恢复后 Write(41, A)
    R-->>A: 拒绝过旧 token
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
sequenceDiagram
    participant A as 旧持有者 A
    participant L as 锁服务
    participant B as 新持有者 B
    participant R as 被保护的资源
    A->>L: 获得租约,token=41
    Note over A: 长暂停,未能续约
    L-->>B: 租约过期后授予 token=42
    B->>R: Write(42, B)
    R-->>B: 接受,记录最高 token=42
    A->>R: 恢复后 Write(41, A)
    R-->>A: 拒绝过旧 token

图中的 token 是 fencing token,栅栏令牌:每次新的所有权得到一个更大的编号,资源端保存已经看到的最高编号,并在同一个原子写入判断里拒绝较旧编号。Chubby 论文用 sequencer 描述了让资源服务检查锁代次的思路。[1]

“写入前确认锁没过期”为什么仍有空隙? ​

A 可以先确认锁有效,随后暂停;B 在租约过期后拿锁并写入;A 恢复后继续执行先前准备好的写入。确认与写入之间的时间差,足以让“先检查再操作”失效。安全条件必须延伸到实际接受写入的资源端。

模型的判断只有几行:

go
if token < r.Fence {
    return errors.New("stale fence")
}
r.Fence = token
r.Value = value

实际运行先写 token 42,再写 token 41:

text
new owner: <nil>
expired owner: stale fence value: B

这里需要三个限定。第一,资源要原子地比较令牌并更新数据,真实部署还要持久化这个状态。第二,该模型在资源看见新令牌之后才拒绝旧令牌;租约一过期、但新令牌尚未到达时,它不会凭空知道旧令牌已经失效。第三,同一令牌允许多次写入,不能解决同一持有者自己的请求乱序;必要时还需操作序号或版本条件。

随机 UUID 作为持有者标识,可以防止 A 删除 B 的锁,但随机值没有新旧顺序,因此不等于 fencing token。两个机制解决的是不同问题。

Redis、etcd、ZooKeeper 的锁有什么差别 ​

组件常见获取方式丢失所有权的主要信号应用仍要补什么
Redis单键 SET key unique-value NX PX ttl;释放时原子比较 value 后删除TTL 到期、续约失败、连接不确定业务幂等或资源端栅栏;核对复制/故障转移模型
etcdlease 绑定键,事务竞争或官方 Lock API/concurrency 库lease/session 丢失、竞争键消失将所有权条件纳入实际写入;外部资源需可验证的 fencing
ZooKeeper临时顺序节点,最小序号持锁,其余监听前驱会话过期、临时节点删除处理断连与会话语义、重试创建的身份、外部资源栅栏

Redis:value 防误删,TTL 防永久占用 ​

Redis 官方分布式锁文档给出 SET ... NX PX 和“比较随机 value 再删除”的释放方式。若只做 DEL key,A 的锁已过期、B 已经取得同名锁时,迟到的 A 会删掉 B 的锁。[2]

比较和删除必须是服务器端的原子操作,不能先 GET,再在客户端决定 DEL。续约同样应确认自己还是持有者;续约响应超时要按所有权不确定处理。

单主 Redis 加异步复制,还存在主节点确认加锁后、锁记录尚未复制就故障的窗口;副本升主后可能再次授予同名锁。官方文档解释了这个原因,并介绍 Redlock 在多个独立 Redis 节点上获取多数锁、扣除获取耗时等做法。[2] Redlock 的有效性依赖其时间与故障假设,不能据此宣称任何暂停和分区下都能保护任意外部写入。涉及不可重复的资源更新时,优先让目标存储提供条件更新、唯一约束或可验证栅栏。

etcd:可以把锁的条件带入 etcd 事务 ​

etcd 的 lease 到期会删除绑定的键;Lock API 用带 lease 的锁键组织所有权。KV 事务可以比较键版本/修订并在同一原子事务里修改 etcd 数据。[3][4]

这使“我还拥有竞争键”和“我要修改的也是 etcd 里的值”有机会放进同一事务范围。如果被保护对象在另一个数据库,先检查 etcd 再发 SQL 仍然跨两步,前面的暂停空隙重新出现。

etcd 的 create revision 常被用于构造可比较的锁代次,但必须保证令牌来源的单调语义跨故障恢复仍成立。把旧快照恢复成一个新的逻辑集群时,不能假设外部资源看到的代次会天然连续;应把恢复流程、代次空间和拒绝旧客户端的策略一起设计。本文没有把任意 revision 都当作通用安全令牌。

ZooKeeper:监听前驱,减少同一时刻一起抢锁 ​

官方锁配方创建临时顺序节点;客户端检查自己是否序号最小,否则只监听前一个节点,前驱消失后再检查。这样释放一次锁通常只唤醒下一名,避免所有等待者一起竞争。[5]

临时节点绑定会话,TCP 暂时断开不等于服务器已经判定会话过期。客户端断连时无法确认自己仍然持锁,应该按协议停止依赖所有权的工作;但“停止工作”仍不能召回已发出的外部请求。创建请求的响应丢失,也要能识别是否已经创建成功,不能每次重试都留下新的竞争节点。

顺序节点编号的作用受父节点生命周期和实现边界限制,不应在删除重建父节点后继续声称编号全局永不重用。若将节点序号或其他版本信息当 fencing token,需要先证明该范围内的代次不会回退。

分布式 ID:先决定唯一性由谁承担 ​

ID 常常同时被要求“唯一、紧凑、趋势递增、无中心、能排序”。这些要求会争用同一批位数和协调成本。应先区分:ID 唯一只解决标识,不能直接证明业务动作的发生顺序。

方案唯一性主要依赖优点代价和适用边界
雪花类 ID时间位、唯一节点号、同时间片序列不重复64 位紧凑,本地生成,按时间大致聚集节点号分配、回拨、序列耗尽、重启状态都要管理
号段模式数据库等权威分配器原子发放互不重叠区间区间内本地生成;分配器调用频率较低预取、故障余量、区间浪费;全局发号顺序未必递增
UUIDv7毫秒时间字段 + 规范允许的随机/单调构造128 位、自描述版本、时间局部性,常不需中心发号碰撞是概率与实现问题;时钟回拨和同毫秒次序需处理

雪花算法:最难的两位不是移位运算 ​

Twitter 历史实现常用 41 位毫秒偏移、10 位节点信息、12 位同毫秒序列,另留符号位;位数与 epoch 可以在其他实现里不同。[6] 这组划分意味着一个节点每毫秒可用 4096 个序列值,41 位毫秒大约覆盖 69.7 年。真正困难的是节点号是否会被同时复用,以及时钟倒退后会不会重新生成旧组合。

模型使用同样的位数,last=-1 初始化,并在回拨或序列耗尽时返回错误:

go
s := Snowflake{last: -1, node: 3}
a, e := s.Next(1000)
must(e)
b, e := s.Next(1000)
must(e)
fmt.Println("same ms distinct:", a != b, "ordered:", a < b)
_, e = s.Next(999)
fmt.Println("rollback:", e)

实际输出:

text
same ms distinct: true ordered: true
rollback: clock rollback

这里的 ms 是相对自定义 epoch 的毫秒偏移,不能直接把当前 Unix 毫秒塞进 41 位。模型只供单线程调用,节点号由调用者保证唯一,不持久化 last;进程重启、并发调用和节点号再次分配均未解决,因此不是生产发号器。

实际实现可以在序列用完时等待下一个时间片,在小幅回拨时等待,也可以使用保守的逻辑时间并监控偏差。每个选择都有代价:等待影响延迟,拒绝影响可用性,逻辑时间可能偏离真实时间,状态丢失仍可能重号。不要静默把回拨后的序列重置成零。

号段:先领一箱号码,再在本地逐个拿 ​

分配器原子把某个业务的最大值从 10000 更新到 11000,把 (10000,11000] 发给一个进程。另一个进程领取下一个不重叠区间。进程内用同步计数器发号;快用完时预取下一段,可以遮住分配器的部分延迟。美团 Leaf 的原始介绍讲了号段及双 buffer 的工程实现。[7]

优势来自减少中心访问次数,代价是进程退出后未用完的号码可能被浪费,通常应接受空洞,不能为了连续无缺口把旧号段重新发给别人。两个进程分别持有相邻号段时,后一段可能先发出去,所以“数值较小”不严格等于“更早创建”。分配器备份恢复如果退回旧的最大值,也可能重复分配区间;这需要恢复策略保护。

UUIDv7:时间有序不等于业务全局顺序 ​

RFC 9562 的 UUIDv7 放入 48 位 Unix 毫秒时间戳,版本和变体占固定位,其余 74 位按规范可用于随机位,或结合亚毫秒精度、计数器等实现单调生成。[8,§5.7、§6.2]

不同节点可能有时钟偏差,同一毫秒也可能独立生成多个 UUID。即便字节排序有良好的时间局部性,也不能据此判断哪个付款先完成,更不能替代日志索引或事务版本。

纯随机尾部的碰撞风险与同时间桶内数量、随机质量及实现有关,并非数学上不可能。对同毫秒使用计数器的实现,还要检查计数器耗尽、线程安全、回拨与重启策略。选择成熟、符合 RFC 的库,核对其承诺,再用数据库唯一约束作为最后一道发现冲突的机制;唯一约束能拒绝冲突,不能保证生成器永不碰撞。

分布式定时任务:调度、执行、效果是三本账 ​

多个调度器都知道“每天 09:00 做结算”,不代表这笔结算只发生一次。调度记录创建成功后,调度器可能没收到响应;工作节点完成后也可能没能回报,于是另一个节点重试。

层次建议记录的身份与状态故障后要回答的问题
调度job ID、计划时刻、时区、调度版本这个时间槽有没有生成执行记录?错过的槽是否补跑?
执行run ID、尝试次数、持有者、lease/fence、心跳谁还可以执行?超时任务由谁接手?
业务效果结算批次/账期等业务幂等键、结果即使执行两次,是否会重复扣款或重复生成报表?

Kubernetes 明确说明 CronJob 在某些情况下可能创建两次 Job,也可能没有创建,任务应按幂等方式设计。[9] 因此不能把所有分布式调度器都笼统宣传成严格“至少一次”,更不能当作 exactly-once。补跑和漏调度检测需要具体实现。

本地模型用 job@scheduled_slot 作为幂等键,把效果和已完成标记放在同一临界区:

text
job first/replay: true false effects: 1

这能说明重复输入应该如何收敛,但内存账本随进程退出就消失。生产系统要把记录持久化,并处理业务效果与标记是否处于同一事务范围。HTTP 回调或外部扣款仍需要目标方幂等与状态查询,不能靠工作节点“记得自己执行过”。

还有几个容易遗漏的调度决定:夏令时跳变时某个本地时刻可能不存在或重复;任务执行超过周期时,是允许重叠、跳过还是排队;历史配置更新后,旧调度消息用哪个版本解释;补跑量很大时,是否会压垮下游。计划时刻与实际开始时刻应分别记录,不能只留下 time.Now()。

分布式会话:请求换了一台机器,身份和状态怎样接上 ​

会话通常可以按两条路线组织:客户端只保存不透明 session ID,状态在共享存储;或客户端携带签名令牌,服务端验证其中的声明。Cookie 是浏览器传输状态的一种机制,JWT 是令牌格式,两者不处在同一个分类维度。[10][11]

方案如何工作优点代价
共享服务端会话Cookie 中 session ID 查 Redis/数据库等共享状态易集中注销、调整权限、缩小客户端暴露每次查询或缓存引入延迟与一致性;共享存储成依赖
签名令牌服务验证签名、有效期、issuer、audience 等某些请求不必逐次查会话库注销、权限变更和密钥轮换更复杂;短有效期和刷新机制有成本
粘性会话尽量把同一用户送回同一实例方便本地状态复用实例故障或迁移后仍会丢状态;不是共享会话的替代保证

JWT 的签名证明令牌未被篡改且来自相应签发者,不提供内容保密。令牌中不应随意放敏感资料;权限应按当前业务风险决定是否还需要服务端查询。删除浏览器中的令牌,也不会自动让已经被窃取的副本失效。

服务端会话要考虑登录后轮换 session ID、防固定会话攻击、TTL 与续期竞争,以及注销传播。浏览器 Cookie 的 Secure、HttpOnly、SameSite 等属性分别影响传输、脚本可见性和跨站发送行为,不能互相代替;浏览器会自动附带 Cookie,改变状态的接口仍要按应用场景处理 CSRF 风险。[10][12]

若多个请求同时修改同一会话,最后写入覆盖也可能丢掉数据。购物车、支付状态等核心业务最好有自己的持久化模型及并发控制,而不是把整个对象塞进 session JSON 后来回覆盖。会话存储更适合表达登录上下文,不应悄悄变成业务数据库。

真正要一起设计的是失效后的动作 ​

选择组件时,可以用四个问题检查边界:锁失效后,旧请求由谁拒绝?ID 生成器重启后,哪种状态防重号?任务重复后,哪一层保护业务效果?会话注销后,已经发出去的令牌还有效多久?

如果答案只停在客户端“应该停止”,说明资源端或恢复路径可能还没接上。可靠性来自这些明确的拒绝条件和恢复记录,不能只看组件是否支持 TTL、顺序号或重试。

依据与验证范围 ​

  1. Burrows:The Chubby lock service for loosely-coupled distributed systems:锁、sequencer 与资源服务检查。
  2. Redis 官方 Distributed Locks:随机 value、原子释放、异步复制缺口与 Redlock 假设。
  3. etcd v3.6 API guarantees:KV、revision、lease 与 API 边界。
  4. etcd v3.6 Concurrency API:Lock、Unlock 与 lease;KV API:事务比较。
  5. ZooKeeper 3.9.3 Recipes:Locks:临时顺序节点与前驱监听。
  6. Twitter 历史 Snowflake IdWorker 源码:位划分、回拨拒绝、同毫秒序列。历史分支用于说明源算法,不代表当前维护推荐。
  7. 美团技术团队:Leaf,美团分布式 ID 生成服务:号段与双 buffer。
  8. RFC 9562,§5.7、§6.2–6.4:UUIDv7、单调性、随机性与唯一性注意事项。
  9. Kubernetes CronJob:Job creation:可能重复/漏创建及幂等要求。
  10. RFC 6265:HTTP Cookie、Secure、HttpOnly。
  11. RFC 7519:JWT 声明与验证;RFC 8725:JWT 安全最佳实践。
  12. OWASP Session Management Cheat Sheet:会话生命周期、Cookie 属性和会话固定风险。滚动文档查阅于 2026-10-13。

Go 1.27.2、Linux/amd64 实际通过运行、测试、race 和 vet。测试穷举模型单节点同一毫秒的 4096 个序列并拒绝第 4097 个,拒绝回拨,验证较旧 token 不能覆盖已观察到的新 token,以及 20 个并发重复任务只改变一次模型效果。未部署 Redis、etcd、ZooKeeper 或调度集群;未实现 UUIDv7/号段服务/登录系统;没有真实租约计时、跨进程重启或持久化效果验证。

完整可复现实验 ​

把以下三个文件放在同一空目录。只依赖标准库,不需要外部服务。

sh
go run .
go test -count=1 -v ./...
go test -race -count=1 ./...
go vet ./...

go.mod ​

mod
module example.com/distributed/components

go 1.27.2

main.go ​

代码块 · 86 行 · 点击收起 / 展开
go
package main

import (
	"errors"
	"fmt"
	"sync"
)

type Resource struct {
	mu    sync.Mutex
	Fence uint64
	Value string
}

func (r *Resource) Write(token uint64, value string) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	if token < r.Fence {
		return errors.New("stale fence")
	}
	r.Fence = token
	r.Value = value
	return nil
}

type Snowflake struct {
	last int64
	seq  uint64
	node uint64
}

func (s *Snowflake) Next(ms int64) (uint64, error) {
	if s.node >= 1<<10 || ms < 0 || ms >= 1<<41 {
		return 0, errors.New("out of range")
	}
	if ms < s.last {
		return 0, errors.New("clock rollback")
	}
	if ms == s.last {
		if s.seq == 4095 {
			return 0, errors.New("sequence exhausted")
		}
		s.seq++
	} else {
		s.last = ms
		s.seq = 0
	}
	return uint64(ms)<<22 | s.node<<12 | s.seq, nil
}

type JobLedger struct {
	mu      sync.Mutex
	done    map[string]bool
	Effects int
}

func (j *JobLedger) Run(key string) bool {
	j.mu.Lock()
	defer j.mu.Unlock()
	if j.done[key] {
		return false
	}
	j.Effects++
	j.done[key] = true
	return true
}
func main() {
	r := Resource{}
	fmt.Println("new owner:", r.Write(42, "B"))
	fmt.Println("expired owner:", r.Write(41, "A"), "value:", r.Value)
	s := Snowflake{last: -1, node: 3}
	a, e := s.Next(1000)
	must(e)
	b, e := s.Next(1000)
	must(e)
	fmt.Println("same ms distinct:", a != b, "ordered:", a < b)
	_, e = s.Next(999)
	fmt.Println("rollback:", e)
	j := JobLedger{done: map[string]bool{}}
	fmt.Println("job first/replay:", j.Run("billing@2026-10-13T09:00Z"), j.Run("billing@2026-10-13T09:00Z"), "effects:", j.Effects)
}
func must(e error) {
	if e != nil {
		panic(e)
	}
}

main_test.go ​

代码块 · 48 行 · 点击收起 / 展开
go
package main

import (
	"sync"
	"testing"
)

func TestFence(t *testing.T) {
	r := Resource{}
	if r.Write(42, "B") != nil || r.Write(41, "A") == nil || r.Value != "B" {
		t.Fatal(r.Fence, r.Value)
	}
	if r.Write(42, "B2") != nil {
		t.Fatal("same owner rejected")
	}
}
func TestIDs(t *testing.T) {
	s := Snowflake{last: -1, node: 1}
	seen := map[uint64]bool{}
	for range 4096 {
		id, e := s.Next(100)
		if e != nil || seen[id] {
			t.Fatal(id, e)
		}
		seen[id] = true
	}
	if _, e := s.Next(100); e == nil {
		t.Fatal("sequence overflow")
	}
	if _, e := s.Next(99); e == nil {
		t.Fatal("clock rollback")
	}
	if _, e := s.Next(101); e != nil {
		t.Fatal(e)
	}
}
func TestJobReplay(t *testing.T) {
	j := JobLedger{done: map[string]bool{}}
	var wg sync.WaitGroup
	for range 20 {
		wg.Add(1)
		go func() { defer wg.Done(); j.Run("job@slot") }()
	}
	wg.Wait()
	if j.Effects != 1 {
		t.Fatal(j.Effects)
	}
}

保留各书来源与许可 · 示例运行条件以正文为准