12-模块二-架构基础功 第12讲-分布式系统核心 - 一致性 可用性与分区容错的工程权衡

3 阅读19分钟

模块二-架构基础功 | 第12讲:分布式系统核心 - 一致性、可用性与分区容错的工程权衡

开场:分布式里没有“免费午餐”,只有“你为谁买单”

当你把 CodeSentinel 从单机演示推进到多实例部署时,团队迟早会在评审会上听到三个词:一致性可用性分区容错。它们不是论文里的抽象三角形,而是线上事故复盘里的具体账单:某次 Redis 主从切换后,仪表盘显示“审核已完成”,但详情页仍短暂显示“处理中”;某次网络抖动后,同一条 PR 被重复计费两次 LLM 调用;某次索引服务落后时,搜索不到刚写入的 finding。

本讲的目标不是背 CAP 定义,而是学会 在工程约束下做可解释的取舍:哪些数据必须强一致(例如审核结论的权威状态),哪些视图可以最终一致(例如统计大盘、全文检索索引),以及如何用 幂等、分布式锁、退避重试 把“偶发不一致”限制在可接受窗口内。我们还会用 CodeSentinel 的真实切片解释:ReviewService 与 IndexService 各自持有的真相版本不同时,平台该如何对外呈现、如何补偿、如何观测。

读完本讲,你应当能回答架构评审里的硬问题:我们到底选择了哪种一致性模型?分区发生时用户体验会怎样退化?重复投递事件会不会破坏业务?下一讲我们将把这些取舍放大到“日处理十万 PR”的系统设计尺度。

为了把“感觉上的正确”变成“可验证的正确”,建议你随身带三个追问模板:第一,这条读路径的单一真相源是谁?第二,如果异步链路失败 10 分钟,业务是否仍可接受?第三,重复执行同一条命令会不会让账本变厚、让计费翻倍、让状态机越界?在 CodeSentinel 里,审核状态机与 LLM 计费是最敏感的两块:前者需要强一致与可审计,后者需要幂等与清晰的责任边界(谁触发、谁重试、谁承担成本)。当你用 AI 生成更多服务代码时,把这三问写进评审清单,通常比泛泛要求“提高可用性”更能阻止隐性债务。

模块二到这里已经进入“架构基础功”的深水区:你不再只画方块箭头,而是要把 失败模式 写进设计。分区不是极端情况,而是常态背景噪声;延迟不是偶尔尖刺,而是你要为之预留预算的长期分布。你会看到我们把理论落回 Python 与 Redis,这不是为了堆栈时髦,而是因为 可运行的示例 才能暴露顺序、超时与锁语义的真实痛点。下一节的全局视角图,会先把 CodeSentinel 的一致性分层钉在纸面上,再进入细节推导。


全局视角:CAP 三角与 CodeSentinel 的一致性分层

CAP 定理常被误读为“三选二”。更实用的工程表述是:在网络分区(P)客观存在时,系统必须在一致性(C)与可用性(A)之间做权衡——并且不同子系统可以选择不同策略。下图给出教学版 CAP 三角(顶点表示理想目标,边表示现实妥协路径)。

graph TB
  C["一致性 C<br/>(线性一致/可串行化)"]
  A["可用性 A<br/>(非失败节点可响应)"]
  P["分区容错 P<br/>(网络可能丢包/割裂)"]

  C --- A
  A --- P
  P --- C

  CS["CodeSentinel 切片"]
  CS --> C1["权威状态:PostgreSQL 事务内强一致"]
  CS --> A1["只读聚合:缓存/搜索可短暂陈旧"]
  CS --> P1["分区时:降级为只读或排队"]

第二张图用 时序图 描述 CodeSentinel 中“审核结果写入”与“索引更新”的 最终一致 路径:主链路先落权威存储,再异步刷新索引;读路径可能短暂读到旧索引。

sequenceDiagram
  participant API as FastAPI 网关
  participant RS as ReviewService
  participant PG as PostgreSQL(权威)
  participant BUS as 事件总线
  participant IDX as IndexService
  participant VDB as 向量/检索索引

  API->>RS: CompleteReview(review_id, findings)
  RS->>PG: BEGIN; UPDATE review; COMMIT
  PG-->>RS: OK
  RS->>BUS: publish ReviewCompleted
  RS-->>API: 202/200 审核完成

  Note over BUS,VDB: 异步、可能重试、可能乱序
  BUS->>IDX: consume ReviewCompleted
  IDX->>VDB: upsert embeddings / inverted index
  VDB-->>IDX: OK

  participant UI as 仪表盘/搜索
  UI->>VDB: 查询 finding
  Note right of UI: 可能短暂查不到最新 finding<br/>(最终一致窗口)

第三张图给出 Raft 简化心智模型:多数派确认提交、Leader 故障转移。你不必自己实现共识,但要知道托管数据库与协调组件(如 etcd)背后的行为边界。

flowchart LR
  L["Leader"] --> F1["Follower"]
  L --> F2["Follower"]
  L --> F3["Follower"]
  L -->|"AppendEntries + 多数派 ack"| CMT["已提交日志条目"]
  L -.->|"分区/宕机"| E["选举新 Leader"]
  E --> L2["新 Leader"]

核心原理:从 CAP 到可落地的一致性模型

1. CAP 的工程化解读:别用“三选二”逃避讨论

经典 CAP 中,分区容错 P 在广域网分布式系统中几乎总是成立:你无法假设网络永远可靠。于是真正的问题是:当分区发生时,你要 牺牲强一致换取可用,还是 牺牲可用换取不返回陈旧读

CodeSentinel 的切分建议是:

  • 权威业务状态(审核单状态、finding 列表、计费锚点)走 强一致事务(单机 PostgreSQL 或可串行化隔离级别下的写入路径),避免“同一 review_id 同时存在两个终态”。
  • 派生视图(搜索、向量索引、统计报表)允许 最终一致:只要窗口可控、可观测、可补偿。

2. 强一致、最终一致、因果一致:你真正需要哪一种?

  • 强一致(Strong / Linearizable):一次写入完成后,所有后续读都能读到该写入或更新值。适合“钱与状态机”。
  • 最终一致(Eventual):如果没有新的更新,系统最终会收敛到一致状态。适合索引、缓存、物化视图。
  • 因果一致(Causal):保证有因果关系的读写顺序被保留。适合协作场景(例如同一会话内的指令顺序),实现代价比最终一致更高。

在 CodeSentinel 中,“审核已完成”“索引已包含某条 finding” 往往没有严格因果强绑定,但必须保证:用户看到“已完成”时,详情页从权威存储读取总能自洽;搜索短暂不全,可用“索引延迟”指标解释。

3. Raft 简化:你需要知道的只有四件事

  1. 日志复制:Leader 把命令复制到多数派 Follower 后才算提交。
  2. Leader 选举:心跳超时触发选举,需要多数票。
  3. 脑裂防护:少数派分区无法提交新日志,因此不会“写入成功但互相矛盾”(在共识层内)。
  4. 你的职责:正确设置 fsync、复制延迟、故障转移 RTO/RPO,并在应用层处理 重复投递与乱序

4. 幂等性:分布式里“至少一次投递”是常态

消息队列、HTTP 重试、客户端超时重发,都会带来 重复处理。幂等的关键是引入 业务幂等键

  • 使用 (review_id, operation, idempotency_key)天然唯一的 finding_id
  • 在存储层用 唯一约束 或 Redis SET key NX 记录“已处理完成”。

5. 重试与指数退避:别把事故放大成雪崩

重试必须 有界:最大次数、最大总时长、抖动(jitter)。对下游 LLM/第三方 API,还要配合 熔断与舱壁,否则分区恢复瞬间会把系统打挂。

6. 真实案例:ReviewService 与 IndexService “各执一词”

场景:PostgreSQL 已标记 ReviewCompleted,但索引服务因消费延迟尚未 upsert 某条高危 finding。

  • 用户从详情 API 读取:应命中 PostgreSQL 权威数据 → 不出现“完成但无 finding”(若你的详情页错误地只查索引,就会翻车)。
  • 用户从搜索/大盘读取:可能暂时查不到 → UI 展示 “索引延迟 12s”,并引导用户用详情页核对。

治理要点:读写路径分层SLA 分层可观测性(lag、dlq、重试次数)。

7. “一致性”在组织协作里的翻译:别把产品语言当成存储语义

很多事故来自 词汇漂移:产品经理说“用户应该立刻看到结果”,工程师把它误实现为“搜索必须立刻一致”。在 CodeSentinel 里建议建立一张 一致性字典:哪些页面是权威读、哪些是派生读、哪些允许骨架屏与渐进增强。架构评审时把这张表贴在墙上,比争论 CAP 更有用。

8. 单调性与版本向量:当事件乱序到来时怎么办?

即使消息系统承诺“至少一次”,也可能出现 重放乱序(尤其在多分区 topic 或消费者重启后)。处理策略包括:

  • 每条 finding 写入带 单调递增版本更新时间戳 + 来源序列
  • 索引 upsert 采用 Last-Write-Wins 前要确认业务是否允许覆盖;
  • 对不可丢失的审计日志,使用 追加写 而非覆盖写。

9. 分布式锁的边界:它能解决什么、不能解决什么?

锁解决的是 互斥(同一时刻只有一个写入者执行临界区),不解决 一致性 本身。若临界区内的操作不是幂等的,锁只能降低碰撞概率,无法消除重复消息。CodeSentinel 中典型临界区是“重建某 PR 的向量切片”或“合并多次增量 patch”,仍要与幂等键配合。

10. 退避与舱壁:与 LLM 网关联动时的系统稳定性

当分区恢复或下游抖动时,无界重试会造成 重试风暴。实践上要把重试分层:客户端重试、网关重试、队列消费者重试各自有上限;并对 LLM 供应商设置 并发上限令牌桶,把失败请求导入 延迟队列 而不是同步阻塞 API 线程池。

11. 观测与验证:如何证明“最终一致窗口”在可控范围?

建议至少三类指标:索引消费 lag、幂等重复率、端到端“完成到可搜”的直方图。再配两类告警:lag 超阈值、重复率异常升高(可能上游重试配置错误)。没有指标的一致性承诺,本质是 玄学可用性

12. 与 Clean Architecture 的衔接:一致性策略属于哪一层?

领域层定义状态机与不变式;应用层定义用例边界与发布事件的顺序;基础设施层实现锁、队列、重试与外部系统交互。Presentation 层负责把“派生视图可能陈旧”翻译为 UI 文案与降级策略。把重试逻辑写进领域实体是最常见的坏味道之一,评审时要坚决打回。

13. 从“论文一致性”到“用户可理解一致性”:产品文案也是架构的一部分

再完美的后端策略,如果前端把缓存结果包装成“绝对最新”,用户会在心智上建立错误预期,进而把偶发滞后当成系统不可信。CodeSentinel 建议在 UI 层显式区分 “已确认(权威)”“同步中(派生)”,并把预计延迟区间写进帮助文档。架构师常常忽略这一层,但它是减少工单与提升信任最便宜的手段。

14. 事务边界与消息:为什么“先库后发”仍可能丢消息?

常见模式是数据库提交成功后再 publish 事件。若进程在 commit 后、publish 前崩溃,索引侧永远收不到事件。解决思路包括:事务性发件箱(Outbox)捕获数据变更流(CDC)、或对关键路径使用 同步双写(成本更高)。本讲不展开实现细节,但你要在评审中把它列为“已知风险与演进路线”,否则团队会误以为“我们已经用了消息队列,所以不会丢”。

15. 读写分离与复制延迟:PostgreSQL 也可能让你“读到过去”

即便你坚持详情读数据库,若走只读副本,仍会遭遇复制延迟。若业务要求读完写后立刻读,必须 读己之写(read-your-writes):同用户会话路由到主库,或在响应中携带版本号让客户端知道该轮询。CodeSentinel 的折中通常是:写路径与关键读路径命中主库,报表走副本。

16. 与 LangChain/工具链的关系:外部调用如何纳入一致性故事?

LLM 调用本质上是 不确定的副作用:同一输入可能产生不同输出,重试会改变结果。架构上要把 LLM 产物当作 建议草案,把落库结论当作 决策结果,并用版本化提示词与模型指纹做审计。否则你会把“概率一致性”误当成“业务一致性”,在合规场景里尤其危险。

17. 小结:本讲的方法论不是背定义,而是做分层承诺

把系统拆成多个“一致性区域”,每个区域选择模型、设定 SLO、配置观测与降级,再把组合关系画清楚——这才是 CAP 在工程里真正的工作方式。你不需要让搜索与数据库同学用同一套 KPI,但必须让他们知道彼此依赖与失败传递路径。


代码实战:幂等处理器、Redis 分布式锁、退避重试装饰器

以下代码为 教学级完整示例:可在单进程内运行演示(需本地 Redis)。将其映射到 CodeSentinel 时,把 process_review_completed 视为 IndexService 的消费逻辑,把锁用于“同 review 的索引重建”互斥。

在阅读代码时,建议你抓住三条主线:第一retry_with_exponential_backoff 把“失败可恢复”与“失败应快速拒绝”区分开;第二RedisDistributedLock 用 token 防止误删他人锁;第三IdempotentReviewProcessor 在“先去重、再加锁、再双检”的顺序上模仿了常见的消费者模式。把顺序写反(先加锁再去重)在高并发下会增加无谓锁竞争,把去重只做在内存里则会在进程重启后失效——这些细节决定了示例能否平滑迁移到生产。

1. 依赖与配置

# requirements-mini.txt(本讲示例)
# redis>=5.0.0

2. 指数退避重试装饰器(带抖动)

from __future__ import annotations

import functools
import random
import time
from typing import Callable, Tuple, Type


def retry_with_exponential_backoff(
    *,
    max_attempts: int = 5,
    base_delay: float = 0.1,
    max_delay: float = 5.0,
    jitter_ratio: float = 0.2,
    exceptions: Tuple[Type[BaseException], ...] = (Exception,),
) -> Callable[[Callable], Callable]:
    """
    有界重试:delay = min(max_delay, base * 2^attempt) * (1 ± jitter)
    生产环境请接入结构化日志与 metrics。
    """

    def decorator(fn: Callable) -> Callable:
        @functools.wraps(fn)
        def wrapper(*args, **kwargs):
            attempt = 0
            while True:
                try:
                    return fn(*args, **kwargs)
                except exceptions as exc:  # noqa: BLE001(教学示例宽泛捕获)
                    attempt += 1
                    if attempt >= max_attempts:
                        raise
                    exp = min(max_delay, base_delay * (2 ** (attempt - 1)))
                    jitter = 1.0 + random.uniform(-jitter_ratio, jitter_ratio)
                    sleep_s = max(0.0, exp * jitter)
                    time.sleep(sleep_s)
        return wrapper

    return decorator

3. Redis 分布式锁(简单 SET NX PX + Lua 释放)

import uuid
from dataclasses import dataclass
from typing import Optional

import redis


@dataclass(frozen=True)
class LockHandle:
    key: str
    token: str


class RedisDistributedLock:
    """
    非红锁教学版:单 Redis 实例下足够演示工程要点。
    生产建议:Redlock 争议阅读后谨慎选择;或依赖数据库 advisory lock / DynamoDB lock。
    """

    def __init__(self, client: redis.Redis) -> None:
        self._r = client

    def acquire(self, name: str, ttl_ms: int = 10_000) -> Optional[LockHandle]:
        token = str(uuid.uuid4())
        key = f"lock:{name}"
        ok = self._r.set(key, token, nx=True, px=ttl_ms)
        if not ok:
            return None
        return LockHandle(key=key, token=token)

    def release(self, handle: LockHandle) -> None:
        lua = """
        if redis.call("GET", KEYS[1]) == ARGV[1] then
          return redis.call("DEL", KEYS[1])
        else
          return 0
        end
        """
        self._r.eval(lua, 1, handle.key, handle.token)


class LockContext:
    def __init__(self, locker: RedisDistributedLock, name: str, ttl_ms: int = 10_000) -> None:
        self._locker = locker
        self._name = name
        self._ttl_ms = ttl_ms
        self._handle: Optional[LockHandle] = None

    def __enter__(self) -> "LockContext":
        self._handle = self._locker.acquire(self._name, ttl_ms=self._ttl_ms)
        if self._handle is None:
            raise RuntimeError(f"无法获取锁: {self._name}")
        return self

    def __exit__(self, exc_type, exc, tb) -> None:
        if self._handle is not None:
            self._locker.release(self._handle)

4. 幂等的审核完成处理器(去重 + 锁 + 可重放)

import random
from dataclasses import dataclass
from typing import Dict, Optional


@dataclass(frozen=True)
class ReviewCompletedEvent:
    review_id: str
    idempotency_key: str
    finding_count: int


class IdempotentReviewProcessor:
    """
    模拟:事件至少一次投递时的处理端。
    - processed:{idempotency_key} 记录处理结果(EX 防止 Redis 无限增长)
    - 锁避免同 review 并发重建索引(教学演示)
    """

    def __init__(self, client: redis.Redis) -> None:
        self._r = client
        self._locks = RedisDistributedLock(client)

    def _processed_key(self, idempotency_key: str) -> str:
        return f"processed:review_completed:{idempotency_key}"

    @retry_with_exponential_backoff(max_attempts=5, base_delay=0.05, max_delay=2.0)
    def _heavy_index_upsert(self, review_id: str, finding_count: int) -> None:
        # 模拟调用向量库/ES:可能超时
        if random.random() < 0.35:  # noqa: S311(教学随机失败)
            raise ConnectionError("index backend timeout")
        # 假装写入成功
        self._r.hset(f"index:review:{review_id}", mapping={"finding_count": finding_count})

    def process(self, event: ReviewCompletedEvent) -> Dict[str, str]:
        pkey = self._processed_key(event.idempotency_key)

        cached = self._r.get(pkey)
        if cached is not None:
            return {"status": "duplicate", "detail": cached.decode("utf-8")}

        lock_name = f"review:{event.review_id}:index"
        with LockContext(self._locks, lock_name, ttl_ms=15_000):
            # 双检:拿到锁后可能已被其他 worker 处理
            cached2 = self._r.get(pkey)
            if cached2 is not None:
                return {"status": "duplicate", "detail": cached2.decode("utf-8")}

            self._heavy_index_upsert(event.review_id, event.finding_count)

            # 标记处理完成(保留 7 天,生产按合规要求调整)
            self._r.set(pkey, f"ok:{event.review_id}", ex=7 * 24 * 3600)

        return {"status": "processed", "review_id": event.review_id}


def demo_run() -> None:
    r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=False)
    proc = IdempotentReviewProcessor(r)

    ev = ReviewCompletedEvent(
        review_id="PR-10086",
        idempotency_key="evt-0001",
        finding_count=3,
    )

    print(proc.process(ev))  # processed
    print(proc.process(ev))  # duplicate


if __name__ == "__main__":
    demo_run()

5. 如何把示例嵌回 CodeSentinel 的分层

  • 领域层:不变式由 Review 聚合保证,领域事件 ReviewCompleted 携带 idempotency_key(可用事件 ID)。
  • 应用层CompleteReviewUseCase 负责事务边界与发布事件顺序。
  • 基础设施层:Index worker 实现 IdempotentReviewProcessor 同类逻辑;Redis 仅作 处理去重与锁,权威仍在 PostgreSQL。

6. 单元测试思路(示意):不用启动 Redis 也能测吗?

教学示例强依赖 Redis,但工程上应为锁与去重抽象 Protocol,并提供 内存假实现(dict + 单线程互斥)用于单元测试。测试用例至少覆盖:首次处理成功、重复事件返回 duplicate、索引后端连续失败触发重试上限、锁争用时行为符合预期(阻塞或快速失败取决于产品策略)。这类测试会把“分布式不确定性”压缩为可重复的快测试,是架构师推动质量内建的抓手。

7. 与 FastAPI 路由协作时的常见陷阱

若在请求线程内同步调用索引 upsert,会把 分区容错 问题放大为 API 超时。更稳妥的是:API 在事务提交后返回,索引更新走异步 worker;同时提供 查询回源策略:详情读库、搜索读索引。若必须同步(例如强合规场景),要把 SLA 与成本写清楚,并准备 降级开关


生产环境实战:分区、延迟与一致性 SLO

  1. 定义 SLO:例如“详情 API 强一致读延迟 P99 < 200ms”;“搜索索引滞后 P95 < 30s”。两者分开承诺。

  2. 观测三板斧:Kafka/Redis Stream 消费 lag、索引 upsert 成功率、幂等键命中率(duplicate ratio 过高可能说明上游重试风暴)。

  3. 降级策略:索引不可用时,搜索入口关闭或提示,仅保留权威详情路径。

  4. Outbox/Inbox:若要“数据库写入与消息投递”更强一致,引入事务性发件箱,避免“库已提交但消息丢失”的灰色地带。

  5. 时钟与排序:跨服务不要用本地时间争论先后顺序,优先用 单调事件版本号由数据库生成的序列

  6. 演练清单(建议每季度做一次):随机断开索引消费者、注入重复消息、拔插 Redis 节点,验证监控是否能在 5 分钟内定位根因,验证 UI 是否在派生视图失败时仍能用权威路径完成核心工作。

  7. 成本视角:强一致往往意味着更少的副本可读、更高的延迟与更复杂的故障转移;最终一致意味着更多补偿逻辑与更强的可观测性。架构师要把这笔账算给业务听,而不是替业务默认选择。

  8. 合规与审计:若审核结论用于合规模型,幂等键与事件 ID 应进入审计轨迹,保证“重复处理不会重复记过、也不会丢失记过”。这与纯推荐系统最终一致策略不同。

  9. 多租户隔离:锁与去重 key 必须带 tenant_id 前缀,避免不同租户事件在 Redis 里撞车;同时避免把租户密钥写进日志。

  10. 从事故复盘反推设计:如果复盘结论是“我们当时以为缓存就是真相”,下一版就要把 单一真相源 写进 ADR,并把读路径分层画进 onboarding 文档。


本讲小结:分布式权衡思维导图

mindmap
  root((分布式权衡))
    CAP
      分区客观存在
      C 与 A 的取舍
      子系统可不同策略
    一致性模型
      强一致 权威状态
      最终一致 派生视图
      因果一致 协作顺序
    可靠性模式
      幂等键
      去重表/Redis NX
      有界重试+抖动
      分布式锁谨慎使用
    CodeSentinel
      PostgreSQL 真相源
      索引异步刷新
      详情走权威
      搜索容忍滞后

延伸阅读与动手练习(把教学脚本推进到可运维组件)

建议你把本讲示例与两条工程路线对照阅读:路线 A 以消息系统为中心(至少一次 + 消费者幂等 + 死信队列),适合吞吐高、可容忍秒级不一致的索引刷新;路线 B 以数据库事务与 Outbox 为中心,适合强审计与强一致优先的计费/合规模块。CodeSentinel 往往会同时存在 A 与 B:这不是架构不纯粹,而是 问题域不同。评审时如果有人用“我们应该统一一种消息语义”来否定差异,你要用业务边界与 SLO 把讨论拉回地面。另建议阅读 Raft 论文“安全性证明”的直觉部分,即便不实现,也能帮助你判断托管服务的故障切换承诺是否被夸大。

动手练习建议拆成三步:练习 1,用 docker run 启动 Redis,跑通 demo_run() 并观察 duplicate 分支;练习 2,把 _heavy_index_upsert 的失败概率调到接近 1,验证重试装饰器是否在最大次数后抛出异常;练习 3,并发启动两个进程同时处理同一 idempotency_key,观察锁与双检如何压制重复写入。完成后,把日志字段标准化(review_id、idempotency_key、attempt、lock_acquired)接入日志平台,让排障路径从“猜”变成“查”。如果你正在推动团队改造旧系统,可以把本讲当作 一致性分层评审模板:先画真相源,再画派生视图,再画失败与重试,最后才讨论具体中间件品牌。


思考题

  1. 若你的详情页为了性能只读搜索引擎,分区恢复后会出现哪些用户可见悖论?如何改读写路径?
  2. 幂等处理键应当取自客户端、网关还是消息系统?各自的风险是什么?
  3. Redis 锁过期时间设置过短可能导致什么并发问题?如何用续租或 fencing token 缓解?

下一讲预告

下一讲进入 系统设计面试式拆解:当日处理量上升到 10 万 PR,CodeSentinel 的网关、队列、Worker 水平扩展、LLM 限流与缓存如何组合成可估算、可演进的架构。我们会给出 背板估算数据流图Redis 队列 Worker 完整示例