分片把不同数据子集分配给不同资源,复制则为同一子集保留多份副本。前者扩展容量与处理能力,后者提供冗余和读服务;实际系统通常同时使用两者。
选分片键前,先列出最常见的查询条件、需要一起修改的数据、数据增长方式和访问倾斜。好的分片键让常见请求可以直接定位,并把经常共同操作的数据放在一起,但通常无法同时优化所有查询。
先区分 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 区分记录。
这是一种访问模式设计,并不限定某个数据库的语法。其边界是大租户或大用户:如果一个租户的数据必须位于单个逻辑分区,该分区容量或吞吐可能先到上限。可以增加月份桶或哈希后缀,代价是查询多个桶并合并排序结果。
跨分片取最近 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]] == []
模型最多收集 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。候选集合必须使用相同过滤条件与确定的全序。
{"id":"sharded-topk-merge","title":"每个分片要取多少,才能得到全局前 K?","summary":"局部第 K+1 条之前已有同分片的 K 条,因此不可能进入全局前 K。候选集合必须使用相同过滤条件与确定的全序。","height":1000,"html":"<h2 data-i18n=\"heading\"></h2><p class=\"intro\" data-i18n=\"intro\"></p><div class=\"control\"><label for=\"k\" data-i18n=\"k\"></label><output for=\"k\" id=\"k-value\"></output><input id=\"k\" type=\"range\" min=\"1\" max=\"4\" value=\"2\" step=\"1\"></div><div id=\"shards\" class=\"shards\"></div><div class=\"metrics\"><div><span data-i18n=\"candidates\"></span><strong id=\"candidates\"></strong></div><div><span data-i18n=\"output\"></span><strong id=\"output\"></strong></div></div><div id=\"global\" class=\"track\"></div><p class=\"hint\" data-i18n=\"hint\"></p><details class=\"scope\"><summary data-i18n=\"scopeLabel\"></summary><p data-i18n=\"scope\"></p></details>","css":".intro{margin:8px 0 18px;color:var(--muted);font-size:14px}.caption,.hint{font-size:12px;color:var(--muted);margin-top:12px}.control{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 12px;align-items:center;margin:14px 0;font-size:13px}.control label{font-size:13px}.control output{color:var(--accent);min-width:5em;text-align:right;font-variant-numeric:tabular-nums}.control input{grid-column:1/-1;width:100%;margin:0}.presets{display:flex;gap:4px;align-items:stretch}.presets button{flex:1 1 0;min-width:0;overflow-wrap:anywhere;font-size:13px}.actions{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}.actions button{flex:1;min-width:0;font-size:13px;min-height:42px}.copy{display:grid;font-size:14px;margin:16px 0}.copy>*{grid-area:1/1;margin:0}.reserve{visibility:hidden}.scope{margin-top:14px;color:var(--muted);font-size:12px}.scope summary{padding:8px 0;cursor:pointer}.scope p{margin-top:10px}.two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.card{border:1px solid var(--rule);border-radius:9px;padding:14px;min-width:0}.card header{font-size:14px;font-weight:600;margin-bottom:10px}.card button{font-size:12px;width:100%;min-width:0}.card small{display:block;font-size:12px;color:var(--muted)}.card code{font-size:12px;overflow-wrap:anywhere}.metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.metrics>div{border-left:2px solid var(--accent);padding-left:10px;min-width:0}.metrics span{display:block;min-height:3.2em;color:var(--muted);font-size:12px}.metrics strong{font-size:23px;display:block;font-weight:500;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.formula{border-top:1px solid var(--rule);padding-top:12px;margin-top:14px;font-size:13px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.value{font:500 20px var(--viz-font-ui)}.legend{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:12px;color:var(--muted);margin:12px 0}.legend span:before{content:'';display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--accent);margin-right:6px}.legend .second:before{background:var(--second)}.check{display:flex;align-items:center;gap:8px;font-size:13px;margin:12px 0}.check input{width:16px;height:16px;flex-shrink:0;accent-color:var(--accent)}select,input[type=text],input[type=number]{background:var(--paper);color:var(--ink);font:inherit;font-size:13px;padding:10px;border:1px solid var(--rule);border-radius:7px;max-width:100%;min-width:0}select{width:100%;min-height:42px}.band{height:20px;display:flex;background:var(--surface);border-radius:4px;overflow:hidden}.band i{height:100%;display:block;background:var(--accent);transition:width .25s}.band i.second{background:var(--second)}button:disabled{cursor:default}.status{font-size:13px;min-height:4.8em}.mono{font-family:var(--viz-font-mono);font-variant-numeric:tabular-nums}.muted{color:var(--muted)}\n@media(max-width:480px){.two{gap:8px}.card{padding:12px 9px}.actions button,.presets button{font-size:12px}.metrics strong{font-size:20px}.metrics{gap:8px}}\n@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}\n.track-row{margin:16px 0}.track-row>span{display:block;font-size:12px;color:var(--muted);margin-bottom:7px}.track{display:flex;gap:5px;min-width:0}.cell{flex:1;min-width:0;min-height:48px;border:1px solid var(--rule);border-radius:5px;background:var(--surface);display:flex;align-items:center;justify-content:center;text-align:center;padding:5px 3px;font-size:12px;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.cell.on{background:var(--accent-soft);border-color:var(--accent)}.cell.gold{background:var(--second-soft);border-color:var(--second)}.cell.empty{border-style:dashed;color:var(--muted)}.cell.error{border-color:var(--second);text-decoration:line-through}.choices{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin:14px 0}.choices button{min-width:0;padding:10px 4px;font-size:12px;overflow-wrap:anywhere}.choices button[aria-current=true]{border-color:var(--accent);background:var(--accent-soft)}.relation{display:grid;grid-template-columns:minmax(0,1fr) 28px minmax(0,1fr);align-items:center;gap:6px;margin:12px 0}.relation .cell{min-height:72px}.relation>i{text-align:center;font-style:normal;color:var(--accent)}.readout{font-size:13px;white-space:pre-line;min-height:6.4em;overflow-wrap:anywhere}.plot{position:relative;border-left:1px solid var(--rule);border-bottom:1px solid var(--rule);padding:8px 0;margin:18px 0}.plot-row{height:28px;position:relative;margin:4px 0;background:var(--surface)}.plot-row i{position:absolute;inset-block:5px;background:var(--accent);min-width:2px;transition:width .2s,left .2s}.plot-row b{position:relative;padding:4px 6px;font:11px/28px var(--viz-font-ui);background:var(--paper);border-radius:3px}.axis{display:flex;justify-content:space-between;font-size:11px;color:var(--muted)}\n\n/* Action labels must remain readable across locales and narrow embeds. */\n.actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}\n.actions button{overflow-wrap:anywhere;white-space:normal;min-height:58px;padding:8px}\nbutton{overflow-wrap:anywhere}\n@media(min-width:640px){.actions{grid-template-columns:repeat(3,minmax(0,1fr))}}\n\n.metrics span{overflow-wrap:anywhere}\n\n.shards{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.shards .cell{margin:5px 0;min-height:42px}#global{min-height:58px}","js":"const $=s=>document.querySelector(s);const set=(id,v)=>$('#'+id).textContent=v;const t=k=>viz.t(k);function cells(id,values){$('#'+id).replaceChildren(...values.map(v=>{const e=document.createElement('div');e.className='cell '+(v.cls||'');e.textContent=v.text;e.title=v.title||v.text;return e;}));}function pressed(mode){document.querySelectorAll('[data-mode]').forEach(e=>e.setAttribute('aria-pressed',String(e.dataset.mode===mode)));}const shards=[[[10,9],[9,8],[7,3],[5,1]],[[10,10],[8,7],[7,4],[4,2]],[[10,11],[8,6],[7,5],[3,0]]],cmp=(a,b)=>b[0]-a[0]||b[1]-a[1];function draw(){const k=+$('#k').value;set('k-value',k);const candidates=shards.flatMap(a=>a.slice(0,k)),global=candidates.slice().sort(cmp).slice(0,k),winners=new Set(global.map(x=>x[1]));$('#shards').replaceChildren(...shards.map((s,i)=>{const box=document.createElement('div');const h=document.createElement('strong');h.textContent='S'+(i+1);box.append(h);s.forEach((r,j)=>{const e=document.createElement('div');e.className='cell '+(winners.has(r[1])?'gold':j<k?'on':'empty');e.textContent=r[0]+' · #'+r[1];box.append(e);});return box;}));set('candidates',candidates.length);set('output',global.length);cells('global',global.map(r=>({text:r[0]+' · #'+r[1],cls:'gold'})));$('#global').dataset.ids=global.map(x=>x[1]).join(',');}$('#k').oninput=draw;draw();","audio":false,"strings":{"candidates":"局部候选数","heading":"每个分片要取多少,才能得到全局前 K?","hint":"蓝色是局部前 K,金色是最终入选;其余记录仍存在,只是不必传回这次归并。","intro":"三份固定数据按 (time,id) 降序。调整 K,对照各分片候选与全局结果;相同时间用 id 决定先后。","k":"全局 K","output":"全局返回数","reset":"重置","scope":"三个不可变分片,每片四条,id 全局唯一。只解释首屏 top-K;跨页一致快照、超时和部分结果需另行处理。","scopeLabel":"模型范围与约定"}}
热点要按成因处理
| 热点类型 | 可考虑的措施 | 需要付出的代价 |
| 一个 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 定位的唯一位置原子抢占创建资格,或用覆盖约束与主记录的事务。前者还需要定义占位成功、主记录未完成时如何恢复,避免留下永久占位;后者需要承担相应跨分片协调。所谓全局唯一约束,成本就在这个必须共同裁决的键上,不能靠所有分片分别建一个本地唯一索引拼出来。
分片键评审速查
- 主要查询是否带有分片键,缺少时要访问多少分区?
- 单个分区的容量、写入和热点是否可能先耗尽?
- 经常一起修改的记录能否共置,跨分区时需要什么事务语义?
- 分片键是否会改变,改变意味着原地更新还是搬迁记录?
- 二级索引、分页与全局排序的额外代价是否可接受?
扇出还会放大尾延迟和故障处理成本:并行查询的响应通常要等待所需结果中最慢的一份。若允许部分结果,必须明确其完整性;若要求完整结果,就要考虑重试、截止时间及重复合并。
继续阅读:一致性哈希说明成员变化时如何减少重映射;动态再平衡说明分区如何迁移。
参考
Keywords: sharding, partition key, range partitioning, hash partitioning, hot key, salting, secondary index