1. 写入

1.1. etcd一致性

etcd 提供了强一致性(Strong Consistency)。所有写操作都必须通过当前的领导者节点(Leader),并在多数追随者节点(Followers)确认后才会被提交。

1、工作流程
  1. 客户端将写请求发送到领导者节点。
  2. 领导者节点将写操作记录到自己的日志,然后将日志条目复制到所有追随者节点。
  3. 当多数节点确认收到日志条目后,领导者节点将其标记为已提交,并应用到状态机。
  4. 领导者节点向客户端返回成功确认。

Raft 算法通过以下机制来处理这些异常:

  • 领导者故障:如果领导者节点失效,集群中的其他节点会发起新的领导者选举,选出新的领导者节点。新的领导者节点会从已提交的日志条目中恢复一致性状态。
  • 追随者故障:如果某个追随者节点失效,领导者节点仍然可以在大多数节点确认写操作后进行提交。一旦追随者节点恢复,它会从领导者节点同步最新的日志条目,恢复一致状态。

因此,当客户端收到写入成功的消息时,表示leader节点及大多数节点都已成功写入,且提交到状态机了。就算还有其他少量节点还没有写入,etcd后续也会保证它们会被同步到。

2、优缺点

优点:提供强一致性保证,即所有节点最终会收敛到相同的状态。适用于对数据一致性要求高的场景。

缺点:在高负载或网络分区情况下,可能会出现写操作延迟。

1.2. etcd写入顺序

由于所有写请求都必须经过 Leader 节点,并且 Leader 节点会将日志条目按顺序添加到自己的日志中,这就保证了写请求的顺序性。

虽然这些写请求在 Leader 节点上的处理和复制过程是并行的,但各个 Follower 节点也是按照 Leader 节点写入的顺序提交日志条目。这是 Raft 共识算法的一部分,确保了整个集群中的所有节点都保持相同的一致性和操作顺序

Raft 算法中的日志复制和提交流程:

  • Leader 写入日志

    • Leader 节点接收到写请求后,会将请求转换为日志条目,并将其添加到本地的 Raft 日志中。
    • 这些日志条目在 Leader 节点上是按顺序写入的,形成一个有序的日志条目列表。
  • 日志条目的复制

    • Leader 节点将新日志条目并行地复制到所有 Follower 节点。
    • Follower 节点接收到这些日志条目后,会将它们添加到本地日志中,保持与 Leader 节点相同的顺序。
  • 日志条目的提交

    • 当 Leader 节点和多数 Follower 节点都确认接收到并持久化了某个日志条目后,Leader 节点会将这个日志条目标记为已提交。
    • 提交是按顺序进行的,即 Leader 节点只有在之前的日志条目提交后,才能提交后续的日志条目。
  • 应用到状态机

    • 提交日志条目后,Leader 和 Follower 节点会按照日志中的顺序将这些操作应用到各自的状态机中。

因此,可以说etcd的写入是严格按照顺序写入的。

1.3. 对比eureka

Eureka 主要采用以高可用性和最终一致性为目标的设计,而不是强一致性。

1、工作流程

写请求处理:

  1. 客户端将写请求(如服务注册或续约)发送到一个 Eureka 服务器节点。
  2. 该节点立即处理请求并将信息更新到本地注册表,然后立即向客户端返回成功确认。
  3. 随后,该节点以异步的方式将更新传播给其他 Eureka 服务器节点。

异步复制机制:

  • Eureka 采用异步复制机制,即写操作先在收到请求的节点上完成,然后通过后台线程将更新同步到集群中的其他节点。
  • 这种设计确保了高可用性和快速响应,但在网络分区或节点故障的情况下,可能会导致短暂的数据不一致。
2、优点

对于服务发现和注册场景,这种最终一致性通常是可接受的,因为服务实例的变化(如上线或下线)不需要立即全局可见。

由于没有主节点,任何 Eureka 服务器节点都可以处理写请求,提高了系统的可用性。如果一个节点失效,其他节点仍然可以处理请求,服务注册和发现不会中断。

3、缺点

由于异步复制机制,Eureka 提供的是最终一致性。这意味着系统在一段时间后会达到一致状态,但在短时间内,可能会存在数据不一致的情况。即客户端收到写入成功的返回时,实际上可能只在一个节点上写入成功,如果此时查询的请求打入其他节点,就查询不到。

另外,因为每个节点都可以先写入,而且没有多数节点都确认。当多个节点同时修改某个配置时,各自节点都写入成功了,但在节点之间日志同步时,就会出现冲突。

4、写入顺序

由于eureka每个节点都可以先写入,然后再异步同步给其他节点,所以没办法保证顺序。

2. 读取

etcd 提供了多种读取方式,主要包括线性一致性读取、顺序一致性读取和最终一致性读取。下面将详细讲解这几种读取方式的读取流程。

2.1. 线性一致性读取(Linearizable Read)

1、流程
  • 发送请求到 Leader 节点:客户端将读取请求发送到集群中的 Leader 节点。
  • Leader 处理读取请求

    • Leader 节点将读取请求作为一个空写操作(no-op)写入 Raft 日志,确保读取操作在所有先前的写操作之后。
    • Leader 节点等待这个空写操作被多数节点(包括自己)确认和提交。
  • 读取最新数据:当空写操作被提交后,Leader 节点执行读取操作,确保返回的数据是最新的。
  • 返回结果给客户端:Leader 节点将读取结果返回给客户端。
2、特点
  • 强一致性:确保读取的数据是最新的,反映了所有先前的写操作。
  • 较高的延迟:需要与多数节点通信并确认空写操作,因此延迟较高。

2.2. 顺序一致性读取(Sequential Read)

1、流程
  • 发送请求到 Leader 节点:客户端将读取请求发送到集群中的 Leader 节点。
  • Leader 处理读取请求

    • Leader 节点直接从本地状态读取数据,并返回给客户端。
    • 由于 Leader 节点已经确保了操作的顺序性,该读取操作是顺序一致的。
2、特点
  • 强一致性:读取操作遵循操作的顺序性,但不一定是最新的。
  • 较高的延迟:不需要与多数节点通信,性能较好。

2.3. 最终一致性读取(Eventually Consistent Read)

1、流程
  • 发送请求到 Leader 节点:客户端可以将读取请求发送到集群中的任意节点(包括 Leader 和 Follower)。
  • 节点处理读取请求

    • 接受请求的节点直接从本地状态读取数据,并返回给客户端。
    • 由于读取的数据可能不同步,因此数据一致性较弱。
2、特点
  • 一致性较弱:返回的数据可能不是最新的,但在一段时间后会达到一致性。
  • 性能最佳:可以从任意节点读取数据,延迟最低,适用于对一致性要求不高的场景。

2.4. 代码示例

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.options.ReadOption;

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;

public class EtcdReadExample {

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        String endpoint = "http://localhost:2379"; // etcd 服务器地址
        String key = "your-key"; // 键名

        // 创建 etcd 客户端实例
        Client client = Client.builder().endpoints(endpoint).build();

        try {
            // 线性一致性读取(默认)
            System.out.println("Linearizable Read (default):");
            linearizableRead(client, key);

            // 顺序一致性读取
            System.out.println("\nSequential Read:");
            sequentialRead(client, key);

            // 最终一致性读取
            System.out.println("\nEventually Consistent Read:");
            eventuallyConsistentRead(client, key);

        } finally {
            // 关闭客户端连接
            client.close();
        }
    }

    // 线性一致性读取(默认方式)
    private static void linearizableRead(Client client, String key) throws ExecutionException, InterruptedException {
        // 调用通用读取方法,不传递任何特殊选项
        performRead(client, key);
    }

    // 顺序一致性读取
    private static void sequentialRead(Client client, String key) throws ExecutionException, InterruptedException {
        // 设置读取选项为顺序一致性
        ReadOption readOption = ReadOption.newBuilder().withConsistency(ReadOption.Consistency.SERIALIZABLE).build();
        performRead(client, key, readOption);
    }

    // 最终一致性读取
    private static void eventuallyConsistentRead(Client client, String key) throws ExecutionException, InterruptedException {
        // 设置读取选项为最终一致性
        GetOption getOption = GetOption.newBuilder().withConsistency(GetOption.Consistency.KUS).build();
        performRead(client, key, getOption);
    }

    // 执行读取操作并输出结果(不传递选项,使用默认的线性一致性读取)
    private static void performRead(Client client, String key) throws ExecutionException, InterruptedException {
        // 执行读取操作(默认的线性一致性读取)
        CompletableFuture<GetResponse> responseFuture = client.getKVClient().get(ByteSequence.from(key, "UTF-8"));

        // 获取读取结果
        GetResponse response = responseFuture.get();

        // 输出读取到的值
        if (response.getKvs().size() > 0) {
            System.out.println("Key: " + response.getKvs().get(0).getKey().toStringUtf8());
            System.out.println("Value: " + response.getKvs().get(0).getValue().toStringUtf8());
        } else {
            System.out.println("Key not found");
        }
    }

    // 执行读取操作并输出结果(传递读取选项)
    private static void performRead(Client client, String key, GetOption getOption) throws ExecutionException, InterruptedException {
        // 执行读取操作
        CompletableFuture<GetResponse> responseFuture = client.getKVClient().get(ByteSequence.from(key, "UTF-8"), getOption);

        // 获取读取结果
        GetResponse response = responseFuture.get();

        // 输出读取到的值
        if (response.getKvs().size() > 0) {
            System.out.println("Key: " + response.getKvs().get(0).getKey().toStringUtf8());
            System.out.println("Value: " + response.getKvs().get(0).getValue().toStringUtf8());
        } else {
            System.out.println("Key not found");
        }
    }

    // 重载方法,适配 ReadOption
    private static void performRead(Client client, String key, ReadOption readOption) throws ExecutionException, InterruptedException {
        // 执行读取操作
        CompletableFuture<GetResponse> responseFuture = client.getKVClient().get(ByteSequence.from(key, "UTF-8"), readOption);

        // 获取读取结果
        GetResponse response = responseFuture.get();

        // 输出读取到的值
        if (response.getKvs().size() > 0) {
            System.out.println("Key: " + response.getKvs().get(0).getKey().toStringUtf8());
            System.out.println("Value: " + response.getKvs().get(0).getValue().toStringUtf8());
        } else {
            System.out.println("Key not found");
        }
    }
}

2.5. 总结

通过代码可以看出,etcd默认的读取方式是线行一致性读取,说明etcd更侧重强一致性的场景。

三种读取方式一致性由弱到强分别为:

1、最终一致性读取

客户端可以将读取请求发送到集群中的任意节点(包括 Leader 和 Follower)。

如果读取请求到了leader节点,或者是同步到leader最新日志的follower节点,其实和“顺序一致性读取”一样。

但如果读取请求到了一个落后leader日志的follower节点(因为网络暂时隔离等情况),那么读取到的数据就是过时的。基于 raft算法,要等到后续网络等恢复之后,该节点才会同步到最新日志。

该节点的好处在于“读写分离”,当高并发读取时,如果都请求leader节点会扛不住。

2、顺序一致性读取

客户端的请求都发送在leader节点,所以相比较前者而言,能保障读到的是当前已提交的最新的数据。

但是etcd 内部使用读写锁机制,因此读操作不会与正在进行的写操作冲突,当有一批非事务的读写命令请求时,没办法严格保障执行顺序。

3、线性一致性读取

读取操作被伪装成空写操作,也会有大多数follower节点的确认。在写入的章节说过,etcd实现了所有节点写入的顺序一致性,因此该读取方式,也实现了严格的顺序。

但缺点就是,所有请求都交给leader处理,读取请求都需要大多数follower节点确认,所以网络消耗较高,高并发时有瓶颈。

2.6. zookeeper的读取

zookeeper和etcd不同,zookeeper 的读取操作默认是从 follower 节点进行的。

  • Leader 节点:负责处理所有写操作,并将写操作通过 Zab 协议(ZooKeeper Atomic Broadcast)广播给所有 follower 节点。
  • Follower 节点:负责处理读取操作和转发写操作请求给 leader 节点。

读取操作的默认行为是从 follower 节点进行,但这也意味着读取操作可能会有一定的延迟,因为 follower 节点需要从 leader 节点同步最新的数据。为了确保读取的一致性,ZooKeeper 提供了 sync 方法,可以强制客户端从 leader 节点同步最新的更新。

sync 方法

sync 方法可以确保在读取操作之前,所有之前的写操作都已经被应用到 follower 节点。使用 sync 可以强制客户端在读取数据前,同步最新的状态,从而实现强一致性的读取。

3. 监听

etcd 的 watch 功能用于监听键或键的前缀,以便在这些键发生变化时通知客户端。watch 是 etcd 中非常强大和常用的功能,可以用来实现服务发现、配置管理和分布式锁等功能。

实际应用场景
  • 配置管理:可以通过 watch 机制监听配置变化,当配置发生变化时,及时通知相应的服务进行更新。
  • 服务发现:通过 watch 机制监听服务注册信息的变化,当有新服务注册或服务下线时,及时通知客户端进行相应处理。
  • 分布式锁:通过 watch 机制监听锁的状态变化,实现分布式锁的抢占和释放。

3.1. 内部实现

1、修订版本(Revision)

在 etcd 中,每次写操作都会生成一个新的修订版本(Revision)。这个修订版本是一个递增的数字,用于标识每次事务提交的顺序。watch 机制利用这个修订版本来跟踪键值的变化。

2、事件存储和转发

当客户端创建 watch 时,etcd 会将 watch 请求转发到当前的 leader 节点。leader 节点会将 watch 请求添加到其内部的监听列表中。

当有新的写操作提交时,leader 节点会检查是否有 watch 监听这个键或键前缀。如果有,leader 节点会将相应的事件通知给客户端。

3、异步通知

etcd 的 watch 机制是异步的。当有新的事件生成时,etcd 会异步地将事件推送给客户端。这种异步通知机制可以提高系统的响应速度和并发处理能力。

3.2. 断点续传

etcd 提供了断点续传功能,确保在客户端连接中断后重新连接时,不会丢失变更事件。具体实现通过以下几个步骤:

  • 指定修订版本:客户端在发起 watch 请求时,可以指定从哪个修订版本开始监听。
  • 事件缓存:leader 节点会缓存一段时间内的事件,以便在客户端重新连接时提供这些事件。
  • 重新连接:客户端在重新连接时,可以通过指定上次接收到的最后一个事件的修订版本,继续接收从该修订版本后的所有事件。
缓存事件时间

etcd 的事件缓存机制主要是为了确保在客户端重新连接时,不会丢失在断连期间的事件。缓存的时间和事件数量由 etcd 的内部实现和配置决定

  • 事件保留时间:默认情况下,etcd 会保留最近一段时间内的事件。这段时间通常是配置的 etcd 副本保留时间(compaction interval)。
  • 事件数量限制:缓存的事件数量也受到内存和其他资源的限制,以防止内存占用过多。

etcd 提供了一些配置项来调节事件的保留时间和缓存策略。

retention configuration(保留配置)

--auto-compaction-retention:这个参数用于指定自动压缩的保留时间间隔。压缩会删除超过这个时间间隔的历史修订版本和事件。
如果设置为 1 小时(--auto-compaction-retention=1h),那么 etcd 会保留过去 1 小时内的所有事件。

Compaction(压缩)

etcd 提供了手动和自动的压缩机制,通过删除旧的修订版本和事件来节省空间。
自动压缩可以通过配置 --auto-compaction-mode 和 --auto-compaction-retention 参数来实现。

3.3. 代码示例

import io.etcd.jetcd.ByteSequence;
import io.etcd.jetcd.Client;
import io.etcd.jetcd.Watch;
import io.etcd.jetcd.Watch.Watcher;
import io.etcd.jetcd.watch.WatchEvent;
import io.etcd.jetcd.watch.WatchResponse;
import io.etcd.jetcd.options.WatchOption;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.atomic.AtomicLong;

@Service
public class EtcdWatchService {

    private static final Logger LOGGER = LoggerFactory.getLogger(EtcdWatchService.class);

    @Value("${etcd.endpoints}")
    private String etcdEndpoints;

    private Client client;
    private Watcher watcher;
    private AtomicLong lastRevision = new AtomicLong(0);
    private volatile boolean shouldReconnect = true;
    private static final int MAX_RETRIES = 5;  // 最大重试次数
    private int retryCount = 0;
    private long baseDelay = 1000;  // 基础延迟时间(毫秒)

    @PostConstruct
    public void init() {
        client = Client.builder().endpoints(etcdEndpoints.split(",")).build();
        startWatching();
    }

    @PreDestroy
    public void cleanup() {
        shouldReconnect = false;
        if (watcher != null) {
            watcher.close();
        }
        if (client != null) {
            client.close();
        }
    }

    private void startWatching() {
        ByteSequence key = ByteSequence.from("my-key", StandardCharsets.UTF_8);
        WatchOption watchOption = WatchOption.newBuilder().withRevision(lastRevision.get()).build();

        watcher = client.getWatchClient().watch(key, watchOption, new Watch.Listener() {
            @Override
            public void onNext(WatchResponse response) {
                response.getEvents().forEach(event -> {
                    switch (event.getEventType()) {
                        case PUT:
                            LOGGER.info("PUT event: {} -> {}", event.getKeyValue().getKey(),
                                    event.getKeyValue().getValue().toString(StandardCharsets.UTF_8));
                            break;
                        case DELETE:
                            LOGGER.info("DELETE event: {}", event.getKeyValue().getKey());
                            break;
                    }
                    // 更新最后的修订版本号
                    lastRevision.set(event.getKeyValue().getModRevision());
                });
            }

            @Override
            public void onError(Throwable throwable) {
                LOGGER.error("Watch error: {}", throwable.getMessage());
                throwable.printStackTrace();
                if (shouldReconnect && retryCount < MAX_RETRIES) {
                    reconnect();
                } else {
                    LOGGER.error("Max retries reached, will not attempt to reconnect.");
                }
            }

            @Override
            public void onCompleted() {
                LOGGER.info("Watch completed");
            }
        });
    }

    private void reconnect() {
        try {
            long delay = (long) (baseDelay * Math.pow(2, retryCount));  // 指数退避
            LOGGER.info("Reconnecting watch in {} ms...", delay);
            cleanupWatcher();
            Thread.sleep(delay);
            startWatching();
            retryCount++;
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            LOGGER.error("Reconnect interrupted", e);
        }
    }

    private void cleanupWatcher() {
        if (watcher != null) {
            watcher.close();
        }
    }
}
lastRevision

在每次监听到写入消息后,都需要记录最新的revision,这样当下次重连时传入lastRevision,可以继续基于上次的版本继续消费缓存的事件。

Watch.Listener 接口三个方法
  • onNext(WatchResponse response): 当被监听的键发生变化时,etcd 会调用此方法。
  • onError(Throwable throwable): 当发生错误时,etcd 会调用此方法。
  • onCompleted(): 当 watch 完成时,etcd 会调用此方法
onError的场景

etcd 的 Watch.Listener 中的 onError 方法会在各种异常和错误情况下被触发,这些情况包括但不限于网络问题、连接断开、权限问题等。包括:

  • 网络问题

    • 网络中断:如果客户端与 etcd 服务器之间的网络连接中断或不稳定,会触发 onError 方法。
    • 超时:如果网络请求超时,导致无法接收到 etcd 服务器的响应,也会触发 onError。
  • 服务端问题

    • etcd 服务器重启:如果 etcd 服务器重启,现有的 watch 连接会中断,触发 onError 方法。
    • etcd Leader 变更:在 etcd 集群中,如果 leader 节点发生变更,现有的 watch 连接可能会中断,触发 onError 方法。
  • 客户端问题

    • 客户端主动关闭:如果客户端主动关闭连接,也会触发 onError 方法。
    • 客户端资源耗尽:如果客户端资源(如线程、文件描述符等)耗尽,可能导致 watch 连接失败,触发 onError 方法。
  • 权限问题

    • 权限不足:如果客户端对所 watch 的键或键前缀没有足够的权限,也会导致 watch 失败,触发 onError 方法。

3.4. 对比zookeeper

zookeeper 的 watch 机制是一种一次性触发的通知机制,允许客户端在指定的节点(znode)上设置 watch,当 znode 的数据或子节点状态发生变化时,客户端会收到通知。

1、基本特性
  • 一次性触发:ZooKeeper 的 watch 是一次性的,即当 watch 被触发后,它就会失效。如果客户端需要继续监视该 znode 的变化,需要重新设置 watch。
  • 没有事件重放机制:ZooKeeper 不像 Etcd,没有事件重放机制,这意味着如果因为连接中断或程序错误等,错过了某个消息事件,重新监听后也不会再收到原事件。
  • 注册的位置:watch 可以注册在数据读取操作(如 getData 或 getChildren)上,当这些操作返回结果时,watch 会被注册到指定的 znode 上。
2、没有断点续传

ZooKeeper 的 watch 机制本身并不直接支持断点续传(即断连后重新连接时能够继续监听断连期间的事件)。

这是因为 ZooKeeper 的 watch 是一次性触发的,当 watch 被触发后,它就会失效。如果客户端需要继续监视该 znode 的变化,需要重新设置 watch。而且 ZooKeeper 的 watch是客户端实现的,并不像etcd,会在服务端将事件缓存一段时间。

ZooKeeper 的设计哲学是一次性触发 watch,它更像是一种通知机制,而不是持续的订阅。

代码示例

zookeeper watch代码:

import org.apache.zookeeper.*;
import org.apache.zookeeper.data.Stat;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;

@Service
public class ZooKeeperWatchService implements Watcher {

    private static final Logger LOGGER = LoggerFactory.getLogger(ZooKeeperWatchService.class);

    @Autowired
    private ZooKeeper zooKeeper;

    private final String watchedNodePath = "/my-node";

    @PostConstruct
    public void init() {
        try {
            watchNode(watchedNodePath);
        } catch (KeeperException | InterruptedException e) {
            LOGGER.error("Error initializing ZooKeeper watch", e);
        }
    }

    @PreDestroy
    public void cleanup() throws InterruptedException {
        if (zooKeeper != null) {
            zooKeeper.close();
        }
    }

    private void watchNode(String path) throws KeeperException, InterruptedException {
        Stat stat = zooKeeper.exists(path, this);
        if (stat != null) {
            byte[] data = zooKeeper.getData(path, this, stat);
            LOGGER.info("Current data at {}: {}", path, new String(data));
        } else {
            LOGGER.warn("Node {} does not exist", path);
        }
    }

    @Override
    public void process(WatchedEvent event) {
        LOGGER.info("Received event: {}", event);
        try {
            if (event.getType() == Event.EventType.NodeDataChanged) {
                // Data has changed, watch the node again to keep watching for future changes
                watchNode(event.getPath());
            } else if (event.getType() == Event.EventType.NodeCreated || event.getType() == Event.EventType.NodeDeleted) {
                // For NodeCreated and NodeDeleted events, re-watch the parent node
                watchNode(watchedNodePath);
            } else if (event.getState() == Event.KeeperState.SyncConnected) {
                // Re-connect to ZooKeeper and re-watch the node
                watchNode(watchedNodePath);
            }
        } catch (KeeperException | InterruptedException e) {
            LOGGER.error("Error watching node", e);
        }
    }
}
  • process 方法是 Watcher 接口的实现方法,用于处理来自 ZooKeeper 的 WatchedEvent
  • 在每次监听到消息后,都需要重新监听节点。调用 zooKeeper.getData(path, this, stat); 获取节点数据,同时设置数据变化的 watch。this 再次表示当前实现 Watcher 接口的实例。
3. Etcd 更优势的方面
  • 高并发时更好:Etcd提供了一个事件流接口,允许客户端连续接收变化通知。这种设计在处理大量连续变化时非常高效,因为它避免了 ZooKeeper 频繁的连接和断开连接操作,减少了网络和服务器端的负担。
  • 消息事件不容易丢失:Etcd支持事件重放,在客户端重新连接后,还可以断点续传。另外 ZooKeeper 的监听是一次性触发,然后再重新注册,如果在这时间窗口之间有变更事件,同样也会丢失。
4. 二者擅长场景

(1)ZooKeeper 的 Watch 机制适用场景

  • 事件触发简单且可预测的场景:ZooKeeper的watch是一次性触发的,这适合于事件变化不频繁,且每次变化后客户端可以简单地重新注册watch的场景。例如,在分布式锁或领导者选举中,某个关键节点的变化通常是较少且重要的,watch的简化触发机制已足够。

(2)Etcd 的 Watch 机制适用场景

  • 高频次事件通知的场景:持续更新和自动重新设置,etcd的watch支持持久连接和事件流,这适合于频繁变化的环境,如动态服务发现和配置管理,客户端无需手动重新注册watch。
  • 需要事件历史和重放能力的场景:事件可靠性:,etcd允许客户端在重新连接后获取自上次断开以来的所有事件,这对于需要确保事件不丢失的系统至关重要,例如在分布式缓存更新中。

4. 租约

租约的核心概念是为键值对设定一个生存时间(TTL),当租约到期时,绑定到该租约的所有键值对都会自动删除。

Etcd 租约的基本操作
  • 创建租约:为键值对分配一个租约,并设置 TTL。
  • 绑定键值对到租约:将键值对绑定到租约上,以便在租约到期时自动删除这些键值对。一个租约可以绑定多个键,当租约到期后都会被删除。
  • 续期租约:在租约到期之前,通过续期操作延长租约的生存时间。注意etcd并不能续期指定时间的租约,而是将租约重置回原来的时间。
  • 释放租约:显式释放租约。
代码示例
import com.google.protobuf.ByteString;
import com.ibm.etcd.api.KeyValue;
import com.ibm.etcd.api.RangeResponse;
import com.ibm.etcd.client.EtcdClient;
import com.ibm.etcd.client.lease.LeaseClient;
import com.ibm.etcd.client.kv.KvClient;
import io.etcd.jetcd.ByteSequence;
import io.etcd.jetcd.Client;
import io.etcd.jetcd.Lease;
import io.etcd.jetcd.common.exception.EtcdException;
import io.etcd.jetcd.kv.GetResponse;
import io.etcd.jetcd.options.GetOption;
import io.etcd.jetcd.options.PutOption;

import java.io.IOException;
import java.util.concurrent.ExecutionException;

public class EtcdLeaseExample {

    public static void main(String[] args) throws IOException, InterruptedException, ExecutionException {
        // 创建 Etcd 客户端
        Client client = Client.builder()
                .endpoints("http://localhost:2379")
                .build();

        // 获取 KV 和 Lease 客户端
        KvClient kvClient = client.getKvClient();
        LeaseClient leaseClient = client.getLeaseClient();

        // 创建租约,TTL 为 10 秒
        long leaseId = leaseClient.grant(10).get().getID();

        // 将键值对绑定到租约
        kvClient.put(ByteSequence.from("key1", "UTF-8"), ByteString.copyFromUtf8("value1"))
                .withLease(leaseId)
                .get();

        kvClient.put(ByteSequence.from("key2", "UTF-8"), ByteString.copyFromUtf8("value2"))
                .withLease(leaseId)
                .get();

        // 续期租约
        leaseClient.keepAlive(leaseId)
                .thenAccept(response -> System.out.println("Lease kept alive"));

        // 等待租约过期
        Thread.sleep(15000);

        // 检查键值对是否已删除
        RangeResponse response1 = kvClient.get(ByteSequence.from("key1", "UTF-8")).get();
        RangeResponse response2 = kvClient.get(ByteSequence.from("key2", "UTF-8")).get();

        if (response1.getKvs().isEmpty()) {
            System.out.println("Key1 is deleted");
        } else {
            System.out.println("Key1 still exists: " + response1.getKvs().get(0).getValue().toStringUtf8());
        }

        if (response2.getKvs().isEmpty()) {
            System.out.println("Key2 is deleted");
        } else {
            System.out.println("Key2 still exists: " + response2.getKvs().get(0).getValue().toStringUtf8());
        }

        // 释放租约
        leaseClient.revoke(leaseId).get();

        // 关闭 Etcd 客户端
        client.close();
    }
}

KerryWu
633 声望157 粉丝

保持饥饿