ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

调度系统的灵魂:状态机如何管好任务状态流转

调度系统的灵魂:状态机如何管好任务状态流转 做了几年调度系统如果让我用一个词来形容这类系统的本质我会毫不犹豫地说状态。并发可以用队列压下去性能可以用分片换上来分布式一致性可以套现成的协议但状态管理一旦搞不好前面这些功夫全部白搭。很多朋友把调度系统理解为定时器加任务队列这个理解不能说错但离靠谱还差着十万八千里。一个真正稳的调度系统核心不是到点触发了什么而是任务此刻处于什么状态、能不能迁移到下一个状态而这套逻辑的统一抽象就是状态机。这篇文章是系列的第4篇前几篇我们把调度队列、执行器通信、分布式派发都聊过了。这一篇我想沉下来聊调度系统里那个最不起眼、却最要命的东西——状态机。为什么说它是调度系统的灵魂因为你可以没有花哨的队列、没有极致的性能优化只要状态模型清晰、转移可控系统的下限就有保证反过来状态一乱系统就像一台失控的推土机能把所有业务都推倒重来。哪怕你用的是 Java 的 Spring StateMachine、C# 的 Stateless还是自研的一百行代码核心模型都是那三样状态、事件、转移。这篇文章我会用一个真实的故障场景切入把状态机的建模思路、代码落地、分布式挑战和实战坑位一次讲透。1. 一次事故带来的顿悟任务卡死在一个不存在的状态里1.1 凌晨两点的告警依赖任务整夜没有触发我记得很清楚那是在一次版本上线后的第三天凌晨两点半告警群突然炸了。值班同事甩出一条消息日度报表任务 JOB_PAY_DAILY 从零点开始就没有触发下游的财务对账脚本全部等待中。我第一反应是调度器挂了或者队列堵了。登上调度平台一看任务实例表里这条记录的状态清清楚楚写着RUNNINGworker 节点也有执行时间也有看起来一切正常。但诡异的是我联系负责执行的业务团队对方明确说这个任务今晚根本没有跑到我们这边我们这边连一条接收日志都没有。既不是没有触发也不是执行失败而是名义上在执行实际上没人执行——这是调度系统最让人头疼的一类问题。数据库里的状态是 RUNNING但对应的物理执行早就不存在了任务就这么凭空卡死在一个不存在的状态里。1.2 根因追踪日志还原出来的竟是两个 worker 在打架我拉出了这个任务实例的完整操作日志。不看不知道一看就发现问题了这个任务在零点零分零八秒和零点零分零九秒分别被两个不同的 worker 节点写入了 RUNNING 状态。也就是说两个 worker 几乎同时领取了同一个任务。为什么会这样看代码就明白了。老版本的任务领取逻辑是这样写的TaskInstance task taskMapper.selectById(taskId); if (task.getStatus() TaskStatus.PENDING || task.getStatus() TaskStatus.READY) { task.setStatus(TaskStatus.RUNNING); task.setWorker(workerId); taskMapper.updateById(task); }先查询再判断再更新。这中间隔着网络请求、CPU 调度、数据库往返两个 worker 完全可能同时读到同一个 READY 状态然后都认为自己抢到了任务。后一个更新覆盖了前一个的 worker 信息但两个 worker 手里的任务引用又都是合法的于是同一个任务被执行了两遍——更准确地说是同一时刻被两个节点各执行了一半两边互相不知道对方的存在最后只有一个节点的状态写回了数据库。这个问题的本质并不是并发控制写得不好而是状态转移没有约束。整条链路里没有任何一个环节去校验任务从 READY 到 RUNNING 这个转移是否被允许、是否唯一。状态被当成一个普通字段随意 set谁抢到算谁的。1.3 状态管理的分水岭从状态靠猜到状态机约束那次事故之后我花了不少时间复盘最后得出一个结论调度系统里绝大多数的线上故障最后追到根因都会落在状态迁移没管好这五个字上。下游任务没触发往往是因为父任务状态被错误覆盖任务重复执行往往是因为两个执行器同时完成了 READY 到 RUNNING 的转移任务永远卡住往往是因为一个状态被写入后再也没有事件能驱动它往下走。而状态靠猜的开发模式就是这些事故的共同土壤。修 bug 只能修一个点把状态流转收敛成一套显式的状态机模型才能把这类问题从根上挡住。所谓状态机说白了就三样东西一组状态、一组事件、一张状态转移表。每个状态能接收哪些事件、转移到哪个新状态全部提前定义好。想从 A 状态到 B 状态必须找到一条合法的转移路径否则直接拒绝。这个道理听起来简单但把简单坚持到工程里难度比想象中大得多。后面几节我详细展开。2. 任务的一生调度状态机里到底该有哪些状态2.1 从提交到终态一张表定义任务的完整生命周期先定义状态。调度系统里单个任务实例从被创建到彻底结束我一般划分为六个核心状态外加一个用于重试分支的状态总共七个状态含义可进入途径后续可转移事件PENDING已创建等待校验和入队新任务提交SUBMIT / CANCELREADY已就绪等待被 worker 领取SUBMIT、RETRYDISPATCH / CANCELRUNNING执行中已有 worker 领取DISPATCHSUCCESS / FAILURE / TIMEOUT / CANCELSUCCEEDED执行成功SUCCESS无终态FAILED执行失败FAILURERETRY / CANCELTIMEOUT执行超时TIMEOUTRETRY需要重新派发/ CANCELCANCELLED已取消CANCEL无终态这套状态设计几乎覆盖了调度系统里 90% 的任务流转场景。注意几个细节SUCCEEDED 和 CANCELLED 是真正的终态没有任何事件能从它们继续出发。终态就是终态任务结束就是结束这一条坚决不能松动。FAILED 和 TIMEOUT 不是终态它们需要支持重试。很多初版调度系统把失败当成终点一旦失败只能人工改库这是极其痛苦的设计。PENDING 是准入口状态任务提交后并不是直接可执行而是要经过校验、计算下一次触发时间、写入队列等步骤再通过 SUBMIT 事件进入 READY。这一步是给系统留出来的缓冲地带避免任务一提交就急着运行。这套状态定义里还特意保留了 CANCEL。用户取消一个任务不应该只发生在 PENDING 阶段在 READY、RUNNING 状态下也应该允许取消。取消不是简单的终止——对于 RUNNING 状态CANCEL 事件发出后调度平台要通知执行器做中断清理只有确认清理完成才能真的进入 CANCELLED。如果直接在状态上硬取消执行器那边的进程还在跑就会造成任务已取消但业务还在执行的严重后果。2.2 状态不是拍脑袋定的三条铁律帮你划边界我见过不少团队设计状态机第一个版本永远会出问题。问题往往是三个状态太细上了十几个状态自己都记不住状态太粗两个完全不同的阶段被混在一起或者出现了一个叫OTHER状态的垃圾桶把所有没定义的情况都丢进去。根据我的经验状态划分有三条铁律第一状态必须可枚举、可穷尽。每一个状态都要有明确的语义定义不允许出现其他状态。前面那个事故里的 EXECUTING 就是这么留下来的历史包袱——早期加了一个临时状态后来没人清理代码里到处是status 4这种魔法数字根本不知道 4 是什么意思。第二终态只能进、不能出。一个任务一旦进入 SUCCEEDED / CANCELLED任何人、任何事件都不能把它拉回去。如果需要重跑一个已成功的任务正确做法不是把状态改回 READY而是新建一个任务实例或者单独定义一个重跑操作去创建新实例。改终态等于篡改历史。第三任何一个合法状态都必须是某种事件序列的结果。也就是说你不能用 setter 把状态随便改成某个值每个状态都必须由一个明确的事件驱动而来。这条铁律能逼着你把业务里的每个状态变化都定义成可解释的。将来排查问题看到一条状态记录就能反推出它经历的事件链路。这三条你品一下它其实就是在给系统的状态空间关笼子。状态越多笼子越复杂状态越少越容易把不同语义混在一起。有一个很朴素的判断标准当你设计状态时如果发现需要加注释来解释这个状态为什么存在大概率是划分不合理。2.3 事件从哪来三类驱动源缺一不可状态不会自己变一定是被事件驱动的。调度系统里的事件来源我归纳成三类外部事件用户或上层系统发起的操作。比如用户在控制台点击提交任务取消任务重跑失败任务这些最终会被封装成 SUBMIT、CANCEL、RETRY 事件。这类事件的特点是不可预知随时可能到达状态机必须随时准备好接收。内部事件调度器自己产生的事件。比如定时扫描发现一个 READY 任务到了预定执行时间派发器给它发 DISPATCH 事件再比如监控线程发现某个 RUNNING 任务超过 30 分钟没有心跳发 TIMEOUT 事件。这类事件是系统内部的时钟心跳驱动着任务状态的自动流转。依赖事件任务之间的上下游关系产生的事件。比如一个 DAG 工作流里父节点执行完成会触发子节点从 BLOCKED 变为 READY。这个事件不来自用户也不来自调度器本身而是来自另一个任务的终态。三类事件在设计状态机时必须全部定义清楚。我见过的最常见问题就是状态定义得很完善但忘了定义谁在什么时候产生什么事件导致状态机模型空转——状态流转的规则写在代码里了但没有任何地方去发对应的事件。这就好比给火车铺好了铁轨却没安排列车时刻表。3. 从抽象到代码用极简状态机引擎换掉一坨 if-else3.1 枚举先行让非法状态停留在编译期理论聊完代码直接落地。状态机落地的第一步永远是把状态和事件定义成枚举而不是用字符串或数字魔法值。我用 Java 写但换到 C#、Go、Python 都一个道理。public enum TaskState { PENDING, READY, RUNNING, SUCCEEDED, FAILED, TIMEOUT, CANCELLED } public enum TaskEvent { SUBMIT, DISPATCH, SUCCESS, FAILURE, TIMEOUT, CANCEL, RETRY }这两个枚举的好处是所有非法状态在编译期就暴露了。比如同事想给任务加一个新状态但忘了更新转移表编译器会直接报错。对比一下字符串方案——字符串写错了没有报错只有跑到线上才发现状态永远转不动这就是一个在编译期花十分钟能解决的问题非要用线上一个通宵来还的经典案例。而且枚举天然自带values()后面要打印状态、做统计、画监控面板都非常方便。3.2 注册表代替分支判断状态转移的配置化表达状态机的核心是一张转移表。最朴素的做法是写 if-elseif (state PENDING event SUBMIT) { return READY; } else if (state READY event DISPATCH) { return RUNNING; }状态一多这种代码就是灾难。我的做法是直接用Map构建一张二维转移表。public class StateMachine { // 核心数据结构当前状态 - 事件 - 目标状态 private final MapTaskState, MapTaskEvent, Transition transitions new ConcurrentHashMap(); // Transition 封装目标状态和要执行的副作用动作 public record Transition(TaskState target, Action action) {} FunctionalInterface public interface Action { void execute(TaskContext ctx); } public void register(TaskState from, TaskEvent event, TaskState to, Action action) { transitions.computeIfAbsent(from, k - new ConcurrentHashMap()) .put(event, new Transition(to, action)); } public TaskState fire(TaskContext ctx, TaskState current, TaskEvent event) { MapTaskEvent, Transition row transitions.get(current); Transition transition row ! null ? row.get(event) : null; // 非法转移直接抛出异常绝不静默忽略 if (transition null) { throw new IllegalStateException(非法状态转移: current event); } if (transition.action() ! null) { transition.action().execute(ctx); } return transition.target(); } }然后初始化转移表StateMachine sm new StateMachine(); sm.register(TaskState.PENDING, TaskEvent.SUBMIT, TaskState.READY, ctx - ctx.log(任务通过校验写入调度队列)); sm.register(TaskState.READY, TaskEvent.DISPATCH, TaskState.RUNNING, ctx - { ctx.setWorkerId(ctx.getWorkerId()); // 派发前再检查一次执行参数是否完整 if (ctx.getCronExpression() null) { throw new IllegalArgumentException(执行时间表达式缺失禁止派发); } }); sm.register(TaskState.RUNNING, TaskEvent.SUCCESS, TaskState.SUCCEEDED, ctx - ctx.publish(new TaskSucceededEvent(ctx.getTaskId()))); sm.register(TaskState.RUNNING, TaskEvent.FAILURE, TaskState.FAILED, ctx - ctx.publish(new TaskFailedEvent(ctx.getTaskId()))); sm.register(TaskState.RUNNING, TaskEvent.TIMEOUT, TaskState.TIMEOUT, ctx - ctx.log(任务执行超时进入超时状态)); sm.register(TaskState.FAILED, TaskEvent.RETRY, TaskState.READY, ctx - ctx.setRetryCount(ctx.getRetryCount() 1)); sm.register(TaskState.TIMEOUT, TaskEvent.RETRY, TaskState.READY, ctx - ctx.setRetryCount(ctx.getRetryCount() 1)); sm.register(TaskState.READY, TaskEvent.CANCEL, TaskState.CANCELLED, null); sm.register(TaskState.PENDING, TaskEvent.CANCEL, TaskState.CANCELLED, null);用注册表代替 if-else 的好处是显而易见的转移关系一目了然整个系统的状态流转规则集中在一处新同学接手时扫一眼就能看懂。新增状态/事件不用改引擎只需要在初始化时多注册一行。非法转移可以把错误信息写得很具体抛出什么状态加什么事件非法排查只需要看这一行报错。3.3 引擎核心校验、动作、持久化三件套fire()方法里隐藏了一个重要细节状态机的执行顺序必须是先校验、再动作、后持久化。校验查转移表确认当前状态 事件是否合法。这一步要快纯内存操作。动作执行副作用比如发事件、打日志、做前置检查。动作不涉及状态变更但要为后面的持久化做准备。持久化把目标状态写回数据库这一步必须是条件更新后面第 4 节细讲。动作和持久化之间还有一个易错点动作里抛异常怎么办。我的做法是如果动作执行失败整个fire()就应该失败状态不落库。因为动作是转移合法性的一部分——比如 DISPATCH 动作里要做参数校验参数不合格就不该进入 RUNNING。这个设计保证了状态永远和业务真相一致不会出现状态已经 RUNNING 但参数不合法这种矛盾。有朋友可能会问动作里有远程调用怎么办比如发个 MQ 消息。我的建议是动作里只做最必要的轻量检查重逻辑全部丢到异步消费端。为什么因为状态机引擎的线程不应该被远程调用阻塞。你可以在动作里publish()一个事件到 MQ但真正的下游处理发生在另一个线程、另一个系统里。状态机只负责保证状态流转正确不负责业务执行成功这个边界一定要守住。3.4 回到那次事故为什么这个引擎能挡住重复领取你可能已经注意到了上面这段代码最关键的地方在于它把 RUNNING 的唯一入口定义成了 READY DISPATCH 事件。任何代码都只能通过fire()来改变状态而fire()会严格校验当前状态。回到第一次事故的场景两个 worker 同时抢一个 READY 任务。用上状态机引擎 条件更新后会发生什么worker-1 执行fire(ctx, currentState READY, DISPATCH)校验通过进入动作、持久化把数据库里的任务从 READY 更新为 RUNNING。worker-2 执行同样的fire(ctx, currentState READY, DISPATCH)在持久化这一步条件更新发现数据库里的状态已经变成 RUNNING 了不再是 READY更新影响行数为 0于是这个 worker 必须重新读取最新状态发现任务是别人的了直接放弃本次领取。看到没状态机的合法性约束加上数据库的条件更新两个机制一配合重复领取的问题从根上被堵死了。这也是我为什么反复强调状态机的灵魂最后要落到持久化的原子性上。4. 分布式调度下的状态机超时、重试、并发一个都不能少4.1 超时事件状态机里的隐形时钟单机状态机好做分布式调度系统里的状态机才能真正暴露问题。第一个隐蔽的难点就是超时。任务进入 RUNNING 之后如果 worker 进程被杀、网络分区、执行器宕机谁来把它从 RUNNING 状态里捞出来答案是调度器必须有一个独立的超时检测机制像隐形时钟一样持续扫描。两种主流方案方案一延迟队列在内存里做定时。任务从 READY 派发到 RUNNING 时同时往一个DelayQueue里塞一个 30 分钟后的检查任务。30 分钟后从队列里取出来看看这个任务还在不在 RUNNING——还在就发 TIMEOUT 事件。优点是精确到毫秒级别缺点是调度器节点挂了内存里的延迟队列也丢了需要有一个兜底机制来接盘。方案二数据库扫表对账式兜底。定时任务每隔一分钟跑一次查询SELECT id, task_id, status, update_time FROM task_instance WHERE status RUNNING AND update_time DATE_SUB(NOW(), INTERVAL 30 MINUTE)扫出所有超过 30 分钟没有更新的 RUNNING 任务逐个发 TIMEOUT 事件。这个方案的优点是依赖数据库天然支持分布式缺点是扫描有延迟、有数据库压力。我的生产实践是两者混用延迟队列作为主触发扫表作为兜底对账。延迟队列的检查漏掉了比如调度器恰好重启扫表一定能捞回来只是晚了最多一分钟。还有一个坑发 TIMEOUT 事件不意味着立刻把状态改成 TIMEOUT。如果 timeout 后 worker 还在执行比如任务就是跑得慢你把状态改了worker 那边回来上报 SUCCESS 就会矛盾。所以正确流程是TIIIMEOUT 事件发出后先通知执行器尝试中断等执行器确认中断成功或者上报了超时信息之后才允许从 RUNNING 迁移到 TIMEOUT。这个确认再迁移的细节能避免大量脏状态。4.2 失败重试链路允许状态回退但要显式设计调度系统里任务是会失败的。失败之后要不要重试、怎么重试这在状态机里必须显式设计。我这里给一套比较通用的策略重试次数限制一个任务最多重试 3 次超过 3 次不允许再走 RETRY 事件只能人工干预。重试间隔策略失败后第一次重试延迟 30 秒第二次延迟 5 分钟第三次延迟 30 分钟。这个指数退避的间隔可以避免失败任务集中在一个时间点把系统打爆。重试前置条件某些任务是幂等的可以随便重试某些任务有外部副作用比如已发短信重试必须确认上次发送是否真的没成功。前者允许自动 RETRY后者要跳到 WAITING_CONFIRM 状态等人确认后再 RETRY。对应到状态机里FAILED 状态并不是终点它通过 RETRY 事件回到 READY。每个任务的实例上还要记录retry_count和retry_reason这是排查重试问题的重要信息。重试还有一个反直觉的点失败重试不该把同一个任务实例状态直接改回 READY更合理的做法是创建一个新的执行实例新的 task_instance_id但共享同一个业务任务 ID。这样每次执行的历史都保留下来查问题时你能看到任务 10086 的实例 001 失败了实例 002 重试成功。如果你的系统里只有一个实例 ID失败后改状态重跑历史就丢了。4.3 条件更新状态机约束在数据库里的落点前面第三节的 engine 代码只是改了个内存变量真正让状态机在分布式环境下生效的是持久化这一步必须做成条件更新Conditional Update。逻辑长这样UPDATE task_instance SET status #{targetState}, worker_id #{workerId}, retry_count #{retryCount}, version version 1, update_time NOW() WHERE id #{taskId} AND status #{expectState} AND version #{expectVersion}执行这条 SQL影响行数为 1代表转移成功影响行数为 0代表当前状态已经变了转移失败需要读取最新状态重新决策。这个设计里WHERE 子句中的 status #{expectState} 就是状态机在数据库层面的投影。每次状态变更都必须在持久化时声明我期望从什么状态来数据库帮你保证如果不是这个状态你这次转移作废。配合上version字段也建议加上。status 和 version 双重校验几乎能挡住所有并发状态覆盖问题。我在复盘第一起事故时最大的懊悔就是当时只更新了 status没做条件约束version 字段甚至都没建。一张表设计得好不好看它敢不敢重启、敢不敢并发写心里就有数了。4.4 不止单任务DAG编排和选主机制里也有状态机聊到这儿你可能以为状态机只管单个任务实例。其实调度系统里的状态机是分层次的。第一层任务实例状态机。就是我们前面讲的那七个状态。这是最基础的一层。第二层DAG 工作流状态机。一个工作流有多个节点节点之间有依赖关系。每个节点有自己的状态但整个工作流也有整体状态。父节点集合全部成功时子节点才能从 BLOCKED 变成 READY。这个什么时候放行子节点的逻辑本质上也是一个状态机——不过是把所有父节点是否完成压缩成了一个计数。父节点每完成一个计数减一减到零子节点解锁。这个计数就是一种隐式状态。第三层调度器节点自身状态机。一个高可用的调度平台通常有主备节点。节点在 STANDBY、ACTIVE、INACTIVE 之间切换这也是状态机。选主成功后从节点被切换为 ACTIVE开始对外提供派发服务主节点失联后经过租约超时重新进入选举状态。这三层状态机叠在一起才是调度系统完整的状态管理体系。很多人觉得调度系统复杂其实就是这三层状态机纠缠在一起互相触发线上排查问题时一个状态不对可能牵出另外两层的连锁反应。理解了这一点再看调度系统的架构就会清晰很多——不管界面多花哨底层就是一套状态 事件的流水线。5. 状态机不是万能药三个典型坑与一套排查招式5.1 坑一把重逻辑塞进转移动作状态机被拖死这是我见过最多、也最容易犯的错。有人会把状态机的动作写得特别重——在 SUCCESS 动作里直接调下游系统、写业务表、循环处理几千条数据。结果状态机从一个轻量调度引擎变成了业务重计算引擎。后果是什么状态机线程被业务逻辑占住后面的状态事件排不上队调度吞吐直线下降。更危险的是如果业务逻辑抛了异常状态转移就失败了任务永远停留在当前状态产生新的卡死。我现在的铁律是动作里只做三类事情——更新关键状态字段、发布轻量级事件、写入状态流转日志。任何需要几十毫秒以上的逻辑一律丢到异步队列或者订阅端去处理。状态机要快要薄要像门卫一样只负责放行与否的决策而不是既当门卫又当搬运工。5.2 坑二为了状态机而状态机把简单问题复杂化也有反方向的坑。有些团队看了这篇文章类似的教程热血沸腾恨不得把系统里每个 boolean 都改造成状态机。这是过度的。状态机适合什么样的场景我的判断标准有三条状态数量 3且状态之间有分叉和汇合——纯二元的 true/false 用 boolean 就很好状态转移不是单向的——有回退、重试、取消等分支多个并发线程或节点可能修改状态——需要约束来消除竞争。如果一个系统只有未处理 - 已处理两个状态你强行上状态机唯一的收获是代码多了一堆类和接口。所以状态机不是越多越好是恰到好处才最好。还有一个相关的坑无限状态别用有限状态机。比如要表达当前任务执行进度百分比这种连续值FSM 表达力就不够了。这种情况要么用状态图Statechart做层次化建模要么用工作流引擎的数据加状态双重表达不要硬套 FSM 的壳。5.3 排查利器状态流转历史日志表前面说了状态机的运行机制但如果真出问题了怎么快速定位我有两个经验第一给每张任务实例表配一张状态流转日志表。表结构长这样字段说明id自增主键task_id业务任务 IDinstance_id任务实例 IDfrom_state转移前状态to_state转移后状态event_name触发事件名称source事件来源调度器/worker/用户/定时扫描trace_id链路追踪 IDextra_dataJSON 扩展信息如重试次数、worker 节点等create_time流转时间出了任何状态问题查这张表SELECT * FROM state_transition_log WHERE instance_id xxx ORDER BY id ASC一条时间线拉下来任务从 PENDING 到 RUNNING、再到 FAILED 的完整路径全部展现在眼前比翻业务日志高效十倍。这张表本质上就是状态机的黑匣子没有它排查状态类问题就像戴着墨镜在晚上开车。第二所有通过fire()产生的状态变更必须写入这张日志表任何绕过状态机的直改数据库操作都要在日志里标记为MANUAL_OVERRIDE并记录操作人。一旦发现日志里有手工修改产生的异常链路立刻追责并推动流程整改。这个细节能在团队里强制形成状态只能通过状态机修改的共识。5.4 坦率讲状态机保证不了消息不丢也保证不了业务一定对最后说几句清醒话。状态机解决的是状态一致性问题但它不是银弹有三件事它管不了动作里的消息可能丢。你在 SUCCESS 动作里publish()了一个事件MQ 可能因为网络抖动、磁盘满等原因把消息搞丢。状态机不保证消息必达。所以真正稳的调度平台除了状态机还要有对账机制——定期扫描任务状态 SUCCEEDED 但下游事件未消费完成的数据补偿触发。状态为成功不代表业务真成功。worker 上报 SUCCESS 时业务系统可能在最后一步写数据失败了但没被捕获。状态机只能保证状态流转符合规则业务正确性需要业务自己的检核。幽灵事件无法彻底避免。网络重试可能导致同一个事件到达两次。所以事件消费端必须有幂等处理比如用事件 ID 去重避免同一个 SUCCESS 被处理两次下游任务被重复触发。这四节内容加在一起基本把一个调度系统中的状态机从建模到落地、再到分布式高可用的完整链路讲完了。状态机不是什么高深算法它就是一套把状态变化管起来的纪律。没有这套纪律调度系统跑再多的任务也只是在给未来的故障埋雷——因为只要状态一乱整个系统的行为就变得不可预测而不可预测是分布式系统最不可接受的事情。
返回列表