我参加过不少架构评审会,最常出现的场面倒不是方案本身有硬伤,而是两个人在讨论里各说各话,争了半天才发现,一个人嘴里的“同步”指的是调用方式,另一个人说的“同步”指的是线程模型。这类概念混淆在系统架构设计里几乎天天发生,而且越是核心的术语,越容易被混着用。今天这篇串讲,就是把我在实际项目、面试、评审里反复遇到的高频易混知识点整理出来,一条一条掰开讲清楚。内容适合正在做架构设计的开发、准备晋升答辩或架构师面试的同学,也适合带团队时想统一技术语言的管理者。
1. 同步异步、阻塞非阻塞:先分清两个维度,再谈四种组合
1.1 这两个概念为什么会缠在一起
我第一次把这两个概念彻底搞清楚,是在排查一个接口超时问题时。当时服务里有一段代码用了异步HTTP客户端,但调用方在后面马上调用了get()等待结果。同事说“这是异步的,不应该阻塞”,另一个同事说“它明明阻塞了线程”。两个人说的都对,但聊不到一块去。
问题出在哪?同步/异步和阻塞/非阻塞实际上是两个独立的维度:
- 同步与异步,回答的是“调用方怎么拿到结果”。同步是调用方主动等待结果返回;异步是调用方不等待结果,结果通过回调、事件、Future通知等机制后续获取。
- 阻塞与非阻塞,回答的是“调用方在等待期间能不能干别的事”。阻塞是当前线程被挂起,直到条件满足;非阻塞是线程可以继续执行其他任务,稍后再来看结果是否就绪。
打个比方。你点了一份外卖,同步就是你一直站在门口等外卖员敲门;异步就是你该干嘛干嘛,外卖到了手机收到通知再去拿。而阻塞就是你站在门口等的时候什么事也做不了,非阻塞就是你一边看电视一边等,隔一会儿看一眼门口有没有人。你看,这两组问题是完全不同的。
1.2 四种组合在架构里的真实场景
把这两个维度组合起来,代码里的行为完全不同:
| 组合维度 | 典型形态 | 架构场景 |
|---|---|---|
| 同步阻塞 | 普通RPC调用、JDBC查询 | 最常见的请求-响应模型 |
| 同步非阻塞 | 轮询Future.isDone()、IO多路复用 |
Netty的Reactor模型、select/poll/epoll |
| 异步阻塞 | async方法里立刻调用get() |
伪异步,白写异步代码 |
| 异步非阻塞 | 消息队列、事件回调、响应式编程 | MQ解耦、Spring WebFlux、Vert.x |
最容易踩坑的是最后两种。很多人以为用了CompletableFuture就是异步非阻塞了,结果在回调链里做了一步计算,计算本身没问题,但外层有人对返回的CompletableFuture直接join()等待,整个调用就又变回了同步阻塞。更常见的是“异步阻塞”:业务代码里写好async方法,调用方为了拿结果立刻.get(),等得死死的,线程池被占满,接口整体RT飙升。
我自己的经验是,在架构评审里遇到争论,先别急着争方案,先问一句:我们讨论的是调用维度,还是线程模型维度? 只要把这个问题对齐,一半的争论会自然消失。比如讨论异步化改造,真正要评估的不是“换了异步API”,而是线程模型是否变化、是否有背压机制、失败后的补偿链路是否设计完整。这些才是异步架构里的核心问题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 水平扩展与垂直扩展:加机器之前,先确认瓶颈能不能被分布式解决
2.1 两种扩法的核心差异
“系统扛不住了,加机器”这句话我听多了。但加机器分两种加法,本质逻辑完全不同。
垂直扩展是给单台机器加CPU、加内存、加磁盘。好处是业务代码基本不用改,数据库连接数不用变,事务、锁、本地缓存都还是原来的语义。坏处是有物理上限,一台机器加到一定程度再往上花钱性价比极低,而且仍然存在单点风险。
水平扩展是增加节点数量,把流量和数据分散到多台机器上。好处是理论上可以无限扩展,坏处是架构复杂度非线性上升。很多人把“水平扩展”和“分布式部署”划等号,这是个误区。水平扩展的前提是应用无状态和数据可分片,否则你只是把同一个瓶颈多复制了几份,流量一来照样全挂。
2.2 无状态化是水平扩展的第一关
要做水平扩展,先要回答一个问题:状态存在哪里?
传统单体应用里,用户登录后session存在本地内存,定时任务在每台机器上都可能触发,文件上传到本地磁盘,本地缓存里堆着高频数据。这些在单机时代都不是问题,一旦要水平扩展,全部变成问题:session不一致、任务重复执行、文件访问不到、缓存各自为政。
所以无状态化的关键动作通常是:
- session外置到Redis或独立会话服务
- 本地缓存替换为分布式缓存
- 定时任务加分布式锁,保证同一时刻只有一个节点执行
- 本地文件存储迁移到对象存储
- 代码里不要依赖
ThreadLocal跨请求传递关键业务数据
我见过不少项目,应用层无状态做得很好,结果数据库单机成了新瓶颈。读流量大就上读写分离,写流量大就要考虑分库分表。但分库分表的代价得提前说清楚:join查询变难、分布式事务出现、全局唯一ID要设计、跨分片分页查询性能差。很多团队做完分库分表后才发现,业务开发效率下降了一半。
2.3 弹性伸缩和普通扩容也不是一回事
还有一个容易混的点是“扩容”和“弹性伸缩”。扩容是指按计划增加资源,比如大促前提前加机器,通常人工评估后操作,适合流量可预期的场景。弹性伸缩是根据CPU、内存、QPS等指标自动增减节点,适合流量毛刺明显、时间分布不确定的场景。
弹性伸缩看着美好,落地时有个隐含前提:新节点要能快速拉起并接入流量。如果应用启动要五分钟,镜像几个GB,弹性伸缩就是个摆设。所以做弹性伸缩的团队一般会把启动时间压到几十秒内,镜像精简,配置外置,服务注册发现自动化。
我评估一个系统能不能水平扩展,习惯先问三个问题:状态在哪?数据怎么分?故障怎么恢复?三个问题都能回答,扩展才是真的扩展,否则就是给自己挖坑。
3. 主从复制、主备切换:高可用和读性能经常被当成一回事
3.1 主从、主备、读写分离的定义边界
数据库这块的术语混用比应用层还严重。很多人口中的“主从”其实包含了两种完全不同的架构意图:一是为了提升读性能做读写分离,二是为了故障切换做高可用。
主从复制的重点在数据流。主库接收写入,通过binlog等机制把变更同步到从库,从库可以承担读流量,这就是读写分离。典型场景是读多写少的业务,比如内容展示、订单查询。它的核心指标是复制延迟,如果主库写完马上读从库,可能读到旧数据。
主备架构的重点在可用性。备节点平时不承担业务流量,专用于在主节点故障时接管服务。冷备只有备份数据,恢复需要时间;温备平时启动但不接收流量,恢复较快;热备保持与主节点同步,随时可以切换。主备架构关注的是RPO(恢复点目标,最多丢多少数据)和RTO(恢复时间目标,多久恢复服务)。
这两个概念经常被混着用,是因为很多生产环境把两者混着做:一主多从,从库既承担读流量,又作为故障切换的候选节点。这没问题,但必须清楚,主从复制不等于自动故障切换,一主多从并不能解决高可用问题。从库只是有数据,主库挂了它不会自动接管,需要额外的选主、切换、VIP漂移机制。
3.2 故障切换里的脑裂与一致性问题
主备切换最容易出问题的场景是脑裂。两个节点之间网络不通,但都认为自己是主节点,都继续接收写入,等网络恢复后数据冲突,没有赢家。解决思路是引入第三方仲裁,让节点在无法确认对方状态时“先下线,再选举”,避免双主写入。
MySQL高可用方案里,MHA和Orchestrator都要求配合半同步复制降低丢数据风险;Redis哨兵模式里有quorum机制,多数哨兵同意才能判定主节点下线。这些设计的核心逻辑是一致的:没有绝对可靠的故障检测,只有通过多数派决策来减少误判和脑裂的可能。
这里有个很隐蔽的概念混用:有人把Redis哨兵称为“主从切换”,把MySQL MHA也称为“主从切换”,但Redis哨兵除了负责切换,还负责配置下发,客户端需要感知新的主节点地址;MySQL的VIP漂移则是让客户端无感知。同样是高可用,机制差别很大,设计时一定要分清楚。
3.3 高可用方案设计的判断标准
我设计高可用方案时,判断依据很朴素:
- 先问业务能不能接受丢数据。不能接受,必须上同步复制或半同步复制,备机的数据必须跟主库保持一致,RPO趋近于零。
- 再问恢复时间要求。要求秒级恢复,就要热备+自动切换;能接受分钟级,温备+人工切换也行。
- 最后问读流量是否需要扩展。读量大,做主从读写分离;读量不大,一台主库加一台热备最省心。
主从复制和主备切换是两件事,但很多人把它们绑在一起谈,导致方案设计时漏掉切换机制,上线后才发现主库挂了服务并没有自动恢复。这种坑,我建议在架构评审时专门拉出来确认。
4. 缓存穿透、击穿、雪崩:三个问题共用一堆“偏方”,方案经常张冠李戴
4.1 三个问题到底差在哪
缓存的问题是最容易背答案、也最容易背串答案的。先看本质区别:
| 问题 | 触发原因 | 请求特征 | 影响范围 |
|---|---|---|---|
| 穿透 | 查询的数据根本不存在 | 缓存永远不命中,直接打到DB | 单点压力,可能是恶意刷接口 |
| 击穿 | 某个热点key在过期瞬间 | 大量并发请求同一个key | 单个key背后的大量DB压力 |
| 雪崩 | 大量key同时过期,或缓存节点故障 | 大规模缓存失效,流量冲垮DB | 整体系统级故障 |
穿透的典型场景是用户查询一个不存在的订单号,业务代码先去查缓存,缓存没有,查数据库,数据库也没有,于是返回空。这个空结果如果没被缓存,下一次同样的查询还会穿透到数据库。如果接口被脚本循环调用,数据库很快被打垮。
击穿只针对热点key。比如一个爆款商品的详情被缓存了,缓存过期的一瞬间,成千上万的请求同时去查数据库,数据库瞬间被打满。注意,击穿是同一个key,不是很多key。
雪崩是群体事件。可能是缓存key的过期时间设成了同一个值,比如统一设置5分钟过期,零点一到大规模key同时失效;也可能是Redis集群整体不可用,所有请求越过缓存直接打到数据库。
4.2 标准解法以及容易弄混的地方
这三个问题的解法,很多人背了但用错场合:
穿透:标准解法有两个。一是缓存空值,查询结果为空也写缓存,设置较短的过期时间,比如60秒。二是布隆过滤器,在请求进入Redis之前先判断key在不在数据库里,布隆说不在就一定不在,直接返回。布隆过滤器解决的是“不存在的key”,不是“过期的key”,这个定位要想清楚。
击穿:核心思路是避免热点key过期瞬间大量请求直接打到DB。常用三种方案。第一,热点数据永不过期,由后台任务定时刷新。第二,逻辑过期,value里额外存一个过期时间字段,请求发现逻辑过期后尝试获取互斥锁去重建缓存,拿不到锁的请求直接返回旧值。第三,互斥锁重建,同一时刻只允许一个线程查DB并写缓存,其他线程等待或降级。
雪崩:核心思路是让过期时间错开。过期时间加一个随机数,比如基础5分钟加0到60秒的随机值。更进一步的方案是多级缓存,本地缓存扛一部分流量,Redis再扛一部分,数据库只接收兜底请求。缓存节点故障则需要缓存集群本身的高可用,比如Redis Cluster或Sentinel。
容易混的地方:有人把互斥锁用到穿透场景,发现没用,因为穿透查询的是不存在的key,重建缓存也没东西可写;有人把布隆过滤器用到击穿场景,也没用,因为key在DB里是存在的,布隆过滤器挡不住真正的热点key。方案和问题必须对应,不能通用。
4.3 一次缓存雪崩的排查复盘
去年有次线上事故,监控显示凌晨整点数据库连接数瞬间打满,Redis命中率从95%跌到30%。排查过程很典型:
先看Redis监控,确认缓存节点没有故障——不是Redis挂了。再看key过期分布,发现大量key的过期时间集中在整点前后的几十秒内,和业务设置的“统一5分钟过期”完全吻合。再看调用链,发现多个服务同时依赖这批缓存,缓存失效后所有流量同时落到数据库。
修复方案分两步。第一步,过期时间加随机偏移,同一批key的失效时间被打散。第二步,对热点数据增加一层本地缓存,即使Redis里的key过期,本地缓存还能扛几秒,给数据库争取缓冲时间。后续又把定时刷新的机制补上,核心数据不再依赖被动过期。
这类问题的排查思路,说到底就是沿着“缓存为什么没生效”这条线反向追:是key不存在,还是key过期,还是缓存整体不可用。三个方向对应三个问题,一开始就把方向定准,能省一半排查时间。
5. CAP与BASE、分布式事务:强一致和最终一致之间还有大量工程细节
5.1 CAP不是三选二,而是分区存在下的二选一
CAP这三个字母被误解得太深了。很多人说分布式系统需要在一致性、可用性、分区容忍性之间三选二,这是错的。正确理解是:网络分区(P)在分布式系统里是不可避免的,只要系统跨网络部署,网络中断就一定会发生。所以分区发生时,你只能在一致性和可用性之间做取舍。
分区没有发生时,系统可以同时保证一致性和可用性。分区发生时,如果选一致性,系统要拒绝请求,保证所有节点看到的数据一致;如果选可用性,系统继续服务,但不同节点可能返回不同数据。
CAP里的一致性是强一致,CP和AP是两个极端。实际架构里,很少有系统会在所有场景下都选极端方案。比如注册中心,AP派(如Eureka)在分区时保留可用性但可能读到旧服务列表,CP派(如ZooKeeper)在分区时可能拒绝写操作但要保证选主正确。选择哪种,取决于业务是“宁可暂时不可用也不能写错”还是“宁可返回旧数据也不能拒绝服务”。
另一个容易混的概念是最终一致性。最终一致强调“如果停止写入,经过一段时间后所有副本会收敛到相同值”。注意,最终一致不是无限期不一致,也不是延迟无所谓。延迟是个工程指标,需要量化,比如订单状态同步类业务通常要求秒级收敛。
5.2 分布式事务方案对比:本地消息表、事务消息、TCC、Saga
分布式事务是架构设计里最容易被复杂化的领域。先把主流方案理清楚:
| 方案 | 核心机制 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 本地消息表 | 本地事务里同时写业务表和消息表,异步投递消息 | 实现简单,不依赖中间件 | 消息表有积压风险,需要清理和重试 | 订单创建后发通知 |
| 事务消息 | Broker半消息机制,先发消息再执行本地事务,事务成功后才对消费者可见 | 消息与业务操作保证原子性 | 依赖特定MQ能力 | RocketMQ、事务性事件通知 |
| TCC | Try-Confirm-Cancel三段式,每个阶段都有业务操作 | 控制力强,适合资金类 | 业务侵入大,要写反向补偿 | 账户扣款、预算扣减 |
| Saga | 长事务拆分成多个本地事务,通过事件编排,失败时反向补偿 | 适合跨服务长流程 | 补偿逻辑复杂,要处理幂等 | 旅游预订、订单全流程 |
容易混的是事务消息和本地消息表。两者都是为了解决“本地事务和消息发送的原子性”问题。本地消息表靠数据库事务保证业务表和消息表同时成功;事务消息靠MQ的half message机制,先把消息发给Broker但消费者不可见,本地事务成功了再commit,本地事务失败就rollback,Broker还会回查本地事务状态,防止commit丢失。
TCC和Saga的区别也要说清楚。TCC是资源层的预留和确认,每个参与方都要实现Try、Confirm、Cancel三个接口;Saga是流程层的编排,每个参与方只需要实现对应的业务操作和补偿操作。TCC更适合短事务、需要强约束的场景,Saga更适合长流程、跨多个服务的场景。
5.3 选择一致性方案时的判断框架
我见过太多团队,业务本来只需要最终一致性,非要引入TCC框架,最后发现事务代码比业务代码还多。我的判断框架是:
- 业务接受读旧数据吗?接受,直接走正常异步化,不需要分布式事务。
- 操作必须原子完成吗?比如转账扣款,必须原子性,走TCC或2PC。
- 失败后能补偿吗?能补偿且补偿逻辑清晰,走Saga。
- 有对账兜底吗?有对账,即使偶发不一致也能发现并修复,那很多场景用本地消息表就够了。
回答完这四个问题,方案基本就定了。资金类业务优先TCC,状态同步类用本地消息表或事务消息,长流程尽量用Saga加对账。选型前先砍需求,能通过业务规则降级的,就别上重型框架。
6. 四层与七层负载均衡、正向与反向代理:流量入口的两组高频混淆
6.1 四层和七层到底分在哪一层
负载均衡的“四层”“七层”经常被口头混用,但它们对应的协议栈完全不同。四层是传输层,基于IP和端口转发,不关心应用层内容;七层是应用层,可以解析HTTP、HTTPS报文内容。
四层负载均衡的典型代表是LVS和HAProxy的TCP模式。它转发的是原始数据包,效率高,吞吐大,适合海量长连接和高吞吐场景,比如WebSocket、游戏服务端。缺点是功能少,做不了根据URL路径的路由,也做不了基于HTTP header的灰度。
七层负载均衡的典型代表是Nginx和HAProxy的HTTP模式。它可以解析URL路径、header、cookie,可以做路由分发、灰度发布、限流、重写。适合HTTP API网关、前端接入层、微服务网关。
| 对比项 | 四层 | 七层 |
|---|---|---|
| 工作层级 | 传输层 | 应用层 |
| 转发内容 | IP+端口 | HTTP报文 |
| 性能 | 高 | 相对低 |
| 功能 | 转发 | 路由、改写、限流、灰度 |
| 典型产品 | LVS、HAProxy(TCP模式) | Nginx、HAProxy(HTTP模式) |
一个常见误区是,所有流量都用Nginx就够了,不需要LVS。实际上Nginx本质上是七层,处理的是HTTP解析,高并发时CPU消耗不低。在入口流量达到一定规模后,标准做法是LVS做四层负载,把流量分给一组Nginx,Nginx再七层分发到应用。这就是很经典的四七层组合链路:DNS解析到VIP,LVS分发到Nginx集群,Nginx按路由转发到后端服务。
6.2 正向代理与反向代理的角色差异
正向代理和反向代理的区别,一句话就能说清:正向代理代理的是客户端,反向代理代理的是服务端。
正向代理站在客户端一侧,代替客户端去访问目标服务。客户端知道代理的存在,配置了代理地址,代理转发请求并把结果返回给客户端。典型场景是公司内网统一的出口代理,做访问控制和缓存;或者本地开发环境里用代理调试外部接口。正向代理的视角里,远端服务只知道请求来自代理,不知道真实的客户端是谁。
反向代理站在服务端一侧,代替服务端接收请求。客户端只知道反向代理的地址,不知道背后具体是哪台服务器在处理。Nginx挂在应用服务器前面就是反向代理,客户端请求到Nginx,Nginx转发给后面的应用实例。反向代理是服务端架构的一部分,负责负载均衡、SSL终结、路由转发。
架构里经常把这几种角色混在一起讨论。实际上它们是可以组合的链路:请求先打到四层负载均衡(LVS),再到七层反向代理(Nginx),再到网关(如Spring Cloud Gateway或Kong),最后到业务服务。LVS管四层流量分发,Nginx管七层入口,网关管路由、鉴权、限流、灰度。职责不同,但可以串联。
6.3 链路设计的一个经验
在入口流量的链路设计上,我的经验是分层而不重叠。基础设施层的负载均衡(LVS、云上的SLB)只做流量分发;七层反向代理(Nginx)可以做基础的连接管理、SSL卸载;业务规则类的路由、鉴权、限流放到网关层。不要把所有规则都堆在Nginx上,Nginx的lua脚本堆太多规则后,排查问题会非常痛苦。
流量规模小的服务,单层Nginx就够用;流量上来了,把LVS挂在前面;有了多团队、多服务的路由和灰度需求,再引入网关。链路不是越复杂越好,而是每一层都有明确的职责,出了问题才能顺着链路快速定位。
7. 幂等、去重、超时、重试:稳定性设计里最容易被想当然的一组概念
7.1 幂等和去重的界限在哪里
“接口要做幂等”和“请求要去重”经常被当成同一个需求,但它们含义不同。幂等是接口能力,同一个操作执行多次,效果和执行一次相同。去重是系统行为,同一个请求只处理一次,重复请求被丢弃。
举个例子。支付回调接口设计成幂等的,意味着同样的回调报文发10次,支付状态只会从“待支付”变成“已支付”一次,后面的调用直接返回成功但不重复改数据。去重则是系统层面记录“这个请求我处理过了”,用一份已处理请求表挡掉重复提交。
实现幂等常用三种手段。第一种,唯一业务ID加唯一索引。比如订单号在支付表里有唯一约束,重复插入直接报错。第二种,版本号乐观锁。更新时where version = ?,版本对不上就更新失败。第三种,状态机校验。比如只有“待支付”状态才能变更为“已支付”,已支付状态不允许再变。
判断一个接口要不要幂等,看两件事:客户端或上游会不会重试?用户会不会重复提交?下游消息消费者有没有可能重复消费?只要有一个“是”,接口就应该设计成幂等的。
7.2 超时和重试的配置不是独立决策
超时和重试是两个配置,但它们必须联动设计。很多人只设了超时没设重试,或者设了重试没考虑超时,最终导致线程池被耗尽。
超时至少分三种:连接超时、读超时、写超时。连接超时是建立连接最长时间,读超时是等待响应最长时间,写超时是发送数据最长时间。配置原则是参考网络往返时间和服务P99响应时间,留出合理余量。过短容易误判失败,过长会拖垮线程池。
重试的坑在于:超时后立刻重试,往往是雪上加霜。如果下游已经过载,重试只会让过载更严重。业界常用指数退避加抖动,第一次失败后等200ms,第二次等400ms,第三次等800ms,再加上随机抖动防止群体同时重试。同时要限制最大重试次数,通常是2到3次,而不是无限重试。
这里有个容易混淆的点:超时时间短不代表系统更快,重试次数多也不代表更可靠。如果超时500ms,重试3次,最坏情况下一个请求要等2秒以上。如果同步调用链路里每个服务都这么配,链路SLA会非常难控。所以超时配置要以链路为单位评估,而不是单个服务自己说了算。
7.3 幂等、重试、熔断是一套组合拳
最后一个心得:幂等、重试、熔断降级不是三个独立话题,它们是一套组合拳。对外接口写幂等,允许上游重试;对内任务写去重,防止重复消费;下游故障时,重试要有上限,超过上限就熔断,熔断后走降级逻辑。
比如订单服务调用库存服务扣减库存。库存服务接口必须幂等,否则订单服务重试时库存会被重复扣减;订单服务配置超时和有限重试;连续重试失败后熔断器打开,订单服务直接走降级逻辑,提示用户稍后支付,而不是继续重试打爆库存服务。一旦链路里这些概念想清楚了,线上故障会少很多。
我自己的习惯是,在接口文档里直接写明“本接口幂等键是xxx,重复请求返回相同结果”,在配置中心统一管理超时和重试参数,并把熔断阈值和重试次数关联起来。这样后续自己排查问题和新人接手都省力很多。
概念上的较真,不是书呆子气。系统架构设计里的每一个易混点,背后都是一次真实的技术决策。把“同步”和“阻塞”、“主从”和“主备”、“穿透”和“击穿”这些概念真正分开,评审时对齐语言,设计时才有共同的地基。我还有个习惯,团队技术讨论时发现有概念分歧,就随手记录下来,积累一段时间后统一整理成团队的术语表。这个动作很小,但长期下来对团队技术透明度的提升,比开十次分享会都有效。
