分布式锁配方、分布式 ID 与持久任务租约
锁服务返回成功以后,工作者仍可能停顿、断连或重启。本篇回答组件层的三个问题:怎样恢复一个不确定的拿锁操作,怎样在故障后继续发出不重复的 ID,以及怎样让到期任务可恢复地被领取。它们共享“身份、任期与持久状态”的问题,但不提供相同保证。
本页固定 ZooKeeper 3.9.2 / release-3.9.2;本地调度实现固定 OpenJDK 17u / jdk-17.0.12+7。Snowflake 位布局是明确假设下的教学模型,没有冒认某个未核验框架的实现。通用租约授权、资源端 fencing、同任期操作序号及发号器恢复协议统一见租约和 fencing 令牌;本页只解释组件如何接入这些约束。
权利、拥有者与业务身份分别保护什么
| 对象 | 回答的问题 | 不能据此推出的结论 |
|---|---|---|
| 锁与租约 | 谁当前有资格开始受保护工作,资格何时需要重新确认 | 旧进程已停止、已发送请求被撤回 |
| 随机 ownership token | 释放或续租的是否为自己创建的那把锁 | token 有任期顺序、旧业务写会被拒绝 |
| fencing token | 资源能否识别比已接受任期更旧的执行者 | token 未送达资源时已自动废止旧权限 |
| 业务 ID / 幂等键 | 两次请求是否表达同一个业务意图 | 谁获授权、不同业务事件的真实先后 |
单实例 Redis 可用 SET key random_token NX PX lease_ms 创建锁。释放与续租必须在服务端原子比较拥有者,不能先 GET 回到客户端判断,再无条件 DEL 或 PEXPIRE。例如 A 读到自己的值后暂停,锁过期由 B 取得,A 恢复的无条件删除会删掉 B 的锁。以下是教学 Lua 脚本,不是 Redis 源码摘录:
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
end
return 0这个脚本只保护锁键。A 仍可能持有数据库连接并继续发 SQL;锁服务不会收回所有外部能力。效果提交点需要怎样原子检查任期、为什么先查 fence 再无条件写会竞争、外部服务不参与时还能保证什么,回查前述 fencing 权威页。不能用安全释放替代这些条件,也不能认为传一个 header 就代表对方执行了检查。
异步复制下的 Redis 主故障切换还可能丢失刚授予的锁,使新主重新授权。多节点算法或客户端 watchdog 必须另列时钟、网络和恢复假设;“用了某客户端库”不是证明。锁可以减少竞争,但库存条件更新、业务唯一键等资源不变量仍需自身成立。资源已能独立裁决时,可评估不使用全局锁;锁服务失联时能否继续,取决于该裁决是否仍可完成。Redis 官方锁模式与Redis 恢复边界分别解释这两层问题。
ZooKeeper 锁配方必须包含失败恢复
为每个受保护资源建立持久父节点,例如 /locks/report-7;每次竞争创建带本次申请 GUID 的临时顺序子节点。临时性把候选节点绑定 session,顺序后缀提供同一父节点下的竞争顺序。GUID 必须标识本次申请,不能给所有并发申请复用同一常量。
按 3.9.2 官方锁配方,一次竞争应包含以下完整循环:
- 创建
guid-lock-临时顺序节点,记住返回的完整路径;如果回复丢失,将结果标成未知,进入恢复流程。 - 读取该父节点的候选列表,按协议解析顺序后缀,确认自己的候选仍存在。若自身已不存在,不能根据旧列表宣布获锁。
- 自己的序号最小时才取得协调层资格;否则找紧邻自己的前驱,在一次
exists(predecessor, watch=true)中检查并注册 watch。 - 若
exists返回不存在,立刻回到第 2 步。若存在,等待通知;收到通知后仍回到第 2 步,不把“收到删除事件”直接当成锁证书。 - 正常释放时删除自己创建的精确候选路径;持有期间仍要处理 session 状态及业务侧授权边界。
只 watch 前驱使一次释放通常只唤醒下一个竞争者。若所有等待者 watch 父目录,任意候选变更都可能唤醒整群,随后多数仍不能工作。前驱 watch 解决的是等待与唤醒规模,不使外部数据库写入和节点删除成为同一事务。
create 回复丢失不能用无条件重建恢复
假设服务端已经创建 gA-lock-0000000011,但返回路径的响应丢失。客户端看到超时,无法据此断言 create 失败;若直接重发,又可能生成 gA-lock-0000000012,同一申请持有多个候选,退出时也可能留下阻塞后继的节点。
官方 GUID 恢复说明要求可恢复错误后读子节点,查找带原 GUID 的候选。恢复应先串行化本申请的创建与重试,确认当前仍是原 session,并核对找到节点的身份;找到就恢复原路径,未找到再按协议重试创建。若因错误的并行重试已经出现多个匹配,先识别并处理自己多建的候选,不能任取一个后遗忘其他节点。不要仅凭路径包含某字符串就删除别人的节点。
这个分支和“watch 注册前前驱已删”是两种不同竞争:前者要恢复操作身份,后者要重读当前顺序。重试 SDK 可以封装它们,但不能把两个不确定性都改成无限 create。
Disconnected 不等于 Expired
Disconnected 只说明当前连接中断,原 session 可能继续有效,临时节点也可能仍在。客户端应暂停新的受保护工作,恢复连接后重新同步候选状态与授权,再决定是否继续。不要在每次断连时另开 session 竞争,让旧候选与新候选同时存在。
客户端收到 Expired 后,旧客户端对象和原申请资格不可继续复用,需建立新 session、用新申请身份重新竞争。该事件不证明服务端 closeSession 已经提交,临时节点删除与客户端感知仍可能先后不同;3.9.6 还可能在服务端不可达时先做本地过期。详细证据边界见ZooKeeper协调与会话恢复。本页不把本地断连事件当作精确租约到期时钟。
原码中的 CAS 与 session 清理边界
ZooKeeper 更新路径由准备事务、提议与 quorum 提交,再进入最终应用阶段。这里选两个与锁恢复直接相关的入口:setData 怎样做条件更新,killSession 怎样删除临时节点。它们修改协调服务内的状态,不调用旧客户端的业务线程,也不能终止一个已经发出的外部请求。
setData 的版本条件在哪里生效
真实连续原码:repo apache/zookeeper,tag release-3.9.2,PrepRequestProcessor.pRequest2Txn,文件 zookeeper-server/src/main/java/org/apache/zookeeper/server/PrepRequestProcessor.java,L380–L398。仅统一公共缩进,没有删去摘录区间内的行。
case OpCode.setData:
zks.sessionTracker.checkSession(request.sessionId, request.getOwner());
SetDataRequest setDataRequest = (SetDataRequest) record;
path = setDataRequest.getPath();
validatePath(path, request.sessionId);
nodeRecord = getRecordForPath(path);
zks.checkACL(request.cnxn, nodeRecord.acl, ZooDefs.Perms.WRITE, request.authInfo, path, null);
zks.checkQuota(path, nodeRecord.data, setDataRequest.getData(), OpCode.setData);
int newVersion = checkAndIncVersion(nodeRecord.stat.getVersion(), setDataRequest.getVersion(), path);
request.setTxn(new SetDataTxn(path, setDataRequest.getData(), newVersion));
nodeRecord = nodeRecord.duplicate(request.getHdr().getZxid());
nodeRecord.stat.setVersion(newVersion);
nodeRecord.stat.setMtime(request.getHdr().getTime());
nodeRecord.stat.setMzxid(zxid);
nodeRecord.data = setDataRequest.getData();
nodeRecord.precalculatedDigest = precalculateDigest(
DigestOpCode.UPDATE, path, nodeRecord.data, nodeRecord.stat);
setTxnDigest(request, nodeRecord.precalculatedDigest);
addChangeRecord(nodeRecord);输入不是一个任意可写的计数器:服务端先验证 session、路径、ACL 和 quota,再把当前数据版本与请求期望版本交给 checkAndIncVersion。校验通过后构造带 newVersion 的 SetDataTxn,并登记待提交的变更;准备出事务还不等于客户端已获得提交成功响应。后面的复制和提交路径见 ZooKeeper 权威页。
真实连续原码:同 repo、tag、文件,checkAndIncVersion,L737–L742。
private static int checkAndIncVersion(int currentVersion, int expectedVersion, String path) throws KeeperException.BadVersionException {
if (expectedVersion != -1 && expectedVersion != currentVersion) {
throw new KeeperException.BadVersionException(path);
}
return currentVersion + 1;
}两个客户端都读到 version 8,分别带期望值 8 更新:先成功的一方把版本推进到 9,后一方再以 8 申请会收到 BadVersion,必须重读并重新做业务决策。期望版本 -1 会绕过相等检查,所以它不能用于“只有我读到的版本仍未改变才更新”的 CAS。
version 是 int,这里直接返回 currentVersion + 1;它不是永不回绕的全局任期。节点删除重建还会产生新的生命周期。用 znode 数据保存业务计数值时,应区分“数据中的持久计数”和“用于 CAS 的 Stat.version”,并另外设计计数溢出、父节点生命周期、授权原子性与灾备恢复,不能直接把 version 当永久 fence。
killSession 删除的是哪些路径
真实连续原码:repo apache/zookeeper,tag release-3.9.2,DataTree.killSession 两个重载,文件 zookeeper-server/src/main/java/org/apache/zookeeper/server/DataTree.java,L1126–L1161。
void killSession(long session, long zxid) {
// The list is already removed from the ephemerals,
// so we do not have to worry about synchronizing on
// the list. This is only called from FinalRequestProcessor
// so there is no need for synchronization. The list is not
// changed here. Only create and delete change the list which
// are again called from FinalRequestProcessor in sequence.
killSession(session, zxid, ephemerals.remove(session), null);
}
void killSession(long session, long zxid, Set<String> paths2DeleteLocal,
List<String> paths2DeleteInTxn) {
if (paths2DeleteInTxn != null) {
deleteNodes(session, zxid, paths2DeleteInTxn);
}
if (paths2DeleteLocal == null) {
return;
}
if (paths2DeleteInTxn != null) {
// explicitly check and remove to avoid potential performance
// issue when using removeAll
for (String path: paths2DeleteInTxn) {
paths2DeleteLocal.remove(path);
}
if (!paths2DeleteLocal.isEmpty()) {
LOG.warn(
"Unexpected extra paths under session {} which are not in txn 0x{}",
paths2DeleteLocal,
Long.toHexString(zxid));
}
}
deleteNodes(session, zxid, paths2DeleteLocal);
}两个参数的入口先从 ephemerals 移出本 session 的路径集合,再交给重载。若关闭事务携带 paths2DeleteInTxn,先删除事务路径;本地清单为空就结束。否则从本地清单移除事务已处理的路径,对意外剩余路径发告警,并继续调用 deleteNodes 清理本地剩余临时节点。不能只看前半段就漏掉最后这条清理分支。
锁候选被删除后,后继可以重新检查竞争顺序;这个过程没有给原工作线程发送“永不可恢复”的信号。因此临时节点提供会话关联的协调清理,不提供业务副作用回滚,也不证明持有者已经崩溃停止。
顺序号只能在声明的生命周期内解释
顺序节点计数归父节点所有。3.9.2 Programmer's Guide明确说明计数器是有符号 32 位整数,超过 2147483647 会溢出为带 -2147483648 的后缀。长期运行的配方不能无条件假设十位字符串排序永远等价于期望的先后;需要在接近边界前受控换代、停止旧代竞争并验证新代规则。
父目录删除重建后,新的计数不能自动与旧目录形成同一长期序列。恢复旧备份同样可能让协调端回到旧发号状态,而外部资源已接受更新任期。后果可能是新请求长期被拒绝,或在错误的资源重置流程下重新接受旧权限。
如果产品要求跨父节点、跨重建、跨灾备的单调授权,应在授权协议中持久维护计数/epoch,令发号与授予资格来自同一权威,并与资源端恢复协同;也可以受控切换资源代际。完整协议只在 fencing 权威页维护。本页的结论是:临时顺序节点的队列顺序,不自动等于永久有效的外部 fencing 令牌。
Snowflake 唯一性来自位预算与恢复约束
取常见的教学布局:最高 1 位保留为非负符号,41 位相对 epoch 的毫秒时间 + 10 位 worker + 12 位同毫秒序列。以下为算法伪代码,不对应本页未固定版本的任何 Snowflake 库:
id = ((millis - epoch) << 22) | (worker_id << 12) | sequence
约束:0 <= millis - epoch < 2^41
0 <= worker_id < 2^10
0 <= sequence < 2^12单 worker 每毫秒有 2^12 = 4096 个序列槽,理想位预算上限为每秒 4,096,000 个;它不等于实测吞吐。一个毫秒的序列耗尽后,应等待/推进到满足协议的新时间槽或拒绝发号,不能在相同时间与 worker 下把序列绕回 0。10 位可表示 1024 个 worker 值,41 位约覆盖 2^41 / (1000×60×60×24×365.2425) ≈ 69.7 年,从选定 epoch 起计算;epoch 到期必须预先设计换代。
唯一性要求已发出的三元组 (相对毫秒, worker, sequence) 不重复。至少要检查三个独立反例:
| 失败条件 | 最小重复输入 | 单独保证 worker 唯一是否足够 |
|---|---|---|
| 两个发号者误用同一 worker | 两者都用 (1000, 7, 0) | 否;需要排他分配并阻止旧发号者继续服务 |
| 时钟回拨后重用过去序列 | 已发 (999, 7, 0),之后又回到 999 从 0 发 | 否;worker 始终唯一仍会重复 |
| 同毫秒重启丢失序列 | 已发 (1000, 7, 0),重启仍在 1000,从 0 开始 | 否;甚至没有发生时钟回拨 |
常见选择包括回拨时等待或拒绝、持久保存已使用时间/序列上界、预分配互不重叠的时间或号段范围,以及用明确的代际协议隔离旧发号者。每种方案都需说明崩溃发生在“持久化边界前后”的行为:先发号后才异步保存最后时间,不能证明不会重用;等待时钟追上则牺牲可用性,还要给出最长等待/报警策略。
用 ZooKeeper 临时节点分配 worker 只能协调服务看到的在线资格,旧进程暂停后仍可能恢复发号。新 owner 获得同一 worker 前,需有旧发号者不能再服务的隔离证据,或使用消费者能核验的代际协议;若依赖最大时钟偏差和停机等待,必须写出这些上界。把 IP 取模、容器编号取模、session 过期当作旧进程永久终止,都不能补齐证明。
号段与 UUID 解决的是不同目标
数据库号段把持久高水位从 h 原子推进到 h+n,把不相交区间交给发号者本地消费。故障后允许浪费未使用号码,不能把未确认是否已使用的旧段重新分配。号段分配响应丢失时,新的请求可浪费一段换取确定性,也可用分配请求 ID 查原结果;无论哪种恢复,分配器备份回退都不能重新发出已用区间。
随机 UUIDv4 用概率换取较少中心协调;它不是绝对无碰撞的证明,也不提供全局提交顺序。按时间组织的 UUID 格式与随机 UUID 的局部性不同,不能笼统说“所有 UUID 都随机无序”。若需要相应格式,须明确版本和生成规则,可查 RFC 9562。
| 选择 | 主要依赖 | 需要接受的成本 |
|---|---|---|
| Snowflake 类布局 | worker 排他、时钟/序列恢复协议 | 回拨与重启处理、位预算到期、按时间可排序不等于因果排序 |
| 数据库号段 | 持久分配器与不重叠区间 | 分配器恢复责任、空洞、批量预取与号段浪费 |
| 随机 UUIDv4 | 足够可靠的随机源与合理的碰撞预算 | 更宽索引键、通常较差的随机写局部性、无业务顺序 |
订单 ID 大小不能证明支付发生先后,也不能代替业务版本或幂等键。业务上“同一订单支付至多生效一次”需要稳定业务身份和效果提交协议,而不是把重试时新生成的 ID 当作去重依据。
本地定时器不能代替持久任务状态机
ScheduledExecutorService 决定单进程内何时使任务具备执行资格,内存中的 ScheduledFutureTask 和延迟队列不会随进程重启自动恢复,也不提供多进程的全局唯一领取。定时器可以唤醒扫描器,任务事实仍需保存在持久存储中。
周期任务异常为什么会停止后续调度
真实连续原码:repo openjdk/jdk17u,tag jdk-17.0.12+7,ScheduledThreadPoolExecutor.ScheduledFutureTask.run,文件 src/java.base/share/classes/java/util/concurrent/ScheduledThreadPoolExecutor.java,L300–L309。
public void run() {
if (!canRunInCurrentRunState(this))
cancel(false);
else if (!isPeriodic())
super.run();
else if (super.runAndReset()) {
setNextRunTime();
reExecutePeriodic(outerTask);
}
}周期任务只有 super.runAndReset() 成功后才计算下一时间并 reExecutePeriodic。任务抛异常时,FutureTask 进入异常完成,不能再满足这一成功分支,后续执行被抑制;这与 Java 17 调度 API 契约一致。未处理异常可能造成扫描器悄悄停止,应用应观察 future 终态、执行失败和扫描延迟,并按业务定义恢复;不能只因为线程池进程仍活着就判断周期任务健康。
fixedRate 按计划节拍安排,fixedDelay 从上一次执行结束后再等待;两者都不保证精确墙钟时刻启动。同一个周期任务的执行不会彼此重叠,但这不约束其他进程、其他任务副本或失联旧执行者。中断与 Future 取消的局部语义详见FutureTask 与异步取消。
due、state、claim、lease、attempt 各有职责
假设一个到期结算任务必须在工作者崩溃后继续处理,任务表至少需要以下可持久解释的状态:
| 字段 | 含义 |
|---|---|
task_id | 稳定任务身份,恢复和重试不能换掉业务去重身份 |
due | 最早可领取时间;与使用的时间权威一致 |
state | READY / RUNNING / SUCCEEDED / CANCELLED / FAILED 等业务状态 |
owner、claim_id、claim_version | 当前工作者、本次领取请求的唯一身份,以及每次成功领取递增的持久版本 |
lease_until | 按领取存储的权威时间判断的可接管边界 |
attempt | 已领取/尝试次数,用于重试预算、退避和人工接管;不等于业务成功次数 |
多个扫描器可以读到同一候选列表,但只有原子条件迁移成功的工作者能领取。以下为教学伪 SQL,命名参数、时间相加和返回新版本需按所选数据库适配;不是本轮已执行的生产 SQL:
UPDATE durable_task
SET state = 'RUNNING', owner = :new_owner, claim_id = :claim_request_id,
claim_version = claim_version + 1,
lease_until = :db_now + :lease_duration,
attempt = attempt + 1
WHERE task_id = :task_id AND due <= :db_now
AND (state = 'READY'
OR (state = 'RUNNING' AND lease_until <= :db_now));:db_now 表示在领取存储原子操作中取得的权威时间,不使用各工作者自行传入的墙钟裁决。该时间源若发生跳变,会影响 due 和 lease 的解释,仍需跳变监控与恢复策略,不能把它当作绝对准确的到期时钟。实际实现还需退避到期、尝试上限、终态不可再领取、版本溢出及索引/扫描策略。
影响行数为 1 才是领取成功;取得新 claim_version 的方式也必须可靠,不能在另一无约束读取中误认其他人的版本为自己的。未提交前不得开始业务。提交回复丢失时,以本次唯一 claim_id 查询并确认当前领取仍属于自己,再取得版本;若已被接管就停止恢复这个旧领取。不能只看工作者名称相同就认领任意尝试,也不能把未知状态直接当作失败重领。claim_id 标识这次领取,task_id 仍标识稳定业务,两者不能混用。
续租和完成要带 task_id + owner + claim_version,只在 RUNNING 且租约按协议仍有效时条件更新;续租后的截止时间也由同一时间权威确定。迟到的旧领取者不能无条件写 SUCCEEDED,否则会覆盖接管者的当前状态。领取版本还需跨故障持久,不因恢复旧备份而重新授权;它自身也不自动成为外部服务会核验的 fencing token。
租约过期只允许重试者接管,不证明旧尝试未产生副作用。“执行了业务,但写完成标记前崩溃”会导致后继重做。业务效果与完成标记同库时可评估同事务条件迁移;跨外部服务时需要对方的稳定幂等键/查询能力与恢复流程,不能以本地任务表承诺端到端只执行一次。对每次尝试生成不同支付幂等键,反而会把重复执行伪装成新业务。
取消的边界必须先定义
对未领取任务,READY → CANCELLED 与 READY → RUNNING 竞争同一原子状态:取消胜出就不能再领取;领取胜出则不能返回“保证没有执行”。对已领取任务可写 cancel_requested,要求工作者在副作用前检查并协作退出,但检查后到远端提交之间仍可能有竞争。
如果业务需要“取消确认后绝无效果”,取消与效果提交必须在能共同裁决的协议内串行化。对已经提交的支付、消息或文件发布,取消可能只能触发查单、补偿或人工处理;补偿是后续业务动作,不是倒转时间。cancel(true)、进程中断、租约到期都不能单独证明没有外部效果。
百万级到期任务应按 due 分桶/索引扫描并限制批次、并发和领取速率。监控最老到期年龄、领取冲突、续租失败、过期接管、尝试次数、终态失败与待确认副作用。只看扫描器每秒运行次数,会漏掉业务长期不推进或旧任务饥饿。
带输入的推演、模型与实际范围
锁恢复的三条纸面轨迹
这组是教学故障轨迹,未运行 ZooKeeper 集群。在隔离命名空间预置候选 n1、n2、n3,按顺序令 n1 获资格、n2 watch n1、n3 watch n2。分别填写以下观察与下一步:
| 输入步骤 | 预期观察 | 必须采取的动作 |
|---|---|---|
| n1 在 n2 注册 watch 前删除 | n2 的带 watch exists(n1) 返回不存在 | 立刻重读,不等待永不会来的旧删除事件 |
| GUID gA 的 create 成功,返回路径丢失 | 客户端只知未知;子节点中已有 gA 候选 | 原 session 恢复后按 GUID 找回路径,避免无条件重建 |
| A 断连后又恢复;另一路确实 session 过期 | 前者候选可能仍在;后者原资格不可恢复 | 分别重新同步、或新 session 重新竞争;都不能据此宣布旧线程已停 |
真实复现需部署固定版本隔离集群,在代理层丢弃 create 响应、控制前驱删除与 watch 注册的先后、单独制造断连和 session 过期,记录客户端状态、候选路径与外部业务提交。正常关闭 CLI 不是进程暂停;单节点 API 结果也不证明分区下的 quorum 保证。本轮没有运行这组集群实验。
可计算的 ID 重复反例
下面是教学模型程序,只编码位布局和故意重复的输入,不是具有 worker 协调/持久化能力的发号器。可直接复制到本地 Python 3:
python3 - <<'PY_ID'
def encode(t, worker, seq):
assert 0 <= t < 2**41 and 0 <= worker < 2**10 and 0 <= seq < 2**12
return (t << 22) | (worker << 12) | seq
first = encode(1000, 7, 0)
restart = encode(1000, 7, 0)
rollback = [encode(t, 7, 0) for t in (999, 1000, 999)]
print('same_ms_restart:', first, restart, first == restart)
print('rollback:', rollback, 'duplicate=', len(set(rollback)) < len(rollback))
print('sequence_per_ms:', 2**12)
print('years:', round(2**41 / (1000 * 60 * 60 * 24 * 365.2425), 1))
PY_ID本轮实际执行以上模型,输出:
same_ms_restart: 4194332672 4194332672 True
rollback: [4190138368, 4194332672, 4190138368] duplicate= True
sequence_per_ms: 4096
years: 69.7它证明这些输入会碰撞,并核对位预算;没有测试某个 Snowflake 客户端、真实时钟回拨、worker 重分配或并发吞吐。将重复三元组排除在外,仍需检验生产实现的所有发号与恢复路径。
持久任务接管与取消的纸面输入
令任务 T7 初始为 READY, due=100, claim_version=0, attempt=0;领取存储当前时间为 100。A 与 B 同时领取,只有一个条件迁移成功,假设 A 得到 version 1、租约截至 130。时间推进至 131,B 接管得到 version 2、attempt 2;此时 A 带 version 1 的续租和完成都应影响 0 行。再令 A 的远端请求早已生效而完成标记丢失,说明拒绝迟到完成并不自动消除重复业务。
另开全新 READY 任务,让取消与领取分别先赢一次:取消先赢,领取应失败;领取先赢,只能按在途取消协议响应,不能报告“从未执行”。最后令周期扫描器抛异常,检查其 future 是否异常完成,以及独立告警能否发现最老 due 年龄持续增加。
以上任务状态与周期异常为未实测的复现预期;本轮实际范围是固定 tag 源码核查、连续摘录比对和 ID 模型计算,没有执行数据库并发任务实验、JDK 定时器程序或 ZooKeeper/Raft 集群。
条件变化后的取舍与三轮追问
锁竞争从每秒十次变成每秒百万次时,先按资源分片,或让数据库条件状态机独立裁决;单全局锁会把并行业务串行化。代价是热点键、分片重划分及单资源仍串行,验收要分别观察正常竞争、长暂停和恢复后的旧操作,而不只测拿锁吞吐。
ID 需求从“唯一、粗略按时间排序”变成“跨地域全局提交先后”时,Snowflake 与随机 UUID 都不能直接提供后一个目标;需接受明确排序权威及其同步成本,或把业务需求改为局部顺序。持久任务从同库结算改为外部支付时,数据库领取版本只约束本地状态,必须重新界定支付幂等、查单和取消确认。
第一轮追问:为什么等锁的客户端不能收到前驱删除就立即开工?答:事件只使旧快照失效,候选列表和自身 session 可能已改变;必须重读,确认自己的有效候选为最小后才获资格。评分点:通知不是状态 2 分,自身身份与顺序复核 2 分。
第二轮追问:worker 都用 ZooKeeper 分配,为什么仍要问同毫秒重启?答:worker 分配只排除一类并行重用;同一 worker 重启后序列归零会重复三元组,旧进程恢复还可能与新 owner 并发。需持久高水位/不重叠范围及旧发号者隔离协议。评分点:两个反例 2 分,恢复约束与代价 2 分。
第三轮追问:任务表能保证只有最新 claim 完成,为什么还要外部幂等?答:旧尝试可能已付款但没留下完成记录,后继会重试;本地状态 CAS 不涵盖外部提交。使用稳定业务键、查询和可核对恢复,不能以 attempt 变化生成全新业务身份。评分点:崩溃窗口 2 分,资源边界与稳定身份 2 分。
自测前的权威回查入口
以下入口可直接打开;答案面板只保留文字与评分,不依赖面板渲染链接。
- 通用租约、fencing 与效果提交:对应自测 1、2、3、5 的完整协议。
- ZooKeeper 配方与错误恢复、CAS 与清理源码:对应 6、7、8。
- Snowflake 与恢复反例、号段与 UUID:对应 4、9、10。
- 本地定时器与任务状态机:对应 11、12。
闭卷验收
本页 12 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。
固定版本来源与证据边界
本页所有原码段均取自完整原文件,元数据中的 SHA-256 校验整文件,而非只校验摘录。固定原码说明本版本分支;教学 Lua、算法伪代码、任务伪 SQL 和纸面轨迹均另行标明,不能作为集群已运行证据。
| 来源 | 本页使用范围 |
|---|---|
| ZooKeeper 3.9.2 Recipes | 临时顺序锁、前驱 watch、GUID 恢复 |
| ZooKeeper 3.9.2 Programmer's Guide | session、顺序计数与溢出;对应固定 tag 文档已核查 |
| PrepRequestProcessor.java | setData 版本检查与事务准备,-1 绕过和 int 递增 |
| DataTree.java | session 临时路径的两份清单与完整清理分支 |
| OpenJDK 17u ScheduledThreadPoolExecutor.java | 周期任务 runAndReset 成功后才重入队 |
| RFC 9562 | UUID 格式与版本边界;不代替具体发号器实现验证 |