一个真实的运维噩梦凌晨两点运维同学接到告警——某台引擎节点故障需要紧急重启。这时候机器上正跑着200多条自动化流程有的在同步电商订单到ERP有的在调用钉钉接口发通知还有几条数据量巨大的全量同步正执行到一半。直接kill那200多条流程全部中断。轻则数据丢失需要人工补数据重则订单重复推送、资金数据不一致。对于付费客户来说一次这样的事故可能直接引发投诉甚至退款。这不是假设场景。做过生产环境运维的人都知道服务重启几乎是日常操作——发版升级、节点扩缩容、故障迁移、K8s滚动更新……任何时刻都可能需要停掉一个正在工作的引擎实例。问题的核心在于你不能让服务说停就停必须给正在执行的流程一个体面的收尾。这就是数环通iPaaS引擎优雅停机机制要解决的问题。停机为什么不优雅先理清楚一个正在工作的iPaaS引擎节点上同时在跑哪些东西触发器消费者从RocketMQ拉取事件消息触发流程执行执行队列消费者分发和调度流程执行任务定时任务线程XXL-Job驱动的轮询触发器CDC监听线程数据库变更捕获实时触发流程消息集成消费者RabbitMQ、RocketMQ等用户自有消息队列的消费OPC UA连接工业设备协议的长连接流程执行线程几十上百条正在跑的自动化流程如果你直接关进程上面所有东西同时断掉。消费者消费到一半的消息会被rebalance到别的实例重新消费造成重复执行正在调用第三方API的流程会超时中断数据不一致CDC连接断开后可能丢失binlog位点……这是一个涉及10多种异构资源、并发执行的复杂停机问题。三阶段停机先断源头再等收尾最后拆基建数环通引擎的停机设计分为三个阶段我们内部叫做关门 → 收工 → 拆台第一阶段关门——切断所有新流量入口stopBefore() { 停止消费trigger事件 停止消费执行队列 停止消费resume消息 停止消费延迟resume消息 停止消费pause消息 停止XXL-Job调度注册 停止消费DLQ死信队列 停止消息集成消费者 停止RabbitMQ消费 停止RocketMQ消费 停止OPC UA长连接 停止CDC监听 }这个阶段的核心思想是不要再接新活了。所有能产生新流程执行的源头全部掐掉。具体来说RocketMQ消费者关闭triggerEvent、executeQueue、flowResume、flowPause这几个核心Topic的PushConsumer全部close。消息会自动rebalance到集群里其他健康节点不会丢失。XXL-Job停止注册通过ExecutorRegistryThread.getInstance().toStop()让调度中心摘除这个执行器新的定时任务不会再分配到这台机器。CDC销毁Debezium的CDC连接器释放binlog监听停止。外部消息队列断开用户配置的RabbitMQ、RocketMQ消费者全部优雅关闭。每一个资源的关闭都包裹在独立的try-catch中。这是一个重要的设计决策——任何一个资源关闭失败不能影响其他资源的正常关闭。线上真实场景中某个RabbitMQ连接可能已经断了、某个RocketMQ Broker可能不可达如果因为一个异常就中止了整个停机流程那反而更糟糕。第二阶段收工——等待正在执行的流程跑完这是最核心也最复杂的阶段。源头断了之后机器上还有若干流程正在执行中。必须给它们时间跑完。// 每3秒检查一次看是否所有流程都执行完了while(true){Thread.sleep(3000);if(executeMachine.getExecutions().isEmpty()){break;// 全部跑完了皆大欢喜}count;if(count20){// 等了60秒还没跑完启动Plan B...break;}}这里有一个现实的权衡你不能无限等。大部分流程执行时间在秒级到分钟级等60秒20次 × 3秒基本能覆盖绝大多数正常流程。但总有例外——比如一条数据同步流程正在处理10万条记录可能要跑几十分钟。这时候就进入了Plan B。Plan B打快照 发恢复信号对于等了60秒还没跑完的流程引擎不是直接杀掉而是执行一个精巧的操作向流程发送DEPLOY_PAUSE中断信号流程在两个步骤之间检测到信号主动停下来保存当前执行快照SnapShot向消息队列发送一条Resume消息集群中另一个节点消费到Resume消息后从快照恢复执行for(StringrootRequestId:rootRequestIdSet){Executionexecutionexecutions.get(rootRequestId);StringflowIdexecution.getFlow().getKey().getFlowId();// 发送部署暂停信号flowDeployPauseMessageListener.deployPause(execution.parseGroupId(),rootRequestId,flowId);}这个设计的精妙之处在于——流程不是被强杀的而是在一个安全点两个步骤的间隙自己停下来的。来看InterruptControl怎么工作的。在流程执行的主循环中每完成一个步骤Step都会检查中断信号// ExecutionRunner 每个步骤执行完毕后if(interruptControl.executeInterrupt(ec)){return;// 检测到中断信号停止执行}// 否则继续生成下一个步骤ec.newStep(currentStep.getNextHandler(),...);中断信号存在本地Guava Cache中基于requestId做key检测到信号后流程进入DEPLOY_PAUSE中断状态。中断处理器会// DeployPauseInterruptHandlerpublicvoidprocessInterruptCompleted(Executionec){// 发送Resume消息到MQ其他节点会接管执行flowResumeProducer.sendEvent(newResumeEventContext(ec.getRootRequestId(),ec.parseFlowId()));}这样做的好处是流程不会在调用第三方API的过程中被中断只在步骤间隙暂停执行状态通过快照持久化不会丢失Resume消息保证了流程一定会被其他节点接管恢复对用户完全透明流程日志中会标记暂停恢复但最终结果不受影响等待暂停确认发出暂停信号后还需要确认流程确实已经停下来了// 最多等10分钟确认所有已暂停的流程都从executions中移除了while(!pausedMap.isEmpty()){Thread.sleep(3000);if(pausedCheckCount200)break;// 超时兜底// 检查流程是否还在执行if(!executeMachine.getExecutions().containsKey(requestId)){iterator.remove();// 确认停止移出等待列表}}第三阶段拆台——关闭基础设施前面两个阶段保证了活干完了或者活被安全转移了第三阶段才开始拆基础设施stopAfter(){// 再等10秒留给可能的异步操作收尾Thread.sleep(10000);// 关闭Dubbo服务ApplicationModel.defaultModel().destroy();// 关闭Nacos注册中心NotifyCenter.shutdown();// 关闭Spring容器Bootstrap.shutdownHook.run();// 关闭最后的MQ资源flowManualStopConsumer.close();producer.close();}注意这里的顺序也是有讲究的先关Dubbo让注册中心摘掉这个服务实例上游流量不会再打过来再关Nacos配置中心不再推送变更最后关Spring容器销毁前面所有的BeanMQ Producer最后关因为前面的暂停恢复还可能需要发消息全局isShutdown标记防止停机过程中接新活还有一个细节值得说。在close()方法的第一行isShutdowntrue;这个全局标记配合引擎的入口逻辑保证了一个重要的不变量一旦进入停机流程即使消费者还没完全关闭关闭有延迟也不会再开始新的流程执行。这是一层额外的防护。在RocketMQ PushConsumer的close和实际停止拉取消息之间可能存在几秒的时间窗口在这个窗口内拉取到的消息不应该再被处理。isShutdown标记就是用来兜住这个边界情况的。K8s滚动更新下的实际表现在K8s环境中数环通引擎的Pod配置了terminationGracePeriodSeconds给了优雅停机足够的时间窗口。滚动更新时的实际行为是K8s发送SIGTERM → JVM ShutdownHook触发0-3秒所有消费者关闭新任务开始由集群其他Pod承担3-63秒等待运行中流程自然完成63秒对剩余流程执行暂停-快照-恢复暂停确认后10秒基础设施关闭Pod终止整个过程中用户无感知。正在执行的流程要么在本机跑完了要么被无缝转移到另一个Pod继续执行。从运行日志上看可能会多一条暂停恢复记录但流程的最终结果是正确的。这套方案解决了哪些真实问题回到文章开头的那个场景——凌晨两点紧急重启。有了优雅停机机制后运维的操作变成了直接重启或者让K8s调度不用先去数流程执行数量不用手工暂停流程不用通知客户重启后不用人工检查数据一致性不用补数据对于一个日均执行几百万次流程的平台来说这套机制每天都在默默工作。每次发版升级、每次集群扩缩容、每次偶发的Pod重调度引擎都在用这套三阶段机制保护着正在执行的流程。设计上的几个关键取舍做优雅停机有几个技术取舍值得展开说说为什么是60秒而不是更长60秒是在用户体验和发版速度之间的平衡点。大部分流程执行在30秒内完成60秒能覆盖99%的场景。如果等太久K8s滚动更新的时间会拉得很长影响发版效率。剩下的1%走暂停恢复路径延迟也就几秒。为什么不直接全部走暂停恢复能自然跑完就自然跑完。暂停恢复虽然可靠但毕竟涉及快照序列化、消息发送、另一个节点反序列化恢复——中间多了几个网络IO和磁盘IO。对于一个还有5秒就跑完的流程等5秒比暂停恢复要高效得多。为什么每个资源单独try-catch线上环境什么情况都可能发生。RabbitMQ集群可能挂了、RocketMQ Broker可能不可达、XXL-Job调度中心可能超时。如果因为一个外部依赖的关闭失败就整体卡住那优雅停机反而变成了不优雅卡死。每个资源独立关闭、独立异常处理是生产环境得出的经验。中断信号为什么用本地Cache而不是Redis性能考量。中断信号的检测发生在流程执行的每一步之间如果每次都查Redis会给流程执行引入额外延迟。用本地Cache检测开销几乎为零。而中断信号只需要在本机生效因为流程就在本机执行不需要跨节点传播所以本地Cache完全够用。写在最后优雅停机不是一个有则加分的特性对于企业级集成平台来说它是基础设施级别的刚需。用户购买iPaaS平台核心诉求就是稳——流程不能莫名其妙断掉数据不能莫名其妙丢失。这些不能的背后就是各种场景下的容错设计在支撑。数环通引擎的优雅停机机制从最初的简单等待演进到现在的三阶段中断信号快照恢复的完整方案经历了无数次生产验证。每天处理百万级流程执行每周多次滚动升级用户侧零感知。这就是工程上做对不难做稳很难的典型体现。数环通iPaaS——企业级自动化集成平台1000应用连接器支撑百万级日流程执行。了解更多www.solinkup.com标签#iPaaS #优雅停机 #微服务架构 #K8s滚动更新 #流程引擎 #数环通 #高可用 #企业集成 #分布式系统 #云原生