CAP 与一致性模型
配置中心已经返回“版本 2 写入成功”,另一个实例随后读取,得到的却是版本 1。判断这次读取是否正确,需要知道读取接口承诺什么、两个操作是否重叠,以及请求最终落在哪个副本。
一次读写承诺由哪些条件组成
先确定对象、操作与观察顺序
一致性模型规定多个客户端能观察到哪些执行结果。分析时至少固定以下对象:
一个逻辑对象:配置 /lesson/value
├── 副本:e1、e2、e3 各自保存数据
├── 操作:put(v2)、get()、compare-and-set(v1, v2)
├── 历史:每次操作的调用、返回和返回值
└── 契约:读最新提交、允许成员本地旧值,或满足会话最低版本“有三份数据”描述存储布局,“多数节点确认后才返回”描述协议动作,“成功写之后开始的读能看到什么”才是客户端的一致性承诺。应用还要单独说明持久性:进程崩溃、机器断电或全部磁盘丢失,分别能否保留已经确认的结果。
以初始值为 v1 的寄存器为例,下面两组历史不同:
不重叠: put(v2) 调用 ── 返回成功
get() 调用 ── 返回 v1
有重叠: put(v2) 调用 ───────────────── 返回成功
get() 调用 ── 返回 v1第一组违反线性一致:读开始时写已经完成,没有后续其他写时应读到 v2。第二组可能合法,读可以被安排在写生效之前。只按两条日志的返回时间排序,会把第二组也误判为旧读缺陷。
线性一致要求每个操作能被放到自身调用与返回之间的一个瞬间,并且这些瞬间组成满足对象规则、尊重不重叠操作实时顺序的全序。这个瞬间称为线性化点;客户端不必知道它对应哪条机器指令。线性一致原论文
网络失败使节点缺少哪些信息
调用发出后没有收到响应,至少可能经过四种路径:
| 发生位置 | 对端可能已经完成的动作 | 调用方立即掌握的事实 |
|---|---|---|
| 请求尚未到达服务端 | 没有执行 | 只有本地发送或连接失败信息 |
| 请求正在排队或执行 | 可能尚未提交 | 截止前没有收到完成结果 |
| 已提交,响应丢失 | 写入已经生效 | 同样表现为超时 |
| 对端与其他副本失联 | 本地可读,部分操作无法取得多数确认 | 某条通信路径失效,不能直接推定整台机器死亡 |
分区是节点组之间无法交换消息。节点可以继续运行,也可以继续接收本地客户端请求;“停掉服务进程”和“只切断成员间通信”因此产生不同观察。单向丢包、DNS 错误、长暂停和高尾延迟在故障检测器里也可能都表现为超时,定位时需要继续检查具体链路。
结果未知的写请求应使用稳定业务操作标识查询结果,或依赖目标端原子幂等机制重试。重新生成请求 ID 后再提交,会把一次未知结果变成第二次独立业务操作。HTTP 与 RPC 的截止时间、取消和重试关系见超时、重试与幂等。
CAP 的三个字母采用特定定义
Gilbert 与 Lynch 的 CAP 论文讨论异步网络中的读写对象:
- C:原子/线性一致。 操作符合单一实时对象的规则。
- A:可用性。 非失败节点收到的每个请求最终完成,不能靠永远不回答或统一返回“服务不可用”满足对象操作。
- P:分区条件。 网络允许任意多的节点间消息丢失,包括分区长期不恢复。
论文中的 A 没有统一的毫秒响应上界。业务定义“99.9% 的请求在预算内成功”,则同时约束成功比例和延迟。一个三秒后报错的客户端请求已经超出这次调用的工程预算;这与理论模型要求的最终完成是两个层次。
分区发生后,一侧完成新写,另一侧完全收不到这次写的信息。后一侧如果立即返回本地旧值,就放宽了实时一致性;如果等待通信恢复,就无法对永久分区保证最终完成。更换错误码无法消除这项信息缺口。
CAP 也不把整个产品永远分成两类。同一集群可以提供需要协调的线性读取和允许陈旧的成员本地读取;同一业务可以让商品说明使用异步副本,让额度扣减使用条件事务。选择要落实到具体操作及失败响应。
不同一致性模型怎样影响业务代码
副本一致性与事务隔离
几个名称经常一起出现,但约束的对象不同:
| 名称 | 主要约束 | 对程序的直接影响 |
|---|---|---|
| 线性一致 | 对象的操作顺序,包含实时先后 | 已完成写之后的新读不能退回更早值 |
| 顺序一致 | 存在统一顺序,保持各进程程序顺序 | 不额外要求不同进程间的真实时间先后 |
| 可串行化 | 多个事务的整体效果等价于某种串行执行 | 阻止需要事务间交错才会出现的结果;该串行顺序不一定遵守外部实时顺序 |
| 严格可串行化 | 可串行化并尊重事务的实时顺序 | 把多对象事务作为原子操作理解 |
| 因果一致 | 有因果依赖的更新按依赖顺序被观察 | 看见回复前能看见它依赖的原文;并发更新仍需冲突规则 |
| 最终一致 | 更新停止且传播、合并等必要条件持续成立后,副本趋于相同 | 应用接受中间差异,并设计合并、传播和恢复 |
单键 get 与 put 都线性一致,也不能让“先读余额,再在客户端算出新余额,最后写回”自动成为原子扣减。两个客户端可以读到同一余额并覆盖对方结果。解决方式是服务器端条件更新、事务或原子算术操作,取决于对象接口。
相反,单节点事务达到可串行化,也不意味着任意外部缓存或异步副本立即提供同样的读取语义。事务提交路径和查询路由必须一起检查。数据库实验可继续参照JDBC 事务与批处理。
会话保证怎样减少用户看到的倒退
会话保证把部分顺序要求限定到同一个客户端会话。四项保证分别处理四种依赖:Session Guarantees 原论文。
| 保证 | 依赖关系 | 例子 |
|---|---|---|
| 读己之写 | 本会话的先前写 → 后续读 | 修改头像后刷新,不能立刻读回修改前状态 |
| 单调读 | 本会话的先前读 → 后续读 | 先读到版本 8,再刷新不能退回版本 6 |
| 单调写 | 本会话的先前写 → 后续写 | 先设为“暂存”,再设为“提交”,传播时保持写入依赖 |
| 写跟随读 | 先前读所依赖的更新 → 本会话后续写 | 基于原文创建的回复,在该原文之后被应用 |
写跟随读保证因果依赖,不负责检测两个用户修改同一字段时的覆盖。编辑冲突仍应由 expectedVersion 等条件处理。
常见实现让写响应返回版本 token,后续请求携带最低版本。路由层选择已经追到该版本的副本;没有满足条件的副本时,可以等待有限时间、回主或明确报错。token 必须包含作用域,例如数据库集群、分片或聚合标识;不能拿一个 Kafka 分区的 offset 与另一个分区直接比较。
粘性路由能减少跨副本倒退,但实例故障或重新均衡后还需要保留会话依赖。最低版本只约束读得不能太旧;在该版本之后发生了什么,仍由读取模式决定。
最终一致需要传播与冲突处理共同工作
两个离线客户端分别添加购物项,可以通过集合或可合并的数据类型保留两次添加;两个客户端分别把同一商品数量改成不同绝对值,则需要明确合并规则。按时间戳选最大值容易实现,却可能因时钟误差覆盖另一次合法修改。版本向量、业务优先级、人工处理解决的是不同冲突。
更新不停地产生时,副本可以持续落后。工程上通常还需要定义新鲜度、积压年龄和异常出口:订单投影延迟超过业务期限时暂停依赖它的动作;坏事件进入可修复队列;独立对账检查事件链之外的漏更新。实现见最终一致、Outbox 与 Inbox。
多数交集只是协议的一部分
固定 N=3 个副本、每次写确认 W=2 个、每次读访问 R=2 个,满足 R+W>N。任意读写集合至少有一个交集成员,但读取仍需知道:返回哪个版本、怎样处理并发写、已写一半的操作是否可见、旧成员是否属于当前配置。
如果临时用其他节点接管副本位置,或者扩缩容时读写双方使用不同成员集合,上述交集前提也可能消失。Dynamo 的 sloppy quorum 允许暂时放宽固定副本集合,不能直接套成线性读协议。Dynamo 原论文
Raft 通过任期、日志匹配、提交规则和成员管理约束副本演进。线性读还要确认当前领导权,并等待相应提交位置在本地应用;单纯从“自称 leader”的内存读取,可能读到已失去领导权的旧状态。这些协调动作让少数分区上的读取与写入受到限制。Raft 原论文
在三成员 etcd 上比较两种读取
启动一个封闭实验集群
使用 Linux、Docker Engine、Compose v2、Bash 和 unzip。下载etcd 分区实验包,保存为 distributed-cap-lab.zip,在新目录解压:
mkdir ds14-cap-work
unzip distributed-cap-lab.zip -d ds14-cap-work
cd ds14-cap-work/distributed-cap
docker version
docker compose version
docker compose configdocker version 必须同时显示客户端和服务端,Compose 应成功展开三个成员。宿主操作者需要已获准访问 Docker daemon,这项权限能够管理宿主容器;三个 etcd 进程另外固定以 10001:10001 运行,没有构建容器。
实验固定 etcd v3.6.5。官方镜像仓库与容器运行参数见容器部署说明。它是本实验的复现版本,生产版本还应按支持线和安全修复另行选择。
docker compose pull
docker compose up -d --wait --wait-timeout 90
docker compose ps
docker exec ds14-cap-e1 /usr/local/bin/etcd --version
docker inspect ds14-cap-e1 --format '{{.Config.User}}'三个服务应达到 healthy,版本含 3.6.5,身份为 10001:10001。启动超时先检查 docker compose logs --tail=80。镜像拉取失败应检查批准的代理或企业镜像仓库,不能关闭证书验证。离线场景可在可信联网环境保存同版本镜像,连同校验和搬运后使用 docker load。
Compose 中的关键设置如下,完整文件在解压目录:
image: quay.io/coreos/etcd:v3.6.5
user: "10001:10001"
read_only: true
tmpfs:
- /etcd-data:uid=10001,gid=10001,mode=0700,size=134217728
mem_limit: 256m成员使用专用 Docker 网络通信,没有映射宿主端口,也没有启用认证和 TLS。数据位于 tmpfs,停止或重建容器会丢失;只能用来观察运行期间的分区与追赶。生产 etcd 应使用持久存储、身份认证和传输保护;三个容器放在一台宿主机上也没有跨宿主容灾能力。
看清客户端入口与成员入口
ds14-cap-peer
├── e1:2379 客户端接口;2380 成员复制通信
├── e2:2379 客户端接口;2380 成员复制通信
└── e3:2379 客户端接口;2380 成员复制通信
宿主 docker exec → 指定成员容器 → 127.0.0.1:2379后面的命令只请求指定成员的回环接口,没有把三个地址一起交给客户端自动切换,因而可以分别观察多数侧和少数侧。etcdctl 已在镜像中,宿主不用另装。
定义一个仅缩短命令的 Bash 函数:
ctl() {
docker exec "ds14-cap-$1" /usr/local/bin/etcdctl \
--endpoints=http://127.0.0.1:2379 --command-timeout=3s "${@:2}"
}
ctl e1 endpoint status --cluster --write-out=table
ctl e1 put /lesson/value v1状态表应列出三个成员、一个 leader 及任期、日志索引、已应用索引。成员 ID、leader 所在位置和这些数值由当前运行产生,不要求与其他机器一致。写入返回 OK 后,确认三个本地副本都已有 v1:
all_ready=1
for member in e1 e2 e3; do
for attempt in $(seq 1 30); do
value=$(ctl "$member" get /lesson/value --consistency=s --print-value-only)
[ "$value" = v1 ] && break
sleep 1
done
if [ "$value" != v1 ]; then
printf '%s\n' "$member 尚未追到 v1" >&2
all_ready=0
break
fi
done
test "$all_ready" = 1三个读取应都达到 v1。未达到时先恢复集群健康,不进入隔离步骤;否则无法区分原有落后与本次注入造成的落后。
etcd 的 --consistency=l 是线性读取,也是默认值;--consistency=s 对应 API 的 serializable=true,表示允许成员本地读取。这里的参数不是把查询提升为数据库“可串行化事务”隔离级别。etcd Range API
隔离 e3,先完成多数侧写入
以下操作只影响本实验网络中的 e3;不要替换成生产容器名称。
docker network disconnect ds14-cap-peer ds14-cap-e3
docker inspect ds14-cap-e3 --format '{{json .NetworkSettings.Networks}}'e3 的网络映射应为空,但进程仍在运行,docker exec 和容器内回环接口仍可使用。如果 e3 原来是 leader,e1/e2 需要重新选举,这时多数侧也会短暂等待。
written=0
for attempt in $(seq 1 10); do
if ctl e1 put /lesson/value v2; then
written=1
break
fi
sleep 1
done
test "$written" = 1
ctl e1 get /lesson/value --print-value-only确认写入 OK 并读到 v2 才继续比较 e3。这里重试的是同一键设置同一值,没有扣款、发消息等额外业务副作用;业务写应另行实现幂等。
本地读取直接使用已应用的数据;线性读取还要与当前集群顺序对齐。etcd API 保证
对比旧值、超时和写入失败
ctl e3 get /lesson/value --consistency=s --print-value-only预期返回 v1,满足成员本地读取的契约,但不能用作最新配置。将同一个读取改成线性模式,保存错误:
linear_rc=0
ctl e3 get /lesson/value --consistency=l > linear.out 2> linear.err || linear_rc=$?
test "$linear_rc" -ne 0 && grep -q 'context deadline exceeded' linear.err \
&& sed -n '$p' linear.err本实验的错误结尾为 Error: context deadline exceeded,命令非零退出。空的 linear.out 不能被解释为键不存在。三秒截止说明客户端没有在预算内得到结果,不是对无限时间执行的观测。
write_rc=0
ctl e3 put /lesson/minority-write forbidden > write.out 2> write.err || write_rc=$?
test "$write_rc" -ne 0 && grep -q 'context deadline exceeded' write.err \
&& sed -n '$p' write.err少数侧写同样在截止后失败。生产写超时仍应按未知结果处理;即使本次已控制隔离条件,也不能外推成“所有超时写都未提交”。
恢复通信并等待确定的值
docker network connect --alias e3 ds14-cap-peer ds14-cap-e3
for attempt in $(seq 1 30); do
value=$(ctl e3 get /lesson/value --consistency=s --print-value-only)
[ "$value" = v2 ] && break
sleep 1
done
test "$value" = v2
ctl e3 get /lesson/value --consistency=l --print-value-only
ctl e1 endpoint status --cluster --write-out=table本地读取和线性读取都应返回 v2,三个成员重新可达。这里切断的是 e3 的全部 Docker 网络通信,属于受控双向隔离;单向丢包、磁盘故障和断电恢复需要另选注入点。etcd 故障模式
需要从头自动复跑时,在新启动的本实验环境中执行 bash partition.sh。脚本会检查值、命令退出和错误类型,异常退出时也尝试恢复 e3 网络。
保留集群运行,可继续执行下面的排障查询。遇到错误类型与预期不符时先恢复网络、检查成员,不把任意非零退出都当成分区效果。
旧读、超时与故障恢复的判断顺序
写后读到旧值
先固定同一个键、集群和客户端配置,再读取 JSON:
ctl e1 get /lesson/value --consistency=l --write-out=json
ctl e3 get /lesson/value --consistency=s --write-out=json检查 header.cluster_id、header.revision 和键的 mod_revision。响应 revision 是读取时的存储版本,mod revision 是该键最后修改的位置;它们不必相同。Raft 日志索引、键的 version(修改次数)与存储 revision 也是不同数字。
若读请求确实在成功写之后才开始,却走了本地模式,修复读取策略或带最低版本的路由。若读取已用线性模式,继续排查是否访问了另一个集群、代理缓存结果、键名不同,或者调用日志没有正确关联。修改后用同一写入和查询重新比较,不能只以 endpoint health 成功结束。
成员可达,但线性读和写都超时
ctl e1 endpoint status --cluster --write-out=table
docker compose logs --tail=80 e1 e2 e3
docker stats --no-stream ds14-cap-e1 ds14-cap-e2 ds14-cap-e3状态表帮助区分少数侧失联、leader 切换与本地应用落后;日志继续定位 peer 连接和磁盘响应。容器资源紧张时,健康检查可能随业务操作一起变慢。恢复多数成员通信或处理磁盘、CPU 问题后,再确认值和已应用位置追上;不应通过强制创建新集群或删除成员掩盖暂时失联。
强一致调用的可用性还取决于客户端能否抵达可以完成操作的成员。固定在孤立节点的客户端,即使另外两个节点健康,也无法完成线性操作。重试换节点可以改善可达性,但要保留原请求截止时间,写请求还需保持业务幂等标识。
Watch 已连接,但应用配置没有更新
Watch 提供按 revision 排序的变更通知,通知可以延迟。成功建立流不代表已经到达最新写入,Watch 缓存也不天然提供线性读取。etcd API 保证
应用应记录最后已应用 revision,区分“没有收到”“收到但解析失败”“解析后没有切换当前配置”。断线重连从下一 revision 继续;所需历史已经压缩时,重新读取一致快照,再从相应后续位置订阅。直接从当前时刻订阅,可能漏掉断线窗口内更新。
选择承诺时同时计算代价
原子分配、比较更新或严格配置切换,通常值得为实时顺序支付协调延迟。只要求用户不读回旧状态时,可以先使用会话最低版本;允许有限陈旧的展示数据,可以通过异步副本降低跨区成本。各方案都应明确超时响应和降级后的数据语义。
副本增加会改变容错、复制和维护成本,却不会自动改变接口承诺。三个成员、五个成员与跨区部署的差异,要结合写入往返、持久化延迟和可失去的故障域评估。容量测试同时观察正常请求与成员追赶,给恢复工作保留资源。
释放实验资源
完成读取对照和排障查询后,在解压目录执行:
docker compose down它删除本实验三个容器和专用网络,tmpfs 数据随之消失。无需执行全局 docker system prune,也无需清理其他项目的镜像或卷。
权威资料与规范地址
一致性模型与协议
- CAP 原论文:https://www.cs.princeton.edu/courses/archive/fall19/cos418/papers/cap.pdf
- 线性一致原论文:https://cs.brown.edu/~mph/HerlihyW90/p463-herlihy.pdf
- Session Guarantees:https://www.cs.cornell.edu/courses/cs734/2000FA/cached papers/SessionGuaranteesPDIS_1.html
- Dynamo 原论文:https://www.cs.princeton.edu/courses/archive/fall17/cos418/papers/dynamo.pdf
- Raft 原论文:https://raft.github.io/raft.pdf
