分片把数据分散到多台机器,不会自动把“同一个热门所有者”的请求分散掉。更难的是迁移:复制完旧分片的快照,若此时旧分片还在接更新,新分片可能永远缺少那次写入。先决定数据归属、读写路径和切换窗口,才有资格讨论加分片。

接续复制与故障切换的分享元数据场景。本篇只把 owner_id 作为候选数据归属键:POST /shares 和 DELETE /shares/{id} 找到唯一写入分片,GET /users/{owner_id}/shares?after=... 在单个所有者的有序列表上翻页。GET /shares/{id} 还需有从分享 ID 找归属的稳定映射,不能只拿一个随机 ID 猜所有者;目录映射或把可校验路由信息编码入 ID 都会新增维护成本。排除跨地域、公开搜索和双主同时写入。

量化热点,别只数机器

独立教学假设:每天 800 万次列表读取、40 万次写,读写比 20:1;峰值读 1000 次/秒、写 50 次/秒,均为日均的 10.8 倍。按 1 KiB/次读取,峰值有效载荷约 0.977 MiB/s ≈ 8.19 Mbit/s,不含索引扫描、日志或复制。每次写入 800 B 元数据、200 B 索引,保留 365 天、两份:400000 × 1000 × 365 × 2 = 292 GB;读写合计 840 万次/日、每次 200 B 日志、7 天一份:11.76 GB,总 303.76 GB。若练习单价是 0.02 货币单位/(GB·月),这些存储项约 6.08 货币单位/月,不含跨分片查询、迁移期间双份数据、网络、实际备份和机器成本。

假设峰值读取里 55% 都属于同一个所有者,其余 45% 均匀分布在四个分片。按所有者哈希,每个分片基础负载 450 / 4 = 112.5 read/s,热门所有者所在分片要承受 550 + 112.5 = 662.5 read/s,约为其他分片的 5.89 倍。把这个所有者整体迁到另一台机器,只是把这 550 次/秒也搬过去;若对 1 KiB 响应的峰值再翻倍,数据出口和热点路径的字节负载也翻倍,哈希分配仍不能切开同一个所有者。要降低单个归属的读热点,可以先看可缓存且不违反撤销契约的公开读,或把数据按分享 ID 再拆并支付跨分片合并列表的代价;不能为了均衡直接把撤销权限检查改成弱读。

列表合法读 p99 小于 200 ms、创建 p99 小于 300 ms 暂作 SLO;迁移额外要求切换后不能漏已确认写入,同一条目版本不得倒退。本篇无 p99 测量。出发条件应是观测到按所有者归属的分片已持续饱和,且已确认索引、应用、缓存都不是首先受限的资源,而不是看到数据量增长就预先做分片。

路由映射比哈希公式更需要版本

最小方案先保留上一章的一主一备;如果单个权威库仍能守住延迟和容量目标,就没有理由引入分片。确实需要分片时,给 owner_id 到分片的归属建立可追踪的路由版本:写操作先确认版本再到唯一权威分片,跨版本旧请求必须拒绝或转发,不能同时接受两处写入。(owner_id, created_at, id) 有序索引留在本分片;按 ID 读取的归属映射需与数据创建事务/变更方案一起设计。

flowchart LR
    C[客户端] -->|按 owner / share 请求| R[带 epoch 的路由服务]
    R -->|epoch=1,迁移前唯一写主| O[(旧分片)]
    O -->|先复制快照,再补更新| N[(新分片)]
    R -->|完成校验并切到 epoch=2 后再写| N
    O -->|版本差异 / 积压指标| R
    N -->|核对读版本及行数| R
    R -->|成功或拒绝过期路由| C

这是待验证的设计,不是 SQLite 实验实现的路由系统。范围分片利于某些区间查询,但单个热区也可能更热;按所有者哈希便于单所有者列表和权限归属,却不消除倾斜。需要迁移时,正确性的难点不是“新分片已经有表”,而是“切换时刻以前旧分片确认的更新有没有补齐”。

在两份真实 SQLite 数据里制造漏写

examples/system-design/labs/08/migrate.py 运行两份隔离的 SQLite 3.45.1 内存数据库。先将 hot 的分享 7、版本 1 (draft) 从旧分片复制给新分片;旧分片此后更新为版本 2 (ready)。若立即切换路由,新分片仍显示版本 1,这会把已确认的更新漏掉。脚本再显式读取旧分片新值,用 ON CONFLICT ... DO UPDATE ... WHERE excluded.version > shares.version 将版本 2 重放到新分片;之后到达的版本 1 不再覆盖版本 2(SQLite UPSERT 文档;本次以真实 SQLite 行为核对)。正常命令退出 0,--strict-snapshot 把“只复制快照”判为错误退出 2;原始输出和限制见 examples/system-design/evidence/08/migration.md。

1
2
python3 examples/system-design/labs/08/migrate.py
python3 examples/system-design/labs/08/migrate.py --strict-snapshot
sequenceDiagram
    participant W as 写客户端
    participant O as 旧分片
    participant N as 新分片
    participant R as 路由
    O->>N: t0 复制 (id=7,version=1,draft)
    W->>O: t1 UPDATE id=7 为 version=2,ready
    O-->>W: 旧分片确认写入
    Note over R,N: 若此刻直接切换,N 仍为 version=1:漏写
    O->>N: t2 重放 version=2 更新
    N-->>R: version=2 校验通过
    R->>N: t3 切换所有权后承接新写
    Note over R,O: 旧路由应被栅栏挡住;实验未实现栅栏

固定输入只演示一条手工重放,没实现 CDC、并发快照、阻写栅栏或原子路由广播。真实迁移必须在切换前核对增量位置,避免 t2 与 t3 之间再次漏写;切换后的回退也不能只把路由拨回旧库,否则新库的更新又会丢。失败恢复时先停新写或在旧、新分片之间建立可信的差异核对与反向同步,再恢复服务。面试里可以问“热点所有者移动后最高单分片 QPS 会不会下降”,再问“旧分片在快照之后成功提交了一次更新怎么办”;能回答这两个问题,比画几台节点更接近设计的核心。

参考资料

  • SQLite:UPSERT:仅用于解释本地实验的冲突条件更新行为;没有用 SQLite 证明生产集群迁移可行。
  • 本地固定输入与原始结果:examples/system-design/evidence/08/migration.md,SQLite 3.45.1,未做真实分片调度或压力验证。