一个真实的运维噩梦
凌晨两点,运维同学接到告警——某台引擎节点故障,需要紧急重启。这时候机器上正跑着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(count>=20){// 等了60秒还没跑完?启动Plan B...break;}}这里有一个现实的权衡:你不能无限等。
大部分流程执行时间在秒级到分钟级,等60秒(20次 × 3秒)基本能覆盖绝大多数正常流程。但总有例外——比如一条数据同步流程正在处理10万条记录,可能要跑几十分钟。
这时候就进入了Plan B。
Plan B:打快照 + 发恢复信号
对于等了60秒还没跑完的流程,引擎不是直接杀掉,而是执行一个精巧的操作:
- 向流程发送DEPLOY_PAUSE中断信号
- 流程在两个步骤之间检测到信号,主动停下来
- 保存当前执行快照(SnapShot)
- 向消息队列发送一条Resume消息
- 集群中另一个节点消费到Resume消息后,从快照恢复执行
for(StringrootRequestId:rootRequestIdSet){Executionexecution=executions.get(rootRequestId);StringflowId=execution.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(pausedCheckCount>200)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:容器销毁前面所有的Bean
- MQ Producer最后关:因为前面的暂停恢复还可能需要发消息
全局isShutdown标记:防止停机过程中接新活
还有一个细节值得说。在close()方法的第一行:
isShutdown=true;这个全局标记配合引擎的入口逻辑,保证了一个重要的不变量:一旦进入停机流程,即使消费者还没完全关闭(关闭有延迟),也不会再开始新的流程执行。
这是一层额外的防护。在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滚动更新 #流程引擎 #数环通 #高可用 #企业集成 #分布式系统 #云原生