尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

软件工程的核心:如何系统性管理并降低系统复杂性

软件工程的核心:如何系统性管理并降低系统复杂性 你有没有遇到过这样的情况一个看起来很小的需求比如“在用户列表页加一个状态筛选”本来预估半小时改完结果你打开代码发现这个列表的状态字段散落在五个服务里三个不同的表结构还有一套历史遗留的缓存逻辑。你甚至不确定改了以后会不会影响其他功能。半小时的活最后花了两天。这种场景几乎每个软件工程师都经历过大大小小的版本。问题不在需求本身而在系统里积累起来的复杂性。软件工程发展了几十年语言从 C 到 Java 到 Go框架从单体到微服务到 Serverless工具链越来越强抽象层次越来越高。但有一件事从来没有变过所有优秀实践的背后本质上都是在做同一件事——管理复杂性。谁能把复杂性控制住谁就能持续交付谁让复杂性失控谁就会被系统反噬。这这篇文章想从“复杂性到底从哪里来”开始拆开软件工程里那些看似高级的概念——分层、模块化、接口、测试、重构、CI/CD——你会发现它们的主线完全一致。我也会给出一些具体的判断方法和排查思路希望能帮你从“能跑就行”走向“能一直改下去”。1. 复杂性从来不是“代码多”而是“牵一发动全身”先给复杂性下一个操作性定义一个系统里当你修改一处代码时需要同时理解、确认或修改的关联点越多复杂性就越高。这不是一个数学指标但它非常贴近工程直觉。比如一个 10 万行代码的单体项目如果模块边界清晰、依赖单向、数据流可追踪它并不一定复杂。反过来一个只有 5000 行的项目如果全局变量到处是、回调嵌套三层、多处隐式依赖时间顺序它已经足够让人抓狂。所以不要用代码行数、文件数量、服务数量来衡量复杂性。那些都是表象。真正的复杂性在于关联关系。1.1 本质复杂性与偶然复杂性先分清敌人在软件工程里有一对经典概念来自 Brooks 的《人月神话》本质复杂性和偶然复杂性。本质复杂性是问题域本身自带的难度。比如你做一个银行核心系统需要处理账务一致性、并发扣款、幂等、对账这些规则本身就是复杂的。你换任何语言、任何架构都无法消除这部分复杂性。它是业务世界映射到系统里的必然结果。偶然复杂性则是我们引入的额外复杂度。比如同一个业务规则被复制粘贴到五个地方改了一处忘了另一处比如一个团队用了六种不同的方式做缓存有的走中间件有的走本地变量比如一个接口的入参传递了八个字段其实四个字段可以通过登录态拿到。这些都不是业务要求而是历史习惯、赶工妥协、缺少设计造成的。管理复杂性的第一步就是把这两类复杂性分开看待。本质复杂性只能被局部化、被封装不能被消灭。偶然复杂性是可以削减的也是绝大多数技术债的藏身之处。1.2 复杂性最容易在五个地方堆积根据我的经验系统的复杂性几乎总是集中在下面五个地方堆积点典型表现为什么危险状态管理全局变量、共享 Mutable 对象、跨模块修改状态你无法确定谁在什么时候改了什么值依赖关系A 依赖 BB 又隐式依赖 A循环依赖排查困难启动顺序、初始化顺序变成玄学数据流同一个数据对象在多处被改写方向不清晰看到代码时不知道这是最新值还是中间值异常路径成功路径很清晰失败、超时、重试路径混乱生产事故往往发生在异常路径隐含约定依赖某个字段的格式、某个接口调用的先后顺序新人不看文档不可能知道老手也会忘这五个堆积点有一个共同特征它们都不是单点错误而是连接错误或时序错误隐藏得更深测试更难覆盖。1.3 单次跑通只能说明流程没有断很多项目在最早期都是“非常顺利”的。一个需求来了写一个接口调通了联调通过上线了。感觉一切都很美好。但随着需求增加同一个模块开始出现分支判断同一个实体开始承担多种角色同一个定时任务开始隐式依赖另一个任务的结果。这时候你会发现改动一个地方变得越来越慢。因为你的心智模型必须从“一个文件”扩大到“整条链路”。你不再只是改代码你还要回忆当初为什么这么写要搜索所有调用点要在脑内模拟各种边界条件。单次跑通说明的是“在给定输入下这条路径能执行完”。它和“系统可维护”之间隔着无数个看不见的关联。这也是为什么很多项目的前六个月都风平浪静后六个月开始天天救火。记住一个功能的完成度不等于一个系统的健康度。2. 为什么很多项目“跑通了”却活不久失控的四个征兆管理复杂性不是一句口号而是非常具体、可以观察的。一个团队的交付节奏开始下滑通常在代码层面有四个明确的征兆。2.1 征兆一改动模块的数量在增长一个需求哪怕再小如果要同时修改三个以上互不相关的文件或模块你就要警惕了。比如一个“修改用户昵称”的功能竟然要动用户服务、消息服务、搜索索引服务同时还要改一个定时任务里的字段映射——这说明业务变更的响应面被人为扩大了。正常的做法应该是修改一个领域边界内的模块通过事件或消息通知下游而不是让下游直接依赖你内部字段。我一般判断一个模块是否健康会看它的“变更影响半径”。询问自己如果这里加一个字段哪些地方会编译错误哪些地方会运行时报错哪些地方运行时不会报错但逻辑错乱如果答案超过三个这个模块就已经在失控边缘了。2.2 征兆二新人在理解系统时找不到“稳定的抓手”当一个新人加入项目你给他一个月时间他能不能独立上手改需求如果他的首要障碍不是业务知识而是一次性要了解十几个模块的内部实现那么系统就已经缺少了“可视化结构”。好的系统一定是可以先看接口再读实现的。新人不应该一开始就扎进数据库表结构、定时任务、消息队列和缓存键拼接逻辑。他应该能先看到“这是一个订单模块它提供以下能力这些能力之间不相互覆盖”。相反如果系统里到处都是绕过 Service 直接访问其他模块内部仓库的代码或者一个实体类被六个业务场景共用那么新人只能靠“考古”式阅读效率极低。2.3 征兆三没人能说清“改这个会不会崩”这是一个非常直接的信号。当你问一个核心模块的老维护者“如果把这段逻辑改掉会不会影响支付对账”他如果回答“我不确定得跑一下全量回归看看”这个系统就已经处于“黑盒恐惧”状态了。这种恐惧的本质是缺少可验证的契约。模块与模块之间应当有清晰的约定比如“输入什么结构、输出什么结构、不保证什么”。约定有了你才能隔离影响。约定缺失你只能靠全部跑一遍来壮胆。2.4 征兆四问题被反复定位到同一块“坏味道”区域你观察一段时间内的线上问题和代码评审会发现很多 Bug 都来自同一片区域某个大函数、某个被频繁修改的实体类、某个绕过了所有分层的工具方法。这个区域通常就是一个“复杂性黑洞”。黑洞区域的特征是调用者多内部逻辑杂职责模糊对它的修改无法用一句话概括。它不是某个人的失误而是一系列短期决策累积的结果。如果不主动治理它只会继续膨胀下去。不是所有技术债都需要立即还清但如果一个区域已经出现三次以上反复故障它就必须被列入改造计划。3. 用“分层隔离”把复杂性化整为零一个可落地的四步法既然复杂性主要来自关联关系那管理它就一个核心思路切分关联、控制依赖方向、定义稳定接口。这不是什么高深理论而是所有软件架构方法共同的内核。3.1 第一步识别“变化点”和“稳定点”一个系统里有些东西很容易变化比如业务规则、外部接口格式、页面展示逻辑有些东西相对稳定比如领域实体的核心关系、权限模型、审计要求。管理复杂性的关键不在变化点本身而在在变化点和稳定点之间画一条线。变化点要独立成模块稳定点要成为接口。以订单系统为例稳定点订单创建、取消、支付状态流转。变化点优惠规则、物流渠道、支付渠道、风控策略。如果你把稳定点和变化点混在一起写每次优惠规则调整都要动订单主流程系统自然越来越复杂。正确做法是让变化点通过策略接口、扩展点或插件机制挂在稳定流程上。3.2 第二步为每个模块定义“只暴露接口、不暴露实现”这里要区分两个不同层次层次一方法级接口。比如一个订单服务有 createOrder、cancelOrder、payOrder 方法调用方使用这些方法不直接访问订单数据库。层次二模块/服务级接口。比如订单服务通过 HTTP、RPC 或消息给外部暴露能力外部不关心它内部是 MySQL 还是 Redis是单体还是微服务。很多项目的问题不是没有接口而是接口定义得太随意或者调用方为了省事直接绕过了接口去获取内部数据。比如一个前端需要展示订单状态后端接口就直接把订单表的一个 status 字段透传出来了而没有包装成状态文本或状态枚举。看起来短平快但当下游消费方越来越多一旦订单状态枚举新增了一个值所有下游都得跟着改。这就是“隐式耦合”。好的接口应该是“表达意图而不是暴露细节”。它应该告诉调用方“你能做什么”而不是“我们的数据长什么样”。3.3 第三步控制依赖方向让依赖总是朝一个方向流动依赖方向是复杂性的骨架。一个系统如果依赖方向混乱基本上就没有重构的可能性了。最经典的依赖方向是分层架构表现层Controller / View应用层用例编排领域层业务规则基础设施层数据库、消息、文件依赖只允许从上层指向下层下层不能反向依赖上层。比如 Controller 可以依赖应用层应用层可以依赖领域层领域层定义仓储接口基础设施层实现这个接口。这样做的好处是当你要更换数据库、消息队列或缓存方案时你只需要改基础设施层领域层完全不动。这就是“依赖倒置”的意义。实际项目中我更建议用一个更简单的检查方式画一张模块依赖图看看是否存在任何一条从底层指向顶层的箭头。如果有比如某基础设施类直接 import 了 Controller或者领域服务里构造了具体 HTTP 请求那么那一段就是注定要不断打补丁的地方。3.4 第四步隔离副作用让批处理任务不污染主流程副作用是复杂性的另一个重要来源。典型如在订单创建成功时除了保存订单还要发短信、刷新缓存、触发用户积分、给运营系统推送数据。如果这些副作用直接写在订单创建的主流程里那么每次新增一个“下单后要做的事”都要改动订单主流程并且主流程的失败概率会因为外部依赖而增大。更合理的做法是主流程只负责完成订单状态变更然后通过事件或消息向外广播“订单已创建”其他模块各自订阅感兴趣的事件。这样订单主流程不会因为短信服务宕机或缓存节点失联而失败也天然支持后续扩展新订阅方。需要强调的是事件驱动不是银弹它会把一致性变成最终一致性并且让问题追踪变得更困难需要额外引入消息记录、重试和补偿。但在处理“主流程之外的一堆后续动作”时它往往是降低复杂性的有效解。以下是一个四步法的总结步骤动作核心问题1识别变化点和稳定点什么在变什么不能变2定义稳定接口调用方依赖的是契约而不是实现3控制依赖方向是否有一条从底层指向高层的脏箭头4隔离副作用后续动作是否会反向影响主流程4. 管理复杂性的三道防线约定、工具、流程架构设计做得再好如果团队没有共识和执行也会逐步退化。现实中代码总是从“清晰”走向“蛛网”的因为每个人的短期决策都有合理理由赶时间、不懂、图省事、不敢改别人的代码。要在长期内守住复杂性边界需要三道防线。4.1 防线一用约定建立“可预期性”约定是指团队内的一种共识什么代码放哪个目录、什么场景用哪种模式、接口命名是什么风格、异常处理是返回码还是抛异常。约定不是越多越好。太多约定会变成束缚让人反感。约定需要围绕“最容易产生分歧、风险最高”的地方来制定。我建议每个项目至少约定以下几件事分层边界什么情况下可以跨层调用什么情况下不允许。比如禁止 Controller 直接操作 Repository。外部依赖隔离所有对第三方服务的调用是否必须经过一个封装层禁止在业务代码里直接拼 URL。异常处理边界哪些异常应该在业务层处理哪些应该在边界层转换禁止底层把数据库异常直接抛给前端。状态变更一致性单个实体状态变更的入口是否统一禁止在多个业务分支里各自更新状态字段。这些约定用一两页 README 写清楚即可不需要做成几十页的规范手册。4.2 防线二用自动化工具强制执行不变式人总会忘记约定但机器不会。所以第二道防线是把约定编码进工具链。代码格式化工具统一风格消除“改一行变格式”的噪音。静态检查检测禁止跨层依赖、禁止空 catch、禁止暴露内部状态等。像 ArchUnitJava、RuboCopRuby、基于 Language Server 的自定义 lint 在建都可以把架构规则变成单元测试。单元测试和契约测试核心模块的关键业务规则必须要有测试兜底。测试并不是为了证明正确而是为了让你在未来重构时敢改。哪怕只是一个小函数只要它有明确输入输出就值得一个测试。CI/CD 流水线每次提交自动跑测试、自动构建、自动部署到测试环境减少人为操作带来的不确定性。这里我特别想强调一下测试的作用。很多人觉得测试是额外工作量但如果从“管理复杂性”的角度看测试本质是在给未来的重构购买保险。没有测试你敢改一个已经被六个模块引用的公共方法吗大概率不敢。不敢改复杂性就会僵在那里越积越重。4.3 防线三用代码评审和重构节奏控制熵增代码评审目前仍然是成本较低、效果稳定的团队级防线。它不是为了找错而是为了确保新的改动尽量符合既定的架构方向。不过很多代码评审容易流于形式。要让评审真正发挥管理复杂性的作用我建议评审者重点看三件事而不是逐行挑语法变更是否越过了模块边界比如一个“订单”的改动是否动到了“用户”的表结构这次改动是让系统更简单还是更复杂哪怕只是多了一个参数多了一层 if也要考虑能否用更简单的方式表达。是否缺少必要的抽象当类似代码重复出现三次以上评审时就应该提出抽象方案而不是接受第四次复制。同时团队里要形成“小步重构”的节奏。不建议单独设立一个“重构月”因为重构如果变成大工程往往还没有开始就结束了。更好的方式是每完成一个需求顺手清理一小块混乱区域比如重命名一个函数、拆掉一个全局静态状态、消灭一个魔法数。这些小步骤积累起来比一次大重构要稳得多。4.4 给两类团队的不同建议不同阶段的团队在管理复杂性上的侧重点不同。小团队 / 快速验证期优先保证“单点改动可控”可以先不做大规模微服务拆分。但至少要把模块边界、依赖方向、核心接口定下来。技术债可以留但不能留那种“改一行崩三处”的债。中大型团队 / 长期维护期必须建立明确的架构守卫机制包括自动化依赖检查、变更影响评估、周期性架构回顾。这个阶段最大的风险不是某个功能做不出来而是系统改不动每个需求都像在雷区里走路。5. 当复杂性已经失控一套排查与修复链路如果项目已经陷入“一处修改、处处牵制”的状态不要急着推倒重写也不要一上来就拆微服务。重建一个系统看起来干净但重新积累的业务细节和边界情况往往比旧的还复杂。正确的路径是先定位复杂性集中点再逐层剥离。5.1 排查顺序从现象到根源发现问题时一定要先看现象再顺着线索找根源按照下面几步排查列出典型症状经常出现线上事故的模块是哪些新需求从评估到上线平均要多久是否有几个 Bug 反复出现找出高风险依赖图用架构分析工具或人工阅读画出核心模块的依赖关系。重点关注谁被最多模块依赖谁依赖了最多的模块是否存在环形依赖定位高变化区在版本控制历史里统计哪些文件被修改次数最多、哪些文件每次需求都会动。这些区域通常是最需要治理的地方。查看调用链与副作用从一次核心业务链路出发记录它经过了几个服务、几层缓存、几个定时任务。你会发现很多不必要的迂回。区分“不敢改”和“不能看”有些代码复杂但稳定有些代码复杂又脆弱。优先处理“复杂又脆弱”的部分。5.2 修复手段的选择重构、加隔离层还是拆服务根据排查结果可以决定下一步动作。常见手段有三种手段适用场景成本和风险小步重构模块内部逻辑混乱但依赖关系还在可控范围成本低风险小应该首选加隔离层 / 防腐层外部系统或历史接口无法改变需要在边界处防止复杂性扩散成本中等能保护核心域拆分为独立服务边界清晰、性能/团队规模需要独立部署成本高必须要有明确边界和可观测能力这里最容易被误解的是“拆微服务”。微服务本身是一种管理复杂性的手段但它只有在单体无法支撑时才值得考虑。如果你的模块边界本身就模糊强行拆成服务只会把模块内的复杂性变成服务间的网络调用不仅没解决还增加了分布式事务、链路追踪、部署运维的复杂性。5.3 重构时的安全网测试、演练、回滚在动重构之前最好先建立安全网。无论重构的目标多么简单没有安全网就不应该大规模改动核心逻辑。安全网至少要有以下几层行为测试对关键函数的输入输出做示例测试。契约测试对服务间的接口、字段、返回结构做兼容性校验。链路演练在测试环境跑一遍核心业务链路记录耗时和日志。灰度发布在真实流量中逐步放量观察错误率和耗时随时回滚。重构不是“改完代码就行”它本质上是一次小型的复杂管理行动。你必须在动手前想清楚如果出了意外怎么退回去如果线上表现不稳定是哪个指标能告诉我6. 把管理复杂性变成日常习惯而不是一次大工程如果你只记住一件事我希望是这个软件工程的核心不是写出能跑的代码而是让代码在长时间、多人协作、需求不断变化的情况下仍然能被理解、被修改、被演进。管理复杂性不是一个阶段性的任务它每天都发生在每个决定里。6.1 一个可以日常自检的清单我把自己积累的经验浓缩成了一份简单清单每次提交代码或者做代码评审时可以快速过一遍[ ] 这次改动是否会影响到两个以上不相关的模块如果会是否存在更合理的切分方式[ ] 我在新代码里是否直接依赖了某个外部系统的内部细节要不要加一个防腐层[ ] 新增的字段是否被多个场景复用如果只为一个场景服务是否应该放在独立的数据结构中[ ] 这次改动的异常路径是否考虑到了失败、超时、重试异常信息是否能让运维快速定位[ ] 如果我三个月后回来看这段代码能否在没有注释的情况下理解它的意图[ ] 类似的逻辑是不是已经在别处出现过如果有是应该复用还是应该先抽象这份清单不算复杂每个问题都能在几分钟内完成自检。长期积累下来它比任何架构文档都更有效。6.2 复杂度的“红线”给系统留好呼吸空间每个团队都应该为自己的核心模块定义几条复杂度红线。不是用来约束所有人的细节而是用来防止系统快速恶化。比如新代码不允许新增循环依赖。不允许在应用层直接访问其他模块的数据库。单个函数体行数超过 100 行时必须拆解。同一个业务实体在一次链路中不能被修改超过两次。这些红线不需要很多五到十条就够。关键是它们要被工具检查比如静态检查里加上对应规则而不只是停留在文档里。6.3 最后的判断复杂性管理是在为“未来的人”节省时间写代码时我们总觉得“未来的人”不会来或者觉得“未来的人”会理解我的苦衷。但真实情况是未来的人往往就是三个月后的自己。你当时图省事留下的隐式依赖总有一天会变成排查事故时的漫长深夜。把自己当成未来那个需要读懂代码的人每天写一点更清晰的抽象、更干净的边界、更明确的接口这比任何“最佳实践”都更接近软件工程的核心。如果你现在正被一个复杂的系统困扰我的建议是不要急着推翻重来先从一次小范围的重构开始从一段混乱区域里抽出接口把依赖方向掰正。你会发现管理复杂性真正的乐趣在于当系统越改越清晰团队越来越敢改代码的时候你不需要大喊“架构”也能感受到那种稳定而持续的交付节奏。这是软件工程最朴素也最值得长期坚持的一件事。
返回列表