13 · 分片:分散负载而不丢掉查询能力
目标:依据热点与查询路径选择分片键,并解释扩容时的路由变化。
复制和分片不是一回事
复制让同一份数据有多个副本;分片让不同组负责不同数据,两者可以组合。按 activity_id 分片,某次报名及该活动名额容易放在同一片;按 user_id 分片,查用户全部报名更方便,却会让同一活动的名额约束跨片。
范围分片保留键的顺序,适合范围查询,但持续增长的时间键可能把最新写入集中到一片。哈希分片打散键分布,通常牺牲全局范围局部性;它打散不同键,不能打散同一个超级热门键的负载。
请求先到哪个节点也需要机制。客户端路由、代理路由或节点转发都需要可更新的分片映射。分片迁移要处理旧路由请求、在途写入及切换点,复制文件并不等于完成安全迁移。
100 个请求与 11 个键
保存 sharding.py。sha256 让映射跨运行稳定;Python 内建 hash 不适合作为此处可复现的持久路由约定。先预测 4 个片能否把热门活动的 90 次请求平均分开。
from collections import Counter
from hashlib import sha256
def bucket(key):
return int.from_bytes(sha256(key.encode()).digest()[:8], "big") % 16
requests = ["popular"] * 90 + [f"a{i}" for i in range(10)]
owners = {i: i % 4 for i in range(16)}
load = Counter(owners[bucket(key)] for key in requests)
assert max(load.values()) >= 90
hot_bucket = bucket("popular")
new_owners = dict(owners)
new_owners[hot_bucket] = 4
moved = sum(owners[bucket(k)] != new_owners[bucket(k)] for k in requests)
assert moved >= 90
assert sum(owners[b] != new_owners[b] for b in owners) == 1
print("hot_shard_at_least_90:", max(load.values()) >= 90)
print("buckets_moved:", sum(owners[b] != new_owners[b] for b in owners))
uv run --python 3.12 --no-project sharding.py
预期输出:
hot_shard_at_least_90: True
buckets_moved: 1
16 个逻辑桶与物理节点分开,迁移只修改某个桶的归属。它使映射变化可控,但迁移整个热点桶只是把热点换了位置。这里没有实现数据复制、并发写入或切换协议,不能直接用修改字典来实现生产扩容。
引导练习:① 将热门键改为 90 个不同活动 ID;② 不再要求最大负载至少 90,改为打印分片分布;③ 与原分布比较。少量样本未必完全平均,关键是同键约束已经消失。
索引也要决定放在哪里
局部二级索引只索引本片记录,写入局部化,但按“城市”搜索可能要广播全部片。全局二级索引按被索引值另行分片,可以快速定位候选,却让一次活动修改需要维护额外片的数据。异步维护全局索引时,搜索结果可能落后。
多租户场景可以先按租户隔离,再对大租户继续拆分,减少“吵闹邻居”影响。给热点加盐能拆散部分读写,但读取要合并多个子键;报名容量这样的全局不变量不能只靠加盐解决。
独立练习:选择按活动分片后,用户希望查看自己所有报名。提出一种查询方案和一种写入代价,并说清楚哪个系统决定名额。
参考设计
活动片保存名额和报名事实;另建按用户分片的报名列表视图,由已提交事件维护。用户列表可能稍晚,报名后立即查询可带请求 ID 回事实源。代价是维护事件交付、去重和视图延迟。如果强制用户列表立即反映所有片的写入,需要额外协调,而不是免费得到。名额始终由活动事实所在的片决定。
分布均匀却仍慢时,检查请求数和记录数是否混淆;迁移后读不到时,检查客户端映射版本和旧节点转发行为。在 design.md 写下候选分片键、热点例外、用户列表路径,以及何种实测条件才触发分片。