Go语言分布式计算(单机KV与一致性)

一、本节目标

  1. 描述 KV store 的 Get / Put / Append 接口
  2. 用时序图解释 linearizability 的定义
  3. 说明为什么单机加锁相对容易实现强一致

二、概念回顾

  1. MapReduce 的任务状态机:idle → in-progress → completed
  2. Worker crash 后的重试依赖幂等性
  3. 临时文件 + 原子重命名保证输出完整性
  4. 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?
  1. 每个操作有一个时间窗口(调用开始 → 返回结束)

  2. 找一个**生效点(Linearization Point)**落在窗口内

  3. 所有生效点排成一个 total order(全局顺序)

  4. 在这个顺序下,每次 Get 读到的值 = 排在它前面最后一次 Put 写入的值

  5. 若能找到这样的分配 → 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 有上限
    • 网络带宽有上限
    • 内存有上限