面试复习资料 列表全部文章
面试复习资料

面试复习:分布式(CAP/BASE、分布式事务、分布式锁、ID、限流、幂等、Raft)初中高级

把分布式面试常考的 CAP/BASE、2PC/TCC/Saga/本地消息表/Seata、分布式锁与 fencing、分布式 ID、一致性哈希、限流、幂等、Raft、注册发现、对账,按初中高三层整理成能背能讲的要点和追问链。

更多

分布式面试有个很有意思的现象:面试官问的都是"你们用的什么方案",但真正拉开差距的是下一句——"它在什么情况下会出错,你怎么兜底"。我自己带过几次面试,也被问过几次,发现同一个题,初级答"是什么",中级答"为什么这么设计",高级答"出了事怎么办、代价是什么"。于是这篇按三层来组织:

  • 🌱 初级(0–3 年):概念、API、基本原理,"是什么/怎么用";
  • 🌿 中级(3–5 年):底层机制、常见坑,"为什么/怎么选";
  • 🌳 高级(5 年+,对标后端约 8 年):架构取舍、线上治理、量化与对账,"怎么落地/出问题怎么办"。

每个专题都有:标准回答要点(能背)、追问链(面试官会怎么继续戳你)、加分项。技术栈不局限于某一家公司,按通用岗位考点写。文末有速查表和参考资料,都是我真实打开读过的。

一、知识地图:分布式面试到底在考什么

先看全景。所有分布式问题,本质上都是在回答同一个问题:多台机器之间没有共享内存、没有全局时钟、网络随时会丢和延迟,怎么让"一件事"要么都成、要么都不成、要么最终成。下图把常考主题按"一致性/协调/流量治理/可靠交付"四块归了类:

分布式:不可靠网络下的协作 一致性 CAP / BASE 2PC / 3PC / XA TCC / Saga / AT 本地消息表 / 事务消息 Raft / Paxos 对账与补偿 协调 分布式锁(Redis/ZK) fencing token 分布式 ID(雪花) 服务注册与发现 一致性哈希 Leader 选举 流量治理 固定窗口 / 滑动窗口 漏桶 / 令牌桶 熔断 / 降级 / 隔离 超时 / 重试 / 退避 网关与单机限流 热点保护 可靠交付 接口幂等(唯一键) 接口防重(Token/锁) 至少一次 + 去重 重试与补偿任务 状态机与版本号 T+1 对账 口诀:先讲清语义(一致性级别),再讲手段(锁/事务/幂等),最后讲兜底(补偿/对账)

二、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/单点串行),"展示"走最终一致;主从延迟破坏读己之写,可以"写后一定时间内读主库"或"带版本号读,读到旧版本就回主库"。

🌳 高级:如何用一致性语义反推架构

高级岗位常问:"你们的核心链路哪里是强一致、哪里是最终一致?依据是什么?"我的回答框架是按"错了的代价"分级:

  1. 资金/库存扣减:错一次就是资损或超卖,必须在单个事务或单个分片内串行完成,靠数据库条件更新(update ... set stock=stock-1 where stock>=1)保证;
  2. 订单状态流转:用状态机 + 乐观锁(版本号/状态条件),非法流转直接拒绝;
  3. 下游通知类(积分、消息、统计、搜索):至少一次投递 + 幂等消费,允许秒级到分钟级延迟;
  4. 兜底:所有最终一致链路都必须有"对账 + 补偿",否则"最终"可能永远不来(后面专题详述)。

加分项:把"一致性窗口"量化成 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 必须处理的三个异常(面试高频):

  1. 空回滚:Try 还没执行(超时/丢包),Cancel 先到了。Cancel 必须识别"没有对应 Try 记录"并直接返回成功,同时记录这次 Cancel,防止后面迟到的 Try 生效;
  2. 悬挂:Cancel 已经执行完,迟到的 Try 才到,如果 Try 执行了就会永远冻结资源。解决办法:Try 前检查事务日志表里是否已有该全局事务的 Cancel 记录,有就拒绝;
  3. 幂等: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 的资源预留发生在业务层,与数据库无关,因此能跨库、跨服务、甚至跨第三方支付。

🌳 高级:怎么选型、怎么兜底、怎么治理

面试官问:"一个下单扣库存扣余额发券的链路,你怎么设计事务?"标准展开:

  1. 先切边界:能放进同一个库同一个事务的绝不用分布式事务。订单和订单明细放一个库;库存和订单如果分库,用"库存预占"作为订单创建的前置步骤而不是硬塞事务。
  2. 核心资源用 TCC 或"预占+确认":库存/余额/优惠券 Try 冻结,订单创建成功后 Confirm,超时未支付 Cancel。冻结记录要带 TTL 和状态,供对账。
  3. 非核心用事务消息/本地消息表:积分、通知、统计走异步,下游幂等消费。
  4. 整个链路用状态机 + 补偿任务:订单状态停留在中间态超过阈值(比如"支付中"超过 15 分钟),由定时任务主动查询下游真实状态并修正。
  5. 不要迷信事务框架:框架只解决"正常失败路径",对"框架自己挂了、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。

由此导出面试里最漂亮的回答框架:

  1. 先问用途:是"效率型"(防止重复执行,偶发重复可接受,比如定时任务防重复触发)还是"正确性型"(一旦并发就资损);
  2. 效率型:Redis 单实例 + 合理 TTL + 看门狗足够,简单高效;
  3. 正确性型:不要单靠分布式锁。锁只是减少冲突的手段,最终防线在资源端:数据库乐观锁(version)、条件更新、唯一约束、fencing token;
  4. 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 设计的业务考量

  1. 信息泄露:连续 ID 暴露业务量,对外订单号应加随机/混淆位(如"时间 + 业务类型 + 分库基因 + 随机");
  2. 分库分表基因法:把用户 ID 的低几位嵌入订单号,使"按用户查订单"和"按订单号查订单"路由到同一分片,避免全库扫描;
  3. ID 服务高可用:号段模式可在服务端双 buffer 预取,DB 短时间挂了还能撑一段;本地雪花无中心依赖,天然高可用;
  4. 迁移与容量:41 位时间戳自定义 EPOCH 决定寿命,别用 1970 起点白白浪费。

一致性哈希:为什么需要、虚拟节点解决什么

🌱 普通取模 hash(key) % N 在节点数变化时几乎所有 key 都要重新映射,缓存大面积失效。一致性哈希把节点和 key 都映射到 0~2^32-1 的环上,key 顺时针找到第一个节点;增删节点只影响相邻区间的一小部分 key。

🌿 节点少时分布不均,某节点可能负载偏高;引入虚拟节点(每个物理节点映射成几百个环上的点),使数据更均匀,且宕机节点的负载能分散到多个节点,避免雪崩式转移。🌳 实战里还要考虑"热点 key"(一致性哈希解决不了单 key 热点,要靠本地缓存/多副本/key 打散)、"加权节点"(性能不同的机器分配不同数量的虚拟节点),以及 Redis Cluster 并没有用一致性哈希,而是 16384 个哈希槽,槽迁移更可控。

A#1 B#1 C#1 A#2 key1 哈希环 要点 ① key 顺时针找第一个节点(key1 落到 A#1) ② 新增/下线节点,只影响相邻一段区间 ③ 虚拟节点(A#1、A#2…)让分布更均匀 ④ 取模法:N 变化 → 几乎全部 key 重新映射 ⑤ Redis Cluster:16384 个槽,非一致性哈希 单 key 热点不靠它解决,靠本地缓存/副本/打散

六、限流:四种算法与分布式限流

🌱 初级:四种算法怎么比

算法原理优点缺点
固定窗口计数每个时间窗口计数,超阈值拒绝实现最简单窗口边界可能出现 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

🌳 高级:限流的治理与代价

  1. 分层:入口(CDN/WAF/网关,按 IP、设备、用户)→ 服务(QPS/并发数)→ 资源(连接池、数据库)。越靠前越便宜;
  2. 集中式 vs 本地:Redis 限流引入一次网络往返,且 Redis 成为新的依赖。热点接口可采用"本地令牌桶 + 定期从中心批量领取额度"的两级方案,减少 Redis 压力;
  3. 被限流后的体验:返回明确的 429 + Retry-After,前端退避重试;关键业务用排队而不是直接拒绝(如秒杀的排队页);
  4. 与熔断降级的关系:限流保护自己(入口),熔断保护调用方(出口),降级保证核心功能;容量规划要有"压测得出的水位",阈值不是拍脑袋,通常取压测极限的 60%–70%;
  5. 故障复盘:常见事故是限流规则误配为 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 过后重复请求还是能进来,所以业务层仍要有唯一键/状态机做最终幂等。

🌳 高级:幂等设计的全链路视角

  1. 幂等键怎么生成:由调用方在"业务意图"产生时生成(如下单页加载时申请请求号),而不是在重试时重新生成;网关层可统一支持 Idempotency-Key 头,服务端缓存"key → 响应结果",重复请求直接返回首次结果;
  2. 幂等的边界:调用第三方支付时,用我方订单号作为对方的请求流水,对方超时未知时先查询再决定是否重试,而不是盲目重发;
  3. "至少一次 + 幂等 = 效果上恰好一次":分布式系统里"恰好一次投递"不可实现,靠幂等达成效果;
  4. 处理中状态:第一次请求还没处理完,第二次到达,应返回"处理中"而不是重复执行——需要在幂等表里记录 PROCESSING/SUCCESS/FAIL;
  5. 去重表膨胀:按时间分区、定期清理(保留期要大于最大重试窗口)。

八、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 自动退位。

🌳 高级:共识在架构中的位置

  1. 只把共识用在"小而关键"的数据上:元数据、配置、选主、分布式锁;业务大数据不要走共识写路径(吞吐受限于 Leader 和磁盘 fsync);
  2. 读怎么保证线性一致:读 Leader 需确认自己仍是 Leader(ReadIndex/租约读),否则可能读到旧 Leader 上的脏数据;Follower 读要接受"可能旧";
  3. 跨机房:多数派意味着 3 机房部署时任意一个机房挂掉仍可用,两机房部署则没有真正的高可用;写延迟等于机房间往返延迟;
  4. Paxos/Raft 之外:ZooKeeper 用 ZAB,思想类似,都是"主备 + 多数派"。

服务注册与发现

🌱 服务启动时向注册中心注册自己的地址,消费者从注册中心拉取实例列表并订阅变化,配合客户端负载均衡(轮询、加权、一致性哈希、最少活跃)。常见:Nacos、Eureka、Consul、ZooKeeper、etcd,或 K8s 的 Service + DNS。

🌿 健康检查:临时实例靠客户端心跳(Nacos 默认 5 秒一次,15 秒未收到标记不健康,30 秒剔除,具体以你使用版本的文档为准),持久实例靠服务端主动探测。消费者要有本地缓存,注册中心挂了也能用旧列表继续调用。

🌳 治理要点:注册中心是AP 优先更合适(可用性优先,返回略旧的列表比全部不可用好);优雅上下线(先摘除注册再停止接收流量,等待在途请求完成);自我保护(大面积心跳丢失时不再剔除,防止网络抖动导致全量下线的"雪崩");灰度/金丝雀基于元数据路由。

九、最终一致性与对账:分布式的最后一道防线

🌱 初级:为什么需要对账

再完善的事务方案也可能出现极端情况:消息丢失、补偿失败、第三方回调没来、人工改库。对账就是定期比较两个系统对同一批业务的记录,发现差异并修复。比如支付:我方订单表 vs 支付渠道账单文件,逐笔比对状态与金额。

🌿 中级:对账怎么做

  1. 准实时:对中间态订单(如"支付中"超过 N 分钟)主动调用渠道查询接口,修正状态;
  2. T+1 批量:次日拉取渠道对账文件,与本地流水按订单号 join,分类为:长款(渠道有我们无)、短款(我们有渠道无)、金额不符、状态不符;
  3. 差异处理:能自动修的自动修(例如补单、发起退款),需要人工的进工单,全程留痕。
-- 简化的对账 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/版本号兜底),所有"最终"都要有对账,否则最终不来。

写完这篇我最大的感慨是:分布式没有银弹,每个方案都是拿一种不确定性换另一种。面试时别急着报方案名,先讲清楚场景约束和取舍,再讲"出错了怎么办",你就已经比大多数人往前站了一步。祝你面得顺利,也祝线上少报警。

参考资料

— 感谢读到这里 —发现错误,或想补充?告诉我 →
TURN KNOWLEDGE INTO SOMETHING REAL

有想法,也想把它做出来?

从技术方案到上线交付,聊聊你的下一个项目。

聊聊需求

一起聊聊

搜索文章

输入关键词,按标题和摘要即时筛选。

浏览分类文章归档

阅读设置

字号
行宽
专注模式

专注模式会隐藏页头与侧栏,只留正文。设置保存在当前浏览器。

稍后阅读

保存在当前浏览器,留给下一次安静阅读。

总浏览 -- 次 · 总访客 -- 人 · 今日浏览 -- 次 · 今日访客 -- 人