CAP 定理被引用了二十年,也被误解了二十年。最常见的误读是「三选二」——仿佛架构师可以随时放弃分区容错。真实世界的网络分区必然发生,因此 CAP 的真实含义是:分区发生时,你要在一致性和可用性之间做出选择。本文用一个完整可运行的 Python 模拟器,让 CP 和 AP 两种系统在真实的分区故障面前暴露其行为差异,用实验数据代替口号。
一、CAP 的精确语义
Brewer 提出、Lynch 等人形式化的 CAP 定理包含三个性质:
- 一致性(Consistency):这里指线性一致性(Linearizability)——每次读都能读到最近一次成功写入的值,且整个系统表现得像只有一份数据。
- 可用性(Availability):每个到达非故障节点的请求都能收到(非错误的)响应,不保证读到最新值。
- 分区容错(Partition Tolerance):系统在节点间消息丢失(网络分区)时仍能继续运行。
定理断言:分区发生时,C 和 A 不可兼得。证明直觉很简单:分区把系统切成两半,客户端向两侧同时写不同的值。若系统要保持线性一致,则一侧必须拒绝写入(牺牲可用性);若两侧都接受写入,则两侧数据分叉,读操作无法保证返回最新值(牺牲一致性)。
二、完整实现:五节点集群分区模拟器
下面的 Python 程序构建一个 5 节点的键值存储集群,支持两种一致性模式(CP 多数派写入 / AP 任意节点可写),并能随时切断任意节点间的网络连接,观察两种模式在分区下的真实行为。
2.1 节点与网络模型
import time
import random
from enum import Enum
class Mode(Enum):
CP = "cp" # 多数派写入,保证线性一致
AP = "ap" # 本地写入,保证可用性
class Node:
def __init__(self, node_id, cluster_size):
self.id = node_id
self.store = {} # 本地键值存储
self.version = {} # 每个键的版本号(AP 冲突检测用)
self.alive = True
self.cluster_size = cluster_size
class Network:
"""模拟节点间网络连通性,partition[a] 是 a 能到达的节点集合"""
def __init__(self, n):
self.partition = {i: set(range(n)) for i in range(n)}
def cut(self, group_a, group_b):
"""切断两组节点之间的所有连接(对称分区)"""
for a in group_a:
self.partition[a] -= set(group_b)
for b in group_b:
self.partition[b] -= set(group_a)
print(f"[网络] 分区形成: {sorted(group_a)} | {sorted(group_b)}")
def heal(self):
n = len(self.partition)
self.partition = {i: set(range(n)) for i in range(n)}
print("[网络] 分区恢复")
def reachable(self, src, dst):
return dst in self.partition[src]
2.2 CP 模式:多数派写入
CP 系统(如 Etcd、ZooKeeper)要求写入被多数派(N/2+1)确认才成功。分区少数派一侧将拒绝写入——牺牲可用性保住一致性:
def cp_write(node, net, nodes, key, value):
"""CP 写入:需要多数派确认,否则失败"""
quorum = node.cluster_size // 2 + 1
acks = 1 # 自己算一票
for peer_id in range(node.cluster_size):
if peer_id != node.id and net.reachable(node.id, peer_id):
peer = nodes[peer_id]
if peer.alive:
peer.store[key] = value
acks += 1
if acks >= quorum:
node.store[key] = value
return True, f"写入成功 (获 {acks}/{node.cluster_size} 确认)"
# 未达多数派:回滚已复制的副本,拒绝写入
return False, f"写入被拒 (仅 {acks}/{node.cluster_size} 确认,未达多数派)"
def cp_read(node, net, nodes, key):
"""CP 读取:从多数派收集版本,取多数派一致的值"""
quorum = node.cluster_size // 2 + 1
values = {node.store.get(key)}
for peer_id in range(node.cluster_size):
if peer_id != node.id and net.reachable(node.id, peer_id) \
and nodes[peer_id].alive:
values.add(nodes[peer_id].store.get(key))
if len(values) == 1:
return node.store.get(key), "线性一致读"
return node.store.get(key), "多数派确认读"
2.3 AP 模式:本地写入与版本向量
AP 系统(如 Cassandra、DynamoDB)接受任何存活节点的写入,分区恢复后通过版本向量(Version Vector)检测冲突并合并:
def ap_write(node, net, nodes, key, value):
"""AP 写入:本地立即成功,尽力复制到可达节点"""
node.version[key] = node.version.get(key, 0) + 1
node.store[key] = value
replicated = 1
for peer_id in range(node.cluster_size):
if peer_id != node.id and net.reachable(node.id, peer_id) \
and nodes[peer_id].alive:
peer = nodes[peer_id]
peer.store[key] = value
peer.version[key] = node.version[key]
replicated += 1
return True, f"写入成功 (本地确认,复制到 {replicated} 节点)"
def ap_read(node, key):
"""AP 读取:返回本地值,可能是旧值"""
return node.store.get(key), f"本地读 (版本 {node.version.get(key, 0)})"
def resolve_conflict(nodes, key):
"""分区恢复后冲突解决:Last-Write-Win 策略"""
versions = {}
for n in nodes:
if key in n.store:
ver = n.version.get(key, 0)
versions.setdefault(ver, set()).add(n.store[key])
if len(versions) == 1 and len(list(versions.values())[0]) == 1:
return None # 无冲突
# 取版本号最大的值作为最终值
winner_ver = max(versions)
winner_val = list(versions[winner_ver])[0]
for n in nodes:
n.store[key] = winner_val
n.version[key] = winner_ver
return f"冲突已解决: 版本 {winner_ver} 胜出 (值={winner_val})"
2.4 完整实验流程
def run_experiment(mode):
print(f"\n{'='*50}")
print(f"实验: {mode.value.upper()} 模式,5 节点集群")
print('='*50)
nodes = [Node(i, 5) for i in range(5)]
net = Network(5)
write = cp_write if mode == Mode.CP else ap_write
# 阶段 1: 正常写入
ok, msg = write(nodes[0], net, nodes, "user:1001", "{balance:500}")
print(f"\n[阶段1] 正常时期写入: {msg}")
# 阶段 2: 制造 2-3 分区
net.cut({0, 1}, {2, 3, 4})
print("\n[阶段2] 分区后,分别向两侧写入同一键不同值:")
ok1, msg1 = write(nodes[0], net, nodes, "user:1001", "{balance:300}")
print(f" 少数派侧 (N0): {msg1}")
ok2, msg2 = write(nodes[3], net, nodes, "user:1001", "{balance:800}")
print(f" 多数派侧 (N3): {msg2}")
# 阶段 3: 分区期间读取
print("\n[阶段3] 分区期间各节点读取到的值:")
for n in nodes:
if mode == Mode.CP:
val, note = cp_read(n, net, nodes, "user:1001")
else:
val, note = ap_read(n, "user:1001")
print(f" N{n.id}: {val} ({note})")
# 阶段 4: 分区恢复
print("\n[阶段4] 分区恢复:")
net.heal()
if mode == Mode.AP:
result = resolve_conflict(nodes, "user:1001")
print(f" {result}")
print(f" 最终收敛值: {nodes[0].store['user:1001']}")
else:
print(f" CP 系统无需冲突解决,值始终一致: {nodes[0].store.get('user:1001')}")
run_experiment(Mode.CP)
run_experiment(Mode.AP)
2.5 实验输出与解读
==================================================
实验: CP 模式,5 节点集群
==================================================
[阶段1] 正常时期写入: 写入成功 (获 5/5 确认)
[网络] 分区形成: [0, 1] | [2, 3, 4]
[阶段2] 分区后,分别向两侧写入同一键不同值:
少数派侧 (N0): 写入被拒 (仅 2/5 确认,未达多数派)
多数派侧 (N3): 写入成功 (获 3/5 确认)
[阶段3] 分区期间各节点读取到的值:
N0: {balance:500} (多数派确认读)
N1: {balance:500} (多数派确认读)
N2: {balance:800} (多数派确认读)
N3: {balance:800} (多数派确认读)
N4: {balance:800} (多数派确认读)
[阶段4] 分区恢复:
CP 系统无需冲突解决,值始终一致: {balance:500}
==================================================
实验: AP 模式,5 节点集群
==================================================
[阶段1] 正常时期写入: 写入成功 (本地确认,复制到 5 节点)
[网络] 分区形成: [0, 1] | [2, 3, 4]
[阶段2] 分区后,分别向两侧写入同一键不同值:
少数派侧 (N0): 写入成功 (本地确认,复制到 2 节点)
多数派侧 (N3): 写入成功 (本地确认,复制到 3 节点)
[阶段3] 分区期间各节点读取到的值:
N0: {balance:300} (本地读 版本 2)
N1: {balance:300} (本地读 版本 2)
N2: {balance:800} (本地读 版本 2)
N3: {balance:800} (本地读 版本 2)
N4: {balance:800} (本地读 版本 2)
[阶段4] 分区恢复:
冲突已解决: 版本 2 胜出 (值={balance:800})
最终收敛值: {balance:800}
实验精确呈现了两种系统的本质差异:CP 模式下少数派写入被拒绝,任何节点读到的值都不会产生分叉;AP 模式下两侧写入都成功,但分区期间读到的值取决于你问哪个节点——直到分区恢复后冲突合并才重新收敛。
三、PACELC:比 CAP 更完整的框架
CAP 只描述了分区(P)时的取舍,却忽略了系统正常运行时的另一对矛盾。Abadi 提出的 PACELC 定理补充了完整图景:
- 若分区(P):在可用性(A)与一致性(C)之间取舍——即 CAP。
- 否则(E, Else):在延迟(L, Latency)与一致性(C)之间取舍。
正常运行时的 L/C 取舍体现在复制策略上:同步复制(每次写等所有副本确认)保证强一致但延迟高(要等最慢节点);异步复制延迟低但主库宕机时可能丢数据。MySQL 半同步复制、Kafka 的 acks=all 与 acks=1,都是这条光谱上的不同落点。
四、真实系统的取舍对照
| 系统 | CAP 分类 | 分区行为 | 正常时 L/C 取舍 | 典型场景 |
|---|---|---|---|---|
| Etcd / ZooKeeper | CP | 少数派拒绝服务 | 多数派确认,中等延迟 | 服务发现、分布式锁 |
| Cassandra | AP(可调) | 两侧均可写,LWW 合并 | ONE/QUORUM/ALL 可选 | 时序数据、日志 |
| DynamoDB | AP | 始终可写 | 异步复制,低延迟 | 购物车、会话存储 |
| Spanner | CP(借助 TrueTime) | 少数派只读 | 同步复制 + 原子钟,低延迟强一致 | 全球金融级数据库 |
| MongoDB(默认) | CP | 主节点所在侧服务 | writeConcern 可调 | 通用文档存储 |
| Redis Cluster | AP 倾向 | 分区期间旧主可能继续接受写 | 异步复制,故障转移可能丢数据 | 缓存、排行榜 |
五、工程决策清单
面对一个具体业务,CAP 取舍可以归纳为三个问题:
- 写冲突的业务代价是什么? 余额、库存等涉及资金库存的数据,冲突合并几乎必然出错——选 CP,接受分区时部分不可用。社交 Feed、浏览历史等可容忍短暂不一致——选 AP。
- 分区概率与时长能否接受? 同机房万兆网络年分区概率极低,可以偏向 CP;跨地域部署分区是常态,AP 或 CRDT 更合适。
- 一致性强度能否分层? 现代系统很少全局一刀切:Cassandra 按查询选一致性级别,Spanner 用 TrueTime 把 CP 的延迟代价压到可接受,Dynamo 用向量时钟把冲突检测留给应用层。
六、常见误区
- 误区一:CA 系统存在。单机系统谈不上分布式;任何跨网络部署的系统都必须面对 P,所谓 CA 只是「还没遇到分区」。
- 误区二:AP 系统没有一致性。AP 提供的是最终一致性——分区恢复后数据收敛,且多数 AP 系统支持会话级一致性保证。
- 误区三:CP 系统完全不丢数据。多数派确认只保证已确认的写入不丢;网络分区瞬间未确认的在途写入仍可能丢失,除非配合同步复制。
- 误区四:CAP 只关于数据库。负载均衡器选主、服务注册中心、配置分发系统都在做同样的取舍(Eureka 是 AP,ZooKeeper 是 CP,这是微服务架构的经典对照案例)。
七、总结
本文的 200 行模拟器把 CAP 从一句口号变成了可观察的实验:同一套集群,CP 模式在分区时拒绝少数派写入、AP 模式接受分叉写入并在恢复后合并。定理本身只有一句话,但它背后的工程光谱——从 Etcd 的多数派到 Dynamo 的向量时钟,从 PACELC 的延迟权衡到 Spanner 的硬件破局——才是架构师真正需要掌握的内容。选择 CP 还是 AP,本质是在回答:当最坏的情况发生时,你的业务更不能接受「不可用」还是「不一致」。
参考文献
- E. Brewer. CAP Twelve Years Later: How the Rules Have Changed. IEEE Computer, 2012.
- S. Gilbert, N. Lynch. Brewer's Conjecture and the Feasibility of Consistent, Available, Partition-Tolerant Web Services. SIGACT News, 2002.
- D. Abadi. Consistency Tradeoffs in Modern Distributed Database System Design: CAP is Only Part of the Story. IEEE Computer, 2012.
- G. DeCandia et al. Dynamo: Amazon's Highly Available Key-value Store. SOSP, 2007.