分片(sharding)把一个数据库拆成多个,每个只保存一部分数据。当一台机器再也扛不住写入量、或者装不下数据时,分片就是答案;但它也是一个系统一生中代价最高的决定之一:查询、事务、模式变更和运维都会变得更难。
本文介绍什么时候值得分片、如何选择分片键、主要的分片方案、查询如何找到正确的分片,以及如何在不停机的情况下重新分片。图中展示的是一个使用逻辑分片、按哈希分片的订单数据库。
先试试其他所有办法
在分片之前,大致按这个顺序检查更便宜的选项:
- 优化查询和索引。 大多数“数据库太慢”的问题,要么是缺了索引,要么是查询读取了太多数据。
- 纵向扩展。 一台内存更大、磁盘更快的机器,能为很多系统争取好几年时间。
- 添加只读副本, 应对读多写少的负载;数据库复制一文 介绍了随之而来的复制延迟。
- 缓存热点读取, 参见 缓存策略一文。
- 按功能拆分: 把分析、搜索或审计相关的表迁到各自的数据库中。
即使做完这些,写入量或数据量仍然超出单个主库的承受能力,而且趋势表明情况会继续恶化,那就该分片了。
选择分片键
分片键决定一行数据存放在哪个分片上。好的分片键有三个特点:
- 每个热点查询都包含它。 如果订单列表查询按客户过滤,
customer_id就能让路由器把查询只发往一个分片。不带分片键的查询必须询问每一个分片。 - 能让负载均匀分布。 取值众多,且没有哪个单一取值贡献了大部分流量。
- 能把相关数据放在一起。 一个客户的所有订单、订单项和付款都在同一个分片上,事务和关联查询就能在本地完成。
对于多租户 SaaS,分片键通常是租户 ID;对于面向消费者的应用,则是用户 ID 或客户 ID。要警惕那些看似不错却会让负载集中的键,比如时间戳(所有新写入都落到一个分片)或国家代码(某一个国家占绝大多数)。
范围、哈希还是目录
| 方案 | 行如何分布 | 优点 | 缺点 |
|---|---|---|---|
| 范围 | 按键的范围,比如 A-F、G-M | 范围扫描只落在一个分片上 | 新的或热门的范围会变成热点 |
| 哈希 | 按键的哈希值 | 分布均匀 | 范围查询会打到每个分片 |
| 目录 | 用一张查找表把每个键或分组映射到分片 | 完全可控,迁移方便 | 目录是关键依赖,必须足够快 |
一种常见的混合方案是:把键哈希到固定数量的 逻辑分片(比如 1,024 个),再维护一张小目录,把逻辑分片映射到物理主机。键永远不会在逻辑分片之间移动,只有整个逻辑分片会在主机之间迁移。Instagram 的工程团队在 2012 年介绍过这种做法:成千上万个逻辑分片,分布在少得多的数据库服务器上。
查询路由
总得有某个组件知道每个键存放在哪里:
- 在应用中, 通过一个对键做哈希并查找主机的库来实现。简单,但每个服务、每种语言都需要同样的逻辑。
- 在代理中, 位于应用和数据库之间。Vitess 用它的 VTGate 代理为 MySQL 实现这一点,Citus 则通过协调节点为 Postgres 实现。
无论哪种方式,都要缓存分片映射,并在它变化时重新加载。迁移期间使用过期的映射,是重新分片中的经典 bug:某个路由器在切换之后仍然写入旧主机,那一行数据就在新主机上悄无声息地消失了。给映射加上版本号,拒绝携带旧版本的写入。不带分片键的查询会扇出到所有分片再合并结果,用于少量管理任务还行,作为常规访问路径则行不通。
不停机重新分片
有了逻辑分片,扩容就意味着把部分逻辑分片迁移到新主机上:
- 在旧主机继续提供服务的同时,把这些逻辑分片的数据复制到新主机。
- 利用数据库的复制功能或变更数据捕获(CDC),持续同步进行中的变更,直到副本追上。
- 短暂阻塞这些逻辑分片的写入,等最后的变更到达,更新分片映射,然后恢复写入。
- 验证无误后,从旧主机上删除已迁移的数据。
Vitess 和 Citus 都把这个流程的不同版本自动化了。在真正需要之前先演练;第一次在负载下重新分片,会是糟糕的一天。
跨分片可用的 ID
每个数据库各用一个序列,会在每个分片上发出相同的 ID。应使用无需协调就能保证唯一的 ID:UUID(第 7 版大致按时间排序,对索引更友好),或者 Snowflake 风格的 64 位 ID,它组合了时间戳、分片或机器编号以及计数器。把逻辑分片号嵌入 ID 还有额外好处:只凭一个订单 ID,路由器就知道该去哪里找,无需询问任何人。
架构图
图表内容
- Order service
- Shard router
- Shard map
- Host 1: shards 0-255
- Host 2: shards 256-511
- Host 3: shards 512-767
- Host 4: shards 768-1023
- Host 5: shards 896-1023
- Analytics warehouse
- query + key
- cached lookup
- move shards 896-1023
- CDC from all hosts
这张图的读法如下:
- 左侧: 订单服务把每个查询都带上客户 ID,发送给分片路由器。
- 中间: 路由器把客户 ID 哈希到 1,024 个逻辑分片之一,并在缓存的分片映射(虚线)中找到对应主机。
- 右侧: 四台 Postgres 主机各持有 256 个逻辑分片。主机 4 正在把它的上半部分迁移到新的主机 5,这就是正在进行的重新分片步骤。
- 右上: 跨客户的报表读取一个由变更数据捕获供数的分析型数据仓库,因此永远不会扇出到各个分片。
- Markdown 块 列出了每个写查询的人都必须遵守的规则。
你要接受的代价
多租户 SaaS 架构图 展示了这种设计中“每个租户一个分片”的变体,下面这些权衡在那里是按租户来计算的。
- 跨分片事务 会变成分布式事务或 Saga。在设计上让它们尽量少出现。
- 跨分片的唯一约束 需要全局 ID 方案,而不是每个数据库各用一个序列。
- 模式变更 要在每个分片上执行,所以迁移需要工具支持,并且必须能容忍只完成了一部分的状态。
- 运维工作成倍增加: 每个分片都要备份、升级和监控。
分片架构图上应该画什么
- 分片键 以及采用的方案(哈希、范围、目录)。
- 逻辑分片和物理分片, 如果你把两者分开的话。
- 路由发生在哪里: 库、代理还是协调节点。
- 不带分片键的查询走哪条路径, 比如数据仓库或搜索索引。
- 重新分片的状态, 如果有迁移正在进行。
在 Archboard 中构建
- 使用 Google 或 GitHub 登录,并新建一张图。
- 添加 C4 模型节点包中的 容器 节点表示服务和分片路由器,再添加一个 数据库 表示分片映射。
- 添加数据节点包中的 Postgres 节点表示各台主机,在标签中注明它们的逻辑分片范围,再为新主机添加一个。
- 添加数据节点包中的一个 数据仓库 用于报表,并用点线的 CDC 连线为它供数。
- 用虚线画出重新分片的迁移,并添加一个基础节点包中的 Markdown 块,写明分片规则。
这个示例共用 10 个图表节点,在 Free 套餐的限制之内。
常见问题
分片和分区有什么区别? 分区通常是指在单个数据库服务器内部拆分一张表。分片则把数据分散到多台服务器上,每台只保存其中一部分。
如何选择分片键? 选择每个热点查询都会用来过滤、并能把相关数据放在一起的属性,它要有大量不同取值且负载均匀,比如租户 ID 或客户 ID。
什么是逻辑分片? 数量固定且较大的一组哈希桶,它们被映射到数量更少的物理服务器上。扩容时在服务器之间迁移整个逻辑分片,就不必对每个键重新哈希。
如何在所有分片上执行查询? 在热点路径上尽量避免。报表需求可以把数据复制到数据仓库;少量管理任务则可以通过路由器做扇出查询。
什么时候应该分片? 在完成索引优化、纵向扩展、只读副本、缓存和按功能拆分之后,如果写入量或数据量仍然超出一个调优良好的单主库所能承受的范围,并且这一趋势还在持续,就该分片了。