复杂度到底从哪来的?
做了快十年,经手的项目从几十行脚本到几十万行系统都有。有一个问题一直绕不开:复杂度到底从哪来的?
不是「为什么会有复杂度」——那个答案太简单:问题本身有复杂度,解决它就得付出复杂度。真正的问题是:为什么很多系统的复杂度,比它要解决的问题大得多?多出来的那一部分是谁加的、什么时候加的、怎么避免?
我观察到的复杂度有三个特点,把它们摆出来,后面才好谈怎么管。
复杂度的三个特点
演进性:复杂度不是一次加进去的,是一次次叠上去的。 没有一个项目立项时会宣布「我们要把系统做复杂」。第一个迭代是简单的——cron 跑个脚本,一个文件搞定。第二个迭代加了个参数,第三个迭代多写了个分支,第四个迭代引了个库。每一个决策单独看都没问题,都合理,都「只是多加几行」。但十轮之后回头看,你已经不知道当初在写什么了。
它不是一个人拍脑袋说「我要上微服务」——那种复杂性反而好查。它是日常的、不起眼的、每次「就这么点小事」累积起来的。复杂度的增长是连续的,但你对它的感知是离散的——你只在某个时刻突然觉得「这系统怎么这么难改了」,但那个时刻之前,每一轮迭代都在往里加东西。
隐蔽性:复杂度在当下总是合理的。 回头看会骂「当初怎么搞成这样」,但把你放回当初那个场景,你大概率做同样的选择。当时的你只有当时的信息量——你不知道三个月后需求会变,不知道这个参数以后会有三个值,不知道那行硬编码会被抄到六个地方。在信息最少的时候做决策,每个决策都是最优的,但最优决策加在一起不等于最优系统。
这个特点最要命的地方在于:它让你没法在「当时」发现问题。只有事后,只有信息够了,你才看得清哪些决策是对的、哪些决策是「当时对但现在不对了」。但事后已经是事后了——代码在线上跑着,业务在依赖着,你的改正成本比当初的决策成本高了十倍。
非唯一性:同一个问题,有无数种解法。 「能跑」不只是一种写法——你可以写 shell,也可以上 Airflow;你可以硬编码,也可以抽配置。这些选择之间不是对错,是 trade-off:选简单的,以后扩展要多写;选灵活的,以后维护要多读。没有「唯一正确」的架构——但每一个选择都会在系统里留下痕迹,这些痕迹合在一起,就是复杂度的底账。
这三个特点加起来得出一个结论:复杂度不可消灭,只能管理。 你不能阻止它增长——业务在变、数据在涨、人在换,每个变化都会带来新复杂度。但你可以管它:知道它从哪来,知道每种来源对应什么防范手段,知道什么时候该动手、什么时候可以再等等。
我把复杂度的来源拆成三个方向:需求带来的、技术选型带来的、时间与成长带来的。三个方向不互斥——一个系统通常三个都有,但拆开看能看得更清楚。
需求复杂度:不知道自己要什么
需求复杂度不是需求太多,是需求在变,而且你不知道它会怎么变。
一个财务系统,第一个版本只要算工资。第二个版本加了个社保,第三个版本加了个个税,第四个版本加了个年终奖分摊。每个版本单独看都不复杂——一个公式,两个参数。但四个版本走完,你发现工资、社保、个税、年终奖分摊不是四个独立公式,是四个互相引用的公式:社保基数依赖工资结构,个税起征点依赖社保扣除,年终奖分摊依赖个税档位。当初每一版都按「加一个公式」做的,没人告诉你它们之间会互相依赖。
需求复杂度的根源,不是你接到的需求多,是你接到的需求在迭代中自己长出了结构。 那个结构没人设计过——是需求本身在时间线上一版一版展开,展开到一定数量,结构自己浮出来了。但你的代码是按「一个需求加一段逻辑」写的,不是按结构写的。
管它的办法不是「想清楚再做」——你想不清楚,因为需求还没长到那个阶段。管它的办法是分层:把需求分核心和边缘。 核心是「没了它系统就不成立」的东西——工资计算、数据入库。边缘是「有了它更好,但没它也能跑」——年终奖分摊、个税优化。核心链路先做、做稳、做对;边缘需求可以加,但加的时候要问自己一句:这个功能会不会让核心链路变复杂?会就等,等到核心链路足够稳了再接。
「能不能加一个」是复杂度最常见的人口。 加之前先问:这个功能在核心链路还是边缘链路?它会不会改变核心链路的执行模型?会就等一等,不会就加。
技术复杂度:选的每一样东西都在记账
技术复杂度不是你不会用某个工具,是你选的每个技术组件都是持续性的成本。
上了 Kafka,不是把消息丢进去就完了——你要维护 topic,要配 retention,要处理 consumer 失联,要应对 broker 宕机。上了 MySQL,不是建张表就完了——你要管连接池,要调索引,要看慢查询日志,要应对锁表。这些不是「出问题了再修」——是每个月、每周、甚至每天都会出现在你运维列表里的东西。
技术选型的账,选的当下只记了一行,但利息是按月收的。
案例:一个定时调度系统,最初用 cron + shell 跑着。后来有人建议上 Airflow——「以后任务会多,提前铺好调度平台」。搭了集群,配了 DAG,写了监控。一年后任务还是那两个,但 Airflow 每个月都出事:Scheduler 挂、Worker 失联、元数据库锁表。两个任务产出的数据价值,没覆盖 Airflow 一个月的运维成本。
管技术复杂度的原则只有一条:默认最简,往上加要有证据。 不是「你猜以后会用到」,是「你已经撞到了当前方案的瓶颈」。当前方案跑不动了——cron 的日志你翻不动、shell 的依赖你管不住、两个任务的执行窗口在打架——这时候你才去找下一个方案。你手里的证据告诉你「为什么 cron 不够用」,你选的替代方案就是在解决一个已知的问题,不是为一个想象的问题提前付账。
依赖成本核算是一个具体的习惯:每次引入新组件,列三样东西——它吃多少运维时间、它出问题模式是什么、它能不能被更简单的方案替换。如果三样都答不上来,就不是在用,是在赌。
成长复杂度:人多了,时间过了,代码漂了
一个系统在时间线上待久了,会有一种特殊的复杂度:语义漂移。 不是你代码写错了,是你代码当初表达的语义,和现在要做的语义,已经不是同一件事。一个函数叫 calculate_salary,三个月前它确实在算工资。今天它还在算工资——但中间被人改过几次,加了社保扣除、加了个税起征点、加了年终奖分摊。它还在叫原来的名字,但它做的事已经和这个名字覆盖的范围不一样了。名字没变,语义变了。这比代码 bug 更难发现——bug 是错了,语义漂移是「没全错,但也不全对」。
但语义漂移只是结果。真正让人头疼的是另一件事:一个决策做了之后,它带来的隐性约束没人写下来,然后随时间慢慢变成了所有人的雷。
案例:一个实时数据处理系统,最初的设计目标是接入单个数据源。后来团队为了在预发环境提前发现问题、方便测试,希望同时接入两个数据源做对比。从代码看,改动不大——加一个配置文件,多加一个数据源路径。数据源原来的设计就不支持多源合并,但为了不动现有系统逻辑,没在代码里加校验和互斥——想着「就是个测试用的临时配置,不值得大改」。
但问题从这开始。两个数据源之间其实不能互相重叠——这是数据源本身的语义决定的,但这条约束从来没被写下来,只在当初那个做配置的人脑子里。后来的人不知道,换了数据源,两个源的数据重叠了,结果出现了一些看起来正确但实际偏差的数据。每次出问题,排查的第一站永远是「是不是因为接了双数据源」——不是一次两次,是每次。一个本来为了发现问题的方案,反而成了问题最大的制造者。更讽刺的是:生产环境只跑单数据源,预发跑双源——环境本身就不一致了,你在预发发现的问题,可能是真的 bug,也可能是双源引入的假阳性。每次都要先排除「是不是配置的问题」,再去看逻辑。
「只是加一个配置」是复杂度最隐蔽的入口。 代码改动小不等于复杂度小。复杂度不在那几行代码里,在那几行代码带来的隐性约束里——数据源不能重叠、环境必须一致、配置更新必须同步。这些约束没人写,但每一个后续接手的人都要自己撞一遍才能学会。你省下的是一次代码重构,付出的是一年后每一次排查都要多走一步冤枉路。
管它的办法不是不让人改代码,不是让所有需求都走完整设计。是两件事:边界文档 + 定期重审。
边界文档不是写「这个系统做了什么」——那个需求文档里就有。是写**「这个系统不管什么」**:哪些场景它故意不覆盖,哪些边界条件它故意不处理,哪些扩展点它故意不接。双数据源的案例里,如果当初写下一句「本系统不保证多源之间的数据不重叠,多源接入仅用于开发/预发环境验证,生产环境不启用」,后来的排查就不用每次都从「是不是双源的问题」开始——没人写,它就变成一个口头知识,口头知识在团队里最多活三个月。
定期重审是:每过一段时间,把那些「临时加的配置」「临时开的开关」「临时做的兼容」拿出来扫一遍。问一句:这东西还在用吗?它在生产环境还是只在预发?它在解决什么问题——是发现了更多问题,还是制造了更多排查成本?如果答案是「制造了更多成本」,就关掉它。你不关,它就永远在;你关了,它才有机会被真正需要的方案替代。
三个来源讲完了。回到开头那三个特点:
演进性说的是复杂度怎么来的——不是一次性决策,是每轮迭代多叠一层。隐蔽性说的是为什么你在当时看不出来——每个决策在当下都合理,不合理的是事后才知道。非唯一性说的是为什么没有标准答案——同一个问题可以选 shell,也可以选 Airflow,选择之间没有对错,有代价。
三来源是让你知道去哪找:需求、技术、成长——每个方向都有它的复杂度在涨。三个特点是让你知道为什么难找:它是慢慢涨的、涨的时候合理、涨了之后回不去。
管复杂度不是让它不涨——你做不到。是让它涨在你看着的地方,不是涨在你没注意的地方。
寒蝉 Hancic

