第二部分:分布式系统设计(第 16~30 题)
本批 10 题进入「第二阶段:高级工程师能力」,聚焦跨服务数据一致、分布式协调与分布式基础组件选型。每题需给出:方案对比 → 适用边界 → 故障兜底 → 落地代码。
第 16 题:跨账户转账——TCC vs Saga vs 消息事务 分布式事务
userId % 32 分 32 库 × 64 表,A、B 账户常落在不同库甚至不同 Region。强约束:①资金守恒(A 减 = B 加,不可多/少一分);②不能重复记账;③跨行最终一致、到账 P99 < 2s;④日终对账差错率 = 0。此时"传统本地事务"已失效——跨库无法用单机 ACID。- 1. 如何保证资金不超扣、不丢失?2. TCC / Saga / 本地消息表(事务消息) 分别怎么落地?3. 转出成功、转入失败如何补救?4. 消息重复 / 丢失怎么办?5. 热点账户(大 V余额频繁变动)怎么处理?6. 跨行跨系统怎么保证一致?7. 日终对账怎么做?
第一步:分析问题。转账不是"两个 UPDATE",而是跨库、跨服务、可部分失败的分布式事务。目标不是强一致 ACID,而是资金守恒 + 可补偿 + 最终一致。
第二步:核心挑战。跨库无法本地事务;网络/服务随时失败需可补偿;重复请求需幂等;热点账户行锁竞争;对账需覆盖所有边缘。
第三步:整体架构。采用"转账编排服务 + 账户服务(TCC 参与者) + 事务消息":Try 阶段冻结双方资金(同库本地事务),Confirm 真正扣加,Cancel 解冻;对跨行/异步场景用 RocketMQ 事务消息 + 对账兜底。
第四步:技术选型。同库内用 TCC(Seata);跨系统/跨行用本地消息表 + 事务消息 + 幂等消费;Saga 用于长流程(如"转账→购汇→汇款"多步可补偿链路)。
第五步:一致性。冻结(预留资源)→确认/补偿;消息表保证本地事务与发消息原子;消费者幂等保证 at-least-once 不重复记账。
第六步:高可用。Confirm/Cancel 失败重试(指数退避 + 最大次数);终有"对账 + 人工挂账"兜底;热点账户用明细流水 + 余额异步汇总降锁。
第七步:性能优化。Try 阶段只冻结不入账,锁持有极短;热点账户拆子账户/按金额分段;消费端批量确认。
瓶颈:Confirm/Cancel 重试风暴、热点账户行锁、跨 Region 网络延迟。
三种方案对比:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| TCC | 强预留、一致快、无长锁 | 侵入业务、需写 Try/Confirm/Cancel、空回滚/防悬挂复杂 | 同域内核心资金、低延迟 |
| Saga | 长流程、松耦合、易编排 | 无隔离性、补偿逻辑复杂、中间态可见 | 跨多服务长事务(购汇汇款) |
| 本地消息表/事务消息 | 对业务侵入小、天然异步削峰 | 只保证最终一致、依赖消费者幂等 | 跨系统/跨行通知类 |
为什么当前场景选 TCC + 消息兜底?核心同库转账要"资金守恒且快"→ TCC;跨行通知要"解耦异步"→ 事务消息。两者用对账收口,构成"强一致为主、最终一致兜底"的双保险。
为什么不直接 2PC?2PC 有全局锁、协调者单点、同步阻塞,银行峰值 8000 TPS 下锁持有过长,性能与可用性都不可接受。
① TCC 冻结(账户服务):
// 同库:业务余额表 + 冻结流水表(tx_id 唯一索引防重) public boolean tryFreeze(String txId, Long acctId, BigDecimal amt) { int n = mapper.freeze(txId, acctId, amt); // UPDATE ... SET frozen=frozen+? WHERE acct=? AND (balance-frozen)>=? return n == 1; // 余额不足返回 false,触发 Cancel } public boolean confirm(BusinessActionContext ctx) { return mapper.unfreezeAndApply(ctx.getTxId()) == 1; } public boolean cancel(BusinessActionContext ctx) { return mapper.unfreeze(ctx.getTxId()) == 1; // 幂等:未冻结则直接成功 }
② 事务消息发送(跨行分支):
TransactionSendResult r = producer.sendMessageInTransaction(
new Message("TRANSFER_OUT", payload), txId); // 本地事务=写转账单(status=处理中)+Try冻结;回查查状态
追问 1:Confirm 一直失败怎么办?标准回答:指数退避重试 + 最大次数;超过阈值转入"待人工挂账",对账任务每小时捞出不终态订单强制补偿,保证资金不丢。
追问 2:空回滚与防悬挂是什么?标准回答:Try 未执行但收到 Cancel = 空回滚,需记录"已回滚"标记直接成功;网络乱序 Cancel 先于 Try 到达 = 悬挂,Try 执行前要查是否已回滚,已回滚则拒绝执行。
追问 3:消息重复消费导致重复加钱?标准回答:消费端按 msgId + 业务单号 写幂等表(唯一索引),重复消息 INSERT IGNORE 直接跳过。
追问 4:热点账户(大 V 余额每秒上千笔变动)?标准回答:拆多个子账户/按金额分段,写时选一个子账户加锁,读时汇总;或用"流水记账 + 余额异步汇总"彻底去掉行锁。
追问 5:跨行转账对方系统长期不可用?标准回答:本地标记"转出成功待清算",入对账差异表,对方恢复后重发;用户侧展示"处理中",SLA 内未完成自动原路退回。
- ❌ 用
@Transactional包住两个 RPC 调用——跨库/跨服务不生效,且长事务锁库。 - ❌ 先扣 A 再调 B,B 失败不补偿——造成资金丢失。
- ❌ Cancel 不做幂等——重试时重复解冻导致超发。
第 17 题:全链路幂等性体系设计 幂等
- 1. 幂等要在哪几层做?2. 如何防止前端重复提交?3. 网关重试如何识别同一请求?4. MQ 重复消费怎么处理?5. 状态机如何防重?6. 幂等 token 怎么设计才安全?7. 幂等表怎么不成为新瓶颈?
第一步:分析问题。幂等不是单点能力,是分层防御体系:越靠前拦截成本越低。
第二步:核心挑战。请求唯一标识如何生成与传递;重复判定存储的原子性与性能;不同业务对"幂等"语义不同(下单按单号、支付按流水号)。
第三步:整体架构。四层:①前端 防重 token;②网关按 X-Request-Id 去重(短 TTL);③服务层业务唯一键 + 唯一索引;④MQ 消费层 msgId 幂等表。最权威的是"数据库唯一约束 + 状态机"。
第四步:技术选型。Redis SET NX 做 token 校验(快、可过期);MySQL 唯一索引做最终裁判(强);本地状态机做业务层防重。
第五步:一致性。token 与订单号同源;消费幂等表与业务表同库同事务,保证"处理过"状态持久。
第六步:高可用。幂等存储(Redis/DB)本身需集群;唯一索引冲突即视为重复,不抛错给用户。
第七步:性能优化。幂等判断只在写入口做;读接口天然幂等不处理;幂等表按时间分片/定期清理。
瓶颈:唯一索引写竞争、幂等表膨胀、token 服务成为新单点。
为什么必须分层?只靠 DB 唯一索引,重复请求已打到库、浪费连接;前端+网关先挡掉 90% 重复,DB 只做"最终裁判"。
为什么用唯一索引而非先查后插?SELECT 再 INSERT 有并发竞态(两个线程都查到不存在然后都插入),唯一索引由数据库保证原子性,DuplicateKeyException 即重复。
状态机防重:支付回调重复到达,只有 待支付→已支付 成功,第二次 已支付→已支付 被拒绝,天然幂等,且不依赖外部存储。
权衡:Redis token 快但可能丢(需 DB 唯一索引兜底);纯 DB 唯一索引可靠但每次写竞争。生产是"Redis 挡量 + DB 兜底"。
① 下单幂等(唯一索引兜底):
public OrderResult create(OrderCmd cmd) { // 1. token 一次性校验 if (!tokenSvc.consume(cmd.token(), cmd.userId())) return OrderResult.repeat(); // 2. 唯一索引最终裁判 try { orderMapper.insert(cmd.toOrder()); } catch (DuplicateKeyException e) { return OrderResult.exist(cmd.orderNo()); } return OrderResult.ok(cmd.orderNo()); }
② MQ 消费幂等:
@Transactional public void onConsume(Message msg) { if (idempotentMapper.insertIgnore(msg.getMsgId()) == 0) return; // 已处理 bizHandler.handle(msg); // 与幂等表同库同事务 }
追问 1:token 存在 Redis,Redis 挂了怎么办?标准回答:降级为"DB 唯一索引 + 业务单号"兜底;token 仅作体验优化,不决定正确性。
追问 2:同一笔支付两个不同 msgId 重复?标准回答:幂等键不能只用 msgId,要用"业务维度唯一键"(如 支付流水号),msgId 只防 MQ 层重复。
追问 3:幂等表无限增长?标准回答:按天分表 + 只保留 7~30 天;或落 TiDB/按 msgId 哈希分片;热数据在 Redis。
追问 4:状态机如何设计?标准回答:枚举定义合法跃迁表,所有状态变更走统一 transfer(from,to) 方法,非法跃迁抛异常,配合乐观锁防并发。
追问 5:GET 接口要幂等吗?标准回答:只读 GET 天然幂等;但"GET 触发副作用"(如计数+1)要当作写处理,加幂等保护。
- ❌ 先
SELECT查"是否存在"再决定插——并发竞态必重复。 - ❌ 只前端 disabled 按钮——刷新/重发/恶意请求绕过。
- ❌ 幂等键用 msgId 却忽略业务单号——换消息重发仍重复处理。
第 18 题:分布式锁选型与 Redlock 争议 分布式锁
- 1. Redis 锁怎么实现才安全?2. 什么是看门狗/锁续期?3. Redlock 为什么被质疑?4. 锁超时但业务没跑完怎么办?5. 资金级互斥该用 Redis 还是 ZK?6. 锁误释放怎么防?7. 锁和事务谁先谁后?
第一步:分析问题。分布式锁本质是在分布式系统里找一个"全局互斥点"。关键属性:互斥、死锁自由、容错、谁加锁谁释放。
第二步:核心挑战。网络延迟/GC 停顿导致"锁过期但业务还在跑";Redis 主从切换导致锁丢失;误释放他人锁;红锁的 fencing 缺失。
第三步:整体架构。按可靠性分级:①协调类/可重入(互斥即可)→ Redisson(SET NX + 看门狗续期 + Lua 释放);②强一致要求 → ZooKeeper/etcd 临时节点(顺序+watch,带 fencing token);③资金级互斥 → 不用锁,用"唯一约束/状态机/TCC"。
第四步:技术选型。Redisson(基于 Redis,性能高,适合非资金互斥);Curator/ZK(强一致,慢);etcd(lease+watch,云原生友好)。
第五步:一致性。释放锁用 Lua 校验 value(唯一 token)再删,防误释放;Redlock 多节点多数派获取,但 Martin Kleppmann 指出仍需 fencing token 防 GC 致旧锁复活。
第六步:高可用。锁服务本身集群;业务侧"获取锁失败即快速失败或排队",不让请求堆积;锁内业务必须可重入安全。
第七步:性能优化。锁粒度尽量小(行级而非表级);锁外不做 IO;能用乐观锁/唯一索引替代就不加分布式锁。
瓶颈:Redlock 多节点 RTT、ZK 写性能、锁粒度过大导致串行。
为什么 Redlock 有争议?Martin Kleppmann 指出:即使多数派拿到锁,若持锁线程发生长时间 GC 停顿超过锁租期,锁过期,另一线程拿到锁,出现"双持锁"。Redis 无单调 fencing token,无法让旧锁操作失效。结论:Redlock 适合"互斥即可"场景,不适合"互斥正确性攸关资金"场景。
为什么用看门狗?固定 EX 过期,业务偶尔超时就被误释放。看门狗在业务未结束前自动续期(Redisson 默认 1/3 租期续一次),业务完成才释放。
为什么 Lua 释放?GET 再 DEL 有窗口:A 查到自己锁→A 锁过期→B 拿到锁→A 删了 B 的锁。Lua 原子校验 value 再删,杜绝误释放。
权衡:Redis 锁快但弱一致;ZK 锁慢但强一致带 fencing;etcd 介于其间且 K8s 原生。资金场景干脆不用锁,改用约束/状态机。
① Redisson 安全使用:
private final RedissonClient redisson; public void safeRun() { RLock lock = redisson.getLock("job:report:day"); if (!lock.tryLock(0, 30, TimeUnit.SECONDS)) return; // 抢不到即退出 try { doReport(); } // 看门狗后台自动续期 finally { lock.unlock(); } // 校验持有者后释放 }
② ZK 强一致锁(带 fencing,Curator):
InterProcessMutex zkLock = new InterProcessMutex(client, "/locks/settle"); zkLock.acquire(); try { Long fence = zkLock.getFence(); settle(fence); } finally { zkLock.release(); }
追问 1:Redlock 真的一无是处吗?标准回答:不是。它适合"不互斥就多跑一次也没事"的场景(如幂等任务)。但金融互斥请用 ZK/etcd 或放弃锁改用约束。
追问 2:业务执行远超锁租期且看门狗也失效(如 STW 60s)?标准回答:看门狗依赖业务线程心跳,极端 STW 确实会失效——所以锁只用于"可重入/可补偿"任务,正确性交给底层唯一约束。
追问 3:如何防误释放?标准回答:value 用唯一 token(UUID/线程ID),释放走 Lua 校验 value == token 才删。
追问 4:锁和 DB 事务谁先谁后?标准回答:先拿锁再开事务,事务提交后尽快释放锁;绝不让分布式锁覆盖整个长事务,否则锁持有过久。
追问 5:大量线程抢一把锁怎么避免惊群?标准回答:Redisson 用 pub/sub 唤醒而非自旋;或用"分段锁"把一把大锁拆成 N 把小锁提升并发。
- ❌
setnx后不设置过期 → 进程挂死锁。 - ❌ 用固定 EX 又不做续期 → 业务超时锁被他人抢走,双写。
- ❌ 资金清算用 Redis 锁 → 主从切换瞬间丢锁造成重复清算。
第 19 题:订单履约最终一致性——Outbox + CDC 最终一致
- 1. 为什么不用同步 RPC 调 5 个服务?2. 本地事务 + 发 MQ 怎么保证原子?3. Outbox 表怎么设计?4. CDC 是什么、为什么用?5. 消费失败怎么办?6. 消息顺序有要求吗?7. 如何保证不漏发?
第一步:分析问题。这是典型"一个写、多个副作用"的最终一致问题。核心是本地事务与发消息的原子性——不能出现"库写了但消息没发"或反之。
第二步:核心挑战。本地 DB 事务与 MQ 发送不在同一事务;下游可能失败/慢;需保证 at-least-once 且消费者幂等。
第三步:整体架构。本地消息表(Outbox) + CDC 投递:下单时在同一 DB 事务里写订单表 + outbox 表(待发消息);Debezium/Canal 监听 binlog 把 outbox 发到 Kafka;各消费者幂等处理。
第四步:技术选型。Outbox 表 + Debezium(基于 binlog,无侵入)/Canal;Kafka 做可靠管道;消费端 Redis/DB 幂等表。
第五步:一致性。事务提交则 outbox 必落库 → CDC 必捕获 → 必发出;消费端幂等保证重复无害。at-least-once + 幂等 = 最终一致。
第六步:高可用。CDC 集群多副本;outbox 有"已发/未发"状态,发失败可重投;消费失败入死信 + 告警。
第七步:性能优化。outbox 批量扫发;binlog 异步不阻塞主链路;只发必要字段。
瓶颈:CDC 消费滞后、Kafka 分区不均、下游幂等表写竞争。
为什么不用"先发 MQ 再写库"或"先写库再发 MQ"?前者 MQ 成功库失败→下游收到假消息;后者库成功 MQ 失败→下游收不到。两者都不原子。Outbox 把消息当数据写进同一库,靠 DB 事务保证原子,再由 CDC 异步搬运,彻底解决。
为什么用 CDC 而非定时扫 outbox?定时扫有延迟、且增加 DB 压力;CDC 基于 binlog 近实时、对业务零侵入、不抢主库连接。
为什么需要幂等?CDC 至少一次投递(如重平衡重发),消费端必须按业务键幂等,否则重复扣库存。
权衡:事务消息(RocketMQ)也能解决原子性但耦合 MQ;Outbox+CDC 与 MQ 解耦、可换管道,更适合异构下游,但引入 CDC 运维复杂度。
① 同事务写订单 + Outbox:
@Transactional public void createOrder(OrderCmd cmd) { Order o = orderMapper.insert(cmd.toOrder()); outboxMapper.insert(new Outbox("ORDER_CREATED", o.toJson(), "PENDING")); } // 提交即两者都持久,CDC 必然捕获
② 消费端幂等:
@Transactional public void onOrderCreated(OrderCreated ev) { if (idempotent.insertIgnore(ev.orderId(), "STOCK") == 0) return; stockSvc.deduct(ev.skuId(), ev.qty()); }
追问 1:CDC 报错了 outbox 一直未发怎么办?标准回答:outbox 留 status=PENDING,监控"PENDING 超时未发"告警;CDC 恢复后自动补发;极端情况脚本扫发。
追问 2:binlog 顺序和消费顺序不一致?标准回答:按聚合根(订单号)路由到同一 Kafka 分区保证单键有序;跨键无需全局有序。
追问 3:Outbox 表太大?标准回答:发成功后异步/定时迁移到历史表;只保留近期 PENDING 行热数据。
追问 4:RocketMQ 事务消息和 Outbox 选哪个?标准回答:强绑 RocketMQ 且下游少 → 事务消息更简;下游异构、要换管道、要解耦 → Outbox+CDC 更优。
追问 5:下游需要回滚怎么办?标准回答:下游只做补偿(如扣库存失败→发"释放库存"事件),不回滚上游;用 Saga 编排补偿链(见 Q16)。
- ❌ 下单里同步 RPC 调 5 个服务——一个下游慢拖垮下单,且部分成功难回滚。
- ❌ 先发 MQ 再写库——库失败下游已执行,状态不一致。
- ❌ 消费不幂等——CDC 重发导致重复扣库存。
第 20 题:CAP 与 BASE 的取舍实战 CAP/BASE
- 1. CAP 三选二怎么理解?2. 分区发生时注册中心该选 C 还是 A?3. 订单库分区怎么选?4. Redis 缓存分区怎么选?5. BASE 是什么?6. 最终一致何时可接受、何时不可?7. 如何度量"一致性窗口"?
第一步:分析问题。CAP 的精确含义:当网络分区 P 发生时,只能在一致性 C 与可用性 A 间选一。无分区时两者可兼得。所以讨论 CAP 实际是"分区时怎么选"。
第二步:核心挑战。不同组件对 C/A 的容忍度不同;错误选 A 会脏读/资损,错误选 C 会拒绝服务。
第三步:整体架构。按数据性质分级:①注册/配置中心 → 选 A(AP,短暂不一致可自愈,全站不能因配置中心挂而不可用);②订单/账户核心库 → 选 C(CP,宁拒绝不可错账);③缓存 → 选 A(AP,允许短暂脏读,最终回源)。
第四步:技术选型。注册中心 Nacos/Consul(AP 模式或 Raft CP);订单库用主从+强一致写;缓存用 Redis 最终一致。
第五步:一致性。核心库靠主从同步+半同步;缓存靠"写后删/更新"最终一致,容忍秒级窗口。
第六步:高可用。注册中心多节点;核心库主从自动切换;缓存多副本。
第七步:性能优化。能放宽的 C 尽量放宽(缓存、计数),把强一致留给"金额/库存/订单状态"等少数核心。
瓶颈:CP 组件在分区时成为不可用点;AP 组件需业务容忍短暂不一致。
为什么注册中心选 A?注册中心短暂不一致(某节点列表旧)只会导致请求短暂打到旧实例,可重试自愈;但若选 C,注册中心主节点挂了全网无法发现服务 → 全站雪崩,代价更大。
为什么订单库选 C?金额写错是资损,不可接受;分区时拒绝部分写入(返回"系统繁忙")比脏写安全。
BASE = Basically Available + Soft state + Eventual consistency。是 CAP 中 AP 的延伸工程化:基本可用(降级)、软状态(中间态存在)、最终一致。
权衡:不是"要么 C 要么 A"的永久选择,而是按子域分别决策。架构师的功力在于划清"哪些必须 C、哪些可 A"。
① 缓存与 DB 双写的最终一致(AP 容忍):
public void updatePrice(Long skuId, BigDecimal price) { db.update(skuId, price); // 先更源 redis.delete("sku:" + skuId); // 删缓存,下次读回源(Cache-Aside) } // 短暂窗口内可能读到旧值(AP 可接受)
② 核心写入强一致(CP 保护):
// 订单状态变更走状态机 + 行锁,分区时主库不可达即失败,绝不脏写 orderMapper.updateWithVersion(o); // 乐观锁 version 防并发脏写
追问 1:Nacos 的 CP 和 AP 模式区别?标准回答:CP 基于 Raft(选主,强一致但主挂不可用);AP 基于 Distro(自研最终一致,高可用)。临时实例用 AP,持久化配置可用 CP。
追问 2:一致性窗口多长算安全?标准回答:由业务定,缓存秒级、对账分钟级、跨行 T+1。超过窗口需监控告警+补偿。
追问 3:MongoDB/ES 是 CP 还是 AP?标准回答:Mongo 默认 CP(副本集 majority);ES 默认 AP(写入主分片即可见,副本异步,近实时)。
追问 4:如何证明"分区确实发生了"?标准回答:依赖超时与心跳;但需防范"脑裂"——用 fencing/lease 机制防止双主同时写。
追问 5:能不能做到 CA?标准回答:单节点或强可信网络下可近似 CA,但分布式系统 P 不可避免,故生产必须面对 C/A 取舍。
- ❌ "CAP 就是三选二,永远只能保两个"——忽略"仅分区时"的前提。
- ❌ 把注册中心也设成 CP 强一致——主挂全站雪崩。
- ❌ 不分场景全用最终一致——核心金额脏写资损。
第 21 题:分布式 ID——雪花算法与时钟回拨 分布式ID
AUTO_INCREMENT(单点+泄露总量);UUID 无序导致索引随机 IO。引入雪花算法(Snowflake)后,某次宿主机 NTP 校时时钟回拨 30ms,出现 ID 重复告警。- 1. 雪花结构怎么设计?2. 时钟回拨为什么危险?3. 回拨 30ms 怎么处理?4. 回拨几秒怎么办?5. 机器号如何分配不冲突?6. 序列号溢出怎么办?7. 还有哪些方案(号段/UUID)?
第一步:分析问题。分布式 ID 要解"全局唯一 + 有序 + 高性能"。雪花用"时间戳+机器+序列"组合,单节点内存生成无中心依赖,但依赖时钟单调递增。
第二步:核心挑战。时钟回拨产生重复 ID;机器号分配冲突;单节点序列溢出;不同数据中心时钟漂移。
第三步:整体架构。雪花(工作机号由部署平台分配)+ 时钟回拨防御(等待/缓存上次最大 + 报警)+ 号段(Leaf-segment)作备用。
第四步:技术选型。Snowflake(简单高性能);美团 Leaf(号段 + snowflake 双模式);百度 UidGenerator(解决时钟回拨+机器号由数据库分配);Redis INCR(简单但有中心)。
第五步:一致性。机器号全局唯一(注册中心/配置分配)保证不同节点不撞;同节点靠序列+时间戳保证不重复。
第六步:高可用。时钟回拨防御保证不产重复;号段模式可预取缓存,DB 短暂不可用仍发号。
第七步:性能优化。号段预取(一次取 1000 个缓存在内存);序列号 12 位支持单节点 4096/ms。
瓶颈:时钟回拨、workId 冲突、单节点 4096/s 上限(实际够,不够则多实例)。
为什么不用 AUTO_INCREMENT?单点瓶颈、暴露业务量、跨库分片无法全局有序。但分库分表内仍可用步长自增(如 32 库各 id%32)。
为什么时钟回拨危险?回拨后时间戳变小,若序列号也重置,可能与历史 ID 完全相同 → 主键冲突/幂等失效。防御:记录 lastTimestamp,回拨时等待;或回拨量大时用"扩展位/历史最大+1"。
为什么用号段?Leaf-segment 从 DB 取一段号(如 1~1000)缓存在内存,DB 挂不影响发号;缺点是 ID 不连续、可预测。
权衡:雪花高性能但怕时钟;号段稳但 ID 可预测;UidGenerator 用"原子级"解决回拨。按业务选:订单用雪花(或号段),内部流水可用 UUID。
① 雪花(含回拨防御):
public synchronized long nextId() { long ts = System.currentTimeMillis(); if (ts < lastTs) { // 时钟回拨 long delta = lastTs - ts; if (delta <= 5) { ts = waitUntil(lastTs); } // 小幅:等追平 else { throw new ClockBackException(delta); } // 大幅:报警切备用 } seq = (ts == lastTs) ? (seq + 1) & MASK : 0; if (seq == 0 && ts == lastTs) ts = nextMillis(); lastTs = ts; return (ts << 22) | (workId << 12) | seq; }
追问 1:workId 怎么保证不重复?标准回答:K8s 下用 StatefulSet 序号/配置中心下发;物理机用 ZK 临时顺序节点申请;或 Leaf 由 DB 分配。
追问 2:单节点 4096/ms 不够?标准回答:加机器号位 or 多实例水平扩展;或改结构(如缩短时间戳精度、增加序列位)。
追问 3:时钟回拨 5s 怎么彻底解决?标准回答:禁用 NTP 大步进校时(用 ntpd -x 平滑);回拨大时切换备用 workId 或号段模式,并告警人工核查。
追问 4:ID 趋势递增泄露订单量怎么办?标准回答:对外暴露用"雪花 ID + 哈希/掩码"或改用不可预测的号段/UUID;内部落库可趋势递增利于索引。
追问 5:跨数据中心呢?标准回答:workId 高位编码 DC 编号(如 10bit 拆成 2bit DC + 8bit 节点),保证全球唯一。
- ❌ 用
new Random()/ UUID 做订单主键——无序导致索引碎片化、IO 飙升。 - ❌ 回拨时不处理直接生成——ID 重复主键冲突。
- ❌ workId 写死在配置——多实例冲突。
第 22 题:一致性 Hash 与数据分片路由 分片路由
hash(key)%N,几乎所有 key 的映射都会变,引发缓存击穿 + 全量数据迁移。热点 SKU(某爆款)又集中落到同一节点,单节点被打满。需要"扩缩容只迁移少量数据 + 热点打散"。- 1. 普通取模扩容的问题?2. 一致性 Hash 怎么解决?3. 虚拟节点解决什么?4. 数据倾斜怎么办?5. 分库分表路由怎么做?6. Redis Cluster 的槽位?7. 热点 key 如何再打散?
第一步:分析问题。分片路由要解决两件事:①均匀分布;②扩缩容时迁移成本最低;③热点可打散。
第二步:核心挑战。取模法扩容迁移率近 100%;节点性能不均导致倾斜;单 key 热点。
第三步:整体架构。用一致性 Hash(带虚拟节点)做缓存路由(扩缩容仅迁移 1/N 数据);分库分表用"基因法 + 范围/取模混合";Redis Cluster 用 16384 固定哈希槽(槽可在节点间迁移,与节点数解耦)。
第四步:技术选型。缓存:一致性 Hash + 虚拟节点;分库分表:ShardingSphere(取模/基因/范围);Redis Cluster:哈希槽。
第五步:一致性。路由规则全局一致(配置中心下发),避免不同节点算到不同库。
第六步:高可用。虚拟节点让单物理节点失效时流量均摊到其他节点;槽迁移在线进行不中断。
第七步:性能优化。热点 key 加随机后缀(如 sku:123:{0..99})拆分为多个子 key 分散读。
瓶颈:虚拟节点数不足仍倾斜;槽迁移时的双写/原子切换;热点 key 单点。
为什么取模扩容灾难?k % 8 → k % 16 只有 1/16 的 key 命中旧位置,其余需重分布,缓存全失效、DB 被打穿。
为什么虚拟节点?物理节点少时 Hash 环上分布稀疏,易倾斜;每个物理节点映射成多个虚拟节点,key 更均匀落到各物理机。
为什么 Redis 用固定 16384 槽?槽与节点数解耦:扩缩容迁移"槽"而非"key 直接按节点算";槽数固定使客户端/集群元数据稳定,迁移粒度可控。
权衡:一致性 Hash 适合缓存(允许失效重加载);分库分表因要稳定路由多用取模+基因法(避免跨片查询),迁移靠双写/CDC 灰切。
① 一致性 Hash(带虚拟节点):
TreeMap<Long, String> ring = new TreeMap<>(); // hash → 物理节点 void addNode(String node) { for (int i = 0; i < VNODES; i++) ring.put(hash(node + "#" + i), node); } String route(String key) { Long h = hash(key); Map.Entry<Long,String> e = ring.ceilingEntry(h); return e == null ? ring.firstEntry().getValue() : e.getValue(); // 顺时针 }
② 热点打散读取:
String k = "sku:" + skuId + ":" + (ThreadLocalRandom.current().nextInt(SUB)); // 写时全量写,读时随机取一副本
追问 1:虚拟节点多少个合适?标准回答:100~200 个/物理节点可在倾斜度与内存间平衡;节点越多可适当减少。
追问 2:分库分表后如何避免跨库 JOIN?标准回答:用"基因法"把路由字段(如 user_id 后 N 位)嵌入关联表主键,保证同用户数据同库;或冗余维度表到各库。
追问 3:Redis 槽迁移时读写怎么办?标准回答:集群把槽标记为"迁移中",读命中源、未命中重定向目标;用 MIGRATE 原子搬数据,平滑无锁。
追问 4:一致性 Hash 节点失效,数据丢了?标准回答:缓存场景丢失即回源重建(可接受);若需持久,用副本/Cluster 多副本 + 槽迁移恢复。
追问 5:范围查询怎么分片?标准回答:范围分片利于范围查询但易热点;按时间+取模组合;或放 Elasticsearch 做多维检索。
- ❌ 缓存与分库都用
hash%N——扩容全量失效/迁移。 - ❌ 一致性 Hash 不加虚拟节点——数据严重倾斜。
- ❌ 热点 key 不拆分——单节点被打满拖垮集群。
第 23 题:分布式定时任务设计与防重复执行 定时任务
- 1. @Scheduled 在集群下的问题?2. 如何保证只跑一次?3. 分片广播怎么实现?4. 任务失败如何重试?5. 执行中实例宕机怎么办?6. 任务幂等怎么做?7. XXL-JOB / Elastic-Job 区别?
第一步:分析问题。单机 @Scheduled 集群下会每个实例都跑 → 重复。需"调度中心统一编排 + 执行器分片 + 执行幂等"。
第二步:核心挑战。重复执行、大数据量串行慢、失败无重试、宕机任务丢失、缺乏监控。
第三步:整体架构。引入调度中心(XXL-JOB Admin / Elastic-Job / SchedulerX):触发时选一个执行器执行(或分片广播到所有执行器并行处理不同分片);任务有失败重试、超时告警、执行日志。
第四步:技术选型。XXL-JOB(轻量、易上手、Bean/GLUE 模式);Elastic-Job(基于 ZooKeeper、弹性分片);阿里 SchedulerX(云原生、可视化强)。
第五步:一致性。调度中心用 DB 锁/选主保证"同一任务同一时刻只有一个触发";分片参数下每个执行器处理指定分片;业务按分片键幂等。
第六步:高可用。调度中心集群 + DB;执行器多实例,宕机任务由其他实例接管(失效转移);分片重平衡。
第七步:性能优化。分片并行(8 实例各处理 1/8);每分片内线程池批量;任务拆小步可断点续跑。
瓶颈:分片不均、单分片过大、DB 锁竞争、重试风暴。
为什么不用 @Scheduled?集群每实例各跑一次,重复对账/重复发账单。除非加分布式锁兜底,但失去调度能力(重试/监控/分片)。
为什么分片广播?千万数据单线程 40 分钟;分片后 8 实例并行,每实例处理 1/8,时间降到约 1/8(理想)。分片键用 user_id % shardTotal。
为什么还要业务幂等?重试/失效转移可能让同一分片被两个实例各跑一次;按 biz_date + shard 唯一约束,重复执行安全。
权衡:XXL-JOB 简单够用;Elastic-Job 弹性分片更强但依赖 ZK;云上直接用 SchedulerX 省运维。
① XXL-JOB 分片执行:
@XxlJob("settleJob") public void settle() { int idx = XxlJobHelper.getShardIndex(); int total = XxlJobHelper.getShardTotal(); long max = userIdMax(); for (long u = idx; u <= max; u += total) // 本分片负责 user%total==idx settleOne(u); }
② 业务幂等防重:
try { settleLogMapper.insert(bizDate, shard); } // 唯一键 (biz_date,shard) catch (DuplicateKeyException e) { return; } // 已跑过,跳过
追问 1:任务执行到一半实例宕机?标准回答:调度中心心跳失效转移给其他实例;业务按分片幂等,接管实例重跑该分片安全。
追问 2:分片不均(某 user 数据远超其他)?标准回答:分片键改用更均匀字段(如 hash(userId)),或按数据量范围动态分片。
追问 3:XXL-JOB 调度中心挂了?标准回答:调度中心集群 + DB;挂掉期间任务不触发,恢复后按"错过补偿"策略补跑(或忽略错过)。
追问 4:秒级/分钟级高频任务?标准回答:高频任务用时间轮/流式处理而非 cron;或用 MQ 延迟消息驱动。
追问 5:如何防止任务叠加(上次没跑完下次又触发)?标准回答:开启"任务串行化/阻塞策略=丢弃后续或覆盖之前";或用运行锁标记。
- ❌ 集群直接 @Scheduled 不加锁——8 实例跑 8 次对账。
- ❌ 任务不幂等——重试/转移导致重复结算。
- ❌ 单线程跑千万数据——超时失败。
第 24 题:分布式鉴权——JWT vs Session 鉴权
- 1. JWT 与 Session 本质区别?2. JWT 怎么验签不查库?3. 注销/踢人怎么做?4. JWT 过期与刷新?5. 令牌泄露怎么办?6. 网关怎么统一鉴权?7. 什么时候该用 Session?
第一步:分析问题。鉴权要解决"我是谁 + 我有什么权限 + 怎么可信"。Session 把状态存服务端,JWT 把状态放令牌里(无状态)。
第二步:核心挑战。Session 集中存储成瓶颈 + 需共享;JWT 无法主动失效 + 体积大;两者都要防泄露。
第三步:整体架构。JWT(短期访问令牌)+ Redis 黑名单(仅存已注销 jti)+ 刷新令牌:网关验 JWT 签名(本地公钥,不查库);注销把 jti 加黑名单(短 TTL);刷新令牌长有效期存 Redis,可吊销实现踢人。
第四步:技术选型。JWT(RS256 非对称,网关用公钥验签);Redis 存黑名单/刷新令牌;Spring Security + 资源服务器(Gateway 统一 OAuth2 资源校验)。
第五步:一致性。黑名单在各网关节点共享(Redis);令牌时钟用 NTP 同步防 exp 误判。
第六步:高可用。JWT 无状态天然水平扩展;黑名单 Redis 集群;刷新令牌可轮换。
第七步:性能优化。网关只验签不查库(20 万 QPS 无压力);黑名单用布隆过滤器前置减少 Redis 查询。
瓶颈:黑名单查询(布隆过滤缓解)、JWT 体积、时钟漂移。
为什么 JWT 能不查库?签名(RS256)由授权服务私钥签、网关公钥验,篡改即失效;claims 自带 userId/roles/exp,网关本地解析即可,无需访问用户库。把"状态"从服务端搬到令牌。
为什么 JWT 难注销?令牌自包含、服务端无状态,无法"让某令牌失效"。方案:①短 exp(15m)+ 刷新;②注销时把 jti 加黑名单(只存到令牌自然过期);③踢人=删刷新令牌。
为什么还要 Redis?纯 JWT 无法主动失效,黑名单/刷新令牌仍需 Redis——所以"完全无状态"是误解,JWT 是把高频读(鉴权)无状态化,低频写(注销)仍用 Redis。
权衡:Session 适合"需频繁吊销、服务端可控"场景(如后台管理);JWT 适合"无状态、跨域、微服务网关统一校验"场景。
① 网关验签(Spring Security 资源服务器):
// 资源服务器用公钥验证 JWT,无需查用户库 public JwtDecoder jwtDecoder() { return NimbusJwtDecoder.withPublicKey(publicKey).build(); } // 黑名单过滤:已注销 jti 拒绝 if (bloom.mightContain(jti) && redis.has(jti)) throw new RevokedException();
② 注销(加入黑名单,TTL=令牌剩余有效期):
public void logout(String jti, long ttlSec) { redis.set("bl:" + jti, "1", Duration.ofSeconds(ttlSec)); // 仅存到令牌自然过期即失效 }
追问 1:JWT 体积太大放进 Header 有问题吗?标准回答:claims 只放必要字段(userId/roles/jti),不要塞权限树;必要时用引用令牌(JWT 只存指针,详情查库)。
追问 2:访问令牌被盗用?标准回答:短期 exp 限制窗口;绑定设备指纹/UA/IP;敏感操作要求二次校验(支付密码/短信)。
追问 3:多端 SSO 怎么统一?标准回答:统一认证服务发 JWT,各端用同一套公钥验签;或 OIDC 授权码流。
追问 4:网关验签失败雪崩?标准回答:公钥本地缓存;黑名单布隆前置;验签失败快速 401,不重试不下沉。
追问 5:什么时候坚决用 Session?标准回答:强管控后台、需秒级全局踢人、且不跨域不微服务化的单体系统。
- ❌ 把用户完整信息塞进 JWT——体积大、且无法吊销。
- ❌ 以为 JWT 完全无状态——注销仍需黑名单/Redis。
- ❌ 用 HS256 对称密钥且前端也持有——密钥泄露可伪造。
第 25 题:配置中心设计——动态推送与灰度 配置中心
- 1. 配置中心要解决什么?2. 动态推送怎么实现(长轮询 vs 长连接)?3. 灰度发布怎么做?4. 配置热更新踩过什么坑?5. 回滚怎么做?6. 配置和代码如何解耦?7. Nacos vs Apollo 区别?
第一步:分析问题。配置中心把"会变的环境参数"从代码剥离,要求:实时推送、灰度、回滚、审计、高可用。
第二步:核心挑战。推送实时性、全量推送风暴、热更新导致 Bean 重建副作用、误改全局影响、配置版本管理。
第三步:整体架构。配置中心(Nacos/Apollo)存配置 + 版本;客户端长连接/长轮询监听变更;变更按"集群/App/IP"维度灰度下发;本地缓存兜底(断网用上次值);管理端支持回滚。
第四步:技术选型。Nacos(动态服务发现+配置一体,长连接推送);Apollo(灰度/回滚/审计强,长轮询);Spring Cloud Config(Git 后端,需配合 bus)。
第五步:一致性。配置版本号递增;客户端拿到新版本校验后生效;本地文件缓存保证配置中心宕机仍可启动。
第六步:高可用。配置中心集群;客户端本地快照;推送失败定时补偿拉取。
第七步:性能优化。只推送变更 key(增量);灰度缩小影响面;高频配置(限流)走本地内存 + 监听。
瓶颈:全量推送风暴、热更新 Bean 重建、误改全局。
为什么用长轮询而非长连接?Apollo 用长轮询(HTTP 挂起 30s),实现简单、穿透防火墙好、服务端无连接数压力;Nacos 2.x 用 gRPC 长连接推送更实时。两者都能"准实时"。
热更新大坑:@RefreshScope 会销毁并重建 Bean,若 Bean 持有连接池/线程池/定时任务,重建会泄漏或重复注册。对策:连接池等"不便热更"的配置走重启;只有开关/阈值类安全热更。
为什么必须灰度?"限流=0"全量下发=全站 502。先 1 台验证、再 10%、再全量,出现副作用及时停。
权衡:Nacos 轻量一体(发现+配置);Apollo 配置治理(灰度/回滚/审计)更专业;小团队 Nacos 够,强治理选 Apollo。
① Nacos 监听配置变更:
configService.addListener(dataId, group, new Listener() { public void receiveConfigInfo(String cfg) { LimitConfig c = parse(cfg); if (c.threshold() <= 0) { log.error("阈值非法,拒绝"); return; } // 防误改 limiter.update(c); // 热更新限流阈值 } });
② 安全热更(只用可变开关,不动连接池):
@RefreshScope // 仅用于无副作用的纯配置 Bean @Configuration public class SwitchConfig { @Value("${feature.x:false}") boolean x; }
追问 1:配置中心全挂,服务能起来吗?标准回答:能。客户端本地快照文件兜底,启动时读本地上次值;仅无法接收新变更。
追问 2:热更新导致连接池泄漏?标准回答:连接池/线程池/定时任务等"有状态资源"不放 @RefreshScope,改这类配置走滚动重启;只热更纯开关/阈值。
追问 3:如何防止"限流=0"类误改?标准回答:配置校验(范围/格式)+ 灰度 + 审批流 + 一键回滚;关键配置加保护阈值。
追问 4:Apollo 与 Nacos 怎么选?标准回答:需要强灰度/回滚/审计、配置治理规范 → Apollo;要服务发现+配置一体、轻量 → Nacos。
追问 5:配置和 Feature Flag 区别?标准回答:配置是参数(阈值/开关值);Feature Flag 是"功能发布开关",常结合配置中心做渐进式发布,但语义更偏发布控制。
- ❌ 把数据库密码/连接池也做成热更新——重建 Bean 连接泄漏。
- ❌ 配置全量直发无灰度——一次误改全站故障。
- ❌ 配置中心无本地兜底——中心挂服务起不来。
第 26 题:单元化架构与跨机房数据同步 单元化
- 1. 什么是单元化(SET 化)?2. 用户怎么路由到归属单元?3. 单元内如何闭环?4. 跨单元写怎么保证最终一致?5. 单元故障如何切流?6. 脑裂怎么防?7. 和"同城双活/异地多活"区别?
第一步:分析问题。跨地域部署的核心矛盾是延迟与一致性:强一致写必须靠近数据,所以把"数据 + 计算"按用户归属切成一个个自包含单元,让绝大多数请求在单元内闭环。
第二步:核心挑战。路由正确性(用户归属稳定)、跨单元写、数据同步延迟与冲突、单元故障流量切换、脑裂。
第三步:整体架构。单元化(SET):每个单元 = 接入层 + 应用 + 数据库,自包含处理本单元用户;全局数据(用户中心/配置)多单元同步;跨单元请求经"跨单元同步通道"(Canal/DRC)最终一致。
第四步:技术选型。单元路由(网关按 uid 归属地/哈希);数据同步用阿里 DRC / Canal / TiDB Placement Driver;全局配置走配置中心多单元推送。
第五步:一致性。单元内主从强一致;跨单元 CDC 异步同步 + 冲突裁决(业务维度,如 last-write-win 或人工);核心资金仍走总部单元。
第六步:高可用。单元故障 → DNS/网关把该单元流量切到其他单元(需数据已同步);多活避免单点。
第七步:性能优化。路由本地化让 95%+ 请求单元内闭环;跨单元调用批量/异步;同步通道压缩。
瓶颈:跨单元写延迟、同步延迟导致短暂不一致、切流时数据完整性。
为什么不直接所有机房连同一 DB?跨地域 RTT 50~100ms,一次写涉及多次交互,事务持有锁过久,吞吐崩塌且任一链路抖全都卡。
单元化 vs 同城双活 vs 异地多活:同城双活(同城市两机房,低延迟可强一致);单元化(按用户分片,各单元自治,跨单元最终一致);异地多活(多地域均可写,复杂度最高)。单元化是异地多活的主流落地形态。
脑裂防护:切流时旧单元可能还在收写,用租约/ fencing token 让旧单元写失效,避免双写。路由层用全局一致的用户归属表。
权衡:单元化牺牲"全局强一致跨单元"换"局部高可用与低延迟",适合用户态、地域亲和业务;不适合强全局一致(如全局库存)的场景。
① 单元路由(按 uid 归属):
public String routeUnit(Long uid) { // 归属地稳定映射;变更走配置中心灰度 return unitMap.getOrDefault(uid % 3, "east"); } // 跨单元写:投到目标单元 MQ,目标单元处理后 CDC 回写 crossMq.send(targetUnit, buildCrossEvent(uid, order));
② 切流防护(fencing):
// 单元切换时旧单元拿不到租约,写被拒绝 if (!leaseMgr.hold(unit)) throw new UnitFencedException();
追问 1:用户搬家归属变了怎么办?标准回答:数据迁移任务(双写+灰度读新单元+校验),路由表平滑切换;迁移期间双单元可读。
追问 2:全局库存类数据能单元化吗?标准回答:不能强单元化,用中心单元/全局库存服务 + 单元缓存,或库存分片到多单元但统一调度。
追问 3:跨单元同步延迟导致读到旧值?标准回答:接受最终一致;关键读走源单元/强一致读接口;用版本号提示"数据同步中"。
追问 4:单元故障切流,数据没同步完?标准回答:RPO 内数据由同步通道补;超出窗口的交易挂账+人工补,保证不丢。
追问 5:如何验证单元封闭?标准回答:混沌工程注入"单元外依赖不可用",验证本单元请求不影响、跨单元请求走降级。
- ❌ 所有机房共享中心 DB——跨地域延迟拖垮写。
- ❌ 单元间频繁同步强一致——退化成中心化,失去意义。
- ❌ 切流不做 fencing——脑裂双写数据冲突。
第 27 题:集群级限流设计 集群限流
- 1. 单机限流为什么不够?2. 集群限流怎么计数?3. Redis 方案性能与精度?4. Sentinel 集群流控原理?5. 限流误判/组件故障怎么办?6. 滑动窗口 vs 令牌桶?7. 热点参数限流?
第一步:分析问题。限流有三维度:接口级(保护系统)、用户级(防刷)、IP 级(防攻击)。单机限流无法约束"集群总和"与"跨节点单用户",需集中计数。
第二步:核心挑战。集群计数一致性、限流本身性能/RTT、故障 fail-open/close、精度与开销权衡。
第三步:整体架构。多层:WAF(粗) → 网关集群流控(全局 Token/窗口) → 服务单机(Sentinel 兜底)。集群计数用 Redis(Lua 原子)或 Sentinel Token Server。
第四步:技术选型。Guava RateLimiter(单机);Sentinel(单机+集群流控,Token Server 模式);Redis + Lua 令牌桶/滑动窗口;Nginx limit_req。
第五步:一致性。Redis INCR/Lua 原子自增 + TTL 实现窗口计数;Token Server 集中发放令牌保证全局精确。
第六步:高可用。限流组件故障默认fail-open(放行)避免误杀;Redis 集群;本地单机限流兜底。
第七步:性能优化。本地预取令牌(一次取一批)、滑动窗口近似计数、批量上报减 RTT。
瓶颈:Redis 计数 RTT、热点 key(单用户计数)、Token Server 单点。
为什么单机限流不够?集群总闸需全局视图;单用户可能全打一个节点,单机看不到全局,击穿。
Redis 滑动窗口(Lua):用 ZSET 存请求时间戳,统计窗口内数量,超过阈值拒绝;Lua 保证"计数+判断"原子,避免并发超放。
Sentinel 集群流控:独立 Token Server 统一发令牌,各节点向 Server 取令牌,精度高但 Server 需高可用(可嵌入模式降级为单机)。
fail-open vs fail-close:限流组件挂,fail-open 放行(宁可风险不误杀),fail-close 拒绝(保系统但可能误伤正常流量)。一般限流选 fail-open,熔断选 fail-close。
权衡:Redis 方案简单但有 RTT(≈1ms/请求);Token Server 精确但有单点;生产常"Redis 集群流控 + 本地单机兜底"。
① Redis 滑动窗口限流(Lua):
-- KEYS[1]=limit:api ARGV[1]=now(ms) ARGV[2]=window ARGV[3]=max redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1]-ARGV[2]) local n = redis.call('ZCARD', KEYS[1]) if n >= tonumber(ARGV[3]) then return 0 end -- 超阈拒绝 redis.call('ZADD', KEYS[1], ARGV[1], ARGV[1]); redis.call('PEXPIRE', KEYS[1], ARGV[2]) return 1
② Sentinel 规则:
FlowRule r = new FlowRule("createOrder").setCount(100000).setGrade(RuleConstant.FLOW_GRADE_QPS); r.setClusterMode(true); // 集群流控,Token Server 统一发放
追问 1:Redis 限流的 RTT 开销?标准回答:每请求约 1ms,50 节点 × 10 万 QPS 下 Redis 承压大;用本地预取令牌/批量 + 单机兜底降 Redis 压力。
追问 2:限流误杀正常用户?标准回答:分级限流(用户 VIP 白名单放宽)、漏桶平滑、监控误杀率;故障 fail-open。
追问 3:热点用户计数 key 集中?标准回答:用户计数 key 加随机后缀打散到多 slot,或用本地 LRU + 异步聚合。
追问 4:Token Server 挂了?标准回答:Sentinel 支持"嵌入模式/独立模式"切换,Server 不可用降级为单机均分阈值(总数≈阈值×节点数,略宽松但可用)。
追问 5:限流和熔断区别?标准回答:限流是"入口控量"(保护自身);熔断是"依赖故障时快速失败"(保护依赖+自身不被拖垮)。
- ❌ 只做单机限流——集群总闸失效、单用户击穿。
- ❌ 限流组件故障 fail-close——Redis 抖动全站 503。
- ❌ 滑动窗口不用 Lua——并发超放。
第 28 题:全链路追踪与可观测性体系 可观测性
- 1. 可观测性三大支柱?2. TraceID 怎么跨服务透传?3. 采样率怎么定?4. Metrics/Logs/Tracing 怎么关联?5. MQ 异步链路怎么追踪?6. 探针性能开销?7. SkyWalking vs Jaeger 区别?
第一步:分析问题。微服务下"一次请求 = 多次跨进程调用",靠日志 grep 已不可能。需要把指标(Metrics)、日志(Logs)、链路(Tracing)打通。
第二步:核心挑战。TraceID 透传(含 MQ/线程池)、数据量爆炸、低开销、关联查询。
第三步:整体架构。OpenTelemetry 统一埋点 → W3C TraceID/SpanID 透传 → Collector → Tracing(SkyWalking/Jaeger) + Metrics(Prometheus+Grafana) + Logs(ELK),三者用 TraceID 关联。
第四步:技术选型。OpenTelemetry(标准 SDK);SkyWalking(Java 字节码探针,零侵入);Jaeger(Tracing);Prometheus(指标);ELK/Loki(日志)。
第五步:一致性。同一请求 TraceID 贯穿所有服务(HTTP header traceparent、MQ message property);日志打 TraceID 便于关联。
第六步:高可用。Collector 集群、采样降量、异步上报不阻塞业务;存储按保留期分层。
第七步:性能优化。采样(常规 1%~10%,异常/慢链路 100%);探针开销 < 5%;关键路径全采。
瓶颈:数据量、存储成本、透传遗漏(线程池/MQ 丢 context)、采样导致漏抓偶发慢链路。
为什么用 OpenTelemetry?厂商中立标准,一套埋点可后端接 Jaeger/SkyWalking/Zipkin,避免绑定。
透传是灵魂:HTTP 用 W3C traceparent;但线程池/ MQ/异步会丢 context(新线程无旧 ThreadLocal),必须显式传递(TaskDecorator / MQ 生产者塞 property / 消费者取出恢复)。漏传=链路断裂。
采样策略:全量采数据量太大(30 服务 × 10 万 QPS);常规 1%~10% 采样;但对错误/慢(>1s)/关键业务 100% 采样,既控成本又保排查。
权衡:SkyWalking 零侵入(字节码增强)但绑定生态;Jaeger 轻量标准但需手动埋点;Prometheus 拉模型适合指标,不适合链路。
① 线程池透传 TraceID(Spring TaskDecorator):
public class TracingDecorator implements TaskDecorator { public Runnable decorate(Runnable r) { var ctx = Context.current(); // 捕获当前 trace context return () -> ctx.makeCurrent().close(); try { r.run(); } finally {}; } } // @Async 线程池 setTaskDecorator(new TracingDecorator()) 防链路断裂
② MQ 透传(生产者塞 property,消费者恢复):
producer.send(msg.setProperty("trace_id", Tracing.currentTraceId())); // 消费端:Tracing.startWith(msg.getProperty("trace_id")) 恢复链路
追问 1:偶发 5s 慢链路采样没抓到?标准回答:对"慢(>阈值)/错误"100% 采样,常规流量抽样;或用 Tail-based 采样(Collector 等请求结束再决定留否)。
追问 2:日志量太大怎么关联?标准回答:日志统一打 trace_id 字段,ELK 建索引,Grafana 点 TraceID 直接跳日志。
追问 3:探针性能开销?标准回答:SkyWalking 字节码增强通常 < 5%;采样+异步上报控制;关键服务可关部分插件。
追问 4:RED 指标是什么?标准回答:Rate(请求率)、Errors(错误率)、Duration(时延分布 P99/P95),是服务健康黄金指标。
追问 5:如何避免链路断裂?标准回答:所有跨线程/跨进程边界(线程池/CompletableFuture/MQ/HTTP 客户端)都需透传 context,统一封装工具类。
- ❌ 只靠日志 grep——微服务跨 12 服务无法串联。
- ❌ 全量采样——存储成本爆炸。
- ❌ 线程池/MQ 不传 context——链路断裂定位失效。
第 29 题:对账系统设计(实时 + 离线) 对账
- 1. 为什么需要对账?2. 三方对账怎么做?3. 长款/短款/错账是什么?4. 实时对账怎么实现?5. 掉单怎么补救?6. 差错处理流程?7. 数据量大怎么加速?
第一步:分析问题。分布式系统中"本地成功 ≠ 对方成功",靠对账兜底发现并纠正不一致。本质是对多源数据的集合差集与字段比对。
第二步:核心挑战。数据量(千万级)、格式不一、延迟、差错裁决、补账幂等。
第三步:整体架构。离线对账(T+1):渠道流水文件/内部流水按"业务流水号"join 比对;实时对账:Canal 捕获平台库 binlog vs 渠道回调,延迟窗口内比对。差错入差错表,按类型处理(补账/冲正/挂账)。
第四步:技术选型。离线:Spark/Flink/DataX 批处理;实时:Canal + Flink CEP;存储:MySQL/ES(差错检索);比对键:渠道流水号/平台订单号。
第五步:一致性。以"权威方"(通常银行流水为准)比对;补账走幂等(唯一流水号),避免重复补。
第六步:高可用。对账任务幂等可重跑;差错告警;人工复核通道。
第七步:性能优化。按天/按渠道分片并行;建索引;增量对账(只比当日)。
瓶颈:千万级 join 性能、时区/金额精度、渠道文件延迟、补账幂等。
三方对账:平台流水、渠道流水、内部账户三方两两比对,以"渠道为准"校验平台是否漏记/多记。掉单(渠道成功平台无)= 最危险,需实时发现补账。
长款/短款:长款=平台记录多于渠道(可能重复入账);短款=渠道成功平台未记(掉单,资损风险)。错账=都存在但金额/状态不符。
实时 vs 离线:离线 T+1 全面但慢;实时用 Canal 捕获平台库变更 + 渠道回调,在延迟窗口(如 5min)内比对,发现掉单立即告警补账,缩小资损窗口。
补账幂等:补账按"原渠道流水号"唯一,重复补账 INSERT IGNORE,绝不重复入账。
权衡:离线准确但滞后;实时快但需处理延迟/乱序;生产两者结合,实时缩窗口、离线终态兜底。
① 离线比对(按流水号 join):
// 平台流水 Map<bizNo, Order> vs 渠道流水 Map<bizNo, ChannelTx> channelMap.forEach((bizNo, ch) -> { Order o = platformMap.get(bizNo); if (o == null) diffDao.insert(new Diff(bizNo, SHORT, "掉单")); // 渠道有平台无 else if (!o.amount().equals(ch.amount())) diffDao.insert(new Diff(bizNo, MISMATCH, "金额不符")); });
② 补账(幂等):
@Transactional public void repairDrop(String bizNo, BigDecimal amt) { if (repairLog.insertIgnore(bizNo) == 0) return; // 已补过 accountSvc.credit(bizNo, amt); // 补入账 }
追问 1:渠道文件 T+1 才到,当天掉单怎么发现?标准回答:实时对账(Canal + 渠道回调延迟窗口比对)5min 内发现;离线 T+1 终态兜底。
追问 2:金额精度(分 vs 元)不一致?标准回答:统一最小货币单位(分)比对;渠道金额转平台单位后比对,避免浮点误差。
追问 3:渠道重复回调致平台重复入账?标准回答:入账按渠道流水号幂等(唯一索引),重复回调直接跳过。
追问 4:时区导致对账错位?标准回答:统一用渠道交易时间或 UTC 归桶;对账窗口留余量(±1 天)防跨日边界。
追问 5:千万级 join 太慢?标准回答:按日期+渠道分片并行;两表都按 bizNo 哈希分桶;建覆盖索引;只比增量。
- ❌ 只靠业务断言"调用成功就一致"——回调丢失造成掉单资损。
- ❌ 补账不幂等——重复补账二次资损。
- ❌ 只有离线对账——掉单 T+1 才发现,资损窗口长。
第 30 题:分布式文件与对象存储架构 对象存储
- 1. 对象存储 vs 文件/HDFS?2. 为什么直传而非过业务服务器?3. 分片上传与断点续传?4. 秒传怎么实现?5. 权限与防盗链?6. 冷热如何分层降本?7. 多副本 vs 纠删码?
第一步:分析问题。非结构化海量文件的核心是:写入降本(直传/分片)、读取提速(CDN)、存储降本(分层/去重)、安全(权限)。对象存储(KV 语义)比文件系统/块存储更适合。
第二步:核心挑战。大文件上传稳定性、去重、权限泄露、成本、跨地域。
第三步:整体架构。客户端 → 向业务申请签名 URL → 直传对象存储(OSS/COS/S3)→ CDN 回源加速读 → 生命周期策略自动转低频/归档。秒传靠内容哈希去重。
第四步:技术选型。托管对象存储(阿里 OSS/腾讯 COS/AWS S3);自建 MinIO/Ceph;CDN;计算哈希用 MD5/SHA256;纠删码降存储成本。
第五步:一致性。多副本/EC 保证持久;读最终一致(版本化);元数据(文件索引)存 DB。
第六步:高可用。多 AZ 冗余、跨区域复制、CDN 边缘缓存。
第七步:性能优化。分片并发上传、CDN 缓存热点、冷热分层、去重省空间。
瓶颈:大文件上传超时、CDN 回源带宽、冷数据取回延迟、权限泄露。
为什么直传而非过服务器?文件过业务服务器占用带宽/连接池、成瓶颈;签名 URL 让客户端直连 OSS,业务只签发临时凭证,安全且省资源。
分片上传:大文件切成 5~10MB 片并发上传,每片独立重试;记录已传分片实现断点续传;全部完成合并。
秒传:上传前算内容哈希,查存储已有则直接返回已有 key(不重复传),省带宽。注意哈希碰撞——关键文件用 SHA256。
冷热分层:标准存储贵但立取;归档便宜 10 倍但取回需分钟级。按访问频率生命周期自动沉降,成本降数倍。
权衡:托管 OSS 省运维但绑定+流量费;自建 MinIO/Ceph 灵活但运维重;多副本简单可靠但空间 3 倍,EC 省空间但重建慢。
① 签发上传签名 URL(OSS 风格):
// 业务侧:临时、限定 key 与过期,客户端拿此 URL 直传,不过业务服务器 GeneratePresignedUrlRequest req = new GeneratePresignedUrlRequest(bucket, key); req.setExpiration(Date.from(Instant.now().plusSeconds(300))); URL url = ossClient.generatePresignedUrl(req); // 返回给客户端直传
② 秒传(哈希查重):
public String upload(MultipartFile f) { String hash = Sha256.of(f); // 内容哈希 return fileMetaDao.findByHash(hash) // 已存在 → 秒传直返 key .map(FileMeta::key).orElseGet(() -> doStore(f, hash)); }
追问 1:私有文件如何防盗链/防泄露?标准回答:签名 URL(短期过期)+ Referer 白名单 + 私有 bucket;绝不暴露永久公开 URL。
追问 2:大文件上传中途失败?标准回答:分片上传,失败仅重传失败分片;服务端记录已收分片,客户端续传。
追问 3:归档存储取回慢怎么办?标准回答:热数据留标准/低频;只有确证冷的数据沉降归档;取回走异步解冻任务。
追问 4:自建还是托管?标准回答:中小团队/快速业务用托管(省运维);强合规/成本敏感/数据主权用自建 Ceph/MinIO。
追问 5:海量小文件元数据?标准回答:文件索引(key/哈希/大小/桶)存 DB/ES,便于检索与去重;OSS 本身只存对象。
- ❌ 文件过业务服务器再转存——带宽/连接池被打满。
- ❌ 永久公开 URL——盗链与泄露。
- ❌ 所有文件标准存储——冷数据成本高 10 倍。