Dayward AI
Week 2 · D14About 6 hours

Deployment and Operations: Multi-Worker Compose, Heartbeats, Health Checks, Graceful Shutdown, Dev/Prod Isolation; Week Two Retrospective

Run mini-koda as a multi-worker deployment with docker compose, add heartbeats, health checks, and graceful shutdown, sort out how dev and prod stay isolated, and wrap up week two.

Today's goals 0/3

Sign in to tick these off and save your progress.

今日目标

  1. 能用 docker compose 把 worker 扩到多个实例并观察流量分摊
  2. 能实现 worker 的心跳上报和 gateway 的健康检查端点
  3. 能实现优雅停机:收到停止信号后处理完手头任务再退出

昨天结尾说了:调度器、总线、Worker、数据库、台账全在你本机一个终端里跑着,出问题只能肉眼看日志。今天把它变成一套能被别人接管的部署,再回头把第二周串成一条线。读完回到页面顶部把三条目标勾掉。

小白版讲解

一个镜像,三个店员:compose 的多副本 worker

楼下那家便利店 24 小时营业,靠的不是某一个店员——同一套流程,三班人轮着上,谁上岗都能收银、上货、盘点。店是「岗位加轮班」,不是「某个人」。 这是今天所有内容的底座:岗位定义清楚,人就可以被替换。

D8 到 D13 已经把岗位定义清楚:Gateway 无状态、Worker 从总线取活、状态全在 Postgres 和 Redis 里。扩副本在架构上早就做完了,今天只是把它声明出来。

声明的地方是一份 compose 文件。它和 D7 那份 Dockerfile 分工不同:Dockerfile 回答「这一个进程怎么起来」,compose 回答「这些进程怎么凑成一套系统」。D7 的镜像今天一行都不改。

YAMLYAML
services:
  gateway:
    build: .
    environment:
      ROLE: gateway            # 一个镜像两种角色,靠环境变量分岔
      APP_ENV: dev
      REDIS_URL: redis://redis:6379
    ports: ['3014:3014']       # 只有它需要对外暴露端口
    depends_on:
      postgres: { condition: service_healthy }
      redis: { condition: service_healthy }
    stop_grace_period: 25s
 
  worker:
    build: .                   # 同一个镜像
    environment:
      ROLE: worker
      APP_ENV: dev
      REDIS_URL: redis://redis:6379
    deploy:
      replicas: 3              # 三个副本,各自独立的容器与主机名
    stop_grace_period: 25s     # 必须大于代码里的 20 秒宽限,理由见第四节

四个决定值得说清楚。

一个镜像,不是两个。 Gateway 和 Worker 本来就在一个仓库里,共用 schema、配置和那份内存实现。打两个镜像意味着两条流水线、两个版本号,还会出现「Gateway 是新版、Worker 还是旧版」这种最难查的故障。用一个环境变量分岔,版本天然对齐。

Worker 副本不需要端口映射。 它不接受任何入站连接,是自己去总线取活的。所以加副本不用动负载均衡、不用注册服务、不用改路由——新容器起来,XREADGROUP 一发就有活干;而 Gateway 每加一台都要挂到负载均衡后面。「拉」天生比「推」好扩容,这是 D9 选消息总线的一个后置收益。

副本的名字得想一想。 三个副本共用同一份环境变量,所以消费者名不能写在里面。本课取容器主机名,它天然每容器一份。代价是 D9 点过的坑:容器重建就换名字,旧名字下没确认的消息成了孤儿,只能靠 XAUTOCLAIM 捡回来。

副本数有天花板,而且不在 CPU 上。 连接数等于副本数乘以连接池大小,而 Postgres 默认 max_connections 只有 100——10 个副本各开 10 条就顶满,第 11 个起来只会拿到「too many clients」。扩容前先算连接数,这是从三个副本走向三十个时第一个撞上的墙,报错还和你的业务代码毫无关系。

到这里 docker compose up -d 起来的就是一套多副本部署,docker compose ps 也如实显示三个 Up。可 Up 只说明「进程还在」——它可能正卡在一次 GC 里什么都不干。而下次发版要把三个容器整批换掉,你怎么保证没有一次正在跑的对话被掐断?

打卡记录比盯着看有用:心跳

便利店店长不会盯着每个店员看一整天,他看打卡记录:谁连着三次没打卡,才走过去看一眼——不是时刻盯着,而是约定一个节奏,节奏断了才报警。

这个节奏叫心跳(heartbeat),本课口径是每 5 秒上报一次,连续 3 个周期(15 秒)没上报就判失联

为什么需要它?因为「进程还在」和「还在干活」是两件事。编排系统看到的是前者:进程没退出就是 Up。但一个 Worker 完全可以进程活着而活儿全停——事件循环被死循环占住、连接池耗尽后每次取消息都超时、宿主机 CPU 被邻居打满。这类「假死」是生产上最常见的故障形态,恰好又是编排系统看不见的那一种。

方向要选对:心跳是副本自己上报(push),不是 Gateway 逐个去问(pull)。 容器随时换 IP 和主机名,去问的一方需要一份永远在变的名单——而那份名单本身就得靠心跳维护,绕回来了。上报只需要一个双方都认识的地方,本课用 Redis 的一个 hash,字段名就是副本名。

上报内容别只写「我活着」。三样各有用处:时间戳判活,手头在跑几个任务区分「闲着」与「忙不过来」,版本号在滚动发布时告诉你新旧两批副本各剩几个。

heartbeat.js
const INTERVAL_MS = 5_000
const STALE_MS = 15_000 // 3 个上报周期
 
export function startHeartbeat(redis, key, workerId, inFlight) {
  const beat = () =>
    redis.hset(
      key,
      workerId,
      JSON.stringify({ beatAt: Date.now(), inFlight: inFlight(), version: VERSION })
    )
  void beat() // 立刻打一次,别让面板空着一个周期才看到新副本
  const timer = setInterval(beat, INTERVAL_MS)
  timer.unref() // 心跳不该成为「进程为什么退不出去」的原因
  return () => clearInterval(timer)
}
 
// 判活只是一次减法。阈值取 3 个周期而不是 1 个:一次 GC 停顿就能让上报迟到,
// 阈值太紧,面板会不停地红绿闪烁,值班的人两天后就不看它了。
export const isAlive = (row, now) => now - row.beatAt <= STALE_MS

工程代价有两笔。一是写入量:3 个副本每 5 秒一次,一天约 5.2 万次写,可以忽略;但周期改成 1 秒、副本扩到 100,一天就是 864 万次,这笔账要算。二是存的形状:用带过期时间的独立键更省心,键没了就是失联;但键一过期,「最后心跳时间」也一起没了,面板只能显示「不存在」而不是「失联 23 秒」——排障时你要的恰恰是后者。本课选 hash 加时间戳,代价是死副本会留下一行残骸,需要定期清理。

总部的营业状态牌:健康检查该回答什么

连锁便利店总部有一块营业状态牌。注意:某家分店打烊,不会让别的分店也停业。 状态牌是给总部看的,不是每家店的开门条件。

健康检查最常写错的地方就在这里——同一个词要回答三个不同的问题,混成一个端点就会出事:

  • 存活探针(liveness):这进程需要被重启吗?
  • 就绪探针(readiness):现在能给我发流量吗?
  • 依赖面板:集群此刻是什么状态?

D8 讲过前两个的区别,也留了一条警告:健康检查里不要把下游依赖全查一遍。今天把它落到具体形状——就绪探针只查 Gateway 自己必需的东西(一句 select 1 之类),心跳面板是另一个端点

JSONJSON
// GET /admin/workers —— 面板,给值班的人和告警规则看
{
  "status": "degraded",
  "total": 3,
  "alive": 2,
  "stale": 1,
  "workers": [
    { "workerId": "worker-a", "alive": true, "lastBeatMs": 1200, "inFlight": 2, "version": "v2" },
    { "workerId": "worker-b", "alive": true, "lastBeatMs": 3400, "inFlight": 0, "version": "v2" },
    { "workerId": "worker-c", "alive": false, "lastBeatMs": 41000, "inFlight": 1, "version": "v1" }
  ]
}

把这两件事合起来会怎样?一个 Worker 失联,所有 Gateway 的就绪探针同时转红,编排系统把整个接入层摘光——一个非核心故障被你自己升级成了全站不可用。而实际上那个 Worker 失联根本不影响接单:消息还在流里,没确认的会被接手(D9),它的租约会因 TTL 到期而易主(D10)。接入层完全应该继续收单。

于是「Gateway 怎么判断一个 Worker 可不可用」这个问题的答案有点反直觉:它不判断,也不需要判断。 Gateway 从来不指定某个 Worker 干活,派活由消费组和租约决定(D9、D10)。心跳的用途是观测和告警,不是路由。想清楚这点,就不会去写「挑一个最闲的 Worker 投递」——那等于把分发职责搬回 Gateway,D8 拆开的两层又粘回去了。

面板怎么接告警?两条规则。存活副本数低于预期就告警——声明 3 个却只有 2 个在打卡,说明有一个既没退出又不干活,这正是编排系统看不见、只有心跳能看见的故障。面板转红不自动摘流量,只叫人:「失联」有时只是 Redis 抖了一下,自动处置会把能自愈的抖动变成真故障。

交班时不能把正在结账的顾客扔下:优雅停机

今天最要紧的一节。便利店换班的规矩很朴素:正在结账的这一单必须结完。 新店员可以立刻接待新顾客,但已经开始的那一单不能中途扔下。

进程也一样。发版、缩容、机器维护、抢占式回收,都会给容器发 SIGTERM,等一段宽限期,超时就 SIGKILL。SIGKILL 拦不住,落到正在跑 Agent 循环的 Worker 身上后果很具体:这次的 run 永远停在 running,用户一直转圈;模型的钱付了,回复没落库;没确认的消息要等空闲阈值到了才被接手(D9)。发一次版掐断几十次对话,这就是没有优雅停机的日常代价。

收到 SIGTERM 之后要走三步,顺序不能变:

TextText
t=0.0s   收到 SIGTERM
         第一步:拒绝新任务——把开关拨过去,消费循环下一轮不再从流里取消息
         (已经读到手上还没开始的那几条,留在 pending 里由别人接手,比硬扛完一整批更快)
t=0.0s   第二步:等手头这次执行跑完,但最多等 20 秒
t=3.7s   手头的 run 收尾成 done、消息确认掉
         第三步:主动交还租约、从心跳面板上注销自己
t=3.8s   进程退出(宽限期还剩 16 秒没用完)

第二步那 20 秒是本课固定口径,取法是「一次正常执行的耗时上限」再留余量。等待必须有上限:一次卡死的模型调用会让你永远等不到,而宽限期一到照样 SIGKILL——与其被动挨刀,不如自己认输退出,没确认的消息还在 pending 里,别人会接手重做。

第三步是 D10 特意留给今天的钩子。D10 的租约靠 TTL 到期自然易主,kill -9 时来不及交还,接手方最坏要等满一个 TTL(30 秒)。但计划内的下线不该走这条路——你明知道自己要走,为什么让接班的人白等?主动交还之后接手方下一轮扫描就能上岗;同理主动从面板注销,让「计划内下线」和「进程死了」在面板上是两种现象,而不是都表现为失联 15 秒。

交还必须带一个条件:只删还写着自己名字的那把牌子。 租约若已过期、别人刚抢到,无条件删除就是撕了对方的值班牌——判据和 D10 的续约一样,比较持有者和改动必须在同一步完成。

shutdown.js
async function shutdown() {
  draining = true // 1. 拒新:消费循环下一轮就不再读流
  stopHeartbeat()
 
  // 2. 等手头的跑完,但最多等 20 秒。消费循环里的 await 链一路通到执行函数,
  // 所以「等这个循环结束」就等于「等手头的跑完」。
  const timeout = new Promise((r) => setTimeout(() => r('timeout'), GRACE_MS).unref())
  if ((await Promise.race([loop, timeout])) === 'timeout') {
    log.warn({ inFlight: inFlight.size }, '等满宽限期仍未跑完,放弃等待')
  }
 
  // 3. 主动交还租约:条件释放,只删还写着我名字的那把牌子
  for (const shard of held) await lease.releaseIfOwner(shard, workerId)
  await presence.forget(workerId)
}
 
// SIGTERM 连按两下不该启动两次排空,所以记住这一次停机的 promise
let stopping
process.on('SIGTERM', () => {
  stopping ??= shutdown().then(() => process.exit(0))
})

四份代码的差别很说明语言性格:Java 的 ExecutorService 本来就是「拒新 + 等完 + 强制中断」三段式;Python 的 asyncio.wait_for 把「等它但有上限」做成了一个函数;Swift 靠结构化并发的取消传播;只有 Node 要自己拼一个超时竞速。三步的语义完全一样,语言只决定你写几行。

还有两件配套的事,漏一件前面全白做。

一是宽限期的配置必须大于代码里的等待上限。 代码等 20 秒而 compose 默认只等 10 秒,第 10 秒就是 SIGKILL,三步永远只走到一半。compose 里是 stop_grace_period,Kubernetes 里是 terminationGracePeriodSeconds,都要留余量(本课 25 秒)。写完停机逻辑,第一件事是去看那个配置。

二是信号得真的传到你的进程。 D7 讲过:启动命令写成包管理器,PID 1 就是包管理器而不是你的进程,SIGTERM 未必传得到,停机代码一次都不会执行。用 exec 形式直接起业务进程。

把这两件事和三步合起来,就是滚动发布不掐断任务的完整答案:编排系统先摘流量、再发 SIGTERM、等宽限期;进程在这段时间里把手头的活做完、交还所有权、干净退出。 面试里问「滚动发布怎么不打断正在处理的任务」,这句话就是答案的骨架。

同一套代码,两个世界:dev 与 prod 的隔离

便利店的员工培训用一台单独的收银机,跑同一套软件,但绝不能连到真实的库存和账目上——培训时的每一笔操作都不能变成真交易。

本机开发就是那台培训机。同一套代码、经常还是同一个 Redis,最容易出的事故是:你在本机起一个 Worker 调试,它连的却是线上那条流,把真实用户的消息捞走执行了。这类事故没有任何报错,两边日志都显示「一切正常」——从代码角度看它确实老老实实处理了一条消息。

隔离按层做,成本从低到高:命名空间(键名带前缀)、独立实例(各自的 Redis 与库)、独立环境(网络、凭证、账号全分开)。生产最终要走到第三层,但本课重点是第一层,因为它成本最低也最容易漏。

本课口径:所有键名带 dev:prod: 前缀,取自 APP_ENV D9 那条输入流因此变成 dev:koda:runsprod:koda:runs,租约键与心跳 hash 同理。关键是前缀只能在一个函数里拼——散落到各处,二十个键名漏掉一个就等于没隔离,而漏掉的那个通常是最新加、最没被测过的功能。

env.js
export function appEnv() {
  const raw = process.env.APP_ENV ?? 'dev'
  // 只认两个值:拼成 development 时立刻报错,而不是静默变成第三套环境
  if (raw !== 'dev' && raw !== 'prod') throw new Error(`APP_ENV 只能是 dev 或 prod,收到 ${raw}`)
  return raw
}
 
// 所有键名都必须过这一层,前缀只在这里拼一次
export const namespaced = (name) => `${appEnv()}:${name}`
export const runsStream = () => namespaced('koda:runs')
 
// 生产环境禁止跑内存实现:少了这一句,一次配置疏漏(忘了注入 REDIS_URL)
// 会让线上进程安静地起来、各自在自己的内存里干活,而且所有健康检查都是绿的
export function assertProdSafety(mode) {
  if (appEnv() === 'prod' && mode !== 'real') throw new Error('生产环境不允许跑内存实现')
}

前缀之外还有三件必须一起做的事。凭证分开:本机那把 key 只能连开发库,配置写错也波及不到线上。破坏性操作要认环境:清库、重放死信、重算索引这类脚本,第一行先读 APP_ENV,生产上要求显式确认。生产禁止降级实现:那套内存实现开发时很好用,可生产上一旦因配置疏漏走到它,进程会安静起来、各自在自己内存里干活,健康检查还全是绿的——这类故障能藏好几个小时。启动时直接报错退出,比事后排查便宜得多。

W2 复盘:一个进程被拆开,又长成一个系统

七天回头看,它不是七个中间件教程,而是一个单进程服务被拆开、再重新长成一个分布式系统的过程,每一天都被前一天逼出来:

  • D8 把会话 Map 换成三张表,并把服务劈成接入层与执行层。idempotency_key 的唯一约束才是幂等的最终裁判——先查后插不算幂等。
  • D9 把中间那个空方框填成 Redis Streams。至少一次是默认语义,恰好一次是消费端幂等做出来的效果;真相之源永远是 runs 表,流只是触发器。
  • D10 消费组的分配单位是「一条消息」,业务要求的串行单位是「一个用户」,于是有了分片加租约。续约必须原子,这就是 Lua 的全部理由。
  • D11 把输出接回用户:状态机挡住非法转换,片段带序号回传,重连靠「最后收到的号加一」。输出流用广播读法,输入流用消费组。
  • D12 装长期记忆,开篇就和 D6 划界:一个解决「这一轮塞不下」,一个解决「上个月说过的事想不起来」。花钱的不是 embedding,是检索结果占掉的上下文。
  • D13 从被动变主动:定时任务只是把按按钮的人从用户换成了钟表,幂等键锚在「计划触发的那一分钟」。
  • D14 今天:多副本、心跳让假死可见、探针分清「能不能接流量」与「集群什么状态」、优雅停机让发版不掐断任务、环境前缀让本机碰不到线上。

如果只带走三句话,我建议这三句。

第一,分布式化的每一步,都是先拆掉一个「单进程里免费」的保证,再显式地把它买回来。 顺序、状态、身份、恰好一次、甚至「这个进程还活着吗」,在一个进程里全是白送的;跨进程之后每一个都要花代码去买,而且都有明码标价的代价。

第二,每个新机制都要配一个「它失效时会怎样」的答案。 租约会脑裂、心跳会误判、停机会超时、幂等键会选错锚点。说不出失效模式,等于没验证过——面试里这也是区分「读过」和「上过线」最快的一刀。

第三,运维的所有问题都能归到一句:谁在什么时候可以被替换掉。 无状态的随时可杀(D8),有状态的必须有交接协议(今天的优雅停机),而判断「该不该换」需要一个独立于进程自身的观测面(心跳)。某生产级 IM Agent 平台踩实的正是这几层,功能才有地方长。

到今天为止,一个 Agent 的生产形态已经完整了。但真实需求里一个 Agent 干不完所有事:客服要先分诊,要查库存,要拟一份退款方案,方案还得有人复核。把这些职责全塞进一个提示词,它会一样都做不好。明天开始的第三周,正题就是让多个 Agent 分工协作。

源码导读

动手实验

🧪 D14 实验:--scale worker=3 全链路 + 心跳面板

Code location: labs/agent-30days/day-14-compose-multi-worker

验收标准:

  1. MOCK=1 SELFTEST=1 pnpm start 五项自检全部 ✅、退出码 0(starter/ 原样跑只有第 1 项 ✅,四个练习点各对应一项 ❌)。
  2. 第 1 项:3 个副本从同一条流上分摊 9 条消息,每个副本都处理到了,合计 9 条、去重后还是 9 条。
  3. 第 2、3 项:让一个副本停止打卡(模拟假死),超过 15 秒(3 个上报周期)后面板显示它失联、状态降级成 degraded,而就绪探针仍然返回 200。
  4. 第 4 项:给一个副本发 SIGTERM,它手头那次执行仍然跑到 done(不是停在 running),等待时间小于 20 秒宽限;停机后到达的新任务它一条都不碰;租约被主动交还,接手方在远小于一个 TTL 的时间里上岗。
  5. 第 5 项:dev 与 prod 算出的流名不同,往 prod 流投一条消息,dev 侧的探针读到 0 条。

MOCK=1 下零外部服务:src/infra/ 那套内存实现不是打桩,租约的 TTL 到期语义和消费组的 pending 语义都真写了出来,离线也能看到失联判定与租约交接。自检把时钟等比压缩 20 倍(心跳 250 毫秒、失联 750 毫秒、宽限 1 秒),比例与生产值一致——否则要等一分多钟才看到一次失联。装了 Docker 就在 lab 根目录 docker compose up -d --build。卡住了先看 README 的「常见坑」。

  1. 先原样跑一次 MOCK=1 SELFTEST=1 pnpm start,那四条 ❌ 的文案就是待办清单。
  2. 练习 1,心跳判活:让停止打卡的副本在 3 个周期后被判失联,第 2 项从「失联 0 个」变成 1 个。
  3. 练习 2,面板汇总:算出存活与失联数,有一个失联就降级成 degraded,同时确认就绪探针仍是 200——这一条是判据,不是顺带。
  4. 练习 3,优雅停机三步:拒新、等手头的跑完(上限 20 秒)、条件交还租约并注销心跳,第 4 项那个 run 的收尾会从 running 变成 done。
  5. 练习 4,环境前缀。然后投一个 6 秒的慢任务并立刻 docker compose stop worker,亲眼看它等任务跑完才退出。

面试题

今天 5 道题在下方题库区,前 4 道覆盖心跳与健康检查、优雅停机、滚动发布、环境隔离,最后一道是系统设计大题:设计一个 IM Agent 平台。那道大题的分析过程给的是一套完整作答框架,照着它把这一周讲一遍,比刷十道小题管用。展开后先看「分析过程」再看要点。

检查清单与明日预告

  • 能用 docker compose 把 worker 扩到多个实例并观察流量分摊
  • 能实现 worker 的心跳上报和 gateway 的健康检查端点
  • 能实现优雅停机:收到停止信号后处理完手头任务再退出
  • 能说清就绪探针、存活探针、心跳面板各回答什么,混成一个会出什么事故
  • 能说出优雅停机三步,并解释宽限期为什么必须大于代码里的等待上限
  • 能不看讲义把 D8 到 D14 逐天串一句话,说清每天是被前一天什么问题逼出来的
  • 实验的 5 条验收标准全部通过(五项自检全 ✅)
  • 5 道面试题至少答出 4 道,系统设计那道能讲满 20 分钟

明天(D15)进入第三周,把「一个 Agent」变成「一组 Agent」:Router 与 Supervisor、Planner-Executor、Critic、Swarm、Blackboard 分别长什么样,什么时候多 Agent 是在解决问题、什么时候是在制造问题,然后用 LangGraph.js 搭出第一个三节点的图。为什么放在部署之后?因为多 Agent 会把今天这套东西整体乘上一个系数——更多步骤、更多状态、更多钱。先能把一个 Agent 稳稳跑在生产上,再谈让它们分工;顺序反了,你会一边调编排一边调基础设施,两头都看不清。

Interview questions

  • With multiple replicas, how do you design heartbeats and health checks? Are they the same thing?多实例部署下,怎么设计心跳和健康检查?两者是同一件事吗?
    Common in ChinaCommon overseasIntermediate#observability#deployment#distributed-systems

    How to reason about it · think before answering

    1. The hinge is are they the same thing. Answering both check liveness loses the point — the interviewer wants to see you split one word into three distinct questions, because conflating them causes real outages.
    2. Separate them: a liveness probe answers should this process be restarted, a readiness probe answers can you send me traffic now, and a heartbeat dashboard answers what is the cluster's state. The audiences differ: the first two are for the orchestrator, the third is for a human.
    3. Then say why heartbeats are not optional: the orchestrator only sees process liveness, but a worker can be alive while doing no work at all — a blocked event loop, an exhausted connection pool timing out every read, a noisy neighbour saturating host CPU. This kind of zombie is exactly what the orchestrator cannot see, and only an application-level heartbeat catches it.
    4. Get the direction right too: replicas push their own heartbeat rather than the gateway polling each one. Containers change IP and hostname constantly, so a poller needs a roster that is always changing — and maintaining that roster is what heartbeats are for, so the logic is circular. Report at least three things: a timestamp for liveness, in-flight count to distinguish idle from overloaded, and a version so you can watch old and new replicas during a rollout.
    5. The sharpest point is isolation: do not query downstream dependencies inside a readiness probe. One worker going quiet would turn every gateway's readiness red, and the orchestrator would pull the entire ingress layer — turning a non-critical fault into a full outage. In reality that worker's absence does not stop intake at all: messages sit in the stream, unacked ones get claimed by someone else, and its lease changes hands when the TTL expires.
    6. Expect: so how does the gateway decide whether a worker is usable? Answer that it does not, and does not need to — the gateway never assigns work to a specific worker; the consumer group and the lease decide that. Heartbeat data is for observability and alerting, not routing. Getting here shows you actually understand the layering.

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

    1. 题眼是「两者是同一件事吗」。答「都是探活」直接失分——面试官想看你能不能把一个词拆成三个不同的问题,因为混起来会造成真事故。
    2. 先拆问题:存活探针回答「这进程要不要被重启」,就绪探针回答「现在能不能给我发流量」,心跳面板回答「集群此刻是什么状态」。三者的读者不同:前两个给编排系统,第三个给人。
    3. 再说心跳为什么不可省:编排系统只能看到进程存活,而 Worker 完全可以进程活着而活儿全停——事件循环被死循环占住、连接池耗尽后取消息全超时、宿主机 CPU 被邻居打满。这类假死恰好是编排系统看不见的那种,只有业务自己上报的心跳能发现。
    4. 方向也要答对:心跳是副本自己 push,不是 Gateway 逐个 pull。因为容器随时换 IP 和主机名,去问的一方需要一份永远在变的名单,而那份名单本身就得靠心跳维护,逻辑绕回来了。上报内容至少三样:时间戳判活、在跑任务数区分闲和忙、版本号在滚动发布时看新旧两批各剩几个。
    5. 最关键的一刀是隔离性:**不要把下游依赖查进就绪探针**。一个 Worker 失联导致所有 Gateway 的就绪探针同时转红,编排系统会把整个接入层摘光——一个非核心故障被自己升级成全站不可用。而实际上那个 Worker 失联根本不影响接单:消息还在流里,没确认的会被别人接手,它的租约会因 TTL 到期而易主。
    6. 可以预期的追问:那 Gateway 怎么判断某个 Worker 可不可用?答「它不判断,也不需要判断」——Gateway 从不指定某个 Worker 干活,派活由消费组和租约决定,心跳的用途是观测和告警,不是路由。答到这里就说明你真的想清楚了分层。

    Key points

    • Split one word into three questions: liveness (restart me?), readiness (send me traffic?), heartbeat dashboard (what is the cluster doing?) — first two for the orchestrator, third for humans
    • The orchestrator sees process liveness but not zombies (blocked loop, exhausted pool, stolen CPU), so an application-level heartbeat is mandatory
    • Heartbeats must be pushed by replicas, not polled by the gateway: containers change IP constantly and polling needs a roster that heartbeats themselves maintain
    • Report timestamp, in-flight count and version — for liveness, load, and rollout progress respectively
    • Never query downstream dependencies in a readiness probe, or one quiet worker pulls the whole ingress layer and escalates a minor fault into an outage
    • The gateway does not judge worker availability — the consumer group and lease assign work; heartbeats are for observability, not routing

    答题要点

    • 一个词要拆成三个问题:存活探针(要不要重启)、就绪探针(能不能发流量)、心跳面板(集群什么状态),前两个给编排系统、第三个给人
    • 编排系统只看得见进程存活,看不见假死(事件循环卡住、连接池耗尽、CPU 被抢),所以业务层心跳不可省
    • 心跳必须是副本 push 而不是 Gateway pull:容器随时换 IP,pull 需要一份靠心跳才能维护的名单,逻辑绕回来了
    • 上报时间戳、在跑任务数、版本号三样,分别用于判活、区分忙闲、观察滚动发布进度
    • 不要把下游依赖查进就绪探针,否则一个 Worker 失联会让整个接入层被摘掉,把非核心故障升级成全站不可用
    • Gateway 不判断 Worker 可用性——派活由消费组和租约决定,心跳只用于观测告警,不用于路由
  • What is graceful shutdown, and why is killing a process outright risky? Walk through the steps.什么是优雅停机?为什么直接 kill 进程有风险?请说出具体步骤。
    Common in ChinaCommon overseasIntermediate#deployment#reliability#operations

    How to reason about it · think before answering

    1. This question tests whether you have actually shipped a release. Reciting finish in-flight work before exiting is just the definition; the interviewer wants the cost, the steps, and the ordering.
    2. Make the cost concrete. Deploys, scale-downs, host maintenance and spot reclamation all send SIGTERM, wait a grace period, then SIGKILL. SIGKILL cannot be trapped, and landing it on a worker mid-agent-loop means: the run is stuck in running forever while the user watches a spinner; you already paid for the model call but never persisted the reply; the unacked message waits for the idle threshold before anyone claims it. One deploy cuts off dozens of conversations — that is the everyday cost.
    3. Then give three steps and stress that the order is fixed. One, stop accepting work: flip a flag so the consume loop stops reading from the stream (messages already fetched but not started stay in pending for someone else, which is faster than forcing a whole batch through). Two, wait for the in-flight execution, but with a ceiling. Three, proactively release leases, deregister from the heartbeat dashboard, and exit.
    4. The ceiling in step two earns points: a hung model call means you wait forever, and the grace period will SIGKILL you anyway. Better to concede and exit — the unacked message is still pending and someone will redo it. This course uses 20 seconds, derived from the upper bound of a normal execution plus margin.
    5. Step three also earns points: leases normally change hands via TTL expiry, but that path exists for sudden death. On a planned shutdown you know you are leaving, so releasing proactively lets the successor take over on its next scan instead of waiting out a full TTL. The release must be conditional — delete only the badge that still bears your name, or you will tear down the badge of whoever just claimed it after your lease expired.
    6. Finish with two companions; miss either and the rest is wasted. The configured grace period must exceed the wait ceiling in code (code waits 20s while compose defaults to 10s, so SIGKILL lands at second 10 and your three steps only half-run). And the signal must actually reach your process (if the entrypoint is a package manager, PID 1 is the package manager, SIGTERM may never arrive, and your shutdown code never runs once).

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

    1. 这题考的是「你有没有真的发过版」。答「等任务跑完再退出」只是定义,面试官要的是代价、步骤和顺序。
    2. 先把代价说具体。发版、缩容、机器维护、抢占式实例回收都会先发 SIGTERM、等宽限期、超时 SIGKILL。SIGKILL 拦不住,落到正在跑 Agent 循环的 Worker 身上:这次的 run 永远停在 running,用户界面一直转圈;模型调用的钱已经付了,回复却没落库;没确认的消息要等空闲阈值到了才被别人接手,用户白等一轮。一次发版掐断几十次对话,这就是日常代价。
    3. 然后给三步,强调顺序不能变:第一步拒新——把开关拨过去,消费循环下一轮不再从流里取消息(已经读到手上还没开始的那几条,留在 pending 里由别人接手,比硬扛完一整批更快);第二步等手头这次执行跑完,但要有上限;第三步主动交还租约、从心跳面板注销,然后退出。
    4. 第二步的上限是加分点:一次卡死的模型调用会让你永远等不到,而宽限期一到照样 SIGKILL。与其被动挨刀,不如自己认输退出——没确认的消息还在 pending 里,别人会接手重做。本课取 20 秒,取法是「一次正常执行的耗时上限」再留余量。
    5. 第三步也是加分点:租约本来靠 TTL 到期自然易主,但那是为进程猝死准备的。计划内下线你明知道自己要走,主动交还能让接手方下一轮扫描就上岗,而不是白等一个 TTL。交还必须带条件——只删还写着自己名字的那把牌子,否则租约已过期、别人刚抢到时,你就把对方的值班牌撕了。
    6. 最后两件配套的事,漏一件前面全白做:宽限期的配置必须大于代码里的等待上限(代码等 20 秒而 compose 默认只等 10 秒,第 10 秒就 SIGKILL,三步只走到一半);以及信号得真的传到你的进程(启动命令写成包管理器,PID 1 就是包管理器,SIGTERM 未必传得到,停机代码一次都不会执行)。

    Key points

    • Concrete cost of a hard kill: the run is stuck in running, the user stares at a spinner, the model call is paid for but the reply is unsaved, and the unacked message waits out the idle threshold
    • Three steps in a fixed order: refuse new work, wait for in-flight work with a ceiling, then release leases and deregister before exiting
    • The wait needs a ceiling (20s here): a hung model call never returns and the grace period kills you anyway, so concede — the message is still pending for someone else
    • Releasing leases proactively lets the successor start on its next scan instead of waiting a full TTL; the release must be conditional on still owning it
    • The configured grace period must exceed the in-code wait ceiling, or the three steps only half-run (stop_grace_period / terminationGracePeriodSeconds)
    • Make sure the signal reaches your process: exec the business process directly rather than letting a package manager be PID 1

    答题要点

    • 直接 kill 的具体代价:run 永远停在 running、用户界面一直转圈、模型的钱已付但回复没落库、没确认的消息要等空闲阈值才被接手
    • 三步且顺序不能变:拒绝新任务 → 等手头的跑完(有上限)→ 主动交还租约并注销心跳,然后退出
    • 等待必须有上限(本课 20 秒):卡死的模型调用会让你永远等不到,宽限期一到照样被 SIGKILL,不如自己认输,消息还在 pending 里
    • 主动交还租约让接手方下一轮就上岗,而不是白等一个 TTL;交还必须条件化,只删还写着自己名字的那把
    • 宽限期配置必须大于代码里的等待上限,否则三步只执行到一半(compose 的 stop_grace_period / K8s 的 terminationGracePeriodSeconds)
    • 信号要真传到进程:用 exec 形式直接起业务进程,别让包管理器当 PID 1
  • During a rolling deploy, how do you keep in-flight tasks from being interrupted?滚动发布时,如何避免正在处理的任务被打断?
    Common in ChinaCommon overseasIntermediate#deployment#reliability#operations

    How to reason about it · think before answering

    1. This is the applied version of the previous question, and the difference is that it demands the orchestrator's side too — describing only the in-process steps answers half of it.
    2. The full skeleton is both sides cooperating: the orchestrator first removes traffic (turns readiness red so the load balancer stops sending new requests), then sends SIGTERM, then waits out the grace period; the process uses that window to finish in-flight work, hand back ownership, and exit cleanly. That sentence is the trunk; everything else is detail.
    3. Then distinguish the two kinds of replica, which is where the points are. A gateway has inbound connections, so draining traffic means something for it. A worker has no inbound connections at all — it pulls work from the bus, so draining for it means stop fetching new messages, which is step one of graceful shutdown. The same word is two different mechanisms on the two replica types, and saying so shows you understand pull versus push.
    4. Next, batching and ordering: replace only a subset at a time (manual batches in compose, maxUnavailable / maxSurge in Kubernetes) so enough replicas are always alive to absorb traffic. This is where the version field in the heartbeat payload pays off — you can see how many old and new replicas remain instead of deploying blind.
    5. Also mention state compatibility: during a rolling deploy old and new code run simultaneously, so schema migrations must be backward compatible (add a nullable column, dual-write, drop the old column last) and message formats cannot change in one shot. Many candidates miss this layer — however gracefully processes stop, two versions that cannot read the same data will still cause an incident.
    6. Expect: what if a single execution legitimately takes five minutes and the grace period cannot wait that long? The answer is not to stretch the grace period to five minutes but to make the task interruptible and resumable — break long work into steps that checkpoint progress (the run state machine from D11 plus at-least-once with idempotency from D9 give you exactly this), so the next replica continues the interrupted step.

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

    1. 这题是上一题的应用题,区别在于它要求你把编排系统那一侧也讲进来——只讲进程内的三步只答了一半。
    2. 完整骨架是两侧配合:编排系统先摘流量(把就绪探针转红,让负载均衡不再把新请求打过来)、再发 SIGTERM、然后等宽限期;进程在这段时间里把手头的活做完、交还所有权、干净退出。这一句话就是答案的主干,剩下都是细节。
    3. 然后区分两类副本,这是拿分点。Gateway 有入站连接,摘流量对它有意义;Worker 没有任何入站连接,它是自己去总线取活的,所谓「摘流量」对它就是「自己不再取新消息」——也就是停机三步的第一步。**同一个词在两类副本上是两种机制**,能说清这一点说明你理解拉与推的差别。
    4. 接着讲批次与顺序:一次只换一部分副本(compose 里手动分批,K8s 里靠 maxUnavailable / maxSurge),保证任何时刻都有足够的存活副本接得住流量。心跳面板上的版本号字段这时派上用场——你能看到新旧两批各剩几个,而不是盲发。
    5. 还要提一句状态兼容:滚动发布期间新旧代码同时在线,所以数据库迁移必须向后兼容(先加可空列、再双写、最后才删旧列),消息格式也不能一次性改。这是很多人漏掉的一层——进程停得再优雅,新旧版本读不了同一份数据照样出事故。
    6. 可以预期的追问:如果一次执行本来就要跑 5 分钟,宽限期不可能等那么久怎么办?答案不是把宽限期拉到 5 分钟,而是让任务可中断可重入——把长任务切成可保存进度的小步(D11 的 run 状态机和 D9 的 at-least-once 加幂等正好提供了这个基础),被打断的那一步由下一个副本接着做。

    Key points

    • The full skeleton is both sides: orchestrator drains traffic, sends SIGTERM, waits the grace period; the process finishes in-flight work, hands back ownership, exits cleanly
    • Draining means two different things for gateways and workers: readiness turning red versus the worker itself stopping its fetch from the bus
    • Replace in batches (maxUnavailable / maxSurge or manual) so enough replicas stay alive; the version field in heartbeats shows how many old and new remain
    • Old and new code run concurrently, so migrations must be backward compatible (nullable column, dual-write, drop last) and message formats cannot change in one step
    • Long tasks are not solved by a longer grace period but by being interruptible and resumable — checkpointed steps that the next replica can continue

    答题要点

    • 完整骨架是两侧配合:编排系统先摘流量、再发 SIGTERM、等宽限期;进程在这段时间做完手头的活、交还所有权、干净退出
    • Gateway 和 Worker 的「摘流量」是两种机制:前者靠就绪探针转红让负载均衡停止转发,后者靠自己不再从总线取新消息
    • 分批替换(maxUnavailable / maxSurge 或手动分批),保证任何时刻有足够存活副本;心跳里的版本号让你看到新旧两批各剩几个
    • 新旧代码同时在线,所以数据库迁移必须向后兼容(加可空列 → 双写 → 最后删旧列),消息格式不能一次性改
    • 长任务不该靠拉长宽限期解决,而要做成可中断可重入:切成能保存进度的小步,被打断的那步由下一个副本接着做
  • How do you isolate dev from prod so local development cannot touch production data?怎么设计 dev 与 prod 的隔离,防止本地开发影响线上数据?
    Common in ChinaCommon overseasBasic#operations#security#configuration

    How to reason about it · think before answering

    1. This looks basic, but it screens for whether you have been burned. People who have start with the failure shape; people who have not start with use different config files.
    2. Describe the failure: same codebase, often the same Redis, and you start a worker locally to debug — except it is connected to the production stream and it claims and executes a real user's message. There is no error anywhere and both sides log business as usual, because from the code's point of view it did dutifully process one message. Precisely because nothing errors, this can run for a long time before anyone notices.
    3. Then give layered options by cost: namespacing (shared infrastructure, prefixed keys), separate instances (its own Redis and database), and separate environments (network, credentials, accounts all split). Production eventually wants the third layer, but the first is the cheapest and the easiest to get wrong, so that is where the focus belongs.
    4. The implementation detail in layer one is where the points are: the prefix may only be assembled in one function. Scatter string concatenation around the codebase, miss one key out of twenty, and you have no isolation at all — and the one you missed is usually the newest, least tested feature. This point signals real experience more than add a prefix does.
    5. Add three companions. Split credentials, so the local key can only reach the dev database and a misconfiguration cannot reach production. Make destructive operations environment-aware: scripts that truncate tables, replay dead letters or rebuild indexes read the environment variable on their first line and demand explicit confirmation in production. And forbid fallback implementations in production: if a config slip makes production take the in-memory path, processes come up quietly, each working in its own memory, with every health check green — that kind of fault hides for hours, so failing fast at startup is far cheaper than diagnosing it later.
    6. Expect: why not just use separate instances and skip prefixes? Because separate instances solve connected to the wrong address while prefixes solve connected to the right address but the wrong namespace — the two fail differently. Prefixes are also nearly free, and they incidentally isolate each developer's data in a shared test environment. Defence should be layered, and there is no reason to skip the cheapest layer.

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

    1. 这题看着基础,但它筛的是「有没有踩过」。踩过的人第一句会说事故形态,没踩过的人第一句说「用不同的配置文件」。
    2. 先说事故形态:同一套代码、经常还是同一个 Redis,你在本机起一个 Worker 调试,它连的却是线上那条流,把真实用户的消息捞走执行了。**这类事故没有任何报错,两边日志都显示一切正常**——从代码角度看它确实老老实实处理了一条消息。正因为没有报错,它可能持续很久才被发现。
    3. 然后按成本分层给方案:命名空间(同一套基础设施,键名带前缀)、独立实例(各自的 Redis 与数据库)、独立环境(网络、凭证、账号全分开)。生产系统最终要走到第三层,但第一层成本最低也最容易漏,所以是重点。
    4. 第一层的关键实现细节是拿分点:前缀只能在一个函数里拼。散落到各处去拼字符串,二十个键名里漏掉一个就等于没隔离,而漏掉的那个通常是最新加、最没被测过的功能。这一点比「要加前缀」本身更能体现工程经验。
    5. 再补三件必须一起做的事:凭证分开(本机那把 key 只能连开发库,配置写错也波及不到线上);破坏性操作要认环境(清库、重放死信、重算索引这类脚本第一行先读环境变量,生产上要求显式确认);生产禁止降级实现(离线用的内存实现在生产上一旦因配置疏漏被走到,进程会安静起来、各自在自己内存里干活,健康检查还全是绿的,这类故障能藏好几个小时——启动时直接报错退出比事后排查便宜得多)。
    6. 可以预期的追问:为什么不干脆只用独立实例,省掉前缀这一层?答:独立实例解决的是「连错了地址」,前缀解决的是「连对了地址但走错了命名空间」——两者失效的方式不同。而且前缀几乎零成本,在共享测试环境、多人并行开发时还能顺带隔离每个人的数据。防御要分层,最便宜那层没理由不做。

    Key points

    • Lead with the failure shape: a local worker attached to the production stream claims and runs a real user's message, with normal logs on both sides and no error, so it hides for a long time
    • Three layers by cost: namespacing (key prefixes), separate instances (own Redis and DB), separate environments (network, credentials, accounts)
    • The prefix must be assembled in exactly one function — scattered concatenation misses one key and voids the isolation, usually the newest and least tested feature
    • Split credentials so the local key only reaches dev; destructive scripts read the environment first and require explicit confirmation in production
    • Forbid the in-memory fallback in production: on a config slip processes come up quietly with green health checks and the fault hides for hours — fail fast at startup instead
    • Separate instances prevent wrong address, prefixes prevent right address wrong namespace — different failure modes, and the cheapest layer is free

    答题要点

    • 先说事故形态:本机 Worker 连上线上流,把真实用户消息捞走执行,且两边日志都显示正常、没有任何报错,所以能藏很久
    • 按成本分三层:命名空间(键名前缀)、独立实例(各自 Redis 与库)、独立环境(网络凭证账号全分开)
    • 前缀只能在一个函数里拼——散落各处漏掉一个键就等于没隔离,而漏掉的通常是最新加、最没测过的功能
    • 凭证分开,本机 key 只能连开发库;破坏性脚本第一行读环境变量并在生产要求显式确认
    • 生产禁止降级到内存实现:配置疏漏时进程会安静起来、健康检查全绿,故障能藏几小时,应在启动时直接报错退出
    • 独立实例防「连错地址」、前缀防「地址对了但命名空间错了」,失效方式不同,最便宜那层没理由不做
  • System design: design an IM agent platform where users chat with an AI assistant inside a messaging app. The assistant calls tools, remembers long-term preferences, and proactively pushes scheduled messages. Target 100k daily active users.系统设计:请设计一个 IM Agent 平台——用户在即时通讯软件里和一个 AI 助手对话,助手能调用工具、记住长期偏好、还能定时主动推送。要求支撑十万日活。
    Common in ChinaCommon overseasDeep dive#system-design#distributed-systems#cost#operations

    How to reason about it · think before answering

    1. Do not start drawing. The most common way to fail a design question is to hear the prompt and immediately sketch boxes, only for the interviewer to realise twenty minutes later that you solved a different problem. Spend three to five minutes on four questions: traffic shape (how many concurrent sessions does 100k DAU imply, and what is the peak-to-trough ratio), latency (how fast must first byte be, is streaming required), the nature of the tools (read-only lookups, or writes with side effects), and the compliance boundary on proactive pushes (may you push at night, what is the daily cap). All four change the architecture materially, so asking them is itself worth points.
    2. Then state the trunk in one sentence: stateless ingress, a message bus for decoupling, stateful workers sharded by user, all state in the database. Walk the data flow: the messaging platform's webhook hits ingress, which does only auth, rate limiting, persistence and publish, and returns 202 immediately; the execution side pulls work, runs the agent loop, and streams output fragments back; proactive pushes come from a central scheduler publishing onto the same bus. The load-bearing argument is that ingress latency is bounded while execution latency is not, so putting them in one process means one slow model call occupies a connection that should have returned in milliseconds — say this out loud, it is the premise of the whole answer.
    3. Then justify each module. Storage: sessions, runs and messages, with runs existing separately because only it can answer whether this attempt actually finished; idempotency comes from a unique constraint on runs, not from check-then-insert. Bus: Redis Streams consumer groups for fan-out, at-least-once semantics, with exactly-once manufactured by consumer-side idempotency, and messages that fail three times moved to a dead-letter stream. Ordering: the consumer group's unit of assignment is one message while the business requires serialisation per user, so hash userId into a fixed set of shards and let exactly one worker hold each shard's lease. Memory: embeddings in pgvector, retrieval wrapped as a tool the model chooses to call, with no identity parameter — identity only ever comes from the session.
    4. Treat proactive push as its own section, because it is what separates this from an ordinary chat service. The central scheduler publishes one message on a time match and the execution side is unchanged; the idempotency key is anchored to the scheduled minute, so replaying after a scheduler restart cannot double-send. For compliance you need timezone, quiet hours and a daily cap — and all three must be evaluated before publishing rather than at send time, or you have already paid for the model call before discovering you should not have pushed.
    5. Then volunteer capacity and cost numbers, which is what separates senior candidates. 100k DAU at ten turns each is a million model calls; at roughly a thousand tokens in and out, with input at $0.15 and output at $0.60 per million tokens, that is about $750 a day. That number immediately implies three requirements: meter token usage per call and convert to dollars (otherwise you cannot tell which user or feature is burning money), build tiered degradation (push over-budget users to a cheaper model rather than refusing them), and recognise that context length is the dominant cost lever (so compress history and cap retrieved items).
    6. Land on operability, which is this week's payoff: multiple replicas, heartbeats to surface zombies, readiness probes that only check their own hard dependencies, graceful shutdown so deploys do not cut conversations, and dev/prod isolation via key prefixes. Pair every mechanism with what happens when it fails — leases can split-brain so you need a self-fencing rule and fencing tokens, heartbeats produce false positives so a red dashboard alerts a human rather than auto-draining, shutdown can time out so the wait needs a ceiling. A mechanism without a stated failure mode reads as something you only read about.
    7. Expect, in rough order of frequency: where are the single points (the scheduler is stateless and restartable; Redis and Postgres rely on managed primary/replica); how do you roll out safely (old and new workers coexist and a version field in the message selects the prompt set); what if the user sends another message mid-reply (merge a change of mind within thirty seconds into the same execution rather than running two concurrently); and how would you halve the cost (cache frequent answers, compress history, route simple intents to a smaller model).

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

    1. 先别画图。系统设计题最常见的死法是听完就开始画框,二十分钟后面试官发现你解的是另一道题。花三到五分钟问清四件事:一是流量形状(十万日活对应多少并发会话、峰谷比多少),二是延迟要求(首字节要多快,是否必须流式),三是工具的性质(只读查询还是有写操作和副作用),四是主动推送的合规边界(能不能在深夜推、每天上限几条)。这四个答案会实质改变架构,问它们本身就是分数。
    2. 然后给主干,一句话先定形状:**接入层无状态、消息总线解耦、Worker 有状态且按用户分片、状态全在数据库**。接着按数据流走一遍:IM 平台的 webhook 打到接入层,接入层只做鉴权、限流、落库、投递四件事,立刻返回 202;执行侧从总线取活、跑 Agent 循环、把输出片段回传;主动推送由一个中心调度器按时间投递进同一条总线。**关键论点是接入层耗时确定、执行层耗时不确定,把它们放在一个进程里意味着一次慢的模型调用会占住一个本该毫秒级返回的连接**——这是整道题的立论基础,要主动说出来。
    3. 再逐个模块给出选择和理由。存储:sessions / runs / messages 三张表,runs 单独存在是因为只有它能回答「这次到底跑完没有」,幂等靠 runs 上的唯一约束而不是先查后插。总线:Redis Streams 的消费组做分摊,语义是至少一次,恰好一次靠消费端幂等做出来;反复失败的消息投递三次后进死信流。顺序:消费组的分配单位是一条消息而业务要求的串行单位是一个用户,所以按 userId 哈希到固定数量分片,每个分片同一时刻只有一个 Worker 持有租约。记忆:pgvector 存 embedding,检索包成一个工具交给模型自己决定要不要查,且不给它身份参数——身份只能来自会话。
    4. 主动推送这一块要单独讲透,因为它是这道题区别于普通聊天服务的地方。中心调度器命中时间点后只投一条消息,执行侧照旧;幂等键锚在「计划触发的那一分钟」,所以调度器崩溃重启后回看重放不会重复推送。合规上要有时区、静默时段、每日上限三道闸,而且这三道闸必须在投递前判断而不是在推送时判断——否则你已经花了模型调用的钱才发现不该推。
    5. 然后主动给出容量和成本的数字感,这是高级候选人的分水岭。十万日活、人均十轮对话是一百万次模型调用;按输入输出各一千 token、每百万 token 输入 0.15 美元输出 0.60 美元估算,一天大约七百五十美元。这个数字立刻推出三件事必须做:token 用量要按调用记账并换算成美元(否则你无法定位是哪个用户或哪个功能在烧钱)、要有分层降级(超预算的用户切便宜模型而不是直接拒绝)、以及上下文长度是主要成本杠杆(所以要压缩历史、控制检索条数)。
    6. 最后收在可运维性上,也就是这一周的落点:多副本部署、心跳发现假死、就绪探针只查自己必需的依赖、优雅停机让发版不掐断对话、dev 与 prod 用键名前缀隔离。**每个机制都要配一句「它失效时会怎样」**——租约会脑裂所以要有自杀规则和护栏令牌、心跳会误判所以面板转红只告警不自动摘流量、停机会超时所以等待要有上限。说不出失效模式的机制,面试官会认为你只是读过。
    7. 可以预期的追问,按出现频率排:单点在哪(调度器无状态可重启,Redis 和 Postgres 靠托管服务的主备);怎么灰度(新旧 Worker 同时在线,靠消息里的版本字段决定走哪套提示词);用户在助手回复中途又发一句怎么办(三十秒内的改口合并进同一次执行,而不是并发开两个);成本再降一半怎么做(缓存高频问答、压缩历史、把简单意图路由到小模型)。

    Key points

    • Spend three to five minutes clarifying four things: traffic shape, latency targets, whether tools have side effects, and the compliance boundary on proactive pushes
    • State the trunk in one sentence: stateless ingress, bus for decoupling, stateful workers sharded by user, all state in the database — premised on bounded ingress latency versus unbounded execution latency
    • Storage is sessions/runs/messages with idempotency from a unique constraint on runs; the bus is Redis Streams consumer groups, at-least-once plus consumer idempotency, dead-lettering after three failures
    • Ordering comes from hashing userId into shards plus leases: the consumer group assigns per message while the business serialises per user
    • Memory is pgvector exposed as a tool the model may call, with no identity parameter — identity comes only from the session
    • Proactive push flows through a central scheduler with the idempotency key anchored to the scheduled minute; timezone, quiet hours and daily caps are enforced before publishing
    • Bring numbers: 100k DAU at ten turns is ~1M calls and ~$750/day, which implies metering, tiered degradation, and context length as the main cost lever
    • Land on operability: replicas, heartbeats for zombies, readiness probes scoped to own dependencies, graceful shutdown, dev/prod prefix isolation
    • Pair each mechanism with its failure mode — leases split-brain, heartbeats false-positive, shutdown times out; a mechanism without one reads as book knowledge

    答题要点

    • 先用三到五分钟问清四件事:流量形状、延迟要求、工具是否有副作用、主动推送的合规边界——它们会实质改变架构
    • 主干一句话:接入层无状态、消息总线解耦、Worker 有状态且按用户分片、状态全在数据库;立论是接入层耗时确定而执行层不确定
    • 存储 sessions / runs / messages 三张表,幂等靠 runs 上的唯一约束;总线用 Redis Streams 消费组,至少一次加消费端幂等,三次失败进死信
    • 顺序靠 userId 哈希分片加租约:消费组的分配单位是一条消息,而业务要求的串行单位是一个用户
    • 记忆用 pgvector 并包成工具交给模型自己决定是否检索,不给身份参数——身份只能来自会话
    • 主动推送由中心调度器投递,幂等键锚在计划触发的那一分钟;时区、静默时段、每日上限三道闸必须在投递前判断
    • 给出成本数字感:十万日活人均十轮约一百万次调用、一天约七百五十美元,由此推出计量记账、分层降级、压上下文三件事
    • 收在可运维性:多副本、心跳查假死、就绪探针只查自己的依赖、优雅停机、dev/prod 前缀隔离
    • 每个机制都配一句失效模式:租约会脑裂、心跳会误判、停机会超时——说不出失效模式等于只是读过

Comments

Sign in to join the discussion

No comments yet — be the first.