系统架构设计:把约束条件落成可执行、可验证、可回滚的决策

发布时间:2026/10/27 16:22:58
系统架构设计:把约束条件落成可执行、可验证、可回滚的决策

接到"把系统架构设计出来"这句话,绝大多数人的第一反应是打开画图工具,先摆几个方框、拉几条箭头,把缓存、消息队列、数据库都堆上去,然后截图发群里等一句"看着挺完整"。我见过不止一个团队这么做,图很漂亮,评审也过了,结果上线三个月后对账对不上、发版要停服、加个字段要改五个地方。问题不在于图画得丑,而在于系统架构设计的本质不是画一张让别人点头的图,而是提前把未来一到两年会反复咬你的那些约束条件,落成一组可验证、可执行、可回滚的决定。构建系统应用这件事,代码只是最后的表达形式,真正决定它能活多久的,是边界怎么切、数据怎么放、契约怎么定、容量怎么算、决策怎么留痕。这篇内容面向正在做系统架构设计的技术负责人、刚被推上架构岗的资深开发,以及需要评审架构方案的同行,我会把每个环节"为什么这么定"讲透,给出可以直接抄的参数、模板和检查清单,也会把踩过的坑原样摆出来。

1. 先别急着分层,把散落在对话里的约束捞干净

我在多个项目里复盘过一个共性现象:架构方案出问题,八成不是技术选型选错,而是一开始就没搞清楚前提条件。有人把"高并发"挂在嘴边,问到峰值多少、能接受多长的延迟、数据能不能短暂不一致,回答往往是"应该挺高的""越快越好""最好一致"。这种模糊表述进到设计里,就变成了拍脑袋的分片数、拍脑袋的副本数、拍脑袋的缓存过期时间。

所以真正的第一步,是把口头需求翻译成两类硬东西:质量属性场景和约束清单。这两样东西写出来之后,很多设计争论会自动消失,因为大家讨论的不再是"我觉得应该用微服务",而是"按这个并发量和这个团队人数,微服务带来的发布协调成本我们扛不扛得住"。

1.1 质量属性场景:把"要快"拆成带条件的句子

一个合格的质量属性场景必须包含六个要素:刺激源、刺激、所处环境、受影响的部件、期望响应、响应度量。缺了最后一项,这个场景就没法验证,也就没法指导设计。

举个具体对比。"系统要快"是废话,"在促销活动期间、峰值每秒 800 次下单请求的环境下,订单创建接口的 P99 响应时间不超过 300 毫秒,且错误率低于 0.1%"才是能落到设计里的句子。后者直接决定了你要不要做读写分离、要不要预热缓存、要不要把风控校验异步化。

注意我强调的是P99 而不是平均值。平均值是最会骗人的指标,一个接口 95% 的请求 20 毫秒返回,5% 的请求 3 秒返回,平均值依然漂亮,但用户体验会被那 5% 彻底毁掉。做容量设计时,我一般会要求团队同时盯三个数:P50 看常态、P99 看体验、P99.9 看是否有个别请求被卡死(往往是连接池耗尽或慢查询)。

下面这张表是我常用的场景登记表格式,直接照抄改数字就能用:

场景编号刺激源刺激环境受影响部件期望响应响应度量
QA-01终端用户提交订单活动峰值,QPS 800订单服务、库存服务返回订单号并锁定库存P99 ≤ 300ms,错误率 ≤ 0.1%
QA-02运营人员导出月度报表工作日白天,数据量 500 万行报表服务、只读库异步生成并给出下载链接任务 10 分钟内完成,主库负载增幅 < 5%
QA-03上游系统批量同步 2000 条主数据凌晨批处理窗口主数据服务全部成功或全部回滚单批次 ≤ 60s,失败可重试且不产生重复

这张表的价值在于,它会逼你把"报表导出"这种平时没人管的功能单独拎出来。我踩过一次坑:报表查询直接打到主库,白天运营一点导出,主库 CPU 冲到 90%,所有在线请求跟着抖动。当时如果早写了 QA-02 这一行,这个设计缺陷在评审阶段就能被发现,而不是等到业务方投诉。

1.2 约束清单:把团队和运维能力也算进架构里

很多人做架构设计时只算技术账,不算人的账。但现实是,架构的复杂度上限由团队规模和运维能力决定,不由技术先进性决定。一个 6 个人的团队维护 15 个独立服务,每个服务都要有独立的配置、日志、监控、告警、发布流水线,最后的结果一定是没人敢改,发布日变成灾难日。

我在一个项目里见过这样的局面:服务拆得很细,但只有两个人懂整套部署流程,其中一个人休假,整个迭代就卡住。这不是技术问题,是架构设计和组织能力不匹配。

所以约束清单至少要覆盖下面几项,并且要写进架构文档的开头,让后面所有决策都能追溯到它:

约束项实际情况示例对架构的直接影响
研发团队规模后端 7 人,分 2 个小组服务数量控制在 6~8 个以内,避免跨组协调爆炸
交付节奏双周迭代,每月一次大版本必须具备灰度发布和快速回滚能力
运维值班能力无专职运维,开发轮值优先选托管能力强的中间件,减少自建组件
现有技术栈团队熟悉某一种语言与框架新引入语言最多一种,且需有明确收益
数据保留要求交易数据保留 3 年可查冷热分离必须提前设计,不能后期补救
预算约束单月基础设施成本上限副本数、实例规格需按容量估算反推

这张表看起来朴素,实际上是最能救命的。每次有人提议"我们上个服务网格吧",你就把这张表拿出来对一遍:值班能力那一栏写着"开发轮值、无专职运维",答案自然就有了。

提示:约束清单不是一次性文档。团队从 7 人扩到 20 人、从单体迁到多服务,约束就变了,架构也该跟着重估。我习惯在每个季度末把这张表拿出来对一遍,改动的行用不同颜色标出来。

2. 拆分粒度:边界要划在"变化节奏不一致"的地方

关于拆分,市面上讲得最多的是"按业务领域拆",这话没错但太粗。真正决定拆分质量的,是你能不能找到那些变化频率明显不同的部分,把它们隔开。两个模块如果永远一起改,拆开只会增加跨服务调用;两个模块如果一个一周改三次、一个一季度改一次,混在一起就会互相拖累,改快的那个要先回归测试慢的那个。

我判断粒度时常问三个问题:这块东西多久变一次?谁在改它?改它的时候会不会被迫改别的地方?三个问题的答案基本就能定出边界。

2.1 先按业务能力切粗块,再按变化频率切细

第一步永远是粗切,切出四到六个大块,比如商品、交易、履约、账务、用户、通知。粗切的好处是稳定,业务能力对应的是组织分工,短期内不会大变。切完之后不要急着继续往下拆,先做第二步:标出每个大块内部各个部分的变化频率。

拿交易举例。下单流程里的"价格计算"和"库存扣减"看起来都在交易域,但变化节奏完全不同:价格计算里塞着各种优惠规则、会员折扣、限时活动,运营几乎每周都在调;库存扣减的规则相对稳定,主要是防超卖和预占释放。这两块放在同一个模块里,每次调价格规则都要把库存逻辑一起回归,测试成本直接翻倍。

这种情况我会把它们拆成两个独立模块,各自有独立的发布节奏。注意,拆模块不等于拆服务。如果团队规模不大、并发量不高,完全可以先在同一个应用进程内做成清晰分层的模块,通过接口调用而不是直接访问对方的数据表。等真正出现独立扩容或独立发布的需求时,再把它抽成独立部署单元。这就是常说的模块化单体,它保留了拆分的清晰度,又省掉了分布式调用的一致性麻烦。

我把这个判断逻辑整理成一张对照表,实际评审时经常用:

判断信号建议做法理由
两部分总是一起改、一起测合并为一个模块拆开只会增加调用开销
变化频率差 5 倍以上拆为独立模块,明确接口隔离变更影响面
一部分需要独立扩容考虑独立部署单元避免整体扩容浪费资源
数据强一致要求高、调用频繁保持同进程同库避免分布式事务复杂度
团队分属不同小组、职责清晰独立部署单元发布节奏解耦,权责对齐

2.2 依赖方向检查:别让"公共工具包"变成垃圾场

边界划好之后,第二件必做的事是画依赖图并检查方向。原则很简单:依赖只能指向更稳定的那一方。稳定指的是变化少、被依赖多。业务模块依赖基础工具是合理的,基础工具反过来依赖某个具体业务模块就是灾难。

我遇到过最典型的翻车案例是"公共工具包"。一开始只是放几个日期格式化函数,后来有人往里塞了个通用的用户信息查询,再后来有人塞了个订单状态枚举,半年后这个包依赖了用户模块、订单模块和配置中心,所有业务模块又都依赖它,依赖图直接成环。最终的结果是改一个枚举值,全公司十几个应用都要重新编译发布。

防御办法有三条,都是低成本高收益的:

  • 公共包只放无业务语义的东西:字符串处理、时间转换、通用异常定义、加解密封装。凡是提到具体业务实体的,一律不进公共包。
  • 定期跑依赖分析:把模块间的依赖关系导出来看有没有环。有环的地方,通常就是职责没划清的地方。
  • 禁止反向依赖用编译期强制:多模块工程可以把依赖方向写进构建配置,业务模块可以依赖基础模块,基础模块依赖业务模块直接编译失败。

注意:依赖成环往往不是一次形成的,而是每次"临时先用一下"累积出来的。我建议在迭代回顾会上固定花五分钟看依赖图的变化,成本极低,能挡住大部分腐化。

3. 数据边界比服务边界更贵:先定一致性,再定调用方式

服务拆错了还能合并,数据拆错了要洗数据、要对账、要停机迁移,代价高一个数量级。所以我在做系统架构设计时,顺序永远是:先想清楚数据怎么放、事务边界在哪、跨边界怎么保证最终一致,最后才决定服务怎么调。

很多架构方案的毛病是反过来的:先画了一堆服务,然后发现下单要同时写订单库和库存库,于是引入分布式事务,再发现分布式事务性能不行,于是改成异步消息,最后发现消息会重复消费,于是补幂等。这一路都是被动的。如果一开始就把一致性想清楚,这些补丁在设计阶段就能预埋。

3.1 事务边界:必须同时生效的数据,放在同一个事务里

划分事务边界的判断标准只有一条:这两次写入如果只成功了一半,业务上能不能接受。不能接受的,必须在同一个本地事务内。

以"下单扣库存"为例。如果订单写成功但库存没扣,就会出现超卖,这是业务不能接受的。所以这两步要么放同一个库同一个事务,要么用可靠消息加补偿。前者简单直接,后者灵活但有延迟。选择哪一个,取决于并发量和数据库承载能力,不取决于时髦程度。

一个可用的本地事务写法大致是这样,重点在于把业务状态变更和消息记录放在同一个事务里,这是后续异步投递不丢消息的基础:

BEGIN; -- 1. 扣减库存,带条件防止超卖 UPDATE stock SET available = available - :qty, version = version + 1 WHERE sku_id = :skuId AND available >= :qty; -- 检查影响行数,为 0 说明库存不足,直接回滚 -- 2. 写入订单主记录 INSERT INTO orders (order_no, user_id, sku_id, qty, status, created_at) VALUES (:orderNo, :userId, :skuId, :qty, 'CREATED', NOW()); -- 3. 同一个事务内落一条待投递消息 INSERT INTO outbox_message (msg_id, topic, payload, status, created_at) VALUES (:msgId, 'order.created', :payload, 'PENDING', NOW()); COMMIT;

这段代码的关键是第三步。把消息写进本地表,和业务数据共享同一个事务,就保证了"业务成功则消息一定被记录"。之后由独立的投递进程扫描 PENDING 状态的消息发出去,发成功改状态。这样即使消息中间件短暂不可用,也不会丢事件。

我见过一个团队直接用消息中间件在事务里发消息,业务回滚了消息却发出去了,下游凭空多出一批"幽灵订单"。这类问题的根因不是中间件不好用,而是没搞清楚"本地事务"和"外部投递"这两件事压根不在一个原子范围内。

3.2 跨边界读写:三种方案与它们的真实代价

跨服务的数据读写,我一般只推荐三种方案,每种都有明确的适用边界和代价,选错了就是自找麻烦。

方案一致性典型延迟复杂度适用场景
同步调用准实时,调用方感知结果毫秒到百毫秒级低需要立刻知道结果的校验类操作,如余额校验
异步消息 + 本地消息表最终一致,秒级收敛秒级中状态流转、通知、积分发放等可延迟的操作
定时对账 + 补偿最终一致,分钟到小时级分钟级中高资金、库存等必须有兜底核对的场景

选型的核心问题是:这个操作晚 3 秒生效,业务能不能接受。能接受就走异步,不能接受才考虑同步。千万别为了"实时"把所有交互都做成同步调用,一条链路上挂五个同步调用,任何一个抖动都会把整条链路拖垮,可用性会变成各环节可用性相乘——五个 99.9% 的服务串起来,整体就掉到 99.5% 以下。

异步方案里我最看重的是补偿逻辑的幂等性。下面这段是常见的补偿骨架,思路是"先查状态,再决定要不要改",而不是无脑重试:

def compensate_order(order_no): order = order_repo.find(order_no) if order is None: log.warn("order not found, skip", order_no=order_no) return # 已经处理过就直接返回,保证幂等 if order.status in ("CANCELLED", "CLOSED"): return # 只有处于待支付超时状态才需要回滚库存 if order.status == "CREATED" and order.expired_at < now(): stock_service.release(order.sku_id, order.qty, biz_no=order_no) order_repo.update_status(order_no, "CANCELLED")

这段代码里biz_no传订单号,是为了让下游的库存服务能做去重——同一个订单号重复释放库存,只生效一次。很多"库存越放越多"的事故,根子就在补偿接口没有做业务去重键。

提示:凡是走异步的地方,一定要在设计阶段就回答两个问题——消息重复了怎么办、消息丢了怎么发现。前者靠幂等键,后者靠对账任务。两个都没有的方案,不能上线。

4. 接口契约:把"以后能改什么"提前写进设计

系统应用一旦对外提供服务,接口就是承诺。麻烦在于,写第一个版本时没人知道半年后要加什么字段,于是最常见的做法是"先这么写着,以后再说"。等到真要改的时候,发现下游有十几个调用方,谁也不敢动,最后只能加一个 v2 接口,v1 永远留着,代码里长出一片分叉。

这种局面可以在设计阶段大幅缓解。做法是把契约当成产品来设计:默认值怎么取、幂等键怎么传、超时怎么设、错误码怎么分段、哪些改动算兼容、哪些必须走废弃流程。这些东西定下来之后,后面所有人的接口都会长得像一家人。

4.1 幂等、超时、错误码:三件必须默认具备的东西

幂等。写操作必须支持幂等,这是底线。落地方式很简单:调用方在请求头或请求体里带一个幂等键,服务端用它做去重。推荐用"业务单号 + 操作类型"拼成键,而不是随机 UUID,因为随机值重试时会变,等于没做幂等。

{ "requestId": "ORDER_CREATE_20240612_8891023", "bizNo": "SO202406120001", "skuId": "SKU-10086", "qty": 2 }

服务端拿到requestId后先查去重表,命中则直接返回上次结果,未命中则处理并记录。去重表要设过期时间,一般 24 小时足够覆盖绝大多数重试窗口。

超时。超时设置有个铁律:外层超时必须大于内层超时之和。客户端等 3 秒,服务端内部调两个下游各等 2 秒,那客户端大概率在服务端还没返回时就断开,结果是服务端做完了但结果没人收,白白浪费资源还可能造成重复。正确做法是从外到内逐层递减,给每层留出处理余量:

层级建议超时说明
客户端(App/前端)5s用户可感知上限
网关4s留 1s 给网络抖动
业务服务总超时3s含内部逻辑处理
单次下游调用800ms最多串 2 次,留出重试余量
数据库查询300ms超时即视为慢查询,需治理

这张表不是让你照抄,而是让你意识到超时必须有层级关系。我见过太多服务用框架默认的无限等待,结果一个下游卡住,上游线程池被占满,雪崩从最边缘一路传到入口。

错误码。错误码最忌讳的是"一个 500 打天下"。调用方拿到 500 完全不知道该重试还是该报错给用户。我习惯按段位划分,让错误码自己说明处理方式:

段位含义调用方应对
0成功正常处理
1xxxx参数类错误不重试,提示用户修正
2xxxx业务规则拒绝(如库存不足)不重试,按业务分支处理
3xxxx系统内部错误可重试,建议指数退避
4xxxx下游依赖不可用可重试,需配合熔断
5xxxx限流或降级中退避后重试或走兜底逻辑

有了这套分段,调用方可以写通用处理逻辑:看到 3 和 4 就重试,看到 1 和 2 就直接把消息透给用户。这比每个接口单独写判断要省事得多。

4.2 版本演进:哪些改动算兼容,哪些必须走流程

接口改动分三档,团队必须提前达成共识,否则每次改接口都要开会吵一遍。

第一档是完全兼容:新增可选字段、新增枚举值(调用方能忽略未知值)、放宽校验规则。这类改动可以直接发,不需要通知。

第二档是条件兼容:新增必填字段、修改字段默认值、调整返回字段顺序。这类要发通知,给下游留出适配窗口,通常不少于两周。

第三档是不兼容:删除字段、修改字段语义、修改错误码含义、调整鉴权方式。这类必须走废弃流程,标准动作是四步:文档标注废弃、接口双写新旧两套、观察调用量降到零、正式下线。整个过程通常需要一到两个季度。

我印象最深的一次事故,是某个接口把金额单位从"元"改成了"分",字段名没变,注释里写了一行小字。下游有三家没注意到,上线后所有订单金额放大了一百倍,当天退款单堆了几千条。这类问题的根因不是技术,而是契约变更没有强制流程。后来我们的做法是:字段语义变更一律新增字段名,比如amountYuan改成amountCents,老字段保留只读。名字长一点,但没人会看错。

5. 容量与成本:让方案从"能跑"变成"能扛"

架构评审上最容易糊弄过去的一句话是"我们扛得住高并发"。问具体能扛多少、依据是什么,往往答不上来。容量这件事没有玄学,就是算术题,只是大部分人懒得算。算一遍的成本大概是半小时,收益是避免上线后被流量打穿,这笔账很划算。

5.1 一次完整的容量推演,从日活算到机器数

我常用的推演路径是这样的,每一步都有明确假设,假设写出来就能被质疑,被质疑就能修正。

第一步,估请求量。假设日活 20 万,人均每天产生 30 次有效请求,总日请求量就是 600 万。平均 QPS 大约是 600 万除以 86400,约 70。但平均值毫无意义,业务流量一定集中在少数时段。按经验,峰值系数取 8 到 12 比较稳妥,取 10 的话峰值 QPS 约 700。

第二步,估单机能力。假设单实例在压测下能稳定处理 300 QPS(这个数字必须实测,不能拍脑袋,且要以 P99 达标为前提)。那么 700 除以 300 约等于 2.3,向上取整需要 3 台。再考虑高可用,任何一台挂掉后剩余实例要能承接全部流量,3 台挂 1 台剩 2 台只有 600 QPS,不够。所以至少 4 台,挂 1 台剩 3 台刚好 900 QPS,留出了 30% 余量。

第三步,估存储。单条订单记录含索引约 1.5 KB,每天 5 万单,一年 1825 万单,约 27 GB。加上索引和副本,实际占用按 3 倍算约 81 GB。三年数据保留就是 243 GB,单机放得下但要考虑备份和查询性能,所以做冷热分离,只把近三个月的数据留在热库。

把这些整理成一张表,评审时一目了然:

估算项假设结果
日请求量日活 20 万 × 30 次600 万
峰值 QPS平均 70 × 峰值系数 10700
单实例能力压测 P99 达标前提下的稳定值300 QPS
最少实例数700 / 300 向上取整 + 高可用冗余4 台
年订单数据量5 万单/天 × 365 × 1.5KB约 27 GB
三年存储占用27 GB × 3 年 × 3 倍(索引与副本)约 243 GB

这张表最重要的是"假设"那一列。评审时有人觉得峰值系数取 10 太高,可以当场讨论改成 6 还是 12,讨论完数字跟着变,方案也就跟着调。整个过程不吵架,只算账。

注意:压测得出的单机 QPS 一定要注明测试条件——数据量级、请求体大小、是否命中缓存、数据库负载。脱离条件谈 QPS 都是耍流氓。我们有一次压测结果特别漂亮,后来发现是缓存全命中,缓存一失效 QPS 直接掉到三分之一。

5.2 缓存、限流、降级:把触发条件写死在配置里

这三个词谁都会说,难的是说清楚什么时候触发。我的习惯是把触发条件和动作都写成配置,让系统自己判断,而不是等人半夜爬起来手动操作。

缓存,关键指标是命中率。命中率 90% 和 95% 对后端压力的影响是翻倍的,所以我会给核心缓存设一条告警线,比如连续 5 分钟命中率低于 85% 就告警。缓存过期时间不要一刀切,热点数据可以短一些保证新鲜度,冷数据长一些减少穿透,具体取值看数据变更频率,变更越频繁过期越短。

限流,阈值怎么定?我的做法是按照容量估算结果的 70% 设置,留 30% 给突发。上面算出 4 台机器 1200 QPS 的总能力,限流阈值就设在 840。超过部分直接拒绝并返回 429 类错误码,让调用方退避重试。这比让所有请求都慢吞吞地排队要好,因为排队会把线程池占满,最后连正常请求都进不来。

rate_limit: order_create: threshold_qps: 840 # 容量估算 1200 的 70% algorithm: token_bucket # 令牌桶,允许短时突发 burst: 120 # 突发容量 fallback: reject_with_retry_after query_order: threshold_qps: 2000 algorithm: sliding_window burst: 0 fallback: serve_from_cache

降级,核心原则是可回滚、有优先级、能自动触发。降级不是关掉功能,而是把功能从"精确"降到"够用"。比如推荐服务挂了,返回热门榜单;风控服务超时,走简化规则并标记为待人工复核;非核心的积分、成长值更新改成异步补算。每个降级开关都要有一个明确的触发条件(错误率超过 20% 持续 30 秒)、一个明确的恢复条件、以及一个能在 10 秒内回滚的入口。

我特别想强调降级的优先级排序。真出事的时候,人是慌的,不可能现场想哪个能关哪个不能关。所以要在设计阶段就列好清单:一级不可降(下单、支付、登录),二级可降级为简化版(推荐、搜索联想、评价展示),三级可直接关闭(活动弹窗、签到提醒)。这份清单贴在值班文档第一页,比任何监控大盘都管用。

6. 决策留痕:让半年后的自己看得懂当初为什么这么定

架构设计里最容易被忽略、事后最痛苦的,是决策记录。半年后有人问"为什么订单库不分片",没人答得上来,于是要么维持现状不敢动,要么贸然重构踩一遍老坑。我在项目里推行架构决策记录(ADR)已经有些年头,最大的感受是:它不是为了给别人看,是为了给未来的自己留一份说明书。

6.1 一份能用的 ADR 长什么样

市面上的 ADR 模板很多,我最终固定下来的版本只有五个字段,多了没人写,少了不够用。

字段内容要求常见错误
背景当时面临什么问题,有哪些硬约束写成需求文档,太长
决策我们决定做什么,一句话说清含糊其辞,没有明确结论
备选方案考虑过哪些,为什么没选只写选中的,不写落选的
后果这个决策带来什么好处,付出什么代价只写好处,不提代价
失效条件什么情况下这个决策需要重新评估完全不写,导致决策永久化

"失效条件"这一栏我认为最有价值,也最少人写。举个例子:当初决定"订单表不分片,因为单表数据量三年内不超过 5000 万行,单库完全扛得住",失效条件就该写成"当订单表行数超过 4000 万行,或单表查询 P99 超过 200ms 时,重新评估分片方案"。有了这句话,运维在监控里加一条告警就行,不用天天惦记。没有这句话,要么过度设计提前分片,要么等到查不动了才手忙脚乱。

一份真实的 ADR 大概是这样:

ADR-007 订单与库存的一致性方案 背景:下单需同时写订单和扣库存,两者必须同时成功。 团队 7 人,无专职运维,峰值 QPS 700。 决策:采用同库本地事务,订单库与库存库合并部署。 备选方案: - 分布式事务:强一致,但性能损耗约 40%,且运维复杂度高,团队无力维护。 - 异步消息:性能好,但需要额外的补偿与对账机制,当前数据量下收益不明显。 后果:获得简单可靠的强一致性;代价是订单与库存无法独立扩容, 单库写入能力成为上限,预计可支撑到峰值 QPS 1500。 失效条件:峰值 QPS 持续超过 1200,或库存表数据量超过单库容量上限时,重新评估拆分方案。

这份记录不到 300 字,但它把当时的判断依据、权衡过程和边界条件全部固化下来了。后来有人提议拆库,直接把这段拿出来看,发现离失效条件还有距离,讨论五分钟就结束了。

6.2 技术债要登记,但更重要的是排偿还节奏

技术债这个词被用滥了,很多团队把它当成"以后再说"的遮羞布。我的做法是建一张债台表,每条债都有明确的影响面和触发条件,然后按影响面排序,固定每个迭代拿出 15% 到 20% 的容量来还。

债台表的字段我一般这么定:债务描述、产生原因、影响面(性能、稳定性、可维护性、安全)、触发条件(什么情况下会真的出问题)、预估偿还成本、责任人。其中"触发条件"和"影响面"决定了优先级,偿还成本决定了排期。

举几个真实的例子。接口没有做幂等,影响面是稳定性,触发条件是"网络抖动导致重试时",偿还成本低(半天),优先级高,下个迭代就做。日志里打了用户敏感字段,影响面是安全合规,触发条件是"任何时候",属于必须立刻处理,插队做。某个模块的单元测试覆盖率只有 20%,影响面是可维护性,触发条件是"下次大改时",偿还成本高(一周),优先级低,排进季度规划。

这里有个经验:不要试图一次性还清技术债。技术债是架构演进的必然产物,追求零债务的团队通常会陷入无休止的重构,业务需求反而被拖住。真正有效的做法是控制增速、优先处理高影响面的、把偿还动作嵌进日常迭代,而不是搞突击式的重构专项。

提示:每个迭代的技术债偿还容量不要超过 20%。超过这个比例,业务方会开始质疑团队产出,压力传导下来,最后债务偿还第一个被砍掉。稳定的小步偿还比一次大规模重构更容易存活。

7. 一些不太上台面但很管用的经验

写到这里,前面六节讲的是方法,最后说几条实际干活时攒下来的体会,都是文档里不会写的东西。

架构方案一定要有"最小可行版本"。我见过太多方案追求一步到位,画了十几个服务、三套存储、两套消息,评审通过后半年还没上线。真正跑起来的方案通常是从最土的那个版本开始的:一个应用、一个库、几台机器,先跑通业务闭环,再根据实际压力一点点拆。拆的前提是有监控数据支撑,而不是"我觉得以后会不够"。

评审时最该问的问题是"这个方案最先撑不住的地方在哪"。这个问题会逼着设计者去想瓶颈,而不是只讲优势。答得出瓶颈在哪、什么时候触发、到时候怎么改,说明这个人是真的想清楚了。答不出来,方案大概率是照着模板拼的。

别在架构文档里写满技术名词。我见过一份方案,一页纸里出现了十几个框架名,读完不知道它到底要解决什么问题。好的架构文档第一段永远是"我们要解决什么问题、现有做法为什么不行、这个方案怎么解决",术语往后放。

上线前演练一次"最坏情况"。把主库停掉、把缓存清空、把某个下游打挂,看系统怎么反应。这个过程往往能暴露出降级开关失效、告警没配、值班人不知道找谁这类问题。演练一次的成本是一天,收益是上线后第一个月的安睡。

架构不是设计出来的,是演进出来的,但演进不等于放任。每次演进都应该有一个明确的触发信号,来自监控、来自容量、来自团队变化,而不是来自某个人的审美偏好。有信号就有依据,有依据就能说服人,也能在事后复盘时说得清楚。

最后再分享一个我自己一直在用的判断标准,很好用:如果一份架构方案,你能用三句话向外行讲清楚它在解决什么问题、为什么这么做、什么情况下要改,那它大概率是个好方案。讲不清楚的,多半是自己还没想明白。