Day 4 Hard Database Sharding Consistent Hashing Resharding

数据库分片 — 单机的尽头是分片,分片的尽头是后悔Sharding: Hash vs Range, Consistent Hashing, Hot Spots, Resharding

问题场景

『加一台机器就能扛 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 还有自己的主从复制)

关键技术点

1. Hash sharding vs Range sharding:性能、热点、跨分片查询的三角

原理:两种基本分片策略,决定数据怎么映射到物理节点。

Hash ShardingRange 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, MemcachedHBase, Bigtable, MongoDB(可选), CockroachDB
怎么选:
# 简单 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
现实案例:

2. Consistent Hashing:让加节点不要全表搬

原理:朴素 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 个虚拟点,统计上更均匀;删节点时其负载也均匀分摊到其他节点而非全压到下一个邻居。

Trade-off:
# 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)
现实案例:

3. Hot Spots:均匀分片为什么仍会被打爆

原理:『统计均匀』 ≠ 『实际均匀』。三类 hot spot:

  1. Key hot:单 key 流量过高(明星账号 1 亿粉、爆款商品 100k QPS)。哈希再好,这个 key 永远落同一 shard。
  2. Range hot:range 分片下『最新数据』集中(按 timestamp 分片,写流量永远在最后一个 shard)。
  3. Tenant hot:multi-tenant 系统中大客户(Slack 中的 Salesforce、Notion 中的 Adobe)占用一个 shard 的 90%。

缓解策略

策略做法适用代价
Saltingkey 加随机/可枚举后缀: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 场景; 反过来不适用
现实案例:

4. Resharding:从 32 个 shard 到 480 个的工程噩梦

原理:再分片是分布式系统中最痛的操作之一,因为它要做到:(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 · 拆链 / 删除旧数据
Resharding 策略对比:
# 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)
现实案例:

扩展与优化(增长后怎么办)

常见陷阱

1. shard key 选了『增量字段』:用 auto_increment id 做 hash key,新数据全集中在少数 shard(auto_increment 在分片场景常有 prefix 偏置);用 timestamp 做 range key,写永远在尾巴。
2. 用 % 做 shard 函数,加节点 = 灾难:N 从 16 → 32 时 ~93.75% key 要搬。要么 consistent hashing,要么 logical-over-physical。
3. 跨 shard 事务硬上 2PC:性能崩塌、协调器单点。改 saga / outbox / 应用层补偿。
4. 没有 shadow read 就 cutover:你以为一致,结果用户看到丢数据 / 重复。Shadow read 跑至少 1-2 周积累 diff 数据再切。
5. 忽视 connection 爆炸:分了 N 个 shard,每个应用实例如果都连所有 shard,连接数 = N × instance 数,Postgres 5k 连接上限秒爆。必须 PgBouncer / proxy 层池化。
6. 跨 shard 唯一约束:unique(email) 在分片表里不能由 DB 保证(不同 shard 可能同 email 都成功)。需要中央 unique 服务(小型 KV 或一个集中表)。
7. 太早分片:单机 Postgres 配 r6i.32xlarge + 良好 schema 能扛 50k QPS / 5TB。很多团队 1k QPS 就开始分片,徒增复杂度。先垂直扩、加 cache、加 replica,最后才分。

面试问题示例

  1. 设计 Twitter,你怎么分 tweets 表?user_id 还是 tweet_id?为什么?分别有什么问题?
  2. 解释 consistent hashing 和 vnode;用一个『加节点』场景说明 mod hash 与 consistent hash 的迁移比例差异。
  3. DynamoDB 一个 partition 突然被限流(hot partition),你不能改 schema,能想到几种缓解方案?
  4. 你设计一个 multi-tenant SaaS,某客户突然涨到全平台 30% 流量,怎么把它『单独搬出来』而其他人无感?
  5. online resharding 的关键阶段是什么?双写期间数据漂移怎么发现?
  6. 为什么 logical shard 数量要远大于物理实例数?给一个具体的扩容场景说明它的好处。

关键资源

English Summary

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.

深入思考(点击展开答案)

1. Notion 用 logical-over-physical(1024 逻辑 shard / 32 物理实例)的关键好处是『扩容不改路由函数』。但如果有一天逻辑 shard 数也不够了(要从 1024 加到 4096),他们会面临什么?是不是只是把问题推迟了?

本质:logical-over-physical 把『分片函数』和『物理拓扑』解耦。前者稳定,后者可调。但只解决了『扩物理容量』的问题,没解决『单个逻辑 shard 容量见顶』。

  • 什么时候要加逻辑 shard:单个逻辑 shard 数据量或 QPS 触顶(例如一个 workspace 大到 1 个逻辑 shard 也装不下)。
  • 这时确实要重新 hash:1024 → 4096,路由 hash 改变,几乎所有 key 都要重新映射。代价等同于第一次分片。
  • 缓解方案 A:一开始就开『超额』逻辑 shard。Notion 选 480 其实留了余量;Figma 直接开了 4096。1 个逻辑 shard 占 1 MB metadata,4096 个也才 4MB,几乎免费。
  • 缓解方案 B:分裂式 resharding。把 shard k 分裂为 k_a 和 k_b,hash range 一半给 a 一半给 b,只影响一个 shard 的数据。CockroachDB / Spanner 内置 range split 就是这思路;应用层 sharding 也能仿照,但路由表会膨胀。
  • 缓解方案 C:异质分片。大租户单独放专属 shard(physical isolation),不参与 hash 池。Slack vitess pod 就这么干。

结论:logical-over-physical 不是银弹,但把『常态扩容』成本降到接近零,把『重新分片』的痛苦推迟数年。这种『推迟』本身就是巨大的工程价值——业务有时间演化、团队有时间成熟、工具有时间完善。不必要的复杂度推迟到必要时再付,是分布式系统设计的核心智慧

2. Pinterest 把 user 的 boards、pins 都 co-locate 在同一 shard(按 user_id)。听起来很美,但如果有用户『关注』另一个用户的 board,关注关系跨 shard。讲讲他们怎么处理跨 shard 的『关注 + feed 生成』?

Pinterest(以及 Twitter / Instagram)面对同样的『社交图跨分片』难题。核心思路:放弃在 OLTP 实时 join,改用预计算 + 异步流水线

  • 关注关系存哪里:单独一张『关系表』,按 follower_id 分片(与 user shard 同 key),存『谁关注了谁』;同时按 followee_id 再存一份(fanout 用),双写。
  • Feed 生成:fanout-on-write。A 发新 pin → 查 A 的 follower 列表 → 给每个 follower 的 feed 表追加一条。每个 follower 的 feed 都在自己的 shard 上,读时 O(1)。
  • Celebrity 问题(10M 粉的明星):fanout 10M 次代价巨大。改为 hybrid:普通用户继续 fanout-on-write;celebrity 改 fanout-on-read(关注者读 feed 时实时拉 celeb 最近 pin 合并)。
  • 跨 shard 强一致丢了:A 关注 B 这一刻,feed 不会立刻有 B 的 pin。最终一致几秒到几分钟,产品上可接受。
  • 反向索引(谁 pin 了这张图):单独一个反向索引服务(按 pin_id 分片),异步从主表 CDC 出来。

关键模式:分片系统中,跨 shard 的关系通过异步 fanout / 物化视图来转化为 shard 内的本地查询。Twitter timeline、Instagram feed、Facebook News Feed 全是这套思路。代价是写放大(fanout 1 写变 N 写)和短暂不一致,换得读路径 O(1) 和水平扩展能力。

面试拓展:被问到『社交 feed 怎么做』,能答出 fanout-on-write/read 混合 + celebrity 特殊处理,已经是高级答案。Day 14 Feed 系统会详细展开。

3. 你设计 SaaS 后端,用 tenant_id 做 shard key。一年后发现:80% 租户 < 1GB,但前 5 名占了总数据的 60%(典型 power law)。这时该怎么演进?

这是经典的『tenant skew』,几乎所有 multi-tenant SaaS 都会遇到。一个分片策略下两层不同的容量级别,必须用『分级架构』解决。

  • 识别层级:先做数据分析,按租户大小划三档:(a)long-tail(< 1GB,几百万个);(b)mid(1GB-100GB,几千个);(c)whale(> 100GB,几十个)。
  • Long-tail:共享 shard pool。一个物理实例放成千上万 tenant,按 hash 分布;这是默认情况。
  • Mid:固定 logical shard。每个 tenant 分配 1 个固定 logical shard,多个 logical shard 共享物理实例,需要时可独立迁移。
  • Whale:dedicated instance。每个 whale 一个独立实例(甚至独立集群),可单独调优、独立 SLA、独立计费。Slack 把 Salesforce/Verizon 这种大客户都单独放 vitess pod。
  • 路由层封装:应用代码看不到差异,get_shard(tenant_id) 查 metadata 决定走哪一档。
  • 过渡机制:tenant 增长跨越档位时(一个 mid 涨到 whale),需要在线迁移;shadow read + dual write + 切流,几小时到几天。
  • 定价上的好处:whale 用独立实例 → 成本可追溯 → 可设置 enterprise tier 收高价。Snowflake、Databricks、MongoDB Atlas 都用这套:『共享 → 独立』就是产品分层的物理基础。

反模式

  • 『全部 tenant 一视同仁 hash 分布』:whale 把 shard 撑爆,long-tail 浪费容量。
  • 『一开始就给每个 tenant 独立 DB』:long-tail 几百万实例运维爆炸。

核心智慧:物理资源向数据分布形状靠拢,而不是硬塞进均匀模型。这也是为什么『tenant_id 做 shard key』只是起点不是终点。

4. Consistent hashing 加节点只搬 1/N 数据,听起来很美。但 Notion 选了 logical-over-physical 而非 consistent hashing。为什么?两者真实工程上差异在哪?

表面看两者都解决『加节点不大搬数据』,但有几个真实工程差异让 logical-over-physical 在 OLTP 数据库场景更友好。

  • (a) 数据迁移粒度。Consistent hashing 加节点时,要搬的是『环上某一段 key』——这些 key 散落在历史所有写入里,迁移本质是 scan + filter,IO 模式随机、慢。Logical shard 迁移是『整个逻辑 shard 整体搬运』——文件级 / 表级 copy,顺序 IO、快、原子性好。
  • (b) 监控与运维直观度。Logical shard 是『可命名实体』:你能看到 shard #237 在 instance-3,占用 X GB,QPS Y。Consistent hash 下 key range 是抽象的环段,监控、debug、容量规划都没有直观对应物。
  • (c) 跨 shard 操作的边界。Logical shard 边界稳定,跨 shard 操作清晰(哪些 query 跨 shard 可预知)。Consistent hash 下相同 key 集合可能在加节点后落入不同段,跨段查询的物理拓扑动态变化。
  • (d) 故障隔离。Logical shard 失败只影响该 shard 的租户;consistent hash 下一段 key 失败可能影响多个不相关业务。
  • (e) 二级索引、外键、约束。OLTP 数据库的 unique / FK 约束往往要绑定到具体表 / shard;logical shard 提供稳定容器,consistent hash 流动的 range 不好绑。
  • (f) Consistent hash 更适合 KV / cache。Memcached、DynamoDB 这种『无 schema、无跨行约束、加减节点频繁』的系统是 consistent hash 天堂。Postgres 这种有 schema 有约束的不是。

结论:consistent hashing 的『最少迁移』优化在 KV cache 类系统价值巨大(节点频繁变动);在 OLTP 数据库(节点变动罕见、每次都是计划内)这点节省不重要,反而是『可观测性 + 运维简洁』更重要。这是为什么 DynamoDB / Cassandra(KV)用 consistent hash,Notion / Figma / Slack(OLTP)用 logical-over-physical。选型不是看哪个理论优雅,是看哪个匹配业务变化频率和运维心智模型

5. 容量估算:你设计一个电商平台,预计 1 亿用户、每用户 100 个订单 / 年、每订单 2KB。要支持 5 年数据。从『要不要分片』『分几片』『shard key』『跨片查询』四个层面给出推演。

第一步:算总规模

  • 5 年订单总数 = 1e8 × 100 × 5 = 5e10 = 500 亿条
  • 裸数据 = 500e9 × 2KB = 1000 TB = 1 PB
  • 加索引(2-3 个二级索引,约 50% 数据量)+ MVCC bloat = 实际 ~2 PB
  • 写 QPS 平均:5e10 / (5 × 365 × 86400) ≈ 317 QPS。但峰值(黑五)放大 20x = ~6.3k QPS
  • 读 QPS:用户每天看 10 次订单页 → 1e8 × 10 / 86400 ≈ 11.6k QPS,峰值 ~200k。

第二步:要不要分片

  • 数据量 2PB 远超单机(即使 r6i.32xlarge 也只有 ~10TB 实用 SSD)→ 必须分片
  • QPS 看似单机能扛,但 2PB 数据在单机上 index 维护、备份恢复、vacuum 都不现实 → 数据量本身就是分片驱动力。
  • 不分片的替代是分布式 SQL(TiDB / Spanner / CockroachDB),但成本可能 3-5x,trade-off 题。

第三步:分几片

  • 目标单 shard:< 1TB 数据 + < 5k QPS(留 50% 余量)。
  • 2PB / 1TB = 2000 个 shard
  • 但 2000 个物理实例太贵 → logical-over-physical:开 2048 个 logical shard,部署在 64-128 个物理实例(每实例 16-32 logical shard)。
  • 未来扩容只需要把 logical shard 迁到新实例,不改路由 hash。

第四步:shard key 选什么

  • 候选 1:user_id。用户查自己订单(90% 流量)一个 shard 完成 ✅。问题:商家维度查询要跨 shard fan-out。
  • 候选 2:order_id(Snowflake-like,含时间戳)。订单详情页 O(1) ✅。问题:用户的『订单列表』要扫所有 shard ❌。
  • 候选 3:(merchant_id, user_id) 复合。商户视角和用户视角都能本地完成。但跨 merchant 的用户『我的所有订单』就难了。
  • 实际选择:user_id 哈希,order_id 包含 user_id 前缀(如 {user_id_hash}_{snowflake}),保证『按 order_id 查也能定位到 shard』。商户维度独立做物化视图(CDC → 商户索引表,按 merchant_id 分片)。

第五步:跨片查询怎么办

  • 用户单维度(订单列表、订单详情)→ 单 shard,无跨片。
  • 商户单维度(商户后台看自家订单)→ 维护商户视角的二级表(CDC 异步同步,按 merchant_id 分片)。
  • 聚合分析(『过去 30 天 GMV』)→ 不走 OLTP,CDC 到 Snowflake / ClickHouse。
  • 风控、推荐→ 走数仓 + 离线 pipeline,不打 OLTP。

成本估算

  • 2PB 实际存储(包含索引、副本 3x)= 6PB。AWS gp3 SSD $0.08/GB/月 → 6PB × $80/TB = $480k/月 仅存储
  • 冷热分层:最近 6 个月热数据(200TB)走 SSD,剩余走对象存储 + ClickHouse → 实际 SSD 需求 600TB,月成本压到 ~$50k。
  • 计算:128 实例 r6i.8xlarge ≈ $150k/月。
  • 总成本量级 $200k-$500k/月,年 $3M-$6M。这就是为什么大型电商必须做冷热分层、数据归档、压缩等优化。

面试关键:能从『数据量』推到『要不要分』,从『QPS + 余量』推到『分几个』,从『主导访问模式』推到『shard key』,从『次要访问模式』推到『二级索引/物化视图』,最后能算出『成本量级』并提冷热分层。这一套下来基本是 staff 级答案。