我在一个内部短链服务上吃过一次大亏:数据量涨到几十亿之后,单节点的内存缓存明显扛不住,于是想当然地上了分片,结果扩容节点那晚,缓存命中率直接崩到个位数,数据库连接池被打满,服务差点雪崩。复盘时发现问题不在“缓存”,而在“分布式”这三个字——哈希重分布、节点感知、数据迁移,每一样都比单机缓存难得多。那之后我花了三周时间从零手写了一套轻量分布式缓存系统,专门用来验证和承载这类场景。
这套系统的定位很明确:不是要替代 Redis,而是给内部特定业务做一个可定制、可嵌入、能随业务需求增加淘汰策略和路由规则的 KV 缓存中间件。它包含一致性哈希路由、节点心跳管理、本地缓存引擎、客户端 SDK、扩容迁移等模块。整套代码跑通之后,我对分布式缓存的认知比之前用任何现成中间件都深。这篇文章就按我实现时的顺序,把设计逻辑、关键代码、真实踩坑全部记录下来,适合已经用过缓存中间件、但想搞清楚“背后到底怎么工作”的后端开发,也适合打算动手做小规模分布式系统的同学参考。
1. 为什么自己动手写一套分布式缓存
先回答一个绕不开的问题:这年头 Redis 遍地都是,自研缓存图什么?我的答案只有一个——分布式缓存的难点从来不在于“缓存”,而在于“分布式”。只把 Redis 搭成集群用,很多底层问题会被隐藏掉,一旦遇到“加节点全量重哈希”这类情况,就会措手不及。自己实现一遍,才能看清楚分片、路由、故障摘除、扩容迁移到底是怎么回事。
1.1 是造轮子吗:先看清缓存场景的特殊性
我并不是建议任何团队都去自研缓存。选这条路前,必须想清楚缓存场景和数据库场景有一个本质区别:缓存数据是可以丢的。
数据库如果丢了一条用户订单,那是事故;缓存如果丢了一个 key,最多就是多查一次数据库。这个“可容忍丢失”的语义,让分布式缓存的实现难度比分布式数据库低了一个量级。我不需要做复杂的 Raft 协商、多副本强一致、崩溃恢复,只需要保证三件事:数据尽量均匀分散到多个节点、某个节点挂了之后系统还能继续对外服务、扩容时不要引发大规模的缓存穿透。
当时我盘了一下内部需求,发现确实有几个用现成中间件反而不爽的场景:第一,业务希望自定义淘汰策略,比如按登录态优先级淘汰,而不是简单的 LRU;第二,部分缓存 value 需要压缩后存储,Redisson 那套序列化协议太重;第三,团队希望在缓存不可用时能自动降级到数据库,并且把这个降级逻辑做成配置开关,而不是散落在业务代码里。这些需求单拎出来都能用 Redis 拼,但拼多了等于在应用层重写一个缓存框架。
所以最后的结论是:自研这套系统,不追求承载所有业务,只做“分片缓存通路”的中间层。内部代号就叫 ring-cache,后面我会一直这么称呼它。
1.2 技术选型对比:自研轻量组件并非“头铁”
动手之前,我列了一个对比表,把可行方案放在一起看过一遍:
| 方案 | 节点分片 | 数据淘汰 | 可定制性 | 运维复杂度 | 我的适用度 |
|---|---|---|---|---|---|
| Redis Cluster | 内置 | 内置策略 | 低 | 中 | 理念学习可以,深度定制受限 |
| Codis/Twemproxy | 代理层分片 | 依赖后端 | 低 | 中偏高 | 不满足自定义淘汰试验需求 |
| 自研 ring-cache | 自研一致性哈希 | 按业务策略扩展 | 高 | 前期高,稳定后低 | 满足实验与内部小流量场景 |
这里想强调一点,自研不是“头铁”,而是试错成本低。缓存系统即便写坏了,最坏的结果就是缓存 miss 后全部回源,不会造成数据错误。这一点给了项目很大的试错空间。如果你们业务对缓存命中率非常敏感,比如几十万 QPS 全靠缓存扛,那我不建议用自研系统直接接生产流量,更好的做法是先做一个旁路实验,等稳定性打出来了再逐步切流量。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据定位:一致性哈希和虚拟节点不是玄学
分布式缓存最基础的一件事,就是给定一个 key,系统必须立刻知道它该去哪个节点。很多人认为这有什么难的,hash(key) % N 不就完事了。第一次扩容前我也是这么想的,直到我用取模方案做了演示,加了一台机器后发现:大部分 key 的映射位置都变了,缓存几乎全部失效。
2.1 从 hash 取模说起:为什么节点一扩全世界都知道
假设现在有 3 个节点,key 的分布规则是 hash(key) % 3。当我把节点扩容到 4 个,规则变成 hash(key) % 4。绝大多数 key 的余数都变了,也就是说,它们原本在节点 A,现在可能应该去节点 B。问题是节点 B 上根本没有这些缓存,于是所有请求瞬间穿透到数据库。
如果数据库能扛住,那还算幸运;如果数据库扛不住,就是教科书级别的缓存雪崩。取模路由最大的问题是“路由结果与节点数量强绑定”,节点数量一变,历史数据几乎全部失效。
一致性哈希解决的正是这个问题。它把哈希值组织成一个环,不直接拿节点数量参与运算。新的节点加入时,只会影响环上相邻的一小段区域,其他区域的 key 完全不用动。
2.2 一致性哈希环与虚拟节点实现
我在 ring-cache 里用 Java 实现了一个很标准的 ConsistentHashRing,核心逻辑用 TreeMap 保存哈希环。TreeMap 的好处是可以直接使用 ceilingEntry 找到“顺时针遇到的第一个节点”,天然契合一致性哈希的查找方式。
java复制public class ConsistentHashRing {
private static final int VIRTUAL_NODE_COUNT = 160;
private final TreeMap<Integer, String> ring = new TreeMap<>();
private final HashFunction hashFunction = new Murmur3HashFunction();
public void addNode(String nodeId) {
for (int i = 0; i < VIRTUAL_NODE_COUNT; i++) {
int hash = hashFunction.hash(nodeId + "#" + i);
ring.put(hash, nodeId);
}
}
public void removeNode(String nodeId) {
for (int i = 0; i < VIRTUAL_NODE_COUNT; i++) {
int hash = hashFunction.hash(nodeId + "#" + i);
ring.remove(hash);
}
}
public String route(String key) {
if (ring.isEmpty()) {
return null;
}
int hash = hashFunction.hash(key);
Integer target = ring.ceilingKey(hash);
if (target == null) {
target = ring.firstKey();
}
return ring.get(target);
}
}
你可能注意到了虚拟节点。为什么一个物理节点要在环上放 160 个虚拟节点?因为如果不这么做,当集群里只有三五台机器时,哈希点在环上的分布会很不均匀,很可能出现某个节点承载了 40% 的数据,另一个节点只有 15%。引入虚拟节点的本质是“让一个物理节点在环上多占几个位置”,把分布的粒度打散,数据量越大越均匀。
160 这个数值不是拍脑袋定的。节点数量少时,太少会倾斜;太多会占用 TreeMap 内存,而且每次节点变更要重算的哈希次数也变多。实测下来,节点在 3 到 20 个范围内,160 个虚拟节点能让数据分布的标准差控制在 5% 以内,效果已经足够好。
2.3 顺时针查找与路由表实时性
路由查找的逻辑一句话就能讲清:把 key 的哈希值放在环上,沿顺时针方向找到的第一个虚拟节点,就是数据归属节点。如果环上的哈希值都小于 key 的哈希值,说明已经绕了一圈,回到环首即可。
真正需要留意的不是查找算法,而是路由表的实时性。当节点动态上下线时,所有客户端或者其他节点上的路由环都必须感知到这个变化,否则就会出现 data moved。我在 ring-cache 的做法是路由环采用写时复制思想,节点变更后构建一份新的 TreeMap 并通过 volatile 引用发布,这样读请求不需要加锁,写变更也只发生在节点增减的瞬间。核心代码类似:
java复制public class RoutingTable {
private volatile ConsistentHashRing ring;
public void refresh(Set<String> onlineNodes) {
ConsistentHashRing newRing = new ConsistentHashRing();
for (String node : onlineNodes) {
newRing.addNode(node);
}
this.ring = newRing;
}
public String route(String key) {
return ring.route(key);
}
}
路由环更新的调用时机非常关键。如果节点 A 心跳超时,但它的进程还没退出,此时直接把它从路由环里摘掉,可能会导致“请求已经到了 A、但路由表说 A 不存在”的错乱。所以在刷新路由环前必须确认节点确实不可用,这套判断机制放到下一节讲。
3. 节点成员管理:心跳、状态同步和脑裂让步
有了路由环,下一步就是让所有节点互相知道对方的存在。从单机缓存到分布式缓存,最容易被忽视的就是“成员管理”。节点什么时候加入、什么时候离开、谁来判断它挂了,这些问题没想清楚,后面全是坑。
3.1 节点发现机制:最少依赖的种子节点设计
成员管理最成熟的做法之一是引入 ZooKeeper/etcd 做服务注册与发现。但 ring-cache 希望尽量降低部署依赖,所以我选了一个更朴素的方案:配置文件里写死一组种子节点地址,新节点启动后先联系任意一个种子节点,上报自己的地址,再由种子节点把当前集群的成员列表广播给新节点。
这套机制和 etcd 那类比当然显得“土”,但对一个缓存系统来说已经够用。注册信息就两类:IP+端口、节点启动时间。广播协议也非常简单,直接走 TCP 报文:
json复制{
"type": "JOIN",
"node": "192.168.1.10:7001",
"startedAt": 1710000000000
}
每个节点收到 JOIN 后,更新本地成员列表,并把自己知道的其他节点同步给新加入的节点,同步完了再触发一次路由环的 refresh。这个方案最大的缺点是当节点数量超过几十台时,广播风暴会变得明显,所以我也没打算让它支撑大规模集群,内部几十个节点的规模完全是够用的。
3.2 心跳与状态判定
节点加入之后,成员管理要解决的核心问题就是故障检测。我的第一版实现很简单:每个节点每隔 1 秒向其他所有节点发送一个 PING,收到 PONG 就认为对方存活,连续 3 次没收到 PONG 就标记为下线。
这个逻辑在测试环境永远正常,因为测试环境网络非常稳定。但在线上,一个 200 毫秒的 GC 暂停就可能让心跳线程没能及时发送报文,PONG 也延迟了,于是其他节点就把这台机器摘掉了。
吸取教训后,我把故障判定改成“连续失败次数 + 超时阈值”。心跳仍然每秒发一次,但超时时间设置为 5 秒,连续 5 次超时才会把节点标记为 offline。这个参数本质上是在“故障发现速度”和“误判概率”之间做权衡。想快速摘除故障节点,就会因为网络抖动而误杀;想避免误杀,就要接受故障节点多存活一段时间。缓存场景下,我宁愿接受后者——多等几秒,最多就是部分请求失败或穿透数据库,比全集群雪崩好太多。
3.3 缓存场景下我们必须接受的“脑裂事实”
网络分区是分布式系统绕不开的话题。假设集群被切成了两半,两边都无法联系对方,这时如果两边都认为自己才是合法集群,就会出现脑裂。分布式数据库对脑裂是零容忍的,因为两边同时写同一份数据会造成不可逆的错误。
缓存系统不同。我们已经接受了“数据可以丢”,所以面对网络分区时采用了一个很务实的策略:少数派节点继续对外提供服务,但数据可能在分区恢复后不是最新版本;业务侧通过 TTL 兜底,过期后重新从数据库加载就行。
这意味着我不会去实现复杂的租约或选举机制,而是把一致性责任大部分交给数据源和过期时间。这个设计一开始会让人心里不踏实,但想明白后会发现非常省事。缓存系统最大的妥协就在于:它不需要证明自己是对的,只需要在绝大部分时间里足够快,出错时能快速恢复即可。
4. 节点本地的缓存引擎:并发、淘汰和过期到底怎么做
分布式缓存的能力最终要落到每个节点的本地存储上。路由做得再漂亮,如果每个节点的缓存引擎性能不行、淘汰逻辑有问题,整体效果也会大打折扣。我在这一部分投入的时间最多,因为单机缓存引擎才是“缓存”二字的本体。
4.1 数据结构选型:ConcurrentHashMap 与 LRU 怎么共存
最自然的本地存储实现是用 ConcurrentHashMap 保存 key-value,因为它的并发读性能极好。但 ConcurrentHashMap 本身不维护访问顺序,要实现真正的 LRU 淘汰,还得额外维护一个双向链表,每次读写都要移动链表节点。这在高并发下会引入严重的竞争,因为所有线程都去抢同一把“移动链表节点”的锁。
我当时的取舍是放弃“严格 LRU”,采用分片近似 LRU:把本地缓存划分为 16 个分片,每个分片内部维护一个 LinkedHashMap,并开启 accessOrder 来记录访问顺序。这样每个分片只有少量线程竞争,锁粒度大大降低。
java复制public class LruShard {
private final int maxCapacity;
private final LinkedHashMap<String, CacheEntry> map;
public LruShard(int maxCapacity) {
this.maxCapacity = maxCapacity;
this.map = new LinkedHashMap<>(16, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, CacheEntry> eldest) {
return size() > maxCapacity;
}
};
}
public synchronized CacheEntry get(String key) {
return map.get(key);
}
public synchronized void put(String key, CacheEntry entry) {
map.put(key, entry);
}
}
你可能会担心 synchronized 性能不够。实测下来,在 8 核心的机器上,16 个分片可以把锁竞争摊得很薄,整体性能达到每秒 20 万次读写,完全够内部服务用。如果想更激进,可以用 Caffeine 那种基于 W-TinyLFU 的算法,但那个实现复杂度很高,对大多数场景来说分片 LRU 的性价比已经很高了。
4.2 过期策略:惰性删除加周期扫描,参考 Redis 但不照搬
TTL 过期是缓存系统最容易写错的地方。很多人第一反应是“我 put 的时候启动一个定时任务,到时间就删”,但这样做在 key 数量很大时会非常浪费。一个 key 一个 Timer,内存还没省下来,定时器倒先占了大量资源。
我参考 Redis 的做法做了两层设计。第一层是惰性删除:每次 get 的时候检查这条记录的 expireAt 是否小于当前时间,是的话直接返回 null,并把 key 从 map 中移除。第二层是周期扫描:每秒随机抽取一批 key,检查过期情况,比如每次抽查 100 个,如果过期比例超过 25%,就继续抽。这个策略避免了全量扫描,也保证了过期 key 不会在内存中停留太久。
java复制public class CleanupTask implements Runnable {
private final LocalCache cache;
@Override
public void run() {
int expired = 0;
for (int i = 0; i < 100; i++) {
String key = cache.randomKey();
if (key == null) {
return;
}
if (cache.isExpired(key)) {
cache.delete(key);
expired++;
}
}
if (expired > 25) {
// 过期比例偏高,下一轮继续扫
}
}
}
这里有一个被忽略的细节:缓存系统里的 TTL 不只是为了让数据按时失效,它还是缓存与数据库之间数据一致性的最后防线。任何主动删 key 的逻辑都可能因为消息丢失或代码 bug 而没执行,TTL 保证了在最坏情况下,数据也会在一段时间后重新从数据库加载。所以 TTL 不要设置太长,内部业务里普通配置缓存我一般控制在 3 到 10 分钟之间。
4.3 内存控制和淘汰策略实现要点
LRU 和 TTL 解决的是淘汰顺序和过期时间,但内存上限必须提前规划。我在每个节点上设置了 maxMemory,比如 4GB。写入数据时,如果发现当前占用超过了 95% 的上限,会触发一次全量的逐出扫描,把最久没访问的 5% 数据淘汰掉。这个操作比较重,所以不能频繁触发,宁可提前在达到 80% 时就主动降低逐出阈值。
另一个容易犯的错是把 value 直接存成对象引用。如果缓存模块和业务模块在同一个 JVM 里,业务侧修改了对象字段,缓存里的数据也跟着变了,排查起来极其困难。我在 ring-cache 中强制要求 value 必须可序列化,并且默认走 byte[] 存储,业务侧自己负责序列化和反序列化。虽然多了一次序列化开销,但隔离性大大提高,线上再也没有出现过“缓存被业务改坏”的诡异问题。
内存淘汰还得分清“优先淘汰谁”。对于本地缓存,我暴露了一个淘汰策略接口,业务方可以按需实现。普通业务用 LRU 足够,但遇到登录态这类“要求过期时间严格”的场景,也可以使用 TTL 优先策略,把即将过期的 key 先淘汰出去。这种可扩展性正是自研缓存系统相对 Redis 的一个优势。
5. 一条读写请求的完整旅程:路由、代理转发与客户端 SDK
前面的章节把存储、节点管理、路由分片都讲完了,现在要把这些模块串起来,回答一个最实际的问题:当业务代码执行 cache.get("userId_123") 时,这条请求到底经历了什么?
5.1 服务端代理 vs Smart Client:两种形态怎么选
读写请求跨节点的方案有两种主流形态。第一种是服务端代理模式,客户端把所有请求发给一个代理节点,由代理节点根据 key 计算目标节点并转发,类似 Codis 的做法。这个模式对客户端友好,但会引入额外一跳,代理节点本身也可能成为瓶颈。第二种是 Smart Client 模式,客户端 SDK 自己持有哈希环,直接根据 key 算出目标节点,然后直连目标节点,类似 Redis Cluster 官方客户端的做法。ring-cache 用的是第二种。
Smart Client 的实现难点在于,SDK 必须能感知集群成员变化。节点上线、下线、心跳超时摘除时,SDK 里的哈希环需要实时刷新。如果 SDK 持有的环和真实的环不一致,就会出现请求发到节点 A,但 A 说“这个 key 不归我管”。
5.2 请求流程:GET、SET、TTL 如何落到具体节点
以一次 GET key 为例,完整流程如下:
- SDK 计算 key 的哈希值,在当前哈希环上找到目标节点地址。
- SDK 通过 Netty 客户端向目标节点发起 GET 请求。
- 目标节点收到请求后,再次用本地路由环计算 key 的真实归属节点。
- 如果目标节点发现“key 属于另一个节点 B”,则返回 MOVED 响应,并附带节点 B 地址。
- SDK 收到 MOVED 后,在新地址更新本地路由环,然后重新发送请求到节点 B。
- 节点 B 本地查找缓存,命中则直接返回,未命中则去数据库加载,回填缓存后返回。
code复制客户端 SDK 请求流程伪代码:
target = ring.route(key)
response = sendGet(target, key)
if response.isMoved():
ring.refreshNode(response.getNewTarget())
target = response.getNewTarget()
response = sendGet(target, key)
这里 MOVED 重定向机制很重要。它保证了即使 SDK 的路由环暂时落后于真实状态,也不会返回错误数据,而是先纠正自己的路由再重试一次。这个设计借鉴了 Redis Cluster 的思路,能让客户端与服务端在路由视图上有短暂不一致时的系统仍然可用。需要提醒的是,一旦收到大量 MOVED,说明 SDK 的环确实过期了,需要立即触发全量路由刷新,否则每个 key 都会浪费一次额外的网络请求。
SET 流程类似,不同的是在节点本地写入时还要带上 TTL。LLT 过期时间的语义是“从现在起多少毫秒”。如果业务需要对某个 key 续期,SDK 需要支持 touch 命令,用来更新 TTL 而不修改 value。这个能力在使用分布式锁或限流时非常有用。
5.3 超时、重试与降级:保证后端不被打垮
缓存系统的存在意义是保护数据库,但如果缓存本身出现故障,而应用层逻辑又“永不放弃”,那么所有请求都会打到数据库,形成雪崩。所以我在 SDK 里设计了超时和降级参数。
单个请求的超时时间一般设置为 50 毫秒。超过这个时间,就认为本次缓存访问失败,直接降级到数据库,而不是继续等待。重试次数设为 0 或 1,超过一次就不再尝试。很多系统正是因为在缓存超时后重试了三次,导致数据库同时接收到三倍流量,瞬间被打挂。
降级逻辑也必须做成可配置的。某些读多写少的场景,如果缓存不可用,可以接受直接查数据库;但某些高并发写场景,缓存挂掉时不能让请求无限量涌入数据库,而应该进行限流或快速失败。我封装了一个 CacheRoute 接口,把“查询缓存失败后怎么处理”交给上层策略决定,默认策略是“查询数据库,同时尝试异步回填缓存”。“渐进式降级”听起来简单,但如果没有提前做好,故障发生时人脑很难冷静分析,最后就是看着监控一片红无能为力。
6. 扩容迁移和踩坑记录:测试环境正常,线上却把数据库打穿了
最后一章分享最容易翻车的扩容迁移,以及几个真实事故的排查过程。前几个章节讲的是“正常怎么运行”,这一章讲的是“异常时怎么活下来”。
6.1 新节点加入时的迁移策略:为什么不能只改路由
当新节点加入集群时,如果直接把路由环刷新,让新节点参与路由,那么一部分原本属于旧节点的 key 会被路由到新节点。可新节点上没有任何缓存,这些 key 全部会 miss,数据库又会迎来一波峰值。
最直观的方案是做一次全量迁移:新节点加入后,从旧节点把属于新节点的 key 复制过来。这个方案的问题在于“判断 key 属于谁”需要遍历旧节点上的所有 key。如果单节点有几千万 key,全量遍历会占用大量 CPU 和 IO,迁移期间旧节点的服务能力会大幅下降。而且,缓存是动态变化的,迁移过程中旧 key 又过期、又新增,倒腾一轮之后可能发现迁过来的数据很多已经过时了。
我更推荐的是“懒迁移 + 预热”的组合方案。
懒迁移的含义是:节点加入后,路由环刷新,但旧数据不立刻搬。访问 key 时先按新路由找到新节点,新节点发现没有数据,再向旧节点发起一个内部 GET 请求,从旧节点取到数据后回填到新节点。这个过程只搬运“被访问到”的数据,流量越大数据搬得越快,流量小的时候也不必浪费资源。
如果担心新节点上线后突然被大量访问造成回源压力,可以对流量做预热:先按旧节点读取热点 key 列表,把这些 key 预先写入新节点。这个步骤可以配合一个后台任务慢慢做,任务限速,比如每秒最多从旧节点读取 5000 个 key。
6.2 迁移期间如何保持命中率
懒迁移的代价是迁移期内命中率会出现短暂下降,因为很多 key 第一次访问时需要二次查询。我的经验是,扩容动作最好在低峰期执行,并且可以先“双订阅”一段时间的备份流量,让新节点通过懒迁移积累热数据,等命中率自然爬升,再把业务流量正式切过去。
另外一个调整余地是虚拟节点的变更粒度。如果一次加入一个物理节点,而这个节点对应 160 个虚拟节点,路由环的变化是分散的,迁移量被摊薄到集群的各个区域,对整体命中率的冲击比大段摘除要平滑很多。这也是虚拟节点在迁移层面的隐形价值——它不只解决了均衡分布,还降低了单次拓扑变更的影响半径。
6.3 线上事故复盘:三个真实问题让我改了三版设计
第一个问题是网络抖动误杀节点。上线第二周,机房网络出现一次轻微抖动,持续不到两秒。但由于心跳超时设置为 3 秒,很多节点在抖动恢复前就被标记为 offline。路由环连续通知所有 SDK 把 offline 节点摘除,请求纷纷转向其他节点,数据库压力瞬间翻了 4 倍。
排查过程很有意思:监控面板上数据库 QPS 先上涨,然后才开始大量报错,而缓存命中率是在数据库上涨两三分钟后才跌下来的。我在日志里看到大量 node offline 记录才意识到,真正的元凶不是数据库变慢,而是网络抖动触发了“缓存节点大规模被判死”。修复方式就是前面提到的,把超时时间调到 5 秒,连续 5 次失败才确认下线。虽然故障发现变慢了,但误杀问题彻底消失。
第二个问题是热点 key 打爆单节点。某个活动的商品详情被广泛分享,而分享出去的链接都指向同一个 key。一致性哈希只能保证 key 在环上分布均匀,但它不感知访问频率。这个 key 被路由到节点 A,节点 A 的 CPU 被打到满载,其他节点却很空闲。
解决方案借鉴了缓存多副本的思路:当 SDK 检测到某个 key 的访问频率超过阈值,就自动在 key 后面拼接随机后缀,生成多个变形 key,让它们分散到不同节点。业务读取时随机选取其中一个变形 key 读取,如果 miss 再访问原 key 并从数据库加载,写回所有变形 key。这样一个热点 key 的访问量就被拆到多个节点上。关键点是变形 key 的 TTL 要略短于原 key,避免数据不一致窗口太长。
第三个问题是超大 value 打满网卡。一个业务方把一份 3MB 的业务配置塞进了缓存,这个 key 的读取频率还不低。尽管单次请求的响应时间正常,但网卡带宽被大量占满,其他正常请求排队,最终表现为接口整体 P99 延迟上升。排查时我用 tcpdump 抓包,发现内网流量远超预期,定位到是某个大 key 的问题。后续在缓存层强制限制 value 不能超过 256KB,超过则拒绝写入并告警;业务方如果确实需要缓存大对象,要先做压缩,压缩后仍然超限就拆成多段分别缓存,读取时在多节点并发拼装。这也算是一次教训:分布式缓存设计的粒度可以是 key,但性能监控的粒度必须细化到字节。
6.4 稳下来之后:核心参数与后续演进方向
系统稳定运行后,我总结了一套相对靠谱的核心参数:心跳周期 1 秒,超时阈值 5 秒,连续失败次数 5 次;路由环写时复制更新;本地缓存分片数量 16;虚拟节点数 160;SDK 请求超时 50 毫秒,重试次数不超过 1 次。这套参数经受过少量线上故障考验,核心思路就是“宁可慢半拍,绝不误杀”。
后续如果要继续演进,我可能会在三个方向补充:一是加入节点维度的监控上报,把每台机器的内存占用、淘汰次数、网络 RT 推送到监控系统,方便做容量规划;二是实现更平滑的权重调节,让新加入的节点通过调低虚拟节点数先承接少量流量,再逐步放开,避免一上线就承接全部路由压力;三是为跨机房容灾预留 Multi-Ring 互备方案,把每个机房独立哈希环的数据做异步汇总,机房故障时切换到另一套环。这些方向都是在对这套系统理解足够深之后才能明显看出价值的,直接看 Redis 源码反而不一定有这么直观的感受。
最后说一个个人体会:自研分布式缓存最大的收获不是“我写出了一个系统”,而是踩完这些坑之后,再回头看 Redis 的集群方案,每一处设计都能读懂它背后的妥协。比如它的 cluster 模式为什么要求客户端缓存槽位信息、为什么要借助 Gossip 传播节点状态、为什么要用异步迁移而不是同步搬迁,这些决策我之前只是背诵,现在是真正理解了。如果你也想走一遍这条路,建议先别碰网络通信和一致性哈希,先在你熟悉的语言里把一个本地并发缓存做到性能达标、TTL 准确、淘汰可控。单机这步走扎实,后面搭建分布式骨架时你会清楚很多。
