分布式面试有个很有意思的现象:面试官问的都是"你们用的什么方案",但真正拉开差距的是下一句——"它在什么情况下会出错,你怎么兜底"。我自己带过几次面试,也被问过几次,发现同一个题,初级答"是什么",中级答"为什么这么设计",高级答"出了事怎么办、代价是什么"。于是这篇按三层来组织:
- 🌱 初级(0–3 年):概念、API、基本原理,"是什么/怎么用";
- 🌿 中级(3–5 年):底层机制、常见坑,"为什么/怎么选";
- 🌳 高级(5 年+,对标后端约 8 年):架构取舍、线上治理、量化与对账,"怎么落地/出问题怎么办"。
每个专题都有:标准回答要点(能背)、追问链(面试官会怎么继续戳你)、加分项。技术栈不局限于某一家公司,按通用岗位考点写。文末有速查表和参考资料,都是我真实打开读过的。
一、知识地图:分布式面试到底在考什么
先看全景。所有分布式问题,本质上都是在回答同一个问题:多台机器之间没有共享内存、没有全局时钟、网络随时会丢和延迟,怎么让"一件事"要么都成、要么都不成、要么最终成。下图把常考主题按"一致性/协调/流量治理/可靠交付"四块归了类:
二、CAP 与 BASE:一切讨论的起点
🌱 初级:CAP 是什么,BASE 是什么
标准回答要点:
- C(Consistency,一致性):所有节点在同一时刻读到的数据一致,这里指线性一致(读到的一定是最新写入);
- A(Availability,可用性):每个未故障节点收到请求都必须在有限时间内给出(非错误)响应;
- P(Partition tolerance,分区容错):网络分区(节点间消息丢失/延迟)时系统仍继续工作。
- 分布式系统里分区不可避免,所以真实的选择是:发生分区时,选 C 还是选 A。不是"三选二"这种静态选择,正常情况下 C 和 A 可以同时满足。
- BASE:Basically Available(基本可用,允许降级/限流/延迟)、Soft state(软状态,允许中间态)、Eventually consistent(最终一致)。它是对 CP 极端的妥协,是互联网业务的主流思路。
追问链:ZooKeeper 是 CP 还是 AP?Eureka 呢?Nacos 呢?——ZooKeeper 是 CP(选主期间不可用,写走 ZAB 多数派);Eureka 是 AP(自我保护、节点间异步复制,宁可返回旧实例也不拒绝);Nacos 临时实例走 Distro 协议是 AP,持久实例和配置管理走 Raft 是 CP,可以按实例类型切换。
加分项:说明 CAP 里的 C 是线性一致而不是 ACID 里的 C;提到 CAP 只描述分区时的取舍,不要说"我们系统是 CA"——那意味着不容忍分区,单机才成立。
🌿 中级:一致性有哪些级别,怎么选
| 级别 | 含义 | 典型场景 |
|---|---|---|
| 线性一致(强一致) | 读一定读到最新写,全局有序 | 分布式锁、选主、账户余额扣减的核心路径 |
| 顺序一致 | 所有进程看到的操作顺序一致,但不要求实时 | ZooKeeper 读(可能读到旧值,但顺序一致) |
| 因果一致 | 有因果关系的操作顺序保持 | 评论与回复、聊天消息 |
| 读己之写 | 自己写完自己一定能读到 | 发帖后立刻刷新看到自己的帖子(主从延迟常破坏它) |
| 最终一致 | 无新写入后,各副本最终收敛 | 缓存、搜索索引、统计报表、库存展示 |
实战怎么选:钱和库存的"扣减"走强一致(数据库行锁/CAS/单点串行),"展示"走最终一致;主从延迟破坏读己之写,可以"写后一定时间内读主库"或"带版本号读,读到旧版本就回主库"。
🌳 高级:如何用一致性语义反推架构
高级岗位常问:"你们的核心链路哪里是强一致、哪里是最终一致?依据是什么?"我的回答框架是按"错了的代价"分级:
- 资金/库存扣减:错一次就是资损或超卖,必须在单个事务或单个分片内串行完成,靠数据库条件更新(
update ... set stock=stock-1 where stock>=1)保证; - 订单状态流转:用状态机 + 乐观锁(版本号/状态条件),非法流转直接拒绝;
- 下游通知类(积分、消息、统计、搜索):至少一次投递 + 幂等消费,允许秒级到分钟级延迟;
- 兜底:所有最终一致链路都必须有"对账 + 补偿",否则"最终"可能永远不来(后面专题详述)。
加分项:把"一致性窗口"量化成 SLA,比如"库存展示与实际偏差 < 3 秒、对账发现率 99.9%、修复 T+1 完成",比说"最终一致"有力得多。
三、分布式事务:方案对比与落地
这是分布式面试的绝对主角。先给全景对比,再分层讲:
| 方案 | 一致性 | 侵入性 | 性能 | 适用 |
|---|---|---|---|---|
| 2PC / XA | 强一致 | 低(依赖数据库 XA) | 差(锁持有到第二阶段结束) | 数据量小、并发低、跨库强一致 |
| TCC | 最终一致(业务层预留) | 高(每个服务写 Try/Confirm/Cancel) | 好(无长事务锁) | 支付、库存、优惠券等核心资金资源 |
| Saga | 最终一致 | 中(写正向 + 补偿) | 好 | 长流程、跨多个服务、允许中间态可见 |
| 本地消息表 | 最终一致 | 中(一张表 + 定时任务) | 好 | 通用,几乎不依赖中间件事务能力 |
| 事务消息(RocketMQ) | 最终一致 | 中 | 好 | 本地事务 + 异步通知下游 |
| Seata AT | 最终一致(全局锁保护隔离) | 低(自动生成回滚镜像) | 中 | 内部系统、关系型数据库、想少改代码 |
🌱 初级:2PC、TCC、本地消息表各是什么
标准回答要点:
- 2PC(两阶段提交):协调者先问所有参与者"能提交吗"(Prepare,参与者写好 redo/undo 但不提交并持有锁),全部同意则发 Commit,否则发 Rollback。缺点:同步阻塞、协调者单点、Commit 阶段部分失败会不一致。
- TCC:Try(检查并预留资源,如冻结库存/冻结金额)、Confirm(真正扣减,必须幂等)、Cancel(释放预留,必须幂等)。
- Saga:把长事务拆成一串本地事务,每个本地事务配一个补偿操作,失败就按反序执行补偿。
- 本地消息表:业务操作与"待发送消息"写在同一个本地数据库事务里,然后由定时任务/binlog 监听把消息投递到 MQ,下游消费幂等处理,投递失败就重试。
追问链:为什么本地消息表能保证不丢?——因为消息和业务在同一事务里落库,业务成功消息必然存在,投递靠"重试直到成功",配合下游幂等达成"至少一次 + 幂等 = 效果上恰好一次"。
🌿 中级:TCC 三大坑与 Seata 的 AT 原理
TCC 必须处理的三个异常(面试高频):
- 空回滚:Try 还没执行(超时/丢包),Cancel 先到了。Cancel 必须识别"没有对应 Try 记录"并直接返回成功,同时记录这次 Cancel,防止后面迟到的 Try 生效;
- 悬挂:Cancel 已经执行完,迟到的 Try 才到,如果 Try 执行了就会永远冻结资源。解决办法:Try 前检查事务日志表里是否已有该全局事务的 Cancel 记录,有就拒绝;
- 幂等:Confirm/Cancel 会被重试,必须用"事务状态表 + 全局事务 ID 唯一键"保证只生效一次。
// TCC 事务日志表:唯一键 (xid, branch_id) 同时解决幂等、空回滚、防悬挂
// status: 0=TRIED 1=CONFIRMED 2=CANCELLED
@Transactional
public boolean tryFreeze(String xid, long userId, long amount) {
// 防悬挂:如果已存在该 xid 记录(已被 Cancel 占位),拒绝 Try
if (tccLogMapper.exists(xid)) return false;
int rows = accountMapper.freeze(userId, amount); // update ... where balance >= #{amount}
if (rows == 0) return false;
tccLogMapper.insert(xid, userId, amount, 0);
return true;
}
@Transactional
public boolean cancel(String xid) {
TccLog log = tccLogMapper.selectForUpdate(xid);
if (log == null) { // 空回滚:占位,防止 Try 晚到
tccLogMapper.insert(xid, 0, 0, 2);
return true;
}
if (log.getStatus() == 2) return true; // 幂等
accountMapper.unfreeze(log.getUserId(), log.getAmount());
tccLogMapper.updateStatus(xid, 2);
return true;
}Seata AT 模式原理(按官方文档的概念梳理):它是 2PC 的演进。一阶段:拦截业务 SQL,解析出要修改的行,先查询"前镜像",执行业务 SQL,再查"后镜像",把前后镜像与行锁信息写入 undo_log,和业务更新在同一个本地事务提交;提交前向 TC(事务协调者)申请全局锁。二阶段:提交则异步清理 undo_log;回滚则根据前镜像生成反向 SQL 恢复数据,若发现"后镜像与当前数据不一致"(脏写)则需要人工介入。
Seata 的隔离性:本地事务已提交,所以默认是"读未提交"级别;要读已提交需要 @GlobalLock + select for update 配合全局锁。这是面试里区分"用过"和"看过"的关键点。
追问链:AT 和 XA 区别?——XA 依赖数据库自身的 Prepare 阶段,锁一直持有到二阶段结束;AT 一阶段就提交本地事务、释放本地锁,只在 TC 持有全局锁,所以并发更好,代价是隔离性弱和 undo_log 开销。TCC 的资源预留发生在业务层,与数据库无关,因此能跨库、跨服务、甚至跨第三方支付。
🌳 高级:怎么选型、怎么兜底、怎么治理
面试官问:"一个下单扣库存扣余额发券的链路,你怎么设计事务?"标准展开:
- 先切边界:能放进同一个库同一个事务的绝不用分布式事务。订单和订单明细放一个库;库存和订单如果分库,用"库存预占"作为订单创建的前置步骤而不是硬塞事务。
- 核心资源用 TCC 或"预占+确认":库存/余额/优惠券 Try 冻结,订单创建成功后 Confirm,超时未支付 Cancel。冻结记录要带 TTL 和状态,供对账。
- 非核心用事务消息/本地消息表:积分、通知、统计走异步,下游幂等消费。
- 整个链路用状态机 + 补偿任务:订单状态停留在中间态超过阈值(比如"支付中"超过 15 分钟),由定时任务主动查询下游真实状态并修正。
- 不要迷信事务框架:框架只解决"正常失败路径",对"框架自己挂了、TC 宕机、网络长时间分区"没有答案,所以最终必须有对账。
RocketMQ 事务消息(官方文档流程):生产者发送半消息(Half Message,对消费者不可见)→ 执行本地事务 → 提交或回滚半消息;若 Broker 长时间未收到二次确认,会回查生产者的本地事务状态。回查接口必须能根据业务主键查出真实状态(不能靠内存),这是它比"先发消息再写库"可靠的根本原因。
// 事务消息的关键:本地事务与回查必须以数据库为准
public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
try {
orderService.createOrder((OrderDTO) arg); // 本地事务
return LocalTransactionState.COMMIT_MESSAGE;
} catch (Exception e) {
return LocalTransactionState.ROLLBACK_MESSAGE;
}
}
public LocalTransactionState checkLocalTransaction(MessageExt msg) {
String orderNo = msg.getKeys();
return orderMapper.exists(orderNo)
? LocalTransactionState.COMMIT_MESSAGE
: LocalTransactionState.ROLLBACK_MESSAGE;
}加分项:说清楚"事务消息只保证生产端本地事务与消息发送的原子性,不保证消费端一定成功",消费端还要靠重试 + 死信 + 幂等。再补一句:Saga 的补偿不是"回滚",而是"业务上的反向操作",比如已发送的短信没法撤回,只能发"订单已取消"的新短信,所以补偿要在设计时就想清楚"哪些副作用不可逆"。
四、分布式锁:从 SETNX 到 fencing token
🌱 初级:Redis 分布式锁怎么写
官方文档给出的单实例正确姿势是一条命令:SET resource_name my_random_value NX PX 30000。NX 表示不存在才设置,PX 设置过期毫秒,值必须是全局唯一的随机串。释放时不能直接 DEL,而要用 Lua 脚本"值相等才删除"(新版本 Redis 8.4 起有 DELEX key IFEQ value),否则可能误删别人的锁。
// 加锁
Boolean ok = redis.opsForValue().setIfAbsent(key, token, 30, TimeUnit.SECONDS);
// 解锁:Lua 保证 "比较 + 删除" 的原子性
String lua = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
redis.execute(new DefaultRedisScript<>(lua, Long.class), List.of(key), token);追问链:为什么要设置过期时间?(防止持有者宕机造成死锁)为什么值要随机?(防止误删别人的锁)为什么 setnx 和 expire 不能分开写?(非原子,中间宕机会留下永不过期的锁)
🌿 中级:过期续期、可重入、Redisson 看门狗
核心矛盾:锁超时时间设短了,业务没执行完锁就没了;设长了,宕机后别人要等很久。Redisson 的做法是看门狗(Watchdog):未指定 leaseTime 时,默认锁 30 秒,后台每隔 lockWatchdogTimeout/3(约 10 秒)检查持有线程是否仍存活并续期。指定了 leaseTime 则不会续期。
- 可重入:Redisson 用 Hash 结构存"线程标识 → 重入次数",加锁次数加一,解锁减一,归零才删 key;
- 等待队列/公平:通过 pub/sub 订阅解锁事件而不是自旋,减少无效请求;
- 坑:主从切换丢锁——官方文档明确指出,Redis 复制是异步的,master 写入锁后宕机、副本未同步即被提升,会出现两个客户端同时持有锁。
| 实现 | 可靠性 | 性能 | 说明 |
|---|---|---|---|
| Redis 单实例 / 主从 | 可能丢锁 | 高 | 效率型锁(避免重复干活,偶尔重复无伤大雅) |
| Redlock(N 个独立 master,多数派) | 依赖时钟假设 | 中 | 官方算法:5 个实例,多数(≥3)成功且耗时小于有效期才算拿到 |
| ZooKeeper 临时顺序节点 | 较强(会话+Watch) | 较低 | 断连即释放;监听前一个节点,避免惊群 |
| etcd(lease + revision) | 强(Raft) | 中 | revision 可天然作为 fencing token |
| 数据库唯一键/行锁 | 强(单库) | 低 | 简单场景,靠表约束兜底 |
🌳 高级:锁不是万能的,fencing token 与"效率锁 vs 正确性锁"
这是能明显拉开层级的一题。Martin Kleppmann 的经典分析指出:即使你拿到了锁,也可能因为 GC 停顿、网络延迟、进程被挂起而在锁过期后继续执行写操作,此时另一个客户端已经拿到了锁,出现并发写。他的解法是 fencing token:锁服务每次授予锁时返回一个单调递增的数字,客户端每次写存储时带上这个数字,存储端记住见过的最大 token,拒绝更小的 token。
由此导出面试里最漂亮的回答框架:
- 先问用途:是"效率型"(防止重复执行,偶发重复可接受,比如定时任务防重复触发)还是"正确性型"(一旦并发就资损);
- 效率型:Redis 单实例 + 合理 TTL + 看门狗足够,简单高效;
- 正确性型:不要单靠分布式锁。锁只是减少冲突的手段,最终防线在资源端:数据库乐观锁(version)、条件更新、唯一约束、fencing token;
- Redis 官方文档自己也提醒:应实现 fencing token,且 Redis TTL 用的是墙钟而非单调时钟,时钟跳变可能让锁被多人获取。
-- 资源端做 fencing:只接受更大的 token,天然防止过期客户端的迟到写
UPDATE account
SET balance = balance - 100, last_token = 34
WHERE id = 1001 AND last_token < 34; -- 影响行数为 0 说明是过期请求,直接拒绝追问链:你会用 Redlock 吗?——我会说:Redlock 在多数派机制上比主从更安全,但它依赖"有界网络延迟、有界进程暂停、时钟漂移可控"这些同步假设,且不产生 fencing token,所以对正确性要求高的场景我更倾向 ZooKeeper/etcd 加资源端校验;对效率型场景,单实例足够,Redlock 的运维成本换不到相应收益。
加分项:提到锁粒度(按业务主键而不是全局锁)、锁超时监控、获取失败的降级策略(快速失败 vs 排队),以及"能不用锁就不用锁"——把并发问题转成单线程串行(按 key 路由到同一分区/队列消费)。
五、分布式 ID 与一致性哈希
🌱 初级:分布式 ID 有哪些方案
| 方案 | 优点 | 缺点 |
|---|---|---|
| UUID | 本地生成,无依赖 | 无序、太长,做 MySQL 主键导致页分裂、索引膨胀 |
| 数据库自增(号段模式) | 有序、简单;号段一次取一批,减轻压力 | 依赖数据库;号段用完要等;ID 连续可被猜测 |
| Redis INCR | 快、有序 | 持久化与宕机后回退风险 |
| 雪花算法(Snowflake) | 趋势递增、本地生成、高性能 | 依赖时钟,时钟回拨会重复;需要分配 workerId |
雪花结构(经典 64 位):1 位符号 + 41 位毫秒时间戳(约 69 年)+ 10 位机器标识(5 位数据中心 + 5 位机器)+ 12 位序列号(同一毫秒内 4096 个)。理论单机每秒约 409.6 万个。
🌿 中级:时钟回拨与 workerId 分配怎么解决
- 时钟回拨:小幅回拨(几毫秒)可以等待时钟追上;较大回拨拒绝服务并报警,或预留几位"回拨位",回拨时自增该位继续生成;
- workerId 分配:容器化后 IP 会变,硬编码不可行。常见做法是启动时从注册中心/数据库/Redis 注册获取唯一 workerId 并定期续约;美团 Leaf、百度 UidGenerator 都有各自实现思路;
- 业务上不要把 ID 当"严格全局有序":多机雪花只是趋势递增。
public synchronized long nextId() {
long ts = System.currentTimeMillis();
if (ts < lastTs) { // 时钟回拨
long offset = lastTs - ts;
if (offset <= 5) { sleep(offset); ts = System.currentTimeMillis(); }
else throw new IllegalStateException("clock moved backwards " + offset + "ms");
}
if (ts == lastTs) {
seq = (seq + 1) & 0xFFF;
if (seq == 0) ts = waitNextMillis(lastTs); // 本毫秒序列用完
} else seq = 0;
lastTs = ts;
return ((ts - EPOCH) << 22) | (workerId << 12) | seq;
}🌳 高级:ID 设计的业务考量
- 信息泄露:连续 ID 暴露业务量,对外订单号应加随机/混淆位(如"时间 + 业务类型 + 分库基因 + 随机");
- 分库分表基因法:把用户 ID 的低几位嵌入订单号,使"按用户查订单"和"按订单号查订单"路由到同一分片,避免全库扫描;
- ID 服务高可用:号段模式可在服务端双 buffer 预取,DB 短时间挂了还能撑一段;本地雪花无中心依赖,天然高可用;
- 迁移与容量:41 位时间戳自定义 EPOCH 决定寿命,别用 1970 起点白白浪费。
一致性哈希:为什么需要、虚拟节点解决什么
🌱 普通取模 hash(key) % N 在节点数变化时几乎所有 key 都要重新映射,缓存大面积失效。一致性哈希把节点和 key 都映射到 0~2^32-1 的环上,key 顺时针找到第一个节点;增删节点只影响相邻区间的一小部分 key。
🌿 节点少时分布不均,某节点可能负载偏高;引入虚拟节点(每个物理节点映射成几百个环上的点),使数据更均匀,且宕机节点的负载能分散到多个节点,避免雪崩式转移。🌳 实战里还要考虑"热点 key"(一致性哈希解决不了单 key 热点,要靠本地缓存/多副本/key 打散)、"加权节点"(性能不同的机器分配不同数量的虚拟节点),以及 Redis Cluster 并没有用一致性哈希,而是 16384 个哈希槽,槽迁移更可控。
六、限流:四种算法与分布式限流
🌱 初级:四种算法怎么比
| 算法 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 固定窗口计数 | 每个时间窗口计数,超阈值拒绝 | 实现最简单 | 窗口边界可能出现 2 倍突发(边界临界问题) |
| 滑动窗口 | 把窗口切成小格,随时间滑动统计 | 更平滑,解决临界问题 | 格子越多内存越多 |
| 漏桶 | 请求进桶,以恒定速率流出 | 输出速率绝对平滑,保护下游 | 无法应对合理的突发 |
| 令牌桶 | 按速率放令牌,请求取令牌;桶满则丢弃 | 允许一定突发,同时限制平均速率 | 参数(桶容量、速率)需要调优 |
面试官最爱问:"令牌桶和漏桶区别?"——漏桶强制匀速,令牌桶允许突发(桶里攒够令牌就能一次取出);Guava RateLimiter 是令牌桶(支持预热的 SmoothWarmingUp)。
🌿 中级:分布式限流怎么做
- Redis + Lua:把"读计数、判断、自增、设置过期"放进一个 Lua 脚本,保证原子;滑动窗口可用 ZSET,以时间戳为 score,删除窗口外元素再统计基数;
- 令牌桶在 Redis 中的实现:存
tokens和last_refill_ts两个字段,每次请求根据时间差补充令牌再扣减,全部在脚本中完成; - Sentinel / 网关限流:网关层做粗粒度(按 IP/用户/接口),服务层做细粒度(并发线程数、QPS、慢调用比例熔断)。
-- 固定窗口 + Lua 原子计数示例
local cnt = redis.call('INCR', KEYS[1])
if cnt == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end
if cnt > tonumber(ARGV[2]) then return 0 end
return 1🌳 高级:限流的治理与代价
- 分层:入口(CDN/WAF/网关,按 IP、设备、用户)→ 服务(QPS/并发数)→ 资源(连接池、数据库)。越靠前越便宜;
- 集中式 vs 本地:Redis 限流引入一次网络往返,且 Redis 成为新的依赖。热点接口可采用"本地令牌桶 + 定期从中心批量领取额度"的两级方案,减少 Redis 压力;
- 被限流后的体验:返回明确的 429 + Retry-After,前端退避重试;关键业务用排队而不是直接拒绝(如秒杀的排队页);
- 与熔断降级的关系:限流保护自己(入口),熔断保护调用方(出口),降级保证核心功能;容量规划要有"压测得出的水位",阈值不是拍脑袋,通常取压测极限的 60%–70%;
- 故障复盘:常见事故是限流规则误配为 0 或被全量下发;规则变更要灰度、可回滚、可观测。
七、幂等与接口防重
🌱 初级:什么是幂等,哪些场景需要
幂等:同一个操作执行一次和执行多次,对系统状态的影响相同。查询天然幂等,更新"设置为某值"天然幂等,"加 100 元"这类增量操作不幂等。需要幂等的典型场景:用户重复点击、网络超时重试、MQ 重复投递、定时任务重复触发、支付回调重复通知。
🌿 中级:几种落地手段
| 手段 | 做法 | 适用 |
|---|---|---|
| 唯一索引 | 业务流水号加唯一键,重复插入会报错,捕获后当成功 | 最可靠,首选 |
| 状态机 + 条件更新 | update order set status=PAID where id=? and status=UNPAID | 状态流转类 |
| 乐观锁版本号 | where version=? | 并发修改 |
| 去重表 / Redis SETNX | 处理前先写 (消费者, msgId),写入成功才处理 | MQ 消费幂等 |
| Token 机制 | 先申请一次性 token,提交时校验并删除(Lua 原子) | 表单重复提交 |
// MQ 消费幂等:去重表 + 业务在同一个本地事务里,避免 "去重记录写了但业务没成功"
@Transactional
public void onMessage(String msgId, PayEvent e) {
try {
dedupMapper.insert(msgId); // msg_id 唯一键
} catch (DuplicateKeyException dup) {
return; // 已处理过,直接确认
}
orderService.markPaid(e.getOrderNo()); // 内部还是条件更新,双保险
}接口防重:与幂等的区别
防重更偏向"短时间内拦截重复提交"(用户连点、按钮抖动),常用手段:前端按钮置灰 + 后端"用户 + 接口 + 参数摘要"作为 key 的短 TTL 锁(Redis SET key NX PX 3000)。它不是幂等的替代品:TTL 过后重复请求还是能进来,所以业务层仍要有唯一键/状态机做最终幂等。
🌳 高级:幂等设计的全链路视角
- 幂等键怎么生成:由调用方在"业务意图"产生时生成(如下单页加载时申请请求号),而不是在重试时重新生成;网关层可统一支持
Idempotency-Key头,服务端缓存"key → 响应结果",重复请求直接返回首次结果; - 幂等的边界:调用第三方支付时,用我方订单号作为对方的请求流水,对方超时未知时先查询再决定是否重试,而不是盲目重发;
- "至少一次 + 幂等 = 效果上恰好一次":分布式系统里"恰好一次投递"不可实现,靠幂等达成效果;
- 处理中状态:第一次请求还没处理完,第二次到达,应返回"处理中"而不是重复执行——需要在幂等表里记录 PROCESSING/SUCCESS/FAIL;
- 去重表膨胀:按时间分区、定期清理(保留期要大于最大重试窗口)。
八、Raft/Paxos 基本认知与服务注册发现
🌱 初级:共识算法解决什么问题
多个副本对"某个值/日志序列"达成一致,即使部分节点宕机或消息丢失。Paxos 是最早、最经典的共识算法,但理解和实现都很难;Raft(论文《In Search of an Understandable Consensus Algorithm》)把问题拆成三个子问题以求"可理解性":领导者选举、日志复制、安全性。etcd、Consul、Nacos(持久实例)、RocketMQ DLedger 等都基于 Raft。
🌿 中级:Raft 的关键机制
- 角色:Leader、Follower、Candidate。所有写请求走 Leader;
- 选举:Follower 在随机化的选举超时内没收到心跳就转 Candidate,任期号 term 加一,向其他节点请求投票,获得多数票成为 Leader。随机超时是为了避免多个候选人同时竞选导致选票分裂;
- 日志复制:Leader 把命令追加到日志,并发给 Follower,多数节点写入后才算已提交(committed),再应用到状态机并回复客户端;
- 安全性:只有日志"足够新"的节点才能当选 Leader,保证已提交的日志不会丢失;
- 容错:3 节点容忍 1 个故障,5 节点容忍 2 个,节点数取奇数;写延迟由多数派中最慢的那个决定。
追问链:脑裂怎么处理?——Raft 靠多数派和 term 避免双主:旧 Leader 被分区隔离后收不到多数确认,无法提交新日志;分区恢复后见到更高 term 自动退位。
🌳 高级:共识在架构中的位置
- 只把共识用在"小而关键"的数据上:元数据、配置、选主、分布式锁;业务大数据不要走共识写路径(吞吐受限于 Leader 和磁盘 fsync);
- 读怎么保证线性一致:读 Leader 需确认自己仍是 Leader(ReadIndex/租约读),否则可能读到旧 Leader 上的脏数据;Follower 读要接受"可能旧";
- 跨机房:多数派意味着 3 机房部署时任意一个机房挂掉仍可用,两机房部署则没有真正的高可用;写延迟等于机房间往返延迟;
- Paxos/Raft 之外:ZooKeeper 用 ZAB,思想类似,都是"主备 + 多数派"。
服务注册与发现
🌱 服务启动时向注册中心注册自己的地址,消费者从注册中心拉取实例列表并订阅变化,配合客户端负载均衡(轮询、加权、一致性哈希、最少活跃)。常见:Nacos、Eureka、Consul、ZooKeeper、etcd,或 K8s 的 Service + DNS。
🌿 健康检查:临时实例靠客户端心跳(Nacos 默认 5 秒一次,15 秒未收到标记不健康,30 秒剔除,具体以你使用版本的文档为准),持久实例靠服务端主动探测。消费者要有本地缓存,注册中心挂了也能用旧列表继续调用。
🌳 治理要点:注册中心是AP 优先更合适(可用性优先,返回略旧的列表比全部不可用好);优雅上下线(先摘除注册再停止接收流量,等待在途请求完成);自我保护(大面积心跳丢失时不再剔除,防止网络抖动导致全量下线的"雪崩");灰度/金丝雀基于元数据路由。
九、最终一致性与对账:分布式的最后一道防线
🌱 初级:为什么需要对账
再完善的事务方案也可能出现极端情况:消息丢失、补偿失败、第三方回调没来、人工改库。对账就是定期比较两个系统对同一批业务的记录,发现差异并修复。比如支付:我方订单表 vs 支付渠道账单文件,逐笔比对状态与金额。
🌿 中级:对账怎么做
- 准实时:对中间态订单(如"支付中"超过 N 分钟)主动调用渠道查询接口,修正状态;
- T+1 批量:次日拉取渠道对账文件,与本地流水按订单号 join,分类为:长款(渠道有我们无)、短款(我们有渠道无)、金额不符、状态不符;
- 差异处理:能自动修的自动修(例如补单、发起退款),需要人工的进工单,全程留痕。
-- 简化的对账 SQL:本地已支付、渠道无记录(短款) / 渠道成功、本地未支付(长款)
SELECT o.order_no, 'SHORT' AS diff_type
FROM t_order o LEFT JOIN t_channel_bill b ON o.order_no = b.order_no
WHERE o.status = 'PAID' AND o.pay_time >= :d AND o.pay_time < :d + INTERVAL 1 DAY AND b.order_no IS NULL
UNION ALL
SELECT b.order_no, 'LONG'
FROM t_channel_bill b LEFT JOIN t_order o ON o.order_no = b.order_no AND o.status = 'PAID'
WHERE b.bill_date = :d AND b.status = 'SUCCESS' AND o.order_no IS NULL;🌳 高级:把对账做成平台能力
- 统一模型:把"我方记录 / 对方记录 / 差异 / 处理状态"抽象成通用对账引擎,新业务只配置数据源和比对规则;
- 度量:差异率、自动修复率、平均修复时长、遗留差异数,接入告警(差异率突增往往是上游事故的最早信号);
- 幂等与可重跑:对账任务可以随时重跑而不产生副作用;修复动作同样必须幂等;
- 与业务监控联动:核心链路"下单数 ≈ 支付数 + 关闭数 + 处理中数"的守恒公式,用来做实时大盘上的"资金守恒告警"。
十、高频追问速查表
| 问题 | 一句话答案 | 出处/延伸 |
|---|---|---|
| CAP 里的 P 能放弃吗 | 不能,分区一定会发生,真实选择是分区时选 C 还是 A | 第二节 |
| 2PC 的问题 | 同步阻塞、协调者单点、二阶段部分失败不一致 | 第三节 |
| TCC 三大异常 | 空回滚、悬挂、幂等,用事务日志表唯一键解决 | 第三节代码 |
| Seata AT 隔离级别 | 默认读未提交,需 GlobalLock/for update 才读已提交 | Seata 官方文档 |
| 事务消息如何防丢 | 半消息 + 本地事务 + 回查,回查以数据库为准 | RocketMQ 官方文档 |
| Redis 锁为何要 Lua 解锁 | 比较值与删除必须原子,防误删别人的锁 | Redis 官方文档 |
| Redisson 看门狗 | 未指定 leaseTime 时默认 30 秒,每约 10 秒续期 | 第四节 |
| Redlock 靠谱吗 | 依赖同步假设,无 fencing token;正确性场景在资源端兜底 | Kleppmann 文章 |
| 雪花时钟回拨 | 小回拨等待,大回拨拒绝并告警,或预留回拨位 | 第五节 |
| 一致性哈希为何用虚拟节点 | 均匀分布、宕机负载分散 | 第五节 |
| 令牌桶 vs 漏桶 | 令牌桶允许突发,漏桶强制匀速 | 第六节 |
| 幂等最可靠手段 | 唯一索引 + 状态机条件更新 | 第七节 |
| Raft 为何奇数节点 | 多数派容错,偶数节点不增加容错能力 | 第八节 |
| 对账发现的差异怎么办 | 能自动修则幂等修复,否则进工单留痕 | 第九节 |
一句话备忘:先分清"强一致还是最终一致",核心资源用预留(TCC/条件更新),外围用消息 + 幂等,锁只是减少冲突而不是正确性保证(资源端用 fencing/版本号兜底),所有"最终"都要有对账,否则最终不来。
写完这篇我最大的感慨是:分布式没有银弹,每个方案都是拿一种不确定性换另一种。面试时别急着报方案名,先讲清楚场景约束和取舍,再讲"出错了怎么办",你就已经比大多数人往前站了一步。祝你面得顺利,也祝线上少报警。
参考资料
- Redis 官方:Distributed Locks with Redis:单实例 SET NX PX 加锁、Lua/DELEX 安全解锁、Redlock 算法步骤与官方对 fencing token、时钟的提醒。
- Martin Kleppmann:How to do distributed locking:提出 fencing token,并分析 Redlock 对同步假设的依赖。
- Apache Seata 官方文档:What is Seata:AT、TCC、Saga、XA 四种事务模式的定位与说明。
- Apache RocketMQ 官方文档:事务消息:半消息、本地事务、提交/回滚与回查机制。
- Raft 论文《In Search of an Understandable Consensus Algorithm》:领导者选举、日志复制、安全性三个子问题。
- JavaGuide:2026 Java 后端面试通关计划:分布式 ID/锁/事务、限流、幂等等在面试中的定位与准备方法。
- Google SRE Book:Monitoring Distributed Systems:四个黄金指标(延迟、流量、错误、饱和度),可用于限流阈值与对账告警的度量思路。
一起聊聊