系统架构 · 数据分布与扩缩容

数据放哪、怎么搬: mod-N 扩容迁走大半, 一致性哈希只迁 ~1/N — 而热点比容量更早杀死你

数据分布与扩缩容 · 一致性哈希环 × 两条扩容路 × 热点拆 key route(key) 决定生死, rebalance 决定夜里的觉 ① 路由: 一致性哈希环 + 虚拟节点 hash(key) 落环, 顺时针找第一个 vnode = 归属分片 顺时针第一个 vnode 归属 key: order:88 CRC32 → 3440 A0 B0 C0 A1 D0·新节点 B1 C1 A2 物理机 A/B/C 各拆 2~3 个 vnode 撒环 (生产 150~200 个) · 新机 D 携自己的 vnode 入环, 只搬 A1→D0 弧段 ≈ 1/N 虚线环 = 哈希空间 0 ~ 2^32-1 卷成圈 · vnode 越多弧段越细, 异构机器按权重配 vnode 数 ② 老路 · mod-N 扩容: %3 → %4 模数一变, 大部分 key 重映射 — 搬家风暴 旧 %3: db0 db1 db2 75% rows moved 3 库扩 4 库 改模数 %4 新 %4: db0 db1 db2 db3 h%3 == h%4 的 key 才留原地 — 3/4 的行换库, 高峰期搬家 = 事故 双写窗口路由漂移 → ERROR 1062 (23000): Duplicate entry '88' for key 'PRIMARY' ③ 新路 · 一致性哈希: 挂 vnode 入环, 只迁 ~1/N 新节点只接管相邻弧段, 其余节点的 key 一个不动 4 节点: n0 n1 n2 n3 ~1/N rows moved 环上只换一段弧 D 携 128 vnode 入环 5 节点: n0 n1 n2 n3 D Kafka / Cassandra / DynamoDB 同源: 一致哈希 + vnode; Redis Cluster 预分片 16384 slot 老客户端当异常抛: (error) MOVED 3440 10.0.0.8:6381 ← slot 已迁走, 不识 MOVED 必炸 ④ 热点比容量更早杀死你 — 大 key 拆散 + 亲和 哈希只保证 key 数量均匀, 不保证访问量均匀 (Skew) 热点 key user:9527 · 大V直播 45w QPS 集中一 key 容量规划救不了流量分布 全打一个分片 shard-3 单分片过载 CPU 100% · conn 8000/8000 GET p99 30000ms 其他 15 个分片: 看戏 客户端大面积 504 net/http: request canceled (Client.Timeout exceeded while awaiting headers) — scatter 尾分片拖死整体 P99 破局: 拆 key + 多副本读 user:9527:{0..15} 拆 16 子 key 分散 单 shard ≤ 3w QPS, 雨露均沾 本地缓存 100ms + 随机副本读吸收 加后缀拆散 user:9527:{0} user:9527:{1} user:9527:{2} user:9527:{3} … 16 份散到全部分片 16 分片雨露均沾 单 shard ~3w QPS 代价: 原子计数没了 — INCR 各子 key, 读时 SUM 合并 大V榜单用预聚合, 别回源全扫 ● 事故链: 大V开播 → user:9527 45w QPS 打爆 shard-3 → scatter/gather 无超时 → net/http request canceled → 订单页 504 → 缓存击穿全站抖动 ● 破局链: 子 key 拆散 {0..15} + 本地缓存 · 扩容用一致性哈希只迁 ~1/N · rebalance 限速 (--throttle) · 倾斜监控 per-shard QPS 读法: 环上顺时针 = key 归属 · rose = 事故路 · emerald = 正确路 · 数字是示意量级, 机制是真的

机制视角 — 路由即命运

  • • route(key) 一旦定下几乎不可逆, 分片键就是数据的户籍
  • • Hash 均匀但失范围 / Range 好删但怕写热点 / List 手工枚举 / Directory 最灵活
  • • vnode 是环上的"权重": 每机 150~200 个, 异构集群也能均匀
  • • 路由三层: 客户端直连 / 代理层 / MOVED 重定向兜底

行为视角 — 搬家的代价

  • • 扩容 = 数据搬家: mod-N 搬走大半, 一致性哈希只搬 ~1/N
  • • rebalance 三铁律: 限速、可暂停、幂等续传
  • • 倾斜让扩容失效: 瓶颈永远在最大的那个分片
  • • 双写窗口是最大的危险期: 路由漂移 = 丢写或重复写

生产价值 — 拆之前先想

  • • 顺序永远是: 索引 → 缓存 → 读写分离 → 最后才分片
  • • per-shard QPS 监控比容量规划更早发现热点
  • • 热点标准解 = 拆 key + 本地缓存, 加机器无效
  • • 逻辑分片一次给够 (1024 桶), 物理扩容只是挪桶

💡 一句话理解

把分片想成图书馆排书架: mod-N 是"每新加一个书架, 全馆的书重新编号归位", 一致性哈希是"新书架只从左右邻居各抽走几格"。机制本质是 route(key) → shard 这条映射规则决定了扩容时的搬迁量——映射再均匀, 也救不了访问倾斜: 热点由流量分布决定, 不由容量决定。

分片解决"单机装不下、扛不住", 代价是失去跨桶事务与全局二级索引, 还送上两份新工作: 路由(请求找对桶)和重平衡(数据搬匀)。所以老工程师的排序永远是: 先索引、再缓存、后读写分离, 分片是最后的大招——因为它上去了就下不来。

🧠 必知必会 必考 & 必会

分区与分片 Partitioning / Sharding
单表/单机扛不住写与数据量时, 按同一规则把数据切到多个桶: 库内切叫分区 (Partition), 跨机切叫分片 (Sharding), 本质都是 route(key) → bucket。代价: 失去跨桶事务与全局唯一约束。
-- MySQL 8.0: 库内 hash 分区, 4 个桶
CREATE TABLE orders (
  id BIGINT NOT NULL
) PARTITION BY HASH (id) PARTITIONS 4;
-- 关键: 分区键必须包含在主键/唯一键里, 否则报 ERROR 1503 (A PRIMARY KEY must include all columns in the table's partitioning function)
-- → id=88 落 p0 (88%4=0); 查询带 id 才能分区裁剪, 不带就扫全部分区
分片键 Shard Key
一旦选定几乎不可逆。两个硬标准: 高频查询能等值命中(避免广播), 基数足够高(避免倾斜)。订单库经典矛盾: 按 buyer_id 切买家查询快, 卖家查询就要广播——标准解法是冗余一份卖家维度的异构表。
-- 错: 按 status 切 — 枚举值只有 4 个, 99% 订单都是 paid
-- 对: 按 buyer_id 切 — 基数 1 亿, 访问按买家天然散开
SELECT * FROM orders WHERE buyer_id = 9527;
-- 关键: 分片键出现在高频 WHERE 里, 请求才能单分片直达
Hash / Range / List / Directory 四种分区法
Hash: 均匀但失去范围语义; Range: 范围查询与归档友好, 但写天然集中; List: 按枚举(地区/租户)手工定桶; Directory: 查路由表, 最灵活, 但路由表自己会成单点。
-- MySQL: 按年 range 分区, 时序数据最常用
PARTITION BY RANGE (YEAR(created_at)) (
  PARTITION p2025 VALUES LESS THAN (2026),
  PARTITION p2026 VALUES LESS THAN (2027));
-- 关键: range 好删旧数据 (DROP PARTITION 秒级), 但写全打最新分区
一致性哈希 Consistent Hashing
把哈希空间卷成环, key 顺时针找第一个节点; 节点增删只影响相邻弧段, 迁移量从 mod-N 的"大半"降到 ~1/N。Dynamo/Cassandra/大量网关的负载均衡全是它。
h := crc32.ChecksumIEEE([]byte("order:88"))
i := sort.Search(len(ring), func(i int) bool { return ring[i].hash >= h })
owner := ring[i%len(ring)].node
// 关键: 顺时针第一个 >= h 的 vnode; 扩容只重划一段弧
// → "order:88" 恒定落同一节点, 除非该 vnode 下线
虚拟节点 Virtual Node
物理机在环上只放 1 个点, 弧段大小纯看运气; 拆成 100~200 个 vnode 撒环: 大机器多挂、小机器少挂(加权), 节点下线时负载由全环分摊而不是压给一个邻居。
// 每台物理机生成 N 个 vnode: vnodeHash = hash(nodeAddr + "#" + seq)
for i := 0; i < 150; i++ {
    ring.add(node, fmt.Sprintf("%s#vn%d", node.Addr, i))
}
// 关键: vnode 数即权重 — 2 倍机器挂 300 个, 承接 2 倍弧段
路由 Routing
路由放哪是架构分叉: 客户端路由(Redis Cluster 智能客户端)省一跳但升级难; 代理层路由(ShardingSphere-Proxy)统一管控但多一跳; 兜底是 MOVED 重定向——路由错了系统自己纠正。
$ redis-cli -p 6379 GET order:88
(error) MOVED 3440 10.0.0.8:6381
# 关键: -c 参数让客户端自动跟重定向; 不加就把 MOVED 当异常抛
# → redis-cli -c 会自动去 6381 再 GET, 返回真实值
重平衡 Rebalancing
节点增减后把数据搬匀的过程。三个铁律: 限速(别打满线上带宽)、可暂停(高峰一键停)、幂等续传(崩了接着搬)。注意 Kafka 的 consumer rebalance 是消费组再分配, 同名不同事。
# Kafka 分区重分配限速 50MB/s: 挪副本不打爆网卡
kafka-reassign-partitions.sh --bootstrap-server localhost:9092 \
  --execute --reassignment-json-file plan.json --throttle 50000000
# 关键: 迁完必须 --verify 清掉限速配额, 否则残留限流咬住正常同步
重分片 Resharding
改分片总数/规则的大手术。线上唯一安全的打法是四步: 双写 → 背景搬迁 → 校验对账 → 灰度切读; 直接停机刷库的都进了事故报告。
// 双写窗口: 新旧库都写, 读还在旧库; 迁移器只搬路由变化的行
writeLegacy(row)       // 旧 %4 路由, 回滚线, 失败阻断主流程
writeTarget(ctx, row)  // 新 %8 路由, 失败进补偿队列, 不阻断
// 关键: 双写以旧库为准回滚 — 对账通过才逐步切读、摘旧库
热点 Hotspot
少数 key 吸走大半流量: 大 V、秒杀 SKU、系统账号。容量规划解决不了它——流量分布一变就换主角。拆法: 子 key 拆散(加随机后缀)、多副本读、本地缓存吸收。
sub=$((RANDOM % 16))
INCR "user:9527:like:{${sub}}"
# 关键: 后缀决定落分片 — 16 份把 45w QPS 摊到 16 台
# → 读侧 SUM 16 份合并; 单 shard 从 45w 降到 ~3w QPS
数据倾斜 Skew
各分片数据量/QPS 不均: hash 不挂 vnode、range 按时间写、list 枚举不均都会造成。症状是"加机器没卵用"——瓶颈永远在最大的分片。先看 per-shard 指标, 再谈扩容。
-- 各分片行数一查, 倾斜立现: 最大的桶 3 倍于均值
SELECT COUNT(*) FROM orders_p0;  -- → 412,000,000
SELECT COUNT(*) FROM orders_p1;  -- → 138,000,000
-- 关键: max/avg > 1.5 就该查分片键, 别急着加机器
本地性 Data Locality
计算尽量靠近数据: 把计算任务调度到数据所在节点/机架, 省的不只是带宽, 更是尾延迟与跨机房流量费。ES 的 rack 感知、Spark 的 locality wait、Kafka 的 client.rack 都在干这件事。
# elasticsearch.yml: 主副分片强制不同机架
node.attr.rack: r1
cluster.routing.allocation.awareness.attributes: rack
# 关键: rack 感知 = 副本不与主分片同机架, 机架掉电不丢数据
自动分片 Auto-sharding
系统自己决定分裂与搬迁: MongoDB chunk 到阈值自动 split + balancer 搬 chunk; Redis Cluster 用 redis-cli --cluster reshard 挪 slot。自动化省人力, 但震荡(split 后仍不均来回搬)要靠预分片 + 合理 chunkSize 压住。
// mongosh: 大写入前预分片, 避免 chunk 反复 split/migrate 震荡
sh.shardCollection("orders.orders", { buyer_id: "hashed" })
sh.splitAt("orders.orders", { buyer_id: NumberLong("2305843009213693952") })
// 关键: hashed 分片可预算 split 点 — 先把环切匀, balancer 就没活干
垂直 vs 水平扩展 Scale-up / Scale-out
垂直扩展(换更大机器)到顶就死: 单机内存/写 IOPS 有物理上限, 单点风险也不变。水平扩展(分片加机器)理论上无限堆, 但引入路由/重平衡/跨片查询的复杂度。
// 扩容决策顺序 (伪码)
if slowQueries { addIndex(); addCache() }      // 便宜, 先做
if writeQPS > 单机上限 || diskFull { shard() }  // 贵, 后做
// 关键: 分片不可逆 — 上去就下不来, 是最后的大招不是第一反应

🏭 生产实战 real world

场景 1 · 订单表 4 亿行水平拆分, 买家卖家两边都要等值查

按 buyer_id 切 16 库后买家查询直达, 卖家查订单却要广播 16 库。解法: 冗余一份按 seller_id 切的 seller_orders 异构表, 双维度各查各的。ShardingSphere MOD 算法配置:

# ShardingSphere 5.x rules.yaml — 订单库水平拆分
rules:
  - !SHARDING
    tables:
      orders:
        actualDataNodes: ds_${0..15}.orders_${0..15}
        databaseStrategy:
          standard:
            shardingColumn: buyer_id            # 买家维度等值直达单库
            shardingAlgorithmName: orders_mod
    shardingAlgorithms:
      orders_mod:
        type: MOD
        props:
          sharding-count: 16
      # 卖家维度不广播的解法: 另存 seller_orders 按 seller_id 再切一份,
      # 下单事务里双写, 用本地消息表保证最终一致

上线后买家/卖家查询都是单分片直达, 广播查询占比从 37% 降到 2%。

场景 2 · Redis Cluster 6 主扩到 8 主, 高峰期不停服迁 slot

手工 CLUSTER ADDSLOTS 容易漏段; 用 redis-cli --cluster reshard 非交互迁移, 控制批量与超时, 全程服务可用:

# 1) 新节点入簇
redis-cli -c -h 10.0.0.8 -p 6379 CLUSTER MEET 10.0.0.1 6379
# 2) 非交互 reshard: 从现有主各抽 4096 个 slot 给新主
redis-cli --cluster reshard 10.0.0.1:6379 \
  --cluster-from all --cluster-to <新主节点ID> \
  --cluster-slots 4096 --cluster-yes
# 3) 迁完体检: 16384 个 slot 必须全覆盖, 无 migrating 卡住的段
redis-cli --cluster check 10.0.0.1:6379
# 迁移中命中迁走的 key 会收到 ASK/MOVED 重定向 —
# 智能客户端(JedisCluster/Lettuce)自动跟, 裸客户端当异常抛

场景 3 · Kafka 订单消息乱序消费, 状态机反复横跳

Producer 没带 key, 轮询发到各分区, 同一订单的"创建→支付→发货"乱序到达。模板: key 哈希分区保住分区内有序, 分区数一次规划到位:

// 同一 key 恒定落同一分区 — 分区内严格有序
ProducerRecord<String, String> rec =
    new ProducerRecord<>("order-events", orderNo, payload);
producer.send(rec, (meta, ex) -> {
    if (ex != null) log.error("send fail {}", orderNo, ex); // 别静默丢
});
// 默认 partitioner: murmur2(key) % 分区数, 同 key 同分区
// 分区数规划: 目标 300MB/s ÷ 单分区 30MB/s = 10 → 留 2 倍余量定 24
// 关键: 分区只能加不能减 — 加分区同一 key 可能换分区, 上线前算够

场景 4 · 监控大盘 8 亿行按月 range 分区, 归档秒删新数据不堵

时序指标按月 range 分区: 查询走分区裁剪, 月底归档 DROP PARTITION 秒级完成, 不产生大事务 undo。关键是预建分区防"无分区可落":

-- 预建下月分区 (别等新月份第一笔写入报 ERROR 1526 才想起来)
ALTER TABLE metrics ADD PARTITION (
  PARTITION p202610 VALUES LESS THAN (UNIX_TIMESTAMP('2026-11-01')));
-- 归档: 秒级删整月, 不锁业务表
ALTER TABLE metrics DROP PARTITION p202501;
-- 查询必须带分区键才裁剪, 否则扫全部分区
EXPLAIN SELECT v FROM metrics
  WHERE created_at >= '2026-09-01' AND created_at < '2026-10-01';
-- 关键: RANGE 写永远打最新分区 — 用热点写换"删除免大事务", 划算

场景 5 · SaaS 3000 租户大小悬殊, directory 路由表 + 本地缓存

租户体量差三个数量级, 哈希切不均; directory 路由表最灵活: 大租户单独指大分片, 长尾哈希均摊。风险是路由表自己成单点——本地缓存 + 版本化变更兜住:

func (r *RouteTable) Lookup(tenant string) (int, error) {
    if shard, ok := r.cacheGet(tenant); ok {
        return shard, nil  // 关键: 命中本地缓存, 路由库 QPS 与业务解耦
    }
    var shard int
    err := db.QueryRow(
        "SELECT shard FROM tenant_route WHERE tenant_id=?", tenant).Scan(&shard)
    if err != nil { return 0, err }
    r.cacheSet(tenant, shard)  // 回填 + 订阅变更失效, 别每请求回源
    return shard, nil
}
// 路由表 3 副本 + 每实例 60s 全量快照兜底 — 路由库挂了系统还能跑

场景 6 · 明星官宣恋情, 缓存命中率 99% 还是崩了 (事故排查)

大V user:9527 的 info key 落在 shard-3, 缓存命中也全打 shard-3 单节点——热点在分片不在 DB。复盘排查路径:

# 1) 全站缓存命中率 99.2%, DB 毫无压力, 但用户超时 → 定位 redis shard-3
redis-cli -h 10.0.0.5 -p 6379 --hotkeys | head
# 2) top key: "user:9527:info" 占该节点请求 87% (45w QPS)
# 3) 处置: info 内容本地缓存 100ms (singleflight 防击穿),
#    key 拆成 {0..15} 多副本, 读随机挑一个副本分摊压力
#    localCache.Get("user:9527:info", 100ms, fetchFromAnyReplica)
# 关键: 命中率是全站指标, 热点要按 per-shard / per-node 看

复盘后给所有读接口补了 per-node QPS 粒度监控, max/mean > 3 自动告警。

场景 7 · 副本重分配打满万兆网卡, 消费堆积 2000 万条

扩盘搬副本没限速, replica fetch 吃光带宽, 生产者 ack 超时雪上加霜。标准三步: 生成计划 → 带限速执行 → 验证并清除限速:

# 生成搬迁计划
kafka-reassign-partitions.sh --bootstrap-server b1:9092 \
  --generate --topics-to-move-json-file topics.json --broker-list 1,2,3,4
# 带限速执行: 50MB/s, 留一半带宽给线上流量
kafka-reassign-partitions.sh --bootstrap-server b1:9092 \
  --execute --reassignment-json-file plan.json --throttle 52428800
# 验证 — 同时负责移除 throttle 配额
kafka-reassign-partitions.sh --bootstrap-server b1:9092 \
  --verify --reassignment-json-file plan.json
# 关键: 不跑 --verify, 限速配额永久残留, 以后正常同步也被咬

场景 8 · 分片键选错两年, 历史数据整体迁到新分片体系

老分片键是"手机尾号", 基数只有 100 且集中在几个尾号; 纠错方案: 影子库按新键建好 → 背景回填 → 对账 → 灰度切读, 全程可回滚:

-- 1) 按 id 段回填影子库 (幂等: 已存在的行跳过)
INSERT IGNORE INTO orders_v2
  SELECT * FROM orders_old WHERE id BETWEEN ? AND ?;
-- 2) 分段对账: 行数 + 内容 CRC 双比对
SELECT COUNT(*), SUM(CRC32(CONCAT(id, amount)))
  FROM orders_v2 WHERE id BETWEEN ? AND ?;
-- 3) 灰度切读: 按租户 1% → 10% → 100%, 每步盯路由命中与耗时
-- 关键: 双写期以旧库为准 — 新库只告警不阻断, 任何时刻可退

场景 9 · 订单搜索跨 16 分片聚合, 尾分片 P99 3s 拖死整个接口

scatter/gather 不设预算, 总延迟=最慢分片; 一个大分片慢查询就把全接口打穿。解法: 并发下发 + 单片超时 + 超时返回部分结果:

ctx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
defer cancel()
results := make(chan []Order, 16)
for _, shard := range shards {
    go func(s *Shard) {
        sc, scCancel := context.WithTimeout(ctx, 500*time.Millisecond)
        defer scCancel()  // 关键: 单片预算 500ms, 慢分片不拖整体
        res, err := s.Query(sc, cond)
        if err != nil { metrics.ShardFail(s.Name); results <- nil; return }
        results <- res
    }(shard)
}
// 汇总只等 ctx 截止 — 超时返回"已到货的部分结果 + partial 标记"

场景 10 · 计算引擎跨机房拉数据, 每月多付 40 万流量费

消费者与 Kafka 副本不在同机房, 每条消息都走一遍机房间骨干。locality 亲和: consumer.rack 对上 broker.rack, 计算贴着数据跑:

# consumer.properties: 只从本机架副本拉流, 跨机房流量归零
client.rack=rack-1
# broker 侧: broker.rack=rack-1 标记机架; 2.4+ 可配 replica.selector.class
# 启用机架感知副本选择, client.rack 与 broker.rack 对上才生效
# Spark 侧: 宁可 1s 调度等待也别跨机架拉数据; 实在没本地就快失败
spark.locality.wait=1s
spark.locality.wait.process=500ms
# 关键: 亲和不是玄学 — 每条消息少走一次机房骨干, 账单肉眼可见降

⚠️ 编码注意与常见坑 pitfalls

坑 1 · 分片键选了 status 低基数枚举列 — 症状: 4 亿行 99% 落在 paid 一个分片, 其余分片空转. 原因: 枚举列基数 <10, 哈希再散也只几个桶. 正解: 选高基数 + 高频等值查询列。
-- 错: PARTITION BY HASH(status) → 4 个枚举值 4 个桶, paid 占 99%
-- 对: PARTITION BY HASH(buyer_id) → 基数 1 亿, 天然散开
--    查询形态优先: 分片键必须在最高频 WHERE 里
坑 2 · mod-N 扩容直接改模数 — 症状: %4→%8 上线当晚一半订单"消失". 原因: 模数一变路由全变, 未迁移的行按新路由找不到. 正解: 双写迁移或一致性哈希, 绝不裸切。
-- 错: db := dbs[h % 8] 直接上线 → 旧数据一半路由错位, 查不到
-- 对: 先双写后切读; h%4 与 h%8 相同的行不动, 只迁错位的行
--    (%4→%8 实际迁一半; %3→%4 才是迁 3/4 — 越扩越贵)
坑 3 · 一致性哈希不挂 vnode, 机器随机倾斜 — 症状: 3 节点环上某节点弧段占 45% 数据. 原因: 每机只放 1 个点, 环上位置纯随机. 正解: 每机 150+ 个 vnode 撒环。
// 错: ring.add(node, "") 每机 1 个点 → 弧段大小看运气
// 对: for i := 0; i < 150; i++ { ring.add(node, "#"+itoa(i)) }
//    → 各节点数据量偏差压到 5% 以内
坑 4 · 无路由层, 查询全分片广播 — 症状: 一个不带分片键的查询打满 16 库, QPS 100 变 1600 连接. 原因: 路由不知道 key 在哪只能全扫. 正解: 强制分片键 + 异构二级索引(ES/映射表)。
-- 错: SELECT ... WHERE phone='138...' (分片键是 buyer_id → 广播 16 库)
-- 对: 先查 phone→buyer_id 映射表, 再单分片直达
坑 5 · scatter/gather 无超时, 尾分片爆炸 — 症状: 单分片慢查询 P99 3s, 聚合接口 P99 也是 3s. 原因: 总延迟=最慢分片, 无预算控制. 正解: 单片超时 + 整体 deadline + 部分结果降级。
// 错: go s.Query(ctx0)  // ctx0 无超时 → 尾分片拖死全部
// 对: 单片 ctx 500ms + 总 ctx 800ms, 超时返回部分结果+标记
坑 6 · 热点名人账号打爆单分片 — 症状: 大V发博, 存其数据的分片 CPU 100%. 原因: 流量按 key 集中, 与容量无关. 正解: 子 key 拆散 + 本地缓存 + 多副本读。
# 错: GET user:9527:followers → 45w QPS 全打 shard-3
# 对: key 加 {0..15} 后缀拆 16 份 + 本地缓存 100ms 吸收
坑 7 · rebalance 不限速, 打满带宽 — 症状: 搬数据当晚线上接口 RT 翻倍, 消费堆积. 原因: 迁移流量吃满网卡与磁盘 IO. 正解: --throttle 限速 + 低峰窗口 + 可暂停。
# 错: kafka-reassign-partitions.sh --execute 不带 throttle → 网卡 100%
# 对: --throttle 52428800 (50MB/s); 白天减半夜里放开, 迁完 --verify
坑 8 · auto-split 震荡, balancer 搬来搬去 — 症状: MongoDB 后台 migrate 不断, IO 周期性抖动. 原因: chunkSize 过小或缺预分片, split 完仍不均. 正解: 大写入前预 split + balancer 限时段窗口。
// 错: balancer 全天跑, 写入高峰照样搬 chunk, IO 互相踩
// 对: setBalancerState 只在 02:00-06:00 窗口开 + 写前预分片
坑 9 · 分片数规划过小, 半年三次扩容 — 症状: 每次扩容都是大手术, 业务陪绑. 原因: 按当前量规划没留余量. 正解: 逻辑分片一次给够(如 1024 桶), 物理扩容只挪桶。
-- 错: 16 库 = 16 个逻辑分片, 每次扩物理机都要动路由规则
-- 对: 1024 个逻辑桶 → 物理机映射表, 加机器只搬桶, 路由规则永不变
坑 10 · directory 路由表单点 — 症状: 路由库一抖, 全站找不到分片. 原因: 路由表无缓存无副本. 正解: 多副本 + 本地缓存 + 版本化推送。
// 错: 每请求 SELECT shard FROM route WHERE uid=? → 路由库 QPS=全站
// 对: 本地缓存 60s + 变更广播失效; 路由库挂了用 60s 前快照兜底
坑 11 · 跨分片事务滥用 2PC — 症状: 下单 RT 从 20ms 涨到 300ms, 锁等待频发. 原因: 每单跨 3 分片强一致, 协调者等所有参与者. 正解: 分片键聚合事务范围, 真跨片用 Saga/消息最终一致。
-- 错: XA 扣库存+订单+优惠券 跨 3 分片 → 任一分片抖全体阻塞
-- 对: 订单+优惠按 buyer_id 同分片落库; 库存走 MQ 异步核销
坑 12 · 时间 range 分片写热点 — 症状: 新月份分区 CPU 90%, 旧分区闲置. 原因: 写全是"最新时间", range 尾部天然热点. 正解: 尾分片高配 + 预建分区; 或 hash+range 混合先散再存。
-- 错: 按天 RANGE, 全部 INSERT 挤进"今天"一个分区
-- 对: RANGE(月) 内再 SUBPARTITION BY HASH(uid) 分 8 份摊写
坑 13 · slot 迁移中途客户端超时重试放大 — 症状: reshard 期间错误率 30%, 重试把源节点打挂. 原因: 迁移中的 key 命中 ASK 重定向, 客户端不懂跟又回源. 正解: 智能客户端识别 MOVED/ASK + 重试预算收紧。
# 错: 单机模式 Jedis 连 Cluster → MOVED 当异常抛, 重试仍打老节点
# 对: JedisCluster/Lettuce 自动跟重定向; 重试预算 ≤10% + 退避
坑 14 · 分片键包含时区 — 症状: 每天零点前后写入集中爆发在同一个分片. 原因: 分片键用本地时区日期, 全球用户零点对齐同一桶. 正解: 分片键用 UTC 或纯 ID, 时间只留给 range 列。
// 错: shard = hash(orderDateLocal + uid % 4) → 零点尖峰固定砸一桶
// 对: shard = hash(uid) % 4; created_at(UTC) 只做 range 列不进哈希
坑 15 · list 分片枚举漏值 — 症状: 新省份上线, 订单插入直接报错. 原因: LIST 分区枚举了 31 省, 第 32 个值没有落点. 正解: 新枚举上线前先加分区 + 对报错告警。
-- 错: 新省份 code=64 插入 → ERROR 1526 (HY000): Table has no partition for value 64
-- 对: 上线新枚举前先 ALTER TABLE ... ADD PARTITION, 并对 1526 告警
坑 16 · 二次哈希不一致 — 症状: 应用层路由与中间件计算落库不一致, 同一 key 两处认为在两个库. 原因: 应用用 md5, ShardingSphere 配 MOD/Groovy, 或输入一边 String 一边 Long. 正解: 路由算法单点实现 + 全量对拍。
// 错: app: md5(uid)%16 vs ShardingSphere INLINE: ds_${buyer_id % 16}
// 对: 两端共用同一个路由实现, 灰度前全量对拍路由结果
坑 17 · 扩容双写窗口不同步 — 症状: 迁移期部分写只进新库, 回滚后数据蒸发. 原因: 双写无主从之分, 新库失败被吞. 正解: 双写以旧库为准, 新库失败进补偿队列, 对账后才切读。
// 错: writeNew(row) 失败被忽略 → 切读后这一单消失
// 对: 新库失败写补偿表, 对账 worker 重放, 行数+CRC 对齐才切读
坑 18 · 枚举/list 分区无法自动均衡 — 症状: 按租户 list 切片, 大租户分片是其他分片 10 倍, 只能手工搬. 原因: list 映射固定, 没有自动重平衡. 正解: 大租户 VIP 独立分片 + 长尾 hash 均分。
-- 错: PARTITION BY LIST(tenant), 大租户A和小租户挤同一分区
-- 对: 大租户独立分区/独立库, 长尾租户再按 hash 均摊
坑 19 · 数据倾斜不监控 — 症状: 扩容 2 倍后大分片照样 502. 原因: 只看全站均值 QPS, 不看 per-shard max. 正解: per-shard 三指标(rows/QPS/RT) + max/mean 比值告警。
# 错: 大盘只有 avg QPS: 8 shard 平均 2w, 看起来很健康
# 对: shard-3 实际 15w — 告警条件: max(shard_qps)/avg > 3
坑 20 · rebalance 期间副本数下降仍写 — 症状: 搬迁 6 小时里 under-replicated 涨到 800, 一个 broker 重启分区全拒写. 原因: min.insync.replicas=2 但副本未到位. 正解: 迁移限速 + under-replicated 联动告警与自动暂停。
# 错: 搬迁期无人盯 → broker 重启 → NotEnoughReplicasException 全站拒写
# 对: under-replicated > 100 自动暂停搬迁 + 电话告警, 副本追平再续