题干与适用场景
设计一个部署在单区域三个可用区的分布式锁服务。系统管理 100,000 个可加锁资源,通常维持 20,000 把 活跃锁,峰值每秒处理 2,000 次加锁。默认租期为 30 秒,客户端每 10 秒续期;无竞争加锁的 p99 延迟目标 是 200 毫秒。被保护的存储或服务支持原子比较 fencing token,并拒绝旧持有者的请求。
题目中的规模、租期和延迟都是面试设计输入,不代表 etcd、ZooKeeper 或其他产品的性能承诺。范围包括 独占锁、等待、续期、释放、故障转移和可观测性;细粒度数据库行锁、完整事务协调器、从零实现共识算法和 跨区域主动多主不在主问题范围内。
这是一道典型的高级后端、基础设施和系统设计题。Amazon 当前的 SDE II 面试指引明确会考察系统设计, 并关注方案的实用性、正确性、效率、可靠性、优化与扩展能力。它的难点也正好覆盖这些维度:服务既要在 客户端宕机后释放资源,又不能让恢复运行的旧客户端破坏新持有者的数据。
面试官考察点
第一,看候选人能否把互斥安全性和故障回收分开。租约解决“持有者失联后多久允许别人接管”,却不能阻止 暂停很久的旧进程恢复后继续写。严格方案还需要 fencing token,并要求被保护资源参与校验。
第二,看一致性边界是否明确。加锁、续期和释放必须经过同一个强一致状态机;少数派分区不能继续发锁。 如果三个可用区各自独立决定,网络分区时同一个资源就可能出现两个持有者。
第三,看容量估算是否覆盖续期流量。20,000 把活跃锁每 10 秒续期,单续期就约 2,000 次每秒。再假设 峰值每秒 2,000 次加锁并有相近数量的释放,状态机约需承受 6,000 次提交每秒,而不是只计算加锁接口。
第四,看故障语义是否落到请求级别:加锁已经提交但响应丢失怎么办,客户端暂停超过租期怎么办,错误的 客户端能否释放别人的锁,多数派不可用时是继续服务还是拒绝,以及新主节点如何保持 token 单调。
最后,看候选人是否知道分布式锁的适用边界。Google Chubby 以粗粒度协调为目标;若数据库唯一约束、 条件更新、队列单消费者或业务幂等键已经能解决问题,引入远程锁会增加新的同步故障点。
回答前需要澄清的问题
- 需要独占锁还是读写锁? 本题先做独占锁。读写锁会增加状态、升级和饥饿语义,不能顺手承诺。
- 锁保护的资源是否支持 fencing token? 本题假设支持原子比较并保存最新 token。若不支持,租约
只能减少冲突窗口,不能严格排除僵尸客户端。
- 等待者需要多强的公平性? 默认提供近似 FIFO;绝对公平会降低故障恢复和吞吐弹性。
- 客户端可以等待多久?
waitTimeout必须有上限,并允许取消,避免无限等待占满服务端状态。 - 租期能否按业务调整? 默认 30 秒、每 10 秒续期;长任务可以申请受限范围内的租期,但不能无限续期。
- 加锁是否允许自动重试? 只有携带稳定
requestId的请求才能安全重试,否则响应丢失会产生未知结果。 - 多数派不可用时偏向什么? 互斥安全优先,拒绝新加锁和续期;不能让每个分区自行发锁。
- 受保护操作是否还需要幂等? 需要。fencing token 排除旧时代请求,业务重试和重复提交仍由业务
幂等键或事务处理。
- 是否要求跨区域低延迟? 主设计是单区域多可用区。跨区域硬全局锁需要接受更高延迟或分区时不可用,
放到追问讨论。
30 秒回答框架
“我会把锁状态放进跨三个可用区复制的强一致状态机,所有加锁、续期和释放都由多数派提交。加锁成功返回 leaseId、30 秒到期时间和单调递增的 fencingToken;客户端每 10 秒续期,并在续期结果不确定时停止 工作。每次访问被保护资源都携带 token,资源端保存已经接受的最高时代并拒绝更旧的 token。这样租约负责 重新选主,fencing 负责拒绝恢复运行的旧持有者。requestId 去重解决提交成功但响应丢失,等待者用顺序 节点加前驱监听避免惊群。20,000 把锁带来约 2,000 次每秒续期,加上加锁和释放约 6,000 次状态提交每秒, 需要按真实持久化和故障转移负载压测。”
分步骤深入解答
先定义不变量,而不是先画组件图:
- 对同一资源,强一致状态机在任一时刻最多记录一个有效
leaseId。 - 每次新的成功加锁得到更大的 fencing token。
- 被保护资源拒绝低于已接受最高 token 的请求。
- 只有匹配当前
leaseId的请求才能续期或释放。 - 无法联系多数派时不创建新锁,也不声称续期成功。
这组不变量把“谁被服务端视为持有者”和“谁的工作仍被资源接受”分开。前者由锁状态机决定,后者由 fencing token 在数据写入点决定。
第一步:定义接口与状态。
Acquire(resource, requestId, leaseTtl, waitTimeout)
-> { leaseId, fencingToken, expiresAt }
Renew(resource, leaseId)
-> { expiresAt }
Release(resource, leaseId)
-> { released }resource 是规范化后的业务资源名,不直接接受无界用户输入。leaseId 是本次持有关系的不可猜测标识; fencingToken 使用共识日志已经提交的全局单调 revision,资源端按自己的资源保存最高已接受 token。 requestId 的去重结果至少保留到调用方重试窗口结束。释放是幂等的,但携带旧 leaseId 的释放不能删除 新持有者的锁。
锁记录包含资源名、leaseId、持有者、fencing token、到期时间和可选等待队列引用。空闲锁记录可以清理, 但不能让 token 倒退。使用全局单调 revision 可以避免删除某个资源记录后把它的时代重新从 1 开始。
第二步:选择强一致复制边界。
使用成熟的共识存储或协调系统承载状态机,不在业务服务里重新实现 Raft 或 Paxos。三个可用区各有一个 副本,主节点把命令复制到至少两个副本后才返回成功。读锁状态需要线性一致读取,或直接由主节点处理; 落后的副本不能发出“资源空闲”的结论。
正常加锁流程是:校验参数和去重键,确认当前锁不存在或租约已由状态机判定过期,分配新的 leaseId 与 revision,提交到多数派,再返回结果。主节点在提交后、响应前宕机时,重试携带相同 requestId,新主节点 返回原结果,不能再创建第二个租约。
三个副本中只剩一个时,服务拒绝新加锁和续期。这样会降低可用性,但保住互斥语义。允许少数派续期看似 能让旧任务继续,恢复连接后却无法判断两个分区中的持有者谁有效。
第三步:用租约回收失联持有者。
默认租期 30 秒,客户端每 10 秒续期,留下两个续期间隔的抖动空间。租约的权威时间由服务端协调状态机 管理;客户端本地时钟只能决定“何时尝试续期”,不能证明锁仍有效。客户端连续续期失败或响应不确定时, 应立即进入静默状态,停止启动新工作并尽快终止进行中的可中断操作。
主节点切换时要保守处理剩余租期,不能因为新主节点时钟较快而过早把锁交给别人。生产上应复用经过验证的 租约实现,并把最大时钟漂移、选主时间和网络重试纳入租期预算。把租期缩到几百毫秒会提高故障回收速度, 也会让正常抖动频繁制造失锁。
第四步:用 fencing token 阻止僵尸客户端。
考虑旧客户端 A 获得 token 41,随后发生长时间垃圾回收暂停。它的 30 秒租约到期,客户端 B 获得 token 42 并开始工作。A 恢复后不知道自己已经失锁,又向存储发送一个延迟写入。若存储只检查 A 曾经拿到过锁, 这次旧写仍可能覆盖 B 的新结果。
正确做法是每次受保护请求携带 token。存储对每个资源原子保存已见过的最高时代:token 42 首次被接受后, 任何 token 41 的请求都被拒绝。相同 token 下的多个业务操作仍可执行,具体顺序、幂等和版本冲突由业务 协议负责,因此判断条件是拒绝“小于最高时代”的 token,而不是机械拒绝相等 token。
etcd 的官方说明也强调,租约本身不能保证对外部资源的互斥,外部资源必须能够验证版本。如果目标系统 无法保存或比较 token,就不能承诺严格安全。可改用数据库条件更新、唯一约束、原生事务锁、单写者队列, 或把关键写入代理到能执行 token 校验的服务。
第五步:处理等待、公平和惊群。
低竞争资源可以让失败调用方按指数退避并增加随机抖动。热点资源若需要等待,则为请求分配递增顺序号, 每个等待者只监听紧邻自己的前驱。前驱释放或过期时,只唤醒下一个等待者,避免所有客户端同时抢锁。 ZooKeeper 的锁配方采用临时顺序节点和前驱监听,正是为了避免羊群效应。
等待队列提供近似 FIFO,而不是绝对公平。取消、超时和会话失效必须删除等待节点;客户端创建节点后响应 丢失时,使用 requestId 找回原节点,不能重复排队。还要限制每个资源和每个租户的等待者数量,防止一个 热点锁耗尽内存。
第六步:估算容量与分片。
20,000 把活跃锁每 10 秒续期,产生约 2,000 次续期每秒。峰值每秒 2,000 次加锁;若平均释放量与加锁量 接近,则共识状态机约处理 2,000 + 2,000 + 2,000 = 6,000 次写提交每秒。等待入队、取消、超时与 去重记录还会增加写放大,容量测试应至少覆盖峰值、一个副本故障和日志压缩同时发生的情况。
假设 100,000 个资源各保留约 500 字节逻辑状态,总量约 50 MB;副本、日志、索引、等待者和存储引擎开销 会显著放大实际占用。这里的主要瓶颈更可能是同步持久化、热点资源和大量续期,而不是静态记录大小。
先用一个共识组满足给定规模,实测达不到目标再按资源哈希分成多个独立组。分片能提高总吞吐,却不能拆开 一个极热资源,而且让跨资源原子加锁变复杂。本题不承诺多资源事务锁;若必须一次锁多个资源,应固定排序 并设置整体超时,或重新设计为单个更高层资源。
第七步:关闭故障与运维环。
- 主节点故障:新主从已提交日志恢复状态,未提交请求由
requestId安全重试。 - 网络分区:只有多数派继续服务,少数派拒绝;旧持有者的写最终由 token 拒绝。
- 客户端暂停:租约过期后可重新发锁,恢复客户端不能通过资源端 token 校验。
- 热点锁:记录等待长度、获取延迟和持有时长,限制队列并查找是否应改成队列或分区任务。
- 续期风暴:客户端续期间隔加入抖动,服务按租约到期风险排优先级,避免所有锁同一时刻续期。
- 存储不可验证 token:明确降级为尽力互斥,或禁止用于资金、库存等要求严格正确性的写入。
核心指标包括加锁、续期和释放的 p50/p95/p99;成功、超时、冲突与未知结果数量;活跃锁、等待队列、 租约过期、续期失败、主节点切换时长、共识提交延迟、token 拒绝数以及按资源统计的热点。审计日志记录 资源、leaseId、token、调用方和结果,但不记录敏感载荷。
验证不能停在单元测试。使用状态机模型测试验证单持有者和 token 单调;故障注入覆盖提交后丢响应、暂停 客户端超过 30 秒、延迟旧写、隔离一个副本、隔离多数派、主节点切换和等待者取消。最关键的端到端断言是: B 的 token 42 已被资源接受后,A 的 token 41 永远不能再改变该资源。
高质量示范回答
“我先定义安全目标:同一资源在强一致锁状态中最多有一个有效租约,而且被保护资源不会接受旧持有者的写。 系统部署在三个可用区,用成熟共识存储复制一份逻辑状态机。加锁、续期和释放必须由多数派提交;只剩少数派 时拒绝服务,不能用可用性换两个持有者。
接口返回 leaseId、到期时间和单调递增的 fencingToken。默认租期 30 秒,客户端每 10 秒续期;本地 时钟只负责发起续期,续期结果不确定时客户端停止工作。租约允许旧持有者失联后重新发锁,但真正的安全边界 在资源端:每个受保护请求携带 token,资源保存已接受的最高时代并拒绝更旧的请求。即使 token 41 的客户端 暂停后恢复,而 token 42 已接管,旧写也无法落地。
加锁请求必须携带 requestId。如果服务已提交但响应丢失,调用方用相同 ID 重试并取回原来的租约,避免 生成第二把锁。续期和释放必须匹配当前 leaseId,旧客户端不能释放新持有者。热点锁需要等待时,我会给 等待者分配顺序号并只监听前驱,近似 FIFO 且避免惊群。
容量上,20,000 把锁每 10 秒续期就是约 2,000 次写每秒;再加 2,000 次加锁和相近的释放,基础负载约 6,000 次共识提交每秒。我要在一个副本故障和日志压缩同时发生时验证 200 毫秒 p99,而不是引用产品宣传值。 先用一个共识组,只有压测证明不足才按资源分片。
最后,我会用模型测试和故障注入验证提交后丢响应、客户端暂停、旧包延迟、网络分区、选主和等待取消。 监控获取与续期延迟、冲突、过期、队列长度、主节点切换和 token 拒绝。若受保护系统不能原子比较 token, 我会明确说明无法提供严格互斥,并优先改用数据库条件更新、唯一约束或单写者队列。”
常见错误
- 只设计一个带过期时间的键 → 暂停的旧客户端恢复后仍可能写入 → 为每次加锁签发单调 token,并在资源端校验。
- 让三个可用区各自发锁 → 分区时同一资源出现多个持有者 → 所有状态变更经过同一个多数派状态机。
- 把客户端本地时间当租约真相 → 时钟漂移和进程暂停会误判 → 由服务端管理租约,客户端不确定时停止工作。
- 失败后换一个请求 ID 重试加锁 → 原请求可能已经提交,形成重复持有关系 → 使用稳定
requestId查询原结果。 - 任何客户端都能按资源名释放 → 旧请求可能删除新持有者 → 续期和释放必须匹配当前
leaseId。 - 只估算每秒 2,000 次加锁 → 忽略 2,000 次续期和相近释放 → 按约 6,000 次基础状态提交容量压测。
- 所有等待者监听锁根节点 → 每次释放都会唤醒全体 → 让等待者只监听直接前驱。
- 为了吞吐立即分很多共识组 → 运维和跨资源语义先变复杂 → 先压测单组,再按证据分片。
- 把分布式锁用于每行数据 → 协调服务成为高频事务路径和单点瓶颈 → 优先用数据库原子约束处理细粒度并发。
- 目标系统不能校验 token 仍宣称严格安全 → 租约无法阻止延迟旧写 → 降级承诺或改变写入边界。
追问及应对
追问一:为什么有租约还必须使用 fencing token?
租约只让锁服务在 30 秒后有权把锁发给 B,不能删除 A 已经发往外部系统、但仍在网络或进程队列中的操作。 A 也可能因长时间暂停而完全不知道租约已经过期。token 42 被资源接受后,资源拒绝 token 41,才能在真正 产生副作用的位置阻止旧时代工作。可复用的判断是:租约决定何时可以选出新持有者,fencing 决定旧持有者 的工作是否仍会被接受。
追问二:被保护的数据库不能存 fencing token 怎么办?
先寻找等价的原子版本条件,例如 UPDATE ... WHERE version = expected、唯一约束、事务锁或数据库 advisory lock。也可以把所有写入送到一个能校验 token 的代理或单消费者队列。若这些都做不到,只能把保证降为 尽力互斥,并说明进程暂停与延迟消息仍可能破坏正确性;资金和库存等严格场景不应接受这种模糊承诺。
追问三:如何设计跨区域的全局锁?
最直接的是为每个资源指定一个 home region,并让所有区域访问同一个跨区域多数派,代价是远距离写延迟, 而网络分区时少数派区域无法加锁。不能让每个区域独立发锁再异步合并,因为互斥冲突无法事后撤销。若业务 更重视区域可用性,可重新划分资源所有权,使一个资源只在一个区域写入,而不是制造主动多主全局锁。
追问四:30 秒租期和 10 秒续期如何确定?
它们是题目输入,真实值应由最长正常暂停、网络 p99、选主时间、时钟误差和可接受故障回收时间共同决定。 租期太短会把正常抖动当失锁,太长会让宕机持有者占用资源更久。续期间隔 10 秒为 30 秒租期留下两次补救 机会,还应加入随机抖动,避免 20,000 个客户端形成整点续期峰值。
追问五:如何支持一次获取多把锁?
最简单的边界是本服务不提供跨资源原子锁。调用方必须按规范化资源名固定排序获取,并为整组操作设置短的 总超时;失败时按相反顺序释放,减少死锁但不提供事务原子性。若业务真的需要“全部获得或全部不获得”, 应把它建模为一个高层资源,或使用能够原子提交多键状态的单一共识组,并接受吞吐与复杂度成本。
追问六:你会怎样证明实现没有双持有者?
先用状态机模型生成加锁、续期、释放、过期和重试序列,检查任一日志位置最多一个有效 leaseId,且 token 严格递增。再做故障注入:提交后断开响应、暂停 A 超过租期、让 B 获取新锁并写入,再恢复 A 发送旧写; 断言资源拒绝旧 token。还要隔离少数派和多数派、反复切主、取消等待者,并在高并发压测中检查所有历史记录 是否满足不变量。