『加一台机器就能扛 10 倍流量』——这是新手对 sharding 的幻想。现实是:分片是不可逆的架构债务。一旦分了,你失去跨分片事务、失去全表 join、失去自动二级索引;获得的是『可以无限加机器』和『从此每个 schema change 都是噩梦』。
那为什么还分?因为单机有硬上限:Postgres 单机写 ~50k QPS、数据量 ~10TB;MySQL InnoDB buffer pool 几百 GB 就到顶;垂直加 CPU/内存/SSD 价格曲线非线性陡升(r6i.32xlarge 月费 ~$8k,再大没有 SKU 了)。当单机不够,sharding 是必然。问题不是要不要分,是何时分、怎么分、怎么改。
反面教材:Foursquare 2010 年 MongoDB 单 shard 装不下,迁移时一台从节点 OOM 全集群雪崩,宕机 11 小时;Slack 2017 年第一次 reshard 花了 9 个月(按 team_id 分片,热门 workspace 还是不均匀);Notion 2021 年从单 Postgres 拆 32 shard 用了半年,2023 年又从 32 拆到 480。
graph TD
APP["Application"]
PROXY["Shard Router
Vitess vtgate / 应用层 / Citus coordinator"]
META["Shard Metadata
ZK / etcd / config DB"]
S1[("Shard 0
user_id % 16 == 0")]
S2[("Shard 1
user_id % 16 == 1")]
S3[("...")]
SN[("Shard 15
user_id % 16 == 15")]
R1[("Shard 0
Replica")]
R2[("Shard 1
Replica")]
APP --> PROXY
PROXY -.读 routing 表.-> META
PROXY --> S1
PROXY --> S2
PROXY --> S3
PROXY --> SN
S1 -.async repl.-> R1
S2 -.async repl.-> R2
classDef app fill:#1a2530,stroke:#64c8ff,color:#e8eef5
classDef proxy fill:#2a1530,stroke:#ff7ab6,color:#e8eef5
classDef meta fill:#1a1a30,stroke:#ffb450,color:#e8eef5
classDef shard fill:#0e2030,stroke:#5eead4,color:#e8eef5
class APP app
class PROXY proxy
class META meta
class S1,S2,S3,SN,R1,R2 shard
三层:应用 → 路由层(查 metadata 决定去哪个 shard)→ 物理 shard(每个 shard 还有自己的主从复制)
原理:两种基本分片策略,决定数据怎么映射到物理节点。
| Hash Sharding | Range Sharding | |
|---|---|---|
| 映射 | shard = hash(key) % N | 按 key 的区间划分([a-f] → S0, [g-m] → S1...) |
| 分布均匀 | ✅ 好哈希函数下接近均匀 | ❌ 易倾斜(新用户都在最后一个 shard) |
| 点查 | ✅ O(1) 算 hash 直接找 | ✅ 二分查找路由表 |
| 范围扫 | ❌ 必须 fan-out 全 shard | ✅ 连续 range 落同一 shard |
| 热点 | 单 key 热(如 superuser)= 单 shard 热 | 连续写入热(如时间序列)= 最后一个 shard 热 |
| 再分片 | 简单 mod 重做痛苦,consistent hash 缓解 | 分裂区间相对容易(按 range 拆) |
| 典型 | DynamoDB(partition key), Cassandra, Memcached | HBase, Bigtable, MongoDB(可选), CockroachDB |
(user_id hash, timestamp range))= 跨用户 hash 分散,单用户内 range 顺序。Cassandra 的 partition key + clustering key 就是这思路。# 简单 hash sharding(应用层)
import hashlib
NUM_SHARDS = 16
def shard_of(user_id: int) -> int:
h = hashlib.md5(str(user_id).encode()).digest()
return int.from_bytes(h[:4], 'big') % NUM_SHARDS
def get_conn(user_id):
return shard_pool[shard_of(user_id)]
# ⚠️ 不要用 Python 内置 hash()——进程间不一致 (PYTHONHASHSEED)
# ⚠️ 不要直接 user_id % N——若 user_id 顺序生成且 N 很小,
# 新用户会集中落在少数 shard
block 表,按 workspace 路由。(channel_id, bucket) 复合 key,channel_id 决定 partition(hash),bucket 是时间窗口(range),同一频道同一时段连续读快。原理:朴素 hash(key) % N 在 N 变化时,几乎所有 key 都要迁移(N: 8→9 时 ~88% 的 key 重映射)。Consistent hashing:把 hash 空间想象成一个环(0 到 2^32),节点和 key 都映射到环上,key 顺时针找到第一个节点。加/减一个节点只影响相邻的一段 key。
graph LR
subgraph Ring["Hash Ring (2^32)"]
direction LR
N1["Node A
vnode 0..200"]
N2["Node B
vnode 201..400"]
N3["Node C
vnode 401..600"]
N4["Node D (new)
插入处只接管
这段 key"]
end
K1["key1 hash=150"] --> N1
K2["key2 hash=350"] --> N2
K3["key3 hash=480"] --> N3
K4["key4 hash=270"] --> N4
classDef node fill:#0e2030,stroke:#5eead4,color:#e8eef5
classDef key fill:#1a1a30,stroke:#ffb450,color:#e8eef5
classDef new fill:#2a1530,stroke:#ff7ab6,color:#e8eef5
class N1,N2,N3 node
class K1,K2,K3,K4 key
class N4 new
但裸 consistent hashing 有问题:节点分布不均时负载倾斜。虚拟节点(vnode)解决——每个物理节点在环上放 100-200 个虚拟点,统计上更均匀;删节点时其负载也均匀分摊到其他节点而非全压到下一个邻居。
1/N 数据;❌ 路由表更复杂、需要查询 vnode 映射;vnode 太多则单节点重启慢(每个 vnode 都要重新加入)。# Consistent hashing 简化版 (vnode 版)
import bisect, hashlib
class ConsistentHash:
def __init__(self, nodes, vnodes=150):
self.ring = [] # sorted list of (hash, node)
self.vnodes = vnodes
for n in nodes:
self.add_node(n)
def _h(self, key):
return int(hashlib.md5(key.encode()).hexdigest(), 16)
def add_node(self, node):
for i in range(self.vnodes):
h = self._h(f"{node}#{i}")
bisect.insort(self.ring, (h, node))
def get(self, key):
h = self._h(key)
i = bisect.bisect(self.ring, (h, ''))
return self.ring[i % len(self.ring)][1]
# 加 1 个节点 (N → N+1), 重映射比例 ≈ 1/(N+1)
# 朴素 mod: 重映射比例 ≈ N/(N+1)
1/N 的数据;Apple 部署 10w+ Cassandra 节点。原理:『统计均匀』 ≠ 『实际均匀』。三类 hot spot:
缓解策略:
| 策略 | 做法 | 适用 | 代价 |
|---|---|---|---|
| Salting | key 加随机/可枚举后缀:celebrity_42#{0..15} | 写极热的单 key | 读放大 N 倍(要并发查 N 个分片合并) |
| Cache 前置 | 热 key 走 Redis / CDN,DB 只承接 miss | 读极热 | cache 一致性 / TTL 抖动 |
| 专属 shard | 大租户独占一个 shard / 实例 | tenant hot | 需要识别 + 迁移 + 容量规划 |
| 动态分裂 | Spanner / DynamoDB / Cockroach 自动检测热 region 并裂开 | 系统支持时 | 分裂期间短暂 throttle |
| 反向时间戳 | rowkey 加 MAX_LONG - timestamp 或 hash 前缀 | range 时序热 | 失去自然顺序,需要应用层翻转 |
| 读副本扩展 | 热 shard 多加几个 read replica | 读热 | 写还是单点,replica lag |
# Salting 示例:write-heavy celebrity 账户的 follower 表
# 原 schema:
# follower(celebrity_id, follower_id, ts) partition by celebrity_id
# 问题: celebrity_id=42 (Elon) 写 QPS 50k, 远超单 partition 1000 WCU 上限
# 改造:加散列后缀
shard_suffix = random.randint(0, 15)
write(table, partition=f"42#{shard_suffix}", row={...})
# 读时 fan-out
results = []
for i in range(16):
results += query(partition=f"42#{i}")
return merge_sort_by_ts(results)
# Trade-off: 读吞吐放大 16x; 但写解决了
# 适合 write >> read 场景; 反过来不适用
原理:再分片是分布式系统中最痛的操作之一,因为它要做到:(a)数据从旧拓扑搬到新拓扑;(b)业务不能停;(c)切换前后强一致;(d)出错可回滚。没有 shadow read/write 和回滚预案的 resharding 都是赌博。
sequenceDiagram
participant App
participant Router
participant Old as Old Shard (16)
participant New as New Shard (32)
Note over Router: Phase 1 · 双写
App->>Router: write k1
Router->>Old: write k1 (主)
Router->>New: write k1 (影子, 异步)
Note over Router: Phase 2 · backfill 历史
Router-->>New: 后台 copy: 全量 + 增量 CDC
Note over Router: Phase 3 · shadow read 校验
App->>Router: read k1
Router->>Old: read (返回给用户)
Router->>New: read (对比, 不返回)
Note right of Router: diff > 阈值 → 暂停切换
Note over Router: Phase 4 · 切流 (按 % 灰度)
App->>Router: write k1
Router->>New: write k1 (主)
Router->>Old: write k1 (回写, 备份)
Note over Router: Phase 5 · 拆链 / 删除旧数据
1/N,但 vnode 映射变化时仍要在线 backfill,工程上没那么白嫖。# Resharding 关键: idempotent dual write + version stamp
def write(key, value):
version = current_ms()
# 双写, 带 version 防止旧值覆盖新值
write_old(key, value, version)
if reshard_phase >= DUAL_WRITE:
try:
write_new(key, value, version) # if-newer
except Exception as e:
log_drift(key, e) # 告警, 不阻塞主流程
def read(key):
if reshard_phase == SHADOW_READ:
v_old = read_old(key)
v_new = read_new(key)
if v_old != v_new:
metrics.diff_count.inc()
log_diff(key, v_old, v_new)
return v_old # 还是返回老的, 直到切换
elif reshard_phase >= CUTOVER:
return read_new(key)
else:
return read_old(key)
auto_increment id 做 hash key,新数据全集中在少数 shard(auto_increment 在分片场景常有 prefix 偏置);用 timestamp 做 range key,写永远在尾巴。
% 做 shard 函数,加节点 = 灾难:N 从 16 → 32 时 ~93.75% key 要搬。要么 consistent hashing,要么 logical-over-physical。
Sharding is irreversible architectural debt — you trade away cross-shard transactions, joins, and global secondary indexes for unbounded horizontal scale. Two basic strategies: hash sharding distributes evenly but kills range scans; range sharding preserves locality but creates hotspots on monotonically increasing keys. Consistent hashing with virtual nodes minimizes data movement when adding/removing nodes — N→N+1 moves only 1/N of keys versus ~N/(N+1) with naive mod-hash. Hot spots are inevitable: celebrity accounts, tenant whales, and time-series tails all break statistical uniformity. Mitigations include salting, dedicated shards, adaptive capacity, and caching. Modern best practice is logical-over-physical sharding: provision many more logical shards (e.g., 1024) than physical instances, so scaling out is just migrating logical shards without changing the routing hash. Online resharding requires dual writes, shadow reads, and gradual cutover; expect 3-9 months of engineering. Default to vertical scale and read replicas first — only shard when you must.
本质:logical-over-physical 把『分片函数』和『物理拓扑』解耦。前者稳定,后者可调。但只解决了『扩物理容量』的问题,没解决『单个逻辑 shard 容量见顶』。
结论:logical-over-physical 不是银弹,但把『常态扩容』成本降到接近零,把『重新分片』的痛苦推迟数年。这种『推迟』本身就是巨大的工程价值——业务有时间演化、团队有时间成熟、工具有时间完善。不必要的复杂度推迟到必要时再付,是分布式系统设计的核心智慧。
Pinterest(以及 Twitter / Instagram)面对同样的『社交图跨分片』难题。核心思路:放弃在 OLTP 实时 join,改用预计算 + 异步流水线。
关键模式:分片系统中,跨 shard 的关系通过异步 fanout / 物化视图来转化为 shard 内的本地查询。Twitter timeline、Instagram feed、Facebook News Feed 全是这套思路。代价是写放大(fanout 1 写变 N 写)和短暂不一致,换得读路径 O(1) 和水平扩展能力。
面试拓展:被问到『社交 feed 怎么做』,能答出 fanout-on-write/read 混合 + celebrity 特殊处理,已经是高级答案。Day 14 Feed 系统会详细展开。
这是经典的『tenant skew』,几乎所有 multi-tenant SaaS 都会遇到。一个分片策略下两层不同的容量级别,必须用『分级架构』解决。
get_shard(tenant_id) 查 metadata 决定走哪一档。反模式:
核心智慧:物理资源向数据分布形状靠拢,而不是硬塞进均匀模型。这也是为什么『tenant_id 做 shard key』只是起点不是终点。
表面看两者都解决『加节点不大搬数据』,但有几个真实工程差异让 logical-over-physical 在 OLTP 数据库场景更友好。
结论:consistent hashing 的『最少迁移』优化在 KV cache 类系统价值巨大(节点频繁变动);在 OLTP 数据库(节点变动罕见、每次都是计划内)这点节省不重要,反而是『可观测性 + 运维简洁』更重要。这是为什么 DynamoDB / Cassandra(KV)用 consistent hash,Notion / Figma / Slack(OLTP)用 logical-over-physical。选型不是看哪个理论优雅,是看哪个匹配业务变化频率和运维心智模型。
第一步:算总规模
第二步:要不要分片
第三步:分几片
第四步:shard key 选什么
{user_id_hash}_{snowflake}),保证『按 order_id 查也能定位到 shard』。商户维度独立做物化视图(CDC → 商户索引表,按 merchant_id 分片)。第五步:跨片查询怎么办
成本估算
面试关键:能从『数据量』推到『要不要分』,从『QPS + 余量』推到『分几个』,从『主导访问模式』推到『shard key』,从『次要访问模式』推到『二级索引/物化视图』,最后能算出『成本量级』并提冷热分层。这一套下来基本是 staff 级答案。