Meerkat:Cloudflare 如何用无领导者写入实现全球强一致协调

2026-08-02 42 预计阅读时间: 1 分钟
来源: infoq.com AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:11 分钟

全球控制面服务经常面对一个难题:系统既要在跨地域网络中保持强一致,又要尽量避免某个中心节点故障导致写入停摆。Cloudflare 最近介绍了内部服务 Meerkat,它基于 QuePaxa 共识算法,在保持强一致性的同时支持无领导者写入,为全球协调服务提供了不同于 Raft 的设计路径。

Raft 的领导者模型为何会限制全球写入

Raft 的工程优势很明确:模型容易理解,日志复制、任期和领导者选举都有成熟实现。客户端通常把写请求发送给当前领导者,由领导者把日志复制到多数节点后提交。

但在全球部署中,领导者模型会带来几个现实问题:

  • 写请求可能需要跨越较高延迟的网络才能到达领导者。
  • 领导者所在区域出现故障或网络隔离时,写入需要等待重新选举。
  • 即使其他区域仍然健康,客户端也可能因为无法访问领导者而暂时无法提交变更。
  • 多个地域的控制面操作最终都要经过同一个写入入口,扩展方式受到限制。

这并不意味着 Raft 不适合全球系统。对于单地域或延迟要求可控的服务,Raft 仍然是很实用的选择。问题在于,全球控制面通常更重视跨地域可用性,以及在故障期间继续处理协调请求的能力。

Meerkat 的核心:保持一致性,但不把写入绑定到单一领导者

根据 Cloudflare 对 Meerkat 的介绍,它使用 QuePaxa 共识算法构建全球一致的控制面服务。与 Raft 的典型领导者写入模型不同,QuePaxa 支持 leaderless writes,也就是写入不必先经过一个固定的领导者。

这里的“无领导者”并不等于“写入后最终会一致”。如果系统需要强一致,写请求仍然必须经过足够严格的排序、确认和提交流程。变化在于:

  1. 写入请求可以由不同地域的节点接收。
  2. 共识协议负责确定操作的全局顺序和提交条件。
  3. 客户端可以减少对单一领导者可达性的依赖。
  4. 某个地域发生故障时,其他健康节点仍有机会继续承担协调工作。

因此,Meerkat 的重点不是删除共识,而是改变共识协议对写入入口的约束。这种设计适合配置发布、路由协调、策略变更、元数据更新等控制面场景:这些操作数量通常不如业务数据写入大,但必须可靠、可审计,并且在全球范围内看到一致结果。

一个可改造的客户端模型

Meerkat 是 Cloudflare 的内部服务,公开资料并不提供可直接调用的公共 API。下面的示例因此是一个假设的 HTTP 接口,用于展示应用侧如何表达强一致写入、幂等键和版本条件。实际接入时,应以目标服务提供的 API、认证方式和错误码为准。

假设服务提供以下接口:

  • PUT /v1/configs/{name}:提交配置变更。
  • If-Match:要求服务端只在客户端已知版本仍然有效时提交。
  • Idempotency-Key:避免客户端重试造成重复操作。
  • 返回的 version:表示已经提交的全局版本。

可以先用 curl 验证调用流程:

export COORDINATION_ENDPOINT="https://coordination.example.internal"
export TOKEN="replace-with-service-token"
export REQUEST_ID="deploy-2025-03-08-001"

curl --fail-with-body --request PUT \
  --url "$COORDINATION_ENDPOINT/v1/configs/routing-policy" \
  --header "Authorization: Bearer $TOKEN" \
  --header "Content-Type: application/json" \
  --header "If-Match: \"version-41\"" \
  --header "Idempotency-Key: $REQUEST_ID" \
  --data '{
    "rules": [
      {"prefix": "api.example.com", "target": "region-us"},
      {"prefix": "static.example.com", "target": "region-eu"}
    ]
  }'

应用代码应把冲突和临时不可用区分开来。版本冲突通常意味着需要重新读取最新配置后重新计算变更,而网络错误则可能适合使用带上限的重试。下面是一个可以直接运行和改造的 Python 客户端骨架:

from __future__ import annotations

import time
import uuid
from typing import Any

import requests


class VersionConflict(Exception):
    pass


class CoordinationClient:
    def __init__(self, endpoint: str, token: str, timeout: float = 5.0):
        self.endpoint = endpoint.rstrip("/")
        self.session = requests.Session()
        self.session.headers.update({"Authorization": f"Bearer {token}"})
        self.timeout = timeout

    def update_config(
        self,
        name: str,
        document: dict[str, Any],
        expected_version: str,
        retries: int = 3,
    ) -> dict[str, Any]:
        request_id = str(uuid.uuid4())

        for attempt in range(retries):
            try:
                response = self.session.put(
                    f"{self.endpoint}/v1/configs/{name}",
                    json=document,
                    headers={
                        "Content-Type": "application/json",
                        "If-Match": f'"{expected_version}"',
                        "Idempotency-Key": request_id,
                    },
                    timeout=self.timeout,
                )
            except requests.RequestException:
                if attempt == retries - 1:
                    raise
                time.sleep(0.25 * (2**attempt))
                continue

            if response.status_code == 409:
                raise VersionConflict(
                    f"configuration changed before commit: {response.text}"
                )

            response.raise_for_status()
            return response.json()

        raise RuntimeError("update did not complete")


if __name__ == "__main__":
    client = CoordinationClient(
        endpoint="https://coordination.example.internal",
        token="replace-with-service-token",
    )
    result = client.update_config(
        name="routing-policy",
        expected_version="version-41",
        document={"rules": [{"prefix": "api.example.com", "target": "region-us"}]},
    )
    print("committed version:", result["version"])

这个客户端示例有三个值得保留的边界:重试使用固定的幂等键,避免同一次逻辑操作被提交多次;版本冲突不自动覆盖别人的更新;提交成功后记录服务端返回的全局版本,而不是把本地时间戳当作一致性依据。

强一致性和可用性之间仍然存在成本

无领导者写入可以降低对单一领导者的依赖,但它不会消除分布式系统的基本约束。跨地域共识仍然需要通信、排序和失败处理,网络分区期间系统仍必须在可用性和一致性之间做出明确选择。

采用类似设计时,团队需要特别确认以下问题:

  • 提交延迟:一次写入需要等待哪些节点确认?最远地域的网络延迟是否会进入用户请求路径?
  • 冲突语义:两个地域同时更新同一对象时,系统是按全局顺序提交、拒绝冲突,还是提供更高层的合并规则?
  • 重试语义:客户端能否安全重试?幂等键的保存时长和作用范围是什么?
  • 读取保证:读取是线性一致、单调读取,还是只保证最终一致?不同 API 是否有不同级别?
  • 故障恢复:节点恢复后如何追赶状态?控制面是否会因为积压更新而影响数据面?
  • 审计能力:配置是谁、在什么时间、以哪个版本提交的?回滚是否也是一个经过共识提交的操作?

什么时候值得考虑这种架构

如果服务只部署在一个地域,写入延迟低,且领导者故障可以接受短暂的重新选举,那么成熟的 Raft 实现往往更简单,运维和排障成本也更低。

如果系统需要在多个地域接收控制面写入,并且不能依赖某个固定区域持续可达,那么 QuePaxa 这类支持无领导者写入的共识模型值得评估。评估时不要只比较吞吐量,应使用真实的跨地域拓扑测试:模拟单地域故障、链路分区、重复请求、并发更新和节点恢复,再检查提交结果、读取顺序与审计记录。

可以把落地检查表压缩为四项:

  • 明确定义每个操作需要的读取和写入一致性。
  • 为每个变更设计幂等键、版本条件和冲突处理。
  • 用故障注入测试跨地域网络异常,而不是只测试节点进程崩溃。
  • 记录全局版本和完整审计事件,让回滚成为可验证的控制面操作。

Meerkat 展示了一条重要思路:全球强一致系统不一定只能通过一个领导者接收写入。真正的工程问题仍然是如何定义提交、排序、冲突和故障恢复,而共识算法的选择,应围绕这些控制面语义和实际网络条件展开。


相关推荐