BugFixed:etcd 单节点宕机后数据“消失”

BugFixed:etcd 节点宕机后数据"消失",真是集群的锅吗?

一、问题现象

一个基于 etcd 的服务的Python驻留程序,etcd集群采用标准三节点集群部署(A,B,C)。某日运维同事反馈:当节点 A 宕机后,连接节点 B 或 C 查询此前写入的 Key,竟返回空结果(None);但只要节点 A 恢复正常,应用层状态恢复正常。第一反应是 etcd 集群出了问题------明明三节点,一台故障应该无影响才对,难道数据没有成功复制?

二、初步怀疑:伪集群陷阱

类似的问题社区中并不少见,很多"集群"其实并未真正组建成功。典型的表现是:向节点 A 写入数据,连接 B、C 也能读到,但 A 宕机后 B、C 全部空白。原因往往是 B 和 C 的 etcd 进程虽然运行着,但未加入同一个 Raft Group,实际上是一个单节点集群(A)和两个孤立的单节点。客户端之所以能从 B、C 读到数据,是因为 v3 API 的线性读会自动把请求重定向到 Leader(即 A)。一旦 A 挂掉,B、C 的本地存储里没有任何数据,自然返回空。

于是我们立刻执行了诊断命令:

bash 复制代码
# 在节点 B 上查看集群成员
etcdctl --endpoints=B_IP:2379 member list

# 检查各节点状态
etcdctl --endpoints=B_IP:2379,C_IP:2379 endpoint status --write-out=table

结果却出乎意料:member list 在所有节点上输出完全一致,三个节点 ID 相同,状态均为 startedendpoint status 显示 Leader 是 C,RAFT TERMRAFT INDEX 也基本同步。这证明集群组建完全正确,不存在独立实例的问题,但就是无法查到数据。

手动恢复A,发现集群仍然正常,可以查到数据。

三、再次深入:Key 究竟去哪了?

既然集群健康且数据应该已被多数派确认,为什么节点 A 宕机后 Key 就消失了?我们直接在节点 B 上用 etcdctl 进行最原始的查询,以排除业务代码干扰:

bash 复制代码
# 列出所有 Key
etcdctl --endpoints=B_IP:2379,C_IP:2379 get "" --from-key --keys-only

结果令人惊讶:整个 etcd 数据库中,确实没有业务指定的那个 Key (/election/leader),但存在其他测试时写入的 Key。这排除了整个数据库为空的可能,说明只有特定 Key 丢失了。

既然只有特定 Key 丢失,联想到 etcd 的 Key 可以和租约(Lease)绑定,到期自动删除。检查租约列表:

bash 复制代码
etcdctl --endpoints=B_IP:2379 lease list

发现活跃租约中并没有业务所使用的 Lease ID。进一步检查应用日志,发现了关键线索:KeepAlive 续租请求在节点 A 宕机后开始大量超时,并最终中断

四、根因定位

至此真相大白:

  1. 业务创建 Key 时绑定了租约(TTL),由客户端定期发送 KeepAlive 请求续租,防止 Key 过期删除。
  2. 该客户端的 etcd 连接池仅配置了节点 A 的 IP,并没有使用多 endpoint 或自动切换能力。
  3. 当节点 A 宕机后,客户端的续租请求全部失败,因为连接池无法切换到存活的 B 或 C 节点。
  4. 租约到期后,etcd 集群判定该 Key 已失效,自动将其删除。这一删除操作经由 Raft 提交,所有节点都会移除该数据。
  5. 节点 A 恢复后,业务进程重新连接上来,会重新创建 Key 和租约,因此数据又出现了------这给了人"数据依赖节点 A"的错觉。

换言之,etcd 集群自身的容错能力完全正常,问题出在应用层没有适配故障转移。数据消失并非集群丢失,而是合法的过期删除;恢复后重现则是业务重新注册的结果。

五、代码层面改进:为 Python etcd3 客户端注入集群容错能力

我们使用的 etcd3 库(pip install etcd3)原生的 Client 类只接受单个 hostport,无法直接传入多个 endpoint 实现故障切换。为了在不更换库的前提下获得高可用能力,我们需要对它进行一层封装:维护一个 endpoint 列表,在连接失败或操作超时后自动切换到下一个可用节点,并对业务完全透明

下面的代码展示了一个简单的多节点 etcd 客户端封装,重点解决了连接切换和续租恢复两个核心问题。

python 复制代码
import etcd3
import time
import random
import threading
from typing import List, Tuple, Optional

class MultiEndpointEtcdClient:
    """
    自动故障转移的 etcd3 客户端封装
    用法:
        endpoints = [("node1", 2379), ("node2", 2379), ("node3", 2379)]
        client = MultiEndpointEtcdClient(endpoints)
        client.put("/service/xxx", "value", lease=lease)  # lease 对象由内部管理
    """

    def __init__(self, endpoints: List[Tuple[str, int]], timeout: int = 10):
        self._endpoints = endpoints
        self._timeout = timeout
        self._client: Optional[etcd3.Client] = None
        self._lock = threading.Lock()
        self._current_index = 0
        self._connect()

    def _connect(self):
        """尝试连接到可用的 etcd 节点,直到成功或全部失败"""
        with self._lock:
            start_index = self._current_index
            while True:
                host, port = self._endpoints[self._current_index]
                try:
                    client = etcd3.client(host=host, port=port, timeout=self._timeout)
                    # 进行一次简单操作以验证连接有效性
                    client.status()
                    self._client = client
                    print(f"成功连接到 etcd: {host}:{port}")
                    return
                except Exception as e:
                    print(f"连接 {host}:{port} 失败: {e}")
                    self._current_index = (self._current_index + 1) % len(self._endpoints)
                    if self._current_index == start_index:
                        raise ConnectionError("所有 etcd 节点均不可用")

    def _get_client(self) -> etcd3.Client:
        """获取当前可用的客户端,并在需要时自动重连"""
        if self._client is None:
            self._connect()
            return self._client

        # 健康检查:如果当前连接可能失效,则触发重连
        try:
            self._client.status()
        except Exception:
            print("当前 etcd 连接失效,尝试切换到其他节点...")
            self._connect()
        return self._client

    def put(self, key: str, value: str, lease: Optional[etcd3.Lease] = None, **kwargs):
        """写操作,带自动重试"""
        for _ in range(3):  # 最多重试3次
            try:
                client = self._get_client()
                return client.put(key, value, lease=lease, **kwargs)
            except Exception as e:
                print(f"put 操作失败: {e},准备重试...")
                time.sleep(1)
                self._client = None  # 强制下次重新连接
        raise RuntimeError(f"无法写入 {key},所有尝试均失败")

    def get(self, key: str, **kwargs):
        """读操作,带自动重试"""
        for _ in range(3):
            try:
                client = self._get_client()
                result = client.get(key, **kwargs)
                return result
            except Exception as e:
                print(f"get 操作失败: {e},准备重试...")
                time.sleep(1)
                self._client = None
        raise RuntimeError(f"无法读取 {key},所有尝试均失败")

    def lease(self, ttl: int) -> etcd3.Lease:
        """创建租约,直接透传底层Lease对象"""
        client = self._get_client()
        return client.lease(ttl)

    def refresh_lease(self, lease: etcd3.Lease):
        """续租封装,失败时尝试切换到其他节点续租"""
        # 续租逻辑较特殊,因为lease对象绑定了原有连接。
        # 当连接断开时,原lease无法再续,需要业务层重新创建。
        # 此处示例提供一个简单的续租方法,如果失败则抛出异常,由上层处理重建。
        try:
            lease.refresh()
        except Exception as e:
            raise ConnectionError(f"续租失败,可能需要重建租约: {e}")

    def close(self):
        if self._client:
            self._client.close()

使用示例:服务注册与续租

python 复制代码
import time

endpoints = [("etcd-1", 2379), ("etcd-2", 2379), ("etcd-3", 2379)]
client = MultiEndpointEtcdClient(endpoints)

# 注册服务,TTL 30秒
lease = client.lease(30)
client.put("/services/my-service/instance1", "10.0.0.1:8080", lease=lease)

# 后台续租循环
def keep_alive():
    while True:
        try:
            client.refresh_lease(lease)
        except Exception:
            # 续租失败,重新创建租约并重新put
            print("租约丢失,重新注册...")
            lease = client.lease(30)
            client.put("/services/my-service/instance1", "10.0.0.1:8080", lease=lease)
        time.sleep(10)

threading.Thread(target=keep_alive, daemon=True).start()

关键点

  • 多 endpoint 轮询_connect 方法会循环尝试所有节点直到成功,并记录当前可用节点的索引,避免每次都从头尝试。
  • 透明故障转移putget 等操作在失败时会强制置空 _client,下次调用 _get_client() 就会触发重连到下一个健康节点。
  • 租约异常处理 :续租失败时,业务层应捕获异常并重新创建租约和 Key,因为原租约对象已随连接失效。这对于服务发现场景是合理的,相当于重新注册。
  • 线程安全:使用锁保护连接切换,避免并发场景下的竞态条件。

通过这样一层轻量封装,即使底层 etcd3 库只支持单节点连接,我们的应用也能获得集群级别的自动故障转移能力,从而避免因单点故障导致的租约过期和数据丢失。

六、延伸思考:为什么 3 节点挂掉 2 台就无法服务?

1.虽然本次问题是应用层导致的,但排查过程中也曾担忧过集群可用性。这里简要阐明 etcd 基于 Raft 的多数派原则。

Raft 要求任何写操作(以及线性一致性读)都必须获得半数以上节点 的确认。3 节点的多数派是 2(即 ⌊3/2⌋ + 1 = 2)。当 2 台节点故障,仅剩 1 台时:

  • 无法选举出 Leader(或现有 Leader 因失去多数派联系而退位);
  • 任何需要提交的请求都得不到足够确认,直接超时;
  • 即使单节点上存有数据,线性读也会被拒绝,以杜绝脑裂场景下读到陈旧数据。

因此,3 节点集群最多容忍 1 台故障,剩下 1 台不能自动恢复服务。如果需要容忍 2 台故障,就必须部署 5 节点集群。这是 etcd 保证强一致性的代价,绝非缺陷。

2.避免偶数节点

生产环境中,偶数节点数(如 4)的容错能力并没有比 3 高,反而在分区情况下更容易整体失效。强烈建议改为 3 或 5 个节点。奇数节点是 etcd 集群的最佳实践,能最大程度避免这种"理论存活,实际不活"的脑裂问题。偶数节点在遭遇对称分区时,无法像奇数节点那样形成天然多数派,所以失效概率更高。 选择的核心是:在满足相同容错能力(容忍 F 个故障)的最小节点数 N = 2F+1 中,奇数节点是性价比和稳定性最好的

Raft半数机制要求:存活结点数≥ floor(N/2) + 1 ,总节点数 N 。

2节点 vs 1节点:容错能力相同,但 2 节点风险更大

容错能力

  • 1 节点:1个节点的集群半数为1,挂掉即不可用,容忍 0 个故障。
  • 2 节点:2个节点集群的半数是2=floor(2/2)+1 = 2`,必须两个节点都存活才能工作,挂掉任意 1 个就不可用。
    结论:容错能力都是 0,2 节点没有提高可用性。

风险

  • 1 节点:单点故障,但无分布式共识开销,不担心脑裂。
  • 2 节点:只要网络一抖,两个节点互相联系不上,就立刻无法形成多数派,集群不可用;而单节点根本不存在这个问题。
    此外,2 节点还引入了一致性写入的额外延迟(需要双方确认),所以,如果不需要高可用,用 1 节点比 2 节点好得多;2 节点是一个看似"多了备份"实则更脆弱的反模式。

3节点 vs 4节点:容错能力相同,但 4 节点分区风险更高

容错能力

  • 3 节点:3个集群的节点半数是2,容忍 1 个故障。
  • 4 节点: 4个集群的半数为3,同样容忍 1 个故障。
    多投入一台机器的成本,却没有换来更高的容错(想容忍 2 个故障需 5 节点)。

网络分区风险

  • 任何规模的分区都可能发生,但偶数节点在分离成两个相等或相近规模的分区时,两边都凑不够法定人数,导致整个集群停摆
  • 3 节点若发生分区,至少还能出现一个 2 节点多数派(比如 2-1 分裂),多数派一侧继续工作;
  • 4 节点若分裂为 2-2,两边各 2 票,都小于法定人数 3,集群直接不可用。
  • 因此,在容忍同样故障数的前提下,奇数节点天然比偶数节点更抗脑裂

性能

节点越多,Leader 需要等待多数派确认的延迟通常也越大,写入性能会稍有下降。4 节点写请求必须等 3 个节点确认,比 3 节点等 2 个确认要慢。

七、总结

这次故障表面看像是 etcd "丢数据",实则是客户端与分布式系统协作不当的典型案例。排查过程经历了"伪集群怀疑 → 租约机制探究 → 应用连接配置审视"三个层次,最终定位在续租中断。代码层的改进则通过一个简单的多 endpoint 封装,弥补了 etcd3 库的短板,让应用真正用上了集群的冗余能力。

这也提醒我们:使用一致性存储时,不仅要关注集群本身的健康,更要确保客户端能够充分利用集群的冗余能力,否则再可靠的分布式系统也无法为单点故障的客户端保驾护航。


愿你我都能在各自的领域里不断成长,勇敢追求梦想,同时也保持对世界的好奇与善意!

相关推荐
小旭95271 小时前
Spring Cloud 集成分布式日志 ELK+Swagger 接口文档实战
java·分布式·后端·elk·spring cloud
SilentSamsara2 小时前
消息队列集成:Python + Kafka/RabbitMQ 生产实践
服务器·开发语言·分布式·python·kafka·rabbitmq
2601_957882242 小时前
分布式媒体中台的非阻塞I/O架构:高并发事件网关、熔断机制与跨域ETL管道流控实践
分布式·架构·媒体
2601_957879332 小时前
分布式媒体中台的多渠道协同架构:数据一致性、高并发调度与跨域路由容错实践
分布式·架构·媒体
张青贤2 小时前
centos7通过kubekey部署k8s集群
kubernetes·etcd·kubekey
2601_957882242 小时前
多云协同架构下的分布式媒体分发:微服务状态机设计、分布式追踪与跨域路由容错实践
分布式·架构·媒体
田里的水稻3 小时前
OE_gitlab服务操作和维护方法
分布式·gitlab
Chasing__Dreams3 小时前
Kafka--基础知识点--20--消费者平衡协议的增量式重平衡协议
分布式·kafka
IronMurphy3 小时前
Kafka拷打!!!
分布式·kafka