各位做微服务或者大数据平台的兄弟,应该都有过这种经历:注册中心里的服务明明已经被下线了,但新上线的任务或请求还是往那个实例上打。排查半天,发现不是代码逻辑问题,而是卡在了注册中心的缓存更新上。Eureka 作为服务注册与发现的老牌组件,在 Spring Cloud 体系里被广泛使用,大数据平台里做数据采集、实时计算任务调度时也经常拿它做服务注册中心。今天我就把 Eureka 服务发现的缓存更新机制从头到尾扒一遍,说清楚三层缓存之间的关系、触发更新的真实链路,以及在大规模集群下那几个让人头秃的坑。
这篇文章不是那种只讲概念的科普,我会结合自己的实际项目经验,把注册、续约、剔除、增量抓取这些操作和缓存更新的关系理清楚,最后附上一个真实踩坑案例的排查过程。适合正在用 Eureka 做服务注册中心、尤其是集群规模不小、对服务上下线延迟比较敏感的同学参考。
1. 注册中心为什么要设计三层缓存,而不是一把锁保护一张表
1.1 先搞清楚 Eureka Server 的注册数据到底存在哪
很多人刚开始看 Eureka 源码的时候都会懵一下,觉得注册表不就是个 Map 吗?实际上在 Eureka Server 内部,服务的实例信息确实是存在一个 ConcurrentHashMap 里的,这个 Map 叫 registry。但客户端拿到的注册表信息,并不是每次请求都直接去这个 Map 里读,中间还隔了两层缓存。
这三层结构可以简单理解为:
- 第一层:
registry,最底层的注册表,保存了所有服务实例的原始信息,也是唯一的数据来源。 - 第二层:
readWriteCacheMap,一个基于 Guava Cache 的读写缓存,服务注册、下线、状态变更时会主动失效对应的缓存条目,以保证最新变更能被快速读取。 - 第三层:
readOnlyCacheMap,一个只读缓存,默认每 30 秒从readWriteCacheMap同步一次,客户端拉取注册表时默认命中的就是这一层。
为什么要加这么两层缓存,不直接让客户端读最底层的注册表?答案很简单:服务注册和发现的场景是典型的读多写少。一个大数据平台的注册中心里,可能有几百上千个实例,每个实例每 30 秒还要发一次心跳。如果所有读请求都直接落到 registry 上,再用一把锁保护一致性,那么在高并发心跳和客户端频繁拉取注册表的双重压力下,锁竞争会非常严重,注册中心的响应延迟和 CPU 消耗都会直线上升。
1.2 三层结构各自的定位和性能取舍
我先说说 readWriteCacheMap。它本质上是注册表和只读缓存之间的中间层,承担的是“数据新鲜度”和“并发读性能”之间的平衡。写入操作(注册、下线、状态变更)发生后,会主动让这个缓存里的对应 key 失效,这样下一次读就能拿到最新数据。同时它又被配置了一个过期时间,默认 180 秒,也就是说即使没有主动失效,每条缓存过了 180 秒也会被过期掉,防止极端情况下数据长期不更新。
readOnlyCacheMap 就更有意思了,它连主动失效都没有。这个只读缓存只在两个时机被更新:
- 每 30 秒的定时同步任务,把
readWriteCacheMap的内容整体刷到readOnlyCacheMap。 - 客户端发来注册表拉取请求时,如果发现
readOnlyCacheMap里的数据还是旧版本,会触发一次同步更新。
注意第二点:客户端请求触发同步,是由于 readOnlyCacheMap 内部记录了一个 lastUpdateTime,当缓存内容过期或者版本对不上时,请求线程会去 readWriteCacheMap 拉最新数据并更新只读缓存。这个设计的优点是大幅降低了缓存失效的粒度,缺点就是:服务端做了变更之后,客户端看到变更的最大延迟,是由这个 30 秒定时周期决定的。
1.3 为什么不是“一层缓存 + 主动失效”就够了
这是个值得深入想的问题。如果只保留 readWriteCacheMap,客户端每次拉注册表都直接读它,主动失效也能保证新数据第一时间被读到,为什么还要多一层 readOnlyCacheMap 挡在前面?
原因在于 readWriteCacheMap 底层用的 Guava Cache 在并发读上有它的代价。Guava Cache 在缓存过期、条目失效时涉及锁操作和回调处理,高并发场景下频繁的失效和重建会带来不小的开销。而 readOnlyCacheMap 的读操作就是最简单不过的 ConcurrentHashMap 读,几乎零成本,适合应对客户端高频的注册表拉取。
所以在 Eureka 的默认设计里,客户端的注册表拉取请求是不直接读 readWriteCacheMap 的,而是读 readOnlyCacheMap。只有当 readOnlyCacheMap 里没有数据时,才会穿透到 readWriteCacheMap。这一层只读缓存的引入,本质上是在用“30 秒的数据延迟”换取“更高的并发读吞吐量”。
对大数据平台来说,这个取舍大多数时候是可以接受的。数据采集节点、计算任务的注册和下线,通常不需要秒级一致。但如果你的业务对服务上下线的实时性要求比较高,那就必须得知道后面这 30 秒是从哪来的,并且从服务端和客户端一起调参数,只调一边是没用的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 服务实例注册之后,一条数据是怎么从注册表走进客户端缓存的
2.1 服务注册时发生了什么
当一个新的服务实例启动,它会向 Eureka Server 发送注册请求。服务端收到请求后,在 AbstractInstanceRegistry.register() 方法里做几件事:
- 把实例的
InstanceInfo存入registry这个 Map。 - 如果实例之前存在且状态有变化,记录变更时间。
- 调用
invalidateCache()方法,把readWriteCacheMap里对应的服务名缓存条目失效。 - 触发一个注册事件,供订阅方感知。
整个过程很快,因为 registry 本身就是 ConcurrentHashMap,注册操作只需要对实例维度的锁做保护,不会锁整个注册表。
关键点来了:invalidateCache() 只清理 readWriteCacheMap,而不是清理 readOnlyCacheMap。这意味着注册完成后,如果客户端拉注册表命中的是 readOnlyCacheMap,它不会立刻看到新实例。想要看到,得等两种情况之一发生:要么 30 秒定时同步把 readWriteCacheMap 里的新数据刷进 readOnlyCacheMap;要么某个客户端请求触发了 readOnlyCacheMap 的同步更新。
2.2 缓存失效操作的真实触发动作
我在排查问题的时候发现,很多人对 invalidateCache() 的理解有一个误区:以为它是把整个缓存清空。实际上,它的粒度是“服务名级别”的。Eureka Server 在处理注册、下线、状态变更时,会根据当前操作的实例所属的应用名,只把 readWriteCacheMap 里对应的 key 删掉。其他服务的缓存条目不受影响,还能继续命中。
这样做的好处很直接:避免一次缓存失效导致所有服务名都重新加载。在大数据场景下,某个数据采集服务扩容几十个实例,触发的失效只会影响这个采集服务对应的 key,而不会让其他计算服务的注册表缓存全部重建。
不过这里也有一个潜在的性能问题:如果同一个服务名下的实例非常频繁地注册、下线、变更状态,那么 readWriteCacheMap 里这个 key 会反复失效。客户端一拉取,就触发重建;重建完又失效,又重建。这个场景叫缓存抖动,对 GC 和 CPU 都有压力。后面我会专门讲大数据集群扩容时怎么避免这种抖动。
2.3 客户端看到新实例的最短时间和最长时间
先看一段常见的客户端配置:
yaml复制eureka:
client:
registry-fetch-interval-seconds: 30
这个配置是客户端每隔 30 秒从服务端拉一次注册表,默认值就是 30。所以即使服务端三层缓存全部瞬间更新完毕,客户端最坏情况下也要等一个拉取周期才能拿到新数据。
把服务端和客户端串起来,一个服务注册后,新实例出现在客户端视角里的完整延迟由三部分组成:
- 服务端
readOnlyCacheMap的同步周期,默认 30 秒。 - 客户端注册表拉取周期,默认 30 秒。
- 客户端本地缓存的更新时间,通常和拉取周期同步。
极端情况下,一个实例注册成功后,最晚 60 秒左右才会被其他服务看到。听上去挺慢的,但默认设计就是这样。如果你在大数据平台上做实时任务调度,要求新扩容的实例 10 秒内就能被调度器发现,那么默认配置肯定不满足需求。
调低延迟的常见做法
我通常建议把这两个参数同时调整:
yaml复制# 服务端
eureka:
server:
response-cache-update-interval-ms: 5000
# 客户端
eureka:
client:
registry-fetch-interval-seconds: 5
response-cache-update-interval-ms 控制 readOnlyCacheMap 从 readWriteCacheMap 同步的周期,默认 30000 毫秒;registry-fetch-interval-seconds 控制客户端拉取注册表的周期,默认 30 秒。把两端都调到 5 秒,理论上一个服务实例从注册完成到被其他服务看到,最多 10 秒左右。
这里有个注意点:response-cache-update-interval-ms 调得越小,只读缓存同步越频繁,服务端的并发压力会相应增加。如果集群规模不大,5 秒完全没问题;如果是几千个实例的大集群,我建议先从 10 秒开始,观察服务端 CPU 和 GC 情况再往下调。
3. 心跳续约和过期剔除让缓存更新走向另一个方向
3.1 心跳续约不触发缓存失效,这是设计上的一笔大账
服务注册之后,实例会按配置的间隔持续发送心跳,默认是 30 秒一次。Eureka Server 收到心跳后只需要更新实例的 lastUpdateTimestamp 就够了,根本不会去碰 readWriteCacheMap,也不会触发 invalidateCache()。
为什么这么设计?因为心跳是所有实例持续产生的请求,频率远高于注册和下线。如果一个几百实例的集群,每个实例每 30 秒发一次心跳,服务端平均每秒要处理大量心跳请求。如果每次心跳都去失效一次缓存,那 readWriteCacheMap 基本上就没有缓存命中可言了,变成一台“失效-重建”循环机器,性能会非常难看。
所以心跳续约在设计上刻意和缓存更新解耦。它只更新底层注册表里的时间戳,客户端视角里实例的状态不会因为一次心跳成功而发生变化。这个设计在绝大多数情况下是合理的,但它也带来一个副作用:服务实例挂掉之后,只要还没被过期剔除,它在注册表和缓存里看起来仍然是“UP”状态。
3.2 过期剔除如何触发缓存更新
Eureka Server 有一个后台定时任务,默认每 60 秒执行一次过期检测,把超过 90 秒没有续约的实例从注册表里摘除。摘除之后,会调用和注册一样的 invalidateCache() 操作,把 readWriteCacheMap 里对应服务名的缓存条目失效。后续只读缓存同步之后,客户端才能看到实例被下线。
所以一个实例因为故障停止心跳后,它被其他服务发现的完整时间线是这样的:
- 心跳停止后的第 90 秒,服务端判定实例过期。
- 服务端执行剔除,并失效
readWriteCacheMap。 - 最多再等 30 秒,
readOnlyCacheMap同步完成。 - 客户端在自己的下一个拉取周期里拿到最新注册表。
也就是说,假如客户端和服务端都用的默认配置,一个实例彻底挂掉后,最坏要 90 秒加 30 秒再加 30 秒,差不多 2 分半钟,其他服务才完全感知到它下线。这个延迟在普通微服务场景下可以忍,但在大数据实时链路里,如果任务调度器还在持续往挂掉的 Worker 上派任务,2 分半钟足够产生大量失败重试了。
3.3 自我保护机制:一个在大数据场景下容易踩的大坑
Eureka 默认开启了自我保护机制,规则是:如果 15 分钟内,续约比例低于 85%,服务端就认为可能是网络分区或者服务端自身出了问题,这时会停止过期剔除,把注册表里所有实例都保留下来。
在大数据平台上我遇到过这样的情况:一批跑批任务的数据节点,平时心跳都正常,突然因为上游数据源卡住,导致节点上的业务线程全部阻塞,GC 频繁,心跳线程也受到影响,心跳续约开始大量失败。15 分钟内续约比例跌破 85%,触发自我保护。等这批节点真正恢复后,注册表里积累了大量的过期实例,这些实例的缓存也不会被更新,客户端看到的就是一个“半死不活”的注册表。
自我保护本身是防止网络分区时误删实例的,但在大数据批处理场景下,它的“保护”常常变成“掩盖问题”。分布式调度系统会在很长一段时间里继续把任务派给已经不工作的实例,导致任务积压。
对于这种情况,我的建议是分环境处理。测试环境可以关闭自我保护,让过期实例及时被剔除:
yaml复制eureka:
server:
enable-self-preservation: false
生产环境不建议直接关,而是要做兜底:在客户端或调度层加失败重试机制,同时把心跳续约间隔调短一点,比如 10 秒,这样即使触发自我保护,也能更快地发现实例状态异常。另外,一定要监控 Eureka Server 的自我保护状态和续约比例,这两个指标通常能提前暴露注册表健康问题。
3.4 增量抓取和缓存一致性的关系
Eureka 客户端拉取注册表不只有全量方式,还有增量方式。客户端第一次拉取时拿全量数据,之后每次拉取,服务端会返回最近 3 分钟内发生的注册、下线、状态变更事件。客户端把这些增量事件合并到本地缓存里,就能避免每次传输整个注册表,减少网络开销和客户端内存使用。
增量事件从哪里来?服务端在每次注册、下线、状态变更时,不仅会更新底层注册表和缓存,还会往一个最近变更队列里写入一条记录。这个队列默认保留最近 3 分钟的数据。客户端每 30 秒来拉一次,正常情况下 3 分钟的窗口足够覆盖两次拉取的间隔。
这里要注意的是,增量抓取的数据和三层缓存的更新是并行的。服务端在缓存失效后立刻把事件写入变更队列,客户端拿到增量事件后,可能还没来得及从全量缓存里看到新数据。所以在极端情况下,客户端通过增量更新已经知道了实例状态变化,但本地缓存里实例列表还需要下一次同步才能完全一致。这也是为什么我们排查问题的时候,不能只盯着服务端缓存,还要关注客户端本地缓存的重建逻辑。
4. 大规模集群下的缓存更新调优,核心参数和实测经验
4.1 影响缓存更新速度的关键参数
我把服务端和客户端最关键的几个参数整理成了一张表,方便对照:
| 端 | 参数 | 默认值 | 作用 |
|---|---|---|---|
| 服务端 | response-cache-update-interval-ms |
30000 | readOnlyCacheMap 从 readWriteCacheMap 同步的周期 |
| 服务端 | eviction-interval-timer-in-ms |
60000 | 过期实例检测执行周期 |
| 服务端 | enable-self-preservation |
true | 是否开启自我保护 |
| 客户端 | registry-fetch-interval-seconds |
30 | 客户端拉取注册表周期 |
| 实例 | lease-renewal-interval-in-seconds |
30 | 实例心跳发送间隔 |
| 实例 | lease-expiration-duration-in-seconds |
90 | 实例过期时间,超过该时间未续约则被剔除 |
注意一个容易被忽略的关系:lease-expiration-duration-in-seconds 必须大于 lease-renewal-interval-in-seconds,正常情况下保持 3 倍关系比较合理。如果心跳间隔是 10 秒,过期时间设 90 秒,那么一个实例挂掉后,最快要等 90 秒才能被剔除;如果过期时间设成 30 秒,服务端最多允许实例漏掉 3 次心跳就会剔除。具体怎么设,要看你注册的实例是否可靠。
4.2 把只读缓存更新周期调小,真的划算吗
我在一个数据服务集群上做过实测。集群规模大约是 800 个实例,服务端 3 节点,客户端有 40 多个服务。原先 response-cache-update-interval-ms 用默认值 30000,服务注册后到被其他服务发现,最快要 30 秒,最慢接近 60 秒。把这个参数调到 5000 之后,服务发现延迟明显下降,注册后 10 秒内基本能被所有客户端感知。
但代价也很明显。调小只读缓存同步周期后,Eureka Server 的 readOnlyCacheMap 更新频率从每分钟 2 次变成每分钟 12 次,同步时涉及遍历服务名和重建 Map,服务端 CPU 占用有一定上升。在 800 实例的规模下,CPU 占用率上升不到 2 个百分点,可以忽略。但如果你有几千个实例,而且服务名特别多,同步带来的开销会明显增加,需要压测观察。
我的经验是:如果要求的服务发现延迟在 15 秒以内,把服务端更新周期调到 5 秒到 10 秒就够用了,没必要调到 1 秒。调到 1 秒不仅对服务端压力大,还会让 readWriteCacheMap 的缓存几乎没有意义,每次客户端拉取都触发重建,反而失去了缓存的意义。
4.3 注册风暴:大量实例同时注册时的缓存更新压力
大数据平台的弹性扩容经常一次性拉起几十甚至上百个实例。这些实例同时向 Eureka Server 发送注册请求,每一笔注册都会触发一次 invalidateCache(),并且往最近变更队列里写一条事件。短时间内大量缓存失效,readWriteCacheMap 里同一个服务名的 key 被反复失效、反复重建。
这种场景下,客户端在执行增量拉取时,可能会拿到大量重复的事件,合并到本地缓存时要处理重复实例信息,这会增加客户端内存和 CPU 的瞬时开销。更麻烦的是,如果扩容期间正好有服务在滚动发布,注册事件和下线事件交织,缓存更新链路会非常繁忙,可能出现客户端一段时间内拿不到完整注册表的情况。
我处理过的一个实际案例是:扩容 200 个任务节点,这批节点注册完成后,有部分调度服务在接下来的一两分钟内依旧拿不到全部节点信息,导致前两批任务只调度到部分节点。排查下来,问题不在 Eureka 本身的缓存更新,而在于调度服务的客户端拉取周期太长,加上增量事件队列里有大量重复事件,客户端合并时消耗了时间。
对这种场景,有几点实操建议:
- 扩容前先把调度服务的
registry-fetch-interval-seconds临时调低,比如调到 5 秒,扩容完成后再调回来。 - 避免所有实例同时启动,可以在启动命令里加随机延迟,让注册请求均匀分布。
- 如果用的 Spring Cloud 版本比较老,注意升级到修复了增量队列并发问题的版本。
4.4 监控缓存更新健康状况的两个信号
很多团队用过 Eureka 很久,但对缓存更新是不是正常没有直观感知。我建议至少监控两个信号。
第一个是服务端的最近变更队列大小。Eureka Server 的 AbstractInstanceRegistry.recentlyChangedQueue 会保留最近 3 分钟的变更事件,如果这个队列长期处于接近容量上限的状态,说明实例上下线过于频繁,缓存更新压力很大,客户端增量拉取也可能跟不上。
第二个是客户端的注册表拉取耗时和失败次数。如果服务端只读缓存同步不及时,客户端拉取时会穿透到 readWriteCacheMap,拉取耗时就会上升。正常情况下,客户端拉取一次注册表应该在几十毫秒内完成。如果发现某个客户端服务拉取耗时常驻在几百毫秒甚至秒级,就要怀疑它命中的是底层注册表而不是只读缓存,或者服务端 GC 有问题。
5. 踩坑实录:一个大数据任务调度系统里服务下线了还被打流量的完整排查过程
5.1 问题现象
有一段时间,我们的大数据任务调度平台频繁出现任务失败,报错都是连接不上某个 Worker 节点。但看调度系统的监控面板,这个 Worker 节点明明还是“UP”状态。于是我们做了个实验:手动把出问题的 Worker 从注册中心下线,结果任务还是持续被调度到它上面,持续了将近三分钟,才完全停止。
这个问题我们先后排查了三个层面,每一层都发现了问题,下面按排查顺序拆开讲。
5.2 第一层:客户端本地缓存没有及时更新
首先怀疑调度服务的本地缓存。Eureka 客户端拉取注册表后不是直接用的,而是在本地维护一个 Applications 缓存。如果客户端解析注册表时出现异常,或者增量事件处理出问题,本地缓存可能一直保留旧数据。
我们先看调度服务的日志,确认它确实在每 30 秒拉取一次注册表,而且没有报错。然后手动调用客户端接口查看本地缓存的实例列表,发现它已经拉到了服务端最新数据,但任务分发时用的却是另一个缓存副本。这提醒我们,使用 Eureka 客户端时,不能只看你有没有拉取,还得看业务代码读的是哪个缓存对象。
5.3 第二层:服务端三层缓存确实存在延迟
在看服务端时,我们把 Eureka Server 的日志调整到 DEBUG,跟踪了一个指定实例的注册表状态。发现实例下线后,底层注册表里已经没有了,但 readOnlyCacheMap 里还残留着该实例,直到下一个同步周期才被清掉。
这个现象其实符合设计预期,因为我们当时的 response-cache-update-interval-ms 还是默认值 30000。但问题是,调度服务的 registry-fetch-interval-seconds 也是 30,两者叠加,最坏情况下服务端同步加上客户端拉取,要 60 秒。再加上我们手动下线后,客户端下一次拉取可能正好卡在同步之前,实际感知延迟超过了三分钟。
这个问题的修复方案很简单:把服务端缓存更新周期调成 5 秒,客户端拉取周期调成 5 秒。调整后重新测试,手动下线到调度系统完全不再分配任务,耗时从三分钟降到 8 秒左右。
5.4 第三层:自我保护机制掩盖了实例状态
但问题没有到此为止。大概过了一周,我们做了一次大规模节点滚动重启,又出现了类似现象。这次我们查了服务端的自我保护状态,发现它被触发了:15 分钟内续约比例低于 85%。滚动重启期间,一大批节点同时停机更新,心跳续约大量中断,服务端认为可能发生了网络分区,停止剔除过期实例,导致已经停机的节点继续留在注册表里。
这个现象在滚动重启场景下特别麻烦。正常的滚动重启应该是分批进行,每批节点停机前先优雅下线,但我们的脚本有个 bug,一批节点全部杀死后才启动新节点,中间有个短暂的空窗期。就是这段空窗期,导致续约比例跌破阈值,自我保护触发。
处理办法分了两步:短期把脚本修正为“优雅下线一批,启动一批,等待心跳恢复,再下一批”,同时在下线脚本里显式调用 Eureka 的 unregister() 接口,确保实例下线后立刻触发服务端缓存更新,而不是等心跳过期;长期把生产环境的自我保护关闭或者调高阈值,同时加一个注册中心健康巡检任务,定期检查注册表里是否存在状态异常但长期未被剔除的实例。
5.5 关于这个案例的几个总结性经验
这个案例让我对 Eureka 缓存的“实时性”有了非常清醒的认识。默认配置只适合对服务上下线不敏感的业务,一旦你用在大数据任务调度、实时计算这类对节点状态敏感的场景,就必须把服务端和客户端的更新周期一起调小,并且在下线逻辑里加入主动反注册的环节。
还有一个细节:手动下线实例时,unregister() 之后,服务端缓存更新链路要完整走一遍,只读缓存同步、客户端拉取、本地缓存替换,每一步都需要时间。所以即使配置调好了,也不要期待毫秒级生效。实测下来,5 秒加 5 秒的配置下,最差情况大约 10 秒延迟,这个值在大数据场景下是完全可以接受的。
最后再分享一个我的排查习惯:每次遇到“服务下线了还被调用”的问题,不要一上来就怀疑代码逻辑或网络,先按“客户端本地缓存 -> 服务端只读缓存 -> 服务端底层注册表 -> 自我保护状态”的顺序逐层检查。只要你把这三层和两个参数的关系理清楚,这类问题基本都能在几分钟内定位到具体环节。
