架构设计
收录于 多租户采集演进
我们差点自己做了一套租户调度系统
从本地化交付往 SaaS 化走时,我们一度非常接近做出一套自己的租户调度控制面。后来停下,是因为这套东西会把团队带到另一条主线上。
我们当时在做的是一条数据采集和入库链路。客户的数据先进来,系统做转换、编排、下游调用,最后再写回平台内部或者别的系统。更关键的背景是,那时候团队正处在一个很典型的阶段:产品还带着很重的本地化交付惯性,但方向已经开始明确往 SaaS 化走。
一旦进入这个阶段,技术设计最先要服务的,通常不是“长期最优架构”,而是“怎么尽快把商业价值跑出来”。你得先把客户接进来,先让流程能稳定交付,先证明这件事可以持续服务,而不是每加一个客户就重做一套。也因为这个前提,当时很多本地化阶段一租户一套的逻辑,并不会立刻消失,它只会换一种更适合云上环境的形态继续活下来。
所以我们最早那版方案,其实很顺:一个租户一个 Pod,用 Kubernetes 做租户级隔离。这个做法不高级,但它非常符合那个阶段的目标。租户边界清楚,排障动作直接,客户出问题时可以单独处理,而且它是当时最快能把业务往 SaaS 方向推一把的路径之一。
麻烦在于,业务继续往前走以后,这套方式开始越来越贵。
为什么一租户一 Pod 在当时是顺手的
如果把当时的约束摊开看,一租户一 Pod 几乎是顺着业务阶段长出来的。
一方面,本地化交付留下来的习惯还很重。之前很多系统本来就是一个客户一套环境,一套部署,一套排障路径。改成 SaaS 以后,你不会第一天就把这些历史包袱全抹平,更现实的做法通常是先把它收进同一套云上底座里,再慢慢往共享化推进。
另一方面,那个阶段最值钱的是验证速度。你要尽快把客户接进来,尽快让流程能交付,尽快验证这个模式到底有没有持续服务的价值。这个时候,用 K8s 把租户直接隔开,做成一租户一 Pod,确实是很自然的一步。它不一定最省资源,不过足够稳,足够直观,也足够快。
而且那时候租户数量本来就不多。租户少的时候,先把成本控制在可接受范围内,同时把隔离性和可观测性留出来,本来就是更优先的事。与其一上来就为了共享化把问题做复杂,不如先保证每个租户出了问题都看得见、拆得开、处理得动。
所以这套方案至少接住了三件很现实的事:
- 隔离清楚。某个租户出问题,先看它自己的实例,不容易把别的租户一起卷进去。
- 排障直接。日志、资源、进程状态都按租户切开,很多问题不用先经过抽象层翻译。
- 上线风险可控。团队当时对多租户采集还没完全摸透,先把链路按租户切开,比一开始就追求高密度共享更容易建立信心。
拐点不是“它太土”,是业务形态变了
后面把这套方式顶到拐点的,是业务形态真的变了。
租户数量开始持续增长,接入和下线越来越频繁;流量分布不再均匀,长尾租户很多,但总会有几个租户在高峰期把单实例直接打满;下游依赖也越来越异构,有的链路很稳,有的链路重试多、耗时长、失败模式很怪。
到了这个阶段,系统维护的重心已经从采集流程本身,转向了一大批租户实例。
问题会一起冒出来:
- 新租户接入还跟着部署动作走,越来越重。
- 很多长尾租户流量不高,却长期各占一个实例,资源利用率很差。
- 节点一旦出问题,租户迁移和接管高度依赖人工。
- 平台缺少一个统一视角,知道“现在到底是谁在承载哪些租户”。
调度系统为什么会冒出来
一旦问题被顶到“租户怎么承载”这一层,脑子里很自然就会长出一套调度系统的轮廓。因为你会开始想,既然租户和实例不该再长期静态绑定,那是不是应该有一层东西专门管这件事。
我们当时脑子里冒出来的,比“调度平台”四个字要具体得多——它是一套完整的运行方式。
如果把当时脑子里的结构压成一张图,大概是这样:
flowchart LR
tenantInput["新租户接入<br/>配置变更"]
opsPolicy["人工 Override<br/>阈值策略"]
k8sEvents["K8s 扩缩容<br/>节点故障"]
subgraph state["中心状态"]
tenantRegistry["租户注册表"]
workerState["Worker 状态表<br/>lease / heartbeat / 负载"]
assignmentTable["Assignment 表<br/>版本化承载结果"]
end
subgraph control["调度控制面"]
scheduler["调度器"]
end
subgraph runtime["Worker 执行面"]
workerPool["Worker Pod 池"]
localExecutor["Worker 本地执行器"]
tenantPipeline["租户消费 / 处理链路"]
end
downstream["下游系统<br/>平台写入"]
tenantInput -->|接入 / 变更| tenantRegistry
opsPolicy -.->|策略 / 人工干预| scheduler
k8sEvents -.->|新 Pod / 故障| workerPool
tenantRegistry -.->|watch 租户| scheduler
workerState -.->|watch worker / 负载| scheduler
workerPool -.->|lease + 心跳| workerState
scheduler -->|生成 assignment vN| assignmentTable
assignmentTable -.->|watch 分配结果| localExecutor
workerPool -->|Pod 内执行| localExecutor
localExecutor -->|拉起 / drain / 接管| tenantPipeline
tenantPipeline -->|采集 / 写入| downstream
这张图里最关键的,不是“多了一个调度器”这件事,而是整条控制链都被拉出来了:接入、状态收敛、分配结果、watch、worker 本地执行和故障接管,最后都会变成一套要长期维护的系统。
先有一批 worker 实际负责消费和处理租户流程。平台手里要有一份租户清单,也要知道每个 worker 现在在带哪些租户、负载大概到什么程度。然后再有一个调度器,周期性地看这些状态,算出最新一轮“哪个租户该落到哪个 worker”。
难点不在“算一份表”本身,而在这份表怎么落到线上。分配结果放在哪里,worker 怎么感知变更,某个 worker 挂掉以后谁来接它手里的租户,租户迁移时旧 worker 什么时候停、新 worker 什么时候开始接,怎样避免两边同时吃或者中间漏一段,这些都会立刻冒出来。
这条路会让人心动,也很好理解:
- 一个 worker 可以承接多个租户,资源利用率终于有机会提上来。
- 新租户接入不一定再跟着新建一轮部署。
- 节点挂掉时,租户可以被别的 worker 接走,不用一直靠人工处理。
- 平台终于可以有一份统一的承载真相,而不是靠人脑和脚本补洞。
站在那个时间点看,这套东西不但讲得通,几乎就是顺着问题长出来的下一步。
如果真往下做,当时准备怎么落
这件事当时并不是一句“做个调度器”就完了。真往下做,最小也得有四块东西,而且每一块都得真的能跑起来。
如果按我们当时脑子里的技术路线走,这套东西的核心机制其实很明确:用一套中心状态存储把租户、worker 和分配结果收拢起来,再靠 watch 机制把变更推给调度器和各个 worker。etcd 之所以会被拿出来讨论,原因很实际:这类问题天然就需要几件东西:租约、版本、watch、原子更新。你要知道谁在线,谁掉了,哪版分配结果是最新的,谁应该立刻收到变更,这些都离不开这几个能力。
第一块,是租户注册表。
平台要先有一份干净的租户清单,里面至少得放清楚每个租户的配置、数据源、资源级别、限流信息和当前期望状态。没有这份表,后面调度器连“现在有哪些租户该被承载”都说不清。更具体一点,这里至少要有一类类似 /tenants/<tenantId> 的元数据记录,告诉系统这个租户是不是启用、优先级高不高、是不是头部租户、当前应该跑在哪种承载等级里。
第二块,是 worker 状态表。
每个 worker 不能只是活着就算数,还得持续上报自己现在在带哪些租户、资源水位大概怎样、最近有没有异常。这样调度器看到的才是一组带着承载状态的执行节点。技术上更像是每个 Pod 启动以后先去注册一个自己的 worker 节点,带着 lease 往 etcd 里写心跳。只要 lease 还在,调度器就知道这个 worker 还活着;lease 断了,调度器就知道它可能已经掉了。
第三块,是分配结果。
调度器要定期把最新结果算出来,然后存成一份大家都认的分配表,比如“租户 A 在 worker-1,租户 B 在 worker-3”。这份表还不能只存结果,最好还要带版本号。因为只要一有扩容、迁移、节点故障,你就得知道哪一版分配才是最新的,不能让旧结果把新结果盖掉。比较直白的做法就是维护一组 assignment key,可能是按 worker 存,也可能是按 tenant 存,但不管怎么存,都得让“最新分配结果”有明确版本,方便 worker 判断自己现在拿到的是不是过期数据。
第四块,是 worker 本地执行器。
worker 不能只负责消费消息,还得负责执行调度动作。它要能看懂“这次自己该接哪些租户、该放掉哪些租户”,然后把本地流程实际拉起来或停下来。否则平台层算得再漂亮,最后也落不到线上。在线上跑时,这一层往往就是 watch assignment 变化:发现自己多了一个租户,就拉配置、建消费者、开始接流;发现少了一个租户,就进入 drain 状态,把手里的活收掉,再把本地处理器停掉。
如果把这套东西再翻成更直白一点的话,它每天要处理的其实就是三类动作,而且这三类动作都围绕“状态变化 -> watch 感知 -> 调度器改分配 -> worker 执行”这条流水线自动往下跑。
第一类,是新租户接入。
新租户进来以后,先写进租户注册表,再由调度器挑一个当前还有余量的 worker。分配结果写下去以后,目标 worker 通过 watch 感知到“自己多了一个租户”,然后拉配置、建消费链路、开始接这个租户,平台再把这次接入标成完成。这样接新租户就不用默认新起一个 Pod,可以先看现有承载面谁还有余量。
还有一种很常见的触发,是新 Pod 上线。
某个 worker Pod 启动以后,会先注册自己的 worker 状态并建立 lease。调度器 watch 到新的 worker 节点出现,就会重新算一轮分配。这样它除了知道多了一台机器,还会进一步判断,哪些原本压在忙节点上的租户现在可以迁过来,能不能顺手做一轮自动负载均衡。
第二类,是租户迁移。
如果某个租户太重,或者某个 worker 已经顶不住了,调度器就要改分配结果。不过改表只是第一步,后面至少还有一段 handoff:旧 worker 先停止继续接新消息,把手里那批正在处理的任务收完;新 worker 等旧 worker 放干净以后再开始正式接。中间还得有一个明确状态告诉平台“现在是在迁移中”,不然两边很容易一边都想吃,或者一边都不敢吃。真正难的不是“改 assignment”这个动作,而是怎么让 drain、切换、接管这几个状态在同一版分配结果里收得住。
第三类,是故障接管。
某个 worker 一段时间不上报心跳,平台就得把它判成失联。调度器接着把它手里的租户重新分出去,其他 worker 再接手。但这里也不能简单粗暴地一失联就立刻重跑,因为你还得防着旧 worker 其实没死,只是网络抖了一下。也就是从这里开始,租约、超时、版本这些东西才会变重要。lease 过期只说明“它可能死了”,不说明“它手里的状态已经绝对安全了”,所以接管动作一定还要配合版本判断和幂等保护。
如果再把它压成一句话,当时准备做的其实就是:平台层维护租户清单和 worker 状态,调度器基于这些状态持续生成“谁该带谁”的结果,再通过 watch 把结果推给 worker,由 worker 执行接入、迁移和接管。
也正是因为这套落地路径已经很具体了,后面评审才会一下子变重。讨论的焦点已经从“要不要更智能一点”变成了“谁来维护 lease 和 assignment,watch 抖动了怎么办,迁移过程怎么收口,故障接管怎么不打架”。
评审一展开,桌上的问题就变了
我们后来没有继续往下做。不是因为某个技术点做不出来,这条路本身也不是假问题。让我开始犹豫的,是评审越往下拆,大家讨论的重心已经在慢慢变。
这里说的“资产”不是一句好听的话,我指的是很具体的东西。把它做出来,后面就得长期维护它、迭代它、替它兜故障,还得继续往上补更多能力。它不会是一个做完就放在那里的工具,更像一条会持续吃人吃时间的主线。
这种犹豫也不是后来才凭空总结出来的,当时其实已经在方案评审里冒头了。最开始把这条路抛出来时,桌上的支持声并不弱。被人工迁移、人工接管折磨得最久的人,会先觉得这事终于像个正解了:既然租户和 worker 的关系已经越来越不适合靠部署动作硬维持,那就该收一层调度器,把租户清单、worker 状态和承载关系统一起来。新租户接入不用再跟着部署走,节点挂了也能自动接管,听起来很顺。
但评审一旦从“方向对不对”往“系统到底怎么落”走,桌上的问题马上就变了。
先有人追负载模型。只看消息量够不够?如果一个租户消息条数不算多,但每条消息后面都挂着慢下游、重试流程和额外写入,那它在线上的重量根本不是消息量能代表的。真要调度,就得继续把耗时、错误率、重试成本、SLA 一层层算进去。不然表面上是在做负载均衡,实际上只是把不均衡从 Pod 分布挪到了调度结果里。
接着就会有人追 handoff。某个 worker 挂了,新 worker 怎么知道该接哪些租户;旧 worker 什么时候算真正放手;切换窗口里如果两边都在吃,重复处理怎么兜;如果两边都不敢吃,中间漏掉的那段谁来补。这种问题一摆出来,讨论已经到了另一个层面:“怎么定义接管语义,怎么让迁移稳定可控”。
再往下,还有人盯着控制面自己问:状态放哪里,谁来维护心跳,分配结果抖动了怎么办,人工 override 要不要留,调度器本身挂了以后谁来兜底。讨论到这里,会议的味道已经完全不一样了。最开始我们像是在评审采集承载怎么演进,后面越来越像在评审另一套长期系统:它有自己的状态模型、故障模式、值班面和运维责任。
心里其实已经有答案了。
我后来看清的是,这条路当然有价值,但它的价值长在另一条线上。
我们本来要解决的是多租户采集和写入治理,结果后面越来越多精力会被分配策略、状态收敛、重平衡窗口、接管流程牵走。再往前补一点现实约束,这套东西还会继续变重。消息条数只是最粗的一层,真到线上,后面跟进来的还有下游耗时、错误率、限流、重试成本、特殊 SLA。把这些都算进去以后,它就不再像一个轻量分配器了,更像一套完整的控制面。
做到最后,它当然可能是一笔很值钱的系统资产。但问题也正在这里:这份资产到底是在强化我们的采集主线,还是在把团队推向另一种产品形态。如果主任务是把多租户采集、消费和写入做稳,那这套资产的演进方向,未必和主任务完全同向。
我就是在这里停下来的。它当然有价值,但这份价值值不值得由我们在这个阶段亲自背下来,是另一回事。
拦住我的,是主线会不会被带偏
到最后,我反复想的已经不是实现细节了。把我拦住的,是评审里最后总会落到桌上的那句话:
如果把这条路做成主线,我们接下来几年到底是在打磨采集系统,还是在打磨一套租户调度平台。
这句话一摆出来,前面那些争论一下子都被收回主线上了。控制面能做,不代表现在就值得做。调度系统也可能是一笔很好的资产,但资产本身也有方向,不能因为它看起来高级、完整、可平台化,就默认它一定该变成当前阶段最重的投入。
最后为什么没继续做下去
一租户一 Pod 不是错,它只是服务了那个阶段最优先的目标:尽快推进 SaaS 化,尽快验证商业价值,尽快把流程跑稳。后面多租户压力上来以后,系统自然会把“租户怎么承载”顶成一个独立问题,所以脑子里会先长出一套调度系统,这也很正常。
让我们停下来的,不是这套控制面做不出来,是评审越往下走,越像在给自己立另一条长期主线。对于一个并不打算把调度能力本身做成主产品的团队来说,这更像是一次实在的取舍:接下来几年,精力到底要花在哪一层。