11-微服务与分布式架构 (进阶必修)
面试官潜台词:“你会写 CRUD 是基础,但能不能处理高并发、数据一致性问题,才决定能不能给你发 High Level 的 Offer。“
1. 分布式理论基石
1.1 CAP 定理 (经典八股)
- Consistency (一致性):所有节点在同一时间的数据完全一致。
- Availability (可用性):服务一直可用,正常响应(不报错)。
- Partition Tolerance (分区容错性):网络断开时,系统仍能工作。
- 结论:三者不可兼得,P 是必须的,所以只能在 CP (强一致) 和 AP (高可用) 之间权衡。
- CP (Zookeeper/Etcd):为了保证数据一致,网络分区时可能不可用(拒绝写)。
- AP (Eureka/Redis高可用):保证服务随时可用,但数据可能短暂不一致。
1.2 BASE 理论 (互联网主流)
- Basically Available (基本可用):响应慢点、功能降级,但还能用。
- Soft state (软状态):允许中间状态(如支付中)。
- Eventually consistent (最终一致性):经过一段时间后,所有节点数据达成一致。
2. 分布式一致性算法 (Raft)
面试场景:Etcd、Consul 等注册中心的核心原理。
核心概念
Raft 协议将节点分为三种状态:
- Leader (领导者):霸道总裁。处理所有客户端请求,日志复制给 Follower。
- Follower (跟随者):小弟。只听 Leader 的,Leader 挂了就变 Candidate。
- Candidate (候选人):竞选者。发起投票,票多者变 Leader。
选主过程 (Leader Election)
- 超时:Follower 收不到 Leader 的心跳 (Heartbeat),认为 Leader 挂了。
- 变身:Follower 变成 Candidate,任期 (Term) +1,给自己投一票,并发起 RequestVote RPC。
- 拉票:如果不冲突,其他节点投票给它。
- 当选:获得大多数 (N/2 + 1) 选票,变成 Leader,立马发送心跳宣誓主权。
日志复制 (Log Replication)
- Client 发送命令给 Leader。
- Leader 写入本地日志(由 Uncommitted 状态)。
- Leader 并发发送 AppendEntries RPC 给所有 Follower。
- 只要 大多数 Follower 回复成功,Leader 就 Commit 该日志,并应用到状态机。
- 返回结果给 Client。
3. 分布式事务 (重点拷打)
场景:用户下单扣钱(微服务A),同时减库存(微服务B)。如果 A 成功 B 失败,钱扣了货没少,账就不平了。
3.1 最终一致性:MQ 事务消息 (RocketMQ 为例)
最推荐的方案,性能高,代码解耦。
- 发送半消息 (Half Message):上游服务(订单)先发一个 “Prepare” 消息给 MQ。此时下游看不见。
- 执行本地事务:上游写数据库(创建订单)。
- Commit/Rollback:
- 本地成功 → 发送 Commit → MQ 把消息投递给下游(库存服务)。
- 本地失败 → 发送 Rollback → MQ 删除消息,下游啥也不知道。
- 回查机制:如果 Commit 丢了,MQ 会定时反查上游:“哎,刚才那个订单到底成功没?”
💡 MQ 实战连环炮 (面试必考)
当你讲完上面的事务消息流程,面试官一定会顺着问这三点:
1. 消息幂等性:如果下游消费了两次怎么办?
- 场景:网络波动导致 Commit 确认丢了,MQ 重新投递,下游收到两条一模一样的“减库存”指令。
- 初级方案:去重表 (
msg_log或叫“消费记录表”)- 在数据库建一张表,消息 ID 设为唯一索引。消费前先插入,插入成功才处理。
- 高并发进阶优化 (面试官最想听到的) ⭐⭐⭐
- 为什么大厂“不爱”写
msg_log表?- 写放大问题:数据库最珍贵的资源是 I/O。如果你单独建表,意味着每处理一个业务,数据库都要多承受一次 B+ 树的写入和索引更新。高并发下,这往往是压死骆驼的最后一根稻草。
- 事务负担:要把“插入日志”和“更新业务”放在一个本地事务里。事务时间越长,锁定资源越多,吞吐量越低。
- 更好的方案 A:原子更新 + 消息 ID 字段 (无表方案)
- 操作:在业务逻辑表里直接加
processed_msg_id。 - SQL:
UPDATE orders SET status = 2, processed_msg_id = 'MsgA' WHERE id = 456 AND processed_msg_id <> 'MsgA'。 - 原理:利用数据库自带的行锁。如果第二次相同的消息进来,
WHERE条件里的id <> 'MsgA'就会不匹配,SQL 影响行数为 0,业务天然幂等,且没有额外写开销。
- 操作:在业务逻辑表里直接加
- 更好的方案 B:状态机边界 (Status Fencing)
- 场景:如果是修改订单状态。
- SQL:
UPDATE orders SET status = 'PAID' WHERE id = 456 AND status = 'UNPAID'。 - 原理:只要状态已经变为 ‘PAID’,后续重复的消息怎么冲撞,这条 SQL 都不会生效。这叫“天然幂等性”。
- 优化 C:Redis 预检:利用 Redis 的
SETNX做前置过滤,拦截 99% 的重试流量。
- 为什么大厂“不爱”写
📌 幂等方案选型指南
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 状态机 (Status Fencing) | 状态流转(如订单:待支付→已支付) | 最高效。无需额外字段,性能零损耗。 | 局限性强。只适用于有状态变更的业务。 |
| 原子更新 + 消息ID | 非状态变更(如修改个人资料、加减积分) | 普适性强。一条 SQL 搞定,性能高。 | 业务表需要多加一个冗余字段。 |
| 去重表 (msg_log) | 逻辑极复杂、跨多表操作 | 逻辑清晰,通用性最强。 | 性能最差。存在写放大问题,容易成为瓶颈。 |
面试总结金句:
“在处理 MQ 幂等时,我会优先考虑状态机方案;如果业务没有明确状态机,我会采用原子更新+消息ID字段的方式来保证 SQL 级别的原子性;只有在极其复杂的场景下,才会退而求其次使用独立的去重表。”
💡 实战案例:签到领积分
你提到的“增加最后签到日期”是非常聪明的做法,这就是 “创造状态边界”。
-
场景:用户每天签到一次领 10 积分。
-
SQL 设计:
UPDATE users SET points = points + 10, last_checkin_date = '2024-01-20' WHERE id = 123 AND last_checkin_date < '2024-01-20' -
为什么这很牛行?
- 天然幂等:第一条消息过来,更新成功,日期变了。第二条消息过来,
WHERE条件不满足,更新失败。 - 无需消息 ID:直接利用业务逻辑本身的约束(一天只能签一次)来做幂等。
- 零成本:不需要额外的逻辑表,也不需要 Redis,性能最高。
- 天然幂等:第一条消息过来,更新成功,日期变了。第二条消息过来,
总结:当你发现业务没有“状态”时,可以尝试找出一个能代表“唯一性”的业务字段(如日期、版本号、流水号)作为栅栏 (Fencing)。
2. 消息丢失:怎么保证消息 100% 投递成功?
- 生产端:开启 Confirm 机制(RabbitMQ)或使用 事务消息(RocketMQ)。
- MQ 服务端:开启 持久化。保证消息写进磁盘而不是只在内存里,就算重启也不丢。
- 消费端:开启 手动 Ack。消费完逻辑业务后手动告诉 MQ,不要自动确认。
3. 消息积压/失败:下游系统挂了,消息一直报错怎么办?
- 重试机制:MQ 会自动重试。
- 死信队列 (DLQ):如果重试 N 次(如 16 次)还是失败,消息会进入“死信队列”。
- 离线处理:程序员订阅死信队列,人工排查原因,修复代码或手动补数据。
❓ RabbitMQ 选手怎么答这些题?
如果你没用过 RabbitMQ,只需要记住它和 RocketMQ 的最大区别:
- RocketMQ:天生支持事务消息(即上面的半消息流程)。
- RabbitMQ:不支持这种自动回查的事务消息,它是靠 消息确认机制 (Confirm + Ack)。
- 生产者发消息 → MQ 回复 Confirm → 生产者才认为本地事务可以提交。
- 这种方案比起半消息方案,需要程序员手写更多的控制代码。
3.2 强一致性:TCC (Try-Confirm-Cancel)
适合:资金流转,必须严格一致,不允许中间状态可见。
- Try:资源预留(冻结余额,冻结库存)。
- Confirm:真正执行(扣减余额,扣减库存)。Try 成功则 Confirm 一定成功。
- Cancel:回滚(解冻余额,解冻库存)。
3.3 Seata (AT 模式)
阿里开源。业务代码无侵入,像写本地事务一样写分布式事务。底层通过 Undo Log 实现自动回滚。
4. 分布式系统常用组件/算法
4.1 一致性 Hash (负载均衡)
问题:普通 Hash(key) % N,如果 N 变了(扩容/缩容),几乎所有数据都要迁移,缓存雪崩。
解决:
- Hash 环:将 Hash 空间 (0 ~ 2^32-1) 连成一个环。
- 节点映射:把服务器 IP Hash 到环上。
- 数据映射:把数据 key Hash 到环上,顺时针找到的第一个节点就是归宿。
- 虚拟节点:节点少时数据会倾斜(某个节点扛了80%流量)。解决办法是给每个真实节点搞 100 个虚拟 IP 撒在环上,让数据均匀分布。
4.2 全局唯一 ID (雪花算法 Snowflake)
结构 (64 bit):
- 1 bit:不用(符号位)
- 41 bit:时间戳(ms 级,能用 69 年)
- 10 bit:机器 ID(5 bit 数据中心 + 5 bit 机器 ID)
- 12 bit:序列号(每毫秒每台机器可生成 4096 个 ID)
优点:有序递增(对 MySQL 索引友好),不依赖数据库,高性能。 缺点:严重依赖服务器时钟。如果时钟回拨,会 ID 重复。
4.3 Top K 问题 (海量数据)
题目:有 10 亿个数,找出最小的 100 个。内存存不下 10 亿个数。
解法:大顶堆 (Max Heap)
- 维护一个大小为
K=100的大顶堆。 - 先放 100 个数进去。
- 遍历剩下的数:
- 如果比堆顶元素小:弹出堆顶,把新数塞进去,调整堆。
- 如果比堆顶元素大:直接无视(它不可能是最小的 100 个之一)。
- 最后堆里剩下的就是最小的 100 个。
- 复杂度:O(N log K)。内存极省。
注意:找最小 K 个用大顶堆,找最大 K 个用小顶堆。
5. 面试实战问答
Q1: Redis 主从同步是怎么做的?
- 全量复制:Slave 刚连上 Master。Master BGSAVE 生成 RDB 发给 Slave,Slave 先清空自己再加载 RDB。
- 增量复制:连接建立后。Master 把写命令存到
repl_backlog_buffer(环形缓冲区),并传给 Slave。 - 断点续传:断网重连,Slave 带着 offset 找 Master。
- 什么是 offset?:可以把它理解为 “数据流的进度条指针”。Master 每写 1 字节数据,offset 就加 1。Master 和 Slave 各自维护一个 offset。
- 判断逻辑:Slave 说“我读到 1000 了”,Master 查缓冲区发现自己缓存了 800-2000 的数据。ok,那把 1001-2000 发给 Slave。
- 失败回退:如果 Slave 太久没连上,说“我读到 1000”,但 Master 缓冲区最早的数据已经是 1500 了(覆盖了),那只能强制触发全量复制。
Q2: 既然有 HTTP,为什么微服务内部还要用 RPC (gRPC)?
- 性能:HTTP/1.1 头部冗余大,是文本协议。gRPC 基于 HTTP/2,是二进制协议,头部压缩,支持多路复用。
- 序列化:gRPC 用 Protobuf,比 JSON 序列化更快,体积更小。
- 强类型:Protobuf 定义契约 (.proto),自动生成代码,不像 HTTP 需要手写文档和解析。