CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
系统设计入门/课时/一致性与 CAP
RawBlame
课时目录12 节

系统设计入门

  1. 01系统设计是什么
  2. 02性能与扩展基础
  3. 03一致性与 CAP
  4. 04负载均衡
  5. 05缓存模式
  6. 06异步与消息
  7. 07数据架构
  8. 08微服务与网关
  9. 09云设计模式
  10. 10可靠性模式
  11. 11性能反模式
  12. 12综合练习

一致性与 CAP

strong/eventual/weak consistency、CAP 取舍。

35 分钟

一致性与 CAP

这一课你将能做到

  1. 用强一致 / 最终一致 / 弱一致三档,给本站任意 API 标注一致级别并说明理由
  2. 讲清 CAP 定理在工程中的真实含义(P 必选,实际在 C 和 A 之间取舍)
  3. 在 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);  // 平时走从库
}

方案 B:状态查询这种小数据干脆全读主库(判题状态单行查询,主库完全扛得住),只把「历史提交列表」放从库。不是所有读都要分流,这是初学者最常见的误区。

步骤 3:把提交接口做成幂等(为异步重试兜底)

至少一次投递(消息队列必然重复)+ 幂等消费,是一致性的左膀右臂:

CREATE TABLE submission (
    id           BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_id      BIGINT NOT NULL,
    problem_id   BIGINT NOT NULL,
    client_token VARCHAR(64) NOT NULL COMMENT '前端生成的唯一请求 ID',
    status       VARCHAR(16) NOT NULL DEFAULT 'PENDING',
    score        INT,
    UNIQUE KEY uk_user_token (user_id, client_token)
) ENGINE=InnoDB;
try {
    submissionMapper.insert(dto);          // 重复提交触发唯一键冲突
} catch (DuplicateKeyException e) {
    return submissionMapper.selectByToken(dto.getUserId(), dto.getClientToken());
    // 返回已有结果,用户无感知 —— 这就是幂等
}

步骤 4:给旁路数据补最终一致的手段

排行榜读的是 Redis ZSet 预聚合数据,判题完成后由消费者异步加分——中间有 1~2 秒不一致。补偿手段三件套:对账任务(每 10 分钟比对 MySQL 聚合与 Redis 差值)、事件重放(Kafka 消息保留 24 小时可重消费)、前端容忍(显示「数据可能有分钟级延迟」)。

讲清楚要点

  1. CAP 的 P 不是选项:网络分区必然发生,所谓选型是「分区发生那一刻,保正确(拒写)还是保可用(容旧读)」。把 CAP 背成「三选二」在面试里会显得没做过分布式。
  2. 一致性级别按业务后果选,不按技术偏好选:判断标准是「读到旧数据的业务后果」——计分错误是不可接受事故,排行榜慢 5 秒无人察觉。
  3. 读己之写是最常用的折中:用户对自己写的数据立刻可见,对别人的数据接受延迟。会话粘滞、时间窗标记、关键字段读主库都是实现方式。
  4. 幂等是一切异步一致性的地基:消息会重复、请求会重试,没有唯一键约束的写入迟早产生脏数据。幂等三板斧:唯一索引、去重表、版本号。
  5. 主从延迟不是「优化掉」而是「绕开」:延迟由复制机制决定(通常毫秒级,高峰秒级),工程手段是路由策略(读主/粘滞)+ 产品文案(「结果生成中」),而不是试图让复制零延迟。

你可能会懵的地方

  • CAP 里 C 和 ACID 的 C 是一个意思吗? 不是。ACID 的 C 指数据库内约束不被破坏(余额不能为负);CAP 的 C 指多副本线性一致(所有节点同一时刻看到同一份数据)。前者是单机事务概念,后者是分布式副本概念。

  • Redis 当缓存算违反一致性吗? 缓存是「副本加速层」,读到旧值就是最终一致的体现。所以缓存课里要先和业务确认:题目详情旧 1 分钟可以,判题结果旧 1 分钟不行——后者根本不该进缓存或必须写后主动失效。

  • BASE 是什么,和 CAP 什么关系? BASE(基本可用、软状态、最终一致)是 AP 路线的工程化口号。CAP 说「分区时只能选 A」,BASE 回答「选了 A 之后怎么把不一致收敛回来」——靠异步同步 + 补偿。

  • MySQL 半同步复制是不是就强一致了? 半同步只保证 binlog 至少到一个从库,不保证从库已回放、更不保证所有副本一致。主库宕机切换仍可能丢最后几笔事务。真要强一致用 MGR/分布式数据库(Paxos/Raft 组提交)。

  • 为什么不像 Google Spanner 那样全站强一致? 能,但每次写都要跨副本共识,延迟和成本翻数倍。判题平台 90% 的读是排行榜和题目浏览,为它们付强一致的代价不划算——取舍思维比技术崇拜重要。

错法对照

错法问题正解
所有读全走从库「提性能」用户读不到自己刚写的数据读己之写:关键字段读主库或 3 秒粘滞
靠「等 1 秒再读」解决主从延迟高峰期延迟不可控,纯赌运气显式路由策略(标记/粘滞),不靠 sleep
消息消费者不做幂等至少一次投递必然重复,计分翻倍唯一键 + 冲突时查回返回
把排行榜做成每次实时聚合 MySQL数据量一大 DB 直接拖死ZSet 预聚合 + 异步更新 + 对账
主从切换后假设数据零丢失异步复制可能丢尾部事务关键链路声明 RPO,用半同步/MGR 收紧

卡点急救

  1. 用户反馈「提交后看不到刚提交的记录」:先查是不是读走了从库(开启 SQL 日志看路由),然后落地读己之写:提交后置 Redis 写标记,或该接口直接读主库。
  2. 主从延迟突然拉到几十秒:看从库是否在跑大事务/大查询(SHOW SLAVE STATUS 的 Seconds_Behind_Master),常见根因是从库上跑运营报表——报表迁到专门的分析库,从库只服务线上读。
  3. 出现重复计分:顺着链路查三处——Kafka 是否重复消费(看 offset 提交时机)、提交接口是否可重复触发(有没有唯一键)、消费失败重试是否无幂等。修复顺序永远是先加唯一索引兜底,再优化链路。

面试常问

  • 讲讲 CAP 定理,你的项目里怎么取舍? 先纠正表述(P 必然存在,实际选 C 或 A),再给业务实例:判题计分链路 CP(宁拒勿错),排行榜 AP(容旧可用),最后补一句「CAP 只约束分区时刻,日常运行靠副本同步质量保证最终一致」。这个三段式回答比背定义高一个档位。

  • 主从架构下如何保证用户读到自己的写入? 答三选一或组合:① 关键读路径直读主库(数据量小、频率低时最优);② 写后短时间窗路由粘滞(Redis 标记);③ 前端交互规避(提交后本地乐观展示,轮询从库)。同时补一句「监控主从延迟,超阈值告警甚至读切主」。

  • 为什么消息队列一定会有重复消息,怎么处理? 生产者重试、broker 分区重平衡、消费者超时未提交 offset 都会造成重复,投递语义实际是 at-least-once。处理靠消费端幂等:唯一索引、Redis 去重表(SETNX message-id)、或状态机判断(PENDING 才处理)。

自测三问

  1. 给本站三个接口标注一致级别:提交状态查询、题目详情页、全站排行榜,并各给一句理由。
  2. CAP 定理的正确表述是什么?为什么说「三选二」是误读?
  3. 写出幂等提交的表结构要点,并说明至少一次投递为什么必然产生重复。

小结一句话

一致性没有免费档:强一致花的是延迟和可用性的钱,最终一致花的是产品容忍度和补偿逻辑——用业务后果决定每个数据该站哪一档。

延伸

  • CAP 定理(Gilbert & Lynch 证明版):原文 Brewer 的猜想 与正式证明
  • 《Designing Data-Intensive Applications》第 5、9 章:一致性与复制的权威论述
  • ShardingSphere-JDBC 读写分离文档:本课动手环节使用的组件
  • 下一课:负载均衡——让多实例真正「多」起来:四层七层、五种算法与一致性哈希。
加载本节练习…
← 上一课性能与扩展基础下一课 →负载均衡