从 1 万到 20 万:OOS Loop 任务规模提升 20 倍的攻坚记录

过去四个月,我们成功完成了技术突破,把单次执行的 Loop Item 上限从 1 万提升到 20 万,全地域 19 个 Region 放开;20 万全量执行时长从压测初期的预计 33 小时压到 6 小时左右,速率做到 20 分钟/1 万台 ECS。

这篇文章记录了这个过程里真正卡住我们的问题、解决过程的取舍以及踩过的坑,希望能给同样在做大规模批量执行的同学一些参考。

一、先认识 OOS 与 Loop 任务

OOS(CloudOps Orchestration Service, OOS,运维编排服务) 是阿里云的运维编排服务:把一连串运维动作写成模板,OOS 按模板顺序执行,中间替你管并发、重试、审批和容错。打补丁、重启、巡检、配置变更这些「重复且容易出错」的操作,都可以写成模板一键执行。

Loop是模板里「对一组对象批量执行指定任务」的能力:模板里定义一个 Loop 节点,传入一组 Items(通常是实例 ID),引擎为每个 Item 创建一个子任务,按配置的并发度执行,最后把子任务输出聚合成父任务结果。批量打补丁、批量配置变更、批量巡检、批量重启都依赖这个能力。

常用模板:

●批量在多台ECS实例上运行云助手命令

●批量上传文件到多个阿里云ECS实例

●批量启动 / 停止 / 重启ECS实例

二、背景:1 万条上限卡住了大客户

随着业务增长,客户管理的实例规模也在持续增长。几万台实例打一次补丁、做一次配置变更,原来要手工把执行拆成好几段分别跑,当前OOS为保证系统稳定而设定的 1 万条的 Loop Item 上限逐渐变成了锁住效率的枷锁。我们把目标定得很直接:解放上限,并且在大规模场景下做到快速创建,稳定执行。

上限是配置改一行就能放开的事,难的是放开之后链路扛不扛得住。第一轮 20 万压测给了三个非常直接的答案:

●执行拉不起来:20 万条单行 INSERT 把数据库打爆,报 Lock wait timeout exceeded;

●预发 32G Redis 内存全程打满,分布式锁获取失败,执行中途卡死;

●执行时长非线性劣化:同样并发 100,5 万规模跑 100 分钟,20 万规模(Item 只多了 4 倍)预计要跑 30 小时,多了 18 倍。

第三个问题最反直觉,它说明链路里藏着跟规模相关的隐性开销,规模越大放大越狠。我们把整条链路拆成五个环节逐一对账:

环节 1 万规模 20 万规模下的问题
子任务创建 一次性写入 1 万行 20 万条 INSERT 导致锁等待超时,执行无法拉起
子任务执行 每个子任务加载并缓存一次父任务 Loop 配置 每份缓存含 20 万实例 ID(约 5MB),Redis 被打满
目标筛选 DescribeInstances 最多返回 1000 条 按标签/资源组筛选 20 万实例时结果被截断、二次过滤失真
执行调度 并发控制粒度粗 调度耗时非线性增长
查询 API ListExecutions 正常 子任务暴增后慢 SQL 超时

三、整体节奏:压测先行,多次灰度

我们没有一上来就改配置放上限,而是按「压测暴露问题 → 逐个优化 → 单地域灰度 → 全地域推平」的节奏走了两轮,先 5 万、再 20 万:

时间 里程碑 关键动作
2024-10 上限 2 万 max_loop_items 提升至 20000(早期探索)
2026-03 20 万首轮压测 预发解除 1 万限制,1 万/20 万双规模对照压测,问题清单成型
2026-03 ~ 04 核心性能优化 批量插入、path 批量计算、移除子执行冗余缓存、资源关系更新异步化
2026-04 上限 5 万 · 青岛灰度 青岛地域率先放开至 50000
2026-05 上限 5 万 · 全地域 5 万上限推平所有地域
2026-05 ~ 06 SelectTargets 改造 突破 DescribeInstances 1000 条限制
2026-06 上限 20 万 · 青岛灰度 青岛地域放开至 200000
2026-06 上限 20 万 · 全地域 全部地域 max_loop_items 提升至 200000

选这个节奏的原因很简单:20 倍跨度太大,一步放开出了问题既难定位也难回滚;拆成两档,每档都先在青岛灰度观察两三周,数据和告警都干净了再推平。

四、第一刀:子任务批量创建,从拉不起到分钟级落库

卡在哪:首轮压测,执行在创建子任务阶段直接失败,Lock wait timeout exceeded; try restarting transaction。旧实现有两个问题:底层是逐条的单次插入,20 万子任务就是 20 万条 INSERT,长事务把 MySQL 锁等待打爆;同时每个子任务创建前还要单独查一次库拿父级 path,又是 20 万次 SELECT。

为什么这么改:我们没有动表结构和存储,判断瓶颈就在「交互次数」上------把 40 万次 DB 往返压缩成批量操作,成本最低、风险最小。

怎么做:

第一步,path 批量计算,先收集本批所有的执行,按每批 1000 个分批 IN 查询(避免 IN 列表过长的 SQL 性能问题),然后在内存里拼接 path:

python 复制代码
# 旧:每个子任务一次 DB 查询
for exec in execs:
    updates = cls._add_path(_context, updates)   # N 次 DB 往返

# 新:批量查 path → 内存计算 → 批量插入
paths = db_api.batch_get(...)      # 分批 IN 查询
p_paths = db_api.batch_get(...)
for updates in updates_list:
    cls._add_path_batch(_context, updates, paths, p_paths)

第二步,批量插入。create() 从逐条 session.add + flush 改成 SQL 原生批量插入(session.bulk_save_objects),所有子任务通过一条 INSERT 写入,按数量自动拆分。这里留了个兜底:遇到唯一键冲突(DBDuplicateEntry)自动降级为逐条 upsert,重试场景下的幂等性不受影响。压测验证一次插入 20 万行没有问题。

第三步,分批熔断。新增配置项 batch_size(默认 50000),20 万子任务拆成 4 批依次落库,单次事务规模可控。

结果:Lock wait timeout 消失,20 万子任务分钟级稳定落库;创建阶段 DB 交互次数从约 40 万次降到数百次。这个数字值得强调一下:它意味着创建阶段的数据库压力只和批次数相关,不再随 Item 数量线性增长,后面再往上调上限,这一层不会先垮。

五、第二刀:删掉没人用的缓存,Redis 内存直降 99%

卡在哪:拉起问题解决后,执行开始中途失败,报错链路是:

csharp 复制代码
socket.timeout: timed out
    ↓
redis.exceptions.TimeoutError: Timeout reading from socket
    ↓
DistributedLockFailed: Failed to acquire or release distributed lock

预发 32G Redis 内存全程打满。第一反应是扩容,扩到 32G 还是打满,说明问题不在规格。

根因:Loop 的每个子执行启动时都会加载父任务的部分属性并写入 Redis 缓存。这份属性里带着完整的 20 万条实例 ID,单份约 5MB。20 万个子执行就是 20 万份这样的缓存------而这份数据在子执行的代码路径里从头到尾没被读过,并发控制、批次推进这些逻辑只发生在父任务侧。这是一处历史遗留:早期版本子执行确实需要它,后来逻辑收敛到父任务,加载代码留了下来。

怎么改:判断该处确实没有实际使用后,直接移除对应代码。

结果:Redis 内存占用下降 99%,增长曲线恢复平稳,读缓存超时报错消失,分布式锁恢复正常。这是整个攻坚里投入产出比最高的一处改动,也给我们留了个习惯:大规模场景下看到任何「每个子任务都要做一遍」的操作,先问一句它真的必要吗。

六、第三刀:执行时长 33 小时压到 6 小时

卡在哪:能跑通之后,速度成了新问题。同样并发 100,5 万规模跑 100 分钟,20 万规模预计要 30 小时------Item 多了 4 倍,时长多了 18 倍,明显有随规模超线性增长的开销。

定位:沿着主调度路径排查,锁定了同步阻塞点:每个子执行完成时都会同步更新「资源-执行关系表」。这个写操作对单个子任务是毫秒级,但 20 万次串在主路径上就成了一堵墙。

怎么改,效果如何:两步走,每一步都用 20 万全量压测验收:

优化动作 20 万全量执行时长 执行速率
优化前 ~33h ~6000 个/h
资源-执行关系更新异步化 20h(-13h) ~10000 个/h
并发 100 → 200(配合批内并发流控) 18h ~11000 个/h
持续优化至今(实测) ~6h ~3 万个/h(20min/1 万台 ECS)

异步化一项就砍掉 13 小时,这验证了我们的判断:主调度路径上的同步写操作会被规模线性甚至超线性放大。并发从 100 提到 200 之所以敢做,是因为 Batch 模式的批内并发流控一直在兜底,下游 API 的调用速率是受控的。

目前实测 20 万 loop item 稳定跑完约 6 小时,速率 20 分钟/1 万台 ECS,且时长和规模恢复了线性关系------这一点比绝对值更重要,它说明链路里没有残留的隐性放大器,上限再往上走执行时长有保障。

七、第四刀:SelectTargets 突破 1000 条限制

前面几刀解决的是「跑得起、跑得快」,这一刀解决的是「Item 从哪来」。实际业务里 Loop Item 很少手工传入,绝大多数来自按标签或资源组筛选实例。筛选本身只能拿到 1000 条的话,20 万 Loop 就是空中楼阁。

问题本质:ECS DescribeInstances 的 TotalCount 和结果集最多返回 1000 条,而且过滤顺序是先按 Tag 取 1000 条、再用其他参数二次过滤。匹配 Tag 的实例超过 1000 台时,二次过滤的结果不完整甚至完全错误。举个压测里真实遇到的例子:1200 台 A 镜像 + 200 台 B 镜像打了同一个标签,按 Tag 取满 1000 台全是 A 镜像,再按 B 镜像过滤,结果一条不剩。

第一版方案:引入 TotalCount 探测,达到 1000 就切换到 ListTagResources------它支持 NextToken 分页,能拉全量匹配标签/资源组的资源 ID,没有 1000 条限制;拿到全量 ID 后再用 DescribeInstances + InstanceIds 分批做参数精筛,也就是两阶段过滤。同步补了 ecs:ListTagResources 权限,切换失败自动回退旧路径。

第二版重构:灰度反馈让我们推翻了 TotalCount 探测------这个字段本身就受 1000 条限制污染,拿它做分支判断不可靠。改成 ECS 场景恒定走两阶段过滤,删掉探测逻辑,顺带修复了 Tags 参数处理的 bug。重构后代码更短,行为也更确定。这轮迭代的教训后面经验部分还会再提。

结果:SelectTargets 支持按标签/资源组筛选 20 万级实例,且与 ImageId、Status 等附加条件组合时结果完整正确。

八、配套修复与稳定性保障

除了上面四个大块,压测还沉淀出几类问题:

1.ListExecutions API 超时(已解):子任务暴增后查询慢 SQL 超时,上海地域线上也出现过类似问题。解法是给 idx_ali_uid_parent_task_execution_id_deleted 索引补上 status 列形成覆盖索引。

2.Redis 写超时未捕获(已改进):压测中有 2 个子执行因为 Redis 写超时的报错直接抛出而卡住。补了异常捕获与重试兜底------基础设施抖动不应该直接击穿业务执行。

3.Daily Task 配额超限(已调整):20 万执行触达 Daily Tasks 20 万配额,调整了 quota 策略适配大规模场景。

4.loop_current_batch_id 为 None(已解):批量创建路径下批次 ID 可能未初始化,导致 get_pending_children 索引异常,加了兜底初始化。

配套的机制性保障也在压测里验证过:Batch 模式 + ConcurrencyInBatches 控制每批并发,全局并发上限保护下游云产品 API;MaxErrors 支持百分比容错,大规模执行里个别失败不会中断整体;loop_child_finish 消息处理用「缓存时间戳 + 分布式锁 + 锁内外双重过期检查」做去重,扛住了 20 万级消息量。测试方面,批量创建、path 批量计算、SelectTargets 大规模场景累计新增 2000+ 行单测,覆盖正常、切换、回退、权限校验全路径,engine 增量覆盖率门禁提到 95%。

九、压测方法与灰度节奏

整套改造优化动静非常大,涉及到整个OOS 系统的底层改造,能够平滑上线,靠的是合理的压测方法。预发环境切换独立压测数据库,临时解除 1 万上限,做「1 万基线 → 20 万极限」双规模对照:每一轮代码改动之后,都完整回归一次 20 万全量执行,用真实执行时长和监控曲线验收,不看局部 benchmark。

两组关键数据:1 万基线下,普通 Loop 并发 100 耗时 28 分钟,Batch 1000 模式 19 分钟;20 万极限从最初的拉起失败,到当前实测约 6 小时跑完,速率 20 分钟/1 万台 ECS。

灰度节奏前面时间线已经列过,补充两个设计点:上限通过地域配置 max_loop_items 独立控制,任何地域可以单独回滚到上一档,这是敢灰度的前提;灰度期间固定观察五个指标------子任务创建耗时、DB 压力、Redis 内存、MQ 消息量、下游 API 限流,5 万档观察约 2 周,20 万档观察约 3 周,数据和告警都干净才推平。

十、结果汇总

指标 优化前 优化后
Loop Item 上限 1 万 20 万(20 倍)
20 万子任务创建 Lock wait timeout,拉起失败 分钟级稳定落库,一次插入 20 万行
20 万全量执行时长 预计 33h+ 约 6h
执行速率 ~6000 个/h ~3 万个/h(20min/1 万台 ECS)
Redis 内存占用 32G 全程打满 降低 99%
DB 交互次数(创建阶段) ~40 万次 数百次
SelectTargets 一次性筛选规模 ≤1000(标签场景) 20 万级

对业务侧意味着什么:客户用一个模板、一次执行就能完成 20 万台实例的批量运维操作,不用再手工拆分编排。这里值得解释一下 6 小时这个数字------它对应的是 20 万次真实的云产品 API 调用(RunCommand + 结果轮询),受下游 API 吞吐约束,属于当前架构下的合理水位;如果客户的操作本身更轻,时间还会更短。

十一、经验沉淀

1.批量化三件套:批量查询(分批 IN)→ 内存计算 → 批量写入 + 分批熔断。这是 DB 密集型批量操作的标准改造范式,改造后压力只与批次数相关,与数据量解耦。

2.同步链路是大规模执行的隐形杀手。资源关系更新异步化一项砍掉 13 小时。主调度路径上的任何同步写操作,都会被规模线性甚至超线性放大。

3.压测先行,用真实规模验收。每轮优化都完整回归一次 20 万全量执行,用执行时长和监控曲线说话。局部 benchmark 发现不了 O(N²) 问题。

4.不要相信被限制污染的探测值。SelectTargets 第一版用 TotalCount 做分支判断,第二版推翻重来------依赖一个本身受 1000 限制的字段做决策,逻辑再严密也是建在沙子上。

5.上限放开必须配流控。Batch 模式 + 批内并发上限,把「能力放开」和「流量放开」解耦,这是并发敢从 100 提到 200 的前提。

6.小步灰度、可回滚。配置化的分地域上限 + 两档灰度,20 倍跨度也走得稳。

压测Tips

压测是一种发现问题非常重要的手段,但是由于压测场景通常比较极端,在多次压测过程中也有一些经验分享给大家。

环境隔离

1.压测必须在预发,且数据库完全隔离。必须避免压测过程和线上共用数据库,压测的锁等待、慢 SQL 会直接打到线上用户;Redis、MQ 这类共享中间件同样要隔离或评估容量。

2.端到端压测提前和上下游业务方打招呼。大批量创建实例、调用云产品 API 都可能挤占上下游的共享容量和限流配额,最终影响的是真实用户;务必 务必 务必 提前沟通,约定时间窗口、错峰执行。

流量与止损

1.压测流量也要带流控和熔断。Batch 模式 + 批内并发上限,别裸奔打满下游 API;触发限流不仅污染压测数据,还可能波及同地域其他租户。

2.留一键止损开关。压测中途发现异常(内存飙升、下游报错)能立刻停,不让影响扩散。

前置检查

1.配额、权限、库存提前盘点。Daily Tasks 配额、API 限流、压测实例库存、新增 API 的权限(如 ecs:ListTagResources)都要提前申请扩容,别让压测跑一半被配额超限打断,非常影响压测结构以及测试效率。

2.监控五件套先就位再开压:子任务创建耗时、DB 压力、Redis 内存、MQ 消息量、下游 API 限流。出了问题能立刻定位,而不是事后猜。一定要多打日志,避免大规模压测后发现因为日志缺失导致重测。

相关推荐
千桐科技1 小时前
qModel算法模型平台功能解析:从源码到 Docker 镜像,部署能自动完成哪些工作?
运维·docker·容器·qmodel
刚入门的大一新生2 小时前
Linux-进程间通信1
linux·运维·服务器
天远Date Lab2 小时前
零信任架构实战:基于天远柠檬查维保构建自动化全国连锁二手车置换上架电子维保报告网关
运维·人工智能·架构·自动化
张宇Joaquin2 小时前
Linux系统下磁盘扇区512n、512e和4kn的区别
linux·运维·数据库
IDCPDU2 小时前
RS485 总线上的 PDU 总丢包:拓扑、终端电阻和地址冲突怎么查
运维
yunwei373 小时前
eBPF 示例:使用 CUPTI 构建 GPU 火焰图分析器
linux·后端·性能优化
const_qiu3 小时前
第四章 07:4.7 本章小结
java·运维·服务器·人工智能
张宇Joaquin3 小时前
自研SP600系列网卡驱动跟麒麟V10 SP3 2403配套关系
linux·运维·网络
mengge.cloud4 小时前
1019Namespace ,Contexts ,Kubernetes Pod
运维·服务器·网络·云原生·容器·kubernetes·云计算