一致性与 CAP
strong/eventual/weak consistency、CAP 取舍。
一致性与 CAP
这一课你将能做到
- 用强一致 / 最终一致 / 弱一致三档,给本站任意 API 标注一致级别并说明理由
- 讲清 CAP 定理在工程中的真实含义(P 必选,实际在 C 和 A 之间取舍)
- 在 MySQL 主从读写分离下实现「读己之写」,并写出幂等提交的表设计与代码
为什么要学
「我明明提交了,刷新一下结果就没了」「排行榜怎么两个人都是第 3 名」——这些都是一致性问题。分布式系统里数据有多个副本(主从库、Redis 缓存、搜索索引),副本之间同步有延迟,读到旧数据不是 bug,是常态。设计者的职责是:让业务最敏感的数据强一致,让无所谓的数据便宜地最终一致。
面试必考:「CAP 定理是什么?」「主从延迟怎么处理?」「为什么不用强一致搞定一切?」——这节课给你能画能背的答案。
人话:强一致像银行转账,转完立刻查余额必须是对的;最终一致像朋友圈点赞数,晚几秒更新没人 care;弱一致像视频播放量,差个几万都无所谓。用错档位要么烧钱(全都强一致),要么出事故(计分也最终一致)。
先建立图像
三档一致性的行为对照:
| 级别 | 行为 | 典型场景 | 本站映射 |
|---|---|---|---|
| 强一致 | 写成功后,任何读必见最新值 | 扣款、比赛计分 | 提交状态、判题结果 |
| 最终一致 | 副本异步同步,短暂读到旧值 | 点赞、浏览量 | 排行榜、题目热度 |
| 弱一致 | 不承诺何时一致 | DNS、边缘缓存 | CDN 上的静态题目描述 |
主从复制下的读写分离是最终一致的最小样本:
写:Client → Primary(MySQL 主库)
复制:Primary --异步 binlog--> Replica(从库,延迟通常 10ms~1s)
读:Client → Replica
风险窗口:写主成功 → 从库还没同步 → 立刻读从 → 读到「PENDING」旧状态
CAP 定理一句话:网络分区(P)在分布式里必然发生,分区发生时只能在一致性(C)和可用性(A)里选一个。
CP:分区时拒绝服务,保证数据正确 —— ZooKeeper、etcd、银行
AP:分区时继续服务,接受短暂旧数据 —— Eureka、Redis 主从、社交产品
CA(无 P):只有单机,分布式里不存在
本站的取舍写出来就是设计文档:
提交/判题链路:CP 倾向 —— 主从切换的 30 秒内宁可提示「系统繁忙」
也不能把一次提交判两遍计两次分(比赛作弊事故)
排行榜/浏览量:AP —— Redis/从库挂了降级返回旧值,页面照常打开
跟着做
步骤 1:搭建主从读写分离的直觉(ShardingSphere-JDBC 配置)
rules:
readwrite-splitting:
data-sources:
readwrite_ds:
static:
write-data-source-name: write_ds # 主库
read-data-source-names: read_ds_0,read_ds_1
load-balancer-name: round_robin
配置生效后,普通 SELECT 走从库,INSERT/UPDATE 走主库——最终一致的问题就在这一刻诞生:用户提交代码后立刻查状态,读走从库,可能还没同步。
步骤 2:实现「读己之写」(Read-your-own-writes)
方案 A(最常用):提交后 N 秒内,该用户的读强制走主库。
// 提交接口:写入主库后,标记「该用户接下来 3 秒读主库」
public long submit(SubmissionDTO dto) {
long id = submissionMapper.insert(dto); // 走主库
// Redis 记录:user:{id}:recent-write = 时间戳,TTL 3 秒
redis.opsForValue().set("user:" + dto.getUserId() + ":rw",
String.valueOf(System.currentTimeMillis()), Duration.ofSeconds(3));
kafkaTemplate.send("judge-task", String.valueOf(id), toJson(dto));
return id;
}
// 查询接口:读路由判断
public SubmissionDTO getStatus(long submissionId, long userId) {
boolean recentWrite = redis.hasKey("user:" + userId + ":rw");
return recentWrite ? readRouter.toPrimary(() -> mapper.selectById(submissionId))
: mapper.selectById(submissionId); // 平时走从库
}