模块二-架构基础功 | 第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 简化:你需要知道的只有四件事
- 日志复制:Leader 把命令复制到多数派 Follower 后才算提交。
- Leader 选举:心跳超时触发选举,需要多数票。
- 脑裂防护:少数派分区无法提交新日志,因此不会“写入成功但互相矛盾”(在共识层内)。
- 你的职责:正确设置 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
-
定义 SLO:例如“详情 API 强一致读延迟 P99 < 200ms”;“搜索索引滞后 P95 < 30s”。两者分开承诺。
-
观测三板斧:Kafka/Redis Stream 消费 lag、索引 upsert 成功率、幂等键命中率(duplicate ratio 过高可能说明上游重试风暴)。
-
降级策略:索引不可用时,搜索入口关闭或提示,仅保留权威详情路径。
-
Outbox/Inbox:若要“数据库写入与消息投递”更强一致,引入事务性发件箱,避免“库已提交但消息丢失”的灰色地带。
-
时钟与排序:跨服务不要用本地时间争论先后顺序,优先用 单调事件版本号 或 由数据库生成的序列。
-
演练清单(建议每季度做一次):随机断开索引消费者、注入重复消息、拔插 Redis 节点,验证监控是否能在 5 分钟内定位根因,验证 UI 是否在派生视图失败时仍能用权威路径完成核心工作。
-
成本视角:强一致往往意味着更少的副本可读、更高的延迟与更复杂的故障转移;最终一致意味着更多补偿逻辑与更强的可观测性。架构师要把这笔账算给业务听,而不是替业务默认选择。
-
合规与审计:若审核结论用于合规模型,幂等键与事件 ID 应进入审计轨迹,保证“重复处理不会重复记过、也不会丢失记过”。这与纯推荐系统最终一致策略不同。
-
多租户隔离:锁与去重 key 必须带
tenant_id前缀,避免不同租户事件在 Redis 里撞车;同时避免把租户密钥写进日志。 -
从事故复盘反推设计:如果复盘结论是“我们当时以为缓存就是真相”,下一版就要把 单一真相源 写进 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)接入日志平台,让排障路径从“猜”变成“查”。如果你正在推动团队改造旧系统,可以把本讲当作 一致性分层评审模板:先画真相源,再画派生视图,再画失败与重试,最后才讨论具体中间件品牌。
思考题
- 若你的详情页为了性能只读搜索引擎,分区恢复后会出现哪些用户可见悖论?如何改读写路径?
- 幂等处理键应当取自客户端、网关还是消息系统?各自的风险是什么?
- Redis 锁过期时间设置过短可能导致什么并发问题?如何用续租或 fencing token 缓解?
下一讲预告
下一讲进入 系统设计面试式拆解:当日处理量上升到 10 万 PR,CodeSentinel 的网关、队列、Worker 水平扩展、LLM 限流与缓存如何组合成可估算、可演进的架构。我们会给出 背板估算、数据流图与 Redis 队列 Worker 完整示例。