📊 第3章 分布式处理的基础知识
📄 一句话概括

存储的解法是分片:100 万条按单号分 4 片,最大最小只差 0.5%。 分片键必须稳定——用内置 hash 每次跑分布都不一样,重启一次数据全跑错片。 分片有代价:跨片的汇总要多走一轮;网络的解法是别搬,把计算送到数据那里。

📝 本章和前面几章的关系

上一篇讲瓶颈怎么找。这一篇讲找到了怎么办。 教材 3.3、3.4、3.5 三节:分布式存储、分布式计算、分布式系统的网络。三节各自解一处瓶颈。 本篇把它们并成一篇,因为它们的思路是同一条:把一件事拆开,让多台机器分担。 但拆开的代价各不相同,这才是要看清的地方。

一、存储:把数据切开

磁盘满了,最直接的办法是把数据摊到多个盘、多台机器上。这叫分片。 怎么摊?按某个键算个哈希,模上片数,得到片号。 演示里 100 万条按单号分 4 片:249184、250380、250033、250403。 最大最小差 0.5%。哈希分区的好处就在这儿:只要键分布均匀,切出来的片就一样大。

但键不一定均匀。 演示换成分区键是城市,只有 6 个城市,4 个片的结果是 333333、333334、166666、 166667——有两片是别人的两倍。 这是分片最根本的限制:分片键的取值个数,决定了最多能切几片。6 个城市最多切 6 片, 再多就是空片。

二、分片键要稳,别用内置 hash

演示里还试了 Python 的内置 hash():四次结果每次都不一样,有一片甚至一条都没有。 原因是从 Python 3.3 起,字符串哈希加了随机种子(防哈希碰撞攻击)。 同一个字符串,这次进程和下次进程算出来的 hash 不同。 在单机上这没问题。 但如果用它算分片号——重启一次,同样的数据就跑到别的片去了,结果就是「数据找不到了」。

所以做分区必须用稳定哈希:md5、crc32、murmurhash。它们对同一个输入永远给同一个输出。 这条不是 Python 的坑,是所有语言共有的:只要哈希函数不可复现,就不能拿来分区。

三、分片之后,查一条只读一个文件

演示把 100 万条真写进四个文件,然后查一条记录。 知道它在第 2 片,只读一个文件:0.1183 秒。不知道在哪,四个文件挨个找:0.4138 秒。快 3.5 倍。 这才是分片真实的样子:数据平时就分开存着,查的时候按分区键直达。

注意这里不是 4 倍而是 3.5 倍,因为读文件有固定开销(打开、关闭、磁盘寻道), 这部分不随文件数线性减少。 还有一个前提被演示藏起来了:你得先知道分区键的值。查「单号 877777」很容易算片号; 查「金额大于 400 的所有订单」就没法算,只能四片都查。 这就是分片的边界:能按分区键定位的查询快,其他查询全靠并行硬扫。

四、计算:分给多个执行体

CPU 撑不住了,办法是把任务拆开分给多个进程、多台机器。 上一篇量过:4 个纯计算任务,串行 4.826 秒,4 个进程 2.520 秒(1.92 倍)。 没有 4 倍,因为开 4 个进程本身要 1.065 秒。并行有启动税,任务越小越不划算。

分布式计算把这件事推到极致:Hadoop、Spark 干的活就是「把一个大任务切成很多小块,分给几百台机器, 收结果」。 但切得越细,调度的开销越大。 所以真实系统里有个参数叫「每个分片多大」——太大则不够并行,太小则全是调度开销。 这个参数没有标准答案,取决于任务本身的耗时。经验值:每个分片至少跑几秒。

五、网络:最好的解法是不搬

演示最后一段:把 50 万条序列化成字节,10.5MB,通过内存管道挪一遍 0.0461 秒; 就地扫一遍求和 0.0784 秒。 搬运居然比计算还快。这个结果有点反直觉,原因是 Python 的循环太慢了,把搬运衬托得很快。 换成 C 写的计算,搬运立刻变成大头。 所以这一组数字只能用来说明「同一台机器上搬数据不算贵」,不能拿来证明搬运便宜。

真正贵的是距离。 同样 10MB:内存里挪一下是微秒级,同机磁盘毫秒级,同机房网络十毫秒级,跨地域按秒算。 这就是分布式系统的第一原则: 把计算送到数据那里去,而不是把数据搬到计算这里来。 MapReduce 的设计核心就是这个。 反过来,如果你的程序在满网络地搬数据,那多半是设计上出问题了,不是网络不够快。

六、动手 Demo:分四片、真写文件、再量搬一次要多久

造 100 万条订单,按单号哈希分 4 片,看分布。 换成分区键是城市,再看一次——这次不匀了。 真写进四个文件,查一条记录,比「只读一片」和「四片全找」。

import hashlib

def shard(key, n):
    h = hashlib.md5(str(key).encode()).digest()
    return int.from_bytes(h[:4], 'big') % n

rows = [(100000 + i, CITY[i % 6], 1 + i % 9, 99.0 + (i % 400))
        for i in range(1000000)]

cnt = [0] * 4
for r in rows:
    cnt[shard(r[0], 4)] += 1
print('按单号:', cnt)

cnt = [0] * 4
for r in rows:
    cnt[shard(r[1], 4)] += 1
print('按城市:', cnt)

实跑输出:

--------------------------------------------------------------------
三个瓶颈三个解法:分片、并行、少搬
--------------------------------------------------------------------

  ① 造 100 万条订单,按单号分 4 片
  --------------------------------------------------------------------
  造好 1000000 条,耗时 0.489 秒
  按单号哈希:249184  250380  250033  250403
  最多 250403,最少 249184,差 0.5%
  哈希分区能把数据摊得挺匀。四条片各约 25 万,最大最小差百分之几。
  这就是「分布式存储」最基础的一步:按某个键把数据切开。

  ② 换个分区键,立刻不均
  --------------------------------------------------------------------
  按城市哈希:333333  333334  166666  166667
  这次看着也还行——因为城市只有 6 个,哈希把 6 个键打散到 4 个片,总会有一片多一片少。
  换成内置 hash:500000  333334  166666  0
  用内置 hash 每次跑出来都不一样,因为 Python 给字符串哈希加了随机种子。
  做分区要用稳定哈希(md5、crc32),不能用内置 hash——不然重启一次,数据全跑错片。

  ③ 真分成四个文件,查一条只读一个
  --------------------------------------------------------------------
  按哈希写进四个文件,耗时 4.339 秒
  知道在第 2 片,只读一个文件:0.1149 秒
  不知道在哪,四个文件挨个找:0.4194 秒
  快 3.6 倍
  这才是分片真正的样子:数据平时就分开存着,查的时候按分区键直达。
  上面那种「查询时临时算一遍 hash 再筛」,反而比全扫慢几十倍——算 hash 是要花钱的。
  所以分片的收益有个前提:分片键要稳定、要能提前算出来。单号可以,因为它在数据产生那一刻就定了;
  「这个用户属于哪个价值分层」就不行,它得算出来才知道。

  ④ 分片的代价:跨片的事要合并
  --------------------------------------------------------------------
  求全局金额前 10:四片各出 10 条(40 条),再合并排序
  第一名:(100399, '荆州', 4, 498.0)
  这就是分片的代价:每片只能算自己的,跨片的汇总必须再走一轮。
  第 6 章讲的分析型数据库,大部分复杂度就花在这「再走一轮」上。

  ⑤ 搬数据比算数据贵
  --------------------------------------------------------------------
  把 50 万条序列化成字节:10502496 字节

| 做法 | 耗时 |
|---|---:|
| 把 50 万条序列化并挪一遍(内存管道) | 0.0428 秒 |
| 就地扫一遍求和 | 0.0663 秒 |

  这一组数字有点反直觉:搬 10 MB 的时间和纯 Python 循环求和居然差不多。
  原因是 Python 的循环太慢了,把搬运衬托得很快。换成 C 写的计算,搬运立刻变成大头。
  所以这一组只能说明「同一台机器上搬数据不算贵」,不能拿来证明搬运便宜。
  真正贵的是距离。
  同样是 10 MB:内存里挪一下是微秒级,同机磁盘是毫秒级,同机房网络是十毫秒级,跨地域是按秒算。
  所以分布式系统的第一原则不是「并行算」,是「别搬」:把计算送到数据那里去,
  而不是把数据搬到计算这里来。

第一段那四个数字加起来正好一百万。哈希分区在键均匀时非常靠谱,这是它成为默认方案的原因。 第二段是反面教材。只有 6 个城市,切成 4 片必然不匀——分区键的取值数是个硬上限。 第三段那个「内置 hash 每次都不同」的现象,是 Python 的安全设计,不是 bug。但拿它分区就是灾难。 最后那段搬数据的对比要小心解读:Python 循环慢,把搬运衬托得快了。 真正的结论是「距离决定代价」,不是「搬数据便宜」。

30 秒实验

  1. 把片数从 4 改成 8,看按城市分区时的分布变成什么样。(会更不匀。)
  2. 把 md5 换成 hashlib.sha1,看分布变不变。 (差不多,哈希函数只影响均匀性,不影响「片数上限」这条规律。)
  3. 在「只读一片」那段前后加 time.sleep(0),看 3.5 倍有没有变。

常见误区

  1. 「分片就是把数据平均切。」平均是结果,不是方法。 靠哈希撞出来的均匀,撞不匀就得换键或者加一层映射。
  2. 「用内置 hash 分区方便。」重启一次全部错位。生产环境必须用稳定哈希。
  3. 「分片越多越快。」片数受分区键取值数限制,也受调度开销限制。片太多,光是协调就比干活贵。
自测三问(点开对答案) 1. 100 万条按单号哈希分 4 片,为什么四片几乎一样大? —— 因为单号是连续整数、分布均匀,md5 把它们打散后取模 4,理论上每片 25%。 实际差 0.5%,是哈希的随机波动。 2. 为什么分区不能用 Python 的内置 hash? —— 因为字符串哈希加了随机种子,同一个字符串在不同进程里算出来不一样。 重启一次,同样的数据就会被分到别的片去。 3. 已知单号,查一条记录只要读一个文件;那查「金额大于 400」呢? —— 四片都得查。因为金额不是分区键,没法算出它在哪一片。 这就是分片的边界:只有按分区键定位的查询才能只读一片。

下一篇讲谁来分派这些活:调度器,以及三种开发方式怎么选。

相关笔记