分片策略

本页目录

分片把不同数据子集分配给不同资源,复制则为同一子集保留多份副本。前者扩展容量与处理能力,后者提供冗余和读服务;实际系统通常同时使用两者。

选分片键前,先列出最常见的查询条件、需要一起修改的数据、数据增长方式和访问倾斜。好的分片键让常见请求可以直接定位,并把经常共同操作的数据放在一起,但通常无法同时优化所有查询。

先区分 key、逻辑分区和机器

业务 key 经分片规则映射到逻辑分区,路由元数据再把逻辑分区映射到副本组或机器。一台机器可以承载多个分区,一个分区可以有多个副本。

因此,“有 100 个分区”不等于“有 100 台机器”。逻辑分区数量决定迁移粒度和元数据规模;机器数量与规格决定实际资源。把这两层分开,扩容时就可以迁移部分分区,而不必重新定义每个业务 key 的含义。

范围分片:保留键的顺序

范围分片按有序键的边界切分,例如 [0,1000)、[1000,2000)。查询 id BETWEEN 950 AND 1050 会访问两个分区;更窄的范围可能只访问一个,更宽的范围可能覆盖全部。优势是能够根据边界裁剪无关分区,而不是保证所有范围查询都只访问一个分区。

若记录按递增时间写入,最新数据可能持续集中在末尾分区。分裂能缩小数据范围,却不一定分散新的写入:后续请求仍可能全部落到新产生的最右分区。需要结合写入并发、时间桶和其他键维度设计。

逻辑相邻也不等于物理顺序 I/O。页组织、LSM 刷盘、压缩和存储介质决定实际 I/O;分片只决定哪个资源负责哪个范围。

哈希分片:打散键的相邻关系

哈希分片先对分片键做确定性哈希,再选择桶或 token 范围。不同 key 通常更容易均匀分散,但哈希打散了原始顺序:如果路由仅依赖 hash(order_id),按订单时间查询一般无法直接知道应访问哪个分区。

均匀的 key 数量不代表均匀的负载。一万个普通 key 可能比一个热门商品 key 的请求量还少。哈希不会自动拆开单个热 key。DynamoDB 的官方建议同样强调按实际访问活动设计分区键,而不能只看键值数量。分区键设计指南

复合键:分区内保留有用的顺序

考虑订单系统,把 user_id 作为分区键,把 (created_at, order_id) 作为分区内排序键。这样,查某用户最近 20 笔订单可以定位该用户所属分区,再做有界范围扫描;同一个时间戳下用 order_id 区分记录。

同一批订单:分片键决定查询需要找谁 按用户查订单 user_id = 42 hash(user_id) 确定一个逻辑分区 用户 42 的订单 内部按时间排序 查全站今日订单 没有 user_id 条件 分区 A 分区 B 分区 C 局部排序保留了单个用户的时间范围查询;全局时间查询需要扇出或另一套索引。

这是一种访问模式设计,并不限定某个数据库的语法。其边界是大租户或大用户:如果一个租户的数据必须位于单个逻辑分区,该分区容量或吞吐可能先到上限。可以增加月份桶或哈希后缀,代价是查询多个桶并合并排序结果。

跨分片取最近 K 条:先局部候选,再全局归并

如果用户数据被拆成多个桶,按时间倒序查最近 K 条,就需要每桶取满足条件的前 K 条,再选全局前 K 条。局部第 K+1 条前面已经有同桶的 K 条,所以它不可能进入全局前 K;这个推理要求各桶使用同一过滤条件和相同的全序。

以下元组表示 (created_at, order_id),两列都降序,order_id 全局唯一。所有数据固定不变,每个分片中的记录预先排好序:

from heapq import merge
from itertools import islice

shards = [
    [(105, 9), (100, 8), (99, 7)],
    [(104, 6), (100, 5), (98, 4)],
    [(103, 3), (102, 2), (100, 1)],
    [],
]

def page(shards, k, before=None):
    if k <= 0:
        raise ValueError("页大小必须为正")
    candidates = [list(islice((row for row in shard
                               if before is None or row < before), k))
                  for shard in shards]
    return list(islice(merge(*candidates, reverse=True), k))

assert page(shards, 3) == [(105, 9), (104, 6), (103, 3)]
collected, cursor = [], None
while batch := page(shards, 2, cursor):
    collected.extend(batch)
    cursor = batch[-1]
assert collected == sorted([row for shard in shards for row in shard], reverse=True)
assert len(collected) == len(set(collected)) == 9

# 同一时间戳跨页时,只保存时间会漏掉其余订单。
ties = [[(100, 8), (100, 5), (100, 1)]]
first = page(ties, 2)
assert first == [(100, 8), (100, 5)]
assert page(ties, 2, first[-1]) == [(100, 1)]
assert [row for row in ties[0] if row[0] < first[-1][0]] == []
每个分片取局部前 K 条,再按同一顺序归并 分片 A 105/9,100/8,99/7 分片 B 104/6,100/5,98/4 分片 C 103/3,102/2,100/1 全局前 3:105/9,104/6,103/3 排序键:时间 / 全局唯一订单 ID 每桶最多取 K 个候选;同时间戳仍需唯一的次序和完整游标。

模型最多收集 P×K 个候选,P 为分片数,然后以多路归并返回 K 个;真实查询还要为超时、部分结果和各桶响应不齐制定规则。Python 的过滤在内存里扫描,数据库实现则应利用排序索引从游标处定位,不应把这个函数当作高效数据库查询计划。

order_id 是分页顺序的最后一个区分维度。若只按 created_at 排序或只把时间放进游标,同毫秒的多笔订单可能跨页丢失。SQL 中 LIMIT 也要求能够确定唯一顺序的 ORDER BY,见 PostgreSQL LIMIT/OFFSET 文档。

复合游标仍不自动提供跨页快照:第一页之后新插入、且排在游标之前的记录,不会出现在后续页;原记录若改了排序键,还可能漏读或重读。若产品要求“遍历同一时刻的完整结果”,必须固定可用的快照或读取版本,并把快照标识、过滤条件和排序规则一起绑定到游标。仅保存最后一条 ID 不够。

每个分片要取多少,才能得到全局前 K?

把局部候选与最终返回值分开,可以看见查询扇出的实际代价:三个分片各返回 K 条,共收集 3K 条候选,但最终只需要 K 条。金色结果必须按相同的 (time,id) 全序选出,不能只比较时间后任意丢弃并列项;图中的记录固定不变,分页期间新增与修改仍需正文中的快照约定。

正在呈现知识画面
每个分片要取多少,才能得到全局前 K?

局部第 K+1 条之前已有同分片的 K 条,因此不可能进入全局前 K。候选集合必须使用相同过滤条件与确定的全序。

热点要按成因处理

热点类型可考虑的措施需要付出的代价
一个 key 被大量读取缓存、请求合并、允许时使用读副本失效与一致性策略
一个计数器被大量更新多个子计数器,读取时聚合总值可见性与精确约束更复杂
最新时间范围持续写入时间桶结合其他散列维度范围查询可能扇出
一个租户远大于其他租户租户内再分片,或独立资源路由与跨分片操作增加
某分区混有多个热门 key拆分或迁移分区迁移期间的资源消耗

Salting 是给逻辑键增加桶维度,例如 (product_id, bucket_id)。必须定义如何选择桶、如何找到旧记录,以及查询是否遍历所有桶。把同一条需要原子更新的记录随机写到不同位置,会改变业务语义,不能作为通用扩容技巧。

例如 (日期, hash(order_id) % B) 可以让知道日期与 order_id 的点查重算桶;若桶是每次写入随机选择的,就需要额外保存位置或遍历桶。无论哪种方式,查询某天的全部订单通常都要访问全部 B 个桶。AWS 的写分片说明对比了随机与可计算后缀的查询代价。Write sharding

让客户端直接连接目标分区可以减少代理开销,但不会提高目标分区自身的存储或计算上限。

二级索引:换一种查询条件,就多维护一种组织方式

假设主表按 user_id 分片,而查询条件是 email。如果每个分片只有自己的本地 email 索引,单个分片查找会更快,但调用方不知道 email 位于哪个分片,仍可能需要扇出。

另一种方式是维护按 email 组织的全局索引,把 email 映射到记录主键。此时可以先查索引,再回主表;代价是一次业务写入需要维护两套组织方式。如果它们跨分区,就必须明确采用跨分区事务、异步传播还是其他一致性协议。

“本地”“全局”的具体定义随产品而异,也不能单靠名字推断索引更新是否原子、是否支持强一致读取。异步索引可能暂时漏掉新记录,或指向已经删除的记录;应用必须按所需语义处理。通过索引检查“没有这个 email”再插入,也不自动构成全局唯一约束。

“索引里不存在”为什么不能当作唯一性锁

两个注册请求使用同一个 email,却被 user_id 路由到不同分片。即使每个主分片的单条插入都原子,下面的交错仍然合法:

时间请求 A请求 B
1查 email 索引,未发现记录
2查同一索引,也未发现记录
3写入用户 A
4写入用户 B
5索引异步更新索引异步更新

两个主记录已经同时存在,后续索引最终保留一个或两个条目都不能撤销这个事实。问题不只是索引传播慢:即使两次查询都强一致,只要“检查不存在”和“取得创建资格”分成两个独立步骤,两次检查仍可能都发生在首次创建之前。

一种方向是在按规范化 email 定位的唯一位置原子抢占创建资格,或用覆盖约束与主记录的事务。前者还需要定义占位成功、主记录未完成时如何恢复,避免留下永久占位;后者需要承担相应跨分片协调。所谓全局唯一约束,成本就在这个必须共同裁决的键上,不能靠所有分片分别建一个本地唯一索引拼出来。

分片键评审速查

  1. 主要查询是否带有分片键,缺少时要访问多少分区?
  2. 单个分区的容量、写入和热点是否可能先耗尽?
  3. 经常一起修改的记录能否共置,跨分区时需要什么事务语义?
  4. 分片键是否会改变,改变意味着原地更新还是搬迁记录?
  5. 二级索引、分页与全局排序的额外代价是否可接受?

扇出还会放大尾延迟和故障处理成本:并行查询的响应通常要等待所需结果中最慢的一份。若允许部分结果,必须明确其完整性;若要求完整结果,就要考虑重试、截止时间及重复合并。

继续阅读:一致性哈希说明成员变化时如何减少重映射;动态再平衡说明分区如何迁移。

参考

Keywords: sharding, partition key, range partitioning, hash partitioning, hot key, salting, secondary index