加载中...

这一讲难在它不是一条线,是四条线并排放着。
选型(三类数据库)· 事务(乐观锁)· 主从(复制)· 分布式(切分)——
它们解决的是四个不相干的问题,原文按顺序讲下来,很容易读成"数据库的四个特性"。

先把这张表刻进去,后面就不会串

这一节 解决什么问题 它的反面是什么
一 · 三类数据库 怎么用——使用界面之争 不是能力之争,三者能干的事高度重叠
二三 · 事务 并发和崩溃会不会弄脏数据 不解决容量、不解决机器坏
四 · 主从 机器会坏(可用性 + 持久性) 每台存全量,不解决放不下
五六 · 分布式 放不下 / 扛不住(容量 + 吞吐) 每台存一部分,不解决机器坏

主从和分片是垂直的两件事,真实系统两个都要。 这是全篇最容易混的一点,第五节会画图。

这一讲的演示脚本(都可以直接 python3 跑):

脚本 演什么 配合哪节
代码/乐观锁.py 版本链 · 撞车回滚 · 幻读(为什么必须记读条件)
代码/断网那一刻.py 拔网线后 CP 与 AP 各自发生什么 · 过半数与脑裂 四 · 六
代码/一个字典拆成三台.py 分片到底是什么——一个大 dict 拆成三个 + 一个函数 五(先跑这个)
代码/分片.py 三种分片函数,扩容各要搬多少数据(真实数字) 五(再跑这个)

一 · 三类数据库:同一个东西的三种「使用界面」

原文按"关系型 / 文档型 / KV"列了三类。它们的差别不在能干什么,在怎么用——这正是 32 讲那个"使用界面"的话题。

1.1 强 schema vs 无 schema,就是 dataclass vs dict

# 关系型:强 schema —— 写错了当场报错
@dataclass
class 用户:
    名字: str
    年龄: int

用户(名字="小王", 年龄="二十")     # 你至少能靠类型检查揪出来

# 文档型:无 schema —— 写错了照收不误
{"名字": "小王", "年龄": "二十"}   # 存进去了,三个月后才发现
{"姓名": "小李", "岁数": 20}       # 字段名都改了,也存进去了

原文那句"质量保障体系弱化,数据可能被弄脏而不自知",说的就是第二段。
好处也是真的:加字段不用改表结构。这就是它的全部取舍。

1.2 「KV 是数据库的基础」是什么意思

原文这句最容易滑过去:

“从实现角度来说,键值存储是数据库的基础。每一组数据库的索引,往往背后就是一组键值存储。

拆开就是——一张有两个索引的表,物理上是三个 KV

# users 表:主键 id,另外在 邮箱 和 年龄 上建了索引

主键KV = { 1001: {"名":"小王","邮箱":"w@x.com","岁":20},      # ← 数据本体在这
           1002: {"名":"小李","邮箱":"l@x.com","岁":31} }

邮箱KV = { "w@x.com": 1001,                                   # ← 索引 = 另一个 KV
           "l@x.com": 1002 }

年龄KV = { (20, 1001): None,                                  # ← 有序 KV,才能范围扫
           (31, 1002): None }

SELECT * FROM users WHERE 邮箱='w@x.com' 实际发生的是:先查邮箱KV 拿到 1001,再查主键KV 拿整行。两次 KV 查找。

这一下就解释清楚了原文那两句看似矛盾的话:

  • 使用上,KV 是数据库的特例”——数据库能建多个索引,KV 只有唯一索引(就是上面的"主键KV")
  • 实现上,KV 是数据库的基础”——数据库的每个索引都是一个 KV,数据库 = 一组 KV + 一个查询规划器

二 · 事务:它到底在防什么

文字版的完整推导已经写在 笔记 · 为什么:事务这一节到底在说什么 里了,这里不重复。
这一节只留一张必须刻住的表,然后直接进脚本。

转账,A -= 50B += 50 这两行之间有两种灾难:

灾难 长什么样 谁来防
崩溃 第一行跑完进程挂了 → 50 块凭空消失 A 原子性(要么都算数要么都不算)
D 持久性(说了成功就不许因断电而回退)
并发 别人恰好在两行中间查账 → 总额对不上 I 隔离性(中间状态不给别人看见)
—— —— C 一致性 = 不是机制,是结果:A+B 永远等于 800

ACID 不是并列的四件事。A/D 对付崩溃,I 对付并发,C 是前三者做到之后想要的那个结果。
原文四条并排列,最容易让人以为要分别实现四个东西。

而"一把大锁"(with 全局锁: 包住整个事务)确实能实现完整 ACID,问题是那个 with 里面含磁盘 IO、业务逻辑、甚至跨机器网络往返——这段时间全世界排队,吞吐掉成 1 ÷ 一个事务的耗时


三 · ★ 乐观锁 = 把 git 那套搬进数据库

python3 代码/乐观锁.py
悲观锁(常规锁) 乐观锁
打比方 改文件前先把文件锁死,别人不许碰 各自在自己分支上改,提交时才检查撞没撞车
顺序 先互斥 → 再改 先改(改在草稿上)→ 提交时检查 → 没撞车才生效
代价 不管有没有冲突,都先付出等待 冲突罕见时几乎零成本;真撞车了整个事务白做、重来
数据库里的说法 git 里的对应物
KEYi -> [(VER0,VAL0),(VER1,VAL1)] 版本链 一个文件的 commit 链
修改先进"草稿",别人读不到 在自己分支上 commit,还没 push
提交时检查读条件 push 前看远端有没有人动过我依赖的东西
撞车 → 整个事务回滚 push 被拒 → 重来

3.1 脚本里的三个场景

场景一 · 改不同的行     → 两个事务同时跑,都成功,全程零互斥
场景二 · 抢同一行       → 后提交的那个【整个白做】,版本链里什么都没留下
场景三 · 幻读           → 同一段代码,只记 KEY 会放过去,记条件才拦得住

场景三是全篇最反直觉的一段,脚本把两种做法各跑了一遍:

做法:只记KEY
  [T1] 读「岁>17」→ 命中 3 行 [1, 2, 3]
  [T2] 改行4 = {'名': '小赵', '岁': 20}  →  提交成功
  [T1] 检查方式:只查我读过的行 [1, 2, 3]
           ✓ 没撞车 → 转正
  ✗ T1 提交成功了,但现在成年人其实有 4 个

做法:记条件
  [T1] 检查方式:扫全库,查所有满足「岁>17」的行
           ✗ 撞车!行[4] 的已提交版本 > 我的基版本 v0

我读过的行一个都没变,但我读到的那个「集合」变了。 这个现象叫幻读(Phantom Read)
所以原文说:要记的不是读过哪些 name/age/address,而是"我读过所有 age>17 的条目"。

3.2 三个最容易读拧的点

原文那句 读拧成 其实是
“提交的时候,锁住整个数据库” 那不还是一把大锁? 差别在锁多久,不在锁多大。大锁横跨整个事务(含 IO、网络);这里只锁住纯内存的版本号比对,微秒级。原文"把锁的粒度降到最低",降的是时间粒度
“避免了出现死锁的可能” 乐观锁有什么魔法 死锁的成因永远是"我拿着 A 等 B、你拿着 B 等 A",成环。乐观锁全程只有一把锁,提交那一瞬间拿、检查完就放,不存在持有一把锁再去等另一把——没有环可以成
“必然有一个事务的提交会成功” 我的事务不会被饿死 保证的是系统级不饥饿(总有人前进),不是个体级。热点行 + 慢事务,完全可能一次次撞车一次次重来。这就是乐观锁的适用边界:冲突罕见才划算

四 · 主从:复制,解决"机器会坏"

python3 代码/断网那一刻.py     # 看第二部分

心智模型:主从 = 复印机。每台机器存的是全量数据,一模一样。
它跟容量、跟吞吐都没关系,它只回答一个问题:这台机器坏了怎么办。

4.1 为什么选主要「超过半数」

原文只说了"超过一半的节点同意",没说为什么。因为要防脑裂

网络一断,两边各选一个主,各自接受写,数据从此分叉——两张 ok 回执,必有一张是假的

"过半数"让这件事在数学上不可能:

5 个节点,过半 = 3 票,网络把它们劈成两半:
    1 : 4   →   左边选不出,右边能选出主
    2 : 3   →   左边选不出,右边能选出主
    3 : 2   →   左边能选出主,右边选不出
    4 : 1   →   左边能选出主,右边选不出

两个都"超过半数"的集合必然相交,而同一个节点不可能同时投给两个主。
脚本穷举了所有组合:反例 0 种

4.2 为什么节点数要奇数

节点数 过半需要 最多能挂几台
2 2 0 主挂了就选不出新主
3 2 1
4 3 1 和 3 台一样,白加一台
5 3 2
6 4 2 和 5 台一样,白加一台

4 台的容错能力和 3 台完全一样,你却多花了一台机器的钱、还多了一台会坏的机器。
偶数那台永远是白给的。

4.3 为什么"写要确保至少一个从收到了"

假设主写完就直接回 ok,然后主立刻宕机 → 所有从都没有这条数据 → 选谁当新主都是版本回退

用户手里那张 ok 回执,又变成假的了。
这条约束、CAP 里选 C、36 讲的"绝不丢数据",说的是同一件事:承诺过的,必须活过任何单点故障。


五 · ★ 分布式:一个大 dict 装不下了

python3 代码/一个字典拆成三台.py     # ← 先跑这个,只讲分片本身
python3 代码/分片.py                 # ← 再跑这个,讲函数怎么选

不要从"哈希分片 vs 范围分片"开始读这一节。 那是第 5.5 小节的事。
先把 5.1~5.4 这四步走完,分片这件事本身其实极简单。

5.1 一台机器的时候

36 讲的结论是:数据库 ≈ 一个能落盘的 dict

用户表 = {"user01": "小王", "user02": "小李", ...}
用户表["user05"]        # → 小刘

现在用户从 9 个变成 1 个亿。一台机器装不下了。

5.2 装不下就拆开 —— 这就是「分片」

机器 = [{}, {}, {}]      # 三台,每台只存一部分

拆完只多出一个新问题:user05 该存进哪台?取的时候又去哪台找?

总不能三台挨个问(那还不如不拆)。所以需要一个函数

def 该找哪台(key):
    return int(key[-1]) % 3      # 拿末位数字对 3 取余

存的时候用它算,取的时候还用它算——同一个 key 过同一个函数,永远算出同一台,所以直奔那台就行。

0 号机: {'user03': '小张', 'user06': '小陈', 'user09': '小周'}
1 号机: {'user01': '小王', 'user04': '小赵', 'user07': '小杨'}
2 号机: {'user02': '小李', 'user05': '小刘', 'user08': '小黄'}

取 user05 → 函数算出 2 号机 → 去 2 号机拿到「小刘」

★ 分片的全部内容就这一句:
一个大 dict → N 个小 dict + 一个「该找哪台」的函数。

而原文那个"哈希分片 / 范围分片",只是这个函数的两种写法

def 该找哪台(key):  return hash(key) % 3            # 哈希分片:答案是【算】出来的
def 该找哪台(key):  return 0 if key < "m" else 1    # 范围分片:答案是【查】出来的

它们不是两个新知识点,是同一个位置的两种填法。

5.3 加一台机器,为什么就要搬数据

三台不够,加到四台。函数必须跟着改

return int(key[-1]) % 3      →      return int(key[-1]) % 4

函数一改,很多 key 算出来的答案就变了。答案变了,那条数据就必须搬到新答案那台去,否则以后再也找不到它。

key       老家(%3)   新家(%4)
user01       1         1
user02       2         2
user03       0         3     ← 要搬
user04       1         0     ← 要搬
user05       2         1     ← 要搬
...                             9 个里有 7 个要搬

★ 扩容要搬数据,不是因为"分布式很复杂",就是因为那个函数变了。
搬多少 = 有多少 key 的答案变了 = 完全取决于函数怎么写。
这就是原文那句"扩缩容会导致重新分片,重新分片意味着需要做数据的搬迁"的全部含义。

5.4 分片 ≠ 主从:它俩拆的是不同的东西

主从(第四节):三台存的是【同一个 dict 的三个复制品】
      机器A = {全部 9 个}
      机器B = {全部 9 个}     一模一样
      机器C = {全部 9 个}
      ✓ A 坏了还有 B、C          ✗ 每台还是得装下全部

分片(本节):三台存的是【同一个 dict 的三块】
      0号机 = {3个}   1号机 = {3个}   2号机 = {3个}
      ✓ 装得下了                  ✗ 坏一台呢?

脚本把 2 号机的硬盘"烧掉"了:

取 user02 → 2 号机 → ✗ 查无此人(数据没了)
取 user05 → 2 号机 → ✗ 查无此人(数据没了)

★ 分片一点也不解决可靠性。
这就是原文最容易读漏的那个词——“分片服务器一起构成一个单副本的数据库”:
三台加起来只是一份数据,不是三份。

所以真实系统是两个叠着用:

    分片1        分片2        分片3      ← 竖着切:解决装不下 / 扛不住
     │            │            │
┌────┼────┐  ┌────┼────┐  ┌────┼────┐
主   从   从  主   从   从  主   从   从   ← 横着复制:解决机器会坏

9 台机器,3 份数据。

5.5 函数怎么选:三种写法的三个后果

既然分片 = N 个小 dict + 一个函数,所有设计选择就都集中在这一个函数上

写法一 · 哈希——答案是出来的

def 该找哪台(key):  return hash(key) % N

hash 把 key 彻底打散,分得很均匀 ✓。代价是相邻的 key 被打到了不同机器:

  • user004500 ~ user004600 这种范围,得问遍所有机器再拼结果 ✗
  • N 一变答案几乎全变 → 扩容搬 75% ✗

写法二 · 范围——答案是出来的

def 该找哪台(key):
    if   key < "user004000": return 0
    elif key < "user008000": return 1
    else:                    return 2

相邻的 key 在同一台,范围查询只问一台 ✓。扩容就是在末尾插一条 if(把一片劈成两半),只搬那一片 ✓。

代价是:key 如果递增(时间戳、订单号、日志 ID),新数据永远落最后一片。分片.py 跑出来的:

今天新写入 1440 条:  机器0: 0 条    机器1: 0 条    机器2: 1440 条

另外两台在睡觉。 这叫热点。

写法三 · 一致性哈希(原文没讲,是工业界对写法一那个 75% 的答案)

思路一句话:别让答案依赖 N。把 key 和机器都映到同一个环上(0 ~ 2¹²⁸),key 顺时针遇到的第一台机器就是它的家。加一台机器 = 在环上插一个点,只有它到前一个点之间那段的 key 换家 → 搬 1/N。

哈希 范围 一致性哈希
分得均匀 ✗ 看 key 长什么样 ✓ 靠虚拟节点
范围查询 ✗ 问遍所有台 ✓ 只问一台 ✗ 问遍所有台
递增 key 会热点 ✓ 不会 ✗ 全压最后一片 ✓ 不会
3→4 台要搬(12000 个 key 实测) 75.2% 16.7% 23.4%
谁在用 MySQL 分库分表 HBase、TiDB Redis Cluster、Cassandra

没有"最好的分片函数",只有"你的查询长什么样"。
要按时间区间翻账单(对账、日志检索)就得范围分片然后忍热点;
只按 ID 点查、写入量大又怕热点(用户表、会话)就用哈希然后忍搬迁。

5.6 搬家本身怎么搬

5.3 说"要搬 75%",但搬的过程才是硬骨头——因为不能停机搬。

设想正在把 user05 从 2 号机搬到 1 号机,就在这一刻:

来了个… 麻烦在哪
user05 该问 2 号机(还没搬完)还是 1 号机(已经搬了)?
user05 写 2 号机?它马上要被删。写 1 号机?可读还在走 2 号机
进程挂了 两边各有一半,重启后怎么知道搬到哪了

原文那句"有一部分数据在源节点,一部分数据在目标节点",说的就是这个。工程上的标准解法是双写 + 灰度切读

阶段1   读走源  ·  写同时落源和目标        ← 目标在后台追数据
阶段2   数据追平 → 读切到目标 · 写仍双落   ← 目标出问题能立刻切回来
阶段3   确认没事 → 停掉源

这跟 36 讲上线新存储时的"双写"是同一个手法:不敢一步切换,就让新旧并行一段,
始终留一条能撤回的路。


六 · ★ CAP:把「机器之间总能说上话」这个假设拿掉

python3 代码/断网那一刻.py     # 看第一部分

前面五节一直有个隐含假设:机器之间的网络是通的。CAP 就是把这个假设拿掉。

6.1 先定位 CAP 在哪一层

这是最关键的一句,先看图:

    分片1        分片2        分片3      ← 横排:存的是【不同的】数据
     │            │            │
┌────┼────┐  ┌────┼────┐  ┌────┼────┐
主   从   从  主   从   从  主   从   从
└─ CAP 说的是这一竖列 ─┘              ← 竖列:存的是【同一份】数据的拷贝
  • 横排的分片之间根本不需要一致——它们各管各的一块,本来就不一样
  • 竖列的主从之间才需要一致——它们是同一份数据的复制品,必须一样

所以 CAP 是第四节(主从)那一层的问题,不是第五节(分片)的。
分片只是让它更常见:机器越多,网线越容易断。

6.2 断网那一刻的两种结局

CP —— 保一致,牺牲可用
  ✂ 拔网线
  往【甲】写 200 → ✗ 拒绝:我联系不上另一半,不敢答应你
  读甲 → 100    读乙 → 100        两边一致,但谁都写不进去

AP —— 保可用,牺牲一致
  ✂ 拔网线
  往【甲】写 200 → ✓ ok        往【乙】写 300 → ✓ ok
  读甲 → 200    读乙 → 300        都能用,但两张 ok 里必有一张是假的

6.3 「三选二」这个说法哪里不准确

常见读法:“C、A、P 三个里挑两个。” —— 听起来像是可以主动放弃 P

但 P(网络分区)不是选项,是会发生的事实。 只要数据在多台机器上,网线就会断、交换机就会挂。所以你永远"选了 P"。

真实的选择只有一个,而且只在断网那一刻才发生

网线断了 →  拒绝服务(保 C 弃 A)  还是  返回可能过期的数据(保 A 弃 C)?
业务 倒向 因为
转账、库存扣减、支付 C 宁可报错,不能算错
点赞数、浏览量、推荐列表 A 看到旧数字无所谓,看不到页面才是事故
用户资料、商品详情 看用途 展示可以旧,拿去做逻辑判断就不行(原文"非敏感场景才能读从节点"是同一个判断)

原文说"通常不放弃 A,于是在 C 和 P 之间权衡"——更准确的读法是:P 躲不掉,你只能预先决定分区发生时倒向哪边。

AP 那个结局正好撞上 36 讲的底线:“服务端不允许 ok 是假的”。
选 AP 就是明知会产生假 ok,赌这类数据假一会儿没关系。
这个赌注只能由业务下,架构师不能替业务下。


七 · ★ 引申:把四节接起来——跨分片的事务有多难

⚠️ 本节是引申,不是原文内容。 但它是这四节唯一的交汇点,也是原文那句"分布式数据库很讨厌事务"的落地。

第三节那把"提交时才拿的全局锁",前提是所有数据都在同一台机器的同一块内存里

一旦分片(第五节),事务要改的两行落在了 3 号机和 7 号机:

  单机事务:    with 全局锁:  比对版本号  →  微秒级
  跨片事务:    3号机: 准备好了吗? ──网络──▶  等
                7号机: 准备好了吗? ──网络──▶  等
                两边都说好了 → 再各发一次"提交"
                                            ← 这就是两阶段提交 2PC

问题出在中间那段等待

  • 网络往返把"微秒级"变成"毫秒级",锁的时间涨了上千倍
  • 更糟的是,如果协调者在"都说好了"和"发提交"之间挂掉——两台机器都锁着资源,谁也不敢动(2PC 的阻塞问题)
  • 而网络分区(第六节)保证了这件事一定会发生

所以工程上最实用的一条不是"用什么分布式事务算法",是"想办法别跨分片"
按用户 ID 分片,让一个用户的所有数据落在同一片——转账在同行内、订单和订单项在一起。
这样绝大多数事务退化回单机事务,第三节那套原封不动能用。

这也回头解释了 5.5 那张表为什么重要:分片键怎么选,决定的不只是数据搬多少,更决定了你的事务能不能留在一台机器上。


八 · 验收

# 问题 答案在
1 主从和分片各解决什么问题?能不能互相替代? 四 / 五
2 哈希分片 3→4 台要搬 75% 的数据,为什么这么多?范围分片搬得少,代价换到哪去了?
3 乐观锁"提交时锁住整个数据库",跟"一把大锁"到底差在哪?
4 为什么事务要记录读条件,而不是读过哪些 KEY?
5 选主为什么要超过半数?为什么 4 个节点不如 3 个?
6 "CAP 三选二"这个说法哪里不准确?
7 为什么说 KV 既是数据库的"特例"又是它的"基础"?
8 CAP 讨论的是分片之间还是主从之间的事?为什么?

答案

1. 不能替代,两件事垂直。
主从 = 复制,每台存全量,解决"机器会坏"(可用性 + 持久性);
分片 = 切分,每台存一部分,解决"放不下 / 扛不住"(容量 + 吞吐)。
原文"分片服务器一起构成一个单副本的数据库"就是提示:分片不产生副本。
真实系统是 3 分片 × 3 副本 = 9 台机器、3 份数据。

2. 因为 hash(key) % 3% 4 的结果几乎无关,机器数一变全班重新排座位
范围分片扩容只是劈开一片,所以只搬那片的一半(16.7%)。
代价换成了两样:分布不均(看 key 怎么长)和热点——
key 只要是递增的(时间戳、订单号),新写入永远只压最后一片
一致性哈希是折中:搬 1/N(23.4%),但也没有范围查询的能力。

3. 差别在锁多久,不在锁多大。
“一把大锁"横跨整个事务,期间要做磁盘 IO、跑业务逻辑、甚至等网络往返;
乐观锁只在提交那一瞬间拿锁,做一次纯内存的版本号比对,微秒级。
原文说的"把锁数据库的粒度降到最低”,降的是时间粒度

4.幻读。只记 KEY 的话:我读了"age>17 的 3 个人"并据此决策,
另一个事务插入了一个 age=20 的新人。提交时检查我读过的那 3 行——一个字都没改过,通过
可结论已经错了(现在是 4 个人)。
我读过的行没变,但我读到的那个"集合"变了。 只有记下条件本身,新插入的行才会落进检查范围。

5. 为了防脑裂——网络一断,两边各选一个主、各自接受写,数据从此分叉。
"过半数"让它在数学上不可能:两个都超过半数的集合必然相交,而一个节点不能同时投两个主
4 个节点过半需要 3 票,最多只能挂 1 台,和 3 个节点完全一样——
多花一台机器的钱,多一台会坏的机器,容错能力一点没涨。

6. 因为 P 不是一个可以挑的选项,它是会发生的事实
只要数据在多台机器上,网络就会断。所以你永远"选了 P"。
真实的选择只在断网那一刻发生:拒绝服务(保 C 弃 A),还是返回可能过期的数据(保 A 弃 C)
而且这个选择由业务下:钱和库存倒向 C,点赞和展示倒向 A。

7. 使用上是特例:数据库可以建多个索引,KV 只有唯一索引。
实现上是基础:数据库的每一个索引都是一个 KV
一张有两个索引的表 = 主键KV(存整行)+ 两个索引KV(存"索引值 → 主键")。
WHERE 邮箱='x' 实际是两次 KV 查找。数据库 ≈ 一组 KV + 一个查询规划器。

8. 主从之间。 分片存的是不同的数据,各管各的一块,本来就不一样,无所谓"听谁的";
主从存的是同一份数据的复制品,必须一样——"一致性"这个问题只在这里成立。
分片只是让网络分区更常见(机器越多网线越容易断),但它本身不制造一致性问题。


下一步

38 讲:对象存储——存储中间件清单上的下一项。

回看:36 讲 · 存储中间件的由来(四堵墙 · 存储即数据结构)

公告栏
这是我的个人知识库。
记录技术,也记录生活 —— 读过的、试过的、想明白的,都堆在这儿。
最新文章
网站资讯
文章数目 :
5
已运行时间 :
本站总字数 :
15.7k
本站访客数 :
本站总访问量 :
最后更新时间 :
全局知识图谱
当前页面 已访问 文章 标签
ESC 关闭 · 滚轮缩放 · 拖拽移动 · Ctrl+G 开关