大数据存算分离:计算节点动态调度实现原理
前阵子帮一个朋友排查集群问题:他们的 Hadoop 集群 CPU 已经飙到 75%,可任务还是在排队,原因很朴素——存算一体的架构里,计算和存储绑在同一批节点上,想扩容就得先把数据副本挪过去,一次扩容光数据均衡就要半小时起步。我后来给他们把架构改成了存算分离,计算节点全部无状态化,再配上一套动态调度,CPU 能按业务高峰自动伸缩,晚高峰过去后节点自动缩掉,账单直接降了三分之一。
这篇内容就想把"存算分离下计算节点动态调度"这件事拆开讲清楚:调度器靠什么感知压力、怎么决定加机器还是减机器、执行扩容缩容时又有哪些容易翻车的细节。适合正在做数据中台底座、在 K8s 上跑 Spark/Flink,或者想给公司大数据平台做弹性伸缩的读者,不管你是平台研发还是运维,应该都能从中找到一些能直接落地的思路。
1. 存算分离架构中,计算节点承担的"最后一公里"
1.1 存储与计算从"绑定"到"解耦"到底解耦了什么
存算分离没有很多人想得那么玄乎。传统 Hadoop 架构里,DataNode 和 NodeManager 是长在同一批机器上的,数据在哪、计算就在哪,这个设计本身没错,但它有一个隐含代价:计算和存储的生命周期被绑死了。你想让计算能力翻倍,存储也得跟着翻倍,哪怕那些磁盘上根本没有热数据;你想缩容,还得小心翼翼地保证每个副本仍然满足机架感知策略,牵一发动全身。
存算分离做的事情,是把"数据住在哪"和"任务跑在哪"彻底拆开。存储层统一走 HDFS、对象存储或者 JuiceFS 这类共享文件系统,计算层变成一批可以随时拉起、随时销毁的无状态执行单元。这时候节点上的 CPU 和内存才是真正的商品,磁盘只是临时落脚点,节点本身不再承载"数据资产"的属性。
可以拿餐饮行业打个比方:存储是中央厨房,菜和原材料都在那里,计算是临时雇佣的厨师团队,哪个分店排队人多,就把厨师调过去,忙完再让他们回家。中央厨房不跟着厨师走,厨师也不背着食材跑。这个类比虽然简单,但基本把存算分离的精髓说清了——资源解耦之后,弹性才成为可能。
1.2 无状态不是真的无状态,计算节点身上还有三样东西
这里必须澄清一个常见误解:很多人以为存算分离之后计算节点就彻底"无状态"了,可以随便杀掉。实际落地时你会发现,节点上仍然有三类临时状态,只是它们的生命周期很短、可恢复性更强而已。
第一类是 shuffle 中间文件。Spark 的 Shuffle 过程会在本地磁盘写大量临时数据,虽然现在越来越多的引擎支持 push shuffle 到远端存储,但在大多数生产环境里,shuffle 依旧优先写本地。第二类是算子运行时缓存,比如频繁使用的字典表、广播变量、PageCache 里的热块,这些数据一旦节点被杀,就需要重新从远端拉取,虽然不算丢失,但会带来明显的启动预热成本。第三类是任务自身的执行上下文,比如正在跑的长事务、正在写外部系统的连接状态,这部分如果硬杀,可能造成数据重复或事务中断。
所以动态调度里提到的"无状态",准确说是"无持久化状态"。调度器在决定杀掉一个节点前,必须给这个节点留出时间把这些临时状态刷出去,否则就谈不上优雅,只能叫强杀。
1.3 为什么动态调度只能在存算分离的土壤里生长
在存算一体架构里做动态调度,不是不行,而是性价比太低。前面说了,扩容一个节点意味着要等数据均衡完成,缩容一个节点意味着要考虑副本数是否仍然满足容灾要求,调度器面对的约束条件太多了:副本位置、机架感知、磁盘水位、数据均衡度……每个约束都会把决策速度拖慢一个数量级。
存算分离之后,计算节点的启停不再受数据位置的制约,调度器的决策空间一下子简单了。它只需要回答三个问题:要不要加节点、加多少、加在哪。三个问题全部围绕计算资源的供需关系展开,不需要理解数据目录结构,不需要触发数据重分布。我自己的体感是,在存算一体架构下,一次扩容操作通常要协调存储、计算、网络三条线的同事;改造为存算分离后,扩容就是一个"改副本数"的动作,平台组自己就能闭环。
这也是为什么社区里几乎所有弹性伸缩方案——不论是 Spark on K8s 的动态 executor、Flink 的 reactive mode,还是 Presto/Trino 按 query 负载伸缩,都默认建立在存算分离的存储底座上。没有这个前提,动态调度就只是一个美好的愿望。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 调度器必须回答的四个问题:从采集到执行的动作闭环
一个完整的动态调度系统,不管代码怎么组织,本质上都在反复回答四个问题:现在发生了什么、要不要动、动多少、怎么动。这四个问题对应的就是四个模块——指标采集、压力评估、容量决策、动作执行。下面逐个拆开讲。
2.1 采集层:指标不是越多越好,关键是"能反映排队与饥饿"
采集层是整个闭环的感知器官。常见的错误是恨不得把所有指标都接进来,CPU、内存、IO、网络、GC、线程数……最后 Prometheus 里指标一大堆,调度器却不知道该看哪个。
我建议把采集指标分成两级:第一级是"压力指标",直接反映资源供需关系;第二级是"健康指标",用于排除假性压力。压力指标里最核心的就三个——CPU 利用率、内存分配率(不是使用率,是分配速率)、任务队列深度。任务队列深度特别重要,它比 CPU 直接反映用户的排队体验:有时候 CPU 才用到 50%,但某个大查询已经把队列堵死了,这时候光看 CPU 永远等不到扩容触发。
健康指标则包括节点网络延迟、本地磁盘 IO 等待、进程 GC 时间等,这些指标用来回答一个很关键的问题:当前的压力是真需要扩容,还是某个节点出了毛病导致的假象。比如一条网络抖动可能让所有 Task 都在等待拉取数据,CPU 看起来不高,但任务卡死,此时扩容不仅没用,反而会把问题放大。我见过一个平台的调度器因为没做健康过滤,网线松动一次就疯狂扩容了 20 个节点,白白烧了一晚上钱。
2.2 评估与决策:阈值、缓冲区间和冷却时间三者怎么配合
评估与决策模块是调度器的"大脑",这里最考验工程经验。最简单的方案是阈值触发:CPU 连续 N 分钟超过 70% 就扩容,低于 30% 就缩容。但这个方案在实际生产中会被打得很惨,原因在于指标总是抖动的,扩容后负载需要一段时间才能下来,缩容后负载也可能很快反弹,处理不好就会出现"扩容-缩容-再扩容"的抖动循环。
我常用的做法是三段式配合:
- 阈值:设置一个扩容阈值(比如 CPU 70%)和一个缩容阈值(比如 CPU 30%),中间留出 40% 的缓冲区间,避免频繁跨线。
- 持续时间:扩容要求指标超过阈值至少持续 5 分钟,缩容要求低于阈值至少持续 15 分钟,用时间窗口过滤瞬时尖峰。
- 冷却时间:每次扩容/缩容动作完成后,进入 10 到 20 分钟的冷却期,冷却期内不再触发同类动作。
另外,决策时不要只算当前负载,还要把"正在排队的任务量"折算成"我们需要多少额外资源"。比如当前有 30 个 Task 在排队,每个 Task 需要 2 核 CPU,那就意味着理想情况下需要再扩容 60 核。按这个预估容量去扩容,比按百分比一点点试探要高效得多。这也是我把队列深度看得比 CPU 更重的原因——队列是供需缺口的直接度量,CPU 只是间接信号。
2.3 执行层:RPC 下发与 Kubernetes 控制器协同的动作细节
决策做完之后,剩下的问题就是怎么把"扩 5 个节点"变成"真的起来了 5 个 Pod"。这里有两种主流的技术路线。
第一种是直接控制资源编排系统。如果你的计算层跑在 K8s 上,调度器就是一个类似 Operator 的控制器,内部 watch 自己的 CRD 资源,计算目标副本数后直接修改 Deployment/StatefulSet 的 replicas 字段。K8s 的 ReplicaSet 控制器会自动帮你把 Pod 拉起来或者杀掉,整个链路非常顺。
第二种是走资源管理框架的 API。比如计算层跑在 YARN 上,调度器通过 YARN REST API 提交新 Application 或调整队列容量;跑在 Spark Standalone 上,则调用 Master 的请求接口动态注册/注销 Worker。这种路线的好处是能拿到更细粒度的队列信息,但坏处是 YARN 本身对容器销毁的处理比较重,扩缩容延迟偏大。
无论哪条路线,有一个细节必须注意:执行动作的下发要做成异步幂等的。所谓幂等,就是调度器说"我要 10 个节点"和"我要 10 个节点"重复调用 100 次,最终状态都是 10 个节点,不会因为消息重试而扩成 20 个。实现上建议每次只下发"期望副本数"这个目标态,而不是下发"增加 N 个 Pod"这种增量指令,否则一旦上一个指令还没执行完、下一个指令又来了,两个增量叠加就会失控。
3. 动态调度落地时最棘手的三个环节:状态转移、优雅下线、数据亲和
动态调度真正难的地方不在"扩",而在"缩";不在"新节点拉起",而在"老节点退场"。这一章聊三个我踩过坑的环节。
3.1 扩容容易缩容难:从"SERVING"到"DRAINING"的状态机
计算节点从加入集群到退出集群,应该有一套明确的状态机,而不是你直接在 K8s 里把 Pod 删掉就完事。我们生产环境用的状态机大致有四个状态:
- SERVING:节点正常服务,接收新任务。
- DRAINING:节点不再接收新任务,但已运行的任务继续执行,等待自然结束。
- IDLE:节点上没有任务了,等待回收。
- TERMINATED:节点资源已被释放。
调度器在下发缩容指令时,首先把目标节点从 SERVING 置为 DRAINING,然后由节点上的 agent 负责执行"排空"操作。只有状态变成 IDLE 之后,才允许真正释放资源。这套机制的窍门在于,DRAINING 和 IDLE 之间的时间是不可控的——长任务可能跑几个小时,所以还要配套一个最大排空时间:超过这个时间还没排空的任务,要么强制迁移,要么记录日志后强杀。
我见过一个比较极端的案例:某平台直接把 Pod 删了,结果一个跑了两小时的 Spark Streaming 任务被硬杀,Kafka offset 提交不完整,恢复时重复消费了十几万条数据。后来他们加上了 DRAINING 状态,把杀 Pod 的权限全部收归调度器,类似的问题再没出现过。
3.2 优雅下线:任务 drain、连接排空、本地缓存刷出的顺序不能错
DRAINING 状态下做优雅下线,动作顺序非常重要,排错了照样出事。按我实践下来的标准顺序是:
- 第一步:摘流量。把节点从服务发现列表里摘掉,不再接收新的任务请求。这一步要在状态切换的瞬间完成,否则还会源源不断地涌进新任务。
- 第二步:排空任务队列。等待所有已接收的任务执行完,同时把队列信息上报给调度器,便于调度器在其他节点上重建未执行的任务。
- 第三步:刷出本地状态。把 shuffle 文件、缓存块、checkpoint 数据写回共享存储或远端。这步最耗时,需要监控刷出进度,不能中途断。
- 第四步:关闭外部连接。通知下游系统这个节点要退出了,让连接池慢慢排空。
- 第五步:确认资源可释放。向调度器上报"IDLE",再由调度器去删 Pod。
这里最容易犯的错是把第四步和第三步搞反。我之前就吃过亏:先把外部连接断了,导致还有任务在写外部系统时突然报连接断开,任务直接失败,而失败后的重试又把一批脏数据写进去了。正确的逻辑应该是:先确保数据都已经刷到持久层,再断对外连接,最后才做连接排空。
3.3 数据本地性是存算分离里最容易被误解的概念
存算分离之后,很多人认为"数据本地性"这个概念已经不存在了——反正数据都在远端,节点在哪儿不都一样吗?实际不是这样。
存算分离消除的是"永久数据"的本地性约束,但没有消除"临时数据"的本地性约束。Spark 的 Shuffle、Flink 的 State、Presto 的 Exchange 中间结果,仍然优先写本地磁盘。这意味着:如果一个节点上正在跑一个大 Shuffle 任务,贸然把它缩掉,那它本地硬盘上的 shuffle 数据全部作废,下游 Task 就得重新拉数据、重算一遍,整个作业的运行时间可能翻倍。
所以调度器在做缩容决策时,必须评估目标节点上的"临时数据量"和"任务运行阶段"。一个常见的规则是:如果节点上的任务正处于 Shuffle Write 阶段,就暂缓缩容;如果正处于 Shuffle Read 阶段,也要谨慎,因为重算代价大。这里没有绝对正确的答案,核心是给调度器增加一个"本地数据代价"的权重因子——只有当缩容省下的资源收益大于可能引发的重算代价时,才真正执行缩容。你可以把这个因子作为决策模块的一个可配置参数,上线初期先把阈值调保守一点,跑稳定了再逐步收紧。
4. 从"被动伸缩"到"预测调度",以及生产环境中的调优与排错
前面讲的都是被动伸缩:负载上来了,调度器响应。被动伸缩的缺陷在于,容器创建和任务启动是有延迟的,通常要 3 到 10 分钟。如果负载是突然涌上来的,等调度器反应过来再扩容,任务可能已经排队排到用户崩溃了。所以生产环境里真正好用的动态调库,都会往前走半步,做"预测调度"。
4.1 基于历史画像的提前扩容:到底该预测提前多少分钟
预测调度的思路不复杂:既然计算负载有明显的周期性,那就基于历史数据预测未来半小时的负载,提前把节点扩起来。你可以用很轻量的方式实现,没必要一上来就上 LSTM、Transformer 这种重型模型。
我建议从时间序列的周期分解开始:按天、按周两个周期统计各个时间窗口的负载均值。比如每周一上午 10 点有一批定时报表任务,过去四周同时间段的 CPU 负载分别是 65%、72%、70%、68%,那么预测时就可以认为这周一 10 点负载会到 70% 左右,提前 20 分钟把节点从 10 个扩到 15 个。
关键参数有两个:预测窗口和提前量。预测窗口一般取过去 4 到 8 个同周期数据,太短了容易受偶然因素干扰,太长了会平均掉近期趋势。提前量则要根据"容器启动时间 + 任务初始化时间"来定:如果从下发扩容指令到 Pod Ready 平均要 5 分钟,那你至少得提前 8 分钟扩容,留出 3 分钟安全裕量。我见过有人把提前量设成 30 分钟,结果容器早早起来了,白白占了 20 多分钟的闲资源——预测调度不是越早越好,提前量越短,资源浪费越小,但风险也越大,需要在两者之间找平衡。
4.2 生产环境中的调度参数调优清单
动态调度系统上线后,最耗精力的就是参数调优。我把常用参数整理成一个清单,供大家参考:
| 参数 | 作用 | 建议初始值 | 调优方向 |
|---|---|---|---|
| 扩容阈值 | CPU/队列达到多少触发扩容 | CPU 70% 或队列深度 > 10 | 任务延迟敏感就调低,成本敏感就调高 |
| 缩容阈值 | 负载低于多少允许缩容 | CPU 30% | 缩容阈值要离扩容阈值远一些,防抖 |
| 扩容持续时间 | 指标超过阈值持续多久才触发 | 5 分钟 | 尖峰多就调长,持续流量大就调短 |
| 冷却时间 | 动作完成后多久内不重复触发 | 10 分钟 | 节点启停慢就调长 |
| 最大缩容比例 | 单次最多缩掉多少节点 | 20% | 保守就调小,避免一波缩完又立即扩容 |
| 节点空闲判定 | 节点多久无任务算空闲 | 15 分钟 | 长任务多就调长 |
| 预测提前量 | 预测触发后提前多久扩容 | 8 分钟 | 按容器启动时间实测调整 |
这些参数没有通用最优值,一定要结合自己集群的容器启动时间、任务时长、业务波动周期来确定。我一般建议先把参数调"钝",宁可让扩缩容慢一点,先保证稳定运行一周,再根据监控数据逐步调"灵"。上线初期就追求极致灵敏,大概率会被抖动折腾得怀疑人生。
4.3 我踩过的坑与排错思路
最后分享几个生产环境里真实踩过的坑,每一个都代表一类容易忽视的问题。
第一个是"缩容把正在写外部系统的事务任务杀了"。症状是任务失败率在缩容后明显上升,排查看日志才发现,杀掉的节点上有任务正在向业务数据库写数据,连接被硬断后引发了一连串重试。这个问题在排空逻辑里加上"等待任务完成外部写入事务"的阶段就能解决,但前提是你得在任务模型里能识别出这类事务。建议在计算框架的任务提交接口里增加一个"事务性任务"标记,调度器对这类节点做缩容时格外保守。
第二个是"扩容阈值只看 CPU 导致大查询依然排队"。现象是 CPU 一直没过 70%,但 SQL 查询的 P99 延迟不断上升。后来查清楚是查询队列深度已经超过 50,CPU 却还在 60% 徘徊——因为查询都在等 IO 或者锁,CPU 根本跑不满。这类问题的修复办法是把队列深度、IO 等待这两个指标加入触发条件,用"或"的逻辑而非"与"的逻辑:任何一个指标超标都应该触发评估。
第三个坑是关于"冷却时间"的:我们的调度器在一次大缩容后进入冷却期,偏偏 5 分钟后流量高峰来了,扩容指令被冷却期挡住,眼睁睁看着集群排队。事后我把冷却时间拆成两个参数:扩容冷却和缩容冷却,扩容冷却设短一些(比如 5 分钟),缩容冷却设长一些(比如 20 分钟)。因为扩容过度最多浪费一点资源,缩容过度却可能导致服务能力不足,两者的风险不对称。
还有一个很隐蔽的坑:当你用共享存储(对象存储或 JuiceFS)作为存算分离底座时,缩容前本地缓存刷出的大量数据会同时涌向存储层,造成存储带宽瞬间打满。建议在刷出逻辑里做限速,按节点 shuffle 控制并发,或者把节点分批下线而不是一次性全部杀完。调度器生产环境的稳定性,很多时候就藏在这些不起眼的细节里。
从我个人经验来说,做动态调度最怕的不是算法不够高级,而是对节点生命周期管理不够细腻。先把状态机捋清楚、把排空顺序做对、把冷却和阈值搭配好,再去研究预测算法,路会顺很多。如果你也正在设计类似的调度系统,建议先从被动伸缩做起,把动作闭环跑稳,再叠加预测能力——这套路线我反复验证过,是最不容易翻车的。
