1. 项目溯源与V4.0架构的核心设计
1.1 为什么叫“马年Freestyle”
先说清楚“马年Freestyle”这个代号是怎么来的。这个项目最早立项在2014年,那年是马年,当时我对架构的认知还处于“只要不把系统写死怎么都行”的浪漫阶段,所以起了“Freestyle”这个名字——自由发挥、别被框架框死。后来的事实证明,这个代号起得很有先见之明,因为这套系统在十年里经历了三四次结构性调整,每次都在往更自由、更不约束业务表达的方向走。
项目本身做的是多算法融合图像处理框架,这名字听起来学术,本质上就是一件事:把边缘检测、形态学处理、阈值分割、特征提取、颜色分析这些算法统一封装起来,让使用方通过配置自由组合,形成不同的图像处理管线,处理工业质检、遥感影像、文档扫描等多种场景的图片。
最早的原型其实是MATLAB写的,在MATLAB里用OOP封装了一批算法类,通过继承和多态来抽象“算法”这个核心概念。这个原型在学术实验环境下跑得很顺,但一拿到工程场景就露馅了——处理速度慢、内存占用大、依赖环境重,完全没法嵌到服务端或者嵌入到桌面端工具里。所以从V1开始用Java重写,一路迭代到V4.0才真正稳定下来。V4.0之所以叫“专业终版”,是因为当时功能边界已经收敛,四十多个算法全部封装完毕,性能也优化到位,我们一度以为这套系统不会再有大动了。
回头看,V4.0确实是那个阶段最合理的答案,但“最合理”只代表“符合当时的约束”,不代表它抗得住未来。这篇文章想完整拆解的就是这条脉络:V4.0怎么设计、哪里撑不住了、V5.5用了哪些核心手段解决这些问题、以及迁移过程中踩过的坑。
1.2 V4.0的三层架构实践
V4.0的架构非常标准,就是教科书上那种三层架构:表现层、业务逻辑层、数据访问层。表现层提供REST API和简单的Web管理界面;业务逻辑层负责算法调度、管线编排、任务管理;数据访问层处理文件存储、数据库读写、缓存缓存管理。
当时采用三层架构,不是因为我们保守,而是经过比较之后做出的选择。系统当时的核心诉求只有一个:把算法原型变成一个能稳定跑、能对外提供服务的工程系统。围绕这个诉求,三层架构的成熟度最高,团队维护成本最低,周围能参考的代码和经验也最多。
我后来经常被问到一个问题:为什么当时不直接上六边形架构或者端口适配器架构?我在V4.0那个阶段给的答案很直接——团队规模配不上架构复杂度。六边形架构的核心优势在于把核心业务逻辑与外部依赖彻底隔离,方便适配不同输入源、输出目标和存储方式,但这个系统的业务逻辑并没有复杂到需要这种抽象层级。我们只有四个人维护代码,六边形那套端口、适配器、应用服务的概念要全员理解,前期骨架搭建成本和学习成本都会翻倍,而当时最缺的恰恰是时间。
三层架构当然有它明显的天花板,最典型的问题就是业务逻辑层会逐渐膨胀,所有用例都往里塞。但在系统功能稳定、算法数量可控的V4.0阶段,这个天花板我们还没撞上。选择三层架构是我个人在“平衡地选择平庸”和“冒险地追求先进”之间反复掂量后做的决定,结论是:架构演进要先解决主要矛盾,当时的矛盾是“系统要稳定跑起来”,不是“系统要应对未知变化”。
1.3 多算法融合的V4.0实现方式
V4.0的多算法融合核心设计是“注册表 + 串行管线”。所有算法统一实现一个Algorithm接口,通过名称注册到算法工厂,管线负责把若干算法节点按顺序串起来执行,后一个节点的输入是前一个节点的处理结果。
当时算法接口的设计大概是这样的:
public interface Algorithm { String name(); AlgoResult execute(AlgoContext context, AlgoConfig config); }每个算法只需要实现execute方法,然后在配置文件中指定名字、参数和执行顺序,AlgorithmContext在整个管线里作为数据传递的载体,前一个算法写进去的结果,后一个算法取出来继续处理。
这套设计支撑了大概两年的真实业务。它的优势很直观:新加一个算法不需要改动管线核心,注册进去就能用;算法组合通过配置就能切换,不用重新编译;每个算法都是独立类,单元测试也好写。
但坏账也从这个AlgoContext开始积累。它本质上是一个巨大的Map容器,什么都能往里塞,时间一长,没人能说清楚里面到底有哪些key、每个key是什么类型、是谁在什么阶段写进去的。这种“隐式依赖”成为V4.0后期最让人头疼的问题,算法之间只要存在数据交换,就必须通过这个黑盒传递,一旦数据格式变化,运行期才报错,排查成本极高。
2. V4.0在真实场景中暴露出的问题
2.1 扩展性瓶颈:新增组合必须改核心代码
V4.0最让我难受的问题出现在“新增算法组合”这个环节。串行管线的表达能力极其有限——固定路径上只能一条直线走到黑,例如“边缘检测 → 形态学处理 → 连通域分析 → 特征提取”这个顺序写死之后,用户需求一旦变成“阈值分割 → 边缘检测 → 特征提取”,研发人员就要改Pipeline核心代码,然后重新打包发版。
算法数量少时还好,一旦有几十个算法,排列组合成百上千,靠硬编码管线根本管不过来。我当时做过一个统计,V4.0后期的代码里,Pipeline相关的if-else分支超过两百处,每个分支对应一种固定的算法组合。这表面上是代码质量问题,本质上是架构的表达能力见顶了——串行管线只能表达线性关系,无法表达算法之间的复杂数据依赖。
真实业务里最典型的场景是工业质检:先做光照校正,再做ROI提取,然后做缺陷检测。ROI提取之前想用边缘检测辅助定位,但ROI模块本身又依赖光照校正的结果。这种“一个节点依赖两个上游结果”的菱形依赖关系,在串行管线里完全无法表达,只能强行拆成两步甚至三步,中间用临时文件或数据库表做中转,又慢又乱。
2.2 单机资源瓶颈与大图内存压力
V4.0用单台服务器多线程跑批处理任务。工业相机的分辨率一上来,单张图片普遍在5000万像素以上,压缩后也有几十MB,处理一张图的内存开销经常冲到2到3GB。单任务还好,多任务并发提交时,内存和CPU争抢非常明显,系统经常因为GC停顿导致任务超时。
我们当时的应对方案是Java线程池加并发限制,简单粗暴,但无法真正解决资源隔离问题。一个任务卡死了,线程池里的其他任务也会被拖住。更麻烦的是,这种情况没法通过加机器来解决,因为V4.0压根没有分布式任务调度的能力,所有工作必须在一台机器上完成,单机上限就是系统上限。
还有一类性能问题是算法的GPU调用与CPU计算混在同一节点内,导致算法线程和调度线程互相抢占资源。虽然V4.0做了线程池隔离,但经过实际压测,在大图多任务场景下,吞吐量还是会被最慢的算法拖累——木桶效应在单机架构里表现得淋漓尽致。
2.3 配置与运维的僵化
V4.0的算法参数全部写在XML配置文件里,修改参数必须重启服务。运维人员要调整一个检测阈值,需要走完“改配置 → 重新打包 → 分发 → 重启”整套流程,前后耗时至少半个多小时。这在离线批处理时代勉强能忍,但业务方一旦提出实时性需求,就彻底不行了。
另一个痛点是配置缺少版本管理。配置文件是文本文件,发版时容易覆盖,回滚时容易丢失,曾经出现过生产环境参数被错误覆盖导致连续三天检测结果偏差的问题。后来虽然加了配置备份机制,但整个配置体系仍然带着“静态”和“手工”的烙印,对系统灵活性的拖累越来越明显。
这些问题的本质,是V4.0把“算法逻辑”和“算法编排”耦合在代码里了。配置只是参数的载体,不是流程的载体。要想让新组合、新依赖、新流程不需要发版就能生效,必须把“编排”本身从核心代码中剥离出来。
2.4 为什么选择演进而不是推倒重写
面对这一堆问题,团队内部有过两轮重大讨论。
第一轮讨论是“要不要推倒重来”。结论很明确:不行。V4.0积累了四十多个算法,每个算法都包含大量的参数调优经验和真实业务验证,这些是花了好几年才磨出来的资产。重写意味着所有算法要在新系统里重新验证一遍,时间成本不可接受,而且很容易因为“重写滤镜”丢掉老代码里那些微妙的边界处理。
第二轮讨论是“要不要顺手切微服务”。这个更诱人,毕竟业界都在讲分布式和微服务是最优解,但理性分析后还是否了。算法平台的核心逻辑是大量计算节点按照特定数据流协作,如果按微服务拆,每个服务都要维护自己的算法库和依赖,部署复杂度瞬间翻倍;而且微服务解决的独立发布、独立扩容、多团队自治等问题,我们这四五个人的维护团队压根用不上。
所以最终定下的方向是:保留算法资产,重构系统骨架。目标不是搞一个更时髦的技术栈,而是把“修改和扩展”的成本降下来,把系统的表达能力从“线性串行”提升到“图表达”。这个方向贯彻到了V5.5,没有跑偏。
3. V5.5架构的设计思路与关键进化
3.1 从三层架构到微内核加插件化
V5.5最核心的变化,是把原来“业务逻辑层”大包大揽的局面打破,改成了微内核加插件化的结构。
微内核在这里不是指操作系统那种微内核,而是从软件架构层面借鉴的思路:内核只保留最基础的能力,其余一切通过插件扩展。V5.5的内核层级分得很清楚:
- 核心层:负责节点调度、事件总线、配置管理、生命周期管理,所有代码加起来不到三千行。
- 扩展层:所有算法插件、数据源插件、输出目标插件,都通过标准接口挂载到内核上。
- 接入层:REST API、消息队列消费者、批处理入口、定时任务,都只是接入插件之一,不再是系统的固定模块。
这个设计与传统三层架构的本质区别在于:三层架构按照“职责”切分代码,微内核架构按照“扩展方式”切分代码。在三层架构里,新增一个算法要改业务逻辑层、数据访问层,还可能动表现层;在微内核架构里,新增一个算法只需要写一个插件类并注册,其他任何层都不需要改。
我把V4.0和V5.5的架构做了一次完整对比:
| 对比维度 | V4.0 专业终版 | V5.5 进化完整版 |
|---|---|---|
| 架构风格 | 三层架构 | 微内核 + 插件化 |
| 流程表达 | 串行管线 | DAG图编排 |
| 算法扩展 | 注册新算法、改管线 | 插件注册、图配置 |
| 算法通信 | 共享Context黑盒 | 事件总线 + 节点输入输出 |
| 部署方式 | 单体单机 | 单机/分布式可选 |
| 配置方式 | XML静态配置 | YAML动态配置、热加载 |
| 并发粒度 | 线程池并发 | 节点级并行 + 分布式调度 |
| 运维流量 | 重启生效 | 配置即时生效 |
表格列出来之后,差异一目了然。V5.5不是简单加了几个新功能,而是把架构的“表达维度”换了。
3.2 从串行管线的DAG图编排
解决菱形依赖问题的核心方案,就是允许算法之间的依赖关系构成一个有向无环图。每个算法节点可以有多个输入、多个输出,节点之间的连线代表数据依赖,调度器根据DAG的拓扑顺序执行,没有依赖关系的节点可以并行处理。
DAG相比串行管线的核心优势有三层:
第一,表达能力更强。任何算法组合都能表达,只要不构成环,节点之间可以是链式、菱形、扇出、扇入任意形态。
第二,执行效率更高。没有依赖关系的节点天然可以并行执行,不需要人为指定线程池策略。比如“边缘检测”和“颜色分析”同时依赖“光照校正”的输出,它们就可以并行跑,这在串行管线里只能排队。
第三,配置和代码分离更彻底。DAG的结构可以用外部配置文件描述,核心代码不需要知道具体执行哪几个算法。新增一个处理流程,只需要新增一个配置文件,不需要改任何Java代码。
调度器的核心逻辑其实不复杂:
public class DagScheduler { private final Map<String, GraphNode> nodes; private final Map<String, Set<String>> dependencies; public void execute(String entryNodeId) { // 拓扑排序,得到无依赖节点的执行顺序 List<String> sorted = topoSort(entryNodeId); for (String nodeId : sorted) { GraphNode node = nodes.get(nodeId); if (node.isReady()) { // 输入就绪时,提交到线程池或远程执行器执行 executor.submit(() -> node.execute()); } } } }这段代码看着简单,实际落地时最大的坑就是“节点就绪判断”不能只判断“上游是否执行完”,还要判断“上游的输出数据是否完整、版本是否正确”。数据版本的问题在后面踩坑环节会专门讲。
3.3 事件驱动架构的解耦价值
V5.5里内核和插件不直接调用,而是通过事件总线通信。算法节点执行完毕后发布一个事件,调度器订阅这个事件,判断相关节点的输入是否全部就绪,就绪则触发执行。
事件驱动带来的一个很重要的副产品是:插件之间彻底解耦了。V4.0里算法A直接调用算法B的场景比比皆是,调用关系一旦硬编码,改一个算法的接口签名,所有调用方全部要跟着改。事件驱动之后,算法A不知道算法B的存在,它只负责“我完成了,发布了事件”,至于谁需要这个结果,由调度器去查询DAG来决定。
我用一个生活化的类比来理解这个变化:V4.0的模式是领导安排工作,每个人只知道自己的直接领导是谁,上面的指令要一层层传达;V5.5的模式是公司内部用公告板,各部门做完工作就贴公告,需要这个结果的部门自己来取。公告板的好处是部门之间不需要认识,部门重组也不影响别人取结果。
当然,事件驱动也带来了新的挑战,比如事件风暴、消息乱序、订阅关系管理,这些会在第五节详细说。但整体来说,V5.5的解耦收益远远大于这些额外成本。
3.4 分布式调度能力
在DAG和事件驱动的基础上,调度器可以随时知道某个节点的输入是否已经就绪。只要输入就绪,这个节点就可以交给远程计算节点执行,而不限于本机。这个设计让分布式变成了一个配置选项,而不是架构改造。
分布式调度的实现借鉴了经典分布式计算的思想:把任务拆分成子任务,分发到多个执行器,结果汇总后进入下一个阶段。具体到V5.5里,每个算法节点都可以打上“可远程执行”的标签,调度器发现这个标签后,就把节点数据和输入数据序列化后发往远程执行器的消息队列,执行器算完再写回结果存储,并发布事件通知调度器。
这样做的好处是资源可以按需扩展。单张图片处理跑不过来,就开五个执行器并行;算法间并行度高的任务,吞吐量几乎跟着执行器数量线性增长。这个能力在V4.0时代是完全不敢想的。
但分布式也引入了一个问题:本地内存共享变成网络传输。V4.0里算法A直接把结果塞进AlgoContext,算法B从内存里取,这几乎零成本;V5.5里如果两个节点部署在不同执行器上,结果必须经过序列化、网络传输、反序列化。这个成本在大图场景下不可忽视,所以V5.5做了一个很务实的优化:同进程内的节点仍然走内存共享,只有跨进程节点才走序列化传输。后面在性能优化部分会详细说这个决策。
4. V5.5核心模块的实现细节
4.1 核心接口设计
V5.5里最核心的两个抽象接口是GraphNode和EventBus。
GraphNode是所有算法节点的统一接口,设计如下:
public interface GraphNode { String id(); List<String> inputs(); List<String> outputs(); void execute(NodeContext context); }inputs声明这个节点依赖哪些上游数据,outputs声明这个节点会产生哪些输出。调度器通过这两个集合判断依赖关系,运行时通过NodeContext获取输入数据、写入输出数据。
EventBus的简化设计如下:
public interface EventBus { void publish(Event event); void subscribe(String topic, EventHandler handler); }算法节点执行完,调用eventBus.publish(new NodeCompletedEvent(nodeId))。调度器在初始化时,对每个节点都订阅其所有上游节点的完成事件,一旦发现某个节点的所有上游都已完成,就触发它执行。
这个设计最大的价值在测试阶段。一个算法节点可以在完全不依赖其他算法的情况下独立测试,只需要构造一个NodeContext,塞入假数据,然后断言输出即可。V4.0时代想要单独测一个算法,必须先把整条管线搭起来,哪怕其他算法跟被测算法毫无关系。
4.2 配置驱动:DAG的YAML描述
V5.5的流程编排全部由YAML配置驱动,DAG结构和参数完全外置。一个典型的工业质检流程配置长这样:
nodes: - id: illumination type: "algo:lighting-correct" params: mode: "homomorphic" - id: roi type: "algo:roi-extract" dependsOn: [illumination] params: method: "edge-assisted" - id: edge type: "algo:edge-detect" params: operator: "canny" - id: detect type: "algo:defect-detect" dependsOn: [roi, edge] params: sensitivity: 0.8注意edge节点没有dependsOn字段,它默认没有上游依赖,可以立即执行;detect节点依赖roi和edge两个结果,只有这两个节点都完成才会触发。这种表达在V4.0的串行模型里几乎是不可能的。
配置加载后,系统会自动构建DAG,校验是否存在环、是否存在缺失依赖,然后交给调度器执行。校验逻辑虽然不复杂,但非常重要,至少避免了一大类配置错误在运行时才炸雷。
4.3 多算法融合的真实案例:从V4.0到V5.5的对比
以我们实际做过的一个光学字符检测流程为例,完整过程是:光照校正 → 二值化 → 边缘检测 → 字符区域分割 → 字符识别 → 结果结构化输出。
V4.0只能用串行管线表达这条路:每个步骤依次执行,没有并行,没有旁路。但真实的业务场景里,字符区域分割其实需要同时看边缘检测的结果和二值化的结果,才能准确定位字符边界。V4.0只能把“边缘检测”的结果通过Context传给“字符区域分割”,然后“二值化”的结果也通过Context传,最后在“字符区域分割”前面做一次上下文判断。这里的问题在于:到底是边缘检测先生成还是二值化先生成,在V4.0里是由Pipeline写死的顺序决定的,一旦搞错了顺序,整个流程就乱了。
V5.5就没有这个问题。配置里字符区域分割节点的dependsOn可以同时写[edgeDetection, binarization],调度器保证只有两个输入都就绪才执行,顺序问题根本不需要关心。如果未来要加一个“倾斜校正”节点,并且它同时依赖“边缘检测”和“字符区域分割”的结果,只需要在配置里加节点和依赖,代码零改动。
这就是我在这篇分享里反复强调的:V5.5真正的进化不是某个算法的精度提高了,而是流程表达的复杂度从系统负担变成了配置内容。
4.4 与几种主流架构思路的对照
很多朋友看了V5.5的设计,第一反应是“这跟微服务有什么区别”,第二反应是“这跟工作流引擎有什么区别”。我在这里把这几个概念理清楚。
微服务强调的按业务能力拆分为独立部署单元,解决的是团队协作和独立扩容的问题。V5.5的插件化强调的是按算法能力拆分为扩展单元,解决的是组合灵活性和代码解耦的问题。两个可以共存,V5.5里的分布式执行器如果进一步拆分部署,其实就是微服务了,但目前没有必要。
工作流引擎强调的是人工审批流、状态流转、事务补偿;V5.5强调的是数据驱动的算法调度。V5.5的DAG是数据依赖图,不是流程状态机。用工作流引擎做算法调度不是不行,但会引入很多与计算无关的复杂度。
六边形架构强调的是核心业务逻辑与外部依赖隔离,这一点V5.5其实是吸收了它的思想,只不过把“端口适配器”细化成了“插件注册机制”。可以说V5.5不是简单回到三层,而是站在六边形的肩膀上做了一套更切合算法领域的实现。
5. 迁移过程中的踩坑与实战排查
5.1 老算法的兼容适配
从V4.0迁移到V5.5,第一个绕不过去的问题就是那四十多个老算法怎么办。每个老算法都实现了V4.0的Algorithm接口,execute方法从AlgoContext这个Map里取值。V5.5的GraphNode接口完全不一样,输入输出声明方式也不一样,如果强行让老算法全部重写,迁移成本会直接爆表。
我们的处理方式是写了一个适配层,把GraphNode包装成老算法能识别的形态:NodeContext被适配成AlgoContext的Map,老算法从Map里取值的行为完全不变。新算法直接实现GraphNode接口,老算法通过适配器运行。这个适配层帮我们省了至少一个月的改造量,但也埋了一个隐患:老算法不声明inputs和outputs,适配器只能从算法代码里反推,遇到依赖不明确的老算法时,配置DAG会比较痛苦。
如果现在让我重新做一次,我会在迁移前花几天时间把所有老算法的输入输出依赖梳理成一张清单,哪怕有些算法已经没人用了,也要梳理清楚再迁移。这个代价很值得。
5.2 事件风暴:订阅粒度太粗
V5.5刚上线时,我们遇到了一个隐蔽的性能问题:系统空跑时CPU占用居然有50%,没有任何任务在执行。
排查过程很有意思。先看线程栈,发现大量线程都在EventBus的handleEvent方法里,再往下一看,每个节点都订阅了全局的“节点完成”事件,一个事件发布出来,几百个订阅者同时被唤醒,但绝大多数订阅者判断后发现“跟我没关系”,空转一轮。
这就是典型的事件风暴。解决办法是给事件添加主题分类,订阅时只订阅相关主题,而不是全量订阅。比如缺陷检测节点只订阅“图像预处理完成”和“边缘检测完成”这两个主题,其他事件根本不会触发它。结果CPU占用从50%降到了3%,几乎可以忽略。
这个坑给我的教训是:事件总线不是万能的,如果把所有依赖关系都掩盖在事件流里,排查问题的难度比直接函数调用还高。事件可以解耦,但不能抹掉依赖关系的可见性。
5.3 消息乱序与数据版本问题
分布式执行刚接入时,我们遇到一个非常诡异的bug:同一个任务跑两次,结果不完全一致,但每次跑完单独看每一步的输出都正常。
查了很久才发现,问题出在并行节点的结果归并上。两个并行节点同时完成,它们的输出结果在汇总阶段的到达顺序不确定。第二个节点先到、第一个节点后到,有的算法会把第二个节点输出覆盖到第一个节点的位置,导致结果错乱。
解决办法是给每个节点的输出加一个“数据版本号”,这个版本号由调度器统一分配,节点执行时带上版本号,汇总时按版本号排序而不是按到达时间排序。这个机制彻底解决了乱序问题,也顺带解决了重试场景下的数据覆盖问题——某节点执行失败重试时,旧版本的数据不会被新任务误用。
5.4 小图的调度开销反而比串行慢
DAG调度器刚完成时,我们做了性能对比测试,结果让人大跌眼镜:节点数少于4个的小图,V5.5的执行效率比V4.0串行管线还慢,慢差不多两倍。
原因是每个节点从“事件发布”到“调度器判断就绪”再到“提交执行”,中间有三次事件总线的完整传递,加上DAG构建和校验的开销,这些在节点多的时候可以摊薄,但在节点少的时候,开销占比就非常高了。
我们最后做了一个“小图直通”优化:DAG节点数小于4个时,不走事件总线,而是按拓扑排序后的顺序直接执行。这个优化让小型任务的执行时间缩短了接近60%,几乎恢复到了V4.0的水平。这个案例也让我意识到,架构设计的复杂度要随着任务规模的复杂度动态调整,一刀切地追求“更先进”往往得不偿失。
6. V4.0到V5.5的合并定稿与迁移实践
6.1 什么是真正的“合并定稿”
V5.5的版本号后面加上了“合并定稿”,这不是一个形式化的措辞,而是有实际含义的。
所谓合并,指的是V4.0积累的算法资产、参数调优结果、稳定API全部合并进了V5.5的骨架里,而不是被丢在新系统外重写。所谓定稿,指的是V4.0冻结为legacy分支,只修不扩;V5.5成为唯一的主线版本,所有新功能和迭代都在这一条版本线上进行。
合并定稿之后,代码仓库的结构也做了调整。原来V4.0时代很多历史遗留的工具脚本、实验代码全部移入archive目录,不再参与构建。主仓库的模块结构按内核、插件、接入层三个层级重新组织,每个插件是一个独立模块,可以单独构建和发布。
这个动作的深层价值是:把“存量资产”和“未来增量”用明确的边界分开了。存量归存量,增量归增量,不会因为新架构的改动破坏了老功能的稳定性,也不会因为老功能的包袱拖慢了新架构的节奏。
6.2 迁移路线:双轨运行与灰度切换
从V4.0切到V5.5,我们采用了双轨运行加灰度切换的策略,整个过程大约持续了三个月。
第一步,V4.0继续跑存量任务,一条任务都不动。新接入的业务全部配置到V5.5上跑,通过配置中心维护两套流程定义。
第二步,对比验证。同一批样本图,V5.5和V4.0各跑一遍,逐像素比对输出结果。图像处理领域的对比验证要特别细心,因为浮点计算的微小差异可能导致结果不完全一致,我们设置了一个置信度阈值,超过阈值的任务才标记为异常。
第三步,在验证通过的流程上,把流量从V4.0切到V5.5,先切10%,再逐步提升到50%、100%。切换时观察指标不只是算法准确率,还包括处理延迟、内存占用、异常率、CPU负载,任何一个指标劣化超过5%就自动回滚。
第四步,稳定运行一个月后,冻结V4.0的配置管理入口,进入只读模式。存量历史任务的数据和日志保留,供审计和回溯使用。
6.3 架构治理文档与版本规范
经历过这次大版本进化之后,我把架构治理文档的体系建设提上了一个优先级。有几类文档现在回头看是真正起作用的。
第一类是ADR(架构决策记录),每次重大架构决策都记录一条,包含背景、选项、决策、后果。比如“为什么从三层架构切换到微内核”“为什么不用工作流引擎做DAG调度”“为什么分布式执行器只做计算不做存储”,这些决策如果只有口头记忆,半年之后就会失真。
第二类是数据流图,不是静态的架构分层图,而是描述数据从输入到输出经过哪些节点的动态图。V5.5改的每一次版本,数据流图都跟着更新,排查问题时第一件事就是查数据流图。
第三类是版本规范。主版本表示架构级变化,功能版本表示算法和功能迭代,补丁版本表示缺陷修复。V5.5之所以定稿为V5.5而不是V5.0,就是因为从V5.0到V5.5之间经过了五轮功能迭代和修复,每一轮都有独立的发布记录。
我个人在实际操作中还有一个体会:架构文档不是写给评审专家看的,是写给半年后接手这个系统的同事看的。写清楚当时为什么做这个决策,比写清楚这个系统有哪些功能更有价值。
这个内容后续还可以这样扩展:如果业务量继续增长,V5.5的内核可以进一步容器化成独立的调度集群,插件分发可以做成动态加载,甚至可以通过热插拔在不重启的情况下更新算法库。这些方向V5.5都已经留下了扩展点,真正的上限取决于业务走多远。但就目前而言,V5.5这套微内核加DAG加事件驱动的合并体系,已经把我们当初列出的所有痛点全部解决掉了,而且没有引入新的不可控复杂度,这就算是一次成功的架构进化。