Dayward AI
Week 2 · D10About 6 hours

Sharding and Leases: Hashing userId → shard, SET NX + TTL + Lua Renewal, Per-User Ordering, Handoff

Use hash-based sharding to spread user traffic across multiple workers, then implement lease renewal with Redis's SET NX + TTL + a Lua script, guaranteeing a single user's messages are processed in strict order.

Today's goals 0/3

Sign in to tick these off and save your progress.

今日目标

  1. 能实现一个 userId 哈希到固定数量 shard 的分配函数
  2. 能用 SET NX + TTL 拿到一个 shard 的租约,并用 Lua 脚本安全续约
  3. 能说清租约到期后 shard 怎么 handoff 给另一个 worker,同时不打乱同用户消息的顺序

昨天那条消息总线跑通了,但它有一个刺眼的副作用:消费组会把消息派给任意一个空闲 worker,于是同一个用户连发的两句话可能被两个进程同时处理。今天正面解决这件事。读完回到页面顶部把三条目标勾掉。

小白版讲解

一条流随便派件,同一个用户的两句话就会被两个人处理

先把昨天留下的问题摆到台面上。一条流 koda:runs、一个消费组 workers、三个 worker 从同一个货架上取件——这套分摊对彼此无关的任务毫无问题,可我们的任务偏偏彼此有关。

场景是电商客服。用户先发「我要退款」,两秒后补一句「订单号 A1024」。两条消息几乎同时躺在流里,消费组把第一条派给 worker-A、第二条派给 worker-B,谁先跑完取决于当时哪台机器负载低。于是用户很可能先看到「请提供订单号」,再看到「已为您登记退款」——他明明已经给过订单号了。

问题的根子不在总线,在分配的单位:消费组的分配单位是「一条消息」,而业务要求的最小串行单位是「一个用户」。

生活里有现成的解法:城市网格化管理。城管不是「哪个网格员闲着就派他去下一个报修单」,而是先把城区划成固定数量的网格,每个网格常态由一名网格员持牌负责——同一个小区的事永远找同一个人。

网格就是分片(shard),本课固定 256 个。用户 id 哈希取模落到某个 shard,shard 归某个 worker,于是同一个用户的所有消息永远排到同一个 worker 的同一条队列上,顺序天然就是入队顺序。

TextText
用户 u-1001 ──shardOf(userId)──▶ shard 68 ──租约──▶ worker-A
用户 u-2077 ──shardOf(userId)──▶ shard 12 ──租约──▶ worker-B
用户 u-3310 ──shardOf(userId)──▶ shard 68 ──租约──▶ worker-A(同一个格子,同一个人)

为什么不干脆用 userId 取模 worker 数?因为 worker 数会变,除数一变几乎所有用户的归属都会变、会话被整体搬家。垫一层固定的 256 个 shard,就是把「用户到 shard」钉死,只让「shard 到 worker」随伸缩浮动。

这里得交代一次架构改动,否则你照着昨天的代码往下写会卡住:分片同时改变了流的形状。 消费组的本职就是把消息分摊给任意空闲消费者,而 XREADGROUP 给你哪条取决于流里还剩什么,没有「只读属于我这批 shard 的消息」这种用法。所以 koda:runs 从今天起拆成 256 条子流 koda:runs:s0koda:runs:s255:Gateway 先算 shard 再 XADD 到对应子流,worker 只读自己持有的那几条,消费组退化成「一条子流一个消费者」,分摊的活被租约接管了。代价是流从 1 条变 256 条,监控和积压告警要按子流聚合,XAUTOCLAIM 也要对每条持有的子流各跑一次。不是昨天写错了,是需求变了——D9 那套分摊是为彼此无关的任务设计的。

哈希函数没有玄机,但有一条要求不能松:同一个 userId 在任何进程、任何语言、任何一次重启之后都必须算出同一个 shard。所以用摘要函数,别用语言内置的字符串哈希。

shard.js
import { createHash } from 'node:crypto'
 
export const SHARD_COUNT = 256 // 固定值,改它等于一次数据迁移
 
export function shardOf(userId) {
  // 摘要函数天生稳定:同一个输入,换进程、换机器、换语言都是同一个结果
  const digest = createHash('sha1').update(userId, 'utf8').digest()
  return digest.readUInt32BE(0) % SHARD_COUNT
}

到这一步「同一个用户永远归同一个格子」已经成立。可格子是死的,人是活的——「谁负责哪个格子」这件事记在哪里,靠什么保证同一时刻只有一个人在负责? 这才是今天真正难的部分。

值班牌:SET NX 加 TTL 就是「谁持有这个 shard」

网格员上岗要领一块值班牌,牌上写着他的名字,有效期 30 分钟,到点回来盖章续期。为什么不发一块永久牌?因为人会失联——出车祸、手机丢了、临时被抽调走,而永久牌意味着这个网格永远没人管。有效期的全部意义就是:不需要任何人干预,牌子会自己失效。

映射到 Redis 就一条命令:

TextText
SET lease:shard:68 "worker-A" NX PX 30000

NX 是「只在这个 key 不存在时才写入」,给出抢占语义:返回 OK 是抢到了,返回空是别人正持有。PX 30000 是「30 秒后自动删除」,给出租约语义。少哪一个都会出事:少了 NX 就是把别人的名字盖掉,少了 PX 就是一把永不失效的死锁,持有者一崩这个格子永久荒废。

TTL 定多长是个真实取舍。太短,一次垃圾回收停顿就让你丢掉租约,shard 反复易主、会话来回搬家;太长,worker 真死了之后它名下的消息要在流里干等满一个 TTL。本课取 30 秒

worker 侧的循环因此很简单:启动后扫一遍 256 个 shard,对没主的挨个 SET NX,抢到几个算几个;之后只从自己持有的 shard 队列里取消息。抢占时顺手取一个单调递增的号存进租约值——这个号第五节救命。

lease.js
// 依赖:ioredis 5.x
const LEASE_TTL_MS = 30_000
 
async function tryAcquire(redis, shard, workerId) {
  // 全局单调递增的号(fencing token),每次成功抢占都比历史上所有号更大
  const token = await redis.incr('lease:fence')
  const key = `lease:shard:${shard}`
  const ok = await redis.set(key, `${workerId}|${token}`, 'PX', LEASE_TTL_MS, 'NX')
  return ok === 'OK' ? { held: true, token } : { held: false, token: 0 }
}

回扣业务:shard 68 只有一个持牌人,落在它上面那个用户的两条消息就只会被一个进程按序取走。租约不是为了「加锁」而存在,它是「一个用户只归一个人管」的技术实现。

盖章之前得先确认牌子上还是你的名字:为什么续约要用 Lua

网格员要在牌子到期前回来盖章。问题是:盖章的人必须先确认牌子上写的还是自己的名字——万一他路上耽误了,牌子已经过期并被交给了别人,这一章盖下去,续的是别人的任期。

这两件事必须在同一次操作里完成。写成两步就会出事:

TextText
第 1 步:GET lease:shard:68        →  "worker-A|2482",是我的,放心
        (此刻仅过了 2 毫秒,但租约刚好到期被 Redis 删掉,worker-B 抢到了它)
第 2 步:PEXPIRE lease:shard:68 30000  →  1,续上了

第 2 步返回 1,看着一切正常,实际上你把 worker-B 的租约续了 30 秒,而你自己还以为自己持有 shard 68。要是第 2 步用的是 SET,你还会把 B 的名字覆盖成自己——两个进程一起动手,前面两节的工作全部作废。

关键认识是:「检查」和「改动」必须是一个不可分割的动作(比较并交换,compare-and-swap)。Redis 执行命令是单线程的,一整段 EVAL 脚本对其他客户端来说是一个原子步骤。所以用 Lua 不是为了性能,是为了把两条命令粘成一条

lualua
-- renew.lua:只有牌子上还是我的名字时才续期
-- KEYS[1] = lease:shard:68,ARGV[1] = "worker-A|2482",ARGV[2] = 30000
if redis.call('GET', KEYS[1]) == ARGV[1] then
  return redis.call('PEXPIRE', KEYS[1], ARGV[2])
end
return 0

返回值只有两种:1 是续上了,0 是牌子已经不是你的了

这个 0 最容易被写错。它不是「重试一次」的信号,是「立刻放手」的信号:把这个 shard 从持有集合里删掉、停止取消息、手上那条没做完的不许再往库里写。太多代码在这里只打一行警告日志然后继续跑——那一行日志就是脑裂的来源。

renew.js
const RENEW_LUA = `
if redis.call('GET', KEYS[1]) == ARGV[1] then
  return redis.call('PEXPIRE', KEYS[1], ARGV[2])
end
return 0
`
 
async function renew(redis, shard, workerId, token) {
  const args = [`lease:shard:${shard}`, `${workerId}|${token}`, String(LEASE_TTL_MS)]
  return (await redis.eval(RENEW_LUA, 1, ...args)) === 1
}
 
if (!(await renew(redis, shard, workerId, token))) {
  held.delete(shard) // 放手要放彻底:不再取消息,也不再写库
}

顺序错了会怎样:从答非所问到取消了不该取消的订单

顺序错了到底损失什么?这决定这套麻烦值不值得。

最轻的是答非所问,就是第一节那个退款例子。重一点是上下文错乱:D6 讲过会话历史按顺序拼给模型,顺序错了模型看到的就不是用户说过的话。再重的是写库冲突:D8 定的 messages 表带着 unique(run_id, seq),两个 worker 各自算 seq 再插入,撞上约束的那条直接落库失败。

最不可逆的是副作用重排。用户先说「把订单改到明天送」,再说「算了,取消吧」——顺序对的结果是订单被取消;顺序反了就是先取消再改期,改期作用在已取消的订单上失败,最终变成「取消了一个用户本来想留下的订单」。顺序错在有副作用的系统里就是业务错。

那顺序靠哪几层保住?这是面试里最值得答清的一条链,一共四层:

  1. 入队有序:Gateway 落库时给同一会话的消息发连续 seq 并按 seq 投递;总线对同一条流是追加有序的。
  2. 消费者唯一:同一个 shard 同一时刻只有一个 worker 在读——今天的租约就干这件事。
  3. 进程内串行:同一个 shard 内不能并发处理两条消息。这层最容易被自己破坏:为了「提高吞吐」把一批消息丢进 Promise.all,顺序就在你自己的代码里丢掉了。租约保住跨进程的顺序,await 保住进程内的顺序,缺一不可。
  4. 在途的先回来:前任挂掉时手上可能有一条已领取但没确认的消息,接管者必须先取回它再读新消息(D9 的 XAUTOCLAIM),否则新消息会插到旧消息前面。

第 3 层有个代价:串行意味着一个用户的慢请求会挡住同一个 shard 上其他用户的消息,一次 20 秒的模型调用能让这个 worker 的 85 个 shard 全部停摆。正确做法是按 shard 并行、shard 内串行:每个持有的 shard 各起一条独立处理链。并行的单位是 shard,不是消息。

最后划一条边界:这套机制保的是同一个用户的顺序,不保证跨用户的全局顺序——全局有序要把并行度压到 1。顺序性和并行度是一对反比,分片的意义就是把「必须有序」的范围缩到最小。

交接班的那 30 秒,以及「两个人都以为自己在值班」

正常的交接全靠 TTL——worker-A 被 kill 时来不及做任何交还:

TextText
t=0.0s   worker-A 持有 shard 68,每 10 秒续约一次
t=12.0s  worker-A 被 kill -9(没有机会交还租约)
t=20.0s  最后一次成功续约的 30 秒 TTL 走完,Redis 删掉 lease:shard:68
t=20.3s  worker-B 的下一轮扫描发现 shard 68 无主,SET NX 成功,B 成为持有者
t=20.4s  B 先把 A 领走没确认的那条消息取回来,再读新消息

最坏的接管延迟是一个 TTL 加一轮扫描间隔,这段时间这个用户的消息在流里积压——这是租约模型明码标价的代价:用一小段可用性空窗,换「绝不会有两个人同时处理」。 想缩短就调小 TTL,但越小越容易把只是卡了一下的 worker 误判成死的。

现在讲今天最重要的一段:TTL 到期,不等于前任真的死了。

最常见的场景不是进程崩溃,而是 worker 只是卡了 5 秒:一次 full GC、宿主机 CPU 被邻居打满、容器被 cgroup 限流。它醒过来时内存里还写着「我持有 shard 68」,于是继续处理手上那条消息、继续往 messages 表写。而 Redis 里的租约早已到期并被 worker-B 抢走。这一刻两个 worker 都真诚地认为自己持有 shard 68,今天所有的顺序保证同时失效。

三条缓解手段,按性价比从高到低。

一、给 worker 设一条自杀规则。 连续两次续约失败、或距上次成功续约超过 TTL 的三分之二,就立刻停止处理、清空持有集合。这条最便宜也必须有——它把「我以为我还持有」的窗口从无限压到两个续约周期。

二、fencing token(护栏令牌)。 每次成功抢到租约时从一个单调递增的计数器取一个号(Redis 的 INCR),和持有者名字一起写进租约值;之后所有有副作用的操作都带上它,下游只接受「号不比自己见过的最大号小」的写入。醒过来的 A 手里是旧号,写入被下游直接拒绝——哪怕它自己还以为自己持牌。落到 D8 那几张表上就是一句条件更新。代价是它需要下游配合:下游若是第三方接口(发短信、扣款),你没法让对方帮你比号,那时只能退回 D8 定的 runs.idempotency_key

三、每次有副作用的操作前重新校验租约,并把校验和写入放进同一段脚本。这只是缩小窗口,不是消除。

三条叠起来,效果不是「绝不脑裂」,而是「脑裂发生时第二个人的写入落不了地」。对同一个用户的消息顺序来说这已经够了——顺序由能写进库的那一个人决定。

一致性哈希与固定分片数:什么时候真的需要那个环

面试聊到分片,八成会问一致性哈希(consistent hashing)。它解决的其实是另一个问题:节点数变化时最小化重新映射。做法是把节点和 key 都哈希到一个环上,key 归顺时针最近的节点;加一个节点只影响它和前驱之间那一段,平均只有 1/N 的 key 换家。

但我们已经用另一种方式解决了同一个问题:用户到 shard 的映射永远不变(256 是常量),变的只有 shard 到 worker 的归属——而这层归属本来就靠租约动态决定。加一个 worker,它只是去抢那些无主的 shard,用户到 shard 的映射一行都不动。

那什么时候真需要它?当分片本身承载状态、迁移代价很高的时候,比如每个分片对应一份本地缓存或一段磁盘数据,换节点接手就得搬数据。我们的 shard 不带状态:状态在 Postgres 和 Redis 里,worker 只是无状态的执行体。没有数据要搬,就不需要一致性哈希。

固定分片数的代价必须说清,一共三条。

并行度上限就是分片数,256 个 shard 意味着最多 256 个 worker 有活干。而改分片数是一次数据迁移:256 改 512,所有用户的归属重算,会话被搬到别的 worker,必须停机或做一段双写过渡。所以这个数一开始就要定得偏大:256 摊在 3 个 worker 上是 85、85、86,多出来的成本只是几百个 Redis key 的内存;当初定成 8 个的话,扩到第 9 个 worker 就撞墙了。

热点。 哈希均匀指的是用户数均匀,不是消息量均匀——一个日发千条的大客户可能和一千个散户挤在同一个 shard 上。出路是给大客户加一张哈希前的例外表单独占一个 shard,而不是调大分片数(那就是上面说的数据迁移)。

一句话记住这个取舍:一致性哈希优化的是迁移量,固定分片优化的是可预测性。 分片无状态、归属又本来就由租约动态决定时,固定分片明显更简单,而在分布式系统里,简单就是可靠。

源码导读

动手实验

🧪 D10 实验:256 shard + 租约 + 双 worker 扩缩容实验

Code location: labs/agent-30days/day-10-sharding-lease

验收标准:

  1. MOCK=1 SELFTEST=1 pnpm start 五项自检全是 ✅、退出码 0;starter/ 原样跑是五项全 ❌。
  2. 第 1 项显示「2000 个用户占 256/256 个 shard」,而 starter/ 那个按长度分片的版本显示「占 1/256 个 shard,最大桶 2000 条」。
  3. 第 2 项显示「A 持有 128 个、B 持有 128 个,交集 0 个、并集 256 个」——交集不为 0 就说明 SET 少了 NX。
  4. 第 4 项显示某个用户的处理顺序是 1、2、3、4、5、6,后三条由接手的 worker 处理;starter/ 会打出类似 1、3、2、5、6、4 的乱序。
  5. 第 5 项显示假死的前任「拿旧号续约被拒、写入被拒」,任何一半是「成功(危险)」都算没通过。

这个实验是常驻循环,所以验收命令是 MOCK=1 SELFTEST=1 pnpm start:它在一个进程里起两个 worker 循环,按剧本走完全程再退出。MOCK=1 下零外部依赖,因为 src/infra/ 里有一份内存实现——租约的 TTL 到期语义是真写出来的,不是打桩。自检把时钟等比压缩了 10 倍(TTL 3 秒、续约 1 秒),否则你要等两分多钟才看到接管。想看真 Redis 上跑同一份业务代码,docker compose up -d 之后设 REDIS_URL 再跑一次,五项结果应当完全一致。

  1. 实现 shardOf:用摘要函数把 userId 映射到 256 个 shard,跑自检看第 1 项从「占 1 个 shard」变成「占 256 个 shard」。
  2. 用 SET NX + TTL 真的去抢租约,看第 2 项的交集从 128 变成 0——这一步之前,两个 worker 都以为 256 个 shard 全归自己。
  3. 把续约从「先 GET 再 PEXPIRE」换成一段 Lua,让比较持有者和续期在同一次 EVAL 里完成,并在返回 0 时立刻放手。
  4. 给下游写入加上 fencing token 的条件校验,看第 5 项里假死的前任从「写入成功(危险)」变成「写入被拒」。
  5. 把同一个 shard 内的处理从 Promise.all 改成逐条 await,看第 4 项的顺序从乱序变回 1 到 6;然后另开两个终端,用真 Redis 手动 kill 一个 worker,亲眼看另一个在 30 秒内接手。

面试题

今天 4 道题在下方题库区,侧重一致性哈希、分布式锁与租约的区别、脑裂。展开后先看"分析过程"再看要点——第 3 题的脑裂是这一章被追问得最多的地方,答到「单靠 Redis 租约做不到绝对互斥」才算及格。

检查清单与明日预告

  • 能实现一个 userId 哈希到固定数量 shard 的分配函数
  • 能用 SET NX + TTL 拿到一个 shard 的租约,并用 Lua 脚本安全续约
  • 能说清租约到期后 shard 怎么 handoff 给另一个 worker,同时不打乱同用户消息的顺序
  • 能说清租约和分布式锁的区别,以及为什么续约必须是一次原子操作
  • 能说出保住同用户顺序的四层,并指出哪一层是被自己的 Promise.all 破坏的
  • 实验的 5 条验收标准全部通过
  • 4 道面试题不看要点也能答出至少 3 道

明天(D11)我们把这条链路接回用户。今天顺序保住了,可用户那边还是「发出去就没声了」——worker 算出来的字没有任何通路流回那条还挂着的 SSE 连接。明天要给每次执行建一个 run 状态机,让 worker 每产出一个片段就带着序号写进输出流,再由接入层按序号推给等待中的连接,顺便处理打断合并。之所以是这个顺序:执行侧的顺序不先保住,回传侧按序号推就是把一堆乱序片段原样送到用户眼前。

Interview questions

  • Why hash user ids into shards instead of letting the consumer group dispatch freely, and how do you pick the shard count?为什么要对 userId 做哈希分片,而不是让消费组随机派发?分片数应该怎么选?
    Common in ChinaCommon overseasBasic#sharding#consistent-hashing#scalability

    How to reason about it · think before answering

    1. The hinge is 'why not dispatch freely'. Answering 'for load balancing' misses it — a consumer group already balances load, and free dispatch balances better than hashing. Sharding buys something else: affinity.
    2. The chain: a consumer group's unit of assignment is one message, while the business requires one user as the smallest serial unit. When those units disagree, two messages from the same user get processed concurrently by two workers.
    3. Second step: why insert a shard layer instead of taking userId modulo the worker count? Because the worker count changes on scale-up, restart, crash and rolling deploy. Change the divisor and almost every user is remapped, so in-flight sessions migrate wholesale. A fixed shard count pins user-to-shard and lets only shard-to-worker float.
    4. For the count, give criteria rather than a number: it caps parallelism (256 shards means at most 256 useful workers), and changing it is a data migration (every user is remapped, requiring downtime or a dual-write transition). So oversize it up front — 256 across 3 workers is 85/85/86 and costs a few hundred keys of memory, while picking 8 walls you in at the ninth worker. Use a power of two so the modulo degrades to a bit mask and future splits stay clean.
    5. Volunteer the limit of uniformity: it means uniform user counts, not uniform message volume. One enterprise account sending a thousand messages a day can share a shard with a thousand one-message users. The fix is an exception table before the hash that gives that account its own shard, not a larger shard count — that would be the migration above.
    6. Expect the follow-up: why not consistent hashing? It optimizes remap volume, which pays off when shards carry state that is expensive to move. Our workers are stateless executors with state in Postgres and Redis, so nothing needs moving, and shard ownership is already decided dynamically by leases. Fixed sharding optimizes predictability, which is simpler and more reliable here.

    分析过程 · 先想清楚再作答

    1. 题眼在「为什么不随机派发」。只答「为了负载均衡」就掉进坑里了——消费组本来就是负载均衡,随机派发在均衡上比哈希分片更好。分片解决的是另一件事:亲和性。
    2. 推导链是这样的:消费组的分配单位是「一条消息」,而业务要求的最小串行单位是「一个用户」;单位对不上,同一个用户连发的两句话就会被两个进程同时处理。所以要把分配单位从消息抬到用户。
    3. 第二步是「为什么中间要垫一层 shard,而不是 userId 直接取模 worker 数」。因为 worker 数会变——扩容、重启、崩溃、滚动发布;除数一变,几乎所有用户的归属都会变,正在处理的会话被整体搬家。固定的 shard 数把「用户到 shard」钉死,只让「shard 到 worker」随伸缩浮动。
    4. 分片数怎么选,要给出可执行的判据而不是一个数字:它是并行度的上限(256 个 shard 最多让 256 个 worker 有活干),而且改它等于一次数据迁移(所有用户归属重算,必须停机或双写过渡)。所以宁可一开始定得偏大——256 摊在 3 个 worker 上是 85、85、86,多出来的成本只是几百个 key 的内存;定成 8 个的话扩到第 9 个 worker 就撞墙了。要用 2 的幂,取模能退化成位运算,也方便将来对半拆分。
    5. 主动说出哈希均匀的边界:均匀说的是「用户数均匀」,不是「消息量均匀」。一个日发千条的大客户可能和一千个散户落在同一个 shard 上。缓解是给大客户在哈希前加一张小的例外表、单独占一个 shard,而不是把总分片数调大(那就是上面说的数据迁移)。
    6. 可预期的追问:为什么不用一致性哈希?答案是它优化的是「节点变化时的迁移量」,前提是分片承载状态、搬迁很贵。我们的 worker 是无状态执行体,状态在数据库和 Redis 里,没有数据要搬;而且 shard 到 worker 的归属本来就由租约动态决定。固定分片优化的是可预测性,在这个场景里更简单,也更可靠。

    Key points

    • Sharding is about affinity, not balancing: it lifts the unit of assignment from one message to one user so a user always lands on the same worker
    • The fixed shard layer keeps user-to-shard stable across scaling; only shard-to-worker ownership moves
    • The shard count caps parallelism and changing it is a migration, so oversize it and use a power of two (256 in this course)
    • Uniform hashing means uniform user counts, not uniform traffic; hot accounts need an exception table before the hash
    • Consistent hashing optimizes remap volume and only pays off for stateful shards; stateless workers do better with fixed shards

    答题要点

    • 分片解决的是亲和性不是负载均衡:把分配单位从「一条消息」抬到「一个用户」,同一个用户永远落到同一个 worker
    • 中间垫一层固定 shard,是为了让 worker 伸缩时用户到 shard 的映射保持不变,只有 shard 到 worker 的归属浮动
    • 分片数是并行度上限,改它等于一次数据迁移,所以一开始就定偏大、用 2 的幂(本课 256)
    • 哈希均匀保的是用户数均匀,不是消息量均匀;大客户热点要靠哈希前的例外表单独拆 shard
    • 一致性哈希优化迁移量,只在分片带状态时划算;无状态 worker 用固定分片更简单
  • Why must a lease carry a TTL, and why renew it with a Lua script instead of GET followed by PEXPIRE?租约为什么必须配合 TTL?续约为什么要用 Lua 脚本,而不是先 GET 再 PEXPIRE?
    Common in ChinaCommon overseasIntermediate#lease#redis#atomicity

    How to reason about it · think before answering

    1. There are two things being tested and the second is the discriminator. The first is really 'do you know a lease is not a lock': a lock means mutual exclusion (I hold, you wait, you get it when I release), while a lease means ownership with an expiry (it lapses even if the holder never releases, because the holder may never come back).
    2. That gives you the necessity of the TTL: holders get kill -9'd, lose the network, lose the whole machine — they never get to hand anything back. Without a TTL you have a lock that is never released and a shard that is permanently orphaned until a human intervenes.
    3. Volunteer the TTL trade-off to show you have tuned this: too short and a GC pause or a network blip costs you the lease, so shards flap and sessions keep migrating; too long and a genuinely dead worker's shards sit idle for a full TTL. A common setting is a 30 second TTL renewed every 10 seconds (one third), which tolerates two consecutive renewal failures.
    4. The second point is atomicity, and you should spell out the failing interleaving: GET says the lease is yours, then within two milliseconds it expires, Redis drops it, another worker wins it with SET NX, and your PEXPIRE succeeds — you have just extended your rival's lease while believing you still hold the shard. If step two is SET rather than PEXPIRE you also overwrite their owner field and both processes start working.
    5. Land on the general principle: check-and-mutate must be indivisible (compare-and-swap). Redis executes commands single-threaded, so one EVAL is a single atomic step to every other client — Lua here is not about performance, it is about fusing GET and PEXPIRE. Redis Functions or WATCH plus a transaction retry are equivalent, but Lua is the most direct.
    6. Expect the follow-up: what should a renewal returning 0 do? Let go immediately — drop the shard from the held set, stop consuming, and refuse to write the in-flight item. Logging a warning and carrying on is the most common source of split brain. Add a self-kill rule too: if the last successful renewal is older than two thirds of the TTL, release everything.

    分析过程 · 先想清楚再作答

    1. 这题有两个考点,第二个才是区分度。第一个考点其实是在问「你知不知道租约和分布式锁不是一回事」——先把这条说清:锁的语义是互斥(我持有、你等待,我主动 release 你才拿得到),租约的语义是带过期时间的所有权(持有者不 release 也会失效,因为它可能永远不会回来了)。
    2. 由此推出 TTL 的必要性:持有者会被 kill -9、会断网、会整台机器掉电,它没有机会归还。没有 TTL 就是一把永不释放的锁,那个 shard 从此永久荒废,只能靠人工介入。TTL 的全部意义是「不需要任何人干预,所有权会自己失效」。
    3. 顺手说出 TTL 的取舍,证明你调过:太短则一次垃圾回收停顿或网络抖动就丢租约,shard 反复易主、用户会话来回搬家;太长则真死了之后要等满一个 TTL 才有人接手。常见口径是 TTL 30 秒、续约间隔取 TTL 的三分之一(10 秒),这样能连续失败两次而不丢租约。
    4. 第二个考点是原子性。两步写法的失败时间线要具体讲出来:GET 返回「是我的」,紧接着的两毫秒里租约恰好到期被 Redis 删除、另一个 worker SET NX 抢到,然后你的 PEXPIRE 执行成功——你续的是对手的租约,而自己还以为持有。如果第二步用的是 SET 而不是 PEXPIRE,你还会把对手的名字覆盖成自己,两个进程一起动手。
    5. 结论要落到通用原理上:检查和改动必须是一个不可分割的动作(compare-and-swap)。Redis 单线程执行命令,一整段 EVAL 对其他客户端就是一个原子步骤,所以 Lua 在这里不是为了性能,是为了把 GET 和 PEXPIRE 粘成一条。等价手段还有 Redis 函数、或用 WATCH 加事务重试,但 Lua 最直接。
    6. 可预期的追问:续约返回 0 应该怎么办?答「立刻放手」——把这个 shard 从持有集合里删掉、停止取消息、手上那条没做完的不许再写。返回 0 只打一行警告日志然后继续跑,是脑裂最常见的来源。再加一条自杀规则:距上次成功续约超过 TTL 的三分之二就主动全部放手。

    Key points

    • A lease is not a lock: locks give mutual exclusion, leases give ownership with an expiry, because the holder may never return
    • Without a TTL you have a never-released lock and a permanently orphaned shard once the holder is killed
    • A 30 second TTL renewed every 10 seconds leaves headroom for two consecutive renewal failures
    • In the two-step window the lease may already have changed hands, so your PEXPIRE extends a rival's term while you still think you hold it
    • Lua fuses the ownership check and the extension into one atomic step; a renewal returning 0 means let go immediately

    答题要点

    • 租约不是锁:锁是互斥,租约是带过期时间的所有权;持有者可能永远不会回来,所以所有权必须能自己失效
    • 没有 TTL 就是永不释放的锁,持有者被 kill 之后那个 shard 永久荒废
    • TTL 30 秒、续约间隔 10 秒(TTL 的三分之一),留出连续两次续约失败的余量
    • 两步续约的窗口里租约可能已易主,你的 PEXPIRE 会替对手延长任期,而自己仍以为持有
    • Lua 的作用是把「比较持有者」和「续期」粘成一个原子步骤,不是为了性能;续约返回 0 必须立刻放手
  • What happens when two workers both believe they hold the same shard lease (split brain), and how do you mitigate it?两个 worker 同时认为自己持有同一个 shard 的租约(脑裂)会造成什么后果,怎么规避?
    Common in ChinaCommon overseasDeep dive#split-brain#fencing-token#reliability

    How to reason about it · think before answering

    1. The scoring criterion here is explicit: does your answer contain the sentence 'a Redis lease alone cannot give absolute mutual exclusion'. Anyone who says SET NX plus a TTL makes it safe gets probed until they run out of answers.
    2. Start with how split brain arises, and use the common case: not a crash, but a holder that merely froze for five seconds — a full GC, a noisy neighbour saturating the host CPU, cgroup throttling. It wakes up still believing it holds shard 68, keeps processing the in-flight message and keeps writing, while the lease expired and was taken. Add the second layer: Redis replication is asynchronous, so a failover can lose the last few milliseconds of writes and let two workers both win SET NX.
    3. Then the consequences, expressed in business terms rather than 'inconsistent data': two messages from one user processed concurrently means out-of-order replies, a corrupted context window, and unique(run_id, seq) violations that silently drop a message. Worst is reordered or duplicated side effects — swap 'cancel the order' with 'move the delivery date' and you cancel an order the user wanted to keep.
    4. The key shift: since you cannot rule out that timeline on the Redis side, the goal is not to prevent split brain but to make the second writer's writes fail — push conflict detection and rejection down to the layer that actually causes side effects.
    5. Give three mitigations by value. First, a self-kill rule in the worker: after two consecutive renewal failures, or when the last success is older than two thirds of the TTL, stop processing and clear the held set — cheapest, and it bounds the 'I think I still hold it' window to two renewal periods. Second, fencing tokens: take a monotonically increasing number (Redis INCR) when acquiring, store it in the lease value, attach it to every side-effecting operation, and have the downstream accept only numbers not lower than the highest it has seen — in a database that is one conditional update. The revived predecessor carries a stale number and is rejected. Third, re-validate the lease immediately before each write inside the same script or transaction, which shrinks the window without closing it.
    6. Expect the follow-up: where does fencing break down? It needs downstream cooperation. Databases do conditional updates, but a third-party endpoint (SMS, payments) will not compare your token, so you fall back to idempotency keys that make duplicate execution harmless rather than impossible. True mutual exclusion means moving to a consensus-backed system such as etcd or ZooKeeper session leases, paying in write latency and operational complexity.

    分析过程 · 先想清楚再作答

    1. 这题的判分点非常明确:答案里有没有出现「单靠 Redis 租约做不到绝对互斥」。说「用了 SET NX 加 TTL 就安全了」的人,会被追问到答不上来。
    2. 先讲脑裂是怎么发生的,而且要举那个最常见的场景——不是进程崩溃,是持有者只卡了 5 秒:一次 full GC、宿主机 CPU 被邻居打满、容器被 cgroup 限流。它醒过来时内存里还写着「我持有 shard 68」,继续处理手上那条消息、继续写库,而 Redis 里的租约早已到期并被别人抢走。再补一层:Redis 主从复制是异步的,切主时可能丢掉最后几毫秒的写入,于是两个 worker 都能 SET NX 成功。
    3. 然后讲后果,而且要落到业务上而不是停在「数据不一致」:同一个用户的两条消息被两个进程并发处理,回复乱序、上下文错乱、messages 表的 unique(run_id, seq) 撞约束导致落库失败;最严重的是有副作用的工具被重排或重复执行——「取消订单」和「改配送日期」顺序反了,结果是取消了一个用户本来想留下的订单。
    4. 关键的认知转折:既然无法在 Redis 一侧排除这条时间线,正确的思路就不是「让脑裂不发生」,而是「让第二个人的写入落不了地」——把冲突的检测与拒绝推到真正产生副作用的那一层。
    5. 三条手段按性价比给出。一是 worker 自己的自杀规则:连续两次续约失败、或距上次成功续约超过 TTL 的三分之二,立刻停止处理并清空持有集合——最便宜,把「我以为我还持有」的窗口从无限压到两个续约周期。二是 fencing token:抢租约时从一个单调递增计数器取号(Redis 的 INCR)写进租约值,之后所有有副作用的操作都带上它,下游只接受不比见过的最大号小的写入,落到数据库上就是一句条件更新;醒过来的前任拿的是旧号,写入直接被拒。三是每次写之前重新校验租约,并把校验与写入放进同一段脚本或同一个事务——这只缩小窗口,不消除。
    6. 可预期的追问:fencing 的局限在哪?答「它需要下游配合」。数据库能做条件更新所以好使,但下游是第三方接口(发短信、扣款)时你没法让对方帮你比号,这时只能退回幂等键,把重复执行变成无害,而不是让它不发生。真要绝对互斥就得换到有共识协议的系统(etcd、ZooKeeper 的会话租约),代价是写入延迟和运维复杂度。

    Key points

    • A Redis lease alone cannot guarantee mutual exclusion: a frozen holder that revives, and asynchronous replication losing writes on failover, are both unavoidable
    • State consequences in business terms: out-of-order replies, corrupted context, unique-constraint violations dropping messages, and reordered or duplicated side effects
    • The goal is to make the second writer's writes fail — push conflict detection to the side-effecting layer instead of hoping split brain never happens
    • Three mitigations: a worker self-kill rule on repeated renewal failure, fencing tokens enforced as conditional updates, and re-validating the lease immediately before writing
    • Fencing needs downstream cooperation; against third-party endpoints fall back to idempotency keys, and true mutual exclusion means a consensus system like etcd or ZooKeeper

    答题要点

    • 单靠 Redis 租约做不到绝对互斥:持有者被冻结再醒来、以及主从异步复制丢写,这两条时间线排除不掉
    • 后果要落到业务:同用户回复乱序、上下文错乱、唯一约束冲突丢消息,最严重是有副作用的工具被重排或重复执行
    • 思路是「让第二个人的写入落不了地」,把冲突检测推到产生副作用的那一层,而不是指望脑裂不发生
    • 三条手段:worker 自杀规则(续约连续失败就放手)、fencing token(写入时带单调号做条件更新)、写前重新校验租约
    • fencing 需要下游配合;下游是第三方接口时只能退回幂等键,要绝对互斥就得换 etcd / ZooKeeper 这类有共识协议的系统
  • In a multi-worker agent service, how do you guarantee that one user's messages are processed in strict order?在一个多 worker 的 Agent 服务里,怎么保证同一个用户的消息严格按顺序被处理?
    Common in ChinaCommon overseasIntermediate#ordering#sharding#distributed-systems

    How to reason about it · think before answering

    1. This is a small system-design question testing whether you can decompose ordering into layered guarantees rather than naming a middleware. 'Partition by key in Kafka' is not wrong, but it leaves 'and inside the process?' unanswered, which is exactly where they will push.
    2. Decompose it along the path from ingress to side effect, four layers. One, ordered ingress: the gateway assigns consecutive seq numbers per session on write and publishes in seq order; a single stream is append-ordered, so this layer is nearly free. Two, single consumer: only one worker reads a given shard at a time, enforced by the lease — that is the cross-process half.
    3. Three, in-process serialization: no two messages from the same shard may be handled concurrently. This is the layer people break themselves, by dropping a batch into Promise.all or a thread pool to raise throughput. Say it explicitly: the lease preserves order across processes, await preserves it inside one. Four, in-flight first: a killed predecessor may hold a delivered but unacknowledged message, so the successor must claim it back before reading anything new, otherwise a newer message jumps ahead of an older one.
    4. Then name the cost of serialization, which is where they judge whether you have shipped this: a single slow request blocks other users on the same shard, and one 20-second model call can stall every shard that worker owns. The right shape is parallel across shards, serial within a shard — one independent processing chain per held shard. The unit of parallelism is the shard, not the message.
    5. Volunteer the boundary: this only guarantees per-user order, never a global order across users. Global ordering requires parallelism of one, which defeats the point. Ordering and parallelism trade off directly, so sharding exists to shrink the 'must be ordered' scope to the smallest useful unit.
    6. Expect two follow-ups. Could you skip leases? Yes — Kafka key partitioning or sticky routing from the gateway to a fixed worker also gives affinity, at the cost of rigid partition counts or of needing a separate failover mechanism when a worker dies; the lease happens to solve failover at the same time. Could the business simply tolerate reordering? Partly, if appends are idempotent and commutative, but any irreversible side effect such as a refund or a shipment forces you to preserve order.

    分析过程 · 先想清楚再作答

    1. 这题是系统设计小题,考的是你能不能把「顺序」拆成分层的保证,而不是丢一个中间件名字。只答「用 Kafka 按 key 分区」不算错,但没有回答「分区之后进程内怎么办」,会被追着问。
    2. 拆法是从消息进入系统到产生副作用,逐层点出谁在保顺序,一共四层。第一层入队有序:接入层落库时给同一会话的消息发连续 seq,并按 seq 投递,总线对同一条流是追加有序的,这层几乎免费。第二层消费者唯一:同一个分片同一时刻只有一个 worker 在读,靠租约实现——这是跨进程的那一半。
    3. 第三层进程内串行:同一个分片内不能并发处理两条消息。这一层最容易被自己破坏——为了提高吞吐把一批消息丢进 Promise.all 或线程池,顺序就在自己的代码里丢掉了。要明确说出「租约保住跨进程的顺序,await 保住进程内的顺序,缺一不可」。第四层在途优先:前任 worker 挂掉时手上可能有一条已领取但没确认的消息,接管者必须先把它 claim 回来再读新消息,否则新消息会插到旧消息前面。
    4. 紧接着说串行的代价,这是面试官判断你有没有上过线的地方:串行意味着一个用户的慢请求会挡住同一个分片上其他用户的消息,一次 20 秒的模型调用能让这个 worker 名下的几十个分片全部停摆。正确做法是按分片并行、分片内串行——每个持有的分片各起一条独立处理链。并行的单位是分片,不是消息。
    5. 主动划边界:这套机制只保证同一个用户的顺序,不保证跨用户的全局顺序。全局有序需要把并行度压到 1,那就没有分布式可谈了。顺序性和并行度是一对反比,分片的意义就是把「必须有序」的范围缩到刚好够用的最小值。
    6. 可预期的追问一:不用租约行不行?可以,Kafka 按 key 分区、或者让 Gateway 直连固定 worker(粘性路由)都能得到亲和性,但代价分别是分区数难改、以及 worker 挂掉时需要额外的故障转移机制——租约恰好把故障转移也一并解决了。追问二:能不能干脆让业务对乱序免疫?部分可以,比如把「追加消息」设计成幂等且可交换的写入,但只要存在不可逆的副作用(退款、发货),顺序就必须保。

    Key points

    • Decompose ordering into four layers: ordered ingress with consecutive seq, a single consumer per shard via the lease, in-process serialization with await, and claiming the predecessor's in-flight message first
    • The lease preserves order across processes and await preserves it within one — reaching for Promise.all to raise throughput destroys it
    • The unit of parallelism is the shard, not the message: one chain per held shard, or a single slow call stalls every shard that worker owns
    • Only per-user order is guaranteed, never a global order; ordering trades off against parallelism, so sharding shrinks the ordered scope
    • Alternatives are Kafka key partitioning or sticky routing, but neither brings failover; any irreversible side effect makes ordering mandatory

    答题要点

    • 把顺序拆成四层:入队有序(连续 seq)、消费者唯一(租约)、进程内串行(逐条 await)、在途消息优先被接管者 claim 回来
    • 租约保住跨进程的顺序,await 保住进程内的顺序,缺一不可——用 Promise.all 提吞吐会当场毁掉顺序
    • 并行的单位是分片不是消息:每个持有的分片各起一条独立处理链,否则一次慢调用会拖停这个 worker 的全部分片
    • 只保证同一用户的顺序,不保证跨用户全局有序;顺序性和并行度是反比,分片就是把有序范围缩到最小
    • 替代方案是 Kafka 按 key 分区或粘性路由,但它们不自带故障转移;只要存在不可逆副作用,顺序就必须保

Comments

Sign in to join the discussion

No comments yet — be the first.