1. 先讲清楚:架构到底解决什么问题
1.1 架构不是画图,而是约束
说到"现代软件开发中常用架构",很多人的第一反应是打开画图工具,画一堆方块、箭头、数据库图标,然后开始争论"微服务是不是比单体高级"。我做了十几年后端开发和架构设计,见过不少团队在架构上反复折腾,也踩过很多坑,今天想换个角度把这事聊透:架构不是一张漂亮的架构图,而是一组约束,是你在"响应速度、一致性、成本、维护性"之间做出的取舍。
为什么这么说?因为架构的本质是决策,而且是那种改了代价极大的决策。你选了一个分层结构,后续写代码就得遵守依赖方向;你选了微服务,就得承担网络调用、数据一致性、链路追踪这些额外成本。架构图只是决策的可视化结果,真正决定系统命运的,是背后那一连串权衡。比如订单系统要实时库存扣减,用强一致的本地事务还是最终一致的消息补偿,这就是架构决策,它直接决定了你在双十一高峰能扛多少流量,也决定了出问题时你要赔上多少个通宵。
所以这篇文章不聊玄学,把单体、分层、微服务、事件驱动、CQRS、Serverless 这些常见架构从头到尾梳理一遍,说清楚每个架构解决什么问题、引入什么成本、适合什么团队,最后给一套可以照抄的选型方法。适合正在做系统设计的开发者、被分布式问题折磨的运维同学,以及准备给团队做技术规划的负责人。
1.2 常用架构的家族谱系
先给这些架构排个序,很多混乱都源于概念层次没分清。单体、微服务、事件驱动、CQRS 这些词,其实不是一个维度上的东西:有的说的是"部署形态",有的说的是"代码组织方式",有的说的是"数据读写模型"。把它们混在一起讨论,永远吵不出结果。
| 架构 | 核心维度 | 一句话定位 |
|---|---|---|
| 单体架构 | 部署形态 | 一个应用包含所有功能,一次构建、一次部署 |
| 分层架构 | 代码组织 | 表现、业务、数据访问按层次划分,关注点分离 |
| 整洁/六边形架构 | 代码组织 | 让业务核心不依赖框架和外部细节,依赖方向由外向内 |
| 微服务架构 | 部署形态 | 按业务边界拆成多个可独立部署的服务 |
| 事件驱动架构 | 交互方式 | 服务之间通过异步事件通信,削峰填谷、解耦上下游 |
| CQRS | 数据模型 | 读和写分离,各自使用最合适的模型和存储 |
| 事件溯源 | 数据模型 | 不存当前状态,只存状态变更事件流,重放即还原 |
| Serverless | 运行形态 | 按需运行、按调用计费,连服务器运维都省掉 |
后面每一节都会拆开细讲。有一点先说明:这些架构不是互斥的,一个复杂系统完全可以同时是"模块化单体 + 分层内部结构 + 关键链路事件驱动",没必要非此即彼。你真正要练的,是搞清楚每个架构在什么条件下成立、在什么条件下开始痛。
2. 单体架构:被低估的默认选项
2.1 单体为什么被低估
现在一说单体,很多人语气里就带着鄙视,好像不拆微服务就不够现代。但我要说句大实话:单体是现代软件开发里被低估最严重的架构。它不是"落后",而是"默认"。绝大多数业务系统,在团队规模小于两个披萨、用户量还没到百万级、发布节奏还在每天几次以内的时候,单体就是最优解。
为什么?因为单体的成本结构太清晰了。一台进程,一个数据库连接池,事务直接走数据库本地事务,日志打在一个文件里,Debug 直接断点打进去,链路都不用追。出了问题,看日志、看监控、回滚发布,三步搞定。这个效率是微服务在同等规模下给不了你的。
单体的真正问题从来不是"一个进程放不下代码",而是边界腐化。代码量大了以后,如果没有清晰的模块边界,就会变成一团互相乱引用的意大利面:下单逻辑里直接 new 了个物流对象,支付模块偷查了订单表,工具类里什么业务都有。这时候的问题不是单体本身,而是没有纪律。
2.2 模块化单体的落地姿势
我自己经历过一个项目:一个单体应用,代码量到五十万行,十八个人维护,发布还是能控制在二十分钟内,关键靠的就是模块化纪律。讲几个可以照抄的做法:
第一,包结构按业务模块划分,禁止按技术类型划分。比如订单、用户、支付、库存各建一个模块包,模块内自己管 controller、service、dao。你要是按 controller、service、dao 分层建包,业务逻辑就会散落在各个技术层里,改一个需求要跨四个包,边界等于没有。
第二,模块间只允许通过模块暴露的接口交互,禁止跨模块直接访问数据库表。订单模块要读用户信息,只能调用户模块的接口。这样数据库 schema 的改动就不会连锁炸掉所有模块。说实话,能做到这一条的单体,维护体验已经超过一半没拆干净的微服务了。
第三,用 ArchUnit 这类工具在 CI 里强制校验依赖方向。人靠自觉不靠谱,我在代码审查里被漏掉过无数次"顺手引了个不该引的类"。后来把架构规则写进自动化测试,谁往错误方向引依赖,构建直接红。这个习惯花了半天时间配置,换来了两年的清净。
// 模块接口示例:订单模块只依赖这个接口,不关心用户模块内部实现 public interface CustomerService { Customer getCustomerById(String customerId); }2.3 什么时候才需要拆出去
模块化单体做到位之后,你的系统已经比大多数微服务健康。那什么时候才真的需要拆?我总结出三个信号,全部命中才值得动手:
- 团队规模达到 7~10 人以上,代码变更频繁互相冲突,一个需求要协调三个组。
- 发布频率被拖垮:某个模块每周要发三次,另一个模块一个月发一次,但被迫跟着一起发布,风险被无限放大。
- 出现独立的扩展需求:比如某个模块的流量是其他模块的十倍,但单体必须整机扩容,成本不可接受。
注意,这三个信号说的是"痛点能不能被具体描述出来",而不是"代码有十万行了感觉该拆了"。我见过一个团队,五个人,日活五百,硬拆了十几个微服务,结果一半时间在配网关、传链路 ID、处理消息重试。拆微服务前先问自己:你要解决的是痛点,还是焦虑?
3. 分层与整洁架构:把代码组织成可维护的形态
3.1 三层架构的正确理解
无论你用单体还是微服务,一个服务内部的代码组织,通常还是逃不开三层架构:表现层、业务逻辑层、数据访问层。这个概念人尽皆知,但落地的时候九成团队会掉进一个坑——把所谓三层做成了"数据库的四层"。
具体表现就是:Controller 里堆业务判断,Service 里全是拼接 SQL,Repository 里躺着几百行 if-else。职责分层的目标永远是"让复杂业务可以被理解",但很多人理解成了"每个层都必须有一堆代码"。正确的状态是:业务规则尽量沉淀在 Service 层;Controller 只做参数校验和协议转换;数据访问层只做持久化映射,不承载业务判断。
另外还要分清两种业务组织风格:事务脚本和领域模型。事务脚本就是 Service 方法里一步步写流程,适合业务简单的系统,直白好维护;领域模型是把业务规则放进对象和行为里,适合业务复杂、规则多变的核心系统。我建议一般团队别一上来就上领域驱动设计,先把事务脚本写清楚,等业务复杂到 Service 方法超过两百行、if-else 满天飞的时候,再逐步往领域模型迁移。
3.2 整洁架构与依赖规则
整洁架构(也叫洋葱架构)和六边形架构本质上是一回事:把系统的核心业务逻辑放在最中心,数据库、消息队列、外部接口这些技术细节放在最外层,依赖方向永远由外向内。用生活类比就是:你的业务规则是房子的承重墙,数据库只是水管电线,水管电线怎么换都不该动承重墙。
核心规则只有一条:依赖不能越过边界向内。外层可以依赖内层,内层绝不能依赖外层。也就是说,订单核心逻辑不应该引用任何框架的注解,不应该 import 数据库驱动,不应该知道消息是 Kafka 还是别的什么。它只依赖自己定义的接口,至于接口的实现是谁,由外层在组装的时候注入进来。
这样做的好处,我在一个支付系统项目里体会很深。当时要换数据库从 MySQL 迁到另外一套存储,牵一发动全身的系统不少,但我们这个按整洁架构整理的模块,改动只涉及最外层仓储实现,核心计算逻辑一行没动,测试用例全部原样跑过。这种安全感,是"代码能跑就行"的团队体会不到的。
3.3 依赖倒置怎么落地
依赖倒置是整洁架构的技术基石,简单说就是"高层模块定义接口,低层模块实现接口"。注意方向:接口的所有权在高层,不在低层。很多项目把接口定义在 DAO 层,让 Service 去实现,这就反了。正确做法是 Service 需要什么就定义什么接口,数据访问层去实现这个接口。
现代开发里,控制反转容器(依赖注入框架)让这件事做起来非常顺手。Spring 这类框架帮你把实现类实例化好,在运行时塞给接口引用。但我要泼一盆冷水:依赖注入不是银弹,很多人用它只是为了让代码不 new 对象,结果随手一搜还是上百个接口只有一个实现类,为抽象而抽象,反而增加理解成本。
// 正确的依赖方向:接口定义在业务侧,数据库实现反着依赖业务接口 public interface OrderRepository { Order findById(String orderId); void save(Order order); } // 这个实现类依赖 MyBatis/JPA 等框架,属于外层实现,业务层不关心 @Repository public class MyBatisOrderRepository implements OrderRepository { // 框架相关的代码都圈在这个类里 }我的实操建议是:当一个接口只有一个实现类、且看不到第二个实现的可能,就不要急着抽接口;先把代码写扎实,等真正出现多个实现或测试需要替身的时候,再通过 IDE 快速抽取。重构成本远比你想象的低,过早抽象的维护成本却很高。
4. 微服务架构:拆分的红利与代价
4.1 微服务到底拆什么
微服务架构的核心价值,不是"代码变小了",而是独立部署、独立扩展、独立故障域。拆对了,你的团队可以各自发布、各自扩容、互不拖累;拆错了,就是把一个单体的问题放大成几十个服务的网络问题。
很多人拆微服务时有个误区:按技术层次拆。把控制层拆成一个服务,业务层拆成一个服务,数据访问再拆一个,结果每次业务请求要走三次网络调用,延迟翻了几倍,事务被拆散,团队之间互相等接口。这是最典型的失败模式。
正确的拆法是按业务能力拆。订单、支付、库存、用户,每个都是完整的业务闭环,数据自己管,服务自己发。康威定律说得很清楚:系统架构会镜像组织沟通结构。所以你拆服务之前,先想清楚你的团队怎么分组——两个小组互相有大量协作需求的时候,不要把他们的代码拆到两个服务里,否则每天的工作就是在联调。
4.2 服务拆分的三个可落地原则
第一,数据边界是最后防线。服务之间可以互相调用接口,但绝不能共享数据库表。一旦两个服务读写同一张表,它们就耦合死了,你想独立演进,表结构却把你们焊在一起。这是拆分时必须守住的底线,破了就等于没拆。
第二,从调用链路长、变更频率低的地方开始拆。比如用户基础数据、商品主数据这种被很多服务依赖的"基座",先独立出来,收益最大风险也相对可控。千万不要拿核心交易链路练手,下单链路跨三四个新建服务,中间任何一个不稳定,整个业务全挂,一次就把团队信心拆没了。
第三,一个服务至少要对应一个可以自治的团队。服务拆出来是要有人养的,没人养的服务就是技术债。拆之前想想康威定律:如果你们团队总共就六个人,硬拆十个服务,人均维护两个半,光升级框架就够喝一壶。
4.3 数据一致性与分布式事务:最大的坑
拆了服务,最痛的往往是数据一致性。单体里一个本地事务就能保证扣库存和创建订单同时成功,微服务里这是两个服务的两次写入,天然没有事务保护。业界方案就那么几种,我直接给结论:
| 方案 | 一致性 | 实现成本 | 适用场景 |
|---|---|---|---|
| 两阶段提交(2PC) | 强一致 | 高 | 极少用,性能差、协调者易单点 |
| 本地消息表 | 最终一致 | 中 | 内部系统,最经典稳妥的方案 |
| 事务消息 | 最终一致 | 中 | 使用支持事务消息的中间件时首选 |
| SAGA 补偿 | 最终一致 | 高 | 跨多服务长流程,需要完备补偿逻辑 |
实际项目里,本地消息表和事务消息是我用得最多的。思路都一样:业务操作和发消息放在同一个本地事务里,写业务数据的同时写一条"待发送消息",然后由一个后台任务扫表发消息,发成功的删掉记录。消息中间件即使挂了,重启后还能从表里捞出来继续发,不会丢。
// 伪代码:业务操作与消息落库同事务,保证不丢事件 @Transactional public void createOrder(Order order) { orderMapper.insert(order); outboxMapper.insert(new OutboxMessage("order.created", JsonUtils.toJson(order))); }注意一个关键词:最终一致。微服务架构下的数据做不到强一致,你必须让业务方接受"订单状态在几秒内最终一致"这个设定。产品和技术对不上这个认知,技术方案再漂亮也是白搭。
4.4 微服务的运维成本清单
讲完红利讲代价,微服务的隐性成本绝大多数团队都没提前算过账。你至少要添置这些:
- 服务发现与注册中心:服务实例上下线要能被感知。
- 统一配置中心:几十个服务的配置不能靠改环境变量。
- API 网关:认证鉴权、路由转发、限流统一收敛。
- 链路追踪系统:跨服务的请求要有唯一的追踪 ID 串起来。
- 监控告警体系:每个服务的基础监控、业务监控、日志采集。
- CI/CD 流水线:几十个独立部署单元,每套都要有自动化发布能力。
这些基础设施,单靠开源组件搭一套能跑的,至少两三个熟练工的半年工作量。小团队不是不能上微服务,而是上之前要算清楚:你拆服务省下的那点发布冲突时间,够不够支付这些运维成本。这也是为什么我一直强调,模块化单体 + 清晰边界,是很多团队更务实的微服务平替方案:代码可以按微服务的思路拆好模块,部署仍然合一。等真需要独立扩展了,把模块直接复制出来加一层接口,立刻就是微服务。
5. 事件驱动架构:用异步换来弹性
5.1 事件驱动的两种形态
事件驱动架构这几年越来越常见,但很多人把它简单理解成"用消息队列解耦",其实不够。事件驱动有两条路线:
一条是事件通知。服务 A 在状态变更时发一个事件通知,服务 B 收到后自己处理。A 不关心 B 的处理结果,也不等它。典型场景:订单创建后发事件,通知服务、积分服务、审计服务各自去响应。好处是上游不用串行等待,削峰填谷的能力很强;代价是流程断点化了,出问题时得有对账和补偿兜底。
另一条是事件溯源。不存当前状态,只存"发生了什么事",当前状态由事件重放出来。这个后面单独讲,它要付出的代价远大于普通事件通知。
日常中最实用的是前者。我做过一个电商项目,下单高峰期流量是平时的几十倍,如果用同步调用,订单服务会被下游的积分、物流通知拖垮。改成事件驱动后,订单服务只管写自己的订单库、发一条"order.created"事件,所有下游各自消费,高峰期下游处理不过来就自动堆积,永远不会反向拖垮上游。这个改造,直接让下单接口的 p99 延迟从 800ms 降到了 150ms。
5.2 消息语义与幂等设计:必修课
用事件驱动,第一个要建立的认知是:消息中间件几乎都是"至少一次"投递语义。也就是说,你发一条消息,消费者可能收到一次、两次,甚至更多。如果消费逻辑不是幂等的,重复消息就会造成重复扣款、重复加积分这种事故。
幂等设计有几个常见套路,我按推荐度排序:
- 业务唯一键去重。每条消息里带一个业务 ID(比如订单号),消费端在本地建唯一索引,插入冲突就说明已经处理过。
- 状态机校验。比如订单只能从"待支付"到"已支付",重复消息来的时候发现状态不是"待支付",直接丢弃。
- 数据库操作天然幂等。比如
update t set status='PAID' where order_id='xxx' and status='PENDING',受影响行数为 0 就说明不用再处理。
// 消费端幂等处理:用唯一索引挡住重复消息 public void onOrderCreated(OrderCreatedEvent event) { try { int inserted = rewardRecordMapper.insertIgnoreDuplicate( new RewardRecord(event.getOrderId(), event.getUserId()) ); if (inserted == 0) { return; // 重复消息,已经处理过了 } // 继续后续业务 } catch (DuplicateKeyException e) { return; } }这里有个细节很多人栽过:幂等判断和业务操作必须在同一个事务里。你先查了唯一键不存在,然后执行业务写,另一个线程同时插了同样的记录,你就会重复执行。正确做法是靠数据库层的唯一索引兜底,而不是"先查后写"的应用层判断。
5.3 事件总线选型要点
消息中间件选型是个老话题,市面上的主流产品我简单对比一下选型时的关注点:
- 吞吐量要求:如果每秒几万的量级,选择支持分区高吞吐的那类;如果每日百万来百万去,常规消息队列完全够。
- 顺序性:同一个订单的事件必须按顺序消费,选型时要么用分区键保证写入有序,要么接受乱序后在应用层排序。很多场景"普通乱序 + 业务幂等"就能解决,强行保序会牺牲性能。
- 可靠性:消息丢一条是大事,选型时看刷盘机制、副本机制和确认机制。消费端记得关闭自动确认,处理成功后再手动确认,否则业务没处理完消息先丢了。
- 堆积能力:下游消费慢是常态,中间件要能扛住消息堆积,并且堆积后不影响延时。
我的建议是:如果团队没有专门的中间件运维经验,用成熟的、文档多的开源方案,别自己造消息队列;如果业务场景就是简单的异步通知,甚至可以先基于数据库轮询实现一个"事件表 + 定时扫描"的方案,等量上来了再替换。技术选型最忌讳一步到位买一堆你根本用不上的能力。
6. CQRS 与事件溯源:特定场景的重武器
6.1 CQRS 什么时候值得用
CQRS(Command Query Responsibility Segregation)核心是"读写分离":写操作走命令模型,读操作走查询模型,两个模型可以完全独立优化。看名字感觉很高端,实际上有一个特别常见的现实驱动力:你的业务写入逻辑很复杂,校验规则一堆;而查询需求五花八门,要各种维度的报表和列表。用一个模型同时服务两端,往往两头都做得差。
典型的适用场景我举两个。第一个是互联网电商的后台报表:业务写入是订单的创建、支付、取消这些强一致操作,查询却是"按时间段、按地区、按商品类目聚合的销量报表",这种聚合查询如果直接压在主库上,不仅慢还会拖垮交易。第二个是高并发读取系统:写入端接受用户提交,读取端每天重放几百万请求。这时候把读模型单独建一套,存成最适合查询的宽表甚至缓存,性能立竿见影。
但要强调,CQRS 不是一个"用了就高级"的架构,它是不对称的。如果你的读写场景差异不明显,强行拆分等于把一套逻辑写两遍,维护翻倍。判断标准很简单:你是不是长期在读模型上做和写模型完全不同的优化?如果是才值得拆。
6.2 事件溯源的复杂度评估
事件溯源和 CQRS 常一起出现,因为它们配合得特别好:一个把状态写成事件,一个把事件读出来投影成多种读模型。事件溯源的核心思想是:把系统状态的所有变更保存为不可变的事件序列,当前状态由事件重放计算而来。
这套思想有几个真实好处。一是完整的审计日志:过去发生了什么,每个事件都记录在案,没有秘密。比如账户系统,每一笔余额变动都是有据可查的事件,合规审计时直接翻事件流。二是时空旅行:你想知道"上周三那个时点的订单状态",把事件流重放到上周三即可,不用靠还原备份。三是模型演进灵活:你可以从同样的历史事件里投影出"月度报表"和"实时风控"两种完全不同的读模型。
但代价是巨大的,而且藏得很深:
- 事件格式版本化:事件一旦发布就不可变,字段改名、结构变化都要处理老事件的兼容,需要事件升级策略。
- 重放性能:超长的事件流重放很慢,需要定期生成快照,快照 + 增量事件重放又是一套机制。
- 模型意外复杂:领域里一堆"更新当前状态为"的操作,要改成"追加一条事件",很多开发者的思维习惯需要硬反转过来。
我的结论是:事件溯源不是给普通业务系统准备的,对于大多数团队,用它等于同时背上了"最终一致性 + 事件版本管理 + 重放性能"三座大山。除非你的业务有强审计需求(比如金融账户流水)或核心状态机确实需要完整溯源,否则老老实实用状态 + 事件通知就够了。用闹钟类比:你只需要知道现在几点,没必要把每一次秒针跳动都记下来。
7. 架构选型:一套可复用的决策方法
7.1 主流架构横向对比
很多人纠结"哪个架构最好",问这个问题本身就是错的。架构没有最好,只有"在给定约束下最合适"。我做了张对比表,核心是看四个维度:适用规模、实施复杂度、运维成本、演进灵活度。
| 架构 | 适用规模 | 实施复杂度 | 运维成本 | 演进方向 |
|---|---|---|---|---|
| 单体(含模块化) | 团队 < 10 人,日活百万级以下 | 低 | 低 | 可平滑演进到微服务 |
| 分层/整洁 | 任意规模服务内部 | 中 | 低 | 提升可维护性,不改变部署 |
| 微服务 | 团队 > 10 人,独立扩展需求明确 | 高 | 高 | 需配套完善的基础设施 |
| 事件驱动 | 峰值流量波动大、下游多样 | 中 | 中 | 可与单体或微服务叠加 |
| CQRS/事件溯源 | 特定复杂场景,读写分化激进 | 高 | 高 | 引入需谨慎,回退代价大 |
7.2 选型五步法
我这些年帮不同团队做架构选型,最后沉淀一套五步决策法,分享出来。
第一步,先列非功能需求,再谈架构。你要知道系统对可用性(四个九还是两个九)、一致性(强一致还是最终一致)、性能(p99 多少)、成本(预算多紧)的真实要求。这些数字定下来,架构选择空间就收敛了一大半。
第二步,评估团队真实能力。不是"团队模板上的能力",而是当前这群人里,有几个人真正调过消息队列的堆积、排查过分布式事务问题?没有的话,任何复杂架构都是给自己埋雷。
第三步,从最小可行架构开始。所有新项目的第一个版本都应该尽量简单:单体 + 分层 + 明确的模块边界。这不丢人,这是给自己留演化空间。架构应该像挤牙膏一样,被真实流量和业务复杂度逼着演进,而不是在纸上先画出来。
第四步,量化拆分/升级的收益。你说要拆微服务,那请回答:拆完能独立发布吗?能单独扩容吗?能减少多少发布冲突?如果答案全是"感觉会更好",那这个拆法大概率不成立。
第五步,写架构决策记录。选了什么、放弃什么、基于什么理由,写成文档放在代码库里。三个月后的开发者看到这份记录,就不会又因为"网上说微服务好"再折腾一轮。我见过的无效重构,一半是因为没人记得当初为什么这么定。
7.3 演进优于革命
最后一条原则,可能是我最想强调的:软件架构应该持续演进,不要动不动推倒重来。业界成熟的做法是绞杀者模式:在旧系统旁边建新系统,通过网关逐步把功能流量从旧系统切到新系统,一块一块地替换,直到旧系统自然消亡。这个过程新老系统并存,风险可控,每一块替换完成都有实际的验证节点。
比如你要把一个单体拆成微服务,正确的姿势不是"花三个月重构完再发布",而是把单体当成承载机:先把用户模块完整地拎出来独立部署,老单体里的旧代码先留着,网关把用户请求导向新服务,新服务里的数据靠同步任务从老库里搬。验证稳定后,再删掉老代码。就这样一个模块一个模块地绞杀,每一块失败都能回滚,团队信心不会被一次"双态运行失败"打崩。
演进式设计的反面,是我见过不少团队犯的错:架构评审会上画了一张超大的目标架构图,然后宣布"三个月内全部迁移完毕"。结果通常是迁移到一半业务需求变了,团队被夹在旧系统和新系统之间,两头都烂。记住:架构是长出来的,不是规划出来的。这跟养植物一样,你要提供合适的土壤(团队能力)、肥料(基础设施)、修剪(架构治理),但你不能规定它每天长多高。
8. 常见问题与排查技巧实录
8.1 分布式环境下的经典故障
把几个我亲手排查过的经典故障列出来,给大家当排雷手册。
故障一:超时重试导致雪崩。服务 A 调服务 B 超时,A 抛出异常后业务代码重试,B 此时已经高负载,A 的重试又加剧了 B 的压力,最后整个调用链被打挂。这类事故的根因不是"B 挂了",而是"A 的重试策略没有上限"。解决思路是给重试加总次数限制、指数退避,以及必须配置熔断器,连续失败一定次数后快速失败而不是继续压上去。
故障二:消息消费端幂等失效。业务逻辑判断了"如果订单状态已支付就返回",结果还是重复加了积分。后来查出来是"先查状态再更新"这段逻辑没有放在同一个事务里,两个并发请求同时查到"未支付",都往下执行了。最终靠唯一索引兜底才解决。幂等设计记住一句口诀:数据库约束兜底,应用逻辑优先。
故障三:分布式事务悬挂。用了事务消息方案,本地事务提交了,但消息一直没发出去(中间件抖动),导致下游永远不知道这单已经创建。这类问题要靠对账任务兜底:定时扫描本地消息表里滞留过久的记录,重新投递。很多团队不做对账,出事全靠人工扫数,这在大促期间是致命的。
8.2 链路追踪与日志关联
分布式排查的第一个基本功,是把一次请求经过的所有服务串联起来。用过一个蠢办法:让各个服务在日志里打各自的消息 ID,然后人工 grep 拼接,简直噩梦。后来上了标准的链路追踪,核心动作是:在网关生成全局唯一的 traceId,通过 HTTP 头或消息头往下游传递,所有服务在日志里打印 traceId。
这个改造本身不难,难的是让每个语言栈、每个框架都自动带上这个 ID。成熟方案是接一套标准化探针,或者至少在所有入口做一次 MDC(日志诊断上下文)注入,让日志框架自动带上 traceId。有了它,排查跨服务问题等于从"大海捞针"变成"按图索骥"。
// 服务入口统一注入 traceId,日志自动带上 MDC.put("traceId", traceId); try { // 调用链上的所有日志都会打出 traceId log.info("处理订单创建逻辑"); } finally { MDC.remove("traceId"); }另一个实用技巧:消息消费端的 traceId 必须在消费线程里重新绑定。很多异步消费链路的追踪断掉,就是因为拿到消息后新线程里的 MDC 是空的,导致下游日志全成了孤儿。
8.3 重试与超时:参数怎么配才不雪崩
服务调用的超时和重试参数,是每个开发者都会写但很少有人认真想过的配置。我见过最大的一个坑:所有服务统一设 3 秒超时、重试 3 次,结果一次数据库慢查询拖到 2.9 秒,下游全部跟着重试,瞬间打爆中间件。正确思路是分层设置:
- 超时要比上游更短。你调的是 A,A 还要调 B,你的超时就得覆盖 A + B 的整体链路。一般调用链每多一层,超时时间就要按比例收紧,避免底层慢查询把整条链路拖死。
- 重试只对幂等接口开放。读接口随便重试,写接口必须确认幂等。不知道是否幂等就重试,等于把"重复扣款"按钮交给了运气。
- 用指数退避 + 随机抖动。每次重试间隔翻倍,并加上随机数,避免集群内所有实例在同一秒集体重试,把下游砸出二次故障。
// 重试策略建议:指数退避 + 抖动,且设总次数上限 public <T> T retryWithBackoff(Callable<T> task, int maxAttempts) { long baseDelay = 200; // 初始 200ms for (int attempt = 1; ; attempt++) { try { return task.call(); } catch (Exception e) { if (attempt >= maxAttempts) { throw e; } long delay = baseDelay * (1L << (attempt - 1)) + ThreadLocalRandom.current().nextLong(100); Thread.sleep(delay); } } }熔断器这块再补一句:比"重试不重试"更重要的,是快速失败。当依赖方已经处于故障状态,继续调用和重试都是浪费资源,此时应该直接把请求短路。经典的熔断状态机——关闭、打开、半开——值得每个人手写一遍流程走一遍,比只看文档强得多。
作为亲身踩过这些坑的人,最后再说一句掏心窝的话:架构没有银弹,只有取舍。我给团队做技术评审的时候,最常问的不是"你用了什么架构",而是"你这个架构引入了什么新的成本,你打算怎么付这个成本"。能回答清楚这个问题的团队,用什么架构都不会太差;回答不清楚的团队,哪怕把全世界最先进的模式堆上去,也只是把问题藏得更深而已。