RPC 与缓存:远程调用与加速的底层

Dubbo/gRPC/Thrift + 本地缓存 Caffeine + 远程缓存 Redis

本主题的底层路线(万变不离其宗):
RPC 底层 = 代理(stub) + 序列化 + 网络传输(NIO/Reactor) + 协议; 缓存底层 = 内存/远程 KV + 淘汰策略 + 并发控制(或持久化+事件循环)。 两者都是"用空间换时间":RPC 用"把远程调用伪装成本地调用"省下分布式心智成本,缓存用"把结果提前放进更快的存储介质"省下重复计算/IO 成本。 一句话:先想清"跨进程调用要靠哪四块零件拼起来"和"加速要靠哪三类机制(结构/淘汰/并发)",框架只是机制的实例化与取舍。区别只在于——缓存离"计算"有多远:本地缓存在进程内,远程缓存在网络另一端。
目录 0. 读前必读:为什么需要 RPC 与缓存(衔接 Part03)
1. RPC 本质:四要素与端到端调用流程
2. RPC 的外围能力:服务发现 / 负载均衡 / 容错 / 超时
3. Dubbo:Netty 长连接 + Hessian2 + SPI + 责任链
3.5 框架架构对比:Dubbo vs gRPC(协议/序列化/网络)
4. gRPC:HTTP/2 + Protobuf + 流
5. Thrift:多协议多传输的跨语言 IDL
6. 三大框架对比:协议 / 序列化 / 语言 / 性能 / 复杂度
7. 本地缓存:内存 KV + 淘汰 + 并发
8. Caffeine:W-TinyLFU 频率统计算法(本地缓存天花板)
9. Guava Cache / Ehcache:分段锁与三级存储
10. 缓存三大问题:穿透 / 击穿 / 雪崩
11. 远程缓存 Redis:单线程事件循环与六种底层结构
12. Redis 持久化 / 集群 / 过期淘汰 / 高级能力
13. Memcached:多线程 Slab 分配器的简单哲学
14. 一句话路线总结

0. 读前必读:为什么需要 RPC 与缓存(衔接 Part03)

在 Part03 我们讲透了 Reactor 事件驱动 + epoll 多路复用:一个线程如何服务十万连接,数据如何在 ByteBuf 里少拷贝地流过。那只是"传输层"的零件。本 Part 要往上搭两层——把"传输"封装成"远程方法调用"(RPC),把"内存/网络"封装成"加速存储"(缓存)。

本部分的认知顺序:RPC 四要素(代理/序列化/传输/协议)→ 端到端流程 → Dubbo/gRPC/Thrift 各自怎么实例化这套零件 → 本地缓存(淘汰算法 W-TinyLFU 才是真难点)→ 远程缓存 Redis(单线程事件循环 + 六种底层编码)→ 用对比看清框架到底替你做了哪些取舍。
直觉锚点:RPC 解决的是"进程 A 想调进程 B 的函数";缓存解决的是"同一份计算/IO 别做第二遍"。两者都是"用空间换时间",只是空间一个在协议栈里、一个在存储里。

1. RPC 本质:四要素与端到端调用流程

1.1 为什么需要 RPC:解耦与跨进程

单机时代,调用一个函数就是一条指令(栈帧压入)。但在分布式系统里,服务提供者和服务消费者往往在不同进程、不同机器、甚至不同语言里。问题来了:

RPC(Remote Procedure Call)的目标:让"调远程函数"在代码层面看起来和"调本地函数"一模一样。你写 userService.getUser(1),框架在背后把它变成"序列化 → 发网络包 → 对面反序列化 → 执行 → 结果发回来"。一句话记住 RPC = 把"跨网络的麻烦"藏进一个代理对象里。

1.2 四要素:代理 / 序列化 / 传输 / 协议

要素它解决的问题底层机制
代理(Proxy / Stub)让远程调用"像本地方法"JDK 动态代理 / CGLIB / 代码生成;客户端 Stub 把方法调用编码成请求,服务端 Skeleton 解码后分发
序列化(Serialization)对象 ↔ 字节流Hessian2 / Protobuf / Thrift / JSON / Kryo;核心是"紧凑、跨语言、可逆"
传输(Transport)字节怎么走网络Part03 的 Netty NIO/Reactor、TCP 长连接、HTTP/2 流;解决"谁就绪了才读写"
协议(Protocol)字节流的"语法"定长头(magic/version/length)+变长体;或 HTTP/2 帧;约定"哪几个字节表示什么"
为什么"协议"要单列? 序列化只管"对象→字节",但一堆字节发过去,对面怎么知道"这一帧哪开始、哪结束、是请求还是响应、是不是心跳"?这靠协议(帧格式)。协议和序列化是正交的两件事:Protobuf 是序列化,但 gRPC 真正用的是"HTTP/2 + Protobuf"——HTTP/2 才是它的传输协议。

1.3 端到端调用流程

下面这张图是整个 RPC 的"心脏"。记住它,所有框架都只是这图的实例化。

Consumer(调用方进程) ① 业务代码 user.getUser(1) ② Stub/代理:方法名+参数 编码 ③ 序列化 → 字节流 ④ 协议封帧 + 网络发送(TCP) ⑦ 收到响应:反序列化 ⑧ 返回给业务代码 Provider(服务方进程) ⑤ 收包:协议解帧 ⑥ 反序列化 → 方法+参数 Skeleton:本地真正执行 结果序列化 → 回包 (沿④原路反向返回) 响应帧回传 请求字节流 响应字节流
图 1:RPC 端到端调用流程。客户端 Stub 把"方法语义"翻译为"字节语义",服务端 Skeleton 还原并执行,全程对业务透明。
记忆锚:请求方向 = 业务代码 → 代理(编码) → 序列化 → 协议封帧 → 网络 → 解帧 → 反序列化 → 本地执行 → 回包。每一步都是"上一层是下一层的输入",这就是"分层"的本质。

2. RPC 的外围能力:服务发现 / 负载均衡 / 容错 / 超时

四要素只解决"一次调用怎么发生"。真实生产里,RPC 框架 80% 的复杂度在"外围"——因为网络会抖、机器会挂、实例会变。

2.1 服务发现(Service Discovery)

调用方不能把"对端 IP:端口"写死。于是引入注册中心(见 Part04:ZK / Nacos / etcd)。流程:Provider 启动时把自己的地址注册上去;Consumer 订阅,拿到一份"可用实例列表"缓存在本地。实例上下线时注册中心推送变更。衔接 Part04 这本质就是"分布式协调 + Watcher 监听"。

2.2 负载均衡(Load Balancing)

一个服务有多个实例,调用方要决定"这次打给谁"。常见策略:

2.3 容错(Fault Tolerance)

策略含义适用
Failover(失败重试)换一个实例再试读操作、幂等写
Failfast(快速失败)出错立刻抛非幂等写(不能重)
Failsafe(安全失败)忽略异常,记日志审计/打点这类"丢了无所谓"
Forking(并行调用)同时打多个,取最快延迟极敏感
Mock / 降级服务挂了返回兜底熔断后保可用
幂等性是前提! Failover 重试在"写"上可能重复扣款。所以重试只应施加于幂等接口(多次执行结果一致,如用唯一请求号去重)。这是分布式里反复出现的原则。

2.4 超时与重试的"死亡螺旋"

超时(Timeout)是 RPC 的"生命线":不超时,一个慢调用能拖垮整条调用链。但超时 + 重试若设错,会放大故障:A 调 B 超时重试 → B 压力翻倍 → 更超时 → 雪崩。一句话记住 超时是给"下游"的,不是给"自己"的——你设置的超时,应小于你对上游承诺的超时(留出网络与排队余量)。

3. Dubbo:Netty 长连接 + Hessian2 + SPI + 责任链

Apache Dubbo 是阿里开源、国内最主流的 Java RPC 框架。它把 Part03 的 Netty 直接拿来当传输层。

3.1 传输:Netty NIO 长连接

3.2 序列化:Hessian2(默认)

Hessian2 是二进制、跨语言、自描述的序列化:字段按"类型标签 + 数据"写,无需先交换 schema,兼容性好,但体积不如 Protobuf 紧凑、速度也略慢。Dubbo 也支持 Kryo/FastJson2/Protobuf 等。

3.3 SPI 扩展机制(Dubbo 的"灵魂")

Dubbo 几乎每个组件(协议、注册中心、负载均衡、Filter)都可插拔,靠的是Dubbo SPI(比 JDK SPI 强:支持按名字加载、@Adaptive 动态适配、依赖注入)。你写一个实现类,在 META-INF/dubbo/ 下配个 key,框架就能加载——这正是"框架只是机制的实例化"的范本。取舍 灵活换组件,代价是配置与调试面变大。

3.4 Filter 责任链

一次调用会穿过一串 Filter(类似 Servlet Filter):监控、限流、traceId 传递、参数校验……每个 Filter 决定"放行或拦截"。这是典型的责任链模式,把横切逻辑与业务解耦。

3.5 多协议:dubbo / tri(Triple)

传统 dubbo 协议是私有二进制,跨语言偏弱。Dubbo 3 推出 Triple 协议:基于 HTTP/2 + Protobuf,天然支持流式调用与多语言,向后兼容 gRPC。这也是 Dubbo 与 gRPC "殊途同归"的证据——底层都奔着 HTTP/2 + Protobuf 去。

3.5 框架架构对比:Dubbo vs gRPC

把两个框架拆成"协议 / 序列化 / 网络"三层,会发现它们都在复用同一套底层零件。

Dubbo(Java 生态为主) 业务接口(@DubboReference) 序列化:Hessian2(可换) 协议:dubbo 私有帧 / tri(HTTP2) 传输:Netty NIO 长连接 gRPC(多语言) .proto 定义的 Stub 序列化:Protobuf(强制) 协议:HTTP/2(帧/流) 传输:Netty(Java 实现) 共同点:都建立在 Netty + Reactor + 长连接上;tri 协议让两者底层趋同
图 2:Dubbo 与 gRPC 的分层对比。两者差异主要在"协议与序列化选型",传输层都复用 Netty 的事件循环。

4. gRPC:HTTP/2 + Protobuf + 流

4.1 为什么选 HTTP/2

gRPC 不用私有 TCP 协议,而是跑在 HTTP/2 上,好处是"复用 Web 基础设施"(网关、负载均衡、TLS 都现成)。HTTP/2 的三个关键能力:

4.2 Protobuf:IDL 驱动的紧凑序列化

Protobuf(Protocol Buffers)是 Google 的二进制序列化:

// user.proto 示例
message User { int64 id = 1; string name = 2; }
service UserService { rpc GetUser (UserReq) returns (User); }

4.3 Streaming 与多语言

gRPC 四种调用模式:Unary(一问一答)、Server Streaming、Client Streaming、Bidirectional Streaming。基于 HTTP/2 的流,chat、实时推送、大文件分块都天然支持。这也是它相比 Dubbo 传统模式的一大优势。

一句话记住:gRPC = HTTP/2(传输/多路复用)当作"管道" + Protobuf(IDL 二进制)当作"货物" + 代码生成当作"装卸工"。多语言不是魔法,是"同一份 .proto 生成多份代码"。

5. Thrift:多协议多传输的跨语言 IDL

Apache Thrift(Facebook 开源,现 Apache)是最早的跨语言 RPC 之一,设计哲学是"协议和传输彻底解耦"。

Thrift vs gRPC 取舍: Thrift 的"协议/传输可任意组合"给了极致灵活(适合内部异构系统),但也意味着"选型和调优责任在用户";gRPC 把组合固定成"HTTP/2+Protobuf",开箱即用、生态统一,但不够灵活。这就是"灵活 vs 约定"的经典权衡。

6. 三大框架对比:协议 / 序列化 / 语言 / 性能 / 复杂度

维度DubbogRPCThrift
传输协议dubbo 私有帧 / tri(HTTP/2)HTTP/2可配(TCP/HTTP 均可)
序列化Hessian2(可换 Kryo/PB)Protobuf(强制)TBinary / TCompact(可换)
跨语言Java 为主,tri 补强原生多语言(一等公民)原生多语言
服务治理强(注册中心/路由/限流内置)弱(需外接,如 k8s/istio)弱(只管传输)
流式tri 支持原生 4 种 stream有限
学习/运维复杂度中高(概念多)中(概念清晰)中(需自己选型组合)
典型场景国内 Java 微服务全家桶跨语言、云原生 gRPC 网关内部异构系统、老牌跨语言
底层视角总结:三者都是"代理 + 序列化 + 网络传输 + 协议"四要素。差异只是:(1) 协议选私有还是 HTTP/2;(2) 序列化选紧凑 IDL 还是自描述;(3) 治理能力的"内置 vs 外挂"。看懂四要素,框架差异就是"选型表"而非"新知识"。

7. 本地缓存:内存 KV + 淘汰 + 并发

7.1 缓存的本质

缓存(Cache)的底层定义就三件事:

一句话记住 缓存 = 用"更快但更小的空间"保存"更慢但更大的空间"里的热点副本,省下重复的计算/IO。代价是"一致性"——副本可能过期。

7.2 地基:ConcurrentHashMap

最朴素的本地缓存就是把数据塞进 ConcurrentHashMap。它靠分段/CAS + synchronized 细粒度锁(Java 8 后:数组每个桶头节点 CAS,冲突才锁单个桶)实现高并发读写。但它没有淘汰——数据只增不减,会 OOM。所以"真正的缓存" = ConcurrentHashMap + 淘汰算法 + 过期机制。

为什么不能直接用 HashMap + 全局锁? 全局锁下读写互斥,高并发缓存反而成了瓶颈。CHM 用"分桶细粒度锁"把冲突概率降到极低,是后面所有缓存并发设计的起点。

8. Caffeine:W-TinyLFU 频率统计算法(本地缓存天花板)

Caffeine 是 Java 本地缓存事实标准(Spring Boot 2 的 @Cacheable 默认后端)。它的灵魂是 W-TinyLFU(Window TinyLFU) 淘汰算法——比经典 LRU 命中率高得多,尤其在"扫描污染"(一次性遍历把热点全挤出去)场景下。

8.1 为什么 LRU 不够

LRU(最近最少使用)只记"时间",不记"频率"。一次全表扫描会按时间顺序把缓存全换成一次性数据,真正的热点被踢光——这叫扫描污染。LFU(最不经常使用)记频率,但老热点"积重难返"(早期高频、现在不用了却因计数高一直占位)。

8.2 W-TinyLFU 的三段结构

Window(窗口) LRU 小区域 新条目先来这里 占总容量 ~1% (接纳新流量) TinyLFU 过滤器 Count-Min Sketch 近似记录访问频率 周期性衰减(aging) 准入决策:新 vs 受害者 Main(主缓存) SLRU 两段: Probation 试用 Protected 保护 存真正高频项 窗口满 准入成功 Ring Buffer 样本 记录最近访问,用于 重置频率统计(防误判)
图 3:Caffeine W-TinyLFU 三段结构。新条目先进 Window,被踢时由 TinyLFU 频率过滤器与 Main 中的"受害者"比频率,胜者才准入 Main——以此同时抵抗"扫描污染"和"老热点滞留"。

8.3 关键机制拆解

W-TinyLFU 记忆锚:Window 接新客 → TinyLFU 数频率 → 比主缓存受害者 → 高频者留下。一句话:既看"新不新"也看"火不火",还会定期"翻旧账"。这就是它碾压 LRU 的底层原因。

9. Guava Cache / Ehcache:分段锁与三级存储

9.1 Guava Cache(Caffeine 的前身)

9.2 Ehcache:堆内 / 堆外 / 磁盘三级

Ehcache 的特色是三级存储层级

层级间按"热→冷"下沉(TTL/TLI 控制)。这本质是"用空间换时间"的多级化:越热的数据越贴近 CPU。

演进视角:Guava → Caffeine 是"淘汰算法 + 并发模型"的升级;Ehcache 则是"存储层级"的扩展。选型时:纯进程内高频小数据用 Caffeine;需要跨 JVM 共享/持久化才上 Redis(见下)。

10. 缓存三大问题:穿透 / 击穿 / 雪崩

用缓存就绕不开这三个经典故障。先分清"敌人是谁",再选对策。

穿透(Penetration) 查不存在的 key 缓存无→每次打 DB 对策:布隆过滤器 / 缓存空值 击穿(Breakdown) 单个热点 key 过期 瞬间大量请求 同时击穿到 DB 对策:互斥锁/单飞 / 逻辑过期 雪崩(Avalanche) 大量 key 同时过期 或缓存宕机 请求全压 DB 对策:随机 TTL / 多级/高可用 共同点:请求"绕过缓存"直击 DB —— DB 才是真正的瓶颈与单点 穿透 = 数据本就不存在(恶意/误查) | 击穿 = 单点过期(热点) | 雪崩 = 大面积失效(时间集中) 兜底:限流 + 降级 + 熔断,保证 DB 不被打死;多级缓存分散失效面
图 4:缓存三大问题对比。三者都表现为"请求穿透到 DB",但诱因与对策不同,需对症下药。

10.1 缓存穿透:查不存在的数据

现象:请求一个数据库里也没有的 key,缓存里永远没有,每次都打到 DB(可能被恶意刷不存在的 ID)。

10.2 缓存击穿:单点热点过期

现象:某个超级热点 key(如首页配置)突然过期,瞬间成千上万请求同时发现"缓存没了",一窝蜂回源 DB。

10.3 缓存雪崩:大面积同时失效

现象:大量 key 在同一时刻过期(比如初始化时设了相同 TTL),或缓存集群整体宕机,请求全部涌向 DB。

本质一句话:三大问题都是"缓存没挡住,DB 被裸奔击中"。设计缓存时永远假设"缓存可能失效",给 DB 留好限流/降级/熔断的后路。

11. 远程缓存 Redis:单线程事件循环与六种底层结构

Redis 是"远程缓存"的事实标准,也常被当 KV 数据库。它快得违反直觉——核心命令执行居然是单线程的

11.1 为什么单线程还这么快

精确说法(避免被面试官抓包): Redis 6.0 之前网络 IO 与命令执行都单线程;6.0 引入多线程网络 IO(读 socket、协议解析、写回可并行,由 io-threads 处理),但命令执行仍是单线程。所以"单线程"特指"命令执行的串行化",这是它无需锁的根本原因。

11.2 对象与编码:type + encoding + ptr

Redis 对外暴露 5 种"类型"(string/hash/list/set/zset),但内部用多种"编码(encoding)"实现,按数据量自动择优切换。这才是 Redis 省内存的精髓。

RedisObject type | encoding | ptr + LRU/LFU 字段 string hash list set zset stream 底层编码(encoding)映射: string → int / embstr(≤44B) / raw(大) | list → quicklist( ziplist+链表 ) hash → listpack(小) / hashtable(大) | set → intset(纯整数) / hashtable zset → listpack(小) / skiplist+dict(大) | 通用底层:sds / ziplist→listpack / dict / intset / skiplist 演进:ziplist(连续内存省空间但更新慢) → listpack(Redis 7 默认替代,解决级联更新)
图 5:Redis 对象(type)与底层编码(encoding)是多对多关系。同一类型按数据规模自动切换更省内存的编码。

11.3 六种底层结构逐个拆

编码演进的底层逻辑:Redis 始终在"内存占用 vs 操作效率"间权衡——数据小用紧凑编码(省内存),数据大换高效结构(保速度)。这和你选 LRU vs TinyLFU 是同一类"取舍思维"。

12. Redis 持久化 / 集群 / 过期淘汰 / 高级能力

12.1 持久化:RDB 与 AOF(及混合)

12.2 高可用:主从 / 哨兵 / Cluster

12.3 集群槽:16384 个槽

Redis Cluster 把整个 key 空间划分为 16384 个槽(hash slot):对 key 算 CRC16(key) & 16383 得到槽号,槽被平均分配到各主节点。 migrating/importing 机制支持槽在节点间迁移(扩缩容)。客户端缓存在本地的"槽→节点"映射,直连正确节点。

key → CRC16(key) & 16383 → 槽号(0..16383) Master A 槽 0 ~ 5460 Master B 槽 5461 ~ 10922 Master C 槽 10923 ~ 16383
图 6:Redis Cluster 槽分配。16384 是 2^14,足够分片又不会让槽映射表过大(集群总线 gossip 消息要带槽位图)。

12.4 过期与淘汰(近似 LRU / LFU)

12.5 高级能力:Pipeline / 事务 / Lua / PubSub

13. Memcached:多线程 Slab 分配器的简单哲学

Memcached 是比 Redis 更早的分布式缓存,设计哲学是"简单到极致"。

Redis vs Memcached 取舍: 要"丰富数据结构 + 持久化 + 高可用 + 生态"选 Redis(单线程靠内存+事件循环,命令多样也很能打);要"纯 KV、极致简单、多核线性扩展"且不需要持久化,Memcached 仍是好选择。本质还是"功能丰富 vs 极简高性能"的权衡。

14. 一句话路线总结

RPC 底层路线:代理(把远程藏成本地) + 序列化(对象↔字节) + 传输(Netty/Reactor 长连接) + 协议(帧格式)。Dubbo/gRPC/Thrift 只是这四块的不同"选型组合";外围的服务发现/负载均衡/容错/超时才是生产复杂度所在。

缓存底层路线:KV 存储 + 淘汰策略(从 LRU 到 W-TinyLFU 频率统计) + 并发控制(分段锁→无锁 CMS);远程缓存再多一层"单线程事件循环 + 底层编码择优 + 持久化/集群"。Redis 靠"内存+单线程无锁+IO 多路复用"快,靠"槽分片+哨兵"扩展高可用。

一句话记住: RPC 与缓存都是"用空间换时间"——前者把"远程"伪装成"本地"省心智,后者把"慢存储"复制成"快存储"省时间。框架只是底层机制的实例化与取舍,看懂四要素与三类缓存机制,所有框架差异都只是选型表。
Part 06 · RPC 与缓存:远程调用与加速的底层。本篇承接 Part03(Netty/Reactor 网络层)与 Part04(ZK/Nacos 注册中心),并为后续分布式事务/限流熔断打底。
配套 Markdown 版本见同名 .md 文件。图示建议结合 HTML 版查看(含 6 张内联 SVG:RPC 端到端流程、Dubbo vs gRPC 架构、Caffeine W-TinyLFU、Redis 对象-编码映射、缓存三大问题、Redis 集群槽)。