手写 RPC、缓存与分布式组件

共 19 题
📑 题目列表 19 题
#
★★★

1. 手写一个最小 RPC 框架,注册中心、序列化、网络传输、负载均衡如何分层?

请手写一个最小 RPC 框架,说明注册中心、序列化、网络传输、负载均衡如何分层?

  • RPC 分层架构
  • 注册中心与服务发现
  • 序列化与网络传输

最小 RPC 框架分层:1)注册中心:服务提供者启动时注册服务地址,消费者从注册中心获取服务提供者列表;2)序列化:把请求参数/响应结果序列化(JSON/Protobuf/序列化)以便网络传输;3)网络传输:基于 Netty/Socket 的长连接,发送请求、接收响应;4)负载均衡:消费者从提供者列表中选择一个实例(随机/轮询/一致性哈希);5)代理层:JDK 动态代理把接口调用转成远程调用。各层职责清晰:注册中心管"服务发现",序列化管"数据表示",传输管"通信",负载均衡管"选实例"。分层让各层可独立替换。

RPC 的"分层"是职责分离:注册中心、序列化、传输、负载均衡、代理各司其职。消费者通过代理发起调用,动态代理生成网络请求,经负载均衡选实例,序列化后传输,服务端反序列化执行并返回。

// 分层:registry(注册中心) -> loadBalance(负载均衡) -> serializer(序列化) -> transport(网络)
public interface Registry { void register(String name, String addr); List<String> discover(String name); }
public interface LoadBalancer { String select(List<String> addrs); }
public interface Serializer { byte[] serialize(Object o); Object deserialize(byte[] b, Class<?> c); }
public interface Transport { RpcResponse call(String addr, RpcRequest req); }
#
★★★

2. 手写 RPC 时如何用 JDK 动态代理生成调用桩(stub)并处理返回值与异常

请手写 RPC 时如何用 JDK 动态代理生成调用桩(stub)并处理返回值与异常?

  • JDK 动态代理生成 stub
  • 返回值与异常处理
  • 远程调用封装

RPC 用 JDK 动态代理为服务接口生成 stub(代理对象)。调用接口方法时,InvocationHandler.invoke 被触发:把方法名、参数类型、参数值封装成 RpcRequest,经负载均衡选择实例,序列化后通过网络发送到服务端;服务端反序列化、找到实现类、反射调用方法,把结果封装成 RpcResponse 返回。消费者端处理返回值:若 RpcResponse 携带异常则抛出,否则反序列化返回结果。异常处理:服务端抛出的异常要序列化传回,客户端还原抛出,保证调用方捕获到服务端异常。

动态代理是 RPC 的"透明调用"关键——调用方像调用本地方法一样调用远程服务。invoke 里完成"请求封装 + 网络调用 + 响应解析 + 异常还原"。

public class RpcProxy implements InvocationHandler {
    private final Class<?> clazz;
    public Object invoke(Object proxy, Method method, Object[] args) {
        RpcRequest req = new RpcRequest(clazz.getName(), method.getName(), method.getParameterTypes(), args);
        RpcResponse resp = doCall(req); // 选实例 + 序列化 + 传输
        if (resp.getError() != null) throw new RuntimeException(resp.getError()); // 还原异常
        return resp.getResult(); // 返回结果
    }
    public static <T> T create(Class<T> clazz) {
        return (T) Proxy.newProxyInstance(clazz.getClassLoader(), new Class[]{clazz}, new RpcProxy(clazz));
    }
}
#
★★★

3. 手写分布式锁(Redis SETNX + Lua 续期)并分析主从切换下的失效窗口?

请手写分布式锁(Redis SETNX + Lua 续期),并分析主从切换下的失效窗口?

  • SETNX 加锁
  • Lua 续期
  • 主从切换失效窗口

分布式锁用 SET key value NX EX 加锁(NX 保证不存在才设置,EX 设过期时间),value 用唯一标识(如 UUID)防止误删。释放时用 Lua 脚本比较 value 再 del,保证原子性(防止误删他人锁)。续期:业务未完成时,用定时任务(看门狗)每过一段时间执行 Lua 把过期时间延长,防止锁过期导致并发。主从切换失效窗口:Redis 主从复制是异步的,若主节点宕机、锁还在主节点上但未同步到从节点,从节点升级为主节点后,锁信息丢失,其他线程可再次加锁成功,造成并发访问。失效窗口 = 主节点故障到从节点完成数据同步的时间差。解决:用 RedLock(多节点)或 ZK/etcd 的强一致机制。

SETNX 原子加锁,Lua 保证"检查+删除"原子,看门狗续期防锁过期。主从异步复制导致"锁丢失"的失效窗口,是 Redis 分布式锁的根本局限,需配合 RedLock 或换强一致存储。

// 加锁
SET lockKey uuid NX EX 30000
// 续期 Lua
if redis.call('get', KEYS[1]) == ARGV[1] then redis.call('expire', KEYS[1], ARGV[2]) end
// 释放 Lua
if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end
#
★★★

4. 手写分布式锁如何解决"锁过期但业务未完成"问题(看门狗续期)?

请手写分布式锁如何解决"锁过期但业务未完成"问题(看门狗续期)?

  • 看门狗续期机制
  • 判断锁是否仍为自己持有
  • 续期与释放

分布式锁设过期时间,若业务执行超过过期时间,锁自动过期,其他线程可加锁,导致并发。解决:看门狗(watchdog)机制——加锁成功后启动一个后台定时任务,周期性(如每 1/3 过期时间)检查:若锁仍由当前线程持有(value 匹配自己的唯一标识),则用 Lua 续期延长过期时间。业务完成后停止看门狗并释放锁。这样锁在业务期间始终被续期,不会提前过期。要确保释放时只释放自己的锁(Lua 比较 value),看门狗也要在释放后停止,避免重复续期。

看门狗的本质是"动态续期",让锁的生命周期跟随业务执行时间。必须用唯一 value 判断锁归属,续期与释放都用 Lua 保证原子。Redisson 的 watchLock 即此机制。

// 加锁后启动看门狗
ScheduledExecutorService watchdog = ...;
watchdog.scheduleAtFixedRate(() -> {
    // Lua: 若 value 匹配则延长过期时间
    if (redis.get(key).equals(myValue)) redis.expire(key, 30, SECONDS);
}, 10, 10, SECONDS);
// 业务完成后
watchdog.shutdown();
redis.eval("if get(K)==V then del(K) end"); // 释放
#
★★★

5. 手写分布式锁,Redis SETNX+Lua 与 ZooKeeper 临时顺序节点的差异如何?

请手写分布式锁,说明 Redis SETNX+Lua 与 ZooKeeper 临时顺序节点的差异?

  • Redis 锁实现
  • ZK 临时顺序节点实现
  • 可靠性差异

Redis 锁:SETNX + Lua 续期/释放,简单高性能,但主从异步复制有锁丢失风险。ZK 分布式锁:利用临时顺序节点——每个请求创建临时节点,按序号排序,序号最小的节点获得锁;其他节点监听前一个节点,前一个删除(释放锁)时唤醒自己尝试获取。临时节点与客户端会话绑定,客户端宕机会话结束节点自动删除,锁自动释放,避免死锁。差异:Redis 简单快、基于过期时间,有主从失效窗口;ZK 强一致(ZAB 协议)、无锁丢失、靠会话自动释放,但性能低于 Redis、需维护 ZK 集群。选型:追求性能用 Redis,追求强一致可靠性用 ZK/etcd。

核心差异是"一致性保证":Redis 是 AP(异步复制可能丢锁),ZK 是 CP(强一致,锁不丢失、自动释放)。ZK 用临时顺序节点天然实现公平锁与自动释放。

// ZK 锁:创建临时顺序节点
String path = zk.create("/lock/", data, CreateMode.EPHEMERAL_SEQUENTIAL);
List<String> children = zk.getChildren("/lock", false);
// 若自己是最小序号则获得锁,否则监听前一个节点
// 前一个节点删除 -> 会话结束自动释放(临时节点)
#
★★

6. 手写一个简单的一致性哈希(虚拟节点)并说明数据迁移量为何最小?

请手写一个简单的一致性哈希(虚拟节点),并说明数据迁移量为何最小?

  • 一致性哈希环
  • 虚拟节点
  • 数据迁移量最小

一致性哈希把节点映射到一个 0~2^32 的哈希环上,数据 key 哈希后也落在环上,顺时针找到第一个节点即为存储节点。添加/删除节点时,只有该节点顺时针到下一个节点之间的数据需要迁移到新节点,其他数据不受影响,因此迁移量最小。虚拟节点:每个物理节点映射多个哈希位置(虚拟节点),使数据分布更均匀,避免节点少时数据倾斜;当节点的虚拟节点变化时,迁移量也控制局部。相比"取模哈希"(增删节点导致几乎所有数据重新映射),一致性哈希只迁移环上局部数据,迁移量最小。

一致性哈希的"最小迁移"源于"key 只映射到最近节点,节点增减只影响相邻区间"。虚拟节点解决分布均匀性。取模哈希每次增删节点都要重算全部,迁移量大。

public class ConsistentHash {
    private final TreeMap<Integer, String> ring = new TreeMap<>(); // 哈希环
    private final int virtualNodes;
    public void addNode(String node) {
        for (int i=0;i<virtualNodes;i++) ring.put(hash(node + "#" + i), node);
    }
    public String get(String key) {
        Integer h = hash(key);
        Map.Entry<Integer,String> e = ring.ceilingEntry(h); // 顺时针找第一个
        if (e == null) e = ring.firstEntry(); // 环回绕
        return e.getValue();
    }
}
#
★★

7. 手写滑动窗口计数与令牌桶限流,说明在网关中的落地差异?

请手写滑动窗口计数与令牌桶限流,说明在网关中的落地差异?

  • 滑动窗口计数
  • 令牌桶
  • 网关落地差异

滑动窗口计数:把时间分成若干小窗口,记录每个窗口的请求数,滑动时淘汰过期窗口,精确限制单位时间内请求数,适合"精确限流、突发敏感"的场景。令牌桶:按速率补充令牌,桶容量允许突发,控制"平均速率+突发容量"。网关落地差异:网关(如 Nginx/Spring Cloud Gateway/Sentinel)常用滑动窗口做"QPS 精确限流"(防止突发打爆),用令牌桶做"平滑限流"(允许一定突发、控制整体速率)。Sentinel 的滑动窗口、限流漏斗、令牌桶可用于不同策略。网关需对每个请求做毫秒级判断,内存要可控(窗口桶数),且要支持分布式限流(Redis 存储计数)。

滑动窗口精确但内存与窗口数相关,令牌桶平滑且内存 O(1)。网关中"精确防突发"用滑动窗口,"平滑控速"用令牌桶;分布式下用 Redis 存储计数/令牌。两者常组合使用。

// 滑动窗口:维护时间戳数组,清除过期,统计当前窗口请求数
// 令牌桶:按速率补充令牌,请求消耗令牌
#
★★

8. 手写 RPC 的负载均衡(随机/轮询/一致性哈希)如何与失败重试联动?

请手写 RPC 的负载均衡(随机/轮询/一致性哈希),说明如何与失败重试联动?

  • 随机/轮询/一致性哈希负载均衡
  • 失败重试
  • 重试与幂等

RPC 负载均衡:随机——从可用实例中随机选一个;轮询——按顺序轮流选择,可加权重;一致性哈希——按请求 key 哈希选实例,保证相同 key 落在同一实例(利于缓存命中)。失败重试联动:调用失败时,从剩余可用实例中重新选择重试(排除已失败的实例),通常设定最大重试次数;重试要配合幂等保证(重试可能导致重复执行)。联动逻辑:负载均衡负责"选实例",失败时标记该实例(临时剔除),重试时排除它再选下一个。可结合熔断:连续失败触发熔断,不再重试该实例。

负载均衡是"选实例",失败重试是"失败后换实例再试"。两者联动需排除已失败实例并控制重试次数,且要求操作幂等,避免重试造成重复副作用。这是 RPC 高可用的关键。

public class LoadBalancer {
    public String select(List<String> addrs, String key) {
        // 随机:addrs.get(rand.nextInt(addrs.size()))
        // 轮询:addrs.get(atomic.getAndIncrement() % addrs.size())
        // 一致性哈希:ring 上按 key 选
    }
}
// 失败重试
for (int i=0;i<maxRetry;i++) {
    try { return call(select(available, key)); }
    catch (Exception e) { available.remove(last); /* 排除失败实例 */ }
}
#
★★

9. 手写本地缓存,TTL+LRU 组合、过期清理线程与并发控制如何实现?

请手写本地缓存,说明 TTL+LRU 组合、过期清理线程与并发控制的实现?

  • TTL+LRU 组合
  • 过期清理线程
  • 并发控制

本地缓存组合 TTL(时间过期)与 LRU(容量淘汰):每个节点记录过期时间与访问时间,get 时检查是否过期(过期返回 null 并删除),超容量按 LRU 淘汰最久未用。过期清理线程:后台定时线程周期性扫描,删除已过期项,避免"从未再访问的过期项"占用内存。并发控制:用 synchronized 或 ReentrantLock 保护读写,或用 ConcurrentHashMap + 原子操作;读操作尽量无锁(如 volatile 字段),写操作加锁。也可用分片锁降低竞争。过期判断用 System.currentTimeMillis() 与记录时间比较。

TTL 处理时效、LRU 处理容量、定时线程清理过期、锁保证并发。三者组合是本地缓存的标准实现。注意清理线程与业务竞争的锁同步。

public class LocalCache<K,V> {
    private final Map<K, Entry<V>> map = new HashMap<>();
    private final long ttl;
    private ScheduledExecutorService cleaner;
    public LocalCache(long ttl){
        this.ttl = ttl;
        cleaner = Executors.newSingleThreadScheduledExecutor();
        cleaner.scheduleAtFixedRate(this::evict, 10, 10, TimeUnit.SECONDS); // 清理线程
    }
    public synchronized V get(K k){
        Entry<V> e = map.get(k);
        if (e==null) return null;
        if (System.currentTimeMillis()-e.ts > ttl) { map.remove(k); return null; } // 过期
        return e.val;
    }
    public synchronized void put(K k, V v){ map.put(k, new Entry<>(v, System.currentTimeMillis())); }
    synchronized void evict(){ /* 清理过期项 */ }
    static class Entry<V>{ V val; long ts; Entry(V v,long t){val=v;ts=t;} }
}
#
★★

10. 手写 RPC 时如何实现调用超时(Future.get 超时/回调超时)与熔断联动

请手写 RPC 时如何实现调用超时(Future.get 超时/回调超时)与熔断联动?

  • Future.get 超时
  • 回调超时
  • 与熔断联动

RPC 调用超时:同步用 Future.get(timeout) 超时抛 TimeoutException;异步用回调,需在回调上设置超时定时器,超时触发超时回调并取消请求。实现:每次调用记录超时时间,超过则终止并返回错误。熔断联动:熔断器监控失败率,达到阈值时熔断(open)——直接快速失败,不再发起调用;超时被视为一次失败,计入失败率,触发熔断。熔断后,半开状态放行部分请求探测恢复。联动:超时 → 记录失败 → 失败率升高 → 熔断 → 快速失败避免雪崩。超时与熔断共同提供"快速失败"保护。

超时是"单次调用不无限等待",熔断是"服务不可用时快速失败保护"。超时计入失败样本,驱动熔断状态切换。half-open 探测恢复。两者都是高可用降级手段。

// 同步超时
Future<Resp> f = pool.submit(() -> call());
try { Resp r = f.get(3, TimeUnit.SECONDS); } catch (TimeoutException e) { circuit.recordFailure(); f.cancel(true); }
// 熔断器
if (circuit.isOpen()) throw new FastFailException(); // 熔断快速失败
try { Resp r = call(); circuit.recordSuccess(); }
catch (Exception e) { circuit.recordFailure(); throw e; }
#
★★

11. 手写两级缓存(Caffeine + Redis)的失效广播与一致性更新

请手写两级缓存(Caffeine + Redis),说明失效广播与一致性更新?

  • 两级缓存结构
  • 失效广播
  • 一致性更新

两级缓存:本地 Caffeine 缓存(快、内存级)+ Redis 缓存(共享、分布式)。读优先查本地,未命中查 Redis,未命中查 DB 并回填两级。一致性更新:写操作更新 DB 后,删除 Redis 缓存并通知其他节点删除本地缓存,其他节点通过广播(如 Redis 订阅/消息队列)收到失效消息后清除本地 Caffeine 缓存,避免读到旧数据。失效广播即"一写多删":写 DB 的节点删除自己的两级缓存,并通过订阅发布通知其他节点删本地缓存。要处理"缓存击穿"(缓存空时并发打 DB)与"缓存一致性"(先删缓存还是先更新 DB,用双删/延迟删除)。

两级缓存的关键是"本地缓存与 Redis 的一致性"。写后删 Redis + 广播通知其他节点删本地缓存,保证最终一致。失效广播用 Redis pub/sub 或 MQ 实现。注意避免击穿与防止旧值回填。

// 读:先本地,再 Redis,再 DB 并回填
V v = caffeine.get(key);
if (v == null) { v = redis.get(key); if (v==null) { v = db.get(key); redis.set(key,v); } caffeine.put(key,v); }
// 写:更新 DB -> 删 Redis -> 广播删其他节点本地缓存
redis.del(key); publish("cache:invalid", key);
#
★★

12. 手写分布式 ID,雪花算法与号段模式的并发/趋势特性如何?

请手写分布式 ID,说明雪花算法与号段模式的并发/趋势特性?

  • 雪花算法结构
  • 号段模式
  • 并发与趋势特性

雪花算法:64 位 ID,由 1 位符号 + 41 位时间戳 + 10 位机器号 + 12 位序列号组成。单机毫秒内可生成 4096 个 ID,时间有序,趋势递增(利于数据库索引),无中心依赖,靠机器号区分,适合高并发分布式场景。缺点:依赖时钟(时钟回拨会重复)。号段模式:从数据库一次性取一段号段(如 [1000,1999]),本地内存分配,用完了再取下一段。并发高(本地内存分配无竞争),但 ID 趋势递增(随号段递增),适合有序场景;缺点:依赖数据库,号段用尽要重新取号,号码可能不连续。

特性对比:雪花算法完全本地生成、并发极高、趋势递增、依赖时钟;号段模式需 DB、靠号段批量取降低 DB 压力、趋势递增、实现简单。两者都保证趋势递增(对有序索引友好)。

雪花算法是"时间戳+机器+序列"的本地生成,号段是"DB 批量取号+本地分配"。雪花趋势递增靠时间戳,号段趋势递增靠号段递增。并发上雪花无 DB 依赖更高,号段降低 DB 压力但仍需 DB。

public class Snowflake {
    private long lastTs = -1, seq = 0;
    private final long machineId;
    public synchronized long nextId() {
        long ts = System.currentTimeMillis();
        if (ts == lastTs) { seq = (seq+1) & 4095; if (seq==0) ts = tilNextMillis(lastTs); }
        else seq = 0;
        lastTs = ts;
        return (ts << 22) | (machineId << 12) | seq; // 时间戳+机器号+序列
    }
}
#
★★

13. 手写 MQ 消息确认的简化版(发送确认 + 消费 ACK + 死信)

请手写 MQ 消息确认的简化版,说明发送确认 + 消费 ACK + 死信?

  • 发送确认
  • 消费 ACK
  • 死信队列

消息确认机制:1)发送确认:生产者发送消息后,broker 确认收到(ack 或 nack),生产者确认消息已持久化,避免发送丢失;2)消费 ACK:消费者处理完消息后向 broker 发送 ACK,确认已消费,broker 才删除消息;若消费者返回 nack 或超时未 ACK,broker 重新投递(重试);3)死信:消息重试多次仍失败、或消费超时、或消息过期,被放入死信队列(DLQ),供人工/补偿处理,避免无限重试堵塞。简化实现:消息带状态(pending/acked/nacked),消费成功后 ack 删消息,失败 nack 重投或进死信。idempotent 处理配合 ACK 保证至少一次投递。

发送确认保证"不丢发送",消费 ACK 保证"不丢消费",死信兜底"重试仍失败"的消息。三者构成可靠消息传递。至少一次投递需配合幂等消费。

// 消费 ACK 简化
void onMessage(Message m) {
    try { process(m); ack(m); }        // 成功 -> ACK
    catch (Exception e) {
        if (m.retryCount >= maxRetry) dlq.put(m); // 重试超限进死信
        else { m.retryCount++; requeue(m); }      // 重投
    }
}
#
★★

14. 手写 RPC 的连接管理,长连接池、连接复用与断线重连如何实现,连接数与线程模型如何匹配?

请手写 RPC 的连接管理,说明长连接池、连接复用与断线重连如何实现,以及连接数与线程模型如何匹配?

  • 长连接池
  • 连接复用与断线重连
  • 连接数与线程模型

RPC 连接管理:1)长连接池:维护到每个服务端的一组连接,复用连接避免频繁建连开销;2)连接复用:多个请求复用同一连接(多路复用),或从池中借还连接;3)断线重连:检测到连接断开(心跳超时/异常),从池中剔除并异步重连,重连成功后恢复。连接数与线程模型:连接数要与线程模型匹配——若用阻塞 IO(一连接一线程),连接数受线程数限制,连接过多导致线程爆炸;若用 NIO 多路复用(Netty,事件循环),一个线程可管理大量连接,连接数可多。连接数也受服务端处理能力与客户端并发限制,需通过连接池设置最大连接数防止资源耗尽。

长连接复用降低建连开销,断线重连保证可用性。连接数与线程模型强相关:阻塞 IO 一连接一线程(连接数=线程数上限),NIO 一线程多连接(连接数可远超线程数)。这是选择 Netty 的重要理由。

// 连接池:Map<addr, Deque<Channel>>,借/还连接
// 断线重连:心跳检测失败 -> 移除连接 -> 异步 reconnect
// 线程模型:NioEventLoopGroup 一个线程处理多个 Channel(多路复用)
#
★★

15. 手写序列化与协议设计,JDK/JSON/Protobuf 序列化的性能与兼容性差异,RPC 协议中的消息头(magic、版本、长度)如何设计?

请手写序列化与协议设计,说明 JDK/JSON/Protobuf 序列化的性能与兼容性差异,以及 RPC 协议中的消息头(magic、版本、长度)如何设计?

  • 三种序列化对比
  • 协议消息头设计
  • 性能与兼容性

序列化对比:JDK 序列化(Serializable)实现简单但性能差、体积大、有安全风险;JSON 可读性好、跨语言、但体积和性能一般(需反射);Protobuf 二进制、体积小、性能高、跨语言、有强类型 schema,但需要定义 proto 文件、可读性差。性能上 Protobuf > JSON > JDK,体积上 Protobuf 最小。兼容性:Protobuf 通过字段编号和 optional 字段支持前后兼容,JSON 天然向后兼容(新增字段不影响),JDK 序列化版本兼容性差(serialVersionUID 变化会失败)。协议消息头设计:通常包含 magic(魔数,标识协议,如 0x1234)、version(版本号)、消息类型、序列化方式、消息长度(body 长度)、请求 ID 等。长度字段用于粘包/拆包处理(按长度读取完整消息)。magic 用于校验协议、长度用于分帧、请求 ID 用于关联请求与响应。

选序列化看性能/体积/兼容性/跨语言需求。协议头设计的关键是"magic 校验 + 长度分帧 + 请求 ID 关联",这是手写 RPC 协议的基础。长度字段解决 TCP 粘包拆包。

// 协议头:magic(2) + version(1) + type(1) + serializer(1) + requestId(4) + length(4)
// 读取时先读 header,按 length 读 body,完整一帧
#
★★

16. 手写熔断器(Circuit Breaker),closed/open/half-open 三态切换、失败率滑动窗口与恢复探测如何实现?

请手写熔断器(Circuit Breaker),说明 closed/open/half-open 三态切换、失败率滑动窗口与恢复探测如何实现?

  • 三态切换
  • 失败率滑动窗口
  • 恢复探测

熔断器三态:closed(关闭,正常放行)、open(打开,快速失败)、half-open(半开,放行少量请求探测恢复)。closed 时统计失败率,当失败率超过阈值(如 50%)且达到最小请求数,切换到 open;open 时所有请求快速失败,不调用下游;经过一段时间(熔断超时)进入 half-open,放行少量探测请求,若成功则恢复 closed,若失败则回到 open。失败率用滑动窗口统计(记录最近 N 个请求的成功/失败),避免瞬时波动。恢复探测是 half-open 的核心——用少量请求验证下游是否恢复。

熔断器用"失败率"驱动打开,用"超时+探测"驱动恢复。半开状态是恢复的关键,避免"全开后无法自动恢复"。滑动窗口让失败率反映近期状态。这是防雪崩的核心组件。

public class CircuitBreaker {
    enum State { CLOSED, OPEN, HALF_OPEN }
    State state = State.CLOSED;
    int failures, successes, minRequests = 10;
    double failureThreshold = 0.5;
    long openTime, timeout = 30000;
    public boolean allow() {
        if (state == State.OPEN) {
            if (System.currentTimeMillis() - openTime > timeout) { state = State.HALF_OPEN; return true; }
            return false;
        }
        return true;
    }
    public void record(boolean success) {
        if (success) { if (state==HALF_OPEN) state=CLOSED; successes++; }
        else { failures++; if (state==HALF_OPEN) { state=OPEN; openTime=now(); }
               else if (failures+successes>=minRequests && failures/(double)(failures+successes)>=failureThreshold) { state=OPEN; openTime=now(); } }
    }
}
#

17. 手写一个 Outbox 模式的简化实现(本地事务+定时投递+去重)?

请手写一个 Outbox 模式的简化实现,说明本地事务 + 定时投递 + 去重?

  • Outbox 本地事务
  • 定时投递
  • 去重

Outbox 模式解决"本地事务与消息发送的一致性":业务操作与消息写入放在同一个本地事务中,消息先写入 outbox 表(本地),事务提交后由独立的投递任务从 outbox 表读取消息并发送到 MQ,发送成功后标记已发送。这样保证"业务成功则消息一定落库",避免先发消息后事务回滚导致消息不一致。定时投递:后台任务周期性扫描 outbox 表中未发送的消息,投递到 MQ。去重:由于投递与标记非原子,可能重复投递,靠消息唯一 ID 在消费端去重(幂等消费),或 outbox 表记录投递状态用版本号防止重复。投递失败的消息保留在 outbox 表供重试。

Outbox 的核心是"把消息写入与业务同事务",保证不丢消息;定时投递保证"最终投递";去重靠唯一 ID 幂等,因为投递可能重复。这是分布式事务的可靠消息方案。

// 本地事务:业务 + 写 outbox 表
@Transactional
public void business(Order o) {
    insertOrder(o);
    insertOutbox(msgId, payload); // 与业务同事务
}
// 定时投递
scheduledExecutor.scheduleAtFixedRate(() -> {
    for (Outbox m : outboxRepo.findUnsent()) {
        try { mq.send(m.getMsgId(), m.getPayload()); outboxRepo.markSent(m.getMsgId()); }
        catch (Exception e) { /* 保留待重试 */ }
    }
}, 0, 1, TimeUnit.SECONDS);
// 消费端按 msgId 幂等去重
#

18. 手写注册中心的最小实现,服务注册、心跳探测与消费者缓存如何?

请手写注册中心的最小实现,说明服务注册、心跳探测与消费者缓存?

  • 服务注册
  • 心跳探测
  • 消费者缓存

注册中心最小实现:1)服务注册:服务提供者启动时向注册中心注册自己(服务名 + 地址),注册中心保存到服务名→地址列表的映射;2)心跳探测:提供者定期发送心跳证明存活,注册中心通过心跳超时判断服务下线,剔除失效节点;3)消费者缓存:消费者从注册中心拉取服务地址列表后缓存到本地,后续调用直接用本地缓存,避免每次查注册中心;缓存需定期刷新并与注册中心同步(订阅变更/定时拉取)。实现:注册中心用 Map<serviceName, Set>,心跳用定时器更新 lastHeartbeat,超时剔除。这样服务发现、故障剔除、本地缓存构成高可用。

注册中心是"服务提供者注册 + 心跳保活 + 消费者发现"。心跳超时剔除下线的服务,本地缓存让消费者不依赖高频查询注册中心。这是服务注册发现的核心。

public class Registry {
    Map<String, Set<String>> services = new ConcurrentHashMap<>();
    Map<String, Long> lastHeartbeat = new ConcurrentHashMap<>();
    public void register(String name, String addr){ services.computeIfAbsent(name,k->CopyOnWriteArraySet()).add(addr); lastHeartbeat.put(addr, now()); }
    public void heartbeat(String addr){ lastHeartbeat.put(addr, now()); }
    public List<String> discover(String name){ return new ArrayList<>(services.getOrDefault(name, Set.of())); }
    // 定时任务:心跳超时剔除
    // 消费者:discover 后缓存本地,定期同步
}
#

19. 手写服务发现的故障剔除与消费者端缓存,心跳超时剔除、本地路由表缓存与容灾降级如何设计?

请手写服务发现的故障剔除与消费者端缓存,说明心跳超时剔除、本地路由表缓存与容灾降级如何设计?

  • 心跳超时剔除
  • 本地路由表缓存
  • 容灾降级

服务发现故障剔除:提供者定期心跳,注册中心在超时(如 3 个心跳周期)未收到后把该地址从服务列表剔除,并通知消费者。消费者端缓存:消费者拉取服务列表后缓存到本地路由表,调用时用本地缓存,减少对注册中心的依赖;本地缓存定期刷新或订阅变更。容灾降级:注册中心不可用时,消费者继续用本地缓存的路由表(stale 数据也能路由),保证服务可用;提供者注册失败时也先本地缓存,已注册的照常心跳。剔除要"软剔除"(先标记不可用再确认剔除)避免误杀;本地缓存要带过期/刷新机制,防止长期用旧数据。

心跳剔除保证"故障自动发现",本地路由表缓存保证"注册中心不可用时仍可用",容灾降级是"降级但不宕机"。这是服务发现高可用的关键:中心故障不影响已缓存的路由。

// 心跳超时剔除
if (now - lastHeartbeat.get(addr) > timeout) { services.get(name).remove(addr); notifyConsumers(name); }
// 消费者本地缓存
Map<String, List<String>> localRoute = loadFromRegistry();
// 容灾:注册中心不可用,用 localRoute 兜底