在 Spring Boot 微服务体系中,注册中心的选型通常要同时考虑一致性、可用性以及客户端生态。etcd 作为 Kubernetes 的底层存储已经有大量生产验证,它提供强一致的键值存储、前缀查询和监听能力,用来做服务注册与发现非常自然。相比 Eureka 只支持最终一致和客户端轮询,etcd 的 watch 机制可以让服务消费方在节点上下线时几乎实时收到通知。

但 etcd 的 Java 客户端不像 Go 语言那么顺手,Spring Boot 官方也没有提供开箱即用的 starter。因此本文会从依赖选择、租约注册、监听发现到自动装配,完整走通 Spring Boot 整合 etcd 实现服务发现的过程。
一、为什么选择 etcd 做服务注册中心
etcd 的核心优势在于 Raft 共识算法带来的强一致性。注册中心如果只是存储临时节点,数据不一致最多导致短暂流量路由错误,但 etcd 把该能力下沉到存储层,可以让多个控制面组件共享同一份权威数据。在服务注册场景中,每个实例写入自己的元数据,消费方通过前缀查询和 watch 感知变化,不需要额外引入消息队列。
和 Consul 相比,etcd 更轻,部署简单,没有内置的 HTTP 健康检查,但 lease 租约可以承担心跳角色。和 Zookeeper 相比,etcd 的 API 基于 gRPC,读写性能更好,而且不会出现 Zookeeper 客户端会话丢失后需要重新注册临时节点的问题。对于 Java 技术栈,唯一要补的就是一个轻量级封装层。
二、核心依赖与客户端初始化
Java 官方提供的 etcd 客户端是 jetcd,核心模块为 jetcd-core。在 Maven 项目中引入如下依赖:
<dependency>
<groupId>io.etcd</groupId>
<artifactId>jetcd-core</artifactId>
<version>0.7.0</version>
</dependency>
需要注意 jetcd 0.x 版本之间的 API 存在差异,例如 Client 构建方式、lease 对象获取方式都有调整。建议统一使用 0.7.0 或更高版本。连接 etcd 集群时,可以在配置文件中声明多个端点,客户端会自动进行故障转移。
etcd:
endpoints: http://127.0.0.1:2379,http://127.0.0.1:2380
registry:
prefix: /services
ttl: 30
初始化 Client 时,把 Client 声明为单例 Bean,保证全局共享连接。启动时不需要额外的认证就可以连接本地 etcd;生产环境建议开启 TLS 或用户名密码认证。下面是一个最简配置类:
@Configuration
public class EtcdConfig {
@Bean(destroyMethod = "close")
public Client etcdClient() {
return Client.builder()
.endpoints("http://127.0.0.1:2379")
.build();
}
}
Client 的 close 方法会释放底层 gRPC 连接,务必在应用关闭时自动调用。如果连接被提前关闭,后续所有 put、get、watch 操作都会抛出异常,因此不要试图在每次调用时新建 Client。
三、基于租约实现服务注册
服务注册的核心不是简单写一条 KV,而是要保证实例宕机后注册信息能被及时清理。etcd 的 lease(租约)机制恰好适合这个场景:客户端先申请一个带 TTL 的租约,再把 key 绑定到该租约上,只要客户端持续续约,key 就存在;续约停止后,etcd 会在 TTL 到期时自动删除 key。
通常会把 key 设计为 /services/{serviceName}/{instanceId},value 保存 JSON 格式的实例元数据,包括主机、端口、权重、启动时间等。instanceId 建议使用 host:port:uuid,避免同一台机器上多个实例互相覆盖。
public class ServiceInstance {
private String id;
private String serviceName;
private String host;
private int port;
private Map<String, String> metadata = new HashMap<>();
// 省略 getter / setter
}
注册流程分为三步:创建租约、执行 put 绑定租约、启动续约任务。下面的代码把这三步封装起来,并提供一个后台线程定期发送 keepalive:
@Service
public class EtcdRegistryService {
private final Client client;
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
private final Map<String, Lease> leases = new ConcurrentHashMap<>();
public EtcdRegistryService(Client client) {
this.client = client;
}
public void register(ServiceInstance instance) throws Exception {
long leaseId = client.getLeaseClient().grant(30).get().getID();
ByteSequence key = ByteSequence.from(
"/services/" + instance.getServiceName() + "/" + instance.getId(),
StandardCharsets.UTF_8);
ByteSequence value = ByteSequence.from(
toJson(instance), StandardCharsets.UTF_8);
PutOption putOption = PutOption.newBuilder().withLeaseId(leaseId).build();
client.getKVClient().put(key, value, putOption).get();
Lease lease = leaseIdToLease(leaseId);
leases.put(instance.getId(), lease);
scheduler.scheduleAtFixedRate(() -> {
try {
lease.keepAliveOnce().get();
} catch (Exception e) {
// 续约失败,可触发重新注册
}
}, 0, 10, TimeUnit.SECONDS);
}
public void deregister(ServiceInstance instance) throws Exception {
ByteSequence key = ByteSequence.from(
"/services/" + instance.getServiceName() + "/" + instance.getId(),
StandardCharsets.UTF_8);
client.getKVClient().delete(key).get();
Lease lease = leases.remove(instance.getId());
if (lease != null) {
lease.close();
}
}
}
这段代码里的 scheduleAtFixedRate 以固定频率续约,间隔通常设置为 TTL 的三分之一左右。租约 TTL 如果设置太短,网络抖动容易导致实例被错误摘除;太长则故障发现时间变慢。30 秒 TTL 配 10 秒续约间隔是比较稳妥的初始值。
还需要注意,keepAliveOnce() 只是单次续约,如果抛出异常应当根据业务决定是否重建租约并重新注册。对于短时间网络闪断,etcd 的租约不会立刻失效,只要断线时间小于 TTL,恢复后继续续约即可。
四、利用 watch 实现服务发现与负载均衡
服务消费方不能每次请求都去 etcd 查询,否则 etcd 的压力会随着调用量线性增长。更合理的做法是先通过前缀查询拉取一次全量数据,再用 watch API 监听后续增量事件,本地缓存实时更新。etcd 的 watch 支持前缀监听,当 /services/order 下的任意 key 发生变化,客户端都会收到事件。
下面实现一个简单的发现客户端,维护 Map<String, List<ServiceInstance>> 作为本地缓存。
@Service
public class EtcdDiscoveryClient {
private final Client client;
private final Map<String, List<ServiceInstance>> cache = new ConcurrentHashMap<>();
public EtcdDiscoveryClient(Client client) {
this.client = client;
}
public List<ServiceInstance> getInstances(String serviceName) {
return cache.getOrDefault(serviceName, Collections.emptyList());
}
public void startWatching() {
ByteSequence prefix = ByteSequence.from("/services/", StandardCharsets.UTF_8);
client.getWatchClient().watch(prefix, response -> {
for (WatchEvent event : response.getEvents()) {
String key = event.getKeyValue().getKey().toString(StandardCharsets.UTF_8);
String serviceName = extractServiceName(key);
if (serviceName == null) {
continue;
}
if (event.getEventType() == WatchEvent.EventType.PUT) {
ServiceInstance instance = parseJson(
event.getKeyValue().getValue().toString(StandardCharsets.UTF_8));
addInstance(serviceName, instance);
} else if (event.getEventType() == WatchEvent.EventType.DELETE) {
String instanceId = extractInstanceId(key);
removeInstance(serviceName, instanceId);
}
}
});
}
}
事件处理中需要保持幂等:PUT 事件可能重复,因此 addInstance 时要按实例 ID 去重;DELETE 事件只删除对应实例,不能清空整个服务列表。缓存更新最好放在独立的线程池中,避免阻塞 etcd 的 gRPC 回调线程。
拿到实例列表后,负载均衡可以先用简单的随机或轮询策略。下面是一个轮询实现:
public class RoundRobinLoadBalancer {
private final AtomicInteger position = new AtomicInteger(0);
public ServiceInstance choose(List<ServiceInstance> instances) {
if (instances == null || instances.isEmpty()) {
throw new IllegalStateException("no available instance");
}
int index = Math.abs(position.getAndIncrement() % instances.size());
return instances.get(index);
}
}
在实际调用时,先通过 EtcdDiscoveryClient 获取可用实例,再选取一个实例,用 RestTemplate 或 WebClient 发起请求。若调用失败,可以选择重试下一个实例,这比静态配置服务地址要灵活得多。
五、Spring Boot 自动配置整合
为了不让业务项目到处复制这些初始化代码,可以把注册与发现能力封装成一个 starter。自动配置类需要创建 Client、注册服务、发现客户端,并在 Spring 容器启动完成后注册当前应用,关闭时注销。
@Configuration
@EnableConfigurationProperties(EtcdProperties.class)
public class EtcdAutoConfiguration {
@Bean(destroyMethod = "close")
public Client etcdClient(EtcdProperties properties) {
return Client.builder()
.endpoints(properties.getEndpoints().toArray(new String[0]))
.build();
}
@Bean
public EtcdRegistryService etcdRegistryService(Client client) {
return new EtcdRegistryService(client);
}
@Bean
public EtcdDiscoveryClient etcdDiscoveryClient(Client client) {
EtcdDiscoveryClient client = new EtcdDiscoveryClient(client);
client.startWatching();
return client;
}
}
这里只是给出基础结构,实际项目中还需要处理 @ConditionalOnClass、@ConditionalOnMissingBean 以及配置项默认值。当前服务实例的注册时机可以通过监听 ApplicationReadyEvent 触发,也可以在 SmartLifecycle 的 start 方法中完成。
@Component
public class ServiceRegistrar implements ApplicationListener<ApplicationReadyEvent> {
private final EtcdRegistryService registryService;
private final EtcdProperties properties;
public ServiceRegistrar(EtcdRegistryService registryService, EtcdProperties properties) {
this.registryService = registryService;
this.properties = properties;
}
@Override
public void onApplicationEvent(ApplicationReadyEvent event) {
ServiceInstance instance = buildSelfInstance(properties);
try {
registryService.register(instance);
} catch (Exception e) {
throw new IllegalStateException("failed to register service", e);
}
}
}
在 Spring Boot 2.7 及更高的版本中,自动配置类需要通过 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 文件声明,而在较低版本中则使用 META-INF/spring.factories 文件。文件内容很简单:
com.example.etcdstarter.EtcdAutoConfiguration
配置好之后,业务服务只需加入 starter 依赖,并在配置文件中指定 etcd 地址和服务信息。应用启动后,可以通过 etcdctl 命令查看注册数据:
etcdctl get --prefix /services/
当启动多个实例时,键值会依次出现;如果直接 kill 进程,租约过期后对应的 key 会在几十秒内自动消失。调用方通过 watch 收到删除事件,更新本地缓存,后续请求就不会再路由到故障节点。
六、常见问题与调优建议
在 etcd 服务发现落地过程中,最常见的错误是把 TTL 设置得过短。假设 TTL 为 5 秒,而应用发生了 3 秒的 Full GC,续约线程被暂停,租约就会立即过期,所有实例被摘除。因此 TTL 不要小于 30 秒,同时续约线程最好使用独立线程池,避免被业务线程池饱和影响。
watch 连接也不是永远稳定,网络闪断后客户端会收到 onCompleted 或异常。jetcd 的 watch 不会自动重连,封装层需要在回调中捕获异常并重新发起 watch,同时重新执行一次全量前缀查询,弥补断开期间可能丢失的事件。
如果实例数量达到数千个,频繁的 watch 事件可能会对本地缓存造成压力。这时可以考虑按服务名拆分 watch,只监听消费方依赖的服务,而不是监听整个 /services 前缀。另外,etcd 的写入性能并不是线性的,大型集群适合使用独立的 etcd 节点组承载注册中心流量,与配置中心数据做物理隔离。

