Etcd 是一个分布式键值存储系统,提供了高可用性和一致性的保证。它在分布式系统中有许多常见的应用场景,以下是一些主要的应用场景
1. 服务发现
Etcd 可以用来注册和发现分布式系统中的服务。服务实例在启动时将其信息注册到 Etcd 中,其他服务可以查询 Etcd 获取可用的服务实例列表。
1.1. 服务注册
服务注册,当前服务启动的时候需要将ip等信息注册到etcd,服务关闭的时候要从etcd中删除信息。为防止因为异常导致服务关闭时未成功删除,在注册到etcd时给键创建租约,通过定时Job定时给租约续期。
- 启动注册:
@PostConstruct
注解的方法init()
在 bean 初始化后调用,用于自动注册服务。调用register()
方法创建一个租约,并将服务信息注册到 Etcd 中。 - 下线删除:
@PreDestroy
注解的方法cleanup()
在 bean 销毁前调用,用于自动注销服务。unregister()
方法在服务关闭时删除对应键。 - 定时续约:可通过定时Job,定时调用
keepAlive()
方法续期租约,确保服务注册信息持续有效。 - 过期删除:如果没有续约成功,说明服务异常故障,但又没执行unregister方法,那么在到期后自动删除键。
ServiceRegistry.java
import io.etcd.jetcd.ByteSequence;
import io.etcd.jetcd.Client;
import io.etcd.jetcd.lease.Lease;
import io.etcd.jetcd.lease.LeaseGrantResponse;
import io.etcd.jetcd.options.PutOption;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import java.util.concurrent.ExecutionException;
@Service
public class ServiceRegistry {
private final Client client;
private final Lease leaseClient;
private long leaseId;
private String serviceName;
private String serviceAddress;
@Autowired
public ServiceRegistry(Client client) {
this.client = client;
this.leaseClient = client.getLeaseClient();
}
@PostConstruct
public void init() throws ExecutionException, InterruptedException {
// 在服务启动时自动注册服务
this.serviceName = "my-service";
this.serviceAddress = "127.0.0.1:8080";
register(serviceName, serviceAddress);
keepAlive(); // Keep the service alive
}
@PreDestroy
public void cleanup() throws ExecutionException, InterruptedException {
// 在服务关闭时自动注销服务
unregister(serviceName);
}
public void register(String serviceName, String serviceAddress) throws ExecutionException, InterruptedException {
// 创建一个租约,TTL 为 10 秒
LeaseGrantResponse leaseResponse = leaseClient.grant(10).get();
this.leaseId = leaseResponse.getID();
// 注册服务
ByteSequence key = ByteSequence.from(serviceName, "UTF-8");
ByteSequence value = ByteSequence.from(serviceAddress, "UTF-8");
client.getKVClient().put(key, value, PutOption.newBuilder().withLeaseId(leaseId).build()).get();
System.out.println("Registered service: " + serviceName + " at " + serviceAddress);
}
public void keepAlive() throws ExecutionException, InterruptedException {
// 续期租约
leaseClient.keepAlive(leaseId);
}
public void unregister(String serviceName) throws ExecutionException, InterruptedException {
// 注销服务
ByteSequence key = ByteSequence.from(serviceName, "UTF-8");
client.getKVClient().delete(key).get();
System.out.println("Unregistered service: " + serviceName);
}
}
1.2. 服务发现
前面服务注册时,将对应服务器的信息注册到etcd中,服务发现只需直接查询对应etcd中的键值信息即可。
但如果有些场景中需要立即发现服务信息变更,还可以通过前缀监听的方式,监听有变更的服务器列表。
ServiceDiscovery.java
import io.etcd.jetcd.ByteSequence;
import io.etcd.jetcd.Client;
import io.etcd.jetcd.kv.GetResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.concurrent.ExecutionException;
@Service
public class ServiceDiscovery {
private final Client client;
@Autowired
public ServiceDiscovery(Client client) {
this.client = client;
}
public String discover(String serviceName) throws ExecutionException, InterruptedException {
ByteSequence key = ByteSequence.from(serviceName, "UTF-8");
GetResponse response = client.getKVClient().get(key).get();
if (response.getKvs().isEmpty()) {
return null;
}
return response.getKvs().get(0).getValue().toStringUtf8();
}
}
2. 配置管理
因为etcd是键值存储,很适合做配置管理。数据存在etcd中,但如果读的场景很多,就不建议每次都直接查etcd。可以在服务器中再构建内存存储,从etcd中基于watch同步最新数据到内存中,业务读就直接查内存。
- 初始读取和版本记录:·updateLocalConfig(String key)· 方法从 Etcd 获取初始配置,并记录最新的版本号(revision)。
- 监听配置变化:·watchConfig(String key)· 方法利用 Etcd 的 watch 功能监听配置变化。在监听到配置变化时,检查版本号,确保只有新版本的配置才会更新到本地内存。
- 定时校验:·checkAndUpdateConfig(String key)· 方法定期从 Etcd 获取最新的配置,并与本地内存中的数据进行比较,确保一致性。如果发现配置不一致且版本号(revision)更高,则更新本地内存中的数据。
- 网络故障处理:在监听过程中,如果因网络故障导致版本号不一致,可以通过定时任务重新同步配置,确保数据的一致性。
import io.etcd.jetcd.ByteSequence;
import io.etcd.jetcd.Client;
import io.etcd.jetcd.kv.GetResponse;
import io.etcd.jetcd.options.GetOption;
import io.etcd.jetcd.watch.WatchEvent;
import io.etcd.jetcd.watch.WatchResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
@Service
public class ConfigManager {
private final Client client;
private final AtomicReference<String> localConfig = new AtomicReference<>();
private final AtomicLong latestRevision = new AtomicLong(0);
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
@Autowired
public ConfigManager(Client client) {
this.client = client;
}
@PostConstruct
public void init() throws ExecutionException, InterruptedException {
// 初始化本地配置
updateLocalConfig("my-config-key");
// 启动配置监听
watchConfig("my-config-key");
// 启动定时任务,定期从 Etcd 获取配置并与本地配置进行比较
scheduler.scheduleAtFixedRate(() -> {
try {
checkAndUpdateConfig("my-config-key");
} catch (Exception e) {
e.printStackTrace();
}
}, 0, 30, TimeUnit.SECONDS);
}
@PreDestroy
public void cleanup() {
scheduler.shutdown();
}
public String getConfig(String key) {
return localConfig.get();
}
private void updateLocalConfig(String key) throws ExecutionException, InterruptedException {
ByteSequence keyByteSequence = ByteSequence.from(key, StandardCharsets.UTF_8);
CompletableFuture<GetResponse> getFuture = client.getKVClient().get(keyByteSequence);
GetResponse response = getFuture.get();
if (!response.getKvs().isEmpty()) {
String newValue = response.getKvs().get(0).getValue().toString(StandardCharsets.UTF_8);
localConfig.set(newValue);
latestRevision.set(response.getKvs().get(0).getModRevision());
System.out.println("Local config updated: " + newValue + ", revision: " + latestRevision.get());
}
}
private void watchConfig(String key) {
ByteSequence keyByteSequence = ByteSequence.from(key, StandardCharsets.UTF_8);
client.getWatchClient().watch(keyByteSequence, response -> {
for (WatchEvent event : response.getEvents()) {
switch (event.getEventType()) {
case PUT:
String newValue = event.getKeyValue().getValue().toString(StandardCharsets.UTF_8);
long newRevision = event.getKeyValue().getModRevision();
if (newRevision > latestRevision.get()) {
localConfig.set(newValue);
latestRevision.set(newRevision);
System.out.println("Config updated via watch: " + newValue + ", revision: " + newRevision);
}
break;
case DELETE:
localConfig.set(null);
latestRevision.set(event.getKeyValue().getModRevision());
System.out.println("Config deleted via watch, revision: " + latestRevision.get());
break;
}
}
});
}
private void checkAndUpdateConfig(String key) throws ExecutionException, InterruptedException {
ByteSequence keyByteSequence = ByteSequence.from(key, StandardCharsets.UTF_8);
CompletableFuture<GetResponse> getFuture = client.getKVClient().get(keyByteSequence);
GetResponse response = getFuture.get();
if (!response.getKvs().isEmpty()) {
String etcdValue = response.getKvs().get(0).getValue().toString(StandardCharsets.UTF_8);
long etcdRevision = response.getKvs().get(0).getModRevision();
String localValue = localConfig.get();
if (etcdRevision > latestRevision.get() && !etcdValue.equals(localValue)) {
localConfig.set(etcdValue);
latestRevision.set(etcdRevision);
System.out.println("Local config updated via scheduled check: " + etcdValue + ", revision: " + etcdRevision);
}
}
}
}
3. 分布式锁
Etcd实现分布式锁的方式,其实和Redis很像。Etcd 可以用来实现分布式锁,依赖于它的强一致性和租约机制。Etcd 的分布式锁通常使用以下策略:
- 创建租约:创建一个带有 TTL(生存时间)的租约,用于管理锁的生命周期。
- 尝试获取锁:使用租约创建一个键,如果创建成功则表示获取了锁。
- 续约:在持有锁的过程中不断续约,以防止锁过期。
- 释放锁:删除键或释放租约来释放锁。
示例代码
import io.etcd.jetcd.ByteSequence;
import io.etcd.jetcd.Client;
import io.etcd.jetcd.lease.Lease;
import io.etcd.jetcd.lease.LeaseGrantResponse;
import io.etcd.jetcd.lock.LockResponse;
import io.etcd.jetcd.options.PutOption;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
@Service
public class DistributedLockManager {
private final Client client;
private final Lease leaseClient;
@Autowired
public DistributedLockManager(Client client) {
this.client = client;
this.leaseClient = client.getLeaseClient();
}
// 获取锁
public boolean acquireLock(String lockKey, long ttl) {
try {
// 创建一个租约
LeaseGrantResponse leaseResponse = leaseClient.grant(ttl).get();
long leaseId = leaseResponse.getID();
// 尝试获取锁
ByteSequence lockKeyByteSequence = ByteSequence.from(lockKey, StandardCharsets.UTF_8);
LockResponse lockResponse = client.getLockClient().lock(lockKeyByteSequence, leaseId).get();
System.out.println("Lock acquired: " + lockKey);
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
// 释放锁
public void releaseLock(String lockKey) {
try {
ByteSequence lockKeyByteSequence = ByteSequence.from(lockKey, StandardCharsets.UTF_8);
client.getLockClient().unlock(lockKeyByteSequence).get();
System.out.println("Lock released: " + lockKey);
} catch (Exception e) {
e.printStackTrace();
}
}
}
和redis分布式锁对比
通过代码来看,二者实现分布式锁的方式基本没区别。但Etcd 强调的是CP,redis更关注AP。redis集群创建分布式锁时,可能出现多个节点都获取到同一个锁,导致锁就没意义了。
虽然redis集群中写入操作都是由master节点处理,但下列一些情况还是会导致问题:
- 主从复制延迟:Redis 主从复制是异步的。当主节点写入数据后,可能需要一些时间才能将数据同步到从节点。如果在数据还没完全同步的情况下主节点发生故障,从节点提升为新主节点,那么新主节点上可能没有最新的锁信息,从而导致另一个客户端获取到相同的锁。
- 网络分区:在网络分区的情况下,不同的 Redis 节点可能无法互相通信。客户端可能会连接到不同的节点并尝试获取锁,导致多个客户端同时获得锁。
- 故障转移(Failover):在 Redis 集群中,如果主节点宕机,会进行故障转移选举新的主节点。如果在故障转移过程中存在网络延迟或者其他问题,也可能导致多个客户端同时获得锁。
为了解决问题,Redis引入了红锁算法,这点可以见之前的文章,实际也是仿造etcd、zookeeper,只有当大多数节点都确认上锁成功了,才返回锁成功。
因此总体来看,如果很看重锁的一致性,更推荐etcd、zookeeper的分布式锁。
4. 领导者选举
领导者选举和分布式锁区别
- 分布式锁:确保在一个分布式系统中同时只有一个节点可以访问或修改共享资源。通常是短期的锁定机制,用于保护临界区代码或资源的并发访问。
- 领导者选举:确保在一个分布式系统中只有一个活跃的领导者节点用于执行特定的任务。这个机制常用于确保数据的一致性和任务的唯一性。领导者需要不断地续约租约以维持领导者身份,如果领导者失效,其他节点会竞选成为新的领导者。
Etcd 领导者选举的实现方式
Etcd 提供了原生的领导者选举机制,主要依赖于以下几个步骤:
- 创建租约:领导者选举通常通过租约(lease)来实现。租约具有 TTL(生存时间),确保在领导者失效时可以自动释放锁。
- 尝试创建键:使用租约创建一个键(key),表示该节点成为领导者。如果键已经存在,则表示其他节点已经成为领导者。
- 续约:领导者节点需要不断地续约租约,以维持其领导者的身份。
- 监听键变化:其他节点监听该键的变化,如果领导者节点失效,其他节点可以尝试获取领导者身份。
示例代码
init()
和cleanup()
方法分别在启动和关闭时被调用。attemptLeadership()
方法创建租约并尝试创建表示领导者的键。startLeaseRenewer()
方法定期续约租约,以保持领导者身份。watchLeaderKey(ByteSequence lockKey)
方法监听领导者键的变化,如果键被删除,则再次尝试成为领导者。
import io.etcd.jetcd.ByteSequence;
import io.etcd.jetcd.Client;
import io.etcd.jetcd.lease.Lease;
import io.etcd.jetcd.lease.LeaseGrantResponse;
import io.etcd.jetcd.options.PutOption;
import io.etcd.jetcd.watch.WatchEvent;
import io.etcd.jetcd.watch.WatchResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
@Service
public class LeaderElectionManager {
private final Client client;
private final Lease leaseClient;
private ScheduledExecutorService scheduler;
private long leaseId;
private boolean isLeader = false;
@Autowired
public LeaderElectionManager(Client client) {
this.client = client;
this.leaseClient = client.getLeaseClient();
}
@PostConstruct
public void init() {
scheduler = Executors.newScheduledThreadPool(1);
try {
attemptLeadership();
} catch (ExecutionException | InterruptedException e) {
e.printStackTrace();
}
}
@PreDestroy
public void cleanup() {
scheduler.shutdown();
}
private void attemptLeadership() throws ExecutionException, InterruptedException {
// 创建租约
LeaseGrantResponse leaseResponse = leaseClient.grant(5).get();
leaseId = leaseResponse.getID();
ByteSequence lockKey = ByteSequence.from("leader-key", StandardCharsets.UTF_8);
ByteSequence lockValue = ByteSequence.from("leader-value", StandardCharsets.UTF_8);
// 尝试创建键表示成为领导者
CompletableFuture<Void> putFuture = client.getKVClient().put(lockKey, lockValue, PutOption.newBuilder().withLeaseId(leaseId).withPrevKV().build()).thenAccept(putResponse -> {
if (putResponse.getPrevKv() == null) {
System.out.println("I am the leader");
isLeader = true;
startLeaseRenewer();
} else {
System.out.println("Another node is the leader");
watchLeaderKey(lockKey);
}
});
putFuture.get();
}
private void startLeaseRenewer() {
scheduler.scheduleAtFixedRate(() -> {
try {
leaseClient.keepAliveOnce(leaseId).get();
System.out.println("Lease renewed");
} catch (Exception e) {
e.printStackTrace();
}
}, 0, 3, TimeUnit.SECONDS);
}
private void watchLeaderKey(ByteSequence lockKey) {
client.getWatchClient().watch(lockKey, response -> {
for (WatchEvent event : response.getEvents()) {
switch (event.getEventType()) {
case DELETE:
System.out.println("Leader key deleted, attempting to become leader");
try {
attemptLeadership();
} catch (ExecutionException | InterruptedException e) {
e.printStackTrace();
}
break;
default:
break;
}
}
});
}
public boolean isLeader() {
return isLeader;
}
}
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。