一、本节目标
- 描述 KV store 的 Get / Put / Append 接口
- 用时序图解释 linearizability 的定义
- 说明为什么单机加锁相对容易实现强一致
二、概念回顾
- MapReduce 的任务状态机:idle → in-progress → completed
- Worker crash 后的重试依赖幂等性
- 临时文件 + 原子重命名保证输出完整性
- MapReduce 的边界:不支持迭代、实时查询
三、本节知识
1、从计算框架到存储系统
MapReduce 解决的是批量计算 问题。但分布式系统还要解决另一个问题:存储
为什么需要分布式存储?
-
数据太多,单机放不下
-
访问量太大,单机处理不了
-
单机故障导致数据丢失
2、分布式存储的两大基本技术
| 技术 | 作用 | 比喻 |
|---|---|---|
| 分区(Partitioning / Sharding) | 把数据拆开存到不同节点 | 一本大书撕成多份,每人拿一份 |
| 复制(Replication) | 把数据复制多份存到不同节点 | 同一本书印多本,存不同地方 |
3、分区
为什么要分区?
答:一台机器放不下。按某种规则把数据分到不同节点,每个节点只负责一部分 key,查询时先确定数据在哪个节点。
| 方式 | 规则 | 优点 | 缺点 |
|---|---|---|---|
| Hash Partitioning | hash(key) % N |
数据均匀,实现简单 | 扩容要迁移大量数据,不支持范围查询 |
| Range Partitioning | 按 key 范围分段(a-m, m-t, t-z) | 支持范围查询,扩容只动边界 | 可能数据不均(热点) |
| List Partitioning | 按预定义列表分配(如按地区) | 可按业务逻辑就近部署 | 需要人工维护,可能热点 |
1)Hash Partitioning
对 key 做 hash,按余数分到不同节点
节点 = hash(key) % N
优点: 数据分布均匀;实现简单
**缺点:**在扩容时几乎所有数据都要迁移;且不支持范围查询
2)Range Partitioning
按 key 的范围分段。
节点 1: key ∈ [a, m)
节点 2: key ∈ [m, t)
节点 3: key ∈ t, z
**优点:**支持高效的范围查询;扩容时只需要迁移边界附近的数据
**缺点:**数据可能分布不均;需要维护范围映射表
3)List Partitioning
按预定义的列表分配
节点 1: 用户地区 ∈ {北京, 上海}
节点 2: 用户地区 ∈ {广州, 深圳}
**优点:**可以按照业务逻辑部署;便于地理分区
**缺点:**需要人工设定映射规则;某个列表值过多时会成为热点
4、复制
为什么需要复制(为什么需要多副本)?
答:避免机器挂了导致数据不可用(相当于备份一份);避免硬盘故障导致的数据丢失;只有一份可能会导致所有读请求打向一台机器,成为瓶颈
复制 = 同一份数据存到多个节点
| 模式 | 特点 | 代表 |
|---|---|---|
| 单主复制 | 一个 leader 处理所有写,同步到 follower | MySQL 主从、Raft |
| 多主复制 | 多个节点都能写,互相同步 | 多数据中心部署 |
| 无主复制 | 所有节点平等,客户端写多个节点 | Dynamo、Cassandra |
1)单主复制(Single Leader)
一个 leader 负责接受所有写入:
Client 写给 Leader ,Leader 再同步复制给 Follower 1 和 Follower 2 ...
优点:写入的路径简单,容易保证一致性(单主复制也是 raft 和 Mysql 主从的默认做法)
2)多主复制(Multi Leader)
多个节点都能接受写入:
Client A 写给 Leader 1 , Leader 1 异步同步给 Leader 2;
Client B 写给 Leader 2 , Leader 2 异步同步给 Leader 1;
优点:写入的性能更好;当某一 Leader 挂了后,不会影响其他区域
缺点:冲突解决复杂;很难保证强一致
3)无主复制(Leaderless)
没有 Leader ,所有节点平等
Client 写入 节点1
Client 写入 节点2
Client 写入 节点3
写入时发给多个节点,读取时也从多个节点处读取
总结:单主复制最简单,一致性最容易保证;多主复制和无主复制能提高可用性,但一致性更难。
5、KV store
什么是 KV store?
答:即Key → Value 的映射,类似于一个大号的 map[string]string
三个基本接口:
| 操作 | 含义 | 比喻 |
|---|---|---|
Get(key) → value |
读取 key 对应的值 | 查字典 |
Put(key, value) → void |
写入/覆盖 key 的值 | 往字典里写词条 |
Append(key, value) → void |
把 value 拼到已有值后面 | 在词条后面追加内容 |
简单记:Get:读key对应的value;Put:覆盖原来的value;Append:追加写入新的value
KV 是最简单的存储接口,但真实系统里有更多形态(SQL、文档、列式、图数据库)。不过不管哪种形态,都要回答一致性问题。
四种常见的存储类型
| 存储类型 | 数据组织方式 | 代表产品 | 典型场景 |
|---|---|---|---|
| 关系型数据库 SQL | 表(行+列),固定 schema,支持 SQL 复杂查询 | MySQL、PostgreSQL、TiDB | 金融、ERP |
| 文档型数据库 | JSON-like 文档,结构灵活,不用预定义 schema | MongoDB | 内容管理、快速迭代 |
| 列式存储 | 按列存储,适合分析型查询(只读几列,不用扫整行) | HBase、ClickHouse | 数据分析、OLAP |
| 图数据库 | 节点+边,适合查关系(好友的好友、最短路径) | Neo4j | 社交网络、推荐系统 |
不管哪种数据组织方式:多个客户端同时访问,都需要回答一致性问题。强一致和性能之间的冲突永远存在
KV 的 linearizability 定义,对其他数据组织方式同样有意义
所以以下还是从 KV 入手来讲解一致性部分内容
6、一致性(Linearizability)
1)引入
KV的单客户端,无并发的场景:
Client A: Put("course", "Distributed Systems")
Client A: Get("course") → "Distributed Systems"
先写入,再读取,没问题
但是到了并发的场景:
Client A: Put("x", "a")
Client B: Put("x", "b")
Client A: Get("x") → ???
Client B: Get("x") → ???
读到的结果可能不一样:
| 可能的执行顺序 | Client A 读到 | Client B 读到 |
|---|---|---|
| A写(a) → B写(b) → A读 → B读 | b | b |
| A写(a) → A读 → B写(b) → B读 | a | b |
| A写(a) → B写(b) → B读 → A读 | b | b |
| ... | ... | ... |
同一个程序跑两遍,可能拿到不同答案。应用层很难写对。
应用通常希望看到的是:
-
KV 服务像一台单机
-
请求按某种顺序处理
-
读到的就是最近一次写进去的值
这正是 Linearizability 要解决的问题。
2)Linearizability核心概念
什么是一致性?
定义:每个操作看起来在某个瞬间生效,这个瞬间落在调用和返回之间。
用更通俗的话说:
-
你发起一个操作(比如 Put 或 Get)
-
这个操作在"某个时间点"生效
-
这个时间点不能早于你发起的时候 ,也不能晚于你收到结果的时候
调用开始 ──────────── 返回结束
↑
生效点(必须落在这个区间里)
同时,按 real-time 顺序处理请求,一次只处理一个
3)一致性的唯一关键约束:real-time order
举例1:
A: |─ Get("x")─| (A 先完成)
B: |─ Put("x",1) ─| (B 后开始)
C: |── Get("x")→1 ──| (C 在 B 完成后开始)
| 关系 | 结论 |
|---|---|
| A 的 Get 在 B 的 Put 开始前就结束了 | A 读不到 1(A 在 Put 生效前就结束了) |
| B 的 Put 在 C 的 Get 开始前就结束了 | C 一定能读到 1 |
只有"先完成 vs 后开始"才有强制先后关系。两个操作时间上有重叠(并发),它们的顺序是可以灵活安排的。
举例2(一致性):
B: |── Put(x,2) ──|
C: |── Get → 2 ──|
D: |── Get → 2 ──|
A: |──────── Put(x,1) ────────|
- A:Put("x",1):时间窗口从最左到最右,覆盖了 C、D 的整个操作时段。
- B:Put("x",2):时间窗口早于 C 开始,更早结束。
- C:Get("x")→2:窗口在 B 结束之后、A 结束之前。
- D:Get("x")→2:窗口在 C 结束之后、A 结束之前。
为什么 D 能读到 2 ?
A 的操作时间窗口很长,只要生效时刻落在 A 自己的起止区间里就合法,我们可以把 A 真正写入x=1的生效时间安排在D 读取完成之后。
合法的全局生效序列逻辑
B(Put2) → C(Get2) → D(Get2) → A(Put1)
举例3:
A: |──── Put(x,1) ────|
B: |──── Put(x,2) ────|
C: |── Get → 1 ──| D: |── Get → 2 ──|
-
C 在 A 和 B 的操作窗口重叠期间执行 → 可以读到 1
-
D 在 B 完成后执行 → 必须读到 2
C 和 A、B 都有时间重叠 → 生效顺序可以灵活安排
4)重叠操作有灵活性
两个操作时间上有重叠 → 生效顺序可以任意排。
一个操作在另一个结束之前开始 → 也没有强制顺序。
只有先结束 vs 后开始,才有强制先后。
| 情况 | 是否有强制先后 |
|---|---|
| A 在 B 开始前已经完成 | 有,A 必须在 B 之前 |
| A 和 B 时间重叠(并发) | 没有,谁先谁后都可以 |
| A 在 B 结束前开始 | 没有,没有强制顺序 |
5)怎么判定是不是 Linearizable?
-
每个操作有一个时间窗口(调用开始 → 返回结束)
-
找一个**生效点(Linearization Point)**落在窗口内
-
所有生效点排成一个 total order(全局顺序)
-
在这个顺序下,每次 Get 读到的值 = 排在它前面最后一次 Put 写入的值
-
若能找到这样的分配 → Linearizable;否则不是
通俗来讲:你能不能让每个操作都"在一个合理的时间点生效",使得所有操作的生效顺序能解释所有 Get 读到的值?能 → Linearizable;不能 → 不是。
6)Linearizability 的工程价值
| 没有 Linearizability | 有 Linearizability |
|---|---|
| 你要考虑各种并发交错场景 | 你按顺序写代码就行 |
| 结果不确定 | 结果可预测 |
| 应用层要处理复杂逻辑 | 应用层写起来像单机 |
7)Linearizability 的代价
系统必须做更多工作来保证:请求不能随意乱序处理,通常需要某种全局协调,性能会受影响。
强一致性和高性能互相冲突
8)实现 Linearizability 的常见方式
单机:mutex 串行化
多机并发:
- 选出一个 leader 处理所有写操作(单主复制)
- 写操作同步复制到多数副本后再返回
- 读操作也从 leader 处或者 副本 中确认最新值
代表:Raft、Paxos、ZooKeeper、etcd
7、Sequential Consistency(顺序一致性)
1)Sequential Consistency 是什么 & 为什么需要 Sequential Consistency
Linearizability 很强,但也很贵。需要全局协调,性能受影响。有没有比它弱一点、但实现代价更低的一致性?
Sequential Consistency 就是比 Linearizability 稍弱的一种一致性保证。
定义:
Sequential Consistency 也要求一个 total order(全局顺序),但条件更松:
-
只要求每个进程内部的操作顺序被保留
-
不要求跨进程的 real-time ordering
和 Linearizability 对比:
| 一致性 | 要求 | 比喻 |
|---|---|---|
| Linearizability | 所有进程的操作按真实时间顺序执行 | 所有人排一个队,先来后到 |
| Sequential Consistency | 每个进程自己的操作顺序不能乱,但不同进程之间的顺序可以重排 | 每个人自己排一个队,但不同人的队可以互相穿插 |
-
Linearizability:看起来像一台机器,按真实时间顺序处理
-
Sequential Consistency:看起来像一台机器,但可能重新排列了并发的请求

2)Sequential Consistency 实现
举例:
P1: |──── Write(x,1) ────| |──── Read(x) ────| → 读到 1 或 2
P2: |──── Write(x,2) ────|
| 情况 | P1 的 Read 读到 | 合法吗? |
|---|---|---|
| 顺序:P1 Write(1) → P2 Write(2) → P1 Read | 2 | ✅ 合法 |
| 顺序:P2 Write(2) → P1 Write(1) → P1 Read | 1 | ✅ 合法 |
为什么两种都合法?
-
不管哪种顺序,P1 自己的 Write 在它自己的 Read 之前(程序顺序被保留)
-
但 P1 和 P2 之间没有 real-time 约束(它们时间有重叠)
Sequential Consistency 只关心每个进程自己的操作顺序,不关心不同进程之间的先后关系。
3)Sequential Consistency 对比 Linearizability
| 对比项 | Linearizability | Sequential Consistency |
|---|---|---|
| 看起来像什么 | 一台单机,按真实时间处理 | 一台单机,但并发请求可能重排 |
| real-time 约束 | 有(先完成的必须在前) | 没有(只关心每个进程内部) |
| 实现代价 | 高(需要全局协调) | 稍低(允许更多自由) |
| 理解难度 | 直观(真实时间) | 稍抽象 |
Linearizability:按真实时间 顺序执行。
Sequential Consistency:按每个进程自己的逻辑顺序执行,不同进程之间可以重排。
4)Sequential Consistency 用在哪
实际场景:
-
多核 CPU 的内存模型
-
某些分布式系统的弱一致性配置
-
对性能要求高、可以接受少量重排的场景
8、Causal Consistency(因果一致性)
这个比刚刚的 sequential 还要弱一点
有因果关系的操作,必须按相同顺序被看到。
P1: Write(x, 1)
P1: Write(x, 2) (2 依赖/发生在 1 之后)
P2: Read(x) → 2
P2: Read(x) → 1 ← 违反 Causal Consistency!
为什么违反?
-
P2 先读到 2,后读到 1
-
但 P2 的两个 read 有前后因果关系:先写了 1 再写的 2,违反了 Causal Consistency
特点:
| 不保证 | 说明 |
|---|---|
| 并发写操作在所有节点上顺序一致 | 并发操作没有因果关系,可以不同顺序 |
| 读操作一定能读到最新的值 | 只保证因果关系不乱,不保证读到最新 |
一致性比 Sequential Consistency 弱,但比 Eventual Consistency 强
9、Eventual Consistency(最终一致性)
最弱的一致性
没有新更新的话,最终所有副本会收敛到相同状态。不保证什么时候收敛,中间状态可以不一致。
P1: Write(x, 1) → 节点 A 确认成功
P2: Read(x) from 节点 B → 可能还是旧值(还没来得及同步)
过一会儿:节点 B 从节点 A 同步了数据,P2 再次读取就能看到 1 了
10、四种一致性对比
| 一致性级别 | 要求 | 实现代价 | 适合场景 |
|---|---|---|---|
| Linearizability | real-time 顺序 + 程序顺序 | 最高 | 金融、库存扣减 |
| Sequential Consistency | 程序顺序(不要求 real-time) | 高 | 共享内存多处理器 |
| Causal Consistency | 因果关系不乱 | 中 | 社交评论(需要看到因果链) |
| Eventual Consistency | 最终一致,中间可以乱 | 最低 | 点赞、浏览量、缓存 |
越往上,系统保证越强,应用越好写。越往下,系统限制越少,扩展性越好
应用场景:
| 业务场景 | 需要什么一致性 | 为什么 |
|---|---|---|
| 银行转账 | Linearizable | 不能多扣也不能少扣 |
| 库存扣减 | Linearizable | 不能超卖 |
| 社交点赞 | Eventual Consistency | 看到旧数据无所谓(多一个少一个不影响) |
| 商品浏览量 | Eventual Consistency | 看到旧数据无所谓 |
11、单机KV如何实现一致性(Linearizability)
1)单机KV的架构
Clients ──网络请求──→ Server
├─ mapstringstring (存数据)
└─ sync.Mutex (保护数据)
-
所有客户端把请求发给同一台服务器
-
服务器内部有一个
map存数据 -
服务器内部有一个
Mutex锁保护这个 map
2)这里Mutex的作用
确保在同一个时刻,只有一个请求能进入临界区(修改 map 的代码)。其他请求都在门外排队。
使请求串行化
每个请求获得锁的先后顺序为一个 total order
而这一个 total order 的顺序自然满足 real time order
如下顺序:
时间线:
t1: Client A 的 Put 请求到达服务器,开始等待锁
t2: Client B 的 Put 请求到达服务器,开始等待锁
t3: A 获得锁,开始执行 Put
t4: A 释放锁
t5: B 获得锁,开始执行 Put
t6: B 释放锁
-
锁强制了一个执行顺序:A → B
-
这个顺序恰好满足 real-time 约束:谁先拿到锁,谁先执行
-
所以单机 + Mutex 天然就是 Linearizable
| 没有 mutex | 有 mutex |
|---|---|
| 并发请求交错执行 | 请求串行化 |
| map 内部状态可能损坏 | 数据完整 |
| 更谈不上 linearizability | 单机自然满足 linearizability |
3)读 Get 也要加锁吗?
要,因为如果 Get 不加锁,它可能在 Put 执行到一半的时候读到数据
4)Append 也要加锁吗
Append 即为 "+="
要,因为 += 不是原子的,而是三个步骤:读 → 修改 → 写回。两个 Append 并发执行,可能交错导致数据丢失。
5)为什么单机容易做到 Linearizable
| 原因 | 解释 |
|---|---|
| 1 | 所有请求都经过同一个 CPU |
| 2 | Mutex 强制了一个全局顺序 |
| 3 | 先拿到锁的请求先执行 |
| 4 | 先执行完的效果对后开始的请求可见 |
单机:Map + Mutex = Linearizability。
12、Clerk
Clerk = client 端的代理,封装了 RPC 调用细节
| 作用 | 说明 |
|---|---|
| 知道 server 地址 | 连接哪个 IP:Port |
| 把 Get/Put/Append 转成 RPC | 本地方法调用 → 网络请求 |
| 处理网络错误 | 重试、超时等 |
Clerk 是应用端的 KV 服务本地代理
13、更弱的一致性保证(除了Linearizability)
| 保证名称 | 含义 |
|---|---|
| Read-your-writes | 自己写的数据,自己一定能读到 |
| Monotonic reads | 不会读到比以前更旧的数据 |
| Monotonic writes | 自己的写操作按发出顺序生效 |
这些比 Linearizability 弱,但实现代价小得多
14、单机的局限性
- 物理故障出现 ------ 机器挂了,数据全丢
- 一台机器的处理能力有限
- 网络隔离时无法使用
- 扩展性问题
- CPU 有上限
- 网络带宽有上限
- 内存有上限