数据放哪、怎么搬: mod-N 扩容迁走大半, 一致性哈希只迁 ~1/N — 而热点比容量更早杀死你
把分片想成图书馆排书架: mod-N 是"每新加一个书架, 全馆的书重新编号归位", 一致性哈希是"新书架只从左右邻居各抽走几格"。机制本质是 route(key) → shard 这条映射规则决定了扩容时的搬迁量——映射再均匀, 也救不了访问倾斜: 热点由流量分布决定, 不由容量决定。
分片解决"单机装不下、扛不住", 代价是失去跨桶事务与全局二级索引, 还送上两份新工作: 路由(请求找对桶)和重平衡(数据搬匀)。所以老工程师的排序永远是: 先索引、再缓存、后读写分离, 分片是最后的大招——因为它上去了就下不来。
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 才能分区裁剪, 不带就扫全部分区
-- 错: 按 status 切 — 枚举值只有 4 个, 99% 订单都是 paid -- 对: 按 buyer_id 切 — 基数 1 亿, 访问按买家天然散开 SELECT * FROM orders WHERE buyer_id = 9527; -- 关键: 分片键出现在高频 WHERE 里, 请求才能单分片直达
-- MySQL: 按年 range 分区, 时序数据最常用 PARTITION BY RANGE (YEAR(created_at)) ( PARTITION p2025 VALUES LESS THAN (2026), PARTITION p2026 VALUES LESS THAN (2027)); -- 关键: range 好删旧数据 (DROP PARTITION 秒级), 但写全打最新分区
~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 下线
// 每台物理机生成 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 倍弧段
MOVED 重定向——路由错了系统自己纠正。 $ redis-cli -p 6379 GET order:88 (error) MOVED 3440 10.0.0.8:6381 # 关键: -c 参数让客户端自动跟重定向; 不加就把 MOVED 当异常抛 # → redis-cli -c 会自动去 6381 再 GET, 返回真实值
# Kafka 分区重分配限速 50MB/s: 挪副本不打爆网卡 kafka-reassign-partitions.sh --bootstrap-server localhost:9092 \ --execute --reassignment-json-file plan.json --throttle 50000000 # 关键: 迁完必须 --verify 清掉限速配额, 否则残留限流咬住正常同步
// 双写窗口: 新旧库都写, 读还在旧库; 迁移器只搬路由变化的行 writeLegacy(row) // 旧 %4 路由, 回滚线, 失败阻断主流程 writeTarget(ctx, row) // 新 %8 路由, 失败进补偿队列, 不阻断 // 关键: 双写以旧库为准回滚 — 对账通过才逐步切读、摘旧库
sub=$((RANDOM % 16)) INCR "user:9527:like:{${sub}}" # 关键: 后缀决定落分片 — 16 份把 45w QPS 摊到 16 台 # → 读侧 SUM 16 份合并; 单 shard 从 45w 降到 ~3w QPS
per-shard 指标, 再谈扩容。 -- 各分片行数一查, 倾斜立现: 最大的桶 3 倍于均值 SELECT COUNT(*) FROM orders_p0; -- → 412,000,000 SELECT COUNT(*) FROM orders_p1; -- → 138,000,000 -- 关键: max/avg > 1.5 就该查分片键, 别急着加机器
# elasticsearch.yml: 主副分片强制不同机架 node.attr.rack: r1 cluster.routing.allocation.awareness.attributes: rack # 关键: rack 感知 = 副本不与主分片同机架, 机架掉电不丢数据
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 就没活干
// 扩容决策顺序 (伪码) if slowQueries { addIndex(); addCache() } // 便宜, 先做 if writeQPS > 单机上限 || diskFull { shard() } // 贵, 后做 // 关键: 分片不可逆 — 上去就下不来, 是最后的大招不是第一反应
按 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%。
手工 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)自动跟, 裸客户端当异常抛
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 可能换分区, 上线前算够
时序指标按月 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 写永远打最新分区 — 用热点写换"删除免大事务", 划算
租户体量差三个数量级, 哈希切不均; 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 全量快照兜底 — 路由库挂了系统还能跑
大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 自动告警。
扩盘搬副本没限速, 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, 限速配额永久残留, 以后正常同步也被咬
老分片键是"手机尾号", 基数只有 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%, 每步盯路由命中与耗时 -- 关键: 双写期以旧库为准 — 新库只告警不阻断, 任何时刻可退
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 标记"
消费者与 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 # 关键: 亲和不是玄学 — 每条消息少走一次机房骨干, 账单肉眼可见降
-- 错: PARTITION BY HASH(status) → 4 个枚举值 4 个桶, paid 占 99% -- 对: PARTITION BY HASH(buyer_id) → 基数 1 亿, 天然散开 -- 查询形态优先: 分片键必须在最高频 WHERE 里
-- 错: db := dbs[h % 8] 直接上线 → 旧数据一半路由错位, 查不到 -- 对: 先双写后切读; h%4 与 h%8 相同的行不动, 只迁错位的行 -- (%4→%8 实际迁一半; %3→%4 才是迁 3/4 — 越扩越贵)
// 错: ring.add(node, "") 每机 1 个点 → 弧段大小看运气 // 对: for i := 0; i < 150; i++ { ring.add(node, "#"+itoa(i)) } // → 各节点数据量偏差压到 5% 以内
-- 错: SELECT ... WHERE phone='138...' (分片键是 buyer_id → 广播 16 库) -- 对: 先查 phone→buyer_id 映射表, 再单分片直达
// 错: go s.Query(ctx0) // ctx0 无超时 → 尾分片拖死全部 // 对: 单片 ctx 500ms + 总 ctx 800ms, 超时返回部分结果+标记
# 错: GET user:9527:followers → 45w QPS 全打 shard-3 # 对: key 加 {0..15} 后缀拆 16 份 + 本地缓存 100ms 吸收
--throttle 限速 + 低峰窗口 + 可暂停。 # 错: kafka-reassign-partitions.sh --execute 不带 throttle → 网卡 100% # 对: --throttle 52428800 (50MB/s); 白天减半夜里放开, 迁完 --verify
// 错: balancer 全天跑, 写入高峰照样搬 chunk, IO 互相踩 // 对: setBalancerState 只在 02:00-06:00 窗口开 + 写前预分片
-- 错: 16 库 = 16 个逻辑分片, 每次扩物理机都要动路由规则 -- 对: 1024 个逻辑桶 → 物理机映射表, 加机器只搬桶, 路由规则永不变
// 错: 每请求 SELECT shard FROM route WHERE uid=? → 路由库 QPS=全站 // 对: 本地缓存 60s + 变更广播失效; 路由库挂了用 60s 前快照兜底
-- 错: XA 扣库存+订单+优惠券 跨 3 分片 → 任一分片抖全体阻塞 -- 对: 订单+优惠按 buyer_id 同分片落库; 库存走 MQ 异步核销
-- 错: 按天 RANGE, 全部 INSERT 挤进"今天"一个分区 -- 对: RANGE(月) 内再 SUBPARTITION BY HASH(uid) 分 8 份摊写
MOVED/ASK + 重试预算收紧。 # 错: 单机模式 Jedis 连 Cluster → MOVED 当异常抛, 重试仍打老节点 # 对: JedisCluster/Lettuce 自动跟重定向; 重试预算 ≤10% + 退避
// 错: shard = hash(orderDateLocal + uid % 4) → 零点尖峰固定砸一桶 // 对: shard = hash(uid) % 4; created_at(UTC) 只做 range 列不进哈希
-- 错: 新省份 code=64 插入 → ERROR 1526 (HY000): Table has no partition for value 64 -- 对: 上线新枚举前先 ALTER TABLE ... ADD PARTITION, 并对 1526 告警
// 错: app: md5(uid)%16 vs ShardingSphere INLINE: ds_${buyer_id % 16} // 对: 两端共用同一个路由实现, 灰度前全量对拍路由结果
// 错: writeNew(row) 失败被忽略 → 切读后这一单消失 // 对: 新库失败写补偿表, 对账 worker 重放, 行数+CRC 对齐才切读
-- 错: PARTITION BY LIST(tenant), 大租户A和小租户挤同一分区 -- 对: 大租户独立分区/独立库, 长尾租户再按 hash 均摊
# 错: 大盘只有 avg QPS: 8 shard 平均 2w, 看起来很健康 # 对: shard-3 实际 15w — 告警条件: max(shard_qps)/avg > 3
min.insync.replicas=2 但副本未到位. 正解: 迁移限速 + under-replicated 联动告警与自动暂停。 # 错: 搬迁期无人盯 → broker 重启 → NotEnoughReplicasException 全站拒写 # 对: under-replicated > 100 自动暂停搬迁 + 电话告警, 副本追平再续