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 相同,状态均为 started;endpoint status 显示 Leader 是 C,RAFT TERM 和 RAFT 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 宕机后开始大量超时,并最终中断。
四、根因定位
至此真相大白:
- 业务创建 Key 时绑定了租约(TTL),由客户端定期发送 KeepAlive 请求续租,防止 Key 过期删除。
- 该客户端的 etcd 连接池仅配置了节点 A 的 IP,并没有使用多 endpoint 或自动切换能力。
- 当节点 A 宕机后,客户端的续租请求全部失败,因为连接池无法切换到存活的 B 或 C 节点。
- 租约到期后,etcd 集群判定该 Key 已失效,自动将其删除。这一删除操作经由 Raft 提交,所有节点都会移除该数据。
- 节点 A 恢复后,业务进程重新连接上来,会重新创建 Key 和租约,因此数据又出现了------这给了人"数据依赖节点 A"的错觉。
换言之,etcd 集群自身的容错能力完全正常,问题出在应用层没有适配故障转移。数据消失并非集群丢失,而是合法的过期删除;恢复后重现则是业务重新注册的结果。
五、代码层面改进:为 Python etcd3 客户端注入集群容错能力
我们使用的 etcd3 库(pip install etcd3)原生的 Client 类只接受单个 host 和 port,无法直接传入多个 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方法会循环尝试所有节点直到成功,并记录当前可用节点的索引,避免每次都从头尝试。 - 透明故障转移 :
put、get等操作在失败时会强制置空_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 库的短板,让应用真正用上了集群的冗余能力。
这也提醒我们:使用一致性存储时,不仅要关注集群本身的健康,更要确保客户端能够充分利用集群的冗余能力,否则再可靠的分布式系统也无法为单点故障的客户端保驾护航。
愿你我都能在各自的领域里不断成长,勇敢追求梦想,同时也保持对世界的好奇与善意!