ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

AI-OCT调度层排坑实录:任务饥饿、重试风暴与幂等性设计

AI-OCT调度层排坑实录:任务饥饿、重试风暴与幂等性设计 1. 项目背景龙呤AI-OCT调度层到底管什么事1.1 先把这个系统说清楚龙呤AI-OCT这个项目本质上是一套架在医学影像设备后面的智能分析平台。OCT设备大家可能不陌生眼科、心血管科用得特别多一次扫描下来能产生几百上千张断层图像。以前这些图像全靠医生一张一张看费眼睛不说碰到早早期病灶还容易漏。我们这个系统要干的事就是设备端采集完图像后自动把检查请求推到后端后端在最短时间内完成图像去噪、分层识别、病灶标记、定量测量、报告生成这一整条AI推理管线最后把一份带结构化的结果推回医生工作站做复核。我在这个项目里负责的是后端任务调度层就是整个AI处理链路里“承上启下”的那一环。所有从各院区设备端进来的检查请求先进调度层调度层决定这个任务什么时候跑、跑在哪台计算节点上、用哪张卡、失败之后怎么办。上游是几十台甚至上百台设备下游是若干个带GPU的推理节点和一个对象存储调度层夹在中间既要扛住流量冲击又要做资源分配还必须在任何异常情况下保证任务不丢、不重、不卡死。1.2 调度层在整体架构里处于什么位置拆开看我们的后端大概是这么个结构设备端通过HTTPS接口上传检查任务网关做鉴权和限流然后落到调度层调度层把任务写入数据库由轮询模块扫描出可执行的任务按一定规则分配给推理节点推理节点跑完模型后把结果写回对象存储再回调调度层更新任务状态最后有一个通知服务给医生工作站推送报告就绪的消息。调度层在整个链路里承担三个职责一是流量缓冲设备端可能瞬间涌入大量请求后端推理能力有限必须有队列先兜住二是资源分配GPU是稀缺资源谁用哪块卡、用多久必须有个统一协调者否则节点自己抢卡会出乱子三是状态管理每个任务从创建到完成要经过好几个阶段中间任何一步失败都需要可恢复、可追踪。可以说业务逻辑的复杂度都在模型和服务里而系统的稳定性和可靠性全都压在调度层上。调试过这类系统的同学应该能理解一个感受业务代码出bug报错信息通常很直接但调度层出问题表现出来往往是“任务不见了”“任务重复跑了”“任务永远在排队”根因藏在好几层调用链之外不把整条链路拉通看根本找不到源头。1.3 为什么调度层是“排坑”高发区我在这个项目上踩的坑比我之前写业务接口那几年加起来都多。后来总结下来调度层容易出问题本质上是因为它同时踩中了三个雷区第一异步链路上的时序问题很难靠单测覆盖很多bug只在特定时间顺序下才会触发第二分布式环境下没有绝对可信的全局状态任务状态在多个节点之间的同步天然存在延迟和冲突第三资源竞争是不可预测的你没法在开发环境完整模拟出生产环境的GPU争抢、网络抖动、上游重试。这决定了调度层的排坑工作很大程度上不是“读代码找bug”而是“从现象反推时序从时序锁定环节从环节定位代码”。这篇文章里记录的坑都是系统跑起来、流量上来之后才陆续暴露的每一个都值得后来人引以为戒。2. 最初的设计一个看似合理的调度方案2.1 任务模型四态存储与轮询分发第一版调度层我们选的是最常规、也最好理解的关系型数据库方案。一张任务表字段包括任务ID、检查ID、患者ID脱敏标识、设备ID、任务状态、优先级、创建时间、更新时间、重试次数、回调地址、请求体存储路径。状态一开始只设计了四个PENDING、RUNNING、SUCCESS、FAILED。概念上非常清晰谁看了都懂。分发逻辑也简单一个轮询组件每隔几百毫秒执行一条SQL把PENDING状态且满足调度条件的任务捞出来交给空闲的计算节点。为了防止多个调度实例同时捞到同一个任务我们用了SELECT ... FOR UPDATE SKIP LOCKED这在PostgreSQL下是标准做法。节点执行完任务后通过接口回写状态关闭这一轮的调度循环。这套方案在早期每天几百个任务的量级下跑得很稳定。我们的开发环境、测试环境甚至预发环境都没出过什么问题。所以后来生产环境连续出状况的时候团队里好几个人第一反应都是“不可能吧这么简单的逻辑怎么会出问题”。事实证明越是看着简单的逻辑越容易在边界条件下翻车。2.2 优先级与GPU资源管理的初版实现优先级设计上我们参考了大部分在线服务系统的惯例分了三档紧急对应医生端在等报告的场景普通对应常规门诊检查低对应历史影像数据批量回刷。实现方式就是在任务表里加一个priority字段轮询SQL里ORDER BY priority DESC, created_at ASC。优先处理紧急紧急里先来先服务。GPU资源管理更粗糙。每个推理节点启动时向调度服务上报自己的GPU型号、显存大小、空闲卡数调度服务在内存里维护一张节点资源表。分发任务时从这张表里挑一个空闲GPU的任务把任务标记为RUNNING然后发出去。节点跑完任务会更新资源表把GPU释放。当时大家觉得这个设计够用了毕竟推理任务单次执行时间不算长节点也就那么几个不会有太复杂的资源冲突。但后来的踩坑经历让我们意识到资源管理如果只看“空闲/不空闲”而不看“健康/不健康”迟早要出事。2.3 现在回头看这个设计埋了哪些雷复盘来看第一版设计至少埋了四颗雷。第一优先级只分三档且没有老化机制低优先级任务理论上可能永远无法被调度。第二轮询依赖数据库锁任务量大了以后扫描本身会成为瓶颈而且锁等待会让调度延迟变得不稳定。第三失败处理只考虑了“任务级重试”没有考虑“节点级熔断”一旦某个节点出问题整批任务会集中失败再集中重试。第四整个系统只记录任务的最终状态不记录状态变更历史一旦出现诡异状态连“它是怎么走到这一步的”都没法查。这四条里任何一条单独拿出来都不算什么高级问题但合在一起碰上真实流量就是连环爆雷。下面的排坑实录就是这些雷逐一被引爆的过程。3. 上量之后开始花式翻车典型排坑记录3.1 坑一低优先级任务被活活饿死第一颗雷爆在一个看起来很普通的周三。有院区反馈历史影像数据回刷任务跑了一周还没跑完。我查了一下任务表这批回刷任务全是PENDING而每天新进来的检查任务有几千个其中不少是紧急优先级。轮询逻辑是ORDER BY priority DESC紧急任务永远排在前面普通任务偶尔能挤进去低优先级任务基本就是在表里躺尸。这就是典型的优先级反转和任务饥饿。表面看是排序策略的问题本质上是缺少“老化”机制。我们当时的修复方案不算复杂但很有效给每个任务增加一个等待时长维度调度排序时不再只看优先级而是用一个加权公式让等待时间超过阈值的低优先级任务权重逐步提升。具体做法是把排序因子改成priority分桶为主桶内再按created_at排序同时每个低优任务等待超过30分钟后自动“升级”到普通桶普通桶等待超过30分钟也自动升级到紧急桶。这相当于从制度上消灭了饥饿的可能性。改完之后我又盯着监控跑了两周低优任务最长等待时间从原来的“无限期”降到了可控范围内。这个坑让我记住了一件事在调度系统里纯优先级策略是最容易想当然的设计任何绝对的优先级最终都会变成对低优先级任务的绝对压榨。3.2 坑二重试风暴把下游打爆第二个坑出现在一个网络抖动比较频繁的下午。某个推理节点因为内网交换机异常所有正在执行的推理请求全部超时。我们的逻辑是任务执行失败后标记为FAILED由重试组件判断是否达到最大重试次数3次没达到就重新置为PENDING同时设置一个重试延迟。这个逻辑单独看没有毛病但问题出在重试延迟的实现上——我们当时用的是最简单的指数退避分别是1分钟、2分钟、4分钟可当一批几十个任务同时失败时它们会在同一时刻进入重试队列然后下一轮又在同一时刻一起重试。这种“同批失败、同批重试”的同步效应非常要命。网络恢复后推理节点收到的不是平稳的请求流而是一波齐射式的高并发请求直接把节点内存打满进程OOM然后这一批任务又集体失败又集体重试。那个下午我们连续收到四轮告警才意识到这不是网络问题而是我们的重试机制自己制造了次生灾害。修复方式是两步走第一重试延迟加上随机抖动具体做法是退避时间设为指数区间的随机值让同一批失败任务的重试时刻分散开第二重试初始退避从1分钟拉长到5分钟给下游恢复留足时间。另外我们还加了熔断机制单个节点连续失败率达到阈值时调度层会暂时不给这个节点分发新任务除非人工确认或节点恢复后重新上报心跳。这三个改动一起上重试风暴就再没出现过。3.3 坑三幂等没做好同一个检查被处理了三遍这个坑最隐蔽排查时间也最长。现象是医院端反馈有少量检查报告出现了重复同一患者同一只眼睛生成了两份结果几乎一样的报告。一开始我们怀疑是模型推理问题觉得是不是某个模型跑出了重复结果但查了推理日志后发现推理节点收到的请求数远大于实际检查数量说明重复发生在进入推理之前。顺着这个方向查根因出在设备端和调度端的重试语义不一致。设备端上传检查任务后如果在规定时间内没收到确认响应会重发请求。调度层接收接口在面对相同任务ID的重复请求时本应该直接返回“已存在”的结果但当时的去重逻辑只依赖数据库的唯一约束。在并发场景下两个相同任务ID的请求同时进来唯一索引会拦住其中一个但INSERT失败的异常处理代码写错了把拦截当成业务失败把任务标记成了FAILED随后触发了重试逻辑重试时又以新的RUNNING状态被执行了一遍。等于一次检查实际被处理了两次再叠加设备端自身的重发就出现了三次处理。排查这个问题时我们把设备端重发、调度层去重、失败重试三条链路分别拉出来画时序图才发现去重和重试之间存在一条“灰色地带”去重逻辑认为重复请求该被拒绝重试逻辑认为失败任务该重新执行两个逻辑在特定条件下会互相打架。修复方案是在调度层引入基于任务ID的分布式锁收到重复请求时直接返回原任务状态不走“新建→失败→重试”的路径。这个改动很小但效果立竿见影重复报告从偶发变成了零。3.4 坑四发版升级时任务静默丢失第四个坑是我们在一次版本升级时发现的。当时我们要给任务表增加一个新字段顺便调整调度逻辑发版流程是停掉旧调度进程、执行数据库迁移、启动新进程。结果迁移完成后队列里积压的任务变得非常少而且很多任务的updated_at时间戳停留在迁移前。我当时的直觉就是出事了。排查后发现旧调度进程被停掉时有一批正在执行中的任务还没来得及把状态回写。新进程启动后按照“只处理PENDING状态”的逻辑它看不到这些RUNNING状态的任务这批任务就成了孤儿永远不会被任何组件重新扫描。这就是典型的优雅关闭问题——进程收到退出信号后应该先停止接收新任务再等待当前任务完成或安全转储最后退出而我们当时的发版脚本直接kill了进程根本没给状态回写留时间。修复措施有两项。第一给调度进程增加退出前的drain阶段收到SIGTERM信号后先拒绝新任务等待当前执行中的任务状态回写完成设置超时时间我们定的是90秒超时才强制退出。第二增加一个兜底扫描任务定期把所有处于RUNNING状态但超过执行超时阈值的任务重新置为PENDING并重新调度。这两个措施加上之后发版导致的任务丢失问题再没出现过。这个坑告诉我们调度系统的稳定性不只是在线逻辑的事连“下线”这个动作本身也需要纳入设计。4. 排查工具和方法论没有监控就全是猜4.1 给调度层补了一整套可观测指标踩完这四个坑之后团队达成了一个共识调度层的很多问题不是“看不看得到报错”的问题而是“根本没有报错”的问题。任务失败可能是静默失败它只是停在某个状态不再向前走。所以排查这类问题的第一步永远是看数据而不是看代码。我们基于Prometheus和Grafana补了一套调度层专属监控。核心指标包括任务到达速率按设备分组看能看出是不是有异常设备在疯狂灌任务任务状态分布实时看PENDING、RUNNING、FAILED各占多少PENDING任务堆积量和平均等待时间这是衡量调度效率最直接的指标任务平均执行时间和P99执行时间执行时间异常波动往往意味着推理节点出了问题失败率与失败原因TOP10这个必须打点记录失败原因分类否则无法定位各推理节点的GPU使用率、队列深度和心跳状态用于判断分发的合理性。这些指标单独看都平平无奇但真正起作用的是我们养成的排查习惯先看指标定位范围再看日志还原时序最后才去读代码确认逻辑。这个顺序不能乱一旦乱了很容易被某个表面现象带偏。4.2 一次典型排查过程复盘从告警到定位拿坑三来复盘一次完整的排查过程。最初的告警是“报告重复率超过阈值”第一反应是推理服务的问题但推理服务的错误率指标是正常的。然后我们看了调度层的任务状态分布发现RUNNING状态的任务数在某些时间段明显高于设备端实际发起的检查数这说明有任务被重复执行。接着我们拉取了任务事件表把重复报告对应的检查ID拎出来按时间线排序看到两个相同任务ID的请求几乎同时到达一个INSERT成功一个INSERT被唯一约束拦截。问题出在拦截后的异常分支——它走了失败重试的路径。到这里代码逻辑的问题点已经很明确了。整个过程大概花了三个小时如果没有事件表和状态分布指标这个bug光靠猜可能要好几天。4.3 排查调度问题的三条铁律经手这几个坑之后我给自己定了几条排查铁律。第一条任务从进到出的每一步都必须有可观测的标记状态变更必须记录到事件表任何疑问都回到事件表去对时间线。第二条永远不要相信“内存里的状态”分布式环境下的节点内存是不可靠的一切以数据库持久化状态为准。第三条排查过程中先保留现场证据日志、监控截图、事件的原始数据都要留好因为很多bug在重启之后就无法复现了。这三条说起来简单但真正做到需要架构层面的支持。事件表就是为此设计的——它记录每个任务每次状态变更的时间、操作者和变更前后的状态相当于给了每个任务一份完整的时间线档案。有了这份档案所谓的“玄学问题”十有八九都能变成“时间线问题”。5. 重构后的调度层哪些改动真正解决了问题5.1 状态机重做把隐性状态全部显式化第一版只有PENDING、RUNNING、SUCCESS、FAILED四个状态看着简单但很多真实情况没法表达任务已经被某个节点领取了但还没执行完这算PENDING还是RUNNING任务重试等待中算PENDING吗节点执行超时但进程还活着该怎么描述这些模糊地带正是问题高发区。重构后我们把状态扩展成了七个CREATED创建、QUEUED入队待调度、CLAIMED已被节点领取、EXECUTING执行中、COMPLETED完成、RETRY_WAIT等待重试、DEAD超过重试次数或已死信。每个状态转移都有明确的触发条件和前置状态非法迁移直接拒绝并告警。这个改动让调度逻辑从“差不多能跑”变成了“有严格契约”后续再改代码时不会因为遗漏某个状态而出现逻辑漏洞。5.2 调度策略的取舍公平、效率与紧急插队的平衡重构时我们专门讨论了优先级策略。纯优先级会饿死低优任务纯FIFO又满足不了紧急插队的业务需求。最后选定的方案是“优先级分桶桶内FIFO等待时间老化”的混合策略。具体来说紧急任务进紧急桶普通任务进普通桶低优任务进低优桶。调度时按桶的权重配额分配调度机会比如紧急桶60%、普通桶35%、低优桶5%但任何一个桶里的任务等待超过设定阈值后会自动升级到更高优先级桶。这样既保证了紧急任务能快速响应也保证了低优任务在极端情况下不会被无限期搁置。这个策略上线后我们特意跑了一周混合流量压测低优任务最长等待时间稳定在8分钟以内紧急任务的P99等待时间也没有明显劣化。5.3 事件溯源与审计日志状态机重构的同时我们上线了事件溯源机制。任务表只保存当前状态所有状态变更都以事件形式写入一张append-only的事件表每个事件包括任务ID、变更前状态、变更后状态、触发者、时间戳、附加信息。这张表不更新、不删除只能追加。有了事件表之后很多排查工作从“猜”变成了“对时间线”。比如判断一个任务是否被重复执行拉出事件表按时间排序一目了然。判断一个任务是否卡死看它最后一个事件是什么、停留在当前状态多久马上就能定位。这个机制的实现成本不高但对调度系统的可维护性提升是质变级别的。5.4 压测验证结果重构完之后我们做了一轮针对性压测重点场景有三个。峰值流量冲击模拟早高峰几百台设备同时上传的场景节点故障恢复模拟推理节点宕机后任务重新调度的过程混合负载大量低优任务和高优任务同时存在验证老化机制是否生效。压测数据比第一版有明显改善在800个并发任务加随机节点故障注入的情况下任务完成率从96.3%提升到99.98%P99等待时间从12秒降到3.5秒低优任务最长等待时间从无上限降到可控的8分钟以内。虽然压测环境跟真实生产环境有差距但至少证明了重构方向是对的也给了团队上线信心。6. 给后来人的排坑建议6.1 设计阶段就该想清楚的事如果你正准备做一个任务调度系统哪怕规模很小下面几件事也建议在设计阶段就定清楚不要等到出问题再补。第一状态机必须显式设计。每个状态、每个转移都要有明确含义能写文档就写文档后面排查全靠它。第二去重和幂等的语义要提前和上游对齐。任务ID的生成规则、重试策略、超时时间这些看起来是“小事”一旦上下游不一致出的问题都特别难查。第三资源调度不能只看空闲数量还要看节点健康状态和队列深度。你分给一个“心跳正常但已经快不行”的节点等于给自己埋雷。第四任何重试逻辑都必须考虑同步重试和惊群效应加随机抖动不是可选项是必选项。第五发版流程里必须有优雅关闭和任务兜底扫描否则你会在下一次升级时发现任务莫名其妙少了。6.2 真出问题时的排查顺序如果现在让我回到当初那个周三下午面对“任务堆积”或“任务重复执行”的告警我会按这个顺序排查。第一步看监控面板上的任务状态分布和堆积量判断问题范围是整个调度层还是单个节点。第二步查事件表把涉及的任务按时间线拉出来看它从创建到当前状态经过了哪些步骤在哪个环节开始异常。第三步对照日志还原调度器、执行节点、上游设备三方的实际行为找出时序冲突点。第四步才打开代码确认具体逻辑。前两步能解决绝大多数问题根本不需要通读整个代码库。最后提醒一句排查过程中遇到任何疑点先记录证据再操作不要急着重启服务。很多调度层的问题都是偶发的一旦重启现场就没了你只能带着一个“复现不了”的bug继续上线。6.3 排坑之后我的真实感受调度层这类的系统做的时候感受不到成就感因为它不像算法模型那样有漂亮的指标也不像业务功能那样有明确的需求方。它更像一个隐形的守门员平时没人注意到它但它出一次问题全链路都要遭殃。我在实际维护过程中慢慢体会到调度层的核心价值不是“做得多复杂”而是“该有的保障都有”。状态可见、失败可重试、重试不叠加、关闭不丢任务、低优不饿死这几条做到位系统就稳了一大半。至于那些花哨的调度算法在业务量真正大到需要之前其实都不是必需品。另外一个很深的体会是调度系统一定要提前设计可观测性不要等踩了坑再补。事件表、状态指标、失败原因分类这些前期投入的成本并不高但它们在关键时刻能帮你省下好几天排查时间。如果你现在正好在做一个带队列、带调度、带重试的系统我建议你早点把这些基础设施搭好别像我一样等到连环翻车之后才痛定思痛。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进