分布式/高性能/高可用
分布式/高性能/高可用
分布式✅
CAP理论
CAP理论是分布式系统设计中的一个重要理论。
- 一致性(Consistency):所有节点访问同一份最新的数据副本
- 可用性(Availability):非故障的节点在合理的时间内返回合理的响应(不是错误或者超时的响应)。
- 分区容错性(Partition Tolerance):分布式系统出现网络分区的时候,仍然能够对外提供服务。
网络分区:分布式系统中的多节点网络原本是连通的,但因为故障导致某些节点间不连通了,网络分为几块区域。
当网络发生分区后,如果要继续服务的话,P是前提,必须要实现。然后在C和A之间二选一。因此分布式系统理论上不可能选择 CA 架构,只能选择 CP 或者 AP 架构。
如果网络分区正常的话(系统在绝大部分时候所处的状态),也就说不需要保证 P 的时候,C 和 A 能够同时保证。
为什么要首先保证P
- 分布式系统的本质:分区容错性(P)是指系统在网络分区发生时,仍然能够继续提供服务的能力。在分布式系统中,网络分区是不可避免的,因此分区容错性是分布式系统必须具备的基本属性。
- 保证系统的高可用性:如果不保证分区容错性,那么一旦网络分区发生,系统可能会因为无法处理分区而崩溃或停止服务,这将严重影响系统的可用性和稳定性。通过P确保系统的高可用性。
- 符合分布式系统的设计理念:分布式系统的设计初衷就是为了提高系统的可扩展性、可靠性和容错性。如果放弃分区容错性,那么分布式系统就失去了其最重要的优势之一。
- 在分布式系统中,通过将数据和服务分布在多个节点上,可以实现负载均衡、故障转移和容错处理等功能。这些功能的实现都依赖于分区容错性的支持。
- 实际应用高并发的需求:在实际应用中,分布式系统往往需要处理大量的并发请求和数据,同时还需要面对各种复杂的网络环境和故障情况。
- 如果不保证分区容错性,那么系统在面对这些挑战时可能会显得力不从心,无法满足实际应用的需求。
CAP 不是「三选二」这么简单
CAP 的作者 Eric Brewer 在 2012 年专门写过一篇《CAP Twelve Years Later》来纠偏:
- CAP 只在发生网络分区的那一刻才需要做取舍,绝大部分时间系统并不处于分区状态,此时应该同时把 C 和 A 做到最好。
- C 和 A 不是布尔值,而是一个连续的谱。例如可以只对核心账务数据保证强一致,对商品浏览数走最终一致。
- 实际系统往往是「分区期间牺牲部分一致性,分区恢复后做补偿」,这才是工程做法。
面试常问的定位:
- CP 典型代表:ZooKeeper、etcd、Consul、HBase(分区期间宁可不可用,也不返回旧数据)。
- AP 典型代表:Eureka、Nacos(AP 模式)、Cassandra、DynamoDB(分区期间照样返回,可能是旧数据)。
- Nacos 同时支持 AP/CP:注册临时实例(ephemeral=true)走 AP + Distro 协议,持久化实例走 CP + Raft,这是很好的答题素材。
BASE 理论
BASE 是对 CAP 中 AP 方案的延伸和落地补充,是大型分布式系统的实际选择,核心思想是「即使做不到强一致性,也可以采用适当方式达到最终一致性」。
- **Basically Available(基本可用)**:出现故障时允许损失部分可用性,但保证核心可用。表现为①响应时间上的损失(平时 0.5s,大促降级到 3s);②功能上的损失(大促关闭「猜你喜欢」推荐,只保留下单主链路)。
- **Soft State(软状态)**:允许系统中的数据存在中间状态,且认为该中间状态不影响系统整体可用性(如订单「支付中」)。
- **Eventually Consistent(最终一致性)**:系统中所有数据副本在经过一段时间后,最终能达到一致的状态。
最终一致性的常见实现手段:
- 读时修复:读取数据时检测多个副本的不一致,并做修复(如 Cassandra 的 Read Repair)。
- 写时修复:写入失败时在本地队列缓存并重试,直到成功(性能最好,无额外读开销)。
- 异步修复:定时任务/对账任务比对副本差异,这是生产中用得最多的兜底手段。
ACID 是刚性事务(强一致),BASE 是柔性事务(最终一致),两者是取舍关系而非对立关系,同一个系统的不同模块完全可以分别采用。
雪花算法
雪花算法(Snowflake Algorithm)是由Twitter公司开发的一种分布式ID生成算法。它的主要目的是在分布式系统中高效地生成全局唯一且有序的ID。这种算法非常适合于大规模互联网应用,其中需要确保不同服务器生成的ID不会发生冲突。
特点
- 全局唯一性:保证不同机器生成的ID不会重复。
- 无协调性:生成ID的过程不需要跨网络的协调,提高了性能。
- 趋势递增:生成的ID具有一定的顺序性,这有助于数据库中的索引优化。
- 信息承载:每个ID都包含了一定的信息,比如时间戳、机器ID等。
结构
一个64位的ID通常由以下几个部分组成:
- 1位符号位:始终为0,表示正数,不携带实际信息。
- 41位时间戳:精确到毫秒,可以使用约69年的时间。
- 10位机器标识:可以部署在1024个节点上。
- 12位序列号:用于同一毫秒内产生的多个ID,可以每毫秒产生4096个不同的ID。
工作原理
- 时间戳:当请求生成一个ID时,首先获取当前的时间戳,并将其编码到ID的相应位置。
- 机器标识:根据部署的机器配置,分配一个唯一的机器ID。
- 序列号:在同一毫秒内,如果同一个机器接收到多个ID生成请求,则通过原子操作增加序列号来保证ID的唯一性。
- 组合成ID:将上述所有部分组合起来形成一个64位的二进制数字,即为最终生成的ID。
使用场景
- 分布式系统中的唯一ID生成:例如,在微服务架构中为不同的服务提供唯一ID。
- 数据库主键生成:作为数据库表的主键,特别是在分库分表的场景下。趋势递增的特性对 InnoDB 聚簇索引特别友好,避开了 UUID 作主键带来的页分裂。
- 链路跟踪 ID / 幂等去重键:全局唯一且不依赖中心节点,适合做 traceId、requestId。
注意事项
- 时钟回拨问题:如果机器的时间被调整到过去(NTP 校时、运维改时间、虚拟机迁移),可能会导致生成的 ID 重复。常见处理方式:
- 小幅回拨就等待:回拨在毫秒级(如 < 5ms)则自旋/sleep 等时钟赶上来。
- 大幅回拨就报错:直接抛异常并告警,不能静默降级。
- 备用位/时钟序列:百度 UidGenerator 重新定义了位分配(秒级时间 + 自增序列),并用 RingBuffer 预生成 ID,从机制上规避了实时取时间。
- 探测时钟异常:美团 Leaf-snowflake 在启动时把时间戳写到 ZK 持久节点,重启时先比对 ZK 上的时间戳,小于上次则拒绝启动。
- 机器ID分配:需要有一个合理的策略来分配机器ID,以避免冲突。容器化/弹性伸缩场景下不能写死在配置里,通常用 ZK/etcd 顺序节点、Redis INCR 或根据 Pod IP 映射来动态分配。
- ID 可推测/信息泄露:雪花 ID 本质是递增的,对外暴露会泄露业务量(竞对可以通过两次下单的 ID 差估算日单量)。对外 ID 应该另做一层映射或混淆。
- 分库分表下的写热点:如果直接拿雪花 ID 取模分片还好,但若用范围分片,递增 ID 会造成所有写入集中在最后一个分片。
常见错说法纠正:雪花 ID 只有全局唯一 + 趋势递增,它没有任何业务可读性,不要把它当成「可读的缓存 key」来答。如果需要可读的业务单号,应该在雪花 ID 外面拼业务前缀/日期。
同时注意:雪花 ID 是 64 位 long,**超出 JS 的 Number 安全整数范围(2^53-1)**,前后端交互必须序列化为字符串,否则会丢精度。这是面试和生产中都高频的坑。
分布式ID方案对比
只会雪花算法是不够的,面试往往会让你横向对比:
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| UUID | 本地生成 128 位随机数 | 本地生成、无中心依赖、性能极高 | 无序导致 B+ 树页分裂、占 36 字节、无业务含义 |
| 数据库自增 | 单库 auto_increment | 简单、单调递增 | 单点、性能上限低、分库需要设步长 |
| 号段模式(Leaf-segment) | 从 DB 一次取一段(如 1000 个)ID 缓在内存,用完再取 | DB 压力降低 1000 倍、可双 buffer 预加载消除毛刺 | DB 宕机后只能再撑一个号段的时间、ID 不连续、不单调递增 |
| Redis INCR | 利用 Redis 单线程原子递增 | 性能高、可控制步长 | 引入 Redis 依赖,RDB 持久化宕机可能回退导致重复 |
| 雪花算法 | 时间戳+机器ID+序列号 | 本地生成、趋势递增、无网络开销 | 依赖时钟、workerId 需要分配 |
| 数据库自增+步长 | 多库设不同起始值和相同步长 | 改造成本低 | 水平扩展困难(步长写死了就不能加节点) |
选型结论:内部主键/traceId 用雪花;对连续性不敏感但要求强可靠的用号段模式;对外业务单号用「业务前缀 + 日期 + 号段/雪花」拼接。
分布式锁
单机锁(synchronized、ReentrantLock)只在一个 JVM 内部生效。一旦服务部署了多份,多个 JVM 的线程之间就需要一个所有进程都能看到的共享存储来做互斥,这就是分布式锁。
一个可用的分布式锁必须满足:
- 互斥:任意时刻只能有一个客户端持锁。
- 防死锁:持锁方宕机也能释放(必须有过期时间)。
- 只能解自己的锁:不能把别人的锁删了。
- 可重入(可选):同一线程可以重复获取。
基于 Redis 实现
正确的加锁姿势只有一种:
SET lock_key {随机值UUID} NX PX 30000
NX保证只有不存在才能设置,完成互斥。PX和SET必须在同一条命令里完成。如果先SETNX再EXPIRE,两条命令之间宕机就会遗留永不过期的死锁。- value 必须是**随机值(UUID + 线程ID)**,释放时先比对 value 再删除。否则会出现:A 的业务执行超时导致锁自动过期,B 拿到锁,A 执行完把 B 的锁删了。
- 释放锁必须用 Lua 脚本把「比对 value + DEL」变成原子操作,否则比对与删除之间仍有窗口期。
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
锁过期了业务还没执行完怎么办?
这是面试必追的一环,答案是 看门狗(Watchdog)自动续期:
- Redisson 的
RLock.lock()不传 leaseTime 时,默认锁过期时间 30s,并启动一个定时任务每 10s(1/3 过期时间)把锁重置回 30s。 - 一旦客户端进程宕机,看门狗停止续期,锁最多 30s 后自动释放,同时兼顾了防死锁和不误删。
- 注意:如果显式传了
lock(10, TimeUnit.SECONDS),看门狗就不生效了,这个细节经常被问。
Redisson 还用 Hash 结构(field = UUID:threadId,value = 重入次数)实现了可重入,并用 Redis 的 Pub/Sub 实现了阻塞等待而非无谓自旋。
Redlock 与它的争议
单主从 Redis 有一个本质缺陷:主从复制是异步的。客户端在 master 上加锁成功,master 还没同步给 slave 就宕机,主从切换后新 master 上没有这把锁,于是两个客户端同时持锁。
Redlock 的思路是向 N(通常 5) 个相互独立的 master 依次加锁,获得超过半数(N/2+1)且总耗时小于锁过期时间才算成功。
但 Redlock 被 Martin Kleppmann 公开质疑过,核心质疑点:
- 依赖各节点的物理时钟,发生时钟跳变就可能失效。
- GC 停顿/网络延迟依旧可能导致客户端以为自己持锁而实际锁已过期。
- 真正的解法是让被保护的资源自己做 fencing token 校验(单调递增的版本号,存储层拒绝比当前版本小的写入)。
结论:对正确性要求极高(金钱相关)的场景,不要只依赖 Redis 锁,要用数据库唯一索引/乐观锁做最终兜底,Redis 锁只当作降低数据库冲突的性能优化。
基于 ZooKeeper 实现
利用临时顺序节点:
- 每个客户端在
/lock下创建临时顺序节点。 - 取出所有子节点排序,序号最小的获得锁。
- 其他客户端只
watch自己前一个节点的删除事件,避免惊群效应(herd effect)。 - 客户端宕机时 session 超时,临时节点自动删除,锁自动释放。
三种实现对比
| 维度 | Redis | ZooKeeper | 数据库 |
|---|---|---|---|
| 一致性 | AP,主从异步复制可能丢锁 | CP,ZAB 协议保证锁不丢 | 强一致 |
| 性能 | 最高(内存) | 中等(每次写都要过半数确认) | 最差 |
| 锁释放 | 依赖过期时间+看门狗 | session 失效自动释放,更可靠 | 需自己处理死锁 |
| 阻塞等待 | Pub/Sub 模拟 | 天然支持 watch | 轮询或事务阻塞 |
| 适用场景 | 高并发、允许极小概率失效 | 强一致、低频关键业务 | 低并发、不想引入新组件 |
实际项目选型:绝大多数业务直接用 Redisson,它把看门狗、可重入、公平锁、读写锁、信号量都封装好了,没必要自己写。
共识算法:Paxos / Raft / ZAB
分布式锁、注册中心、主从选主的底层都靠共识算法,面试会让你至少把 Raft 讲清楚。
Raft 把问题拆成三块:
- 领导者选举:节点有 Follower / Candidate / Leader 三种角色。Follower 在随机选举超时(150~300ms,随机化是为了避免反复平票)内没收到心跳,就变为 Candidate 发起投票,得到过半数票成为 Leader。
- 日志复制:所有写请求都走 Leader,Leader 先 append 到本地日志,再并行发给 Follower,过半数确认后才 commit 并应答客户端。
- 安全性:通过「只有拥有最新日志的节点才能当选」保证 Leader 不会丢已提交的日志。
关键结论:
- Raft/Paxos 能容忍
(N-1)/2个节点故障,所以集群节点数应为奇数(3、5、7)。四节点和三节点容错能力一样,白白多一台机器。 - ZAB 是 ZooKeeper 的协议,与 Raft 思路相近,区别在于 ZAB 以 epoch + zxid 标识主周期,且包含一个专门的「数据同步(Recovery)」阶段。
- Multi-Paxos 理论上更通用但工程实现极难,Raft 是为了可理解性而设计的等价简化版,所以 etcd、TiKV、RocketMQ DLedger、Nacos CP 模式都选 Raft。
- Gossip 是另一派(最终一致),不选主、无中心,靠节点互相传染状态,Redis Cluster、Consul、Cassandra 的节点发现都用它。
分布式事务如何实现?
分布式事务要解决的核心问题是:一个业务操作跨越了多个数据库/多个服务,如何保证这些操作”全部成功”或”全部失败”。根据一致性强弱,可以分为刚性事务(强一致,如2PC/3PC)和柔性事务(最终一致,如TCC、本地消息表、MQ事务消息、Saga)。
| 方案 | 原理 | 适用场景 | 缺点 |
|---|---|---|---|
| 2PC | 准备+提交阶段 | 传统数据库XA | 同步阻塞、单点故障 |
| 3PC | 引入预提交+超时 | 对2PC改进 | 实现复杂 |
| TCC | Try-Confirm-Cancel | 电商、金融 | 业务侵入大 |
| 本地消息表 | 事务内写业务表+消息表 | 最终一致性 | 延迟较高 |
| MQ事务消息 | 半消息+回调检查 | RocketMQ原生 | 需MQ支持 |
| Saga | 长事务拆分+补偿 | 微服务长流程 | 隔离性差 |
2PC(两阶段提交)
由事务协调者(Coordinator)统一调度各参与者(数据库/资源管理器):
- 阶段一(Prepare):协调者询问所有参与者”能否提交”,参与者执行事务但不提交,锁定资源后返回 Yes/No。
- 阶段二(Commit/Rollback):若所有参与者都返回 Yes,协调者通知全部提交;只要有一个 No,则通知全部回滚。
- 缺点:整个过程参与者一直持有锁,同步阻塞、性能差;协调者是单点,一旦协调者宕机在阶段二,参与者会一直阻塞(虽然可以通过日志恢复,但仍有窗口期数据不一致)。
3PC(三阶段提交)
在 2PC 基础上增加了 CanCommit 阶段,并给参与者引入了超时机制:
- CanCommit:协调者先询问参与者是否有能力执行事务(不加锁),提前过滤掉明显会失败的场景。
- PreCommit:类似 2PC 的 Prepare,参与者预执行并锁资源。
- DoCommit:正式提交。
- 参与者在等待协调者指令超时后,会默认提交(而不是像2PC一样一直阻塞),一定程度降低了阻塞时间,但依然无法彻底解决数据不一致问题,且实现复杂度更高,实际生产中很少使用。
TCC(Try-Confirm-Cancel)
业务层面实现的两阶段提交,把一个事务拆成三个方法:
- Try:对业务资源做检查和预留(如冻结库存、冻结金额),不做真正的业务处理。
- Confirm:所有参与者 Try 成功后,执行真正的业务提交(如实际扣减库存)。
- Cancel:只要有一个参与者 Try 失败,则对所有已成功的参与者执行释放资源的操作(解冻库存)。
- 优点:不依赖数据库锁,性能好;缺点:需要对每个业务方法都实现 Try/Confirm/Cancel 三个接口,业务侵入性大,开发成本高。常见框架:Seata(TCC 模式)、Hmily、ByteTCC。
TCC 必须解决的三大问题(面试高频追问):
- 空回滚:Try 因网络超时没执行成功,但事务管理器已经发起了 Cancel。此时 Cancel 必须能识别「Try 未执行」并直接返回成功,不能去释放没有预留的资源。解法:记录事务行为表,Cancel 时先查 Try 是否执行过。
- 幂等:Confirm/Cancel 会被框架不断重试,必须保证多次执行结果一致。
- 悬挂:Try 的网络包阻塞在路上,Cancel 已经执行完了,Try 才姗姗到达,于是预留了一笔永远不会被释放的资源。解法:Try 执行前先检查是否已有 Cancel 记录,有则直接拒绝。
这三个问题能答出来,基本就能证明你真写过 TCC 而不是只看过概念。
本地消息表
- 业务方在同一个本地事务中,既写业务数据,又写一条”待发送”的消息记录到本地消息表。
- 之后通过定时任务轮询消息表,把状态为”待发送”的消息投递给 MQ 或下游系统,下游处理成功后再更新消息状态为”已完成”。
- 优点:实现简单,不依赖第三方组件;缺点:需要业务方自己维护消息表和轮询任务,存在一定延迟。
MQ 事务消息(如 RocketMQ)
- 发送方先发送一条半消息(Half Message),MQ 收到但不会投递给消费者。
- 发送方本地执行业务事务,执行成功后再发送 Commit 通知 MQ 正式投递消息;执行失败则发送 Rollback 消息丢弃。
- 如果发送方长时间未响应 Commit/Rollback,MQ 会主动回调发送方提供的事务状态回查接口,确认本地事务的最终状态,避免消息悬挂。
- 优点:由 MQ 保证消息投递的可靠性;缺点:依赖 MQ 对事务消息的支持(如 RocketMQ),Kafka/RabbitMQ 原生不支持。
Saga
- 把一个长事务拆分为多个本地事务(子事务),每个子事务都有一个对应的补偿操作。
- 正向执行:依次执行各个子事务;一旦某一步失败,则按逆序依次调用之前已成功步骤的补偿操作,回滚到初始状态(类似”事后补偿”而非”锁资源预留”)。
- 优点:无需像 TCC 一样预留资源,实现相对轻量,适合流程长、参与方多的微服务场景(如订单-库存-积分-物流);缺点:由于没有预留资源阶段,事务执行过程中中间状态对外可见,隔离性差,可能出现”脏读”。
方案如何选型?
- 对一致性要求极高、参与方少、性能要求不高 → XA(包括 Seata XA 模式,或直接用支持分布式事务的数据库如 TiDB、OceanBase)。
- 业务无侵入、快速落地、允许短暂的全局锁等待 → Seata AT 模式(生产中最常用)。
- 电商下单、金融转账等强业务校验、高并发且不想被全局锁拖慢 → TCC。
- 异步、允许一定延迟的场景(如下单成功后发通知、更新积分)→ 本地消息表 / MQ 事务消息。
- 微服务链路长、涉及多个服务且需要补偿逻辑 → Saga。
重要纠正:原文把「Seata AT 模式」归入「支持 XA 的分布式数据库」是错的,三者完全不同:
- Seata 是应用层的分布式事务框架,不是数据库。
- AT 模式并不依赖数据库 XA 协议,而是 Seata 自己实现的二阶段提交:Seata 会代理 JDBC,在一阶段解析 SQL、拍下
beforeImage/afterImage生成undo_log,然后直接提交本地事务并释放数据库锁;二阶段如果全局提交就异步删undo_log,如果回滚就用beforeImage反向生成 SQL 补偿。- 真正的 XA 对应的是 Seata 的 XA 模式,它才是全程持锁、依赖数据库原生 XA 接口的。
Seata AT 模式的关键细节:
- 因为一阶段就提交了本地事务,数据库锁持有时间极短,性能远好于 XA。
- 代价是 Seata 需要在 TC(事务协调器)上维护一个全局锁来防止脏写:其他全局事务想改同一行时拿不到全局锁会等待/回滚。
- 隔离级别默认是读未提交:一阶段已提交的中间状态对外可见。需要读已提交必须手动用
SELECT FOR UPDATE(Seata 会代理它去拿全局锁)。 - 不适用场景:非关系型存储(Redis/ES/MongoDB)、数据库不支持
undo_log表、SQL 太复杂无法反解。这些场景只能转 TCC 或 Saga。
追问:如果 TCC 的 Confirm 阶段网络超时? → TCC 框架(如 Seata)会不断重试 Confirm,Confirm/Cancel 必须保证幂等性。
追问:Seata 支持哪些模式? → AT 模式(Seata 自研的二阶段提交:一阶段直接提交本地事务并记录 undo_log,靠全局锁防脏写,业务无侵入)、TCC 模式(手动实现三个方法)、Saga 模式(状态机驱动,适合长流程)、XA 模式(基于数据库原生 XA 协议,全程持锁)。生产中 AT 模式因为业务侵入小、使用简单,是最常用的模式。
追问:Seata 的三个角色是什么? → TC(Transaction Coordinator) 独立部署的服务端,维护全局事务状态与全局锁;TM(Transaction Manager) 开启/提交/回滚全局事务的发起方,就是标了 @GlobalTransactional 的那个方法;RM(Resource Manager) 管理分支事务的资源,向 TC 注册分支并上报状态。面试问 Seata 基本会先问这三个。
追问:TC 自己挂了怎么办? → TC 可以集群部署,并将事务会话信息持久化到 DB/Redis(store.mode),恢复后从存储重建状态继续推进。注意 TC 本质上是个强依赖,TC 不可用会让所有全局事务无法提交,这是引入 Seata 的架构代价。
追问:如何保证消息不丢、不重? → 不丢依赖 MQ 的持久化+ACK机制+本地消息表兜底重试;不重依赖消费端做幂等处理(如唯一键去重、状态机判断、Redis 分布式锁标记已处理)。
幂等设计
分布式系统里重试、重复消费、用户重复提交都不可避免,所以幂等是分布式设计的底线能力,而不是可选项。
常见实现方案(从弱到强):
- 前置查询判重:先查后写。最简单但并发下不可靠,两个请求可能同时查到不存在。只适合做快速失败的前置优化。
- 数据库唯一索引:拿业务唯一键(如
订单号 + 业务类型)建唯一索引,重复插入直接抛DuplicateKeyException。最可靠、最常用的兜底。 - 乐观锁/版本号:
UPDATE t SET amount=?, version=version+1 WHERE id=? AND version=?,更新行数为 0 则说明已被处理。适合更新类操作。 - 状态机流转:
UPDATE order SET status='PAID' WHERE id=? AND status='UNPAID',用前置状态做条件,天然幂等。业务语义最清楚。 - Token / 去重表:客户端先领取一个唯一 requestId,服务端根据 requestId 去重(落去重表或 Redis
SET NX)。适合防重复提交。 - 分布式锁 + 标记:并发高时先拿锁再处理,适合不能建唯一索引的复杂场景。
注意区分:不是所有接口都需要幂等处理。查询(GET)、删除(DELETE)、全量覆盖(PUT)天然幂等;只有新增(POST)和增量更新(如
amount = amount + 100)需要额外保障。
另外不要把 Redis 去重当成唯一手段:Redis 会过期、会丢数据,应该把它当作前置拦截,底层依旧靠数据库约束。
RPC框架
RPC(Remote Procedure Call)框架是一种允许程序调用另一个地址空间(通常是网络上的另一台机器)上的过程或函数,就像调用本地程序中的函数一样。RPC 框架隐藏了网络通信的复杂性,使得开发者可以更加专注于业务逻辑的实现,而不是底层的网络通信和序列化/反序列化等细节。
RPC 框架通常包含以下几个关键组件:
- 客户端(Client):发起远程过程调用的程序。客户端负责将调用请求发送给服务器,并等待服务器的响应。
- 服务端(Server):提供远程过程或函数供客户端调用的程序。服务端接收来自客户端的请求,执行相应的操作,并将结果返回给客户端。
- 通信协议(Protocol):客户端和服务端之间通信所遵循的规则。这包括数据的编码方式、传输方式(如TCP/IP)、请求和响应的格式等。
- 序列化/反序列化(Serialization/Deserialization):由于客户端和服务端可能运行在不同的机器上,它们之间的数据交换需要通过网络进行。序列化是将数据结构或对象状态转换成可以存储或传输的格式的过程,反序列化则是其逆过程。RPC 框架需要处理这些数据的序列化和反序列化。
- 服务注册与发现(Service Registry and Discovery):在微服务架构中,服务注册与发现是一个重要的组成部分。服务注册允许服务实例向注册中心注册自己的信息,服务发现则允许客户端从注册中心查询所需服务的信息,以便进行远程调用。
- 负载均衡(Load Balancing):当服务有多个实例时,负载均衡器负责将请求分发到不同的服务实例上,以提高系统的可用性和吞吐量。
在Java中,有多种RPC(远程过程调用)框架可供选择,这些框架为开发分布式系统提供了强大的支持。以下是一些常见的Java RPC框架:
- Dubbo
简介:Dubbo是阿里巴巴开源的高性能RPC框架,具有简单易用、高性能、可扩展等特点。它支持多种协议和负载均衡策略,提供了服务注册、发现和调用的解决方案。
特点:面向接口的远程方法调用、智能容错和负载均衡、服务自动注册和发现等。
适用场景:广泛应用于许多大型互联网公司,特别是需要高性能和可扩展性的分布式系统。 - Spring Cloud(严格说不是 RPC 框架)
简介:Spring Cloud 本身是一套微服务治理解决方案集合,而不是 RPC 框架。它的服务间调用默认靠 OpenFeign + Spring Cloud LoadBalancer,本质是基于 HTTP/JSON 的声明式 REST 调用,并非二进制协议的 RPC。
特点:服务注册发现、配置中心、网关、熔断限流一应俱全,与 Spring Boot 生态无缝集成。
适用场景:适合需要快速构建微服务架构、对性能不极致敏感的 Java 应用。
性能对比:HTTP/JSON 的序列化开销和报文体积都明显大于 Dubbo 的 Hessian2/Protobuf,高 QPS 内部调用应优先选 Dubbo/gRPC。 - Thrift
简介:Thrift是由Facebook开源的跨语言RPC框架,支持多种编程语言,包括Java。它使用接口描述语言(IDL)定义服务接口,通过生成和序列化代码来实现跨语言的通信。
特点:高效的序列化和传输机制,支持多种传输协议和压缩算法,适用于各种复杂的分布式应用场景。
适用场景:当系统需要支持多种编程语言,且对性能和效率有较高要求时,Thrift是一个很好的选择。 - gRPC
简介:gRPC是由Google开源的高性能RPC框架,它使用Protocol Buffers作为接口描述语言,并使用HTTP/2作为传输协议。gRPC支持多种编程语言,包括Java。
特点:基于 HTTP/2 天然支持**双向流式(Streaming)**、多路复用、头部压缩,这也是它在 AI 大模型场景(流式输出)被大量采用的原因。
适用场景:跨语言调用、需要服务端流/双向流(如 LLM 逐 token 输出、实时推送)的场景。 - Apache CXF(已较少用于新项目)
简介:Apache CXF是一个开源的全功能的服务框架,它支持多种和Web服务相关的标准和协议,包括SOAP、REST和WS-*协议。
适用场景:主要出现在遗留系统或与银行/政企对接 SOAP 接口的场景,新项目基本不再选择。 - 其他RPC框架
RMI(基于JRMP通信协议)、Hessian(基于二进制RPC协议)等已基本退出主流。另外 Java 原生序列化(RMI 依赖它)存在反序列化 RCE 风险,生产上应避免对外暴露。
Java中的RPC框架有多种选择,开发人员可以根据具体需求选择合适的框架来实现远程过程调用。这些框架的出现极大地简化了分布式系统的开发,提高了开发效率和系统性能。
Dubbo 3.x 带来的变化(面试高频)
很多资料还停在 Dubbo 2.x,但 Dubbo 3.x 已经是事实上的主流版本,三个变化必须知道:
- Triple 协议:新一代默认推荐协议,基于 HTTP/2 且兼容 gRPC。相比 Dubbo2 自定义的二进制
dubbo协议,Triple 可以直接穿越网关、被浏览器/其他语言客户端调用,并且原生支持双向流式通信。- 流式开发上使用
StreamObserver<T>:服务端通过onNext()多次推送数据帧、onError()传递异常、onCompleted()告知结束,客户端无需等待完整结果即可逐帧消费。AI 大模型的打字机效果、大批量数据分页推送都靠这一机制。
- 流式开发上使用
- 应用级服务发现:Dubbo2 是「接口级」注册,一个应用有多少个接口就向注册中心写多少条数据,大集群下注册中心存储和推送压力巨大。Dubbo3 改为按应用粒度注册(与 Spring Cloud/K8s 服务模型对齐),注册数据量降低一个数量级。
- 服务网格与云原生:支持 Sidecar/Proxyless Mesh,可以直接用 Kubernetes Service 作为注册中心。
另一个已经过时的点:Spring Cloud Netflix 体系(Ribbon、Hystrix、Zuul、1.x Eureka)均已停止维护并在 Spring Cloud 2020.0 之后被移除。现在的对应选择是:负载均衡→Spring Cloud LoadBalancer,熔断→Resilience4j / Sentinel,网关→Spring Cloud Gateway,注册中心+配置中心→Nacos。答题时如果仍然张口就是 Ribbon/Hystrix,会直接暴露知识陈旧。
高性能✅
CDN工作原理详解
CDN(Content Delivery Network/Content Distribution Network),内容分发网络,其将静态资源分发到多个不同的地方以实现就近访问,进而加快静态资源的访问速度,减轻服务器以及带宽的负担。
CDN和全站加速不同,全站加速既可以加速静态资源又可以加速动态资源,CDN主要针对静态资源。
- 基本概念
- CDN节点:CDN节点是部署在不同地理位置的服务器。它们可以缓存内容并处理用户请求。
- 源站:源站是内容的原始服务器,即CDN未介入时用户直接访问的服务器。
- POP(Point of Presence):指的是一个CDN服务点,通常是一个小型的数据中心,包含多个CDN节点。
- 缓存:CDN节点会缓存从源站获取的内容,以减少对源站的请求频率和用户的访问延迟。
- CDN的基本工作流程
- 用户请求内容:当用户访问一个使用CDN的网页时,用户的请求首先会被重定向到离用户最近的CDN节点。
- 查找缓存内容:
- 缓存命中:如果该CDN节点已经缓存了所请求的内容(缓存命中),则直接将内容返回给用户。
- 缓存未命中:如果缓存中没有请求的内容(缓存未命中),该节点会向源站发起请求以获取内容,然后将内容返回给用户,同时缓存该内容以供后续请求使用。
- 全局负载均衡:CDN利用全局负载均衡系统根据地理位置、服务器负载、网络状况等因素,将用户请求引导至最合适的节点。
- 内容刷新与失效:CDN可以根据配置设置内容的缓存时间,过期后需要重新从源站获取。同时,源站也可以主动通知CDN刷新或失效某些内容。
- CDN的优化
- 预热:预热是指在 CDN 上提前将内容缓存到 CDN 节点上。
- 使用CDN的优势
- 降低延迟:由于CDN节点分布在全球各地,用户请求可以在离用户最近的节点上得到响应,减少了物理距离带来的延迟。
- 提高可用性:CDN通过分布式架构可以在某些节点发生故障时自动切换到其他节点,提高服务的可靠性。
- 分担源站压力:通过将大量的内容缓存到CDN节点,减少了对源站的直接请求,特别是在流量高峰期,显著减轻了源站的压力。
- 加速内容分发:CDN可以通过多种优化手段加速内容分发,提升用户体验。
- 常见的应用场景
- 网站加速:静态内容(如图像、CSS、JavaScript文件)的加速分发。
- 视频点播:将视频内容缓存到各个CDN节点,提高视频播放的流畅性。
- 实时流媒体:利用CDN的低延迟特点,确保实时视频流的顺畅传输。
- 下载加速:通过CDN分发大文件(如软件、游戏安装包等),加快下载速度。
如何找到最合适的 CDN 节点?
GSLB(Global Server Load Balance,全局负载均衡)是 CDN 的大脑,负责多个 CDN 节点之间相互协作,最常用的是基于 DNS 的 GSLB。CDN 会通过 GSLB 找到最合适的 CDN 节点:
- 浏览器向 DNS 服务器发送域名请求;
- DNS 服务器向根据 CNAME(Canonical Name) 别名记录向 GSLB 发送请求;
- GSLB 返回性能最好(通常距离请求地址最近)的 CDN 节点(边缘服务器,真正缓存内容的地方)的IP地址给浏览器;
- 浏览器根据IP地址直接访问指定的 CDN 节点。
负载均衡✅
负载均衡指的是将用户请求分摊到不同的服务器上处理,以提高系统整体的并发处理能力以及可靠性。
一般分为服务端负载均衡和客户端负载均衡:
- 服务端负载均衡主要发生在网关层,可以使用软件(便宜,性能也够用)或者硬件(贵,但是性能好)实现。软件负载均衡通过如Nginx之类的软件实现,可在传输层、应用层实现负载均衡
- 传输层主要协议是 TCP/UDP,该层能看到数据包里的源端口地址和目的端口地址,会基于这些信息通过一定的负载均衡算法将数据包转发到后端真实服务器,核心就是 IP+端口层面的负载均衡。
- 应用层主要协议是 HTTP,该层的负载均衡会读取报文的数据部分,根据读取到的(URL、Cookie)做出负载均衡决策。执行第七层负载均衡的设备通常被称为 反向代理服务器。
- 客户端负载均衡主要应用于系统内部的不同的服务之间。客户端会自己维护一份服务器的地址列表,发送请求之前,客户端会根据对应的负载均衡算法来选择具体某一台服务器处理请求。(Spring Cloud LoadBalancer、Dubbo 内置 LoadBalance)
完整链路上的多级负载均衡
一个真实请求从用户到应用,往往要经过 4~5 层负载均衡,面试时能把这条链路说完整会非常加分:
- DNS 负载均衡:一个域名配多个 A 记录,实现机房级/地域级分流。优点是零成本;缺点是 DNS 缓存导致故障切流极慢,且无法感知后端存活。
- **四层负载均衡(LVS / F5)**:工作在传输层,只改 IP+端口不解开应用层报文,单机可承载百万级连接。LVS 的 DR 模式回包不经过负载均衡器,因此吞吐极高。
- **七层负载均衡(Nginx / 网关)**:能读 URL、Header、Cookie,可做精细路由、灰度、报文改写、SSL 卸载。代价是需要完整解包,单机吞吐低于四层。
- 客户端负载均衡(Dubbo / Spring Cloud LoadBalancer):微服务内部调用,在进程内直接选一个目标实例,少一次网络跳转,且能基于实时调用统计做决策。
- **服务网格(Istio/Envoy Sidecar)**:把负载均衡从业务进程下沉到 Sidecar,对业务代码零侵入,代价是多一次本机代理跳转的延迟。
典型组合:DNS → LVS(四层) → Nginx(七层) → 网关 → 微服务客户端负载均衡。
常见负载均衡算法✅
- 随机法:随机选择一台服务器处理请求。
- 优点:实现简单,适用于分布较为均匀的场景。
- 缺点:不保证请求的均匀分配,可能导致短时间内某些服务器负载过重。请求量越大越接近均匀(大数定律)。
- 轮询法:将请求依次分配给服务器,以循环的方式逐一选择服务器。
- 优点:实现简单,能够较为均衡地分配请求。
- 缺点:对于性能差异较大的服务器,可能导致某些服务器过载。
- 演进:加权轮询可以区分机器性能;但简单加权会产生「AAAAB」这种突发式分配,所以 Nginx 用的是 **平滑加权轮询(Smooth Weighted Round-Robin)**,使高权重节点的请求均匀散开成「ABABA」。
- 两次随机法(P2C, Power of Two Choices):随机选择两台服务器,比较它们的负载,将请求分配给负载较轻的那台服务器。
- 优点:只需比较两个节点就能把最大负载从 $O(\log n)$ 降到 $O(\log\log n)$,用极小代价拿到接近全局最优的效果,且不需要维护全局排序。
- 现状:这是 gRPC、Envoy/Istio、Kratos 等现代框架的默认算法,面试时能点出这一点会很加分。
- 哈希法:根据请求的某些特征(如源IP、URL)计算哈希值,并根据哈希值选择相应的服务器。
- 优点:对相同特征的请求分配到相同的服务器,适合需要会话保持、本地缓存命中的场景。
- 缺点:普通哈希取模(
hash % n)在服务器数量变化时,几乎所有请求的映射都会变,缓存全部失效。
- 一致性Hash法:将服务器和请求都映射到一个哈希环上,请求沿环顺时针找到最近的服务器。
- 优点:当服务器增加或减少时,只有
1/n的请求会被重新分配,适合于动态变化的分布式系统。 - 必须补充的关键点:虚拟节点。直接把物理节点映射到环上,节点少时会严重数据倾斜(某台机器担了大半流量)。解法是每个物理节点对应 150~200 个虚拟节点均匀散在环上,不能光说「哈希环」不说「虚拟节点」。
- 应用:Redis Cluster 的槽位(16384 slot)、一致性哈希分库分表、CDN 回源。
- 优点:当服务器增加或减少时,只有
- 最小连接法:将请求分配给当前连接数最少的服务器。
- 优点:动态调整负载,适用于长连接、请求耗时差异大的场景。
- 缺点:需要实时监控服务器的连接数,较为复杂。
- 最小活跃数法:将请求分配给当前「已发出未返回」请求最少的服务器。
- 优点:活跃数天然反映了服务器的处理能力——越慢的机器积压的活跃请求越多,自然就少分到流量,能自动避开慢节点。这是 Dubbo 的
leastactive策略。 - 缺点:需要在客户端维护每个提供者的活跃计数,有一定开销。
- 优点:活跃数天然反映了服务器的处理能力——越慢的机器积压的活跃请求越多,自然就少分到流量,能自动避开慢节点。这是 Dubbo 的
- 最快响应时间法:将请求分配给响应时间最快的服务器。
- 优点:可以提供更好的用户体验,适用于对响应时间要求较高的场景。
- 缺点:需要滑动窗口统计历史 RT,且容易造成马太效应——刚启动/刚恢复的节点 RT 不准,可能被瞬时打死,需要配合**预热(温度/权重缓启)**使用。
补充一个面试常问的工程细节:上面所有算法都建立在健康检查之上。如果没有健康检查把死节点摘除,再好的算法也会把 1/n 的请求坑进去。健康检查分主动探活(定时 ping/请求
/health)和被动剔除(连续失败 N 次就摘掉,Envoy 叫异常点排除 Outlier Detection)。
缓存(高性能的第一手段)
缓存是消除性能瓶颈最直接的手段,也是面试密度最高的一块。
多级缓存体系
从用户到数据库,每一层都可以拦一部分流量:
- 客户端缓存:浏览器
Cache-Control/ETag、App 本地存储。成本最低、效果最好,因为请求根本没发出来。 - CDN 缓存:静态资源、可缓存的接口响应。
- 网关/反向代理缓存:Nginx
proxy_cache。 - 应用本地缓存:Caffeine、Guava Cache。无网络开销,延迟级别在纳秒;代价是多实例之间数据不一致、占 JVM 堆、重启就没了。
- 分布式缓存:Redis。数据共享、容量大,延迟在毫秒级。
- 数据库缓存:InnoDB Buffer Pool。
本地缓存 + Redis 的组合是应对超高 QPS 的标准答案:本地缓存挡住 90%+ 的热 key 请求,避免单个热 key 把 Redis 单分片打死。一致性靠「短 TTL(如 5s) + MQ/发布订阅广播失效」解决。
缓存穿透 / 击穿 / 雪崩
三个名词很容易混,关键在于区分 key 是否存在、是一个还是一批:
| 问题 | 定义 | 解决方案 |
|---|---|---|
| 穿透(Penetration) | 查数据库里根本不存在的数据,缓存永远不命中,每次都打到 DB。常被恶意利用(构造不存在的 ID 刷接口) | ①缓存空值(存 null 并设短 TTL,简单但占内存);②布隆过滤器(前置拦截,空间极省,但有假阳且不能删除,需删除则用布隆计数器/Cuckoo Filter);③参数校验 + 对异常 IP 限流 |
| 击穿(Breakdown) | 单个热点 key 过期的那一瞬间,大量并发请求同时回源 DB | ①互斥重建(只让一个线程拿分布式锁去查 DB,其余等待或返回旧值);②热点 key 不设过期,靠异步任务主动更新;③逻辑过期(value 里存过期时间,发现逻辑过期就异步重建、同步返回旧值) |
| 雪崩(Avalanche) | 大批 key 同时过期,或 Redis 集群整体宕机,流量全量打到 DB 将其击垮 | ①TTL 加随机扰动(如 30min + rand(0,5min)),避免集体失效;②Redis 高可用(主从 + 哨兵/Cluster);③多级缓存,本地缓存做第二道防线;④限流 + 熔断 + 降级,保证 DB 不被打死而不是追求全部成功;⑤缓存预热,上线/重启前先把热数据灌进去 |
一句话区分:穿透是「数据不存在」,击穿是「一个热 key 过期」,雪崩是「一批 key 失效或缓存整体挂了」。
缓存与数据库一致性(面试必问)
四种组合的正确性对比:
| 方案 | 问题 |
|---|---|
| 先更缓存,再更 DB | DB 失败就脏数据,最差 |
| 先更 DB,再更缓存 | 并发写下两个线程的更新顺序可能倒置,导致缓存是旧值 |
| 先删缓存,再更 DB | 删完缓存到 DB 提交之前,读请求会把旧值回填进缓存,概率高 |
| 先更 DB,再删缓存(Cache Aside) | 也有理论上的不一致窗口,但概率最低,是业界首选 |
为什么是删而不是更新缓存?①删除是幂等的,更新有先后顺序问题;②写多读少的数据每次都算缓存是浪费(延迟加载思想)。
为什么 Cache Aside 仍有不一致窗口?极端情况:读请求发现缓存 miss 去查 DB(拿到旧值)→ 写请求更新 DB 并删缓存 → 读请求才把旧值写回缓存。条件是读操作比写操作还慢,概率极低。
工程上的加强手段:
- 延迟双删:更新 DB 后删一次,再延迟 500ms~1s 异步删第二次,消除回填旧值。缺点是延迟时长靠拍,且引入一次额外删除。
- 删除失败重试:把删除动作丢到 MQ,失败则重试,保证最终一致。
- 订阅 binlog(推荐):用 Canal 订阅 MySQL binlog,根据变更异步删缓存。优势是把缓存维护完全从业务代码里剔除出去,不会漏删,也不会因为业务分支多而遗漏。
- 设合理的 TTL 兜底:无论上面哪种方案,都必须给缓存设过期时间,这是最后一道保险。
结论式表达:引入缓存就意味着放弃强一致。只要缓存与 DB 是两个系统且不用分布式事务,就不可能做到强一致,只能做到最终一致。真需要强一致就不该用缓存,或者直接读 DB 主库。
热 key 与大 key
- 热 key:单个 key 的 QPS 极高,把 Redis 某一个分片/节点打满(Redis Cluster 按 slot 分片,热 key 无法通过扩容解决)。
- 解法:①本地缓存前置挡住;②key 拆分,把
hot_key拆为hot_key_0..N随机读一个;③热 key 多副本分散到不同节点。
- 解法:①本地缓存前置挡住;②key 拆分,把
- 大 key:单个 value 过大(如 String > 10KB、Hash/List 元素 > 5000)。危害:①网络带宽被打满;②删除大 key 会阻塞 Redis 单线程(应用
UNLINK异步删除);③集群下导致数据倾斜。- 解法:拆分为多个小 key、分页存储、用 Hash 代替大 String。
读写分离与分库分表
当缓存也挡不住时,就要动数据库架构了。顺序不能反:先 SQL 优化与索引 → 再缓存 → 再读写分离 → 最后才分库分表。
读写分离
主库写、从库读,适用于读多写少的场景。核心难点只有一个:主从延迟。
延迟的根源:MySQL 主从复制是 binlog dump → relay log → SQL 线程重放,早期 从库 SQL 线程是单线程,而主库是多线程并发写,天然跟不上。
应对手段:
- 强制读主:对一致性敏感的查询(如下单后立即查询订单)直接路由到主库。ShardingSphere 的
HintManager.setWriteRouteOnly()、MyBatis 自定义注解都能做。最简单最可靠。 - 写后读缓存:写入时同步把结果写 Redis,短时间内读缓存。
- 并行复制:MySQL 5.7+ 开启基于 LOGICAL_CLOCK/WRITESET 的并行回放,能大幅降低延迟。
- 监控剔除:根据
Seconds_Behind_Master把延迟过高的从库从读列表里摘除。 - 半同步复制:主库等至少一个从库收到 binlog 才返回,降低丢数据风险(但不能消除读延迟)。
分库分表
先分清两个维度:
- 垂直拆分:按业务拆库(用户库/订单库/商品库)、按字段拆表(大文本字段单独一张表)。解决的是耦合和单表字段过宽。
- 水平拆分:同一张表按行拆到多个库/表。解决的是单表数据量和单库写压力。面试说「分库分表」一般指这个。
分片策略:
- 范围分片(按时间/ID 区间):扩容极简单,但写入全部集中在最后一个分片,有写热点。适合日志、流水表。
- 哈希分片(
id % N):数据均匀,但扩容需要大量数据迁移。技巧:一开始就按 2 的幂次分片,扩容时翻倍,只需迁移一半数据(id % 4→id % 8,原分片 0 的数据只会去 0 或 4)。 - 一致性哈希:扩容只影响局部数据。
分片键选择是最关键的决策:必须选「绝大多数查询都会带上的字段」。例如订单表用 user_id 分片,则用户查自己订单只需路由到一个分片。
分库分表带来的新问题(面试重点,也是为何要把它放到最后):
- 跨分片查询:不带分片键的查询会变成广播查询,扫所有分片再内存归并。解法:建基因表/映射表(如
order_no → user_id),或把分片键的特征基因注入到订单号里。 - 多维度查询:商家也要查订单,但表是按
user_id分的。解法:双写两套分片(一套按用户、一套按商家),或者把完整数据同步到 ES 等异构索引做多条件查询。 - 跨分片分页:
LIMIT 10000, 10在 N 个分片上各取 10010 条再归并,深分页直接爆内存。解法:改为游标分页(WHERE id > 上页最大id LIMIT 10),或限制最大页码。 - 分布式事务:跨库写入需要引入 Seata 或最终一致方案。
- 全局唯一 ID:不能再用数据库自增,需要雪花/号段模式。
- 跨分片 JOIN / 聚合:COUNT、SUM、GROUP BY 都需要内存归并。解法:小表广播(字典表每库一份)、绑定表(主子表同分片规则,保证 JOIN 在单分片内完成)。
主流组件:ShardingSphere-JDBC(客户端模式,无额外部署、无网络跳转,但仅支持 Java)、ShardingSphere-Proxy(代理模式,对客户端透明、跨语言,代价是多一跳)、MyCat(迭代较慢)。
避坑提醒:分库分表是不可逆的重大决策,会把很多原本一行 SQL 能完成的事变成应用层拼接。单表千万级且索引得当时 MySQL 完全能撑,不要一上来就分库分表。如果只是存储量大但 QPS 不高,考虑 分区表或 TiDB/PolarDB 这类原生分布式数据库,成本低得多。
消息队列
消息队列是一种存放消息的容器,当需要使用消息的时候,直接从容器中取出使用即可。参与消息传递的双方是生产者和消费者,生产者负责生产并发送消息,消费者负责处理消息。
这里提到的消息队列不是操作系统进程通信中的消息队列,而是各个服务以及系统内部各个组件/模块之前的通信,属于一种中间件。
中间件(英语:Middleware),又译中间件、中介层,是一类提供系统软件和应用软件之间连接、便于软件各部件之间的沟通的软件,应用软件可以借助中间件在不同的技术架构之间共享信息与资源。中间件位于客户机服务器的操作系统之上,管理着计算资源和网络通信。
消息队列有什么作用
- 异步处理。将用户请求中的一些耗时操作,通过消息队列实现异步处理,将对应的消息发送到消息队列之后就立即返回结果,减少响应时间,提高用户体验。比如异步秒杀的核心是减库存和创建订单这种耗时多的分离出来,单独安排一个线程去执行,这样可以提高接口的响应速度。
- 削峰/限流。将短时间高并发产生的事务消息存储在消息队列中,然后后端服务再慢慢根据自己的能力去消费这些消息,避免高并发直接把后端服务打垮掉。
- 降低系统耦合性。如果模块之间不存在直接调用,那么可以只用消息队列进行解耦。这样新增模块或者修改模块就对其他模块影响较小,这样系统的可扩展性无疑更好一些。
- 消息队列使用发布-订阅模式工作。对新增业务,只要对该类消息感兴趣,即可订阅该消息,对原有系统和业务没有任何影响,从而实现网站业务的可扩展性设计。
- 实现分布式事务。分布式事务的解决方案之一就是 MQ 事务,允许事件流应用将消费,处理,生产消息整个过程定义为一个原子操作。
- 顺序保证。消息队列可以保证数据按照特定的顺序被处理,适用于对数据顺序有严格要求的场景。
- 延时/定时处理。在发送消息时,可以指定一个时间,到时间后该消息才可以被消费。
- 数据流处理。针对分布式系统产生的海量数据流,如业务日志、监控数据、用户行为等,消息队列可以实时或批量收集这些数据,并将其导入到大数据处理引擎中,实现高效的数据流管理和处理。
使用消息队列会带来的问题
- 系统可用性降低。使用消息队列后要考虑消息队列宕机/挂掉的情况。
- 系统复杂性提高。使用消息队列需要保证消息没有被重复消费、处理消息丢失的情况、保证消息传递的顺序性等等问题!
- 一致性问题。可以将一些耗时操作放入消息队列中进行异步处理,但是如果消费者并没有正确消费消息,就会出现数据不一致的情况。
消息队列如何保证消息被消费
消息队列系统在保证消息被消费方面采用了多种机制,以下是一些关键的策略:
- 消息确认(Acknowledgment):消息消费后,消费者需要向消息队列发送确认消息。只有在收到确认后,消息队列才会将其从队列中删除。未确认的消息会被视为未消费,可以重发给其他消费者。
- 消息持久化:为了防止消息丢失,消息队列通常会将消息持久化到磁盘。即使系统崩溃,持久化的消息依然可以在系统恢复后重新消费。
- 重试机制:如果消费者在处理消息时发生错误,消息队列可以将该消息标记为待重试,稍后会重新投递给消费者。这样可以确保消息最终被消费。
- 死信队列(Dead Letter Queue, DLQ):当消息经过多次重试仍然无法成功消费时,可以将其转移到死信队列,以便后续进行分析或人工干预。
- 消费组(Consumer Groups):在分布式系统中,消息队列可以将多个消费者组织为消费组,以实现负载均衡。每条消息只会被消费组中的一个消费者处理,确保每条消息只被消费一次。
- 消息顺序性:在某些场景中,消息的顺序性也很重要。消息队列可以采用分区或分组策略来确保某些特定消息的消费顺序。
- 监控和报警:监控消息队列的健康状态和消费进度,一旦发现异常情况(如消息积压),及时发出警报,便于运维人员处理。
通过这些机制,消息队列可以有效地保证消息被可靠地消费,确保系统的稳定性和数据的一致性。
JMS
JMS(JAVA Message Service, Java 消息服务)API 是一个消息服务的标准或者说是规范,允许应用程序组件创建、发送、接收和读取消息。它使分布式通信耦合度更低,消息服务更加可靠以及异步性。
命名已变更:随着 Java EE 捐赠给 Eclipse 基金会并改名 Jakarta EE,JMS 已正式改名为 Jakarta Messaging,包名从
javax.jms变为jakarta.jms。Spring Boot 3.x / Spring Framework 6.x 只支持jakarta.*,面试说到升级迁移时这是一个常见的坑。
JMS 定义了五种不同的消息正文格式以及调用的消息类型,允许发送并接收以一些不同形式的数据:
- StreamMessage:Java 原始值的数据流
- MapMessage:一套名称-值对
- TextMessage:一个字符串对象
- ObjectMessage:一个序列化的 Java 对象
- BytesMessage:一个字节的数据流
两种消息模型
- 点到点(P2P)模型:使用队列(Queue)作为消息通信载体;满足生产者与消费者模式,一条消息只能被一个消费者使用,未被消费的消息在队列中保留直到被消费或超时。比如:生产者发送 100 条消息的话,两个消费者来消费一般情况下两个消费者会按照消息发送的顺序各自消费一半(也就是你一个我一个的消费)。
- 发布/订阅(Pub/Sub)模型:发布订阅模型(Pub/Sub)使用主题(Topic)作为消息通信载体,类似于广播模式;发布者发布一条消息,该消息通过主题传递给所有的订阅者。
AMQP
AMQP(Advanced Message Queuing Protocol, 高级消息队列协议)是一个面向消息中间件的二进制应用层开放协议。基于此协议的客户端与消息中间件可传递消息,并不受客户端/中间件同产品、不同的开发语言等条件的限制。
纠正一个常见错误说法:很多资料写「AMQP 兼容 JMS」,这是不对的。AMQP 是 wire-level 线路协议,JMS 是 Java API 规范,两者处于不同抽象层次,不存在「兼容」关系。准确的表述是:可以用 JMS API 去操作一个 AMQP Broker(如 RabbitMQ 提供 JMS Client 插件、Qpid 提供 JMS 实现),但协议与 API 本身不是同一回事。
JMS/AMQP
表格修正说明:①原文两行都写作「支持消息类型」,实际上一行说的是消息模型/路由模式;②
topic change是topic exchange的拼写错误;③AMQP 0-9-1 规范实际只定义了 direct / fanout / topic / headers 四种 exchange,「system exchange」在规范中并未真正实现,不要跟着背「五种」。RabbitMQ 另外有一个常用的 dead letter exchange,但它是实现级能力而非协议定义的类型。
- AMQP 为消息定义了线路层(wire-level protocol)的协议,而 JMS 所定义的是 API 规范。在 Java 体系中,多个 client 均可以通过 JMS 进行交互,不需要应用修改代码,但是其对跨平台的支持较差。而 AMQP 天然具有跨平台、跨语言特性。
- JMS 支持
TextMessage、MapMessage等复杂的消息类型;而 AMQP 仅支持byte[]消息类型(复杂的类型可序列化后发送)。 - 由于 Exchange 提供的路由算法,AMQP 可以提供多样化的路由方式来传递消息到消息队列,而 JMS 仅支持 队列 和 主题/订阅 方式两种。
补充:现在新项目基本不再围绕 JMS/AMQP 选型,因为 Kafka 和 RocketMQ 都不遵循这两个标准,它们各自用自定义的二进制协议 + 官方 SDK。JMS/AMQP 的价值主要在于理解「协议 vs API 规范」这个抽象层次的差异。
消息队列选型
| 对比方向 | 概要 |
|---|---|
| 吞吐量 | ActiveMQ、RabbitMQ 吞吐量在万级;RocketMQ、Kafka、Pulsar 吞吐量在十万~百万级。 |
| 可用性 | 都可以实现高可用。RabbitMQ 靠镜像队列/**Quorum Queue(基于 Raft,3.8+ 推荐)**;RocketMQ 4.5+ 提供 DLedger(Raft) 实现主从自动切换;Kafka 靠多副本 + ISR,少数机器宕机不丢数据。 |
| 时效性 | RabbitMQ 基于 Erlang 开发,延迟最低,典型在亚毫秒~单位数毫秒;其他几个在数毫秒级。 |
| 功能支持 | RocketMQ 事务消息/定时消息/顺序消息最齐全;Kafka 强在流计算生态;Pulsar 存算分离 + 多租户,是云原生方向。 |
| 消息丢失 | 全部都能通过持久化 + 副本 + ACK 做到极低丢失率;但任何 MQ 都无法单靠自身做到绝对 0 丢失,必须配合生产端确认 + 消费端手动 ACK + 业务对账。 |
总结:
- RabbitMQ 吞吐量稍逊于 Kafka、RocketMQ 和 Pulsar,但延迟最低、路由能力最灵活。如果业务量级在万级 TPS 以内且需要复杂路由,可以使用 RabbitMQ。
- RocketMQ 是业务型 MQ 的首选:事务消息、定时/延时消息、顺序消息、消息回溯、消息轨迹都是开箱能力,金融/电商交易场景优先选它。
- Kafka 提供超高的吞吐量、极高的可用性以及可靠性,而且分布式可以任意扩展。Kafka 从 0.11 开始已支持幂等 Producer 和事务,能做到 Exactly-Once,所以「Kafka 一定会重复消息」已经是过时说法。大数据领域的实时计算、日志采集依旧首选 Kafka。
- Pulsar 采用存算分离(Broker 无状态 + BookKeeper 存储),扩缩容不需要数据迁移,天然多租户,适合多业务线共享一套 MQ 基建的场景;代价是组件多(还需 ZooKeeper/元数据服务)、运维复杂度高。
- ActiveMQ 经典版(5.x)性能较差、迭代慢,新项目不推荐。但不能笼统说「ActiveMQ 已被淘汰」:它的下一代 ActiveMQ Artemis(源于 HornetQ) 仍在活跃维护,也是 Red Hat AMQ 的内核。
选型一句话:业务事务/定时/顺序→RocketMQ;海量日志与流计算→Kafka;复杂路由且量不大→RabbitMQ;多租户云原生→Pulsar。
Kafka✅
Kafka 是一个分布式流式处理平台。具有三个关键功能:
- 消息队列:发布和订阅消息流,该功能类似于消息队列,这也是 Kafka 也被归类为消息队列的原因。
- 容错的持久方式存储记录消息流:Kafka 会把消息持久化到磁盘,有效避免了消息丢失的风险。
- 流式处理平台:Kafka 提供了一个完整的流式处理类库,在消息发布的时候进行处理。
Kafka应用场景
- 消息队列:建立实时流数据管道,以可靠地在系统或应用程序之间获取数据。
- 数据处理:构建实时的流数据处理程序来转换或处理数据流。
Kafka优势
- 极致的性能:基于 Scala 和 Java 语言开发,设计中大量使用了批量处理和异步的思想,提供了超高的吞吐量,最高可以每秒处理千万级别的消息。
- 生态系统兼容性:Kafka 与周边生态系统的兼容性非常好,尤其在大数据和流计算领域。
Kafka 为什么能做到高吞吐?
Kafka 的高吞吐设计是多维度优化的结果:
- 顺序写磁盘:消息追加到日志文件末尾,避开了机械盘寻道开销,顺序写吞吐可以比随机写高几个数量级
- PageCache 机制:重度依赖操作系统 PageCache,生产者写入先写到 PageCache,消费者读取优先从 PageCache 读,存储层几乎不占 JVM 堆,也不会因 GC 抖动
- 零拷贝(Zero-Copy):使用
sendfile()系统调用,数据从 PageCache 直接进入网卡缓冲区,拷贝次数从 4 次降为 2 次(均为 DMA),上下文切换从 4 次降为 2 次 - 消息集合传输与统一二进制格式:生产者、Broker、消费者使用同一种二进制消息格式,Broker 无需解开重组,可以直接落盘、直接转发
- 批量处理 + 压缩:Producer 支持批量发送(
batch.size和linger.ms),Consumer 支持批量拉取;批量压缩(推荐lz4/zstd)是整个 batch 一起压,压缩率远高于单条压缩 - 分区并行:Topic 分为多个 Partition,不同 Partition 可以并行读写
- 稀疏索引:每个日志段维护
.index(offset→物理位置)和.timeindex(时间戳→offset)两个稀疏索引,内存映射后二分查找
零拷贝失效的两个场景(追问高频,很多资料不提):
- 开启 SSL/TLS 时,数据必须进入用户态加密,
sendfile()无法使用,吞吐会明显下降。- 消息格式需要转换时(如旧版本客户端读新版本格式的日志),Broker 必须解开重写,零拷贝失效。所以生产上应尽量保持客户端与 Broker 版本对齐。
延伸:Kafka 适合日志/大数据场景(高吞吐),RocketMQ 适合金融/事务场景(低延迟+事务消息)。
Kafka消息模型
Kafka的消息模型是发布订阅模型。发布订阅模型(Pub-Sub)使用主题(Topic)作为消息通信载体,类似于广播模式;发布者发布一条消息,该消息通过主题传递给所有的订阅者,在一条消息广播之后才订阅的用户则是收不到该条消息的。在发布 - 订阅模型中,如果只有一个订阅者,那它和队列模型就基本是一样的了。
核心概念:
- Producer(生产者):产生消息的一方。
- Consumer(消费者):消费消息的一方。
- Broker(代理):一个独立的 Kafka 实例。多个 Kafka Broker 组成一个 Kafka Cluster。
- Topic(主题):Producer 将消息发送到特定的主题,Consumer 通过订阅特定的 Topic(主题) 来消费消息。
- Partition(分区):Partition 属于 Topic 的一部分。一个 Topic 可以有多个 Partition,且同一 Topic 下的 Partition 可以分布在不同的 Broker 上,这也就表明一个 Topic 可以横跨多个 Broker。
Kafka 中的 Partition(分区)实际上可以对应成为消息队列中的队列。

Kafka多副本机制
Kafka为分区(Partition)引入了多副本(Replica)机制。多个副本之间有一个 leader,多个follower。发送的消息会被发送到 leader 副本,然后 follower 副本从 leader 副本中拉取消息进行同步。
生产者和消费者默认只与 leader 副本交互,其他副本只是 leader 副本的拷贝,它们的存在只是为了保证消息存储的安全性。当 leader 副本发生故障时会从 follower 中选举出一个 leader,但是 follower 中如果有和 leader 同步程度达不到要求的参加不了 leader 的竞选。
补充 ISR(In-Sync Replicas) 这个关键概念:ISR 是「与 leader 保持同步的副本集合」。判定标准是
replica.lag.time.max.ms(默认 30s) 内是否跟上了 leader 的日志。只有 ISR 里的副本才有选举资格,落后太多会被踢出 ISR 进入 OSR。这是 Kafka 在「强一致」和「高可用」之间的折中设计——不像 Raft 硬要求过半数,而是动态维护一个同步集合。
同时注意两个水位线:LEO 是副本日志末端位移,HW(High Watermark) 是 ISR 中最小的 LEO。消费者只能读到 HW 之前的消息,这就是 Kafka 避免读到未提交数据的机制。
多分区(Partition)以及多副本(Replica)机制有什么好处呢?
- Kafka 通过给特定 Topic 指定多个 Partition, 而各个 Partition 可以分布在不同的 Broker 上, 这样便能提供比较好的并发能力(负载均衡)。
- Partition 可以指定对应的 Replica 数, 这也极大地提高了消息存储的安全性, 提高了容灾能力,不过也相应的增加了所需要的存储空间。
补充:分区也不是越多越好。每个分区都对应一组磁盘文件和文件句柄,分区过多会导致①顺序写退化为随机写;②故障恢复/Leader 选举时间变长;③内存与句柄开销上升。实际容量规划应根据目标吞吐量 / 单分区吞吐量 估算。
元数据管理:从 ZooKeeper 到 KRaft
这是本文最需要更新的知识点。「Kafka 依赖 ZooKeeper」已经是彻底过时的说法。
演进时间线:
- Kafka 2.8 引入 KRaft(KIP-500) 作为实验特性,首次可以不装 ZooKeeper。
- Kafka 3.3 宣布 KRaft 正式可用于生产环境。
- Kafka 4.0(2025 年 3 月)开始,ZooKeeper 模式被完全移除,只有 KRaft 一种模式。当前维护的 4.x 系列已经没有 ZooKeeper 的任何位置。
KRaft 是什么:把集群元数据本身当成一个内部 Topic(__cluster_metadata),由一组 Controller 节点通过 Raft 协议对这个日志达成共识。Broker 作为它的「消费者」回放元数据日志构建本地状态。
为什么要去掉 ZooKeeper(面试重点):
- 运维简化:不再需要维护两套分布式系统、两套监控、两套安全配置。
- 元数据规模上限提升:ZK 时代分区数量受限(数万级就吃力),KRaft 可支持百万级分区。
- Controller 故障恢复从分钟级降到秒级:旧模式下新 Controller 上任要从 ZK 全量拉取所有元数据;KRaft 下 Controller 已经在内存里持有最新状态,切主几乎瞬时完成。
- 消除元数据不一致窗口:旧架构下 ZK 是真相、Controller 再异步推送给 Broker,中间存在不一致窗口;KRaft 下所有节点回放同一份有序日志。
历史上 ZooKeeper 在 Kafka 里干什么(仅作为旧版本知识保留,面试应说清「这是 2.8 之前的架构」):
- Broker 注册:每个 Broker 启动时到
/brokers/ids下创建临时节点,写入自己的 IP 和端口。 - Topic/分区元数据:如
/brokers/topics/my-topic/partitions/0,维护分区与 Broker 的对应关系。 - Controller 选举:多个 Broker 竞争创建
/controller临时节点,成功者成为 Controller。 - 消费者位移:注意这个更早就变了——从 Kafka 0.9 开始,consumer offset 已从 ZK 迁到了内部 Topic
__consumer_offsets,不要再说「offset 存在 ZK」。
Kafka如何保证消息的顺序性
Kafka 中 Partition(分区)是真正保存消息的地方,发送的消息都被放在了这里。而 Partition(分区) 又存在于 Topic(主题) 这个概念中,并且我们可以给特定 Topic 指定多个 Partition。每次添加消息到 Partition(分区) 的时候都会采用尾加法,消息在被追加到 Partition(分区)的时候都会分配一个特定的偏移量(offset),通过偏移量(offset)来保证消息在分区内的顺序性。Kafka只能保证Partition(分区)中的消息有序。
Kafka 中发送 1 条消息的时候,可以指定 topic, partition, key, data(数据) 4 个参数。如果发送消息的时候指定了 Partition 的话,所有消息都会被发送到指定的 Partition。并且,同一个 key 的消息可以保证只发送到同一个 partition。总结有两种做法:
- 1 个 Topic 只对应一个 Partition。代价是完全失去并行能力,生产上不可取。
- (推荐)发送消息的时候指定 key/Partition,把需要保序的一组消息(如同一个订单 ID)路由到同一个分区,即「局部有序」。
两个必须补充的细节:
- 光指定 key 不够。如果
max.in.flight.requests.per.connection > 1且开启了重试,前一批失败重试、后一批先成功,同一分区内依然会乱序。解法是开启enable.idempotence=true,Kafka 会基于序列号保证单分区内有序(此时允许 in-flight 最大 5)。- 分区数变更会破坏顺序。
hash(key) % partitionCount在扩分区后映射会变,同一个 key 的新旧消息可能落在不同分区。对顺序敏感的 Topic 不要动态扩分区。- 另外,Kafka 2.4+ 对
key=null的消息默认使用**粘性分区器(Sticky Partitioner)**而不再是轮询:先把一个 batch 填满在同一分区,再换下一个,目的是提升批量效率、降低延迟。

Kafka如何保证消息不丢失
重要更新:从 Kafka 3.0 开始,Producer 的默认配置已经变成了「安全优先」,很多资料里的默认值已经错了:
acks默认从1变为allenable.idempotence默认从false变为trueretries默认已是 **Integer.MAX_VALUE**,真正控制重试时长的是delivery.timeout.ms(默认 2 分钟)所以「
acks默认值为 1」、「retries一般设为 3」这两句只适用于 Kafka 3.0 以前的版本。
- 生产者消息丢失:生产者(Producer)调用
send()方法(异步)发送消息之后,消息可能因为网络问题并没有发送过去。- 可以通过
get()获取调用结果,但这样也让它变为了同步操作,吞吐量大幅下降。 - 推荐:使用带回调的
send(record, callback),在回调里判断异常。注意区分可重试异常(RetriableException,如 leader 切换、网络超时,客户端会自动重试)和不可重试异常(如消息过大、序列化失败,重试无意义,必须落入本地兜底表)。 - 重试参数:推荐依赖默认的
retries=Integer.MAX_VALUE+delivery.timeout.ms控制总时长,并设置retry.backoff.ms避免瞬时密集重试。 - 异步发送的缓冲区丢失:消息先进
RecordAccumulator内存缓冲区再由 Sender 线程发送,进程被 kill 会丢掉缓冲区里的消息。所以关机前必须调producer.flush()/close()。
- 可以通过
- 消费者消息丢失:消费者拉取到了分区的某个消息之后,消费者会自动提交了 offset。假如消费者刚拿到消息准备消费的时候挂掉了,实际消息并没有被消费,但offset却自动提交了。
- 关闭自动提交(
enable.auto.commit=false),每次在真正消费完消息之后再自己手动提交 offset。但这样会出现重复消费的问题:如刚刚消费完消息之后,还没提交 offset 就挂掉了,消息会被消费两次。所以先处理后提交 = At Least Once,必须配合幂等。 - 避开一个坑:不要把消息丢给线程池后立即提交 offset。这样 offset 已提交但任务可能还在队列里,进程崩溃就真丢了。
- 关闭自动提交(
- Kafka消息丢失:leader副本所在的broker挂掉,但leader中的数据还没有被follower副本完全同步,会造成消息丢失。
- 设置
acks=all(3.0+ 已是默认值),表示ISR 中所有副本都接收到消息后生产者才会接收到响应。注意是 ISR 中的副本,不是全部副本,这个区别很关键。 - 设置
replication.factor >= 3,保证每个分区至少有 3 个副本。虽然造成了数据冗余,但是带来了数据的安全性。 - 设置
min.insync.replicas > 1,代表消息至少要被写入到 2 个副本才算是被成功发送。其默认值为 1 要尽量避免。一般设置为replication.factor = min.insync.replicas + 1,这样可以容忍 1 台机器宕机而不影响写入;如果两者相等,挂一台 Broker 整个分区就不可写。 - 设置
unclean.leader.election.enable = false(新版本已是默认值),代表当 leader 副本发生故障时不会从 ISR 之外的落后副本中选举 leader。这是一个典型的 CAP 取舍:设为 false 偏 CP(宁可分区不可用也不丢数据),设为 true 偏 AP。
- 设置
一个完整的面试回答应该是:消息不丢失需要三段都不丢——生产端
acks=all+ 幂等 + 回调处理异常;Broker 端多副本 +min.insync.replicas+ 禁止 unclean 选举;消费端先处理后提交 + 幂等。且无论如何都要有业务层对账兜底,因为消息还可能因为log.flush.interval.messages未落盘而在整机房断电时丢失。
Kafka如何保证消息不重复消费
重复消费原因:
- 已经消费的数据没有成功提交 offset(根本原因)。
- 由于业务处理时间过长超过
max.poll.interval.ms(默认 5 分钟),或心跳超时session.timeout.ms,让 Kafka 认为消费者失活,触发了分区 rebalance,已拉取未提交的消息被分配给其他消费者重新消费。
解决方案:
- 消费端做幂等校验是唯一可靠的工程手段,比如用 Redis 的
SET NX、MySQL 主键/唯一索引、状态机判断。因为即使开了事务,一旦消息被写入下游异质系统(发短信、调第三方)就无法回滚。 - 调优避免无必要的 rebalance:增大
max.poll.interval.ms、减小max.poll.records、把耗时业务异步化。 - 将
enable.auto.commit设为 false,开发者在代码中手动提交 offset。- 处理完消息再提交(At Least Once):依旧有重复消费的风险,必须配合幂等。
- 拉取到消息即提交(At Most Once):会有消息丢失的风险。允许消息延时的场景一般会采用这种方式,然后通过定时任务在业务不繁忙(比如凌晨)的时候做数据兜底。
补充:Kafka 的 Exactly-Once 语义
「Kafka 只能 At Least Once」是过时说法。从 0.11 版本开始 Kafka 就提供了两个能力:
- 幂等 Producer(
enable.idempotence=true,3.0+ 默认开启):每个 Producer 有一个 PID,每个分区维护递增序列号,Broker 丢弃重复序列号的消息,从而消除因重试导致的重复写入。限制:只在单会话、单分区内生效,Producer 重启后 PID 变了就不再保证。 - 事务(
transactional.id+initTransactions/beginTransaction/commitTransaction):可以把多个分区的写入 + 消费位移的提交放在同一个原子事务里,跨会话也能保证。消费端需设isolation.level=read_committed才不会读到未提交消息。
关键边界(追问必问):Kafka 的 Exactly-Once 只在 Kafka → Kafka 的闭环内成立(也就是 Kafka Streams 的
processing.guarantee=exactly_once_v2)。一旦你的消费逻辑要写 MySQL、调第三方接口,Kafka 事务就管不到那边了,依旧必须靠业务幂等。能把这个边界说清楚,比背一堆参数更有说服力。
重试失败后的数据如何再次处理?
当达到最大重试次数后,如果没有配置死信队列,数据会直接被跳过,继续向后进行。
死信队列(Dead Letter Queue,简称 DLQ)是消息中间件中的一种特殊队列,主要用于处理无法被消费者正确处理的消息,通常是因为消息格式错误、处理失败、消费超时等情况导致的消息被”丢弃”或”死亡”的情况。当消息进入队列后,消费者会尝试处理它。如果处理失败,或者超过一定的重试次数仍无法被成功处理,消息可以发送到死信队列中,而不是被永久性地丢弃。在死信队列中,可以进一步分析、处理这些无法正常消费的消息,以便定位问题、修复错误,并采取适当的措施。
@RetryableTopic 是 Spring Kafka 中的一个注解,它用于配置某个 Topic 支持消息重试,更推荐使用这个注解来完成重试。它的实现方式是为每个重试级别自动创建一个 xxx-retry-N Topic,把重试异步化到其他 Topic,从而避免在原 Topic 里阻塞住后面的消息。当达到最大重试次数后,消息会被发送到对应的死信队列中。对于死信队列的处理,既可以用 @DltHandler 处理,也可以使用 @KafkaListener 重新消费。
为何不能在原地阻塞重试:Kafka 分区是严格有序的单指针消费,如果对第 1 条消息原地
sleep重试,整个分区后面的消息全部被卡住,而且很容易超过max.poll.interval.ms触发 rebalance。这也是 Kafka 和 RocketMQ 的一个重要差异:RocketMQ 内置了 16 个级别的重试队列(%RETRY%消费组)和死信队列(%DLQ%消费组),开箱即用;Kafka 原生没有重试队列概念,需要依赖 Spring Kafka 或自己实现阶段重试 Topic。
Kafka 补充:Rebalance 与消费者组
Rebalance(重平衡)是面试高频追问点,指分区所有权在消费者组内重新分配的过程。
触发条件:①消费者加入/退出/崩溃;②订阅的 Topic 数量变化;③Topic 分区数变化。
为什么 Rebalance 很痛:旧的「Eager 协议」采用 stop-the-world 方式——所有消费者先放弃全部分区,然后重新分配,期间整个消费组停止消费。集群大时可能持续数十秒。
演进:
- Kafka 2.4 引入 **CooperativeStickyAssignor(渐进式再平衡)**:只让需要迁移的分区释放,其余分区继续消费,大幅降低停顿。
- Kafka 4.0 中 KIP-848 新消费者组协议正式 GA:把分区分配从客户端的 Group Leader 下沉到 Broker 端的 Coordinator,彻底消除全局同步屏障,让 rebalance 变成增量、非阻塞的。
工程参数关系:一个分区同时只能被同一个消费者组内的一个消费者消费,所以消费者数量 > 分区数时,多出的消费者完全空转。想提升消费能力必须先扩分区。
Kafka 补充:消息积压怎么处理?
这是最贴近生产的一道题,应该分两步回答:
先定位原因(积压 = 生产速率 > 消费速率):
- 消费端变慢:下游接口/数据库变慢、慢 SQL、Full GC。
- 消费端报错死循环:一条消息永远失败并阻塞重试,卡住整个分区。
- 并行度不够:分区数就是消费并行度的硬上限。
- 流量突增:大促/批量刷数据。
再给方案(按优先级):
- 先恢复消费能力:如果是死循环,先把异常消息跳过/打到死信队列。
- 提升单机并行度:拉取后丢给业务线程池处理(注意要自己管 offset 提交与流控,否则会丢消息)。
- 扩容:先扩分区,再扩消费者实例。注意只扩消费者不扩分区是无效的。
- 紧急预案:搞个临时转发程序。当分区数固定、来不及扩容时,写一个只做转发的消费者,把消息快速搬到一个分区数更多的新 Topic,再用大量消费者并行处理。这是面试里的加分答案。
- 降级丢弃:对日志/监控类可丢消息,直接重置 offset 到最新位置保住时效性。
注意:Kafka 积压本身不会导致 Broker 崩溃(数据就在磁盘上),但要盯
log.retention.hours(默认 7 天)——如果积压时长超过保留时间,未消费的消息会被直接删除,这是真丢数据。
RocketMQ✅
RocketMQ 具有高性能、高可靠、高实时、分布式的特点。它是一个采用 Java 语言开发的分布式消息系统,由阿里巴巴团队开发,2016 年 11 月捐赠给 Apache,2017 年 9 月毕业成为 Apache 顶级项目。在阿里内部,RocketMQ 很好地服务了集团大大小小上千个应用,在每年的双十一当天,更有不可思议的万亿级消息通过 RocketMQ 流转。
RocketMQ 通过在一个 Topic 中配置多个队列并且每个队列维护每个消费者组的消费位置,实现了主题模式/发布订阅模式。
RocketMQ 存储设计:与 Kafka 的关键差异
这是两者对比题的根本区别,很多资料没讲清:
- Kafka:每个 Partition 一组独立的日志文件。分区少时顺序写极快,但分区多了会退化为随机写,吞吐断崖式下降。
- **RocketMQ:所有 Topic 的消息全部顺序追加到同一个
CommitLog**,ConsumeQueue只存「消息在 CommitLog 里的位置+大小+tag hash」这样的 20 字节索引。- 优势:无论多少个 Topic/队列,磁盘上永远是一个文件在顺序写,支持单机十万级 Topic,非常适合业务拆分细的企业场景。
- 代价:读取时需要先读 ConsumeQueue 拿到偏移,再回 CommitLog 随机读,多一次跳转(靠 PageCache 和预读缓解)。
- 所以一句话总结:Kafka 适合少 Topic、超高吞吐;RocketMQ 适合多 Topic、业务功能丰富。
RocketMQ的消息模型
RocketMQ 的消息模型是发布订阅模型,与主题(Topic)对应,主题内部再划分为多个队列(MessageQueue)。

主要模块:
- 生产者组(Producer Group):
- 消费者组(Consumer Group):
- 主题(Topic):Producer 将消息发送到特定的主题,Consumer 通过订阅特定的 Topic(主题) 来消费消息。
- 代理(Broker):一个独立的 RocketMQ 实例。多个 RocketMQ Broker 组成一个 RocketMQ Cluster。
- 队列:每个Topic有多个队列(提高并发能力),集群模式下主题和队列可以分布在不同的Broker,一个消费者集群共同消费一个 topic 的多个队列,一个队列只会被一个消费者消费。如果某个消费者挂掉,分组内其它消费者会接替挂掉的消费者继续消费。
为什么队列为每个消费者组维护一个消费偏移
在发布订阅模式中一般会涉及到多个消费者组,而每个消费者组在每个队列中的消费位置都是不同的。消息被一个消费者组消费后是不会删除的,其他消费者组也需要消费。所以队列为每个消费者组维护一个偏移(offset),每次消费者组消费完会返回一个成功的响应,然后队列再把维护的消费位移加一,这样就不会出现刚刚消费过的消息再一次被消费了。
RocketMQ架构

RocketMQ架构中有四个角色:
- Broker:消息队列服务器,主要负责消息的存储、投递和查询以及服务高可用保证。生产者生产消息到 Broker,消费者从 Broker 拉取消息并消费。
- Broker和Topic是多对多的关系,一个 Topic 分布在多个 Broker上,一个 Broker 可以配置多个 Topic。
- 如果某个 Topic 消息量很大,应该给它多配置几个队列(提高并发能力),并且尽量多分布在不同 Broker 上,以减轻某个 Broker 的压力。
- Broker做集群和主从部署,slave 定时从 master 同步数据。
- 这里必须区分两个概念:①刷盘方式(同步刷盘
SYNC_FLUSH/ 异步刷盘ASYNC_FLUSH)描述的是单机上数据什么时候落到磁盘;②复制方式(同步双写SYNC_MASTER/ 异步复制ASYNC_MASTER)描述的是数据什么时候同步到 slave。原文把它们混为一谈是不准确的。生产上典型配置是「异步刷盘 + 同步双写」,兼顾吞吐与可靠性。 - 主从切换已经演进,不要只说旧结论:
- **传统主从(Master-Slave)**:master 宕机后 slave 只能提供读(消费),不能写入,且不会自动升主,需要人工干预。这是早期 4.x 的默认形态。
- DLedger 模式(4.5+):基于 Raft 实现,slave 可以自动选举升主,真正做到高可用。代价是至少需要 3 个节点、写入要过半数确认,吞吐比传统主从低。
- RocketMQ 5.x:引入无状态 Proxy 层和存算分离架构,客户端通过 gRPC 访问 Proxy,Proxy 再访问 Broker,兼容多语言轻量 SDK;同时支持**分级存储(冷数据卸到对象存储)**。
- NameServer:注册中心,主要提供 Broker 管理 和 路由信息管理 功能。Broker会将自己的信息注册到NameServer(路由表),消费者和生产者从 NameServer 中获取路由表然后照着路由表的信息和对应的 Broker 进行通信(生产者和消费者定期会向 NameServer 去查询相关的 Broker 的信息)。
- 为了保证高可用,NameServer以去中心化(无主节点)的集群方式部署。通过单个 Broker 和所有 NameServer 保持长连接 ,并且在每隔 30 秒 Broker 会向所有 Nameserver 发送心跳,心跳包含了自身的 Topic 配置信息。
- Producer:支持分布式集群方式部署。在生产者需要向 Broker 发送消息的时候,需要先从 NameServer 获取关于 Broker 的路由信息,然后通过 轮询 的方法去向每个队列中生产数据以达到 负载均衡 的效果。
- Consumer:支持分布式集群方式部署。消费者通过 NameServer 获取所有 Broker 的路由信息后,向 Broker 发送 Pull 请求来获取消息数据。Consumer 可以以两种模式启动—— 广播(Broadcast)和集群(Cluster)。广播模式下,一条消息会发送给 同一个消费组中的所有消费者 ,集群模式下消息只会发送给一个消费者。

NameServer作用
需要使用多个Broker进行负载均衡,如果没有NameServer,那么多个生产者和消费者直接和多个Broker相连,会产生耦合问题,NameServer 注册中心就是用来解决这个问题的。
生产者不建议单一线程大量创建
Apache RocketMQ 的生产者和主题是多对多的关系,支持同一个生产者向多个主题发送消息。对于生产者的创建和初始化,建议遵循够用即可、最大化复用原则,如果有需要发送消息到多个主题的场景,无需为每个主题都创建一个生产者。
生产者不建议频繁创建和销毁
Apache RocketMQ 的生产者是可以重复利用的底层资源,类似数据库的连接池。因此不需要在每次发送消息时动态创建生产者,且在发送结束后销毁生产者。这样频繁的创建销毁会在服务端产生大量短连接请求,严重影响系统性能。
普通消息/定时消息
- 普通消息:一般应用于微服务解耦、事件驱动、数据集成等场景,这些场景大多数要求数据传输通道具有可靠传输的能力,且对消息的处理时机、处理顺序没有特别要求。以在线的电商交易场景为例,上游订单系统将用户下单支付这一业务事件封装成独立的普通消息并发送至 RocketMQ 服务端,下游按需从服务端订阅消息并按照本地消费逻辑处理下游任务。每个消息之间都是相互独立的,且不需要产生关联。另外还有日志系统,以离线的日志收集场景为例,通过埋点组件收集前端应用的相关操作日志,并转发到 RocketMQ 。
- 定时消息:在分布式定时调度触发、任务超时处理等场景,需要实现精准、可靠的定时事件触发。使用 RocketMQ 的定时消息可以简化定时调度任务的开发逻辑,实现高性能、可扩展、高可靠的定时触发能力。定时消息仅支持在 MessageType 为 Delay 的主题内使用,即定时消息只能发送至类型为定时消息的主题中,发送的消息的类型必须和主题的类型一致。在 4.x 版本中,只支持延时消息,默认分为 18 个等级分别为:1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h,也可以在配置文件中增加自定义的延时等级和时长。在 5.x 版本中,开始支持定时消息,在构造消息时提供了 3 个 API 来指定延迟时间或定时时间。
普通消息生命周期
- 初始化:消息被生产者构建并完成初始化,待发送到服务端的状态。
- 待消费:消息被发送到服务端,对消费者可见,等待消费者消费的状态。
- 消费中:消息被消费者获取,并按照消费者本地的业务逻辑进行处理的过程。此时服务端会等待消费者完成消费并提交消费结果,如果一定时间后没有收到消费者的响应,RocketMQ 会对消息进行重试处理。
- 消费提交:消费者完成消费处理,并向服务端提交消费结果,服务端标记当前消息已经被处理(包括消费成功和失败)。RocketMQ 默认支持保留所有消息,此时消息数据并不会立即被删除,只是逻辑标记已消费。消息在保存时间到期或存储空间不足被删除前,消费者仍然可以回溯消息重新消费。
- 消息删除:RocketMQ 按照消息保存机制滚动清理最早的消息数据,将消息从物理文件中删除。
基于定时消息的超时任务处理具备如下优势:
- 精度高、开发门槛低:基于消息通知方式不存在定时阶梯间隔。可以轻松实现任意精度事件触发,无需业务去重。
- 高性能可扩展:传统的数据库扫描方式较为复杂,需要频繁调用接口扫描,容易产生性能瓶颈。RocketMQ 的定时消息具有高并发和水平扩展的能力。
定时消息生命周期
- 定时消息生命周期初始化:消息被生产者构建并完成初始化,待发送到服务端的状态。
- 定时中:消息被发送到服务端,和普通消息不同的是,服务端不会直接构建消息索引,而是会将定时消息单独存储在定时存储系统中,等待定时时刻到达。
- 待消费:定时时刻到达后,服务端将消息重新写入普通存储引擎,对下游消费者可见,等待消费者消费的状态。
- 消费中:消息被消费者获取,并按照消费者本地的业务逻辑进行处理的过程。此时服务端会等待消费者完成消费并提交消费结果,如果一定时间后没有收到消费者的响应,RocketMQ 会对消息进行重试处理。
- 消费提交:消费者完成消费处理,并向服务端提交消费结果,服务端标记当前消息已经被处理(包括消费成功和失败)。RocketMQ 默认支持保留所有消息,此时消息数据并不会立即被删除,只是逻辑标记已消费。消息在保存时间到期或存储空间不足被删除前,消费者仍然可以回溯消息重新消费。
- 消息删除:Apache RocketMQ 按照消息保存机制滚动清理最早的消息数据,将消息从物理文件中删除。
定时消息的实现逻辑需要先经过定时存储等待触发,定时时间到达后才会被投递给消费者。因此,如果将大量定时消息的定时时间设置为同一时刻,则到达该时刻后会有大量消息同时需要被处理,会造成系统压力过大,导致消息分发延迟,影响定时精度。
顺序消息/事务消息
顺序消息:顺序消息仅支持使用 MessageType 为 FIFO 的主题,即顺序消息只能发送至类型为顺序消息的主题中,发送的消息的类型必须和主题的类型一致。和普通消息发送相比,顺序消息发送必须要设置消息组。要保证消息的顺序性需要单一生产者串行发送。
纠正一个错误说法:原文「单线程使用 MessageListenerConcurrently 可以顺序消费」是不对的。
MessageListenerConcurrently并不保证顺序,即使把消费线程数设为 1,它依旧会并行拉取多个队列且失败重试会打乱顺序。要保证顺序必须用MessageListenerOrderly,它的实现原理是对每个 MessageQueue 加锁,同一时刻一个队列只能被一个线程串行处理,这样不同队列仍可并行,同一队列严格串行。完整的顺序消息需要三端都保证:
- 发送端:用
MessageQueueSelector(4.x) 或消息组 MessageGroup(5.x) 把同一业务实体(如同一订单号)路由到同一个队列,且必须同步发送。 - 存储端:同一队列内天然有序。注意 Broker 扩容时队列数变化会打破路由一致性。
- 消费端:用
MessageListenerOrderly,且不能在里面异步提交给线程池,否则前面的保序全白做。
另一个高频追问:顺序消费时某条消息一直失败怎么办?
MessageListenerOrderly下返回SUSPEND_CURRENT_QUEUE_A_MOMENT会阻塞该队列持续重试,最多 16 次后进死信队列。这是顺序消息的固有代价:为了保序,必然牺牲失败隔离。- 发送端:用
事务消息:事务消息是 Apache RocketMQ 提供的一种高级消息类型,支持在分布式场景下保障消息生产和本地事务的最终一致性。简单来讲,就是将本地事务(数据库的 DML 操作)与发送消息合并在同一个事务中。例如,新增一个订单。在事务未提交之前,不发送订阅的消息。发送消息的动作随着事务的成功提交而发送,随着事务的回滚而取消。
**事务消息完整流程(必背)**:
- 生产者先发送半消息(Half Message)到 Broker。Broker 把它存到内部 Topic
RMQ_SYS_TRANS_HALF_TOPIC,不构建 ConsumeQueue 索引,因此消费者看不到。 - 生产者执行本地事务。
- 根据本地事务结果发送二次确认:Commit 则 Broker 把半消息重写到真实 Topic 并构建索引,消费者可见;Rollback 则丢弃。
- 如果生产者宕机或二次确认丢失,Broker 会定时**回查(
checkLocalTransaction)**生产者集群,询问本地事务的最终状态。默认最多回查 15 次,仍无结果则默认回滚。
关键边界:事务消息只保证「本地事务与消息发出」的原子性,它不管下游消费是否成功。下游消费失败依旧靠重试 + 死信队列 + 业务幂等,所以它实现的是最终一致而非强一致。
- 生产者先发送半消息(Half Message)到 Broker。Broker 把它存到内部 Topic
消费者分类
RocketMQ 5.x 把消费者分为三类,面试要能说出适用场景:
| 类型 | 接口 | 特点 | 适用场景 |
|---|---|---|---|
| PushConsumer | PushConsumer |
SDK 自动拉取、自动流控、自动提交位点,业务只写回调 | 绝大多数业务场景,开发成本最低 |
| SimpleConsumer | SimpleConsumer |
业务主动调 receive() 取消息、主动 ack(),可自定义并发与不可见时间 |
需要自己控制消费节奏、批量处理、多语言客户端 |
| PullConsumer | PullConsumer |
最底层,业务自己管理队列分配和位点 | 流计算对接(如 Flink)、需要精确控制位点回溯 |
“Push”的真相:RocketMQ 的 PushConsumer 并不是 Broker 主动推,底层仍然是客户端拉,只不过用了长轮询(Long Polling):拉取请求到达 Broker 后,如果没有新消息则挂起连接最多 15 秒,期间一旦有消息到达就立即唤醒返回。这样既有推的实时性,又避免了空轮询的资源浪费。能答出“伪 Push = 长轮询”是个加分项。
消费模式:集群 vs 广播
- 集群消费(Clustering,默认):同一消费组内一条消息只被一个实例消费,位点存在 Broker 端,支持重试和死信队列。
- 广播消费(Broadcasting):同一消费组内每个实例都消费全量消息,位点存在客户端本地文件。因此①本地文件丢了会重消费;②广播模式不支持重试和死信队列,消费失败就丢了。典型用于本地缓存刷新、配置下发这类“每台机器都要收到”的场景。
RabbitMQ✅
RabbitMQ 是基于 Erlang 开发、实现 AMQP 0-9-1 协议的消息中间件。它的定位与 Kafka/RocketMQ 不同:不拼吞吐量,拼路由灵活度和低延迟。
核心组件
Producer → Exchange →(Binding Key)→ Queue → Consumer
- Exchange(交换机):RabbitMQ 的灵魂,生产者只把消息发给 Exchange,Exchange 根据类型和 Binding 规则决定投递到哪些 Queue。四种类型:
direct:routing key 完全匹配,最常用。fanout:忽略 routing key,广播给所有绑定的队列。topic:模糊匹配,*匹配一个单词,#匹配零个或多个单词(如order.*.created)。headers:根据消息头属性匹配,性能差,实际很少用。
- Queue:真正存消息的地方。注意 RabbitMQ 的一个队列在一个节点上是单线程处理的,这也是它吞吐上不去的根本原因。
- Virtual Host:逻辑隔离单元,不同 vhost 的 Exchange/Queue 完全隔离,可以当多租户用。
如何保证消息不丢(三段式)
- 生产者 → Broker:开启 **Publisher Confirm(发布确认)**,Broker 持久化成功后回 ack。另有 Return 机制处理「到了 Exchange 但路由不到任何 Queue」的情况。
不要答“事务模式(
channel.txSelect)”:它是同步阻塞的,吞吐量会降低一个数量级,生产上基本不用。 - Broker 内部: Exchange、Queue、Message 三者都要设持久化,只持久化队列不持久化消息照样丢。
- Broker → 消费者:关闭自动 ack(
autoAck=false),业务处理完后手动basicAck;异常时用basicNack/basicReject并决定是否 requeue。
延时消息怎么做(面试高频)
RabbitMQ 原生没有延时消息,两种实现:
- **TTL + 死信队列(DLX)**:给消息/队列设 TTL,过期后自动进入死信交换机,消费死信队列就等于消费延时消息。
- 致命坑:如果给单条消息设不同 TTL,由于队列是头部检查的,会发生队头阻塞——前面一条 TTL=10s 的消息会拖住后面 TTL=1s 的消息。所以只能按固定延时级别建多个队列。
rabbitmq_delayed_message_exchange插件:官方插件,在 Exchange 层面做延时,支持任意精度,无队头阻塞问题。推荐用法。
对比:RocketMQ 4.x 内置 18 个延时级别、5.x 支持任意时间定时消息;Kafka 原生不支持延时消息,需要自己用分层 Topic + 时间轮实现。
高可用方案演进
- 普通集群:只同步元数据,消息只存在一个节点,那个节点挂了消息就不可用。不算高可用。
- 镜像队列(Mirrored Queue):消息同步到所有镜像节点。注意它已在 RabbitMQ 3.9 被标记为不推荐,并在 4.0 中被移除,因为它在网络分区时可能丢消息。
- Quorum Queue(3.8+ 推荐):基于 Raft 实现,写入过半数确认,网络分区下不丢消息。现在回答 RabbitMQ 高可用应该直接说 Quorum Queue,而不是镜像队列,这是一个很好的区分度。
三大 MQ 横向对比速查
| 维度 | Kafka | RocketMQ | RabbitMQ |
|---|---|---|---|
| 存储结构 | 每 Partition 独立日志 | 全局 CommitLog + ConsumeQueue 索引 | 内存为主 + 可持久化 |
| 一个 Topic 上限 | 千级(多了退化为随机写) | 十万级 | 万级 |
| 事务消息 | 有(仅 Kafka 内闭环) | 有(半消息 + 回查,支持业务事务) | 有(同步事务,性能差) |
| 延时消息 | 无,需自建 | 有(4.x 十八级 / 5.x 任意精度) | 靠 TTL+DLX 或插件 |
| 重试队列 | 无,靠 Spring Kafka 阶段 Topic | 内置 16 级重试 + DLQ | 靠 DLX 自己搭 |
| 消息回溯 | 按 offset/时间戳 | 按 offset/时间戳 | 不支持(消费即删) |
| 高可用机制 | 多副本 + ISR + KRaft | DLedger(Raft) / 传统主从 | Quorum Queue(Raft) |
| 适合干什么 | 日志、埋点、流计算 | 交易、订单、定时任务 | 企业集成、复杂路由 |
高可用✅
什么是高可用
高可用描述的是一个系统在大部分时间都是可用的,可以提供服务的。高可用代表系统即使在发生硬件故障或者系统升级的时候,服务仍然是可用的。
- 系统的可用性还可以用某功能的失败次数与总的请求次数之比来衡量,比如对网站请求 1000 次,其中有 10 次请求失败,那么可用性就是 99%。
可用性与宕机时长对照表(面试直接报数字比背百分比有说服力):
| 可用性 | 叫法 | 年宕机时长 | 月宕机时长 |
|---|---|---|---|
| 99% | 2 个 9 | 约 3.65 天 | 约 7.2 小时 |
| 99.9% | 3 个 9 | 约 8.76 小时 | 约 43 分钟 |
| 99.99% | 4 个 9 | 约 52.6 分钟 | 约 4.3 分钟 |
| 99.999% | 5 个 9 | 约 5.26 分钟 | 约 26 秒 |
工程上要注意:串联依赖会乘法降低可用性。一个请求串行依赖 5 个可用性 99.9% 的服务,整体可用性只有
0.999^5 ≈ 99.5%。所以微服务拆得越细,对单个服务的 SLA 要求就越高,这也是为什么必须有降级兜底。
哪些情况会导致系统不可用?
- 黑客攻击;
- 硬件故障,比如服务器坏掉。
- 并发量/用户请求量激增导致整个服务宕掉或者部分服务不可用。
- 代码中的坏味道导致内存泄漏或者其他问题导致程序挂掉。
- 网站架构某个重要的角色比如 Nginx 或者数据库突然不可用。
- 自然灾害或者人为破坏。
- ……
提高系统高可用的方法
注重代码质量,测试严格把关
代码质量有问题比如比较常见的内存泄漏、循环依赖都是对系统可用性极大的损害。比较实际可用的提高代码质量方法就是 CodeReview。
使用集群,减少单点故障
比如使用一个 Redis 实例作为缓存的时候,这个 Redis 实例挂了之后,整个缓存服务可能就挂了。使用了集群之后,即使一台 Redis 实例挂了,不到一秒就会有另外一台 Redis 实例顶上。
限流
流量控制,其原理是监控应用流量的 QPS 或并发线程数等指标,当达到指定的阈值时对流量进行控制,以避免被瞬时的流量高峰冲垮,从而保障应用的高可用性。
超时和重试机制设置
一旦用户请求超过某个时间的得不到响应,就抛出异常。这个是非常重要的,很多线上系统故障都是因为没有进行超时设置或者超时设置的方式不对导致的。在读取第三方服务的时候,尤其适合设置超时和重试机制。一般使用一些 RPC 框架的时候,这些框架都自带的超时重试的配置。如果不进行超时设置可能会导致请求响应速度慢,甚至导致请求堆积进而让系统无法再处理请求。重试的次数一般设为 3 次,再多次的重试没有好处,反而会加重服务器压力(部分场景使用失败重试机制会不太适合)。
熔断机制
超时和重试机制设置之外,熔断机制也是很重要的。 熔断机制说的是系统自动收集所依赖服务的资源使用情况和性能指标,当所依赖的服务恶化或者调用失败次数达到某个阈值的时候就迅速失败,让当前系统立即切换依赖其他备用服务。
框架选型已变,不要再说 Hystrix:Netflix Hystrix 自 2018 年已进入维护模式、停止新功能开发,并已从 Spring Cloud 中移除。当前的主流选择是:
- **Sentinel(阿里)**:国内使用最广,亮点是 控制台可视化 + 规则动态下发 + 与 Nacos 打通,且支持系统自适应保护(根据 Load、CPU 自动限流)。
- Resilience4j:Hystrix 官方推荐的替代品,轻量、函数式、基于滑动窗口,Spring Cloud CircuitBreaker 的默认实现。
- Spring Cloud CircuitBreaker:一层抽象 API,底层可以切 Resilience4j / Sentinel。
Hystrix 与 Sentinel 的核心区别(面试加分点):Hystrix 依赖线程池隔离,每个依赖一个线程池,隔离彻底但线程切换开销大、上下文丢失;Sentinel 默认用**信号量计数(在调用线程上直接统计)**,开销极低,但无法隔离慢调用对调用方线程的占用。
异步调用
异步调用的话我们不需要关心最后的结果,这样就可以用户请求完成之后就立即返回结果,具体处理我们可以后续再做,秒杀场景用这个还是蛮多的。
使用缓存
如果系统属于并发量比较高的话,如果单纯使用数据库的话,当大量请求直接落到数据库可能数据库就会直接挂掉。使用缓存缓存热点数据,因为缓存存储在内存中,所以速度相当地快!
其他
- 核心应用和服务优先使用更好的硬件
- 监控系统资源使用情况增加报警设置。
- 注意备份,必要时候回滚。
- 灰度发布:将服务器集群分成若干部分,每天只发布一部分机器,观察运行稳定没有故障,第二天继续发布一部分机器,持续几天才把整个集群全部发布完毕,期间如果发现问题,只需要回滚已发布的一部分服务器即可
- 定期检查/更换硬件: 如果不是购买的云服务的话,定期还是需要对硬件进行一波检查的,对于一些需要更换或者升级的硬件,要及时更换或者升级。
- ……
冗余设计
冗余设计是保证系统和数据高可用的最常的手段。
- 对于服务来说,冗余的思想就是相同的服务部署多份,如果正在使用的服务突然挂掉的话,系统可以很快切换到备份服务上,大大减少系统的不可用时间,提高系统的可用性。
- 对于数据来说,冗余的思想就是相同的数据备份多份,这样就可以很简单地提高数据的安全性。
高可用集群(High Availability Cluster,简称 HA Cluster)、同城灾备、异地灾备、同城多活和异地多活是冗余思想在高可用系统设计中最典型的应用。
- 高可用集群:同一份服务部署两份或者多份,当正在使用的服务突然挂掉的话,可以切换到另外一台服务,从而保证服务的高可用。
- 同城灾备:一整个集群可以部署在同一个机房,而同城灾备中相同服务部署在同一个城市的不同机房中。并且,备用服务不处理请求。这样可以避免机房出现意外情况比如停电、火灾。
- 异地灾备:类似于同城灾备,不同的是,相同服务部署在异地(通常距离较远,甚至是在不同的城市或者国家)的不同机房中
- 同城多活:类似于同城灾备,但备用服务可以处理请求,这样可以充分利用系统资源,提高系统的并发。
- 异地多活:将服务部署在异地的不同机房中,并且,它们可以同时对外提供服务。
异地多活难在哪里?
只说「多个机房同时提供服务」是答不到点上的,异地多活真正的难点只有一个:数据。
核心约束是物理延迟:北京到上海网络往返约 30ms,一次请求若往返跨域调用 10 次就是 300ms。所以必须保证一个请求的完整链路尽量局限在单个机房内,这就是**单元化(Set 化)**的由来。
单元化的三个关键点:
- 选定路由维度:选一个能把流量切开的维度(互联网业务通常选
user_id,支付选会员号),同一用户的所有请求总是路由到同一个单元。 - 数据分类:
- 单元数据(如订单、账户):每个单元只负责自己那部分,本单元读写。
- 全局数据(如商品、配置、库存):存在一个中心单元写、其他单元只读,只读副本允许短暂延迟。
- “库存这类强一致资源不能多活写”是个很关键的结论,只能单点写或库存分桶到各单元。
- 双向同步与冲突:单元之间靠 DRC/Otter/Canal 类工具双向同步 binlog。必须解决:
- 循环复制:给同步数据打标,避免 A→B→A 无限循环。
- 主键冲突:各单元 ID 段错开(如雪花 workerId 分段、自增步长错开)。
- 写冲突:同一行在两个单元同时被修改。只能靠路由保证同一用户不跳单元来从根本上避免,切流时还需要一个禁写窗口期等待数据追平。
- 切流能力:多活的价值完全体现在切流上。必须有全局流量调度平台能按用户维度改路由规则,并且平时就要定期演练切流,否则真出事时没人敢按钮。
一句话总结:异地多活不是部署问题,是数据分片 + 流量路由 + 双向同步 + 切流演练四件事,成本极高,只有业务体量到一定规模才值得做。
隔离
隔离是高可用里很容易被遗漏但极为重要的一环,目的是把故障关在笼子里,不让它拖死全局。
为什么需要隔离:线程池耗尽雪崩
典型故障链:一个不重要的下游接口(如推荐服务)变慢到 10s → Tomcat 的 200 个工作线程全部卡在这个接口上 → 下单、支付等核心接口也拿不到线程 → 整个应用不可用。
这就是为什么光有超时和熔断不够(熔断开启前的那几十秒已经够把线程池吃完了)。
常见隔离粒度
- 线程池隔离:为每个依赖分配独立线程池(Hystrix 的做法)。隔离最彻底,即使某个依赖完全卡死也只吃掉自己那个池。代价:①线程切换开销;②ThreadLocal 上下文会丢(traceId、登录信息、事务上下文都需要手动传递)。
- 信号量隔离:不换线程,只限制同时进入的并发数(Sentinel/Resilience4j 默认)。开销极低、无上下文丢失,但无法隔离慢调用对调用方线程的占用(调用方线程仍然被阻塞着)。因此必须配合严格的超时使用。
- 线程池拆分:核心接口与非核心接口用不同的 Tomcat/Dubbo 线程池(Dubbo 可以按服务配置独立
executes/线程池)。 - 集群/分组隔离:把核心业务和非核心业务部署到不同集群,或把大客户单独切一个集群(Dubbo 的服务分组、阿里叫“分组隔离”)。隔离最彻底,成本也最高。
- 数据隔离:重要客户独立库、重要业务独立 Redis 实例,避免一个慢 SQL/大 key 拖垮全部。
- 读写隔离:报表/导出这类重查询走专用从库,不影响在线业务。
面试回答框架:完整的服务容错应该是四层递进——超时(不无限等)→ 隔离(坏了也不拖累别人)→ 熔断(坏了就快速失败不再试)→ 降级(快速失败后给个兜底结果)。四个一起说比单说熔断完整得多。
优雅上下线
发布是线上故障的最大来源,而很多发布期的报错就是上下线不优雅导致的。
优雅下线:避免请求被直接切断
关键在于先断流量,再停进程,而不能反过来:
- 主动从注册中心下线(Dubbo 的
unregister、Nacos 去注册),或先从负载均衡/网关摘掉。 - 等待一段时间让调用方刷新完服务列表。这一步必不可少,因为注册中心推送、客户端缓存都有延迟(通常秒级),不等就会出现大量
Connection refused。 - 拒绝新请求、处理完存量请求(Spring Boot 的
server.shutdown=graceful+spring.lifecycle.timeout-per-shutdown-phase)。 - 清理资源:MQ 消费者先停消费并提交位点、定时任务停调度、线程池
shutdown()等待任务跑完、Producerflush()。 - K8s 下具体对应:
preStop钩子里先 sleep 几秒 + 去注册,再给足够的terminationGracePeriodSeconds。
优雅上线:避免新节点被瞬时打死
新启动的节点存在一堆“冷”的东西:JIT 未编译、连接池未建立、本地缓存为空、类未加载。如果一上线就按等权重分流量,很可能瞬时超时。
- 健康检查就绪探针:用 K8s
readinessProbe/ Spring Boot/actuator/health/readiness确保真正准备好才接流量,而不是进程起来就接。 - 服务预热 / 慢启动:Dubbo 的
warmup参数会在预热期内按时间线性抬升权重,新节点从小流量逐步升到正常。Nginx/网关也可以做slow_start。 - 延迟暴露服务:Dubbo 的
delay参数,等 Spring 容器完全初始化完再注册,避免“Bean 还没好就有请求进来”。 - 启动时预加载:主动预热本地缓存、预创建数据库连接(设
minimum-idle)。
另一个容易忽略的点:发布本身就是一次可控的降容。滚动发布时集群容量会下降(比如 1/4 的机器在重启),所以不要在业务高峰发布,也不要在集群水位已经很高时发布。
可观测性
“出了故障 5 分钟内定位”本身就是高可用能力的一部分。可观测性的三大支柱:
- Metrics(指标):聚合数值,回答“有没有问题”。Prometheus + Grafana。
- 关键要盯 黄金四指标:延迟(Latency)、流量(Traffic)、错误(Errors)、饱和度(Saturation)。
- 一定要看分位而不是平均值:平均 RT 50ms 完全可能掩盖了 P99 = 3s。监控应该看 P95/P99/P999。
- Logging(日志):离散事件,回答“具体发生了什么”。ELK/EFK、SLS。必须在日志里带 traceId 才能串起来。
- Tracing(链路):调用链,回答“慢在哪一环”。
- 注意技术选型已变:现在的事实标准是 **OpenTelemetry(OTel)**,它已合并了 OpenTracing 和 OpenCensus,两个旧项目都已归档。存储/展示端用 SkyWalking、Jaeger、Zipkin 都可以。答题时提 OpenTelemetry 比只提 Zipkin 更新。
- 核心模型:
traceId串起全链,spanId标识单个调用,parentSpanId构成调用树。 - 采样率是必须权衡的点:全量采集存储成本极高,通常用「低比例头部采样 + 错误/慢请求全采」的尾部采样策略。
上面三个之外,生产上还需要:
- 告警分级与降噪:告警太多等于没有告警。要区分 P0/P1、做告警收敛与抑制。
- 故障演练 / Chaos Engineering:用 ChaosBlade、Chaos Mesh 主动注入 CPU 满载、网络延迟、依赖不可用,验证降级和熔断真的有效——很多降级代码写了三年从没跑过,真出事时才发现它自己就会报错。
- 应急开关预案:所有降级开关接配置中心(Nacos/Apollo/Diamond),保证能不发布就生效。故障时发布是最慢的手段。
常见限流算法✅
固定窗口计数器算法
原理是将时间划分为固定大小的窗口,在每个窗口内限制请求的数量或速率,即固定窗口计数器算法规定了系统单位时间处理的请求数量。
假如规定系统中某个接口 1 分钟只能被访问 33 次的话,使用固定窗口计数器算法的实现思路如下:
- 将时间划分固定大小窗口,这里是 1 分钟一个窗口。
- 给定一个变量 counter 来记录当前接口处理的请求数量,初始值为 0(代表接口当前 1 分钟内还未处理请求)。
- 1 分钟之内每处理一个请求之后就将 counter+1 ,当 counter=33 之后(也就是说在这 1 分钟内接口已经被访问 33 次的话),后续的请求就会被全部拒绝。
- 等到 1 分钟结束后,将 counter 重置 0,重新开始计数。
优点:实现简单,易于理解。
缺点:
- 限流不够平滑。例如限制某个接口每分钟只能访问 30 次,假设前 30 秒就有 30 个请求到达的话,那后续 30 秒将无法处理请求,这是不可取的,用户体验极差!
- 临界问题(边界突发):窗口切换瞬间可能放得更多,例如阈值 100/分钟:在窗口末尾打满 100,然后切到新窗口再打 100,总共在很短时间内放了 200
滑动窗口计数器算法
滑动窗口计数器算法限流的颗粒度更小,其把固定窗口算法中的固定窗口再次划分为若干片。
例如接口限流每分钟处理 60 个请求,可以把 1 分钟分为 60 个窗口。每隔 1 秒移动一次,每个窗口一秒只能处理不大于 60(请求数)/60(窗口数)的请求,如果当前窗口的请求计数总和超过了限制的数量的话就不再处理其他请求。很显然,当滑动窗口的格子划分的越多,滑动窗口的滚动就越平滑,限流的统计就会越精确。
优点:
- 相比于固定窗口算法,滑动窗口计数器算法可以应对突然激增的流量。
- 相比于固定窗口算法,滑动窗口计数器算法的颗粒度更小,可以提供更精确的限流控制。
缺点:
- 相比较于固定窗口计数器算法,滑动窗口计数器算法实现和理解起来更复杂一些。
- 通常需要更多内存/数据结构
- 精度与子窗口粒度相关(粒度越细,越精确但开销越大)
漏桶算法
可以把发请求的动作比作成注水到桶中,处理请求的过程可以比喻为漏桶漏水。往桶中以任意速率流入水,以一定速率流出水。当水超过桶流量则丢弃,因为桶容量是不变的,保证了整体的速率。如果想要实现这个算法的话也很简单,准备一个队列用来保存请求,然后定期从队列中拿请求来执行就好了(和消息队列削峰/限流的思想是一样的)。

优点:
- 实现简单,易于理解。
- 可以控制限流速率,输出速率恒定平滑,对下游最友好,避免网络拥塞和系统过载。
- 请求可以在桶里排队等待而不是立即报错,对用户体验更好。
缺点:
- 无法应对突然激增的流量,因为只能以固定的速率处理请求,对系统资源利用不够友好(突发时明明有余力却不敢多处理)。
- 桶流入水(发请求)的速率如果一直大于桶流出水(处理请求)的速率的话,那么桶会一直是满的,一部分新的请求会被丢弃,导致服务质量下降。
- 排队会引入额外的排队延迟,对延迟敏感的接口不友好。
纠正一个常见错误说法:很多资料写「实际业务场景中基本不使用漏桶算法」,这是错的。漏桶算法在生产中用得非常多:
- Nginx 的
limit_req模块就是漏桶实现(rate控制流出速率,burst控制桶容量,nodelay决定是排队还是立即通过)。- Sentinel 的「匀速排队」流控模式也是漏桶,专门用于把突发流量匀速化后投给不能承受突发的下游(如数据库批量写入、第三方接口有严格 QPS 限制)。
- 调第三方接口时必须用漏桶,因为对方的限额不允许你突发。
准确的说法应该是:入口层面对用户流量多用令牌桶(允许突发,体验好),出口层面对下游/第三方多用漏桶(平滑保护对方)。
令牌桶算法
和漏桶算法一样,不过现在桶里装的是令牌了,请求在被处理之前需要拿到一个令牌,请求处理完毕之后将这个令牌丢弃(删除)。根据限流大小,按照一定的速率往桶里添加令牌。如果桶装满了,就不能继续往里面继续添加令牌了。
优点:
- 可以限制平均速率和应对突然激增的流量。桶里积攒的令牌就是允许的突发额度,这是它与漏桶最本质的区别。
- 可以动态调整生成令牌的速率。
- 工程实现成熟:Guava 的
RateLimiter、Redisson 的RRateLimiter、Sentinel 默认快速失败模式均可直接用。
缺点:
- 如果令牌产生速率和桶的容量设置不合理,可能会出现问题比如大量的请求被丢弃、系统过载。
- 突发允许本身就是双刃剑:若下游承载不住瞬时突发,令牌桶反而会把下游打死。
- 相比于其他限流算法,实现和理解起来更复杂一些。
Guava RateLimiter 的两个模式(面试追问常考):
SmoothBursty:默认模式,允许突发,且支持**预消费(先欠后还)**——本次请求可以先拿走令牌,把等待代价留给下一个请求。SmoothWarmingUp:预热模式,启动后速率从低到高逐步爬升。适合服务刚启动时连接池未建立、JIT 未预热、缓存为空的场景,避免刚上线就被打死。Sentinel 的 WarmUp 流控模式同理。
四种限流算法对比与选型
| 算法 | 能否应对突发 | 输出是否平滑 | 实现复杂度 | 典型实现 |
|---|---|---|---|---|
| 固定窗口 | ✗(且有临界双倍问题) | ✗ | 最低 | Redis INCR + EXPIRE |
| 滑动窗口 | △(统计更准) | ✗ | 中 | Sentinel 滑动窗口、Redis ZSet |
| 漏桶 | ✗(只能排队) | ✓ | 中 | Nginx limit_req、Sentinel 匀速排队 |
| 令牌桶 | ✓ | △(突发时不平滑) | 中高 | Guava RateLimiter、Redisson RRateLimiter |
一句话选型:
- 只需粗略保护、QPS 不高 → 固定窗口就够。
- 面向用户、希望允许合理突发 → 令牌桶。
- 保护下游/第三方、对方有硬性 QPS 限制 → **漏桶(匀速排队)**。
- 需要精确统计、追求阈值准确 → 滑动窗口。
补充一个容易被忽略的算法:自适应限流。前面四种都需要人工定阈值,而阈值很难拍准。Sentinel 的系统自适应保护基于 BBR 思想:以
maxPass * minRt估算系统真实容量,当 Load 超阈且当前并发 > 估算容量时才限流,能在不配阈值的前提下把系统稳在高水位。能提到这一点会明显拉开区分度。
针对什么来进行限流?
实际项目中,还需要确定限流对象,也就是针对什么来进行限流。常见的限流对象如下:
- IP :针对 IP 进行限流,适用面较广,简单粗暴。
- 业务 ID:挑选唯一的业务 ID 以实现更针对性地限流。例如,基于用户 ID 进行限流。
- 个性化:根据用户的属性或行为,进行不同的限流策略。例如, VIP 用户不限流,而普通用户限流。根据系统的运行指标(如 QPS、并发调用数、系统负载等),动态调整限流策略。例如,当系统负载较高的时候,控制每秒通过的请求减少。
单机限流怎么做
单机限流只需在进程内维护计数器,没有网络开销,常见方式:
- **Guava
RateLimiter**:令牌桶,支持平滑预热,适合单个方法/资源的速率控制。 - Sentinel:支持 QPS/并发线程数两种统计维度、快速失败/WarmUp/匀速排队三种控制行为,并自带控制台。生产首选。
- 信号量
Semaphore:限制并发数而不是 QPS。注意区分:限 QPS 是限「每秒多少个请求进来」,限并发是限「同时有多少个在跑」。对下游是慢接口的场景,限并发比限 QPS 更有效。
分布式限流怎么做
分布式限流针对的分布式/微服务应用架构应用,在这种架构下,单机限流就不适用了,因为会存在多种服务,并且一种服务也可能会被部署多份。
分布式限流常见的方案:
- 借助中间件限流:可以借助 Sentinel 或者使用 Redis 来自己实现对应的限流逻辑。
- 网关层限流:比较常用的一种方案,直接在网关层把限流给安排上了。不过,通常网关层限流通常也需要借助到中间件/框架。就比如 Spring Cloud Gateway 的分布式限流实现RedisRateLimiter就是基于 Redis+Lua 来实现的,再比如 Spring Cloud Gateway 还可以整合 Sentinel 来做限流。
如果你要基于 Redis 来手动实现限流逻辑的话,建议配合 Lua 脚本来做。为什么建议 Redis+Lua 的方式?主要有两点原因:
- 减少了网络开销:可以利用 Lua 脚本来批量执行多条 Redis 命令,这些 Redis 命令会被提交到 Redis 服务器一次性执行完成,大幅减小了网络开销。
- 原子性:一段 Lua 脚本可以视作一条命令执行,一段 Lua 脚本执行过程中不会有其他脚本或 Redis 命令同时执行,保证了操作不会被其他指令插入或打扰。
降级
降级指的是在服务压力过大或部分功能出现故障时,主动减少或关闭某些非核心功能,从而确保核心功能的正常运行。通过降级,系统可以在不影响主要功能的情况下,减轻负载,避免因部分功能故障导致整个系统不可用。
应用场景:
- 系统负载过高:当系统承受的请求量过大,可能会导致性能下降。此时,可以通过关闭一些耗资源的非关键功能,确保核心服务的响应速度。
- 依赖服务异常:如果系统依赖的某个外部服务发生故障或延迟过大,可以选择临时关闭与该服务相关的功能,而不是完全停掉系统的服务。
- 业务需求波动:在某些特殊时期(如促销活动),为了应对突增的流量,可以提前降级部分非核心功能。
实现方式:
- 关闭某些功能:通过开关、配置中心等手段,临时禁用部分功能或模块。
- 提供默认值:在依赖服务不可用时,返回默认数据或缓存数据。
- 减少服务质量:降低服务的质量,例如降低图像分辨率、减少查询结果数量等。
优势:
- 保障系统核心功能的可用性。
- 减轻系统负载,避免雪崩效应。
熔断
熔断是一种故障隔离机制,当系统某个组件(如外部服务)出现问题时,熔断器会自动切断对该组件的请求,从而避免故障蔓延到整个系统。熔断器在一段时间后会尝试恢复连接,如果故障消失,系统会恢复正常调用。
熔断的状态:
- 关闭状态(Closed):正常情况下,所有请求都通过,熔断器处于关闭状态。
- 打开状态(Open):当外部服务持续出现故障,超过一定阈值时,熔断器切换到打开状态,直接拒绝请求并返回错误响应。
- 半开状态(Half-Open):熔断器在一段时间后尝试恢复连接,允许少量请求通过,测试外部服务是否恢复正常。如果请求成功,熔断器切回到关闭状态;如果失败,继续保持打开状态。
应用场景:
- 依赖服务不可用:当依赖的外部服务出现异常或性能下降时,频繁的调用失败会导致资源浪费和系统阻塞。此时,熔断器可以及时切断这些无效请求,保护系统的其他部分不受影响。
- 防止级联故障:在分布式系统中,如果一个服务的故障导致下游服务的负载激增,可能引发连锁反应。熔断可以防止这种级联故障的发生。
实现方式:
- 错误率监控:根据外部服务的错误率或响应时间来判断是否触发熔断。
- 超时设置:如果外部服务的响应时间超过设定的阈值,触发熔断。
- 自动恢复:熔断器在一段时间后自动尝试恢复连接。
优势:
- 防止故障扩散,保障系统的稳定性。
- 提高系统的容错能力和恢复能力。
降级与熔断的区别
- 目标不同:降级的目标是保障核心功能在高负载或异常情况下仍然可用;熔断的目标是防止系统因依赖的某个组件故障而出现更大范围的故障。
- 触发条件不同:降级既可以基于系统负载、请求量主动调整,也可以由人工推开关;熔断则是基于对外部服务的健康状态监控自动触发。
- 恢复方式不同:熔断具有半开状态探测的自动恢复机制;降级的恢复则看它是怎么触发的。
一个常见误区需要修正:很多资料说「降级必须人工恢复」,这不准确。降级分两类:
- 自动降级:由规则触发(如 Sentinel 的慢调用比例/异常比例降级、系统自适应保护),指标恢复后会自动关闭,不需要人工介入。
- **人工降级(预案开关)**:大促前主动关闭推荐、关闭非核心写入等,这类确实需要人工恢复。
两者真正的差异是作用对象:熔断看的是「依赖方健不健康」,降级看的是「自己还剩多少能力」。而且熔断后通常就要接降级逻辑(fallback),两者是搭配使用而不是二选一。
服务雪崩与背压
服务雪崩(Cascading Failure) 是分布式系统最典型的故障模式:下游 D 变慢 → C 的线程堆积 → C 不可用 → B 的线程堆积 → 一直传到入口,整条链路全部崩溃。
注意:雪崩的导火线往往不是“报错”而是“变慢”。报错会快速释放线程,反而不可怕;变慢才会持续占用资源直到耗尽。所以监控要盯的不只是错误率,更要盯 慢调用比例。
防雪崩的完整手段集:超时 → 隔离 → 熔断 → 降级 → 限流 → 重试退避。
重试风暴(Retry Storm) 是一个高频追问:下游已经过载了,上游的重试会把流量放大 N 倍,直接把它彻底打死。应对:
- 只在最内层重试,不要每层都重试。三层调用每层重试 3 次,就是 27 倍放大。
- **指数退避 + 随机扰动(Exponential Backoff + Jitter)**,避免所有客户端同时重试形成同步峰。
- 重试令牌桶/重试比例限制:如只允许 10% 的请求参与重试(gRPC 的 retry throttling)。
- 熔断优先于重试:熔断已开时直接快速失败,不再重试。
背压(Backpressure) 是更上游的思路:不是等资源耗尽后报错,而是当自身处理不过来时主动向上游发信号让它慢下来。典型实现:TCP 滑动窗口、Reactive Streams 的 request(n)、MQ 的拉模式(消费者能处理多少就拉多少,天然带背压,这也是 Kafka/RocketMQ 都选拉模式的原因之一)。
超时机制
超时机制用于防止请求长时间等待而不返回结果。它为每个请求设定一个最大等待时间,一旦超过这个时间,系统就会认为该请求失败,从而采取相应的措施(如重试、降级或直接返回错误)。
关键点:
- 超时的设定:超时时间应根据具体业务需求、网络延迟和系统性能来合理设定。超时过短可能导致误判,超时过长又可能影响用户体验和系统响应时间。
- 超时的作用:防止资源的长期占用,减少系统的线程或连接被长时间挂起,从而保持系统的响应能力。
- 分级超时:在复杂的分布式系统中,不同的服务或组件可以有不同的超时设置,以适应各自的性能特点和业务需求。
示例:
在微服务架构中,假设服务 A 需要调用服务 B,B 可能由于各种原因(如高负载、网络抖动)无法及时响应。A 可以设置一个超时时间(如 2 秒),如果 B 在 2 秒内没有响应,A 会认为调用失败并处理该情况。
重试机制
重试机制用于在请求失败时自动重试,以应对临时性故障。重试可以显著提高成功率,特别是在分布式系统中,网络故障、资源争用等问题可能只是暂时的。
关键点:
- 重试策略:重试机制需要设计合理的策略,包括:
- 重试次数:设定最大重试次数,防止无限重试导致系统过载。
- 重试间隔:设置重试之间的等待时间,可以是固定时间间隔,也可以是指数退避(每次重试间隔逐渐增加)。
- 重试条件:明确哪些错误或状态需要重试,如网络超时、连接中断等。
- 幂等性考虑:重试机制要求操作是幂等的,即同一操作多次执行不会产生副作用。如果操作不可避免地产生副作用(如扣款操作),需要设计幂等处理逻辑。如购买商品时判断是否已经购买过了。
示例:
在支付系统中,用户支付请求可能由于网络抖动而失败。系统可以在支付失败后自动重试 3 次,每次间隔 1 秒。如果第 3 次重试后仍然失败,则返回错误给用户。
超时与重试的协作
超时和重试通常结合使用,以实现更高的可用性:
- 超时后重试:请求在超时后进行重试,直到达到最大重试次数。
- 分布式场景中的挑战:在分布式系统中,超时和重试可能会放大问题,例如请求风暴或级联故障。因此,在设计时要特别注意这些可能的副作用。
总结:
- 超时机制防止请求长时间挂起,提升系统的资源利用效率。
- 重试机制则通过自动化的重试操作,提高请求的成功率和系统的容错能力。
- 两者结合使用时,需要精心设计超时和重试策略,以确保系统的高可用性和稳定性。
性能测试
性能测试
性能测试方法是通过测试工具模拟用户请求系统,目的主要是为了测试系统的性能是否满足要求。通俗地说,这种方法就是要在特定的运行条件下验证系统的能力状态。性能测试是你在对系统性能已经有了解的前提之后进行的,并且有明确的性能指标。
负载测试
对被测试的系统继续加大请求压力,直到服务器的某个资源已经达到饱和了,比如系统的缓存已经不够用了或者系统的响应时间已经不满足要求了。负载测试说白点就是测试系统的上限。
压力测试
不去管系统资源的使用情况,对系统继续加大请求压力,直到服务器崩溃无法再继续提供服务。
稳定性测试
模拟真实场景,给系统一定压力,看看业务是否能稳定运行。
常见性能优化策略
性能优化之前需要对请求经历的各个环节进行分析,排查出可能出现性能瓶颈的地方,定位问题。
- 系统是否需要缓存?
- 系统架构本身是不是就有问题?
- 系统是否存在死锁的地方?
- 系统是否存在内存泄漏?(Java 的自动回收内存虽然很方便,但是,有时候代码写的不好真的会造成内存泄漏)
- 数据库索引使用是否合理?
- …
相关指标
- QPS(Query Per Second):服务器每秒可以执行的查询次数;
- TPS(Transaction Per Second):服务器每秒处理的事务数(这里的一个事务可以理解为客户发出请求到收到服务器的过程);
- RT:响应时间RT(Response-time)就是用户发出请求到用户收到系统处理结果所需要的时间。
- 并发数:可以简单理解为系统能够同时供多少人访问使用也就是说系统同时能处理的请求数量。
- 吞吐量:吞吐量指的是系统单位时间内系统处理的请求数量。
QPS(TPS) = 并发数/平均响应时间(RT)
并发数 = QPS * 平均响应时间(RT)
QPS vs TPS:QPS 基本类似于 TPS,但是不同的是,对于一个页面的一次访问,形成一个 TPS;但一次页面请求,可能产生多次对服务器的请求,服务器对这些请求,就可计入“QPS”之中。如,访问一个页面会请求服务器 2 次,一次访问,产生一个“T”,产生 2 个“Q”。
上面的公式就是 **利特尔法则(Little’s Law)**,面试时能叫出名字会加分。实际容量估算例子:单机并发能力 200 个线程、平均 RT 50ms,则单机理论 QPS = 200 / 0.05 = 4000。要支撑 4 万 QPS 就需要 10 台机器再乘以冗余系数。
必须补充:为什么不能只看平均 RT
这是原文最大的一个缺口。平均值会系统性地骗人:
举例:100 个请求,99 个是 10ms,1 个是 5000ms。平均 RT = (99*10 + 5000)/100 ≈ 60ms,看起来很健康。但那个等了 5 秒的用户已经跑了。
所以工程上必须看**分位值(Percentile)**:
- P99 = 20ms 意为「99% 的请求都快于 20ms」。
- 常用 P95 / P99 / P999。内部服务盯 P99,核心交易链路盯 P999。
- SLA/SLO 定义也应该用分位而不是平均,例如「P99 < 200ms 且可用率 > 99.95%」。
另一个关键概念:尾延迟放大(Tail Latency Amplification)。一个请求如果并行扇出调用 100 个下游,只要每个下游的 P99 是 1s,那么这个请求几乎必定会遇到至少一个慢调用(
1 - 0.99^100 ≈ 63%)。这是为什么大扇出架构必须做 超时 + 对冲请求(Hedged Request) + 允许部分结果降级。
补充:全链路压测
单接口压测只能测出单点能力,真实大促容量必须靠全链路压测(阿里的双十一备战标配):
- 在生产环境压:预发环境的机器规格、数据量、缓存命中率与生产完全不同,压出来的数字没参考价值。
- 流量染色:给压测流量打标记并全链路透传,下游根据标记把数据写入影子表/影子库,避免污染生产数据。
- 外部依赖 Mock:短信、支付、第三方接口必须拦截,不能真发真扣钱。
- 找到水位而不只是找到极限:最终要得出「单机安全水位」(通常取极限的 60~70%),作为扩容依据和限流阈值的根据。
分布式任务调度
定时任务在单机时一个 @Scheduled 就完事,但一旦部署多份就会每台机器都跑一遍,造成重复扣款、重复发消息。
演进路径:
- 分布式锁抢占:多台机器同时到点,抢到锁的执行。简单,但无法分片、失败无重试、无执行记录。
- Quartz 集群模式:靠数据库行锁抢任务。问题是数据库竞争激烈、不支持分片、无可视化控制台。
- 专业调度平台:
- XXL-JOB:中心式调度(调度中心 + 执行器),自带控制台、失败重试、超时告警、任务依赖、分片广播。国内中小团队最常用。
- ElasticJob:去中心化(靠 ZK 选主与分片),分片能力最强,适合大数据量批处理。
- **SchedulerX(阿里云):商业化方案,支持网格任务(MapReduce 模型)**,能把一个大任务拆成海量子任务分发到多机并行跑,并自动汇总结果。
分片是分布式调度的核心价值:不是「只让一台机器跑」,而是「把数据切成 N 份让 N 台机器并行跑」。例如 1000 万条待处理记录,按 id % 10 分为 10 片,10 台机器各拉一片,耗时直接降为 1/10。
定时任务的工程要点(面试常追问):
- 幂等:调度平台的失败重试、手工重跑都会导致重复执行,任务逻辑必须幂等。
- 阿里一个典型坑:任务集中在整点。大量任务都配
0 0 0 * * ?,零点瞬时数据库被打满。应该将启动时间错开或加随机延迟。 - 熔断与进度:长任务必须有超时熔断(否则上一轮没跑完下一轮已启动,造成任务堆叠)和进度上报。
- 不要在任务里一次性
SELECT *把全表拉进内存:必须分页/游标迭代,否则数据量一大直接 OOM。
处理上限为100的接口,突然10000个请求过来了,怎么办?
当接口突然接收到超出其处理能力的大量请求时,需要采取一些策略来防止系统过载,并确保服务的可用性。
- 限流:通过限制每个时间单位内允许处理的请求数量来防止过载。常见的限流算法包括漏桶算法和令牌桶算法。
- 负载均衡:使用负载均衡器将请求分配到多个服务器上,以均衡负载。可以使用软件解决方案(如 Nginx 或 Apache)。
- 缓存:缓存可以减轻数据库和后端服务的压力。对于一些可以缓存的请求结果,可以使用 Redis 进行缓存。
- 降级:在高负载时,可以对某些非关键功能进行降级,例如延迟处理或返回默认值。
- 消息队列:将请求放入消息队列中进行异步处理,如使用 Kafka、RabbitMQ 或 ActiveMQ。
- 扩展资源:根据实际情况扩展服务器资源,包括水平扩展(增加服务器数量)和垂直扩展(增加单个服务器的处理能力)。
公司逐渐发展数据量与业务量暴增,如何设计公司的系统架构?
当公司从小规模发展壮大,数据量和业务量激增时,系统架构的设计必须能够应对当前的增长,并且为未来的扩展留有余地。以下是在这种情况下可以采取的一些架构设计策略:
- 分层架构(Layered Architecture)
- 表现层(Presentation Layer):用户界面层,处理所有用户请求和响应。它可以独立于后端逻辑和数据层,通过 REST API 或 GraphQL 与后端通信。
- 业务逻辑层(Business Logic Layer):处理公司核心业务逻辑,保证系统的可维护性和可扩展性。可以通过微服务架构拆分为独立的服务。
- 数据访问层(Data Access Layer):负责与数据库的交互,采用合适的数据存储方式来管理不同的数据类型,例如使用关系数据库(如 MySQL)和 NoSQL 数据库(如 MongoDB)。
- 微服务架构(Microservices Architecture):随着业务增长,将单一的应用拆分为多个微服务,每个微服务专注于特定业务领域。微服务的优势包括:
- 独立部署和扩展:每个微服务可以独立部署,按需水平扩展。
- 故障隔离:如果某个服务出现故障,其他服务不受影响。
- 技术异构:可以为不同的服务选择最合适的技术栈。
- 水平扩展和负载均衡(Horizontal Scaling & Load Balancing)
- 水平扩展(Horizontal Scaling):增加更多服务器实例来分担负载,可以利用云服务平台(如 AWS、Azure、阿里云)来实现弹性扩展。
- 负载均衡(Load Balancing):使用负载均衡器(如 Nginx、HAProxy)将流量分配给不同服务器实例,以避免单点故障并提高吞吐量。
- 缓存层(Caching Layer):在高并发的情况下,使用缓存机制可以显著提高系统性能。
- 缓存数据:使用 Redis 或 Memcached 缓存频繁访问的数据,减少数据库的查询压力。
- 页面缓存:可以在 CDN(如 Cloudflare)层面缓存静态内容,减少请求的响应时间。
- 数据库设计与优化:随着数据量的增加,数据库的设计和优化至关重要。
- 读写分离:可以通过主从数据库架构,将读操作分发到从库,写操作集中在主库。
- 分库分表:当单个数据库难以承载大量数据时,可以通过数据分片(Sharding)来分散数据压力。
- NoSQL 数据库:对于非结构化数据或需要快速存取的场景,可以引入 NoSQL 数据库,如 Cassandra、MongoDB。
- 服务发现与通信:在微服务架构中,服务之间的通信和发现是关键问题。
- 服务发现:使用服务注册中心(如 Consul、Eureka)来管理服务的注册与发现。
- API 网关:引入 API 网关(如 Kong、Zuul)统一管理外部请求,并提供安全、路由、负载均衡等功能。
- 异步通信:对于一些延时不敏感的任务,可以使用消息队列(如 RabbitMQ、Kafka)进行异步通信,减轻系统的实时压力。
- 自动化运维与监控
- 容器化与编排:使用 Docker 容器化应用,利用 Kubernetes 管理集群,自动化扩展和负载分配。
- 监控与告警:使用 Prometheus、Grafana 等工具实时监控系统性能,设置告警机制,及时发现和处理问题。
- 日志管理:引入 ELK(Elasticsearch, Logstash, Kibana)等日志管理方案,集中处理日志数据并进行分析。
- 安全性设计
- 认证与授权:使用 OAuth、JWT 等标准认证机制来保障用户的身份验证和权限管理。
- 数据加密:对敏感数据进行传输和存储加密。
- 安全审计:实时记录系统访问和操作,进行安全审计,防止恶意攻击。
- 灾难恢复与备份:随着业务的增长,容灾和数据备份显得更加重要。
- 数据备份:定期进行数据库和文件系统的备份,确保在灾难发生时数据可恢复。
- 异地容灾:在多个地域部署冗余系统,确保一地出现故障时,业务可以无缝切换到其他地区。
通过以上方法,可以构建一个可扩展、高可用、易维护的系统架构,支持公司在数据量和业务量快速增长的情况下平稳运行并持续扩展。
如何在不停机的情况下完成系统更新
在不停机的情况下更新系统,通常称为无缝升级或热升级,主要目标是确保服务在更新期间不中断。以下是几种常用方法:
- 滚动更新(Rolling Update):滚动更新是一种逐步更新系统的方式,其中服务的实例逐一被替换。更新过程中,旧版本仍然在运行,当某一实例被更新到新版本后,继续处理请求。常见步骤如下:
- 先将一部分实例下线,不接收新的请求。
- 更新这些实例并重新上线。
- 逐步对系统中所有实例进行更新,直到所有实例都使用新版本。
- 常用于微服务架构和容器化部署(如 Kubernetes)。
- 蓝绿部署(Blue-Green Deployment):蓝绿部署通过并行运行两个环境来实现无缝切换:
- “蓝色”环境是当前正在运行的版本,处理所有的生产流量。
- “绿色”环境则是新版本,一旦部署完成并确认其正常运行,生产流量将从“蓝色”切换到“绿色”环境。
- 这样可以保证在整个更新过程中,至少有一个环境在提供服务,并且如果绿色环境出现问题,能快速切换回蓝色环境。
- 适用于云原生应用或虚拟机等灵活资源管理系统。
- 金丝雀发布(Canary Release):金丝雀发布是一种逐步扩展的更新方式:
- 将新版本首先部署到一小部分服务器或特定用户群体中。
- 监控该群体的反馈和系统表现,确保新版本稳定。
- 确认没问题后,逐渐扩大新版本的覆盖范围,直至全面替换旧版本。
- 可以与A/B测试结合,确保最优的用户体验。
实施中的注意事项:
- 数据库迁移:如果系统更新中涉及数据库结构的变化,必须确保兼容性,通常会通过版本化的迁移脚本或双写机制实现。
- 监控和回滚:在发布新版本时,应有完善的监控系统来跟踪错误和性能问题,必要时快速回滚到旧版本。
- 自动化工具:使用CI/CD工具(如Jenkins、GitLab CI)自动化部署过程,提高更新效率并减少人工干预。









