44.14 分库分表

一句话

分库分表是最后手段:它把「单机容量 / 写入瓶颈」换成「跨分片查询、分布式事务、扩容迁移」三笔长期债;只有当索引、归档、读写分离、缓存、分区表都救不了时才动它,而一旦动了,分片键的选择就决定了后面 90% 的麻烦。

先算门槛:五个信号,五件更便宜的事

信号 经验阈值 为什么是这个数
单表行数 2000 万行(行宽 1KB) 1170 × 1170 × 16 ≈ 2190 万 是 3 层 B+ 树的上限;超过只是多一层,不是性能悬崖(见下)
单库持续写入 5000 TPS(含二级索引与 sync_binlog=1) 再高时 redo/binlog 的 fsync 成为瓶颈
单库数据量 2TB 全备 + 恢复时间超过 RTO(见 36.1 RTO / RPO)
DDL 窗口 加索引 / 改表超过业务低峰窗口 大表 DDL 重写表 + 复制延迟
慢查询分布 Top 10 全在同一张大表,加索引无效 说明是数据量问题,不是索引问题

读写分离只能扩读、写仍然压在唯一主库上(见 44.13 主从复制与读写分离),所以「写 TPS 撞墙」这个信号出现时,分片才是唯一出路。

2000 万行这个经验值与 B+ 树层数的推导见 44.8 索引原理。

2000 万行是「层数边界」,不是「性能悬崖」

这个数字被传成了一条铁律,但它的原始含义只是「3 层 B+ 树装满了」。按页 16KB、单页可用 14KB、页指针 12 字节、主键 int 算一遍:

项 计算 结果
单个索引页能放的指针数 14 × 1024 / 12 1194
单行 0.9KB 时单页放的行数 14 / 0.9 15
3 层树的理论容量 1194² × 15 约 2138 万行
4 层树的理论容量 1194³ × 15 约 255 亿行
单行 5KB 时 3 层树容量 1194² × 2 约 285 万行

(行宽 0.9KB 取自住范儿生产库「订单商品表」的实测均值,与压测用的 1KB 单行接近。)

从 3 层到 4 层,B+ 树只多了两个索引页的读取和一次页修改;上层索引页数量少、几乎常驻 Buffer Pool,所以实测差异是「多 1~2 次比较」,不是「性能断崖」。真正让大表变慢的是另外三件事:单行太宽导致每页放不下几行、局部性失效,以及 DDL 窗口。

所以「2000 万行」应该这样读:

判断 结论
单行 ~1KB、以点查为主、索引能覆盖 2000 万行以上完全可以继续用,不必分表
单行 5KB 以上 3 层容量掉到 285 万行,先「垂直分表」把大字段挪走,而不是水平分片
超 2000 万且频繁加索引/改表 分表的主要理由是 DDL 窗口,不是查询变慢
单库写入 > 5000 TPS 或单库 > 2TB 这才是「必须分片」的信号(见上表)

🔑 「表大就分」是把一个索引与表宽问题,升级成分布式系统的复杂度。分片的判据永远是「哪个资源先撞墙」,不是「行数到了多少」。

在动分片之前,按这个顺序把更便宜的手段用尽:

顺序 手段 能解决什么 成本
1 索引优化(复合索引、覆盖索引) 90% 的「慢」 小时级
2 归档 / 冷热分离 单表行数降到 10% 天级
3 读写分离 读吞吐 天级
4 缓存 读 QPS 与延迟 天级
5 分区表(PARTITION BY) 单机内的 DDL 与归档 天级
6 分库分表 写吞吐 + 单库容量 月级,且不可逆

🔑 判据不是「表多大」,而是「哪个资源先撞墙」:读撞墙 → 缓存 + 从库;写撞墙或单库容量撞墙 → 才轮到分片。按「表大就分」动手,是把一个索引问题升级成分布式系统的复杂度。

四种拆法:先垂直,再水平

分片与分区在扩展性视角下的取舍见 37.3 分片与分区;本节从实现与运维角度展开。

拆法 做法 解决 代价
垂直分库 按业务域拆:订单库 / 用户库 / 商品库 单库连接数、IO、故障域 跨库 JOIN 变成服务调用;分布式事务
垂直分表 主表 + 扩展表(大字段、低频字段) 单行过大、热数据被冷数据拖慢 两次查询;事务跨表
水平分库 同一张表按分片键拆到多个库 写吞吐、单库容量 跨库查询、分布式事务、扩容迁移
水平分表 同库内拆成多张同构表 单表容量、DDL 时长 路由、聚合、跨表分页

🔑 垂直拆分的收益常常被低估:按业务域把「订单」和「商品详情」拆到两个库,可能一次性解决 80% 的连接数与 IO 问题,而且几乎不用改 SQL。 先垂直、再水平,是成本最低的路径。

分片键:第一判据不是均匀,是「查询能带上它」

分片键的通用设计方法——基数、倾斜、热点——见 19.1 分区键设计;本节只讲它在「用户 / 订单 / 商家」这类双维度业务里的取舍。

四条判据,按优先级:

判据 说明 反例
① 覆盖率 80% 以上的查询能带分片键 按 order_id 分片,但 80% 查询是「我的订单」
② 基数与均匀 取值多、分布均匀 按「省份」分片,一线城市一个分片扛一半流量
③ 不可变 分片键一旦写入不能改 用「用户等级」做分片键,升级就要跨片搬数据
④ 事务局部性 一个事务涉及的数据落在同一片 订单与订单明细用不同的分片键
候选键 好处 致命问题
user_id C 端查询单分片;用户维度天然隔离 商家后台、运营报表跨全部分片
order_id 按订单查单分片 用户订单列表跨全部分片
merchant_id 商家后台单分片 C 端用户查询跨全部分片
created_at 范围查询、归档友好 写入热点(当前时间片)、跨月查询扫多片
自增 id 均匀 业务上不可控,扩容后新 ID 与旧数据分布不同

基因法解决「两个维度都要单分片」:

order_id = seq × n + (user_id % n)     // 把分片信息编进订单号低位
分片 = order_id % n = user_id % n      // 按订单号、按用户,落同一片

代价:订单号不再是纯自增(可读性差、不能直接看出时间);需要保证 n 是 2 的幂,或把「基因位」做成固定宽度(如 seq × 1024 + hash(user_id) % 1024)。

🔑 分片键选错的典型症状:均匀但查询不带它——单表慢查询变成了 8 个分片的慢查询,延迟不变、成本 ×8。

分片算法:按「扩容时搬多少」来选

算法 路由 扩容 8→16 需迁移 范围查询 适用
范围(range) 按 ID / 时间区间 0(只加新范围) ✅ 最好 日志、账单、可按时间归档
哈希取模 hash(key) % n 50% ❌ 全分片 分片数固定、查询点查为主
一致性哈希 环上顺时针找节点 加 1 台约 1/(N+1) = 11%;翻倍仍约 50% ❌ 节点会增减
虚拟槽 16384 个槽映射到节点(Redis Cluster) 以槽为单位搬迁,数量同一致性哈希 ❌ 需要在线扩缩容
基因法 分片信息编进业务 ID 取决于取模方式 ❌ 双维度查询(订单 + 用户)

节点增减时的最小迁移方案见 19.4 一致性哈希。

取模为什么是 50%:h % 16 与 h % 8 只有在 h % 16 ≥ 8 时才不同,恰好一半。8 片扩到 16 片要搬一半数据,1TB 就是 500GB。

⚠️ 注意「翻倍」是任何方案都躲不开的最坏节奏:一致性哈希在 8→16 时,8 个新节点合计要接手半个环,迁移量同样是 50%;它的优势体现在「加 1 台」(8→9 只搬约 11%)。而取模在 8→9 时要搬约 89%(h%72 里只有 8 个余数位置不变)。

💡 主流做法是预分 1024 个逻辑分片,再映射到少量物理库(如 1024 → 8 库)。扩容时按逻辑分片搬、改映射表,好处是迁移粒度可控、可在线灰度,而不是「少搬」:8→9 台搬 1/9,8→16 台仍要搬 50%。代价是路由多一层查表(可缓存)。

扩容:双写迁移五步,每步都要有回滚点

步骤 做什么 失败模式 回滚
① 双写 新库建好,写老库 + 写新库(异步或同步) 新库写失败、数据漂移 关掉新库写,回到单写
② 全量迁移 按分片、限速搬历史数据 迁移把主库 IO 打满 暂停迁移任务(可续传)
③ 增量追平 用 binlog / CDC 追迁移期间的变更 binlog 被 purge,追不上 重新全量
④ 校验 行数 + 摘要 checksum + 抽样比对 静默丢行、字段截断 修复后重跑校验
⑤ 切读 → 停旧写 灰度 1% → 10% → 100%,稳定后停老库写 灰度流量发现遗漏的分片键 切回老库(保留 7 天)

时间账:1TB 数据、限速 50MB/s → 5.6 小时;增量追平要求 binlog 保留期覆盖整个迁移窗口(通常 ≥ 3 天)。迁移期间不要做 DDL——两边表结构必须完全一致。

跨分片查询:四种解法,优先级从高到低

解法 做法 适用 代价
① 让查询带上分片键 改接口 / 改表设计(加冗余列) 80% 的查询 改业务代码
② 异构索引表 按 merchant_id 再建一份索引(ES / 独立表),双写 多维度后台查询 双写一致性、存储 ×2
③ 冗余宽表 把要查的字段冗余进主表 少量字段 更新放大
④ 广播 + 内存归并 查所有分片再合并 低频、小结果集 延迟 = 最慢分片、内存放大

深分页:offset 在分片下是乘法

扫描行数 ≈ 分片数 × (offset + limit)

8 个分片、offset=200, limit=10 → 每片取 210 行、共 1680 行;offset=10000 时是 80,160 行,还要在内存里归并排序。

方案 扫描行数 限制
LIMIT offset, n N × (offset + n) offset 越大越慢,不可用于深分页
游标(seek) N × n 只能「下一页」,不能跳页
搜索引擎(ES) 由倒排索引决定 数据同步延迟

⚠️ 聚合也有同样的坑:COUNT / SUM 可以各分片算完再归并,COUNT(DISTINCT) 无法精确归并——只能用 HyperLogLog 近似(1.6 万寄存器误差约 0.8%),或者查明细表。

JOIN:分片后基本判死刑

跨分片 JOIN 要么退化成 N 次查询 + 应用层组装,要么把小表全量广播。正确做法:冗余字段、宽表、或把 JOIN 移到离线数仓。这也是「共享数据库反模式」在分片后的加强版。

全局唯一 ID:分片后主键必须「唯一且大致有序」

方案 结构 优点 问题
雪花(Snowflake) 41 bit 毫秒 + 10 bit 机器 + 12 bit 序列 趋势递增、每节点 4096 ID/ms、无中心依赖 依赖时钟,回拨要处理(等待 / 借位 / 报错)
号段(segment) DB 一次发 1000 个,内存分配 不依赖时钟、性能高 DB 是依赖;重启浪费一段;多实例要双 buffer 预取
UUID 128 bit 随机 本地生成、无依赖 无序 → B+ 树页分裂、写放大 2–3 倍;36 字节主键让所有二级索引膨胀
Redis INCR 单点自增 简单 Redis 故障 / 持久化回退可能导致重号
DB 自增步长 N 库 auto_increment_offset + step=N 简单 扩缩容要改步长,且老数据分布不跟着变

🔑 分片表的主键要「趋势递增」:雪花或号段二选一。UUID 可以做业务 ID(对外暴露),但不要做聚簇索引主键——随机写入会让 InnoDB 的页分裂与缓冲池命中率同时变差。

分布式事务:设计目标是「不跨片」

方案 一致性 性能 复杂度 适用
同一分片本地事务 强 最高 无 首选:分片键 = 事务边界
2PC / XA 强 差(锁持有 = 2 RTT + 提交) 中 极少用
TCC 最终一致 中 高(空回滚 / 幂等 / 悬挂) 资金类
Saga 最终一致 高 中(要写补偿) 长流程
本地消息表 最终一致 高 低 跨片异步场景

具体协议与选型见 24.9 事务模型选型。能靠分片键把事务收进一个分片,就不要引入分布式事务——这是分片设计里回报最高的一条约束。

中间件:客户端 SDK 还是代理

方案 代表 额外跳数 多语言 运维
客户端 SDK ShardingSphere-JDBC 0 ❌(Java 为主) 升级要动应用
代理 ShardingSphere-Proxy、MyCat 0.2–1ms ✅ 独立部署、统一治理
数据库原生 Vitess(MySQL) 0 ✅(协议兼容) 重,需 K8s
自研 — 0 按需 长期维护成本高

⚠️ 代理层有个容易忽略的放大:应用 100 个连接 × 8 个分片 = 800 个物理连接,代理自身的连接池与后端 max_connections 要一起算;SQL 兼容性也要提前压测(跨分片 JOIN、子查询、UNION、ORDER BY + LIMIT 的归并语义)。

上线配置:ShardingSphere 分片规则

# config-sharding.yaml(ShardingSphere 5.x 结构;本机未部署 Proxy,以下配置未验证)
databaseName: order_db
dataSources:
  ds_0: {url: "jdbc:mysql://10.0.1.21:3306/order_0", username: app, password: "***"}
  ds_1: {url: "jdbc:mysql://10.0.1.22:3306/order_1", username: app, password: "***"}
rules:
  - !SHARDING
    tables:
      t_order:
        actualDataNodes: ds_${0..1}.t_order_${0..15}
        tableStrategy:
          standard:
            shardingColumn: order_id
            shardingAlgorithmName: order_mod
        databaseStrategy:
          standard:
            shardingColumn: user_id
            shardingAlgorithmName: user_mod
    shardingAlgorithms:
      order_mod: {type: MOD, props: {sharding-count: 16}}
      user_mod:  {type: MOD, props: {sharding-count: 2}}

两条路由规则指向不同的键:user_id 决定库(同一用户落同一库),order_id 决定表(订单均匀打散)。这就是“分片键第一判据是查询能带上它”的落地——如果查询只带 order_id,库路由就只能扫全部库。

上线验收三查:

-- ① 倾斜:各分片行数应接近;差 2 倍以上说明分片键选错或数据偏斜
SELECT MOD(user_id, 2) AS db_shard, COUNT(*) FROM t_order GROUP BY db_shard;

-- ② 跨片:不带分片键的查询会打全部分片,用代理/中间件的路由日志确认
SELECT COUNT(*) FROM t_order WHERE created_at > NOW() - INTERVAL 1 DAY;

-- ③ 双写一致性:迁移期每天对比新旧表的行数与关键聚合
SELECT COUNT(*), SUM(amount) FROM t_order;

分片键选得对不对,不用等 Proxy 上线就能先验。本机 1 万行 MySQL 26.7 实测(Proxy 后面同一条 SQL 语义相同):

$ mysql -e "SELECT MOD(user_id,2) AS db_shard, COUNT(*) AS rows_cnt FROM t_order GROUP BY db_shard ORDER BY db_shard;"
db_shard	rows_cnt
0	5000
1	5000

跨片查询不是“不能用”,而是要明确知道它扫了几个分片;不知道就是等容量上来后才发现。

回滚开关:双写关闭 → 读回旧表;分片规则回退到单库配置后重载 Proxy(规则操作流程按你的版本执行,未在本机验证)。

可运行:路由、基因法、跨分片归并、扩容比例

1000 个用户 × 每人 10 单 = 1 万单,8 个分片,分片键 user_id,订单号用基因法编入分片信息:

package main

import (
	"fmt"
	"sort"
)

type order struct {
	id       int // 订单号(基因法:低位带分片信息)
	userID   int
	merchant int
	amount   int
}

type shard struct {
	id   int
	rows []order
}

func main() {
	const n = 8 // 分片数
	shards := make([]*shard, n)
	for i := range shards {
		shards[i] = &shard{id: i}
	}

	// ===== 写入:1000 个用户 × 每人 10 单 = 1 万单 =====
	// 分片键是 user_id;order_id 用基因法把「分片信息」编进低位,
	// 这样按订单号也能直接定位到同一个分片(order_id % n == user_id % n)。
	seq := 0
	for uid := 1; uid <= 1000; uid++ {
		for j := 0; j < 10; j++ {
			seq++
			id := seq*n + uid%n
			o := order{id: id, userID: uid, merchant: uid%3 + 1, amount: (seq*37)%1000 + 1}
			shards[id%n].rows = append(shards[id%n].rows, o)
		}
	}
	for _, s := range shards {
		fmt.Printf("分片%d:%d 行\n", s.id, len(s.rows))
	}

	// ===== 路由 ①:按订单号查(单分片)=====
	// 取一个真实存在的订单号:基因法保证 order_id % n == user_id % n
	sample := shards[2].rows[0]
	s := shards[sample.id%n]
	var found *order
	for i := range s.rows {
		if s.rows[i].id == sample.id {
			found = &s.rows[i]
			break
		}
	}
	fmt.Printf("① 按订单号 %d 查:直达分片%d(其余 7 个分片不访问),命中用户 %d\n",
		sample.id, s.id, found.userID)

	// ===== 路由 ②:按 user_id 查(单分片)=====
	uid := sample.userID
	s = shards[uid%n]
	cnt := 0
	for _, o := range s.rows {
		if o.userID == uid {
			cnt++
		}
	}
	fmt.Printf("② 按用户 %d 查订单:直达分片%d,命中 %d 单(其余 7 个分片不访问)\n", uid, s.id, cnt)

	// ===== 跨分片:按商家查 + 深分页 =====
	const merchant, offset, limit = 2, 200, 10
	page, total := queryByOffset(shards, merchant, offset, limit)
	fmt.Printf("③ 商家 %d + offset=%d:每片取 offset+limit 行 → 共扫 %d 行,归并后返回 %d 行,首行金额 %d\n",
		merchant, offset, total, len(page), page[0].amount)

	cursorPage, totalCursor := queryByCursor(shards, merchant, 1<<30, limit)
	fmt.Printf("   同一查询改用游标(id < last):每片取 limit 行 → 共扫 %d 行,返回 %d 行,首行 id %d\n",
		totalCursor, len(cursorPage), cursorPage[0].id)

	// ===== 扩容:8 分片 -> 16 分片,有多少行要搬 =====
	moved := 0
	for _, sh := range shards {
		for _, o := range sh.rows {
			if o.id%n != o.id%(2*n) {
				moved++
			}
		}
	}
	fmt.Printf("④ 扩容 8→16 分片:需迁移 %d/%d 行(%.0f%%)\n", moved, seq, 100*float64(moved)/float64(seq))
}

// queryByOffset 模拟「广播 + 内存归并」:每个分片都取前 offset+limit 条,
// 汇总后统一排序再切片。
func queryByOffset(shards []*shard, merchant, offset, limit int) (page []order, scanned int) {
	var cand []order
	for _, s := range shards {
		var rows []order
		for _, o := range s.rows {
			if o.merchant == merchant {
				rows = append(rows, o)
			}
		}
		sort.Slice(rows, func(i, j int) bool { return rows[i].amount > rows[j].amount })
		if len(rows) > offset+limit {
			rows = rows[:offset+limit]
		}
		scanned += len(rows)
		cand = append(cand, rows...)
	}
	sort.Slice(cand, func(i, j int) bool { return cand[i].amount > cand[j].amount })
	if len(cand) > offset {
		cand = cand[offset:]
	} else {
		cand = nil
	}
	if len(cand) > limit {
		cand = cand[:limit]
	}
	return cand, scanned
}

// queryByCursor 模拟游标分页:每片只按条件取 limit 条,再归并。
func queryByCursor(shards []*shard, merchant, cursor, limit int) (page []order, scanned int) {
	var cand []order
	for _, s := range shards {
		var rows []order
		for _, o := range s.rows {
			if o.merchant == merchant && o.id < cursor {
				rows = append(rows, o)
			}
		}
		sort.Slice(rows, func(i, j int) bool { return rows[i].id > rows[j].id })
		if len(rows) > limit {
			rows = rows[:limit]
		}
		scanned += len(rows)
		cand = append(cand, rows...)
	}
	sort.Slice(cand, func(i, j int) bool { return cand[i].id > cand[j].id })
	if len(cand) > limit {
		cand = cand[:limit]
	}
	return cand, scanned
}

输出:

分片0:1250 行
分片1:1250 行
分片2:1250 行
分片3:1250 行
分片4:1250 行
分片5:1250 行
分片6:1250 行
分片7:1250 行
① 按订单号 90 查:直达分片2(其余 7 个分片不访问),命中用户 2
② 按用户 2 查订单:直达分片2,命中 10 单(其余 7 个分片不访问)
③ 商家 2 + offset=200:每片取 offset+limit 行 → 共扫 1680 行,归并后返回 10 行,首行金额 939
   同一查询改用游标(id < last):每片取 limit 行 → 共扫 80 行,返回 10 行,首行 id 80000
④ 扩容 8→16 分片:需迁移 5000/10000 行(50%)

四条结论:

  • 基因法让「按订单号」和「按用户」都只碰 1 个分片——这是双维度查询唯一不靠冗余表的解法;
  • 按商家查是广播:商家维度与分片键无关,8 个分片全要访问——要么接受,要么上异构索引;
  • 深分页是乘法:offset=200 就扫了 1680 行(8 × 210),游标只要 80 行(8 × 10),21 倍;offset 再大 10 倍,差距就是 210 倍;
  • 取模扩容搬一半:8→16 有 50% 的行要迁移,这就是「分片数一次定够 / 预分逻辑分片」的原因。

反模式

反模式 后果
按 created_at 分片做订单主表 写入全压当前时间片;用户查订单跨所有分片
分片键均匀但查询不带它 单表慢查询变成 N 个分片的慢查询,成本 ×N
用 UUID 做主键 页分裂、写放大 2–3 倍、所有二级索引膨胀
分片后还在跨分片 JOIN 退化成 N 次查询 + 应用层组装,延迟不可控
用 LIMIT offset, n 做深分页 扫描行数 = N × (offset+n),越翻越慢
每张表用不同的分片键 跨片事务、跨片 JOIN 全部出现
分片数一次定成 8,不留余量 扩容要搬 50% 数据,窗口不可控
分片后仍用单库自增 ID 多库 ID 冲突,或步长调整导致分布不均
迁移不做校验、直接切读 静默丢行,几个月后对账才发现
迁移期间做 DDL 两边结构不一致,增量追平失败
代理层不算连接数放大 应用 100 连接 × 8 分片 = 800 物理连接,打满数据库
小表也分片(配置表、字典表) 复杂度上升,收益为零

自检

Q1 单表 5000 万行,慢查询 Top 10 都是「按 user_id 查订单列表」。同事要求立刻分库分表。你的判断和行动顺序?

▸ 答案:先别分。5000 万行 × 1KB ≈ 50GB,若 buffer pool 有 128GB,数据可以全放内存,分片的收益主要是「DDL 与备份窗口」而不是读性能。行动顺序:① 确认 (user_id, created_at) 复合索引是否存在、是否覆盖查询列(多数「按用户查列表」慢在这里);② 归档 12 个月前已完结订单到历史表,行数降到 10%–20%;③ 若读 QPS 仍高,加从库 / 缓存(37.4 读写分离);④ 只有当写入 TPS 持续 >5000 或单库 >2TB 导致备份恢复超 RTO 时,才按 user_id 水平分片。判据是「哪个资源撞墙」,不是「表有多少行」。

Q2 分片键选 created_at(按月分表)看起来「范围查询友好、扩容简单」,为什么大多数订单场景不能这么选?

▸ 答案:三个问题。① 写热点:所有新订单都落在当前月分片,其他分片闲着——分片本来是为了分摊写入,结果写全压一个片;② 查询放大:「我的订单」要扫所有月分片,用户越老、分片越多;③ 扩容不等于零成本:虽然不用搬历史数据,但新月份的分片要建索引、要预热,且跨月统计要归并。按月分表只适合「天然按时间访问、不按用户查」的数据:日志、账单归档、监控指标。订单表应按 user_id 分片,时间只做分片内的二级索引。

Q3 8 分片扩到 16 分片,同事说「一致性哈希只搬 1/8,为什么我们还搬了 50%」?怎么把迁移量降下来?

▸ 答案:因为你们用的是取模,h % 16 ≥ 8 的那一半全部换了位置,迁移量恰好 50%。同事把「加 1 台」的数字套到了「翻倍」上:一致性哈希只有在增加 1 个节点时才只搬 1/(N+1)(8→9 约 11%);节点数翻倍时,8 个新节点合计要接手半个环,迁移量同样是 50%。真正的降法不是换算法,而是换节奏与形态:① 一次只加 1–2 台(8→9 搬 11%,但会经历 8→9→10… 多轮迁移);② 用预分逻辑分片 + 灰度搬迁,把 50% 的搬迁拆成可暂停、可回滚的小批次,而不是让它变少;③ 按范围分片,新数据只进新分片、历史数据不动(代价是写热点)。没有「免费扩容」:要么平时多付路由复杂度,要么扩容时多付迁移时间。

Q4 8 个分片,offset=10000, limit=20 的深分页要扫描多少行?给出两种改法。

▸ 答案:8 × (10000 + 20) = 80,160 行,还要在内存里归并排序,P99 必然爆炸。改法一:游标分页(WHERE id < last_id ORDER BY id DESC LIMIT 20),每片只取 20 行、共 160 行,代价是不能跳页(只支持「下一页」),适合信息流 / 订单列表。改法二:搜索引擎(ES / 异构索引),由倒排索引直接定位,代价是数据同步延迟与一致性维护。产品上还可以限制最大可翻页数(如 100 页),把长尾需求导到「筛选条件」而不是「翻页」。

Q5 分片后为什么不能用 UUID 做主键?如果业务一定要全局唯一字符串 ID,怎么办?

见 44.24 发号器与全局 ID:分片之后主键怎么发、位宽怎么切。

▸ 答案:InnoDB 是聚簇索引,主键决定行的物理顺序。UUID 随机分布会让插入落在 B+ 树的任意页:页分裂频繁、缓冲池命中率下降、写放大 2–3 倍;而且 36 字节的主键会被每个二级索引复制一份,索引体积膨胀数倍。解法:① 主键用雪花 / 号段(趋势递增、8 字节),对外暴露的业务 ID 另用 UUID / 短链,两者用唯一索引关联;② 若必须用字符串主键,用有序的(如时间前缀 + 随机后缀),并在应用层保证单调;③ 用 BINARY(16) 存 UUID 并做时间有序 UUID(v7),比 CHAR(36) 省一半空间、恢复部分局部性。

关联