CAP 定理被引用了二十年,也被误解了二十年。最常见的误读是「三选二」——仿佛架构师可以随时放弃分区容错。真实世界的网络分区必然发生,因此 CAP 的真实含义是:分区发生时,你要在一致性和可用性之间做出选择。本文用一个完整可运行的 Python 模拟器,让 CP 和 AP 两种系统在真实的分区故障面前暴露其行为差异,用实验数据代替口号。

一、CAP 的精确语义

Brewer 提出、Lynch 等人形式化的 CAP 定理包含三个性质:

定理断言:分区发生时,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 定理补充了完整图景:

正常运行时的 L/C 取舍体现在复制策略上:同步复制(每次写等所有副本确认)保证强一致但延迟高(要等最慢节点);异步复制延迟低但主库宕机时可能丢数据。MySQL 半同步复制、Kafka 的 acks=all 与 acks=1,都是这条光谱上的不同落点。

四、真实系统的取舍对照

系统CAP 分类分区行为正常时 L/C 取舍典型场景
Etcd / ZooKeeperCP少数派拒绝服务多数派确认,中等延迟服务发现、分布式锁
CassandraAP(可调)两侧均可写,LWW 合并ONE/QUORUM/ALL 可选时序数据、日志
DynamoDBAP始终可写异步复制,低延迟购物车、会话存储
SpannerCP(借助 TrueTime)少数派只读同步复制 + 原子钟,低延迟强一致全球金融级数据库
MongoDB(默认)CP主节点所在侧服务writeConcern 可调通用文档存储
Redis ClusterAP 倾向分区期间旧主可能继续接受写异步复制,故障转移可能丢数据缓存、排行榜

五、工程决策清单

面对一个具体业务,CAP 取舍可以归纳为三个问题:

  1. 写冲突的业务代价是什么? 余额、库存等涉及资金库存的数据,冲突合并几乎必然出错——选 CP,接受分区时部分不可用。社交 Feed、浏览历史等可容忍短暂不一致——选 AP。
  2. 分区概率与时长能否接受? 同机房万兆网络年分区概率极低,可以偏向 CP;跨地域部署分区是常态,AP 或 CRDT 更合适。
  3. 一致性强度能否分层? 现代系统很少全局一刀切:Cassandra 按查询选一致性级别,Spanner 用 TrueTime 把 CP 的延迟代价压到可接受,Dynamo 用向量时钟把冲突检测留给应用层。

六、常见误区

七、总结

本文的 200 行模拟器把 CAP 从一句口号变成了可观察的实验:同一套集群,CP 模式在分区时拒绝少数派写入、AP 模式接受分叉写入并在恢复后合并。定理本身只有一句话,但它背后的工程光谱——从 Etcd 的多数派到 Dynamo 的向量时钟,从 PACELC 的延迟权衡到 Spanner 的硬件破局——才是架构师真正需要掌握的内容。选择 CP 还是 AP,本质是在回答:当最坏的情况发生时,你的业务更不能接受「不可用」还是「不一致」。

参考文献

  1. E. Brewer. CAP Twelve Years Later: How the Rules Have Changed. IEEE Computer, 2012.
  2. S. Gilbert, N. Lynch. Brewer's Conjecture and the Feasibility of Consistent, Available, Partition-Tolerant Web Services. SIGACT News, 2002.
  3. D. Abadi. Consistency Tradeoffs in Modern Distributed Database System Design: CAP is Only Part of the Story. IEEE Computer, 2012.
  4. G. DeCandia et al. Dynamo: Amazon's Highly Available Key-value Store. SOSP, 2007.
← 返回文章列表