news 2026/10/24 3:20:09

主程序/子程序架构:2025年仍被低估的稳定架构风格

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
主程序/子程序架构:2025年仍被低估的稳定架构风格

1. 为什么2025年还要聊主程序/子程序架构

1.1 一个被低估的“老架构”

先说一个我自己的观察。这两年做系统架构评审,十个方案里有八个在聊微服务、事件驱动、云原生,剩下两个在聊Serverless和AI编排。但真正落到代码层面,我发现一个很有意思的现象:大量跑得最稳、维护成本最低的系统,骨架其实还是主程序/子程序那一套。

这不是怀旧。主程序/子程序(Main Program / Subroutine)架构风格,是所有架构风格里最古老、最朴素、也最容易被忽视的一种。它的核心思想简单到一句话就能说完:系统有一个主控流程,负责编排和调度;具体功能被拆成一个个独立的子程序,由主程序按顺序或按条件调用。

你可能会说,这不就是“写函数”吗?对,但也不对。写函数是编码层面的动作,而主程序/子程序是一种架构风格——它规定了控制流的方向、职责的边界、数据的传递方式,以及模块之间“谁说了算”的问题。这个区别很关键,后面我会展开讲。

1.2 它到底解决了什么问题

我拿一个真实场景来说明。之前参与过一个数据处理平台的架构设计,需求是这样的:每天凌晨要跑一批任务,包括拉取数据、清洗、校验、入库、生成报表、发通知。听起来不复杂,但如果用纯事件驱动的方式做,每个环节都发消息、订阅消息,链路一长,排查问题就是噩梦——一个数据没入库,你得沿着消息队列一路追,追到半夜。

后来我们改成了主程序/子程序风格:一个主调度程序,按顺序调用六个子程序,每个子程序干一件事,干完返回结果,主程序决定下一步走不走。整个链路的执行状态一目了然,日志清晰,出错就停在那一环,重试也简单。

这就是主程序/子程序架构的核心价值:用明确的控制流换取可预测性和可维护性。它不追求极致的解耦和弹性,它追求的是“我能一眼看懂系统在干什么”。

1.3 谁适合参考这篇内容

这篇内容适合三类人:

第一类,正在做中小型系统架构设计的工程师。你的系统规模还没到必须上微服务的地步,但又不想写成一坨面条代码,主程序/子程序是一个非常好的中间选择。

第二类,需要维护遗留系统的开发者。很多老系统就是典型的主程序/子程序结构,理解它的设计逻辑,你才能改得动、扩得开。

第三类,准备系统架构师相关能力评估的技术人。主程序/子程序是架构风格体系里的基础款,理解它的适用边界和演进方向,是绕不开的基本功。

接下来我会从设计思路、核心细节、实操落地、问题排查四个维度,把这种架构风格彻底拆开讲透。不是教科书式的概念罗列,而是我这些年实际用下来、踩过坑之后的经验总结。

2. 主程序/子程序架构的设计思路拆解

2.1 控制流的“总-分”哲学

主程序/子程序架构的本质,是一种控制流的集中式管理。主程序是大脑,子程序是手脚。大脑决定什么时候抬手、什么时候迈腿,手脚负责执行具体动作,执行完把结果反馈给大脑。

这个“总-分”结构带来的第一个好处是执行顺序可控。在分布式架构里,顺序往往是最难保证的东西——消息可能乱序到达,事件可能并发触发。但在主程序/子程序架构里,顺序是写死在主程序里的,第一步做完才做第二步,这是天然的确定性。

第二个好处是状态管理简单。因为控制流是集中的,状态可以集中在主程序里维护,子程序尽量做成无状态的纯逻辑。这样一来,你不需要引入分布式状态管理、不需要考虑状态同步,一个内存变量就能搞定。

第三个好处是调试友好。出了bug,你只需要在主程序里打几个断点,顺着调用链一路走下去,每一步的输入输出都清清楚楚。对比一下微服务架构里跨服务追踪的复杂度,这个优势非常明显。

但“总-分”也有代价。主程序容易变成瓶颈——所有逻辑编排都堆在它身上,它会越来越臃肿。子程序之间的通信必须经过主程序,不能直接对话,这在某些场景下会显得笨拙。所以设计的关键在于:主程序只负责编排,不负责实现;子程序只负责实现,不负责决策。这条边界守住了,架构就不会烂。

2.2 什么时候该选它,什么时候该躲开

不是所有场景都适合主程序/子程序。我总结了一个简单的判断标准,用三个问题来筛:

问题一:业务流程是否相对固定?如果你的业务流程是“步骤A→步骤B→步骤C”这种线性或简单分支的结构,主程序/子程序非常合适。但如果流程是高度动态的、根据运行时条件不断变化的,那主程序会变成一堆if-else,维护起来很痛苦。

问题二:各环节是否需要独立伸缩?如果某个环节的负载远高于其他环节,需要单独扩容,那主程序/子程序就不太合适,因为它天然是一个整体部署单元。这种情况下,把重负载环节拆成独立服务更合理。

问题三:团队规模和协作方式是什么?如果是一个小团队(3-5人)维护一个系统,主程序/子程序能极大降低沟通成本。但如果是多个团队各自负责不同模块,需要独立发布、独立迭代,那这种集中式架构会成为协作的阻碍。

我个人的经验是:日请求量在百万级以下、业务流程相对稳定、团队规模不大的系统,主程序/子程序架构的性价比极高。超过这个量级或者业务变化极快,再考虑向更分布式的架构演进。

2.3 和分层架构、管道过滤器架构的区别

很多人会把主程序/子程序和分层架构搞混,这里必须澄清一下。

分层架构的核心是按职责垂直切分——表现层、业务层、数据层,每层只和相邻层交互。它的关注点是“代码放在哪一层”,控制流是隐式的,通过层间调用来体现。

主程序/子程序的核心是按执行顺序水平编排——主程序决定先调谁后调谁,子程序之间是平级的。它的关注点是“谁在什么时候被调用”,控制流是显式的。

管道过滤器架构则不同,它的核心是数据在过滤器之间流动,每个过滤器对数据做一次加工,数据像水流一样穿过管道。它强调的是数据变换,而不是控制编排。

打个比方:分层架构像一栋楼,每层住不同的人;管道过滤器像一条流水线,原料从一头进、成品从另一头出;主程序/子程序像一个乐队指挥,指挥决定哪个乐器什么时候响。

理解这些区别,你才能在架构选型时做出正确的判断,而不是把所有东西都往一个模式里套。

3. 核心细节解析与实操要点

3.1 主程序该管什么、不该管什么

这是主程序/子程序架构里最容易出问题的地方。我见过太多项目,主程序写着写着就变成了“上帝类”——什么逻辑都往里塞,最后变成一个几千行的巨型文件。

主程序的职责应该严格限定在三件事:

第一,流程编排。决定子程序的调用顺序、调用条件、循环次数。比如“先调A,A成功了调B,B失败了重试三次,三次都失败就调C做补偿”。

第二,状态维护。维护流程级别的状态,比如当前执行到第几步、上一步的结果是什么、是否需要中断。这些状态是跨子程序共享的,放在主程序里最合适。

第三,异常处理。子程序抛出的异常,由主程序统一决定怎么处理——是重试、是跳过、还是终止整个流程。子程序自己不应该做这种决策。

主程序不该管的事情也很明确:具体的业务逻辑、数据转换、外部调用细节。这些都应该封装在子程序里。判断标准很简单:如果一段代码是“怎么做”的细节,它就不该在主程序里;如果一段代码是“做什么、什么时候做”的决策,它才属于主程序。

提示:一个实用的检验方法是,主程序的代码行数不应该超过任何一个核心子程序。如果主程序成了最长的文件,说明职责边界已经失守了。

3.2 子程序的粒度怎么定

子程序拆得太粗,主程序编排的灵活性就低;拆得太细,主程序要调用的次数就多,管理成本上升。这个粒度怎么把握?

我的经验法则是按“业务动作”拆分,而不是按“代码步骤”拆分。什么叫业务动作?一个业务动作是一个完整的、有业务含义的操作,比如“校验用户权限”“计算订单金额”“写入数据库”。什么叫代码步骤?比如“打开连接”“执行SQL”“关闭连接”——这些是技术细节,应该封装在一个子程序内部,而不是拆成三个子程序。

另一个原则是子程序应该可以被独立测试。如果你写了一个子程序,但测试它必须先把另外三个子程序跑一遍,那说明拆分粒度有问题。好的子程序应该是:给定输入,产生输出,不依赖外部状态。

还有一个实操技巧:先粗后细,按需拆分。一开始不要把粒度定得太细,先按大块功能拆成几个子程序,等发现某个子程序内部逻辑太复杂、或者被多处复用时,再把它拆细。过早细化会导致过度设计,反而增加维护成本。

3.3 数据传递的三种模式及选择

主程序和子程序之间怎么传数据,这个看似简单的问题,实际上直接影响架构的可维护性。我总结下来有三种模式:

模式一:参数传递。主程序把数据作为参数传给子程序,子程序处理完通过返回值传回。这是最干净的方式,子程序完全无状态,可测试性最好。缺点是参数多的时候,函数签名会很长,调用起来啰嗦。

模式二:共享上下文。主程序维护一个上下文对象(Context),里面存放流程状态和共享数据,子程序接收这个上下文,按需读取和写入。这种方式适合数据在多个子程序之间流转的场景,避免了参数在调用链上层层传递。缺点是对上下文的修改是隐式的,追踪数据变化比较麻烦。

模式三:消息/事件传递。子程序之间通过消息队列或事件总线通信,主程序只负责触发。这种方式解耦最彻底,但引入了异步复杂性,排查问题难度上升。

我的建议是:优先用参数传递,数据量大或共享频繁时用上下文对象,只有在确实需要异步解耦时才用消息传递。大部分中小型系统,前两种模式就够了,不要为了“架构感”而引入不必要的异步。

3.4 错误处理与重试机制的设计

主程序/子程序架构的一个核心优势,就是错误处理可以做得非常清晰。因为控制流是集中的,你可以在主程序里针对每个子程序的调用做精细化的错误处理。

我通常会把子程序的执行结果分成三类:成功、可重试失败、不可重试失败。可重试失败比如网络超时、临时资源不可用;不可重试失败比如参数校验不通过、业务规则冲突。主程序根据失败类型决定下一步动作。

重试机制要特别注意两点:重试次数要有上限,不能无限重试;重试要有退避策略,比如第一次等1秒,第二次等2秒,第三次等4秒,避免瞬间打爆下游。这些逻辑都放在主程序里,子程序只管抛异常,不管重试。

还有一个容易被忽视的点:补偿逻辑。如果流程走到第三步失败了,前两步已经产生的副作用怎么处理?是在主程序里调用补偿子程序回滚,还是标记状态等人工介入?这个决策必须在设计阶段就想清楚,不能等到出问题再临时加。

注意:补偿逻辑不要写在子程序内部,否则子程序就承担了它不该承担的流程决策职责。补偿是流程级别的事情,归主程序管。

4. 实操过程与核心环节实现

4.1 一个完整的主程序/子程序落地案例

我用一个“每日数据同步任务”的场景,把整个实现过程走一遍。需求是:每天定时从三个数据源拉取数据,合并清洗后写入目标库,最后生成一份统计报告。

第一步:定义子程序清单。我把整个流程拆成五个子程序:

子程序职责输入输出
FetchSourceA从数据源A拉取数据日期原始数据列表
FetchSourceB从数据源B拉取数据日期原始数据列表
FetchSourceC从数据源C拉取数据日期原始数据列表
MergeAndClean合并清洗数据三个数据列表清洗后数据
WriteToTarget写入目标库清洗后数据写入条数
GenerateReport生成统计报告写入条数、日期报告文件路径

第二步:设计主程序的控制流。主程序逻辑用伪代码表示:

def main_job(date): context = {"date": date, "errors": []} # 阶段一:并行拉取三个数据源 results = parallel_execute([ lambda: FetchSourceA(date), lambda: FetchSourceB(date), lambda: FetchSourceC(date) ]) # 检查拉取结果 for i, result in enumerate(results): if result.status == "failed": if result.retryable: result = retry(lambda: fetch_by_index(i, date), max_retry=3) if result.status == "failed": context["errors"].append(result.error) # 阶段二:合并清洗 if not context["errors"]: merged = MergeAndClean(results) count = WriteToTarget(merged) report_path = GenerateReport(count, date) return {"status": "success", "report": report_path} else: return {"status": "partial_failure", "errors": context["errors"]}

这个主程序做了几件事:编排调用顺序、并行执行独立子程序、处理重试、根据结果决定分支。每个子程序只负责自己的那件事,不关心别人。

第三步:子程序的实现要点。以FetchSourceA为例,它的实现要遵循几个原则:不依赖全局状态、失败时抛出明确的异常类型、记录自己的执行日志。代码结构大致是:

def FetchSourceA(date): logger.info(f"FetchSourceA started for {date}") try: conn = create_connection(config.SOURCE_A) data = conn.query(f"SELECT * FROM table_a WHERE dt='{date}'") logger.info(f"FetchSourceA fetched {len(data)} rows") return {"status": "success", "data": data} except TimeoutError as e: logger.warning(f"FetchSourceA timeout: {e}") return {"status": "failed", "retryable": True, "error": str(e)} except ValidationError as e: logger.error(f"FetchSourceA validation failed: {e}") return {"status": "failed", "retryable": False, "error": str(e)}

注意这里子程序返回的是结构化的结果对象,而不是直接抛异常给主程序。这样做的好处是主程序可以统一处理,而不需要为每个子程序写不同的try-catch。

4.2 参数选择与性能考量

在这个案例里,有几个参数需要仔细选择:

并行度。三个数据源拉取是独立的,可以并行。但并行度不是越高越好,要考虑数据源端的承受能力。我一般设置为数据源数量的1-2倍,这里三个源,并行度设为3就够了。

重试次数和退避时间。重试次数设为3次,退避策略用指数退避:1秒、2秒、4秒。这个配置的依据是:大部分临时故障在几秒内能恢复,超过三次重试还失败,基本就是真故障了,继续重试没意义。

超时时间。每个子程序的超时时间要根据历史执行时间设定。我的做法是取P99执行时间的1.5倍作为超时阈值。比如FetchSourceA的P99是30秒,超时设为45秒。这样既能容忍偶发的慢查询,又不会让整个流程卡死。

批次大小。WriteToTarget写入时,如果数据量大,要分批写入。批次大小一般设为500-1000条,太小会导致频繁IO,太大会占用过多内存。这个值需要根据实际数据宽度和数据库性能调优。

4.3 日志与可观测性设计

主程序/子程序架构的日志设计有一个核心原则:主程序记流程日志,子程序记执行日志。

主程序的日志应该包含:流程开始/结束时间、每个子程序的调用时间和耗时、每个子程序的执行结果、重试记录、最终流程状态。这些日志串起来,就是一条完整的执行链路。

子程序的日志应该包含:自己的输入参数摘要、关键执行步骤、输出结果摘要、异常信息。子程序不需要记录“我是被谁调用的”,那是主程序的事。

我通常会给每个流程实例分配一个唯一的trace_id,主程序和所有子程序都带上这个id。这样排查问题时,用trace_id一搜,整个流程的执行记录就全出来了。这个做法成本很低,但收益极大。

提示:日志里不要打印敏感数据,比如用户密码、身份证号。参数摘要只记录关键字段和数量,不要全量打印。

4.4 部署与调度方式

主程序/子程序架构的部署方式很灵活。最简单的方式是把主程序和所有子程序打包成一个应用,部署在单台机器上,用cron或系统定时任务触发主程序。这种方式适合数据量不大、对可用性要求不极端的场景。

如果数据量较大,可以把主程序部署为调度节点,子程序部署为工作节点,主程序通过RPC或HTTP调用子程序。这样每个子程序可以独立扩容,但引入了网络调用的复杂性。

还有一种折中方案:主程序和子程序在同一个进程内,但通过线程池实现并行执行。这种方式兼顾了性能和简单性,是我在中小型系统里最常用的方案。

选择哪种部署方式,核心考量是子程序是否需要独立伸缩。如果所有子程序的负载差不多,单体部署就够了;如果某个子程序是瓶颈,再考虑拆出去。

5. 常见问题与排查技巧实录

5.1 主程序膨胀了怎么办

这是最常见的问题。项目初期主程序很清爽,随着需求迭代,各种判断逻辑、特殊处理、临时补丁都往主程序里加,半年后主程序变成了一个两千行的怪物。

我的应对策略是提取“流程片段”。当主程序里某一段逻辑超过20行,并且有明确的业务含义时,就把它提取成一个独立的子程序。比如“检查数据完整性并决定是否继续”这段逻辑,就可以提取成ValidateData子程序。

另一个策略是用状态机替代if-else。如果主程序里大量的if-else是在判断“当前处于哪个阶段、下一步该做什么”,那说明你需要一个状态机。把状态和转移规则抽出来,主程序只需要驱动状态机运转,代码量会大幅下降。

还有一个根治性的做法:定期做架构守护。在代码审查环节加一条规则——主程序的代码行数不得超过阈值(比如300行),超过就必须重构。这条规则听起来简单,但执行下来效果非常好。

5.2 子程序之间出现循环依赖

子程序A调用了子程序B,子程序B又调用了子程序A,这种循环依赖在主程序/子程序架构里是不应该出现的。因为按照设计原则,子程序之间不应该直接调用,所有编排都应该由主程序负责。

如果出现了循环依赖,说明两个子程序的职责边界没划清楚。解决办法是把共同依赖的部分提取成第三个子程序,让A和B都调用C,而不是互相调用。

还有一种情况是子程序A需要子程序B的结果才能继续。这种情况下,正确的做法是把B的调用提到主程序里,由主程序先调B,再把B的结果传给A。这样控制流仍然是集中的,依赖关系是清晰的。

5.3 流程执行到一半失败了怎么恢复

这是主程序/子程序架构必须面对的问题。我的经验是设计阶段就要考虑幂等性和断点续跑。

幂等性是指:同一个子程序执行多次,结果是一样的。比如“写入数据”这个操作,如果重复执行会导致数据重复,那它就不是幂等的。解决办法是加唯一键约束,或者先删后插。

断点续跑是指:流程失败后,能从失败的那一步继续,而不是从头再来。实现方式是在主程序里记录每一步的执行状态,失败后重新触发时,跳过已成功的步骤。这要求每个子程序的执行结果可以被持久化。

我通常会在数据库里建一张流程状态表,记录每次流程执行的trace_id、当前步骤、各步骤状态。主程序启动时先查这张表,决定从哪一步开始。这个机制在数据量大的场景下特别有用,能避免大量重复计算。

5.4 常见问题速查表

问题现象可能原因排查方向解决建议
主程序执行时间过长某个子程序阻塞查看各子程序耗时日志给子程序加超时,优化慢子程序
子程序返回结果不一致子程序依赖了外部可变状态检查子程序是否有全局变量改为无状态设计,状态由主程序管理
重试导致数据重复子程序不幂等检查写入逻辑是否有唯一约束加唯一键或改为先删后插
流程卡死无日志子程序死锁或无限等待检查子程序是否有未设超时的等待所有外部调用必须设超时
内存持续增长上下文对象未释放检查主程序是否持有大对象引用流程结束后清理上下文
并行子程序互相干扰共享了可变资源检查子程序是否操作同一份数据改为数据隔离或加锁

5.5 几个我踩过的坑

坑一:子程序里做了流程决策。有一次我在子程序里写了“如果数据为空就跳过后续步骤”的逻辑,结果主程序不知道这个决策,后续步骤还是执行了,导致空指针异常。教训是:子程序只报告事实(数据为空),决策由主程序做。

坑二:重试没有区分错误类型。早期我做重试是无差别重试,不管什么错误都重试三次。结果参数校验失败也重试,白白浪费了时间和资源。后来改成只有标记为retryable的错误才重试,效率提升明显。

坑三:日志没有trace_id。并行执行三个子程序时,日志混在一起,根本分不清哪条日志是哪个子程序打的。加了trace_id之后,排查效率至少提升一倍。

坑四:上下文对象越传越大。一开始图方便,把所有数据都塞进上下文对象,结果上下文变成了一个巨大的Map,谁都可以往里写,数据来源完全不可控。后来改成上下文只放流程状态,业务数据通过参数传递,清晰多了。

这些坑说到底都指向同一个原则:边界要清晰。主程序和子程序的边界、子程序和子程序的边界、数据和状态的边界,每一条边界守住了,架构就不会失控。

5.6 向更复杂架构演进的时机判断

主程序/子程序架构不是终点。当系统发展到一定阶段,你会感受到它的局限:主程序成为瓶颈、子程序无法独立部署、流程编排越来越复杂。

我判断演进时机的标准有三个:第一,主程序的代码行数持续超过500行且无法通过重构降低;第二,某个子程序的负载是其他子程序的10倍以上,需要独立扩容;第三,多个团队需要独立修改和发布不同的子程序。

满足其中任意两个,就该考虑向更分布式的架构演进了。演进路径通常是:先把子程序拆成独立服务,主程序变成编排服务;然后引入消息队列,把同步调用改成异步;最后根据业务需要,引入事件驱动或工作流引擎。

但我要强调的是:不要为了演进而演进。我见过太多系统,本来主程序/子程序跑得好好的,非要拆成微服务,结果复杂度上去了,稳定性反而下来了。架构演进的唯一驱动力应该是真实的业务压力,而不是技术潮流。

我在实际项目中的体会是,主程序/子程序架构就像一把趁手的工具刀——它不炫酷,但在大多数日常场景下,它比那些花哨的电动工具更好用、更可靠。把它用对地方,用到位,你就能用最简单的结构撑起一个稳定运行的系统。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/24 3:20:07

WPS JS宏批量合并Excel文件:跨工作簿数据汇总实战方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/24 3:19:45

p5.js粒子系统:从Mover到Particle的生命周期建模

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/24 3:19:12

宝塔插件开发全流程:从工程结构到上架避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/24 3:19:02

城市行政区边界数据处理全流程实战:从坐标系到可视化

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/24 3:18:35

零基础理解大语言模型:从N-gram概率猜词到神经网络

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/24 3:18:33

近世代数期末复习指南:群环域核心概念与实战策略

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华