后端面试问到深处,绕来绕去都是那几个词:高并发、高可用、高性能。八股文铺天盖地,知识点一个比一个全,可真到了设计一个系统,或者回答"你用 X 解决过什么问题"这种开放题时,能把 MySQL、缓存、消息队列、微服务、ES 与 MongoDB 这五块串成一条线的人不多。
这篇是我梳理后端核心知识时画的一张总图:每个主题都配了一张思维导图当导航,知识点尽量覆盖全,关键的"为什么"和容易踩的坑补几句。每个主题结尾留一句记忆抓手,面试时能快速想起来。
这五块,各自在解决什么
先把位置摆清楚,不然容易记成五坨散点:
- MySQL:数据存在哪。从单机扛不住,一路演进到分库分表。
- ES 与 MongoDB:MySQL 扛不住的弹性扩展和全文检索。
- 缓存:别什么都去查库。用空间换时间,挡在数据库前面。
- 消息队列:把同步调不通的事变成异步,生产者和消费者解耦,顺带削峰。
- 微服务:服务拆开之后怎么保证整体不塌,注册发现、负载均衡、超时、熔断、限流、降级、隔离。
一句话串起来:数据存在 MySQL、ES、MongoDB 里,缓存负责读得快,消息队列负责写得顺,微服务治理负责系统不倒。
一、数据库与 MySQL:存储底座的演进
这条线最经典,面试官最爱顺着挖。整体是"单机优化 → 分布式演进"两段,分库分表是贯穿主线。
索引
索引是用额外数据结构加速查询,本质是空间和写入维护成本换查询性能。
先说 B+ 树为什么适合做 MySQL 索引:非叶子节点只存 key,叶子节点存数据或主键,叶子之间用链表串起来。好处是树矮(三层就能撑千万级数据)、范围查询友好(顺着链表扫)、磁盘 IO 可控。那为什么不用 B 树、二叉树、红黑树、跳表?B 树非叶子也存数据导致树更高;二叉树和红黑树太高,磁盘 IO 次数多;跳表范围查询不如 B+ 树顺手,内存友好性也差一点。
索引按不同角度分类:聚簇索引(InnoDB 主键索引,叶子存整行)和非聚簇/二级索引(叶子存主键值,查到主键再回主键索引找整行,这叫回表);覆盖索引(查询字段都在索引里,不用回表,能省大量随机 IO);还有唯一索引、前缀索引、组合索引、全文索引、哈希索引。
组合索引的最左匹配原则有个口诀:AND 用 OR 不用,正用反不用,范围则中断。意思是组合索引按字段顺序匹配,遇到范围查询(>、BETWEEN)后面的字段就用不上索引了。
索引不是越多越好,有代价:占磁盘空间、占 buffer pool 内存,写入更新删除都要同步维护索引。所以索引失效场景要会举例:左模糊 LIKE '%x'、对字段套函数、隐式类型转换、OR 条件、低区分度列(比如性别)、数据量太小的表。
索引和 NULL 也有讲究,唯一索引允许有多个 NULL,不同数据库处理可能不一样,记住"尽可能使用索引"这个大方向就行。
SQL 优化
SQL 优化的答题顺序是固定的:先用 EXPLAIN 定位,再改 SQL、改索引、改分页或改表结构。
EXPLAIN 重点看几个字段:type(从好到差大概是 const > eq_ref > ref > range > index > ALL,一旦出现 ALL 全表扫描就要警惕)、possible_keys(可能用的索引)、key(实际用的索引)、rows(预估扫描行数)、filtered(过滤比例)。
选索引列有几个原则:外键、WHERE 里的列、ORDER BY 的列(减少排序开销)、关联条件列、区分度高的列优先。
大表改表结构是个坑,直接 DDL 可能锁表阻塞所有操作。三种办法:停机变更、业务低谷期变更、创建新表加数据迁移加双写(最稳但最复杂)。
具体优化手段:用覆盖索引减少回表;优化 COUNT(*)(大表精确 COUNT 很贵,能接受预估就用预估,必须精确就维护计数表,进阶用 Canal 监听 binlog 维护);别用索引提示强制走索引(那是非良好实践);用 WHERE 替换 HAVING(注意 SQL 执行顺序,WHERE 在分组前过滤更早);优化深分页偏移量(别只写 LIMIT 100000, 20,用游标分页、延迟关联、记住上次最大 ID)。
事务
事务用 ACID 保证一组操作正确,MySQL 靠 undo log、redo log、binlog 加锁和 MVCC 协同完成。
undo log 既管回滚,又给 MVCC 提供历史版本,存的是版本链。它记什么要分清:INSERT 记主键(回滚时按主键删)、DELETE 记主键(回滚时把删除标记置回 false)、UPDATE 没动主键就记被更新字段的原始内容和主键,动了主键相当于先删后插。其实只要记住 INSERT 和 DELETE,UPDATE 就能推出来。
redo log 保证崩溃恢复,是 WAL(Write-Ahead Logging)思路,特点是顺序写,比随机写快得多。顺序写这个特性很多中间件都在用。它的刷盘由 innodb_flush_log_at_trx_commit 控制。
以 UPDATE 为例,事务执行过程是:先把数据读到 buffer pool 并加锁,写 undo log 准备好回滚内容,修改 buffer pool 里的数据,写 redo log,提交事务(按参数决定要不要刷 redo log 到磁盘),最后才把 buffer pool 的脏页刷到磁盘。注意:事务提交了不代表 buffer pool 的数据马上落盘。
binlog 在 Server 层,作用是数据恢复和主从同步。刷盘看 sync_binlog(0 让操作系统决定,N 就是每 N 个事务提交刷一次)。redo log 和 binlog 的一致性靠两阶段提交保证:事务触发提交 → redo log 进入 Prepare → 写 binlog → redo log Commit。这一步是为了避免"主库提交了但 binlog 没写,导致从库丢数据"。
写入语义是个通用话题:单机层面有写到中间件 buffer、写到 page cache、写到磁盘三档;主从结构有只写主、写一个从、写固定数量从、写多数从、写全部从五档,Kafka 的 acks、Redis 的 AOF 都是这套。刷盘时机怎么取舍?重数据就 sync_binlog=1 和 innodb_flush_log_at_trx_commit=1,重性能就调大或改成 0/2,说白了就是在"要数据安全"和"要性能"之间选边站。
MVCC
MVCC 通过版本链和 Read View 让读操作读到合适的历史版本,减少读写阻塞。为什么需要它?因为锁的并发性能差,解决不了读写互相阻塞的问题。
四个隔离级别:未提交读、已提交读(RC,很多互联网应用用这个)、可重复读(RR,MySQL 默认)、串行化。对应三种读异常:脏读、不可重复读、幻读。MySQL 的 RR 级别解决了幻读,但要分清:快照读靠 MVCC,当前读靠 next-key lock。
版本链靠两个字段:trx_id(事务 ID,大的 trx_id 一定后开始)和 roll_ptr(回滚指针,像链表指针一样指向 undo log 里的旧版本)。
Read View 判断某个版本对当前事务是否可见,核心字段是活跃事务 ID 集合 m_ids、当前事务 ID m_creator_trx_id、下限 m_up_limit_id、上限 m_low_limit_id。RC 和 RR 的区别就在生成 Read View 的时机:RC 每次查询都生成新的,所以同一事务里两次查询可能看到不同结果;RR 在事务开始时生成,之后复用,所以可重复读。
为什么要调整隔离级别?因为很多互联网业务用不上 RR,RR 还容易引起死锁,RC 性能更好。如果业务确实需要可重复读,可以改造业务(基本没有业务是非可重复读不可的),或者在 Session 或单个事务级别指定隔离级别。
锁
锁用来控制并发写和当前读,保证一致性,代价是牺牲并发性能。
锁和索引强相关:查询命中索引才可能是行锁,命中单条记录、记录不存在、范围查询行为各不相同;查询没用索引就退化成表锁,要尽力避免。
两阶段加锁有个坑点:很多人以为 SQL 执行完就释放锁了,其实锁是事务执行过程中加、提交或回滚时才释放。
乐观锁和悲观锁:乐观锁靠 CAS,适合读多写少,实践中优先用乐观锁;悲观锁直接加锁,适合写多读少。锁的粒度上,InnoDB 支持行锁(前提是命中索引),表锁是性能杀手。还有共享锁和排它锁、表结构变更时用到的意向锁。
RR 下重点搞清记录锁、间隙锁、临键锁:记录锁锁已有记录;间隙锁锁记录之间的空隙;临键锁是记录锁加间隙锁,用来防幻读。间隙锁和临键锁只在 RR 下有效。口诀:遇事不决临键锁,右边缺省间隙锁,主键和唯一索引的等值查询才是记录锁。一个经典死锁场景:SELECT FOR UPDATE 查一条不存在的记录会触发临键锁,紧接着 INSERT 就容易死锁。解决办法是直接 INSERT(降级成行锁)、把隔离级别降到 RC、或者用乐观锁。另外 SELECT FOR UPDATE 加 UPDATE 这种悲观锁用法,能用乐观锁(配合循环重试)替代就替代。
分布式事务
分布式事务解决跨资源、跨库、跨服务的一致性问题,但强一致成本很高。
两阶段提交(2PC):准备阶段锁定资源(这一步会产生 undo 和 redo,其实事务大部分活在这步干完),提交阶段统一提交。缺点是阻塞、协调者单点故障、策略保守。
三阶段提交(3PC):多了个 CanCommit 阶段先检查资源够不够,然后 PreCommit,最后 Commit。相比 2PC 降低了阻塞风险,但工程实践远不如 TCC、SAGA 常见。
XA 是数据库层支持的分布式事务,侵入业务少,但性能和可用性成本高,微服务里通常谨慎用。
TCC 分 Try(预留资源,对应 2PC 第一阶段)、Confirm(确认,对应提交)、Cancel(取消,对应回滚)。难点是空回滚、悬挂、幂等和业务侵入。
SAGA 把长事务拆成本地事务加补偿动作,适合长流程,只能做到最终一致。AT 可以看作 SAGA 的一种特殊形态。
实在不行就禁用跨库事务,或者用延迟事务(分库分表中间件基本都支持,针对跨库事务,不是强一致,是最终一致)。
重试失败兜底有几种:监控加告警加人工介入、读请求加修复程序、监控告警加自动故障处理(后者还能当改善系统可用性的例子讲)。面试取舍一句话:能不用分布式事务就不用,优先靠业务拆分、消息最终一致、对账补偿解决。
数据库综合应用
综合题别上来就分库分表,先确认瓶颈是查询、写入、容量、锁冲突还是可用性。优化路径是:SQL 和索引优化 → 参数和连接池优化 → 读写分离 → 缓存 → 分库分表 → 引入专用中间件。
参数优化主要调 innodb_flush_log_at_trx_commit、sync_binlog、隔离级别、innodb_buffer_pool_size、innodb_buffer_pool_instances,查询缓存相关的参数(注意 MySQL 8.0 已经移除查询缓存,因为收益太小)。读写分离要补充主从延迟,强一致读走主库,弱一致读走从库。分库分表不是银弹,会引入分页、事务、全局唯一 ID、跨库查询、数据迁移和一致性问题。
分库分表容量预估
先看分区表:常见按日期分区,数据分块存储,优点是提高查询性能、减少并发竞争、利于增量更新和大数据存储;缺点是分区表本身有开销、跨分区操作性能差、不能用外键。关键是分区表解决不了写瓶颈和硬件资源瓶颈。
2 的幂为什么受欢迎?求余数简单,扩容时迁移的数据比例相对可控。
为什么要分库分表?性能瓶颈逼的。和分区表比,分区表解决不了写瓶颈和硬件瓶颈;和读写分离比,读写分离解决不了写瓶颈(主库有问题它就解决不了),也解决不了大表问题。
分库还是分表?硬件资源瓶颈、部分写瓶颈就分数据源或分库;表维度的性能瓶颈就分表。分多少?根据已有数据(去掉没必要迁移的)、数据增长趋势、公司规划倒推,别只说"先分 16 张"。怎么扩容?扩容后重新评估容量,然后走数据迁移那套流程。最后还有个亮点:流量复制重放,记录原始请求和响应,异步重放后比对两次响应判断数据是否一致,注意并发和 HTTPS 脱敏问题。
分库分表主键生成
分库分表主键要同时满足全局唯一、趋势递增、高性能生成,最好还能反推出路由信息。
UUID 太长、无序,做聚簇索引容易页分裂(这正好能引导到页分裂和操作系统、文件系统的话题),还有顺序读命中率低的问题。数据库自增可以用步长分给不同库,但性能受限于数据库本身。
雪花算法是主流:保留位 1 比特、时间戳 41 比特、机器 ID 10 比特、序列号 12 比特。可以调整各段长度。难点是序列号耗尽(调长序列号比特或阻塞限流)、数据堆积(随机起点或复用上一时刻序列号)、时钟回拨、机器 ID 分配。
亮点方案是把分库分表键嵌进 ID,序列号用随机数,优点是能直接用 ID 发起查询(少一次路由),缺点是非严格自增、随机数可能冲突(冲突就重新生成)。发号器优化有提前取、批量取(比单个查询高效,省系统调用、网络、磁盘 IO)、singleflight 取、局部分发(借鉴 TLB 思路)。
分库分表分页查询
分库分表分页难在数据分散,单库的 offset 语义不等于全局 offset。中间件形态有三种:SDK(语言强相关、性能好)、proxy(性能差、有单点和集群管理问题,分页场景压力尤其大)、sidecar(没成熟产品)。
几种方案:全局查询把 LIMIT x+y OFFSET 0 下发到所有分片再归并排序,性能差(网络、内存、CPU 都重);平均分页(结果不准确);禁用跨页(天然适合 App 的"下一页"模式);换中间件(Elasticsearch 适合检索、ClickHouse 适合分析、TiDB 直接解决了分页问题);二次查询(先平均分 offset,找到边界最小值,再用 BETWEEN 二次查询修正);引入中间表。
老实说,深分页本身就是反模式,优先改产品交互和查询模型,别硬拼 SQL。
无分库分表键查询
没有分片键的查询,本质是当前数据分布不支持这个查询维度,需要冗余、索引表或换查询系统。分片键选择的唯一标准是根据查询来,主键、外键、索引列、日期列,其实没太多选择。
方案对比:主键生成策略(主键内嵌分片键)能支持用 ID 查询;中间表查询快但写入和一致性复杂、怕写、难适应多变场景;二次分库分表(数据复制一份再分一次,相当于中间表加强版,全复制浪费空间、部分复制可能要回原表);其他中间件(基本也要复制一份数据过去);广播(兜底,性能差)。
数据同步是绕不开的配套:双写、监听 binlog,容错靠重试、监控告警、人工介入、自动故障处理。分布式环境不存在完美方案,基本都是重试加人工。一个重要原则:多份数据都允许写会陷入无穷无尽的一致性问题,尽量设计成单写多读。
记忆抓手:先榨干单机(索引/事务/MVCC/锁),再走分布式(读写分离/分库分表/分布式事务/数据迁移)。
二、缓存:性能与并发的第一道防线
缓存这块,面试官爱从"你用缓存解决过什么问题"切入,然后一路追到一致性和分布式锁。
缓存模式
缓存模式解决的是"读写缓存和数据库时,职责放业务代码还是缓存层"。
Cache Aside 最常见:写操作是业务代码写数据库再写缓存,读操作是读缓存、未命中读数据库。一致性问题在于先写库还是先写缓存都有坑。Read Through 写操作同 Cache Aside,读操作交给缓存层去加载数据库(可以直接返回错误或默认值,可以加载后返回,也可以加载并回写后返回)。Write Through 是写到缓存、缓存去更新数据库。Write Back 读写都走缓存,缓存过期时才回写数据库,性能好但有丢数据风险,适合不介意少量丢失且缓存高可用的场景。Refresh Ahead 利用 CDC 监听数据变更后刷新缓存。
还有几个实用招式:Singleflight(多个线程缓存未命中时,同一个 key 只放一个去加载,其他等着,适合高并发);删除缓存(更新数据库后直接删缓存,可立刻删也可异步删,代价是降低命中率);延迟双删(更新数据库后删一次缓存,过段时间再删一次,是面试高频,能降低并发读写导致的脏缓存)。选用什么模式要因地制宜,面试一般答延迟双删。实现上可以用装饰器模式做到无侵入,适配不同缓存实现。
缓存一致性
一致性问题的根因就两个:操作部分失败、并发操作。
double-check 是优化并发性能的常用套路,步骤是检查、加锁、再次检查,名字就是这么来的。一个例子:先加读锁检查条件,符合就执行,不符合就释放读锁换写锁再检查。变种有初次检查不加锁(分布式锁里常见)、初次检查用原子操作(高性能中间件常见)。
解决方案有:用消息队列保证业务内有序、用版本号(可以用更新时间代替)、多级缓存(先更新数据库再更新本地缓存最后更新 Redis)。亮点方案是一致性哈希负载均衡加缓存:节点内用 singleflight,节点上线时短时间内只读不写缓存(等老节点处理完已接收请求即可)。还有分布式锁方案,思路一是执行本地事务→加锁→删缓存→释放锁(但事务和加锁之间锁可能被别人拿走,数据可能不一致);思路二是开启事务→执行→获取锁→删缓存→提交事务→释放锁(删缓存失败事务回滚,删成功但提交失败不影响),缺点是事务可能超时(拿不到锁或网络不通删除超时),还有个变种是先获取锁再开事务。
原则就一句:能接受最终一致就做补偿,必须强一致就少用缓存或缩小缓存范围。
缓存过期
过期是在命中率、内存占用、数据新鲜度之间做权衡。监控缓存命中率是重点。
实现过期机制一般有四种思路:定期删除(时间精确但定时器开销大、改时间要重置)、延迟队列(时间精确但队列开销大)、懒惰删除(实现简单、改时间方便,但时间不精确、浪费内存)、定期删除(实现方便但时间不精确、性能损耗不可控)。
Redis 用的是懒惰删除加定期删除。为什么不立刻删?因为另外两种都有上面说的缺点。怎么控制定期删除开销?控制执行时间。怎么控制频率?靠 hz 和 dynamic-hz。从库怎么处理过期 key?3.2 之前能读到,3.2 之后读不到,等主库的删除命令传过来。持久化怎么处理?RDB 主库不读不写、从库原封不动;AOF 正常记 DEL 命令、重写时忽略。
怎么确定过期时间?根据用户体验来。案例:高并发幂等方案里 Redis 的过期时间、热点数据的动态过期时间、预加载配超短过期时间。一句话,过期时间不是技术常量,是业务新鲜度和系统容量的折中。
缓存淘汰策略
淘汰策略解决的是"内存不够时删谁"。算法上,LRU 看最近是否使用(适合时间局部性强的场景,不适合查历史记录和遍历,借 LinkedHashMap 之类很容易实现);LFU 看访问频率(可以是全部访问次数,也可以是某段时间内的)。
Redis 的淘汰策略有一串:volatile-lru、volatile-lfu、volatile-random、volatile-ttl、allkeys-lru、allkeys-lfu、allkeys-random、noeviction。规律是带 volatile 的从过期 key 里挑,带 allkeys 的从全部 key 里挑,noeviction 是内存不足时拒绝写入、不主动淘汰。
业务级控制缓存使用量基本都用 Lua 脚本,思路是控制键值对数量或大小:额外记录数量,加入新键时检测是否超限,监听删除事件更新数量。亮点是按优先级淘汰(大对象优先腾空间、小对象优先算得快、代价低优先、低热度优先),Redis 落地还是靠 Lua 加有序集合,按优先级排序淘汰。
缓存三大问题
穿透、击穿、雪崩是绕不开的三连问。穿透是查一个根本不存在的 key,每次都打到数据库,解法是布隆过滤器或缓存空值。击穿是某个热点 key 突然过期,瞬间大量请求打到数据库,解法是互斥锁或逻辑永不过期。雪崩是大量 key 同一时刻集中过期,解法是给过期时间加随机值,避免同时失效。
Redis 单线程
"Redis 是单线程的"这个老题,指的是命令执行单线程,不是整个进程只有一个线程,别的线程会处理数据同步、持久化这些事,6.0 之后还引入了多线程。
Redis 为什么快?纯内存操作、epoll 事件驱动、数据结构高效、避免命令执行阶段的锁竞争。epoll 作用是增删改查文件描述符,结构上用红黑树维护全部监听的 fd,用双向链表维护就绪的 fd,关键 API 有 epoll_create(初始化)、epoll_ctl(增删改 fd 并告知关心什么事件)、epoll_wait(查找就绪 fd,超时 -1 阻塞、0 立即返回、正数在超时时间内等)。它靠中断(数据来了的中断、超时的时钟中断)来工作。poll 和 select 是轮询加超时,性能差。
Reactor 模式是分发器加处理器,组件有 Reactor(分发事件)、Acceptor(接收请求)、Handler(处理事件)。单线程 Redis 用 epoll 拿事件,单线程一个人扮演全部角色。那为什么 Memcache 用多线程?多线程能利用多核 CPU,但有上下文切换、模型复杂;单线程没有上下文切换、Redis 瓶颈又不在 CPU,但用不上多核。Redis 6.0 引入多线程是为了充分利用多核让主线程集中执行命令:主线程负责接收和分发,IO 线程从可读可写列表拿连接做读写,读到的命令交主线程执行,主线程的响应交 IO 线程写回。一句话,单线程执行命令,多线程处理 IO 和后台任务。
分布式锁
分布式锁在分布式系统里给共享资源提供互斥访问。能做分布式锁的中间件,关键点是支持排他性操作,比如 Redis、Nacos、ZooKeeper、关系型数据库。
锁的本质就是一个普通键值对,加锁用 SETNX、释放用 DEL。加锁用 SETNX:设置成功就是加锁成功,没设置说明别人持有。等待时间怎么定?参考业务正常执行时间,可以看 P99 或 P999 线。等待怎么实现?睡眠或监听锁释放(DEL 事件)。超时重试用 Lua 脚本执行,关键是检测上次有没有加锁成功:没有 key 就直接加;有 key 且值是自己(上次成功)就重置过期时间;有 key 但值是别人(上次没成功)说明别人持有。
过期时间参考业务完成时间,保守策略是设更长、依赖主动释放。续约要提前续,确保提前量够重试且成功;续约失败有保守(中断业务)、激进(继续执行)两种选择。中断业务纯靠代码里检测(循环检测加关键步骤间检测)。释放锁必须检测这把锁是不是自己加的。进阶有 Redlock(多数原则)、用 singleflight 减少全局竞争把锁本地转交、甚至去分布式锁(数据库乐观锁或一致性哈希负载均衡)。
缓存综合应用
综合应用围绕命中率、一致性、容量、降级、预热展开。
一致性哈希加本地缓存加 Redis:客户端用一致性哈希负载均衡保证同一业务落到同一节点,服务端先查本地缓存再查 Redis,都没有回查数据库,回写时先写本地缓存。优点是性能好、本地命中率高、内存占用少。
Redis 降级成本地缓存目的是保住数据库:Redis 正常时先 Redis 后数据库,发现 Redis 崩了就启用本地缓存先本地后数据库,同时监控 Redis 状态,恢复后逐步把流量转回去。缺点是降级后一致性问题更严重,可结合一致性哈希缓解。
还有请求级别缓存(有效期跟请求一致,响应时清空,一致性强但场景少)、会话级别缓存(跟会话一致,一致性较强、范围较广)、客户端缓存(客户端缓存服务端返回的数据,性能好、局部性好、少一次微服务调用,但一致性差)。预热和预加载:启动时全量或只加载热点数据,或者灰度流量加载(小流量预热再逐步加大,靠基于权重的负载均衡控制)。
记忆抓手:读多用 Cache Aside,强一致别迷信缓存,最终用业务一致性兜底。
三、消息队列:异步、解耦、削峰
概述
消息队列是用于异步通信的中间件,核心价值就三个词:异步、解耦、削峰。使用场景有日志处理(大数据的特例)、消息通讯、秒杀、订单超时取消。
为什么一定要用消息队列?不用的话性能差(不是所有业务用了 MQ 都更快,但如果原本同步或并发调用都搞不快,MQ 的异步化能帮上忙)、扩展性差(接入新业务困难,侵入式修改违反开闭原则)、可用性差。亮点是事件驱动:低耦合、高扩展(同时意味着高研发效率)、高可用(可靠发送、消息不丢、可靠消费),还能做基于事件驱动的 SAGA 事务。不过要记住,不是所有业务用 MQ 都更快,关键看能不能异步化和削峰。
消息不丢失
消息不丢失要覆盖三段链路:生产者到 MQ、MQ 自身存储、消费者处理。
前置知识是 Kafka 主从同步:分区分布在 Broker 上,acks 控制写入确认级别——0 没要求、1 写入主分区、all 写入主分区和 ISR(In-Sync Replicas),注意这个参数是发送方控制的。消息丢失场景对应三段:生产者发送 acks=0 会丢、主从同步 acks=1 主挂了会丢、刷盘任何 acks 值都可能丢、消费者提交主要考虑异步消费问题。
生产者侧用本地事务表保证一定发送了消息:开启本地事务→执行业务→插入本地事务表→提交事务→发送消息→标记已发送,再配补偿机制(找出长时间未发送的消息补发再标记)。进阶是消息回查。
MQ 自身不丢失靠 acks(最好 all,1 勉强能接受)和刷盘参数(log.flush.interval.messages 定量刷、log.flush.interval.ms 定时刷、log.flush.scheduler.interval.ms 定时刷)。消费者侧一定先处理成功再提交 offset,注意异步消费。
亮点是 Kafka 支持消息回查:组件有回查中间件、数据库、look_back_msg topic。准备消息带业务 topic、消息体、业务类型和 ID、准备状态(提交或回滚)。步骤是业务发准备消息→回查中间件存储→业务发提交消息→回查中间件转发到业务 topic。回查实现要注意可扩展性(HTTP 的 POST 带 body,RPC 一般用泛化调用),数据存储用分区表、交替表或分库分表,有序消息要保证同一业务的准备消息先于提交消息。
做到"绝对不丢"成本很高,通常是不丢失加可补偿加可对账。
重复消费
重复消费躲不掉,消费者必须做幂等。前置知识是布隆过滤器:常用于高并发大数据,特点是判断不存在就必然不存在、判断存在只是可能存在(有假阳性),优点是性能好、省内存。重复消费的场景有发送者重复发送和消费者重复消费。
基础思路是唯一索引,可以是额外引入的也可以是业务本身的。基于本地事务就把业务操作和插入唯一索引做成一个事务。非本地事务分三步:插入唯一索引置为初始化状态→执行业务→更新为成功,再配异步检测(定时扫描初始化状态的数据,比较业务数据,完成了就标记成功、失败了就触发重试),这种策略广泛用于各种追求最终一致性的场景。
高并发场景下更狠的做法是布隆过滤器加 Redis 加唯一索引三层:布隆过滤器挡绝大部分重复请求,Redis 再削减一层流量,唯一索引兜底最准确。步骤是先查布隆过滤器判断不存在就处理,否则查 Redis 有就直接返回(重复),否则插唯一索引冲突就是重复。更新顺序上先插唯一索引,布隆过滤器和 Redis 顺序无所谓,Redis 的 key 过期时间要确保重复请求来时还没过期。简化方案可以只留 Redis 加唯一索引,或布隆过滤器加唯一索引,还能用本地布隆过滤器或 bit array 替代。
一句话,先挡流量,后做准确判断,最终靠唯一索引兜底。
消息顺序
有序消息通常是"同一业务维度内有序",不是全局所有消息有序。前置知识:基本含义是 topic 内有序,跨 topic 的有序消息是另一回事;消息在分区上靠 WAL(只追加、不能改)组织,分区内有序、分区间无序,其他中间件基本也是这个思路。
基础方案是单分区:一个 topic 只有一个分区,所有消息都在这,只能有一个消费者。优点是简单、业务影响小,缺点是性能差,发送方分区所在服务器负载高(JVM GC、磁盘 IO),消费方来不及消费引起积压,靠异步消费(引入内存队列,相同业务进同一内存队列)缓解,但又有消息未消费问题。
进阶一点用多分区:同一业务消息发到同一分区(按业务 ID 算分区),多个消费者。缺点是数据不均匀(靠槽位分配或一致性哈希缓解),以及增加分区引起消息失序(新分区暂时不消费)。一句话,顺序性靠路由,吞吐量靠分区。
消息积压
消息积压是生产速度长期大于消费速度,队列长度持续增长。
前置知识是消费者和分区的关系:一个分区最多一个消费者,一个消费者可以消费多个分区。确定分区靠压测(Kafka 自带性能测试脚本)或计算(生产者速率除以单分区性能、除以消费者速率,取大的)。
解决方案:临时性积压不用管;增加分区(变种是建新 topic 给更多分区,因为瓶颈是消费者所以按消费者速率算分区数);优化消费者性能;消费者降级(体现你能灵活运用服务治理);聚合消息和批量操作(生产者改批量发、消费者改批量消费,没有批量接口效果就不好);异步消费(线程池或批量提交,但会引入消息丢失、重复消费靠幂等解决、部分失败靠重试或丢回 MQ)。
处理顺序是先判断是不是临时积压,再看消费速率、分区数和下游瓶颈。
延迟消息
延迟消息是在指定时间后再投递或消费的消息。现成方案:RocketMQ 自带延迟消息特性;RabbitMQ 用 TTL 加死信队列,或 rabbitmq_delayed_message_exchange 插件(但有消息丢失问题、不支持高并发),也能自定义插件。
Kafka 基础方案是定时任务调度,或分区设置不同延迟时间:组件有 delay_topic、延迟消费组、biz_topic。业务发送者发到 delay_topic,延迟消费组消费后按延迟时间睡眠,睡醒转发到 biz_topic。要点是 rebalance 问题(核心是 Pause 加 Resume)、一致性(消费者要幂等),优点是简单、缺点是延迟时间必须预设、分区间负载不均。
再进一步是基于 MySQL 的方案:组件有 delay_topic、延迟消费者、延迟发送者、MySQL、biz_topic。延迟消费者消费 delay_topic 转存数据库,延迟发送者轮询数据库到点转发 biz_topic 并更新状态。亮点是分区表(按并发和数据量分月/周/天/小时)、表交替、分库分表(按 biz_topic 分、轮询)、延迟消息有序性(同一 biz_topic 进同一分区、存同一张表)、批量操作。短延迟用队列,复杂延迟落库调度。
高性能:Kafka 为什么快
Kafka 高性能来自顺序写、page cache、零拷贝、批量处理、分区并行、分段索引、压缩。
分段和索引:一个 topic 多个分区(不同分区就是不同目录),每个分区多个 segment 文件,每个段有两个索引文件(偏移量索引、时间戳索引)都要放内存。查找步骤是按 topic 和分区定目录、按偏移量二分定文件、查索引(恰好有就直接读,没有就找最近的索引项向后遍历)。
零拷贝指 CPU 参与的拷贝次数为 0。传统 IO 是磁盘→内核缓存→应用缓存→内核写缓存→NIC 缓存,2 次 CPU 拷贝、2 次 DMA 拷贝、4 次用户态内核态切换。零拷贝是磁盘→内核缓存→NIC 缓存,0 次 CPU 拷贝、2 次 DMA 拷贝、2 次上下文切换,Kafka 用的是 send_file。
批量处理对比非批量:N 个请求是 N 次系统调用、N*2 次切换、N 次网络开销,批处理是 1 次系统调用、2 次切换、1 次网络开销。顺序写对应 WAL,类似技术还有 AOF,但分区过多会带来问题(可以合并 topic)。分区缩小并发粒度提升性能,压缩要注意是端到端的。
Kafka 综合运用
Kafka 优化分层看:生产者、Broker、消费者、操作系统、JVM。
压缩算法在压缩比和压缩速率(影响吞吐)间反比权衡。swap 交换区追求性能的都要避免。
优化生产者:acks(追求不丢就别降)、批次(适当调大但不是越大越好)、缓冲池、压缩(启用或换算法)、解决缓冲池溢出导致的阻塞。优化 broker:swap、网络读写缓冲(net.core.rmem_default 等一串参数)、磁盘 IO(用 XFS、禁用 atime)、主从同步(num.replica.fetchers 等)、JVM。优化消费者核心是解决积压。中间件优化思路通用:操作系统参数、本身参数、JVM、客户端。
架构设计:基于 MySQL 设计消息队列
基于 MySQL 设计 MQ,本质是把 topic、分区、消息存储、消费 offset、延迟投递这些概念映射到表结构和查询模型。
保留 topic 和分区的设计:一个 topic 一个逻辑表,一个分区一个物理表(为什么不直接一张表?为了拆分和迁移)。broker 消息存储要让不同分区的数据存在不同数据库或主从集群上。发送用推模型(考虑批量、事务、唯一 ID、状态、失败补偿),消费用拉模型直接查数据库、记录消费偏移量(初始化、更新、重置)。延迟消息加 send_time 列加索引,记录上次拉取的最大 send_time,同时发送问题要注意(不能用 LIMIT、不能用 >=,解法是先读 50 条,再把和第 50 条 send_time 相同的一起取出)。
追问陷阱:MySQL 实现 MQ 适合低吞吐、强查询、业务定制场景,高吞吐通用 MQ 不该用数据库硬扛。
记忆抓手:异步提性能,解耦提扩展,削峰保可用。
四、微服务架构:系统级的服务治理
服务一拆,问题就从"单机怎么跑"变成"一堆服务怎么协作还不塌",剩下的事基本都归服务治理。
服务注册与发现
服务注册与发现解决的是"服务实例动态变化后,调用方怎么找到可用节点"。
基本模型是个三角形。服务上线:服务端启动成功后注册自身信息,注册中心和服务端保持心跳(注册中心靠心跳判断服务端是否崩溃),客户端找注册中心查可用节点列表并缓存到本地(可引导到缓存一致性),客户端和注册中心也要保持心跳和数据同步,然后才发请求收响应。服务下线:服务端通知注册中心准备下线,注册中心通知客户端,客户端不再用该服务端,服务端等一段时间后下线,这段等待时间要覆盖注册中心通知和客户端刷新缓存的时间,这正好引导到微服务的优雅退出。
高可用靠心跳机制。服务端崩溃检测:心跳不通立刻通知客户端、继续保持心跳,一直失败就认为崩溃,恢复了就通知客户端可用。客户端容错的原因有服务端崩溃、网络不通、连不上注册中心(客户端一直拿老的节点列表),措施是请求失败就把节点挪出可用列表、继续对它发心跳,重试都失败就彻底剔除,心跳恢复就放回去。注册中心选型看中间件成熟度、社区活跃度、性能和 CAP,CP 和 AP 之间选 AP,因为服务发现最怕不可用。
注册中心挂了怎么答?客户端通常继续用本地缓存,服务端继续试心跳,注册中心恢复后再同步。别说"注册中心挂了系统就完全不可用"。
负载均衡
负载均衡是在多个可用节点间选一个处理请求,目标是提高吞吐、降低延迟、避免局部过载。
基本算法:轮询与加权轮询(按处理器能力、节点重要性、位置设权重,Nginx 用的平滑加权轮询算法);随机与加权随机;哈希与一致性哈希(关注哈希值均匀性,Redis Cluster 用一致性哈希);最少连接数(多路复用问题可引向 IO 模型);最少活跃数;最快响应时间(响应时间取平均、P99、P999 或中位数,所有采集指标都要考虑衰减)。这些算法都有固有缺陷:没考虑请求所需资源不同导致偶发不均,动态算法算了但不准。
更有意思的是让调用结果反过来影响负载均衡:动态调整权重要设上下限(错误调整会引发线上事故),客户端的服务治理措施都能用调用结果来调整。还有一致性哈希加本地缓存:提高命中率、减少内存,但仍有不一致问题(可引到服务扩容的例子)。
追问怎么答:节点性能不同优先讲权重,有本地缓存优先讲一致性哈希,节点健康频繁波动优先讲熔断和动态权重衰减。
超时
超时控制给调用设最大等待时间,避免慢请求长期占用线程、连接、内存。目标是用户体验和及时释放资源(后者是提升可用性的关键)。
形态有调用超时和链路超时(后者更考验研发能力)。确定超时时间看用户体验、响应时间(P99/P999)、压测、代码。超时中断业务要手动检测。监听超时时间有两种:客户端监听(超时不发请求,已发超时就丢响应),或客户端和服务端同时监听(服务端收到时已超时不执行,返回超时响应后丢弃应用响应)。
链路超时控制是重点。超时时间字段走链路元数据:HTTP 放头部,RPC(如 Dubbo)放协议头。超时时间的值有两种传法:剩余超时时间(要估计网络传输时间,跨地区传输问题更严重),或超时时间戳(要考虑时钟同步问题,正常很轻微,但闰秒时钟回拨、不同云服务商会有问题)。控制不住就要求下游优化性能,或本地缓存调用结果。
熔断
熔断在依赖持续异常时主动切断调用,避免故障扩散。它要综合考虑负载均衡、限流、降级,核心是判定节点健康状态。
判定指标有服务指标(响应时间、错误率)、硬件指标(CPU、内存、网络 IO、磁盘 IO)、第三方依赖(核心依赖崩溃就触发熔断)。关键是持续时间:太短容易抖动,太长不能及时熔断。怎么恢复?等一段时间,再逐步放开流量(类似灰度发布),抖动和前两个因素有关。
亮点是结合负载均衡:服务端返回熔断响应,客户端把节点挪出可用列表,等一段时间后试探性发请求,还被拒就再等,逐步加大流量。
状态机答法:关闭状态正常调用,打开状态直接失败或走降级,半开状态放少量探测流量,成功就恢复失败就继续熔断。和限流、降级的区别:熔断针对依赖不可用,限流针对流量太大,降级针对保核心舍非核心。
限流
限流限制进入系统的请求量,保护服务不被突发流量压垮。
算法分静态和动态。静态有令牌桶、漏桶、固定窗口、滑动窗口。动态(自适应)算法可强调参考了 TCP 流量调度,比如 BBR,或自定义算法(类似熔断降级,要选合适指标)。对象分单机和集群(集群限流依赖 Redis,网关做集群限流可以不用 Redis),还有业务相关限流(针对非 VIP、IP、业务 ID)。做法上注意和熔断限流对比:同步阻塞、同步转异步、调整负载均衡算法。
阈值怎么算?优先压测(第一选择),解读 QPS 与响应时间、资源利用率、吞吐量的关系,选择点有性能优先、并发数优先、吞吐量优先。微创新方案是参考类似服务或按比例转化。手动计算不准但能凑合(按数据库操作、微服务调用、Redis、Kafka 等耗时操作估算)。
算法选择:固定窗口简单但边界有尖峰,滑动窗口更平滑,漏桶输出稳定,令牌桶允许突发、工程里最常见。限流后的动作也要说清:拒绝、排队、同步转异步、降级或调整负载均衡。
降级
降级在资源不足或依赖异常时牺牲非核心能力,保证核心链路可用。基本概念和熔断一致(判定健康、恢复、抖动)。
怎么降级?跨服务降级按业务价值判定,三种方案:全部停掉、停部分节点、停止访问某些资源(第三方崩溃的场景)。有损服务包括返回默认值、禁用可观测性组件、同步转异步、简化流程(注意审核)。
亮点方案:读写服务降级写服务(读服务更重要、QPS 更高,写服务显著提高服务器负载),合并部署服务的降级策略(BC 端降级 B 端、VIP 和非 VIP 降级非 VIP、付费和非付费降级非付费),快慢路径降级慢路径(正常请求先快后慢,降级请求只走快路径)。
风险点:降级必须有开关、监控和回滚,涉及审核、支付、库存等强一致流程不能随便返回默认成功。
隔离
隔离把不同业务、资源或依赖分开,避免一个局部问题拖垮整体。基本原则是核心与非核心隔离、核心与核心隔离,好处是可用性、性能和安全性都能涨一截。
常见措施:机房隔离、实例隔离、分组隔离(BC 端、VIP 和非 VIP、读写隔离即 CQRS、快慢隔离)、连接池隔离(微服务、数据库、其他连接池)、线程池隔离(有些语言没这概念)、第三方依赖隔离(核心 Redis 单独集群、开持久化的 Redis 单独集群、物理数据库隔离、日志 Kafka 和业务 Kafka 隔离)。方案上还有慢任务隔离、制作库与线上库分离。
隔离经常和限流、熔断、降级组合使用,是高可用体系的底座,但会增加资源成本和运维复杂度,不是越细越好。
调用第三方
调用第三方平台要把不可控的依赖变成可观测、可限流、可重试、可降级、可替换的依赖。
基本措施:一致性抽象(提高研发效率、高可扩展性);客户端治理(限流、重试,但重试只能用于幂等接口);可观测性支持(日志、metrics 用 Prometheus、tracing 用 ZipKin 或 SkyWalking、告警);测试支持(mock 响应的成功/失败/超时、mock 回调)。亮点方案:同步转异步(对时效性要求不高的接口,临时存储请求用 MQ 解耦)、自动替换第三方(判断服务是否健康)、压测支持(模拟响应时间和容错、全链路流量分发)。
重试原则:只对幂等接口重试,非幂等接口要有幂等号、去重表或状态机保护。
综合服务治理方案
高可用微服务架构不是单点技术,而是监控、治理、隔离、变更流程和自动恢复的组合。
高可用要考虑容错(自身服务故障最重要、依赖故障、基础设施故障)、限制影响范围(业务损失小、影响用户少、影响组件少)、快速发现修复(监控告警加自动故障处理)、规范变更流程(发版、配置变更)、组织建设。
面试基本思路是模板化的:发现问题(监控告警不完善、缺乏服务治理、缺乏合理变更流程)→ 计划方案(引入监控告警、服务治理、第三方高可用、拆分共同依赖、规范变更流程)→ 落地实施(监控业务和软硬件依赖、熔断降级限流隔离、拆共同依赖)→ 取得效果(99 变 999、Bug 变少)→ 后续改进(进一步减少共同依赖)。亮点是异步解耦(消息队列)和自动故障处理(但凡需要手动修复的数据,都可以考虑自动修复)。
最后一句总纲:监控告警发现、服务治理止血、自动修复恢复、复盘减少复发。很多线上故障不是代码 bug,而是发布和配置变更引起的。
记忆抓手:监控发现,治理止血,自动恢复,复盘防复发。
五、ES 与 MongoDB:关系型之外的补充
MySQL 不是万能的,ES 和 MongoDB 补的就是它不够弹、不够会搜的那部分。
MongoDB 高可用
先说为什么用 MongoDB:灵活的文档模型,易于扩展(分库分表很难,但 MongoDB 分片全自动)。高可用依赖复制集和分片集群,复制集解决节点故障,分片解决容量和吞吐扩展。
分片机制有普通集合和分片集合之分。分片集合涉及分片、块(一个分片含多个块,块会分解、能在分片间迁移)、再平衡过程(有触发阈值)。块的概念在 MongoDB 里比较重要,其他中间件虽然有分片但不一定有块。配置服务器可以对标 Kafka 的元数据,存分片信息、权限控制信息、控制分布式锁。复制机制应用读写分离,靠主从同步,核心是 oplog(幂等的,所以 oplog 会很大,而且会被删除)。写入语义用 w(majority 要求同步给大部分节点、0 不用等、1 必须写主节点、N 是主加从合起来 N 个)、j(true 必须写磁盘日志)、wtimeout(写入超时)控制可靠性。
面试思路是从主从结构切入(引入仲裁节点、启用主从模式的配置服务器、多数据中心主从),再引入分片,最后调整写入(w 调到 majority、j 调到 true)。记忆点是:复制保可用,分片保扩展,w 和 j 决定写入可靠性。oplog 用于主从同步,本质是变更日志,从节点落后太多、旧 oplog 被删就可能要重新同步。
MongoDB 高性能
MongoDB 高性能主要从索引、查询路由、文档模型、排序、系统资源几个方向优化。
查询过程是应用发查询到 mongos,mongos 执行路由,分片执行查询,mongos 合并结果集。ESR 规则用于设计复合索引:相等第一、排序第二、范围第三,衍生有 ES 和 ER,但不是所有场景都机械套用。优化方案包括用覆盖索引(查询只查必要字段、更新也别全量更新)、优化排序(普通文档排序,分片集合排序和分库分表一样难)、增加 mongos 数量(类比分库分表的代理,缓解性能瓶颈)、拆分大文档(类比拆宽表)、嵌入文档(嵌入整个文档或冗余字段,关系型也常用)、操作系统优化(增大内存、调整 swap)。
一句话:先看索引,再看路由,再看文档大小,最后看机器资源。
Elasticsearch 高可用
ES 高可用要同时处理节点故障、写入洪峰、协调节点瓶颈和跨集群容灾。
节点角色有候选主节点、协调节点、数据节点(分热、暖、冷)。写入数据分两条线:写文档是先写 Buffer、再写 Page Cache(生成段、段合并)、最后刷盘,这个过程叫 refresh;写 Translog 是只追加、定时刷盘。索引与分片要注意,ES 里的索引还包含数据本身,一个索引有多个分片,每个分片又由主从分片构成。
优化方案:限流保护节点(ES 插件、网关或代理如极限网关、客户端限流,微服务里客户端限流的优缺点这里一样适用);利用消息队列削峰(数据写数据库,监听 binlog 发消息到 MQ,消费消息写 ES,变种是按业务价值降级不重要消费者、或彻底停掉写入腾资源服务读);保护协调节点(它类似分库分表的代理,是性能和可用性瓶颈,用单一角色、分组隔离和微服务联动,重要请求和大请求用专属协调节点);双集群(CCR 是钞能力,或者 MQ 双写:写入时写 MQ 启动两个消费者分别写 AB 集群,读取时靠客户端或 DNS 容错)。
Elasticsearch 查询性能
ES 查询性能的核心是让查询更快命中倒排索引,减少跨分片、深分页和不必要字段的开销。
倒排索引的步骤是把文档切成关键词、为每个关键词建倒排索引。倒排索引的 key 是关键词,value 是文档 ID、关键词位置(第几个词)、偏移量(首字符位置)、出现频率(可看做相关性强弱)。FST 用来定位 Block。
优化分页查询要和分库分表关联:深分页难点是 FROM X SIZE Y 等于 N*(X+Y),每个分片都要取大量数据再归并。方案有 Scroll 和 Scroll Scan(不适合深度分页)、Search After(近似禁用跳页方案)。批量提交(Kafka 也用过,单个转批量、优化批次大小,调大批次或降级丢数据)、调大 index.refresh_interval(简单好用的手段)、优化不必要字段(只同步被查询字段、回查主表获得全部数据,类似分库分表中间表方案,注意索引字段只加不减、要减只能新建索引)、冷热分离(热节点高性能高可用、冷节点廉价量大,用生命周期管理自动迁移)、常规优化(垃圾回收用 G1/ZGC 减少 STW、swap 直接禁用或调 vm.swappiness、文件描述符)。
倒排索引解决查得快,Search After 解决翻得深,冷热分离解决存得久。
综合面试题
MongoDB 的题:为什么用 MongoDB(数据结构灵活、字段多变、适合文档聚合优先 MongoDB;强事务、复杂关联、强一致优先 MySQL);相比关系型的优势(灵活文档模型、自然表达聚合、自动分片、高吞吐友好);复制机制怎么运作(主写 oplog、从拉取回放、延迟大要关注 oplog 是否还在);主从选举怎么优先同机房(节点优先级、投票配置、机房部署和网络隔离);为什么 oplog 总是很多(记录幂等变更、大文档和批量写入会放大);怎么控制写入语义(w、j、wtimeout)。
Elasticsearch 的题:用 ES 解决过什么(全文检索、多条件组合、日志检索、聚合分析);怎么查询(倒排索引定位候选文档、计算相关性、分片内查询、协调节点归并);怎么写入(Buffer→Translog→refresh 生成段→flush 落盘→段合并);是不是实时(近实时,要等 refresh);会不会丢数据(取决于副本、刷盘、确认和故障场景,生产上通常用数据库或 MQ 做源数据,靠重建索引兜底);为什么分页慢(深分页每个分片读大量无用数据、协调节点还要合并排序);怎么优化(分页、JVM、GC、操作系统、冷热分离、减字段)。
记忆抓手:选型讲模型,ES 是搜索引擎不是主库,近实时不是强实时。
把五条线缝起来:一个高并发下单场景
光记点没用,能串起来才是面试想要的。拿电商下单走一遍:
- 请求到网关,先限流挡住突发流量(微服务治理)。
- 查商品走缓存,热点商品防击穿,查不到再回 MySQL。
- 商品搜索、日志分析走 ES,不压主库(ES 与 MongoDB)。
- 下单写库:单表扛不住就分库分表,跨服务的一致性用消息做最终一致,而不是强一致的分布式事务(MySQL)。
- 扣库存、发通知、记日志全丢进消息队列异步处理,削峰解耦。
- 全程挂监控,依赖抖动靠熔断、降级止血,核心和非核心做好隔离。
走完这一套,五个主题就拼成了一个完整的高可用系统,谁也离不开谁。面试时这么讲,比背一百个八股点都值。
最后
这篇是张尽量铺满知识点的总图,每个主题单独拎出来都还能往深里写,比如 MySQL 的分库分表迁移、缓存的分布式锁续约、Kafka 的零拷贝细节。后面会按主题一个一个拆开细聊,这篇先当索引用。