功能明明正常,架构为什么还是会腐化?<第一章>

最近我开始阅读《分布式系统架构:架构策略与难题求解》。读第一章时,有一个概念让我反复停下来思考:架构适应度函数

第一次看到这个词,我的感觉是:这应该是一种复杂的数学函数,或者某种专门用于架构评估的算法。继续往下读,并结合自己之前设计秒杀系统的经历思考后,我才发现它并没有名字听起来那么神秘。

在我现在的理解里,适应度函数解决的其实是一个非常现实的问题:

架构师设计了一套规则,但随着代码不断迭代,我们怎样知道系统仍然遵守这些规则?

这篇文章记录我对第一章几个核心概念的理解:为什么架构没有所谓的最佳实践、什么才算架构决策、为什么需要 ADR,以及适应度函数到底在保护什么。

一、架构没有最佳实践,只有更合适的取舍

做开发时,我们经常会搜索"最佳实践"。

例如:

  • Spring Boot 项目应该怎样分层;
  • Redis 的 Key 应该怎样设计;
  • MQ 消费者如何实现幂等;
  • MySQL 索引应该如何建立。

这些问题通常能够找到相对通用的答案。但架构问题不一样,因为架构决策高度依赖具体上下文。

假设我正在设计一个秒杀库存系统,需要在数据库事务、Redis 分布式锁和 Redis Lua 之间选择一种扣减方式。如果脱离业务背景直接问"哪个方案最好",这个问题其实没有答案。

选择 Redis Lua,可以得到:

  • 更高的并发处理能力;
  • 单线程执行带来的原子性;
  • 更少的数据库行锁竞争;
  • 更低的核心链路延迟。

但与此同时,我也必须承担:

  • Redis 成为实时库存的事实来源;
  • Redis 与 MySQL 之间只能实现最终一致;
  • Redis 更新成功但 MQ 发送失败的一致性缺口;
  • 幂等、补偿、重试和对账机制带来的复杂度;
  • 更高的监控和故障排查成本。

所以,"Redis Lua 比数据库锁更好"并不是一个完整的架构结论。

更完整的表达应该是:

在峰值流量很高、数据库行锁已经成为瓶颈、业务允许异步落库,并且团队能够治理 Redis 与 MQ 一致性问题的前提下,Redis Lua 是一组更合适的取舍。

如果系统每秒只有几十个请求,同时对审计和强一致性的要求很高,那么直接使用数据库事务可能更加简单可靠。

我由此理解了第一章最重要的一句话:做架构不是寻找没有缺点的方案,而是决定当前系统更愿意承担哪一种缺点。

二、什么才算架构问题

书中对软件架构给出了一个非常有意思的理解:架构关注的是系统中那些以后很难修改的部分。

我更愿意把它理解为:

一个决定的影响范围越大、持续时间越长、修改成本越高,它就越接近架构决策。

例如下面这些问题通常属于架构问题:

  • 服务应该如何划分;
  • 数据应该归哪个服务所有;
  • 服务之间使用同步还是异步通信;
  • 一个业务流程是否允许最终一致;
  • 多个服务是否共享数据库;
  • 服务能否独立开发、测试和部署。

而下面这些问题通常更接近实现设计:

  • Java 类叫什么名字;
  • Lua 脚本中的变量如何命名;
  • 使用 Redisson 的哪个 API;
  • 某个异常类如何定义。

两者的边界并不是绝对的,但我发现可以用两个问题辅助判断:

  1. 这个决定以后是否很难修改?
  2. 修改它时是否会迫使多个模块、服务或团队同时变化?

如果答案都是"是",它大概率已经进入了架构范畴。

三、数据为什么会让分布式架构变得困难

第一章还强调了一件容易被忽视的事情:数据通常比应用程序活得更久。

服务可以重写,框架可以替换,部署方式也可能从虚拟机变成容器,但订单、库存、支付和客户数据不能随着技术升级而消失。

在传统单体系统中,多个模块往往共享同一个关系型数据库。数据库为我们解决了很多问题:

  • 本地事务;
  • 外键约束;
  • 一致性校验;
  • 跨表查询;
  • 统一报表。

当系统被拆成微服务后,数据库也开始进入服务边界。原来一个数据库事务能够完成的事情,现在可能横跨库存服务、订单服务和支付服务。

于是,数据所有权、分布式事务、最终一致性和跨服务查询都变成了架构问题。

这也让我意识到:微服务真正困难的地方并不只是远程调用,而是数据和事务被一起拆开以后,我们还要想办法把完整的业务重新连接起来

四、ADR:记录的不只是决定,而是决定背后的代价

架构决策往往会影响系统很多年。但现实中,团队通常只记得"最后选择了什么",却忘记了"当初为什么这样选择"。

半年以后,新成员看到 Redis、MQ、补偿任务和对账逻辑,可能会认为系统被过度设计,于是试图把它们简化掉。但他不知道,这些复杂度可能正是为了应对曾经出现过的数据库锁竞争和流量洪峰。

因此,第一章推荐使用架构决策记录,也就是 ADR(Architecture Decision Record)。

一份简化的 ADR 至少包含三部分:

text 复制代码
上下文:我们正在解决什么问题,有哪些候选方案?

决策:最终选择了什么,为什么这样选择?

后果:获得了什么,又付出了什么代价?

以秒杀库存为例,我可以这样记录:

ADR:秒杀库存使用 Redis 原子预占

上下文

活动峰值期间,MySQL 行锁竞争导致库存接口响应时间大幅上升。系统不能超卖,允许库存结果异步写入数据库。

决策

使用 Redis Lua 原子更新可用库存和预占库存,通过可靠事件机制异步同步到数据库。

后果

  • 降低数据库竞争,提高库存扣减吞吐量;
  • Redis 成为活动期间的实时库存来源;
  • 系统接受 Redis 与数据库在短时间内不一致;
  • 必须增加幂等、补偿、重试、对账和监控机制;
  • 团队需要处理 Redis 成功但消息发送失败等一致性缺口。

以前我写技术方案时,往往花大量篇幅描述"方案如何实现",却很少认真记录"为什么这样选择"和"它带来了哪些副作用"。

现在我认为,ADR 最有价值的部分恰恰是"后果"。因为只写优点的技术方案更像是在推销,而不是在做架构决策。

五、适应度函数:让架构规则真正执行起来

ADR 记录了架构决策,但记录不等于执行。

假设 ADR 规定:

text 复制代码
订单服务不能直接访问库存数据库,必须通过库存服务访问。

几个月后,一名开发人员为了快速完成需求,直接在订单服务中连接库存数据库。功能测试可能全部通过:订单能够创建,库存也能够查询。

但是系统的架构边界已经被破坏了。

以后库存表结构一旦改变,订单服务也必须跟着修改;库存服务也很难再独立部署。此时业务功能虽然正常,架构却已经开始腐化。

这正是适应度函数要解决的问题。

在我现在的理解中:

适应度函数就是把"系统应该保持什么架构特征",转换成能够持续运行和客观判断的检查机制。

它可以被抽象为:

text 复制代码
适应度函数(当前系统和运行环境) → 通过 / 不通过 / 一个度量结果

例如:

text 复制代码
代码中的循环依赖数量              → 必须等于 0
订单服务对库存数据库的直接依赖数量  → 必须等于 0
指定负载下接口的 P99 延迟           → 必须低于目标值
服务节点故障后的恢复时间            → 必须低于目标值
构建产物中的高危依赖漏洞数量        → 必须等于 0

这里的"函数"不一定是一个具体的 Java 方法。任何能够客观评估架构特征的机制,都可能成为适应度函数,例如:

  • ArchUnit 等代码结构测试;
  • CI/CD 流水线中的依赖检查;
  • 性能压测;
  • 线上监控和告警;
  • 安全漏洞扫描;
  • 混沌工程实验;
  • 必要时的人工合规审查。

如果把架构目标比作"我要保持健康",那么适应度函数就是血压、血糖、心率和体脂率等体检指标。

"系统要高性能"只是愿望;"在目标峰值流量下,P99 延迟低于约定值、错误率低于约定值"才是可以验证的标准。

六、功能测试和适应度函数有什么区别

这是我理解适应度函数时最容易混淆的地方。

功能测试关心的是:系统做的事情对不对。

适应度函数关心的是:系统是否仍然以我们期望的架构方式工作。

以库存系统为例:

检查内容 更接近的类型
扣减 1 件商品后库存应该减少 1 业务测试
可用库存不能小于 0 业务测试
同一个请求不能重复扣减库存 业务测试或集成测试
订单服务不能直接访问库存数据库 架构适应度函数
目标流量下 P99 延迟不能超过约定值 架构适应度函数
Redis 节点故障后系统必须在规定时间内恢复 架构适应度函数

两者并不总能完全分开,有些测试可能同时验证业务正确性和架构特征。但这个区分仍然很有帮助:

功能测试保护业务行为,适应度函数保护架构特征。

这也解释了为什么一个系统即使拥有很高的功能测试覆盖率,架构仍然可能逐渐变成"大泥球"。功能测试只能证明功能还能工作,不能证明模块边界、性能、弹性和可部署性仍然健康。

七、原子适应度函数和整体适应度函数

书中将适应度函数分为原子和整体两种类型。

1. 原子适应度函数

原子适应度函数一次只检查一个架构特征,例如:

text 复制代码
循环依赖数量必须等于 0。

它目标明确,结果也很容易判断。

2. 整体适应度函数

整体适应度函数同时观察一组相互影响的架构特征。

例如进行一次完整的秒杀压测时,我不能只看 QPS,还需要同时观察:

  • P99 延迟;
  • 错误率;
  • Redis CPU 和连接数;
  • MQ 堆积速度;
  • 数据库写入延迟;
  • 流量恢复后积压消息的消化时间;
  • 故障发生后的恢复能力。

如果系统通过大量排队提高了吞吐量,却让用户等待几十秒,那么只看 QPS 得出的结论就是片面的。

整体适应度函数关注的是:多个架构特征组合之后,系统的综合表现是否仍然可以接受。

八、把架构目标转成适应度函数

以后遇到一个模糊的架构目标,我准备使用下面这个模板:

当【某种代码变更、业务负载或故障】发生时,系统的【某个架构特征】必须满足【可测量标准】,否则执行【阻断发布、告警、回滚或人工确认】。

例如:

当订单服务代码发生变更时,它不得依赖库存服务的持久层,否则阻断构建。

或者:

当系统承受预计峰值流量时,接口的 P99 延迟和错误率必须低于约定目标,否则不能发布上线。

这个模板迫使我回答四个问题:

  1. 我要保护的架构特征到底是什么?
  2. 在什么情况下进行检查?
  3. 用什么客观标准判断?
  4. 不符合标准时应该采取什么行动?

只有这四个问题都有答案,"系统要高性能""模块之间要解耦"才不再是停留在架构图和会议纪要里的口号。

九、适应度函数不是越多越好

适应度函数很有价值,但它也可能被滥用。

如果架构师把所有个人偏好都变成强制规则,开发团队就会被大量约束束缚。即使业务背景已经发生变化,旧规则仍然可能阻止团队采用更合适的设计。

所以我认为,适应度函数应该优先保护那些:

  • 对业务非常关键;
  • 容易在日常开发中被无意破坏;
  • 能够客观衡量;
  • 一旦破坏就很难恢复的架构特征。

同时,它应该和 ADR 放在一起管理。如果业务上下文和架构决策发生了变化,对应的适应度函数也应该调整,而不是永远保持不变。

十、我的最终理解

读完第一章后,我对架构有了三个新的认识。

第一,架构没有脱离上下文的最佳实践。架构师真正的工作不是选择最流行的技术,而是明确当前最重要的目标,并判断团队愿意承担哪些副作用。

第二,架构决策必须记录。ADR 不只记录"选择了什么",更要记录"为什么这样选择"以及"为此付出了什么"。

第三,架构决策需要被持续验证。文档和架构图只能表达意图,适应度函数才能把重要的架构原则变成持续运行的保护栏。

如果一定要用一句话总结我对适应度函数的理解,我会这样说:

适应度函数就是架构的自动体检和保护栏,它防止系统功能仍然正常,但架构已经在不知不觉中腐化。

这也是我从第一章获得的最大启发:好的架构并不是设计完成的那一刻看起来多么漂亮,而是在经历一次次需求变更后,关键特征依然没有被破坏。


本文是我阅读《分布式系统架构:架构策略与难题求解》的学习记录。后续我会继续梳理耦合、架构量子、服务粒度、数据所有权、分布式事务和 Saga 等内容。

相关推荐
用户077911816611 小时前
记一次 yfinance 源码调试:SOCKS5 代理下 Chart API 正常,历史数据却一直超时
后端
步行cgn1 小时前
PageHelper 的用法
java·后端
foggyprojects1 小时前
业务系统有表格,BI 有看板,AI 问数还需要做什么?
后端
生锈的键盘1 小时前
深入理解 Socket 与 epoll:从内核数据结构到用户态回调的全链路解析
后端
Sam_Deep_Thinking1 小时前
从REST到gRPC,一个API选型的思考框架
java·后端·程序员
李高钢2 小时前
C# WinForms 架构与项目实战:多窗体通信、三层架构、数据库、日志与配置
架构·c#·winforms
程序员爱钓鱼2 小时前
Rust const泛型详解:让常量也成为泛型参数
后端·面试·rust
许彰午2 小时前
06-EaEngine调度器
java·低代码·架构
evans在进步2 小时前
Spring Boot 工程化核心详解:Parent、Starter、热部署、事务与多数据源
spring boot·后端·python