RPC 与缓存:远程调用与加速的底层
Dubbo/gRPC/Thrift + 本地缓存 Caffeine + 远程缓存 Redis
本主题的底层路线(万变不离其宗):
RPC 底层 = 代理(stub) + 序列化 + 网络传输(NIO/Reactor) + 协议;
缓存底层 = 内存/远程 KV + 淘汰策略 + 并发控制(或持久化+事件循环)。
两者都是"用空间换时间":RPC 用"把远程调用伪装成本地调用"省下分布式心智成本,缓存用"把结果提前放进更快的存储介质"省下重复计算/IO 成本。
一句话:先想清"跨进程调用要靠哪四块零件拼起来"和"加速要靠哪三类机制(结构/淘汰/并发)",框架只是机制的实例化与取舍。区别只在于——缓存离"计算"有多远:本地缓存在进程内,远程缓存在网络另一端。
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:解耦与跨进程
单机时代,调用一个函数就是一条指令(栈帧压入)。但在分布式系统里,服务提供者和服务消费者往往在不同进程、不同机器、甚至不同语言里。问题来了:
- 地址问题:B 在哪台机器、哪个端口?IP 写死就失去了弹性。
- 协议问题:网络只认识字节流,你传的是 Java 对象,必须"拆成字节 → 对面再拼回来"。
- 调用语义问题:本地调用抛异常立刻知道,远程调用可能"请求丢了、响应超时了、服务挂了",需要超时/重试/熔断等语义。
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 的"心脏"。记住它,所有框架都只是这图的实例化。
图 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)
一个服务有多个实例,调用方要决定"这次打给谁"。常见策略:
- Random / Weighted Random:简单,权重应对机器性能不均。
- Round Robin / Weighted RR:轮流,平滑但无视实例真实负载。
- Least Active:打给"当前最闲"的(活跃请求数最少),更贴近真实负载。
- Consistent Hash:同参数总打到同一实例,适合有状态/需本地缓存亲和。
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 长连接
- 长连接:Consumer 与 Provider 建立 TCP 长连接并复用,避免每次 RPC 都 TCP 三次握手 + 慢启动。一个连接上跑成千上万次请求(靠请求 ID 匹配响应)。
- 线程模型:Boss 线程 accept,Worker(IO 线程)负责编解码与读写;业务方法在独立的"业务线程池"执行(Dispatcher 决定哪些事件进业务线程:all / message / connection / direct 等)。
- 协议:dubbo 协议是自定义二进制帧——固定 16 字节头(magic=0xdabb、请求/响应标志、序列化类型、请求 ID、body 长度)+ 变长 body。头部就解决了"粘包/解帧"问题。
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
把两个框架拆成"协议 / 序列化 / 网络"三层,会发现它们都在复用同一套底层零件。
图 2:Dubbo 与 gRPC 的分层对比。两者差异主要在"协议与序列化选型",传输层都复用 Netty 的事件循环。
4. gRPC:HTTP/2 + Protobuf + 流
4.1 为什么选 HTTP/2
gRPC 不用私有 TCP 协议,而是跑在 HTTP/2 上,好处是"复用 Web 基础设施"(网关、负载均衡、TLS 都现成)。HTTP/2 的三个关键能力:
- 多路复用(Multiplexing):一个 TCP 连接上并发多个"流(stream)",帧带 stream ID 交错传输,彻底解决 HTTP/1.1 的"队头阻塞"。一次 RPC = 一个 stream,天然无连接数爆炸。
- 头部压缩(HPACK):HTTP 头(如 :method、content-type)大量重复,HPACK 用静态/动态字典 + 哈夫曼编码压缩,省带宽。
- 流控 + 二进制帧:帧是二进制且带优先级,比 HTTP/1.1 的文本协议解析更快。
4.2 Protobuf:IDL 驱动的紧凑序列化
Protobuf(Protocol Buffers)是 Google 的二进制序列化:
- IDL 先行:先写
.proto 定义 message 和 service,再用 protoc 生成各语言代码——这就是"跨语言"的来源(一份 schema 生成 Java/Go/C++/Python…)。
- 紧凑高效:字段用
(field_number << 3) | wire_type 的 tag + 变长整数(varint)编码,不带字段名字符串,体积远小于 JSON,序列化/反序列化是 memcopy 级速度。
- 向后兼容:新增字段用新编号、旧客户端忽略未知字段,天然支持接口演进。
// 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 之一,设计哲学是"协议和传输彻底解耦"。
- 多协议:TBinaryProtocol(简单二进制)、TCompactProtocol(压缩二进制,类似 Protobuf 的 varint)、TJSONProtocol 等。序列化与协议绑定,但可替换。
- 多传输:TSocket(阻塞)、TNonblockingTransport(非阻塞,配 TNonblockingServer 走多路复用)、TFramedTransport(按帧切分,解决粘包)等。
- IDL:写
.thrift 定义 struct/service,thrift -gen 生成各语言客户端/服务端骨架。
- 非阻塞服务模型:TNonblockingServer / THsHaServer(半同步半异步)靠多路复用服务大量连接。
Thrift vs gRPC 取舍: Thrift 的"协议/传输可任意组合"给了极致灵活(适合内部异构系统),但也意味着"选型和调优责任在用户";gRPC 把组合固定成"HTTP/2+Protobuf",开箱即用、生态统一,但不够灵活。这就是"灵活 vs 约定"的经典权衡。
6. 三大框架对比:协议 / 序列化 / 语言 / 性能 / 复杂度
| 维度 | Dubbo | gRPC | Thrift |
| 传输协议 | 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)的底层定义就三件事:
- 一个 KV 存储:key → value,且"存得离 CPU 越近越快"(进程内内存 > 远程网络)。
- 一个淘汰策略:内存有限,满了"踢谁"?FIFO / LRU / LFU / TinyLFU。
- 一套并发控制:多线程读写不能脏读、不能丢更新、不能加锁把性能拖死。
一句话记住 缓存 = 用"更快但更小的空间"保存"更慢但更大的空间"里的热点副本,省下重复的计算/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 的三段结构
图 3:Caffeine W-TinyLFU 三段结构。新条目先进 Window,被踢时由 TinyLFU 频率过滤器与 Main 中的"受害者"比频率,胜者才准入 Main——以此同时抵抗"扫描污染"和"老热点滞留"。
8.3 关键机制拆解
- Count-Min Sketch(CMS)频率统计:用几个哈希函数 + 二维计数数组近似记录每个 key 的访问次数。牺牲一点精度(可能冲突高估),换来 O(1) 且无锁的超高并发统计——这是它能扛高 QPS 的关键。
- 窗口(Window)+ 主缓存(SLRU):1% 容量的 LRU 窗口接纳新流量,避免"刚来就被踢";主缓存用分段 LRU(Probation 试用段 + Protected 保护段),命中一次就从试用段升到保护段。
- 准入/淘汰(Admission):窗口满了,候选者要和主缓存里"即将被淘汰的受害者"比频率,只有候选频率更高才替换——这就是"用频率对抗扫描污染"。
- 周期性衰减(Aging):CMS 计数定期右移(除以 2),让老热点"遗忘",解决 LFU 的"积重难返"。
- Ring Buffer 样本 + 异步重置:记录近期访问样本,当样本填满才整体重置频率计数,既防误判又几乎零开销。
- 分阶段/异步过期:Caffeine 的过期(expireAfterWrite / expireAfterRead)与刷新(refreshAfterWrite)在"读时顺带"或后台线程执行,不阻塞 get;写时也不全局加锁。
W-TinyLFU 记忆锚:Window 接新客 → TinyLFU 数频率 → 比主缓存受害者 → 高频者留下。一句话:既看"新不新"也看"火不火",还会定期"翻旧账"。这就是它碾压 LRU 的底层原因。
9. Guava Cache / Ehcache:分段锁与三级存储
9.1 Guava Cache(Caffeine 的前身)
- 分段锁(Segment):内部把哈希表分成若干段,每段一把锁,并发读写只在段内互斥——思路同 CHM 早期版本,但粒度仍比 Caffeine 粗。
- 定时回收:基于"写后过期(expireAfterWrite)""读后过期(expireAfterAccess)",惰性 + 周期性清理。
- refresh:值过期时先返回旧值,后台异步加载新值,避免"同一时刻一堆请求都去回源"(见 §10 击穿)。
9.2 Ehcache:堆内 / 堆外 / 磁盘三级
Ehcache 的特色是三级存储层级:
- 堆内(Heap):最快,但受 GC 影响、容量最小。
- 堆外(Off-Heap):用 DirectByteBuffer 直接内存,不受 GC 管理(避免大对象 GC 停顿),但读写要序列化/反序列化。
- 磁盘(Disk):持久化、容量最大、最慢。
层级间按"热→冷"下沉(TTL/TLI 控制)。这本质是"用空间换时间"的多级化:越热的数据越贴近 CPU。
演进视角:Guava → Caffeine 是"淘汰算法 + 并发模型"的升级;Ehcache 则是"存储层级"的扩展。选型时:纯进程内高频小数据用 Caffeine;需要跨 JVM 共享/持久化才上 Redis(见下)。
10. 缓存三大问题:穿透 / 击穿 / 雪崩
用缓存就绕不开这三个经典故障。先分清"敌人是谁",再选对策。
图 4:缓存三大问题对比。三者都表现为"请求穿透到 DB",但诱因与对策不同,需对症下药。
10.1 缓存穿透:查不存在的数据
现象:请求一个数据库里也没有的 key,缓存里永远没有,每次都打到 DB(可能被恶意刷不存在的 ID)。
- 对策 1 · 缓存空值:即使 DB 查不到,也把一个"空标记"写进缓存并设短 TTL,挡住重复查询。代价:缓存里多一些空对象。
- 对策 2 · 布隆过滤器(Bloom Filter):在缓存前放一个 BF,所有合法 key 预先灌入。查询先问 BF,"一定不存在"的直接拒绝,绝不打 DB。BF 用多个哈希位图,有误判(说存在可能不存在)但绝不漏判——这正是它适合"拦截不存在"的原因。
10.2 缓存击穿:单点热点过期
现象:某个超级热点 key(如首页配置)突然过期,瞬间成千上万请求同时发现"缓存没了",一窝蜂回源 DB。
- 对策 1 · 互斥锁 / 单飞(Singleflight):第一个发现过期的请求去加载并加分布式锁,其余请求等待/重试,保证"只有一个去回源"。Go 的 singleflight、Caffeine 的 refresh 都是这思路。
- 对策 2 · 逻辑过期:value 里带过期时间,过期后不删,由后台异步刷新;读请求永远拿到"旧但可用"的值,不阻塞。
10.3 缓存雪崩:大面积同时失效
现象:大量 key 在同一时刻过期(比如初始化时设了相同 TTL),或缓存集群整体宕机,请求全部涌向 DB。
- 对策 1 · 随机化 TTL:给过期时间加随机抖动(如 base + random),避免"集体到点"。
- 对策 2 · 多级缓存:本地缓存 + Redis 多级,Redis 挂了本地还能顶一阵。
- 对策 3 · 高可用 + 限流降级:Redis 用集群/哨兵保证不整体挂;上游限流、熔断,DB 前最后一道闸。
本质一句话:三大问题都是"缓存没挡住,DB 被裸奔击中"。设计缓存时永远假设"缓存可能失效",给 DB 留好限流/降级/熔断的后路。
11. 远程缓存 Redis:单线程事件循环与六种底层结构
Redis 是"远程缓存"的事实标准,也常被当 KV 数据库。它快得违反直觉——核心命令执行居然是单线程的。
11.1 为什么单线程还这么快
- 纯内存操作:所有数据在内存,没有磁盘寻道。命令执行是"算指针"而非"等 IO"。
- 单线程无锁:命令串行执行,没有并发竞争、没有锁开销、没有上下文切换。KV 操作本就简单,单核就能跑到很高 QPS。
- IO 多路复用 + 事件循环:网络读写用 epoll(Linux)做 Reactor 事件循环(呼应 Part03),一个线程同时盯上万连接,"谁就绪处理谁"。衔接 Part03
- 高效数据结构:针对场景定制的底层编码(见下),避免不必要的内存与计算浪费。
精确说法(避免被面试官抓包): Redis 6.0 之前网络 IO 与命令执行都单线程;6.0 引入多线程网络 IO(读 socket、协议解析、写回可并行,由 io-threads 处理),但命令执行仍是单线程。所以"单线程"特指"命令执行的串行化",这是它无需锁的根本原因。
11.2 对象与编码:type + encoding + ptr
Redis 对外暴露 5 种"类型"(string/hash/list/set/zset),但内部用多种"编码(encoding)"实现,按数据量自动择优切换。这才是 Redis 省内存的精髓。
图 5:Redis 对象(type)与底层编码(encoding)是多对多关系。同一类型按数据规模自动切换更省内存的编码。
11.3 六种底层结构逐个拆
- SDS(Simple Dynamic String):不是 C 字符串。结构里存了 len(长度)、free(剩余)、buf。好处:O(1) 取长度(C 串要遍历)、二进制安全(不遇 \0 截止)、预分配减少扩容。这是 Redis 所有"字符串/键"的基石。
- ziplist / listpack:连续内存的紧凑列表,省内存(无指针开销)。但中间插入要"级联搬移"——所以只用于小数据。Redis 7 用 listpack 替代 ziplist,彻底消除了 ziplist 的"级联更新"缺陷(每个节点自解释长度,不再依赖前节点)。
- skiplist(跳表):zset 的有序结构。多层"索引"让查找/插入/删除都是 O(log n),且范围查询天然高效(顺着底层链表走)。相比红黑树,跳表实现简单、区间遍历友好、并发更易做。
- dict(哈希表):Redis 自己实现的哈希表(拉链法),用于 hash/set 的大编码、以及"数据库本身"(key→value 的全局映射)。渐进式 rehash:扩容时不是一次性搬,而是每次操作顺带搬一点,避免卡顿。
- intset(整数集合):set 全是整数且量小时用,紧凑数组,按数值大小有序存,查找二分。
- quicklist:list 的底层 = "链表 + ziplist/listpack",把大列表切成若干小段(每段是紧凑列表),兼顾"省内存"和"插入不爆"。
编码演进的底层逻辑:Redis 始终在"内存占用 vs 操作效率"间权衡——数据小用紧凑编码(省内存),数据大换高效结构(保速度)。这和你选 LRU vs TinyLFU 是同一类"取舍思维"。
12. Redis 持久化 / 集群 / 过期淘汰 / 高级能力
12.1 持久化:RDB 与 AOF(及混合)
- RDB(快照):某一时刻把内存全量 dump 成二进制文件。恢复快、文件小,但"两次快照间宕机丢数据"(最多丢一个周期)。
- AOF(追加日志):把每条写命令追加到日志(先写内存再 fsync 到磁盘)。数据更全(可每秒/每次 fsync),但文件大、恢复慢。AOF 会定期rewrite 重写压缩(把多条命令合成最终状态)。
- 混合持久化(Redis 4.0+):AOF 文件头部放一份 RDB 快照,后面追加增量命令——兼顾"恢复快"和"数据全"。这是生产推荐配置。
12.2 高可用:主从 / 哨兵 / Cluster
- 主从复制:主写、从读,从节点异步复制主节点。解决"读扩展"和"备份"。异步意味着主挂可能丢少量未同步数据。
- 哨兵(Sentinel):监控主节点,主挂了自动选一个从提升为新主并通知客户端——解决"主挂了谁来顶"。
- Cluster(分片):数据按槽(slot)分片到多主,水平扩展写能力。
12.3 集群槽:16384 个槽
Redis Cluster 把整个 key 空间划分为 16384 个槽(hash slot):对 key 算 CRC16(key) & 16383 得到槽号,槽被平均分配到各主节点。 migrating/importing 机制支持槽在节点间迁移(扩缩容)。客户端缓存在本地的"槽→节点"映射,直连正确节点。
图 6:Redis Cluster 槽分配。16384 是 2^14,足够分片又不会让槽映射表过大(集群总线 gossip 消息要带槽位图)。
12.4 过期与淘汰(近似 LRU / LFU)
- 过期删除:被动(访问时发现过期就删)+ 主动(定期随机抽一批删过期)。不是"到点立刻删",避免集中 CPU 抖动。
- 内存淘汰(maxmemory-policy):内存满时踢数据。近似 LRU(随机采样 N 个比最久未用,而非全局排序,省开销);LFU(Redis 4+,用 8-bit Morris 计数器记访问频率 + 衰减,比 LRU 更抗扫描)。
12.5 高级能力:Pipeline / 事务 / Lua / PubSub
- Pipeline:把多条命令打包一次网络往返发出,减少 RTT——注意它不是事务,只是"批量发"。
- 事务(MULTI/EXEC):把命令排队,EXEC 时串行执行(单线程天然隔离),但不支持"执行中失败回滚"(不是 ACID 的回滚)。
- Lua 脚本:把一段逻辑发到服务端原子执行(单线程下不会被打断),用于"读-改-写"原子化、限流令牌桶等。
- Pub/Sub:发布订阅,简单的消息广播(不持久、不保证到达,重负载场景不如专业 MQ,见 Part05)。
13. Memcached:多线程 Slab 分配器的简单哲学
Memcached 是比 Redis 更早的分布式缓存,设计哲学是"简单到极致"。
- 多线程:和 Redis 单线程相反,Memcached 用多线程处理请求(每线程一个事件循环 + 细粒度锁),靠多核线性扩展。简单 KV 场景下吞吐高。
- Slab 分配器:内存被切成若干 page,按"块大小分级(slab class)"预分配——比如 64B、128B、256B… 每个 class 的 chunk 固定大小,避免内存碎片。取舍 固定 chunk 会有"内部碎片"(存 70B 占 128B),但换来"零碎片 + O(1) 分配"。
- LRU:每个 slab class 独立一个 LRU 链表,满了踢最久未用。
- 只做 KV:无持久化、无复杂结构、无事务——纯粹"内存 KV + 网络",因此极快极稳,但功能远少于 Redis。
Redis vs Memcached 取舍: 要"丰富数据结构 + 持久化 + 高可用 + 生态"选 Redis(单线程靠内存+事件循环,命令多样也很能打);要"纯 KV、极致简单、多核线性扩展"且不需要持久化,Memcached 仍是好选择。本质还是"功能丰富 vs 极简高性能"的权衡。
14. 一句话路线总结
RPC 底层路线:代理(把远程藏成本地) + 序列化(对象↔字节) + 传输(Netty/Reactor 长连接) + 协议(帧格式)。Dubbo/gRPC/Thrift 只是这四块的不同"选型组合";外围的服务发现/负载均衡/容错/超时才是生产复杂度所在。
缓存底层路线:KV 存储 + 淘汰策略(从 LRU 到 W-TinyLFU 频率统计) + 并发控制(分段锁→无锁 CMS);远程缓存再多一层"单线程事件循环 + 底层编码择优 + 持久化/集群"。Redis 靠"内存+单线程无锁+IO 多路复用"快,靠"槽分片+哨兵"扩展高可用。
一句话记住: RPC 与缓存都是"用空间换时间"——前者把"远程"伪装成"本地"省心智,后者把"慢存储"复制成"快存储"省时间。框架只是底层机制的实例化与取舍,看懂四要素与三类缓存机制,所有框架差异都只是选型表。