服务注册与发现:Nacos、Eureka、Consul的源码级对比
服务注册与发现:Nacos、Eureka、Consul的源码级对比
适读人群:中高级Java工程师 | 阅读时长:约20分钟 | 技术栈:Spring Boot 3.x、Spring Cloud 2023.x
开篇故事
2019年,我们的微服务架构用的是 Eureka,一切运行正常。直到有一次,我们做了一次滚动部署,发布了 20 个服务实例,每个实例启动后都向 Eureka 注册,Eureka 的 renewsLastMin(每分钟续约次数)一直没有达到预期值,Eureka Server 触发了自我保护模式。
自我保护模式的逻辑是:当 Eureka 认为大量实例不正常时(通过心跳续约频率判断),它会停止剔除过期实例,以防止网络抖动误删正常节点。本意是好的,但我们的情况是:20 个旧实例已经下线,但 Eureka 在自我保护模式下拒绝删除它们的注册信息。新启动的 20 个实例也注册了,所以服务列表里同时有 40 个实例,其中 20 个是已经下线的僵尸实例。
负载均衡器在 40 个实例中轮询,每隔一个请求就打到僵尸实例,返回连接拒绝,错误率飙到 50%。那次故障持续了将近 20 分钟,才手动关闭自我保护模式恢复正常。
那次之后我把三大注册中心的源码读了一遍。
一、核心问题分析
服务注册与发现的核心流程是:
- 注册:服务启动时,向注册中心上报自己的地址、端口、元数据。
- 续约:服务定期发送心跳,证明自己还活着。
- 发现:消费者从注册中心拉取服务列表,建立本地缓存。
- 下线:服务正常关闭时,主动注销。异常崩溃时,注册中心根据心跳超时自动剔除。
三个注册中心在这四个环节的实现策略完全不同,各有侧重。
二、源码级原理解析
Eureka:AP 设计,最终一致性
Eureka 是 Netflix 开源的,设计哲学是"宁可返回旧数据,也不能报错",典型的 AP 系统。
核心数据结构:双层 Map,ConcurrentHashMap<String, Map<String, Lease<InstanceInfo>>>,外层 key 是服务名,内层 key 是实例ID,value 是 Lease(租约)对象,包含实例信息和最后心跳时间。
三级缓存架构:
- Registry(注册表):实时数据,每次注册/注销直接更新。
- readWriteCacheMap(读写缓存):30 秒过期,对 Registry 的 JSON 序列化缓存。
- readOnlyCacheMap(只读缓存):每 30 秒从 readWriteCacheMap 同步一次。
客户端每 30 秒从 readOnlyCacheMap 拉取服务列表,所以服务注册/注销到客户端感知,最大延迟可达 60 秒(30秒刷新 readWriteCacheMap + 30秒客户端拉取)。
自我保护模式:Eureka Server 期望每分钟收到 instanceCount * 2 * renewalPercentThreshold(默认 0.85)次续约。如果实际续约次数低于期望值,进入自我保护模式,停止剔除过期实例。目的是防止网络抖动误删正常实例(宁可留僵尸,不要误杀正常节点),但代价是服务列表可能包含已死亡的实例。
Zookeeper + Curator(CP)
ZK 做服务注册的核心是临时节点:服务启动时创建临时节点 /services/order-service/instance-001,节点内容是实例的地址和端口。服务崩溃后,ZK Session 超时,临时节点自动删除,相当于自动注销。
消费者监听父节点的子节点变化,实时获得服务上下线通知,延迟通常在秒级。
ZK 的问题是网络分区期间 Leader 选举会拒绝写操作(CP 特性),服务无法注册,这就是 CAP 那篇里说的坑。
Nacos:支持 AP 和 CP,按需切换
Nacos 同时支持 AP(临时实例,基于 Raft/Distro 协议)和 CP(持久实例,基于 Raft 协议)。默认情况下,服务注册的实例是临时实例(AP),配置管理是 CP。
Nacos 的心跳机制:临时实例每 5 秒发送心跳,Nacos Server 15 秒未收到心跳将实例标记为不健康,30 秒未收到心跳删除实例。这三个时间都比 Eureka 短,所以 Nacos 的服务下线感知比 Eureka 快。
推拉结合机制:客户端既会定期主动拉取(30 秒),也会订阅服务变更推送(UDP 推送)。正常情况下,服务上下线后 1-2 秒就能被客户端感知,远快于 Eureka 的 60 秒。
Consul:CP,多数据中心支持
Consul 基于 Raft 协议,是 CP 系统。特色是原生支持多数据中心联邦、内置健康检查(支持 TCP/HTTP/Script 多种检查方式)、内置 KV 存储和 DNS 接口。
Consul Agent 运行在每台服务器上,既做服务注册,也做健康检查,不依赖中心化的注册服务器做检查,减少了注册服务器的负载。
健康检查:Consul Agent 每隔 N 秒检查本机服务的健康状态,结果上报给 Consul Server,这与 Eureka(由实例自己发心跳)和 Nacos(由实例自己发心跳)的设计不同,避免了"实例发心跳说自己健康,但实际对外服务不正常"的问题。
三、完整代码实现
Spring Cloud + Nacos 服务注册
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
<version>2022.0.0.0</version>
</dependency>spring:
application:
name: order-service
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8848
namespace: prod
group: DEFAULT_GROUP
# 临时实例(AP 模式),默认为 true
ephemeral: true
# 心跳间隔(ms)
heart-beat-interval: 5000
# 心跳超时(ms)
heart-beat-timeout: 15000
# 实例删除超时(ms)
ip-delete-timeout: 30000
# 元数据
metadata:
version: "1.2.0"
env: "prod"服务发现与负载均衡
@Configuration
public class LoadBalancerConfig {
/**
* 自定义负载均衡规则(轮询 + 版本匹配)
*/
@Bean
@LoadBalancerClient(name = "inventory-service", configuration = VersionAwareLoadBalancer.class)
public ReactorLoadBalancer<ServiceInstance> versionAwareLoadBalancer() {
return null; // Bean 由 @LoadBalancerClient 自动创建
}
}public class VersionAwareLoadBalancer implements ReactorServiceInstanceLoadBalancer {
private final String serviceId;
private final ObjectProvider<ServiceInstanceListSupplier> serviceInstanceListSupplierProvider;
private final AtomicInteger position = new AtomicInteger(new Random().nextInt(1000));
@Value("${spring.cloud.nacos.discovery.metadata.version:}")
private String currentVersion;
@Override
public Mono<Response<ServiceInstance>> choose(Request request) {
ServiceInstanceListSupplier supplier =
serviceInstanceListSupplierProvider.getIfAvailable(NoopServiceInstanceListSupplier::new);
return supplier.get(request).next().map(instances -> {
// 优先选择同版本的实例
List<ServiceInstance> sameVersionInstances = instances.stream()
.filter(i -> currentVersion.equals(i.getMetadata().get("version")))
.collect(Collectors.toList());
List<ServiceInstance> candidates = sameVersionInstances.isEmpty()
? instances : sameVersionInstances;
if (candidates.isEmpty()) {
return new EmptyResponse();
}
// 轮询
int idx = Math.abs(position.getAndIncrement() % candidates.size());
return new DefaultResponse(candidates.get(idx));
});
}
}服务健康检查端点
@Component
public class CustomHealthIndicator implements HealthIndicator {
@Autowired
private DataSource dataSource;
@Autowired
private StringRedisTemplate redisTemplate;
@Override
public Health health() {
Health.Builder builder = new Health.Builder();
// 检查数据库连接
try (Connection conn = dataSource.getConnection()) {
conn.isValid(1);
builder.withDetail("database", "UP");
} catch (Exception e) {
return builder.down()
.withDetail("database", "DOWN: " + e.getMessage())
.build();
}
// 检查 Redis 连接
try {
redisTemplate.opsForValue().get("health-check");
builder.withDetail("redis", "UP");
} catch (Exception e) {
return builder.down()
.withDetail("redis", "DOWN: " + e.getMessage())
.build();
}
return builder.up().build();
}
}management:
endpoints:
web:
exposure:
include: health,info,metrics
endpoint:
health:
show-details: always
# 将自定义 HealthIndicator 暴露给 Nacos 健康检查
health:
defaults:
enabled: true优雅上下线
@Component
@Slf4j
public class GracefulShutdownHandler {
@Autowired
private NacosServiceRegistry nacosServiceRegistry;
@Autowired
private Registration registration;
@Autowired
private ApplicationContext applicationContext;
/**
* Spring 容器关闭时,先主动注销服务,再等待已有请求处理完
*/
@PreDestroy
public void deregister() {
log.info("服务开始优雅下线,主动注销 Nacos 实例");
try {
nacosServiceRegistry.deregister(registration);
log.info("Nacos 注销成功");
// 等待已在处理的请求完成(给负载均衡器更新时间)
Thread.sleep(10_000);
} catch (Exception e) {
log.error("Nacos 注销失败", e);
}
}
}四、三大注册中心横向对比
| 维度 | Eureka | Nacos | Consul |
|---|---|---|---|
| CAP | AP | AP/CP 可切换 | CP |
| 心跳间隔 | 30s | 5s | Agent 独立检查 |
| 下线感知延迟 | 最长 90s | 1-2s(推送) | 数秒 |
| 多数据中心 | 需自己实现 | 支持 | 原生支持 |
| 配置中心 | 不支持 | 支持 | 支持 KV |
| 维护状态 | 仅维护模式 | 阿里活跃维护 | HashiCorp活跃维护 |
| 部署复杂度 | 低 | 中 | 中 |
| 国内生态 | 减少使用 | 主流 | 较少 |
选型建议:新项目用 Nacos,国内社区生态好,功能全(服务注册+配置中心),维护活跃。已有 K8s 基础设施的,考虑 Consul 的多数据中心能力。Eureka 不推荐用于新项目(Netflix 已停止维护 Eureka 2.x)。
五、踩坑实录
坑一:Eureka 自我保护模式(开篇故事复盘)
根因分析:滚动部署时,20 个旧实例下线,心跳续约次数降低,触发自我保护。旧实例的注册信息没有被及时清理,负载均衡器将流量路由到已下线的实例。
解决方案:
- 开发/测试环境关闭自我保护:
eureka.server.enable-self-preservation=false - 生产环境适当调低 renewalPercentThreshold:
eureka.server.renewal-percent-threshold=0.75 - 减少心跳间隔和超时:快速感知实例下线
迁移到 Nacos 后,由于推送机制,下线感知从 90 秒缩短到 2 秒,这类问题完全消失。
坑二:Nacos 推送 UDP 包丢失
Nacos 的推送用的是 UDP,UDP 不可靠,包可能丢失。丢失后客户端要等到下一次 30 秒拉取才能感知变化,感知延迟退化到 30 秒。
排查方式:看 Nacos 客户端日志中的 pushReceiver 相关日志,如果长时间没有推送接收日志,说明 UDP 推送不通(可能是防火墙拦截了 UDP 端口)。
解决方案:确保服务器防火墙放通了 UDP 端口(Nacos 默认 9848),或缩短客户端轮询间隔。
坑三:服务注册信息的 IP 选错
容器化环境里,一个容器可能有多个网络接口(容器内网、主机网络、Docker bridge 等),Nacos 客户端在自动选择注册 IP 时,可能选到了容器内网 IP(如 172.x.x.x),而不是外部可访问的 IP。
解决方案:明确配置注册 IP:
spring:
cloud:
nacos:
discovery:
ip: ${POD_IP} # K8s 注入的 Pod IP
# 或者
network-interface: eth0 # 指定网卡六、总结
三大注册中心的选型本质是 CAP 取舍的再次体现:
Eureka(AP):简单,但感知慢,自我保护模式是把双刃剑,不推荐新项目使用。 Nacos(AP+CP):功能全面,推拉结合感知快,国内生态最好,新项目首选。 Consul(CP):多数据中心能力强,健康检查更主动,适合多云和国际化场景。
生产上,注册中心的选型确定后,优雅上下线是必须实现的功能——主动注销先于进程退出,给客户端和负载均衡器足够的时间感知变化,避免因"僵尸实例"导致的流量损失。
