图 1:本文封面,分区一旦发生,C 和 A 只能留一个。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。题面:什么是 CAP?为什么说分布式系统只能三选二?
先把结论立住:**CAP 不是常态下的属性标签,而是故障时刻的取舍预案——设计时要回答的问题是「断网那几分钟,你的系统宁可牺牲什么」。**2000 年 Brewer 提出猜想,2002 年 Gilbert 与 Lynch 给出形式化证明:网络共享数据系统无法同时满足一致性、可用性与分区容错。原论文对三词的定义是解题的地基:一致性指所有节点同一时刻看到同一份数据,可用性指每个请求都能在有限时间内得到非错误响应,分区容错指节点间消息丢失后系统仍要继续运转。
**先破掉「三选二」的误读。**网络分区在分布式系统里迟早发生——光纤被挖断、交换机故障、机房断网,都不是「会不会」而是「什么时候」。所以 P 不是可选项而是必选项,真正的选择只在分区发生的那一刻才出现:两侧节点连不上,一个写请求来了,你要么拒绝它(保一致,牺牲可用),要么各自应答(保可用,牺牲一致)。常态下 C 和 A 可以兼得,「三选二」描述的从来是分区期间。
**选 C 的一侧归为 CP。**宁可部分节点不可用,也不返回不一致数据。典型手段是多数派:写必须过半确认,拿不到过半的分区拒绝服务。元数据、账务、配置中心这类「错一次就出事」的场景走这条路。
**选 A 的一侧归为 AP。**各分区独立应答,先保证服务在线,愈合后再对账合并。缓存、浏览型数据、内容分发走这条路——短期读到旧值可以忍,服务不在线不能忍。代价是分区期间的并发写会分叉,愈合后必须靠补偿机制收场。
图 2:三个词的定义与分区时刻的取舍路径。
「分区时 C 和 A 互相排斥」可以用一个最小仿真看清楚。下面用 Node.js 22.15.0 模拟一个双节点存储:正常期写入同步到两节点;分区后分别按 CP 与 AP 两种策略处理写请求(node cap.js):
'use strict';
// 最小分布式存储:两个节点互相同步写入。模拟网络分区时 CP 与 AP 两种取舍。
function makeCluster(mode) {
const nodes = [
{ name: 'N1', data: {}, log: [] },
{ name: 'N2', data: {}, log: [] },
];
let partitioned = false;
return {
partition() { partitioned = true; },
heal() {
partitioned = false;
// 分区愈合后的对账:last-write-wins,后写的覆盖先写的
for (const [k, v] of Object.entries(nodes[0].data)) nodes[1].data[k] = v;
for (const [k, v] of Object.entries(nodes[1].data)) nodes[0].data[k] = v;
// 合并后两节点取并集,但同 key 的并发写只有一个能活
const merged = { ...nodes[0].data, ...nodes[1].data };
nodes[0].data = { ...merged };
nodes[1].data = { ...merged };
},
write(target, key, value) {
const node = nodes[target];
const other = nodes[1 - target];
if (!partitioned) {
node.data[key] = value;
other.data[key] = value;
return `OK(已同步到两个节点)`;
}
if (mode === 'CP') {
// 选 C:无法确认对端状态就拒绝写,宁可不可用也不产生分歧
return `拒绝(分区期间无法保证一致,写入失败)`;
}
// 选 A:本分区独立应答,先记下,分歧留给愈合后对账
node.data[key] = value;
node.log.push(`${key}=${value}`);
return `OK(仅写入 ${node.name},与对端产生分歧)`;
},
read(target, key) {
return nodes[target].data[key] ?? null;
},
consistent() {
const [a, b] = nodes.map((n) => JSON.stringify(Object.keys(n.data).sort().map((k) => [k, n.data[k]])));
return a === b;
},
};
}
// CP 演示
const cp = makeCluster('CP');
console.log('—— CP 取舍(分区期间宁可不可用)——');
console.log('正常期写 config=a:' + cp.write(0, 'config', 'a'));
cp.partition();
console.log('分区后 N1 写 config=b:' + cp.write(0, 'config', 'b'));
console.log('分区后 N2 写 config=c:' + cp.write(1, 'config', 'c'));
console.log('两节点数据一致:' + cp.consistent() + '(都停在 config=a)');
// AP 演示
const ap = makeCluster('AP');
console.log('—— AP 取舍(分区期间保持在线)——');
console.log('正常期写 config=a:' + ap.write(0, 'config', 'a'));
ap.partition();
console.log('分区后 N1 写 config=b:' + ap.write(0, 'config', 'b'));
console.log('分区后 N2 写 config=c:' + ap.write(1, 'config', 'c'));
console.log('分区期间从 N1 读到:' + ap.read(0, 'config') + ',从 N2 读到:' + ap.read(1, 'config'));
console.log('两节点数据一致:' + ap.consistent() + '(分歧已发生)');
ap.heal();
console.log('愈合对账后从 N1 读到:' + ap.read(0, 'config') + ',从 N2 读到:' + ap.read(1, 'config'));
console.log('愈合后两节点一致:' + ap.consistent() + '(其中一次写入被对账覆盖,需要补偿机制兜底)');
实测输出:
—— CP 取舍(分区期间宁可不可用)——
正常期写 config=a:OK(已同步到两个节点)
分区后 N1 写 config=b:拒绝(分区期间无法保证一致,写入失败)
分区后 N2 写 config=c:拒绝(分区期间无法保证一致,写入失败)
两节点数据一致:true(都停在 config=a)
—— AP 取舍(分区期间保持在线)——
正常期写 config=a:OK(已同步到两个节点)
分区后 N1 写 config=b:OK(仅写入 N1,与对端产生分歧)
分区后 N2 写 config=c:OK(仅写入 N2,与对端产生分歧)
分区期间从 N1 读到:b,从 N2 读到:c
两节点数据一致:false(分歧已发生)
愈合对账后从 N1 读到:b,从 N2 读到:b
愈合后两节点一致:true(其中一次写入被对账覆盖,需要补偿机制兜底)
CP 分支用「拒绝写入」守住了一致性;AP 分支分区期间两边各写各的,config 在 N1 是 b、在 N2 是 c,分歧真实发生,愈合后 last-write-wins 只保住一个值,另一次写入永远丢失——这就是 AP 必须配补偿机制的原因。
**常见误区。**一是把 CAP 当常态标签背「某某系统是 CA」——不存在分区时谈取舍没有意义;二是把一致性混同为数据库 ACID 的 C,CAP 的 C 指多节点视角的线性一致,不是事务约束;三是以为 AP 就是「不要一致性」,它是把一致性推迟到愈合后,并且要为丢写付代价;四是以为多数派能同时保住 C 和 A——多数派保住的只是多数侧的可用,少数侧照样不可用。
AI 工程里向量库、特征存储、模型注册中心的选型都要回答「分区时选一致还是可用」,CAP 是分布式组件选型的第一性框架。答题节奏:先正名三词,再破「三选二」误读,然后给出分区时刻的 CP/AP 决策路径,最后落到「断网时宁可牺牲什么」的预案。你负责的系统在分区预案上选了哪一边?欢迎分享。