第3章 TiDB vs 分库分表 全面对比
🎯 本章学习目标
- 把你 yunlan 第九章 里 ShardingSphere-JDBC 分库分表的每一条复杂度,逐条对应到 TiDB 的原生能力,建立「TiDB 替你做了什么」的清晰清单。
- 从开发维度对比五个痛点:分片键心智负担、跨库 JOIN/聚合/分页、分布式事务、全局唯一 ID、热点与数据倾斜——说清 TiDB 分别在哪儿消灭了它们。
- 从运维维度对比五个痛点:扩容 rebalance、DDL 变更铺表、存量数据迁移、监控排障、高可用与副本——说清 TiDB 如何自动化。
- 保持批判视角:TiDB 不是银弹,它也有分库分表没有的新成本(集群资源门槛、跨节点网络开销、对 MySQL 程序性对象不支持、小规格不如单机 MySQL 划算)。
- 形成一棵选型决策树:什么数据量/团队/场景该继续分库分表,什么该上 TiDB。
3.1 一张总对比表(先建立全局印象)
| 维度 | 分库分表(MySQL + ShardingSphere) | TiDB |
|---|---|---|
| 编程模型 | 单库单表 SQL + 时刻想着分片键 | 单库单表 SQL,无需分片键 |
| 数据切分 | 人为按分片键拆成 N 库 M 表 | 引擎自动按 Region 切,应用无感 |
| 跨分片 JOIN | 受限,常需绑表/广播表/应用层拼装 | 原生支持,引擎分布式执行 |
| 全局排序/聚合/分页 | 中间件归并,深分页痛 | 引擎原生,跨节点并行归并 |
| 分布式事务 | XA / 柔性事务(Seata)额外引入 | 内置 Percolator 两阶段提交 |
| 全局唯一 ID | 自建雪花/号段 | 自增/AUTO_RANDOM 原生全局唯一 |
| 扩容 | rebalance:搬数据+双写+校验 | 加节点即扩,PD 自动迁移 Region |
| DDL 变更 | 铺到 N 张物理表逐个执行 | 一条 ALTER,在线异步执行 |
| 高可用 | 主从 + MHA/哨兵,运维拼装 | 多副本 Raft,自动故障转移 |
| HTAP 分析 | 需抽数到数仓/单独 OLAP | TiFlash 列存副本原生加速 |
| 运维复杂度 | 高(中间件+多库+同步链路) | 中(一套集群,组件自动协调) |
| 起步资源门槛 | 低(几台 MySQL 即可) | 中高(分布式集群,最少 3+ 节点生产) |
看最后两行就能体会到本章的批判基调:TiDB 用「更高的起步资源门槛」换来「开发与日常运维的大幅简化」。它适合数据量大、增长快、不想被分库分表牵着走的团队;不适合「数据其实没多少、却非要上分布式」的小规模场景。
3.2 开发维度逐项对比(你最直接的体感)
3.2.1 分片键:从「必须记着它」到「不用管它」
分库分表:你选 user_id 做分片键,于是——
// yunlan 第九章的典型写法:查订单"必须"带分片键,否则广播全库
// 不带 user_id 的查询会被迫扫所有分片再归并,性能塌方
SELECT * FROM t_order WHERE order_no = ?; // ⚠️ order_no 不是分片键 → 全分片扫描
// 为了能按 order_no 查,你还得额外建"基因法/映射表"把 order_no 也编码进分片信息TiDB:没有「分片键」这个应用层概念。数据按主键/RowID 自动切 Region,任何条件的查询都由优化器定位相关 Region 并行取数:
-- 不带任何"分片键",TiDB 照样高效执行;要更快就给 order_no 建普通索引即可
SELECT * FROM t_order WHERE order_no = ?; -- ✅ 走 order_no 上的索引,直达对应 Region省掉的事:不用再设计「所有查询维度都尽量带分片键」的取巧方案、不用基因法/映射表解决「按非分片键查」、不用背「哪些表是分片表、分片算法是什么」的心智包袱。
3.2.2 跨库 JOIN、聚合、分页
分库分表:JOIN 两张分片表是老大难——ShardingSphere 要靠「绑定表」(分片规则一致避免笛卡尔积)或「广播表」(小字典表每库都放一份),复杂聚合要在中间件做「改写 + 各分片执行 + 归并」,深分页 LIMIT 100000,10 更是每个分片都取 100010 条再归并,痛。
TiDB:JOIN/GROUP BY/ORDER BY/LIMIT 就是一条普通 SQL,优化器把它编成分布式执行计划,在多节点并行完成并归并(第 6 章看计划)。你不需要「绑定表」这种概念。
3.2.3 分布式事务
分库分表:一次业务写要跨多个库(订单写库 A、库存扣库 C),本地 @Transactional 管不了跨库,得引入 Seata(AT/TCC/SAGA)或 XA,链路复杂、有性能损耗、还有一致性边界要处理。这与站内分布式事务专题讨论的难题同源。
TiDB:跨节点、跨 Region 的 ACID 分布式事务是内置的(Percolator 模型、两阶段提交 + PD 的 TSO 全局时钟)。你写的还是一个 @Transactional,TiDB 底层自动保证原子与隔离——不需要 Seata。
// 分库分表里要跨库、需要 Seata 的场景,在 TiDB 就是一个本地事务:
@Transactional
public void placeOrder(Order o, List<OrderItem> items) {
orderMapper.insert(o); // 可能落在 Region 群 A
itemMapper.batchInsert(items); // 可能落在 Region 群 B —— TiDB 自动保证两者原子提交
}3.2.4 全局唯一 ID
分库分表:多库各自自增会撞,所以你必须上雪花算法/号段模式(yunlan 第九章就做了全局 ID 设计)。
TiDB:主键自增即全局唯一(不保证连续,见第 2 章),或 AUTO_RANDOM 防热点(第 4 章)。业务单号类仍建议应用层雪花——这条经验不变,但你不再被迫为「技术主键」设计发号器。
3.2.5 热点与数据倾斜
分库分表:按 user_id 取模,若某大户数据特别多,它所在分片就倾斜;自增主键写入永远打到「最后一个分片」形成写热点,要靠各种手段打散。
TiDB:自增主键同样可能写热点(尾部 Region 过热),但 TiDB 原生给了打散工具:AUTO_RANDOM 把 RowID 打散到不同 Region、SHARD_ROW_ID_BITS 无主键表打散、PD 自动把过热 Region 迁移到空闲节点(第 4 章详解)。打散是数据库能力,不再是你中间件层的土办法。
3.3 运维维度逐项对比(你凌晨被告警叫醒的次数)
| 运维动作 | 分库分表 | TiDB |
|---|---|---|
| 加机器扩容 | 规划新分片→搬存量数据→双写灰度→校验→切流,一轮数周,心惊胆战 | 加 TiKV/TiDB 节点,tiup scale-out,PD 自动 rebalance Region,对应用透明 |
| 缩容/下线 | 极麻烦,要把数据从待下线库迁走 | 缩容节点,PD 自动补齐其上 Region 副本 |
| DDL 加字段/索引 | 在 N 个物理库 × M 张表逐个执行(gh-ost/pt-osc 铺一遍) | 一条 ALTER TABLE,在线异步自动应用到所有底层数据 |
| 存量数据迁移 | 写脚本按分片规则灌入各库各表 | DM 工具全量+增量自动同步(第 7 章),分库分表还能自动合并 |
| 高可用/故障 | 主从切换靠 MHA/Orchestrator,切换可能丢数据、需人工兜底 | 多副本 Raft,少数副本挂了自动选主/补副本,无需外部组件 |
| 备份 | 多库分别 xtrabackup/mysqldump,再统一编排 | BR 工具对整个集群一致性备份/恢复(第 7 章) |
| 监控排障 | 每库各配监控 + 中间件日志,链路长 | Prometheus+Grafana 统一大盘 + TiDB Dashboard(SQL 洞察/慢查询/Heatmap) |
| 读写分离 | 配 ShardingSphere 读写分离规则 + 主从延迟处理 | 加 TiDB 节点即扩写入口;分析走 TiFlash,天然分流 |
一句话:分库分表的运维复杂度根源是「逻辑一张表 = 物理 N 份,任何全局动作都要 ×N 并保持一致」。TiDB 把「N 份」这件事收回内核,于是扩容、DDL、备份、故障转移都变成「对集群发一个指令,内核自己协调」。
3.4 批判视角:TiDB 的新成本,别只听好消息
上 TiDB 不是没有代价,选型时必须认清:
- 资源门槛高:生产级 TiDB 至少 3 TiKV + 3 PD + 若干 TiDB(还可选 TiFlash),起步就是 6~8 台像样的机器。数据量没到亿级/没到单库瓶颈,硬上 TiDB 是「杀鸡用牛刀」,成本反而高于「一台 MySQL + 偶尔分个表」。
- 网络与延迟:计算与存储分离 + 多副本 Raft,意味着每次提交要跨节点多数派确认,同机房还好,跨城部署延迟敏感型事务会受影响。单机 MySQL 没有这层网络往返。
- 运维技能栈切换:你不再管分库分表,但要管 TiUP 集群、PD 调度、Region、Grafana 大盘——复杂度是转移不是消失,团队要有分布式数据库的运维心智(第 7 章)。
- MySQL 程序性对象不支持:存储过程/触发器/外键/FULLTEXT 要改造(第 2 章清单)。重度依赖这些的存量系统,迁移改造量不小。
- 小查询不如单机 MySQL 极致的低延迟:TiDB 强在「大规模 + 并发 + 弹性」,纯低延迟单行点查、小库场景,单机 MySQL/PG 性价比更高。
3.5 选型决策树
数据量/增速?
├─ 单表 < 千万级且增长可控 ─────────► 优先单机 MySQL/PG;别为分布式而分布式
└─ 单表趋近亿级 / 增长快 / 频繁触到单库瓶颈
│
├─ 团队重度依赖 存储过程/触发器/外键/全文索引,且短期无法改造
│ ────────────────────────────► 谨慎:评估 MySQL 优化/读写分离/局部分表,或改造后再上 TiDB
│
├─ 需要 弹性扩容 + 跨库分布式事务 + HTAP 分析 中任意多项
│ ────────────────────────────► TiDB 价值最大(免分库分表、内置分布式事务、TiFlash)
│
└─ 只是「单库单表撑不住、但业务简单、能接受运维拼装」
────────────────────────────► 分库分表(ShardingSphere) 仍是成熟低成本选项;
但要清楚后续扩容/JOIN/DDL 的持续成本经验总结:
- 分库分表是「用应用层和运维层的复杂度,换一台 MySQL 撑不住的容量」;
- TiDB 是「用更高的硬件/运维门槛,换掉应用层的分库分表复杂度 + 分布式事务 + 弹性 + HTAP」。
- 数据真的巨大、团队不想长期背分库分表的债、还要分布式事务/分析能力 → TiDB 香;规模有限、预算紧、业务简单 → 别勉强上。
3.6 本章小结
- 开发上 TiDB 免掉了:分片键心智、跨库 JOIN/聚合/分页的中间件归并、Seata 式分布式事务(内置 Percolator)、被迫自建全局 ID、打散热点的土办法。
- 运维上 TiDB 自动化了:扩容 rebalance、在线 DDL、DM/BR 迁移备份、Raft 自动故障转移、统一 Dashboard 监控。
- 代价要认清:起步资源门槛高、跨节点网络与延迟、运维技能栈切换、MySQL 程序性对象不支持、小场景不如单机划算。
- 选型看三点:数据规模与增速、是否要分布式事务/HTAP/弹性、团队能否接受分布式运维。分库分表与 TiDB 都对,只是复杂度放在不同层。
✏️ 课后练习
- 拿出 yunlan 第九章 订单分库分表的配置,列出你当时为「分片键、全局 ID、跨库查询、扩容」各做的处理,逐条对应到 TiDB 的原生能力(写成一张「我之前做的 → TiDB 帮我做的」对照表)。
- 用决策树给三个场景做选型判断并说明理由:① 日活百万的评论系统、单表已 8000 万;② 内部后台、数据百万级、大量存储过程;③ 风控:交易库 OLTP + 同时要多维实时分析报表。
- 针对 3.4 的「网络与延迟」,思考:一个对单行读延迟极度敏感(要求 < 1ms)的缓存前置场景,为什么 TiDB 未必比「Redis + 单机 MySQL」更合适?
- 用自己的话回答面试题:「我们都上 TiDB 了,为什么还要关心业务单号用雪花 ID 而不是数据库自增?」(提示:结合第 2 章自增跳号 + 3.4 成本视角)
📖 参考答案
这章答案需要哪种环境? 第 1、3 题的表实验只要任意一种能连上 SQL 终端的环境就行:① WSL2 +
tiup playground(第 1 章第 1 题搭的);② TiDB Cloud Serverless 免费实例(https://tidbcloud.com/free-trial,浏览器 SQL Editor 直接贴)。第 2 题是纯分析题,不用环境。第 1 题的第 6 步、第 3 题的「扩容看 Region」属于生产集群动作,本机 playground 跑不了,答案里只给命令和预期输出形态,不需要你真的有 6 台机器。本章实验表统一用前缀
t_3x_,跑完可以DROP TABLE清掉。所有对账数字都是可手算的:12 条订单总额 900.00、PAID 总额 750.00、1000 行分页表第 981~990 行 id 之和 9855。
第 1 题:拿出 yunlan 第九章订单分库分表的配置,列出你当时为「分片键、全局 ID、跨库查询、扩容」各做的处理,逐条对应到 TiDB 的原生能力(写成一张「我之前做的 → TiDB 帮我做的」对照表)。
第 1 步,先把对照基准抄准。打开 yunlan 第九章,当时的分片规则是 2 库 × 每库 2 表 = 4 张物理表:
# ShardingSphere-JDBC(原书配置,节选)
rules:
- !SHARDING
tables:
t_order:
actualDataNodes: db-orders-${0..1}.t_order_${1..2}
databaseStrategy:
standard:
shardingColumn: user_id # ★ 分库键:按用户取模
shardingAlgorithmName: db-mod
tableStrategy:
standard:
shardingColumn: id # ★ 分表键:按订单 id 取模
shardingAlgorithmName: tbl-mod
t_order_item:
actualDataNodes: db-orders-${0..1}.t_order_item_${1..2}
# ★ 与 t_order 用同样规则 → 这叫「绑定表」,否则 JOIN 会笛卡尔积
shardingAlgorithms:
db-mod: { type: INLINE, props: { algorithm-expression: db-orders-${user_id % 2} } }
tbl-mod: { type: INLINE, props: { algorithm-expression: t_order_${id % 2 + 1} } }
keyGenerators:
snowflake: { type: SNOWFLAKE }第 2 步,把这张配置翻译成你当时被迫做的四件事(这是后面对照表的左列,先立住):
| 你要处理的维度 | 当时具体做了什么 |
|---|---|
| 分片键 | 所有 SQL 尽量带 user_id;按 order_no 查要在单号里埋「基因位」(order_no 末两位 = user_id % 2)或建映射表 |
| 全局 ID | keyGenerator: SNOWFLAKE,主键和业务单号都应用层发号,绝不能靠 MySQL 自增 |
| 跨库查询 | t_order_item 配成绑定表;小字典表配成广播表;GROUP BY/ORDER BY/深分页靠中间件改写 + 归并 |
| 扩容 | 2 库扩到 4 库要重建分片规则 → 新库建表 → 存量按 user_id % 4 搬迁 → 双写 → 校验 → 切流 |
第 3 步,在 TiDB 上建等价的两张单表(不再分片),并灌入可手算的 12 条订单 + 12 条明细:
CREATE TABLE t_3x_order (
id BIGINT PRIMARY KEY,
order_no VARCHAR(32) NOT NULL,
user_id BIGINT NOT NULL,
status VARCHAR(16) NOT NULL,
amount DECIMAL(10,2) NOT NULL,
created DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_uid (user_id),
UNIQUE KEY uk_no (order_no) -- ★ 按 order_no 查靠这条索引,不需要基因法
);
CREATE TABLE t_3x_order_item (
id BIGINT PRIMARY KEY,
order_id BIGINT NOT NULL,
product VARCHAR(32) NOT NULL,
qty INT NOT NULL,
price DECIMAL(10,2) NOT NULL,
KEY idx_oid (order_id)
);
INSERT INTO t_3x_order (id, order_no, user_id, status, amount) VALUES
(1,'O20260101001',1,'PAID',100.00),( 2,'O20260101002',1,'PAID',200.00),
(3,'O20260101003',2,'PAID', 50.00),( 4,'O20260101004',2,'WAIT', 60.00),
(5,'O20260101005',2,'WAIT', 70.00),( 6,'O20260101006',3,'PAID', 80.00),
(7,'O20260101007',3,'PAID', 90.00),( 8,'O20260101008',3,'PAID',110.00),
(9,'O20260101009',4,'CANCEL',20.00),(10,'O20260101010',4,'PAID', 30.00),
(11,'O20260101011',1,'PAID', 40.00),(12,'O20260101012',2,'PAID', 50.00);
-- 每张订单一条明细,金额刻意等于订单金额,方便下一步 JOIN 校验
INSERT INTO t_3x_order_item (id, order_id, product, qty, price) VALUES
(1, 1,'A',2,10.00),( 2, 2,'A',5,40.00),( 3, 3,'C',1,50.00),( 4, 4,'A',6,10.00),
(5, 5,'D',1,70.00),( 6, 6,'A',8,10.00),( 7, 7,'B',1,90.00),( 8, 8,'A',11,10.00),
(9, 9,'E',1,20.00),(10,10,'A',3,10.00),(11,11,'F',1,40.00),(12,12,'A',1,50.00);第 4 步,先确认数据没灌错(对账基数):
SELECT COUNT(*) AS cnt, SUM(amount) AS total FROM t_3x_order;
SELECT status, COUNT(*) AS cnt, SUM(amount) AS amt FROM t_3x_order GROUP BY status ORDER BY status;cnt | total
12 | 900.00
status | cnt | amt
CANCEL | 1 | 20.00
PAID | 9 | 750.00
WAIT | 2 | 130.00手算:100+200+50+60+70+80+90+110+20+30+40+50 = 900;20 + 750 + 130 = 900。后面每一步都用这三个数对账,只要对不上就说明表里数据被改过。
第 5 步,验证「不带分片键的查询」在 TiDB 上是什么行为(对应你当时的基因法/映射表):
-- 分库分表时代:这条 SQL 的 WHERE 里没有 user_id,中间件不知道去哪个库 → 4 张物理表全扫再归并
EXPLAIN
SELECT id, order_no, user_id, status, amount FROM t_3x_order WHERE order_no = 'O20260101007';
SELECT id, user_id, status, amount FROM t_3x_order WHERE order_no = 'O20260101007';| id | user_id | status | amount |
7 | 3 | PAID | 90.00 |
-- 计划里出现的是(算子名字随版本略有差异,认准这两点):
-- Point_Get / IndexLookUp(走 uk_no 唯一索引) 而不是 FullScan
-- 并且没有 “广播 / 全分片” 这种概念判读要点:TiDB 的计划里没有「该去哪个库」这一步,因为只有一张逻辑表;uk_no 是唯一索引,优化器直接定位到索引条目所在的 Region。而在 ShardingSphere 里,同一条 SQL 会被改写成 4 份分别发到 db0.t_order_1/2、db1.t_order_1/2,这就是你当初做「基因法」的全部原因。
第 6 步,验证跨分片 JOIN + 聚合归并变成一条普通 SQL:
-- 分库分表时代:t_order 与 t_order_item 必须是「绑定表」才能这样写;
-- 在 TiDB 上不存在绑定表这个概念
SELECT o.user_id, COUNT(DISTINCT o.id) AS orders, SUM(o.amount) AS order_amt,
SUM(i.qty * i.price) AS item_amt
FROM t_3x_order o
JOIN t_3x_order_item i ON i.order_id = o.id
WHERE o.status = 'PAID'
GROUP BY o.user_id
ORDER BY o.user_id;user_id | orders | order_amt | item_amt
1 | 3 | 340.00 | 340.00
2 | 2 | 100.00 | 100.00
3 | 3 | 280.00 | 280.00
4 | 1 | 30.00 | 30.00手算对账(PAID 的订单:user1 → 1/2/11 号 = 100+200+40 = 340;user2 → 3/12 = 50+50 = 100;user3 → 6/7/8 = 80+90+110 = 280;user4 → 10 = 30;合计 750,与第 4 步的 PAID 750.00 对上)。order_amt 与 item_amt 每行都相等,这条断言顺便证明了 JOIN 没有漏行也没有重复行——分库分表里绑定表配错就会出现「金额对不上」,TiDB 直接把这类 bug 从根上消灭。
第 7 步,验证深分页:先用 0~9 数字表交叉连接造出 1000 行,再做 offset 翻页 vs 游标翻页对比:
CREATE TABLE t_3x_digit (n INT PRIMARY KEY);
INSERT INTO t_3x_digit VALUES (0),(1),(2),(3),(4),(5),(6),(7),(8),(9);
CREATE TABLE t_3x_page (id BIGINT PRIMARY KEY, seq INT, name VARCHAR(20));
INSERT INTO t_3x_page (id, seq, name)
SELECT a.n*100 + b.n*10 + c.n + 1, a.n*100 + b.n*10 + c.n + 1, CONCAT('r', a.n*100 + b.n*10 + c.n + 1)
FROM t_3x_digit a, t_3x_digit b, t_3x_digit c;
SELECT COUNT(*) AS cnt, MIN(id) AS mn, MAX(id) AS mx FROM t_3x_page; -- 1000 | 1 | 1000
-- 写法一:offset 翻页(要跳过 980 行)
SELECT SUM(id) AS s FROM (SELECT id FROM t_3x_page ORDER BY id LIMIT 980, 10) x;
-- 写法二:游标翻页(记住上一页最后一行的 id = 980)
SELECT SUM(id) AS s FROM (SELECT id FROM t_3x_page WHERE id > 980 ORDER BY id LIMIT 10) x;s
9855 ← 两种写法都必须等于 981+982+...+990 = (981+990)*10/2 = 9855两种写法结果必须一模一样(都是第 981~990 行),但代价不同:
| 场景 | offset LIMIT 980,10 实际要处理多少行 | 说明 |
|---|---|---|
| 分库分表(4 个分片) | 每个分片各取 990 行 → 中间件归并 3960 行 | 公式:分片数 × (offset + pageSize),offset 越大越爆炸 |
| TiDB | 仍需扫过 990 条主键,但只有一处排序归并,Region 间并行 | 公式:offset + pageSize,不再 ×分片数 |
| TiDB + 游标 | 只读 10 行 | 走主键 Range,WHERE id > ? 直接把起点定位过去 |
结论一句话:TiDB 让深分页从
n × (offset + k)降到offset + k,但根治靠游标翻页这件事在哪个数据库都一样——别以为上了 TiDB 就能LIMIT 1000000, 10。
第 8 步,验证扩容不需要搬数据(生产集群动作,本机照读即可):
-- 看这张表被切成多少 Region、Leader 分别落在哪个 store
SHOW TABLE t_3x_order REGIONS;
SELECT store_id, address, status_address FROM information_schema.tikv_store_status ORDER BY store_id;# 扩容前(3 个 TiKV):Leader 均匀分布在 store 1/2/3
REGION_ID | START_KEY | END_KEY | LEADER_STORE_ID | PEERS
101 | t_85_r1 | t_85_r5 | 1 | 1,4,7
...
# 生产上执行(tiup cluster,不是 playground):
# tiup cluster scale-out tidb-prod ./scale-out.yaml # 新增 2 台 TiKV
# tiup cluster display tidb-prod
# 之后不需要任何一条 SQL 或应用改动:PD 检测到新 store 是空的,
# 自动把部分 Region 的副本 move/copy 过去,Leader 也会被 transfer-leader 均衡。
# 再 SHOW TABLE t_3x_order REGIONS,LEADER_STORE_ID 就会包含新节点的 store_id。
(具体 Region_ID / store_id 以你实测为准)第 9 步,把第 2、5、6、7、8 步的结论压成交付用对照表(这就是题目要的表):
| 我之前做的(ShardingSphere + 多 MySQL) | TiDB 帮我做的 | 本章验证方式 |
|---|---|---|
databaseStrategy: user_id % 2 选分库键 | 无分片键概念,按 RowID/主键自动切 Region | 第 5 步:不带 user_id 的查询照样走索引 |
order_no 里埋基因位 / 建映射表 | 直接 UNIQUE KEY uk_no(或普通索引) | 第 5 步:WHERE order_no='O20260101007' → Point_Get/IndexLookUp |
keyGenerator: SNOWFLAKE 全局发号 | 自增/AUTO_RANDOM 全局唯一(但业务单号仍建议应用层发号,见第 4 题) | 第 2 章 Q1 实测跳号 6 → 30001 |
t_order_item 配绑定表防笛卡尔积 | 普通 JOIN,优化器自己决定顺序与算法 | 第 6 步:order_amt = item_amt 每行相等 |
| 小字典表配广播表(每库一份) | 不需要,一张表就是全局的 | 第 6 步:单表 JOIN 单表 |
中间件改写 + 归并 GROUP BY/ORDER BY | 分布式执行 + Coprocessor 下推(第 6 章) | 第 6 步:一次 SQL 出 4 行结果 |
深分页 n × (offset+k) | offset+k,Region 间并行 | 第 7 步:两种写法 SUM(id)=9855 |
| 跨库写要 Seata/XA | 内置 Percolator 分布式事务,@Transactional 一把梭 | 第 3.2.3 节;第 5 章有完整工程 |
| 扩 2 库到 4 库:搬数据 + 双写 + 校验 + 切流 | tiup cluster scale-out,PD 自动 rebalance | 第 8 步:SHOW TABLE ... REGIONS 前后对比 |
| DDL 要在 4 张物理表铺 gh-ost | 一条 ALTER TABLE,在线执行 | 第 2 章:ADMIN SHOW DDL JOBS |
第 10 步,收尾清场:
DROP TABLE t_3x_order_item, t_3x_order, t_3x_page, t_3x_digit;这题的坑:
| 坑 | 现象 | 说明 |
|---|---|---|
| 以为「上了 TiDB 就不用管分页」 | LIMIT 1000000,10 依然很慢 | TiDB 只是把 ×分片数消掉了,offset 本身还要扫;游标翻页才是根治 |
把 t_3x_order_item 的 id 与 order_id 写混 | 第 6 步两列金额不等 | 每行明细的 order_id 必须等于订单 id,用第 6 步的等式做自检 |
在 playground 上尝试 scale-out | tiup cluster scale-out 报找不到集群 | playground 不是 cluster 实例,tiup cluster list 结果为空;本题只读命令 |
SHOW TABLE t REGIONS 在单容器 Docker 版报错 | mock tikv 没有 Region | 第 1 章说过的 pingcap/tidb 单容器限制,换 WSL2 + tiup 或 TiDB Cloud 专属版 |
第 2 题:用决策树给三个场景做选型判断并说明理由:① 日活百万的评论系统、单表已 8000 万;② 内部后台、数据百万级、大量存储过程;③ 风控:交易库 OLTP + 同时要多维实时分析报表。
第 1 步,先给决策树装上可算的量——不要凭感觉说「数据很大」。用这条 SQL 拿到 MySQL 上单表的真实体量(TiDB 上同样能查):
SELECT table_schema, table_name,
table_rows AS est_rows,
ROUND(data_length/1024/1024, 1) AS data_mb,
ROUND(index_length/1024/1024, 1) AS index_mb,
ROUND((data_length+index_length)/1024/1024/1024, 2) AS total_gb
FROM information_schema.tables
WHERE table_name = 't_comment'
ORDER BY total_gb DESC;
table_rows对 InnoDB 是估算值(可能偏差 10%~20%),只做量级判断;要精确就SELECT COUNT(*)(大表很慢,放到从库或低峰期)。
第 2 步,场景①:日活百万的评论系统,单表 8000 万 → 结论:上 TiDB(或至少进入 TiDB 评估名单)。
- 增速测算(这就是要写进评审文档的数):日活 100 万 × 人均 3 条评论 = 300 万行/天 → 月增约 9000 万行 → 年增约 10.95 亿行。现有 8000 万 ≈ 不到一个月的量,一年后单表破 12 亿。
- 反推「能不能靠单机 MySQL 撑」:InnoDB 单表经验值几千万~1 亿后 DDL、二级索引维护、慢查询明显恶化 → 撑不住。
- 反推「继续分库分表行不行」:评论系统有两个高频维度——按
post_id(看某篇文章的评论,还要按点赞数排序)和按user_id(我的评论)。分片键只能选一个,另一个维度就要走映射表/异构索引表;再加「运营后台按内容关键词 + 时间范围的多维检索」,中间件归并直接失控。 - TiDB 方案要点:
CREATE TABLE t_comment (
id BIGINT PRIMARY KEY AUTO_RANDOM(5), -- 第 4 章:高频写 + 大导入,防尾部热点
post_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
content VARCHAR(2000),
like_count INT NOT NULL DEFAULT 0,
created DATETIME NOT NULL,
KEY idx_post_like (post_id, like_count), -- 注意:TiDB 不支持降序索引(第 2 章),只建普通联合索引
KEY idx_user (user_id, created)
) PRE_SPLIT_REGIONS = 3; -- 冷启动不集中写一个 Region起步拓扑(生产最小骨架,第 7 章 7.5):3 PD + 2 TiDB + 3 TiKV;存储估算:单行约 500B × 1 亿 ≈ 50GB 原始,× 3 副本 ≈ 150GB 起,NVMe SSD。加节点随时 scale-out。
- 被否方案与理由:①「继续 MySQL 分库分表」——能撑容量,但多维查询要再造一套异构索引 + 每次扩容搬数据,团队已经在背这个债;②「只加读从库」——解决不了写入热点和单表体量,DDL 还是要铺 N 份。
第 3 步,场景②:内部后台、数据百万级、大量存储过程 → 结论:别上 TiDB,继续单机 MySQL/PG。
- 量级:百万级行、
total_gb大概率 < 5GB → 决策树第一个分支「单表 < 千万级且增长可控」直接命中。 - 改造成本:TiDB 不支持存储过程/函数/触发器/事件(第 2 章清单),「大量存储过程」意味着业务逻辑要从数据库搬回应用层或外部调度——这是重写级别的工作量,换来的收益是零(百万级数据单机毫无压力)。
- 如果立项动机是「国产化/信创替代」:那不是 TiDB 的题,应该看达梦/PostgreSQL(站内有达梦教程);TiDB 是分布式架构选型,不是国产替代选型。
- 如果非上 TiDB 不可(比如集团统一技术栈),落地路径只有两条:存储过程逐条改 Java(MyBatis-Plus)+ 外部调度(XXL-JOB);或先把存储过程当黑盒留在 MySQL,用 DM 把表结构同步到 TiDB 做灰度。先做改造评估再决定,不要先建集群。
- 被否方案与理由:①「TiDB 更先进所以选它」——起步 6~8 台像样的机器 vs 一台 8C32G 云主机,成本和运维技能栈都是净增;②「顺手分个表」——百万级不需要。
第 4 步,场景③:风控,交易 OLTP + 同时多维实时分析报表 → 结论:TiDB(TiKV 行存 + TiFlash 列存)最贴合,这是 HTAP 的教科书场景。
- 需求拆解:①交易写入要 ACID + 峰值弹性(OLTP);②报表是「大范围扫描 + 多表关联 + 多维聚合」(OLAP);③「实时」意味着不能接受 T+1 抽数(风控要当下就能查可疑模式)。
- 以前怎么做:MySQL 主库 + 抽数到 ClickHouse/Doris/Hive,链路 = binlog → Kafka → 数仓,延迟分钟到天,双份运维 + 双份数据一致性对账。
- TiDB 方案:
-- 交易表(OLTP):高频写打散
CREATE TABLE t_risk_txn (
id BIGINT PRIMARY KEY AUTO_RANDOM(5),
account_id BIGINT NOT NULL, txn_time DATETIME NOT NULL,
channel VARCHAR(16), amount DECIMAL(18,2), device_id VARCHAR(64),
KEY idx_acc_time (account_id, txn_time)
) PRE_SPLIT_REGIONS = 4;
-- 给同一张表加一份列存副本(第 4 章 4.6):不用抽数、不用第二条链路
ALTER TABLE t_risk_txn SET TIFLASH REPLICA 1;
SELECT TABLE_NAME, REPLICA_COUNT, AVAILABLE FROM information_schema.tiflash_replica
WHERE TABLE_NAME = 't_risk_txn'; -- 必须 AVAILABLE=1 才能用
-- 报表侧:点名列存(第 6 章)
SELECT channel, DATE(txn_time) d, COUNT(*) cnt, SUM(amount) amt
FROM t_risk_txn
GROUP BY channel, d ORDER BY amt DESC LIMIT 20;- 拓扑与隔离:3 PD + 2 TiDB + 3 TiKV + 2 TiFlash(TiFlash 副本数 ≥2 才有冗余);OLTP 和报表用不同账号跑在同一集群,靠 Resource Control 隔离:
CREATE RESOURCE GROUP rg_oltp RU_PER_SEC=20000 BURSTABLE;
CREATE RESOURCE GROUP rg_report RU_PER_SEC=1000;
CREATE USER 'risk_app'@'%' RESOURCE GROUP rg_oltp;
CREATE USER 'bi_ro'@'%' RESOURCE GROUP rg_report;- 被否方案与理由:①「TiDB 不加 TiFlash」→ 大范围聚合压在 TiKV 上会抢交易资源,行存扫描慢;②「MySQL + ClickHouse」→ 数据链路长、实时性和一致性都要妥协,团队要维护两套存储 + 一条同步链路。
第 5 步,把三题答案压成一页纸(面试/评审可直接用):
| 场景 | 关键量 | 结论 | 一句话理由 | 被否方案 |
|---|---|---|---|---|
| ① 评论系统 | 300 万行/天,年增 10.95 亿 | TiDB | 单表已撑不住 + 双维度查询,分片键选不出来 | 继续分库分表(扩容搬数据 + 异构索引) |
| ② 内部后台 | 百万行、<5GB、大量存储过程 | 单机 MySQL/PG | 杀鸡用牛刀,且存储过程要重写,收益为零 | 上 TiDB(成本/运维净增) |
| ③ 风控 HTAP | OLTP + 实时多维报表 | TiDB + TiFlash | 一份数据两种引擎,免抽数链路;Resource Control 做隔离 | 只上 TiKV(报表抢交易);MySQL+CK(链路太长) |
第 6 步,给自己的团队补一个量化门槛(避免「感觉该上了」):
满足下面任意 2 条,TiDB 的「起步门槛」才划算:
□ 单表 > 5000 万行,或年新增 > 5000 万行
□ 查询维度 ≥ 2 个且都高频(分片键选不出来)
□ 需要跨库 ACID(现在在用/准备用 Seata)
□ 需要「同一份数据的实时分析」,不接受 T+1
□ 一年内必须扩容,且不能接受「搬数据 + 双写 + 切流」第 3 题:针对 3.4 的「网络与延迟」,思考:一个对单行读延迟极度敏感(要求 < 1ms)的缓存前置场景,为什么 TiDB 未必比「Redis + 单机 MySQL」更合适?
第 1 步,先把「一次 TiDB 单行主键点查的耗时构成」拆开——这是回答的关键,别停在「分布式所以慢」:
客户端 ──MySQL协议──► TiDB Server ──gRPC──► TiKV Region Leader ──► 返回
①解析+优化 ②跨节点 RPC(一次网络往返) ③读 MemTable/BlockCache + 快照读(MVCC,无需 Raft 多数派)
· 读单行只需访问 Leader 副本一个节点,不触发 Raft 多数派;
但 ② 这一趟网络往返是「跑不掉」的:TiDB 与 TiKV 是计算/存储分离的两个进程(哪怕同机也是 RPC)。
· 写单行才要 Raft 多数派确认(默认 3 副本里的 2 个),同机房约 1~2 个 RTT。第 2 步,确认 TiDB 已经为这种查询准备了最快路径——PointGet / TablePointGet:
CREATE TABLE t_3x_kv (id BIGINT PRIMARY KEY, v VARCHAR(64));
INSERT INTO t_3x_kv VALUES (1,'a'),(2,'b'),(3,'c');
EXPLAIN SELECT v FROM t_3x_kv WHERE id = 2; -- 主键等值 → PointGet/TablePointGet(跳过完整算子树)
EXPLAIN ANALYZE SELECT v FROM t_3x_kv WHERE id = 2;| id | estRows | task | access object | operator info |
Point_Get_1 | 1.00 | root | table:t_3x_kv, indexed value, ... |
(EXPLAIN ANALYZE 里 time≈0.5~2ms 量级,本机 playground 与生产集群差别很大,以实测为准)也就是说 TiDB 不是「把点查当大查询跑」,它专门做了短路优化。但它省不掉跨节点 RPC 和 RPC 排队,这就是 1ms 门槛的来源。
第 3 步,做可跑的对照实验(延迟只有实测才算数)。用一段 Java 计时脚本打 2000 次主键点查:
// TidbPointLatency.java 编译:javac TidbPointLatency.java
// 运行:java -cp .;mysql-connector-j-8.3.0.jar TidbPointLatency
long[] ns = new long[2000];
for (int i = 0; i < ns.length; i++) {
long t0 = System.nanoTime();
try (PreparedStatement ps = conn.prepareStatement("SELECT v FROM t_3x_kv WHERE id=?")) {
ps.setLong(1, (i % 3) + 1); // 循环打同 3 行,让 BlockCache 命中,测的是「最好情况」
ps.executeQuery();
}
ns[i] = System.nanoTime() - t0;
}
java.util.Arrays.sort(ns);
System.out.printf("avg=%.3fms p50=%.3fms p99=%.3fms max=%.3fms%n",
java.util.Arrays.stream(ns).average().orElse(0)/1e6,
ns[(int)(ns.length*0.5)]/1e6, ns[(int)(ns.length*0.99)]/1e6, ns[ns.length-1]/1e6);第 4 步,把结果和典型量级对照(下面是社区常见实测区间,你的网络拓扑决定最终数字,务必以第 3 步实测为准):
| 路径 | P50 | P99 毛刺来源 |
|---|---|---|
| Redis GET(同机房,pipeline 关闭) | 0.05~0.3ms | 大 key、RDB/AOF fork、 eviction、单线程排队 |
| 单机 MySQL 主键点查(buffer pool 命中) | 0.2~0.8ms | 锁等待、刷脏、慢 SQL 抢 CPU |
| TiDB 主键点查(同机房,BlockCache 命中) | 0.5~2ms | Region Leader 迁移/调度、PD TSO 获取、TiKV 线程池排队、GC、Raft 日志同步抖动 |
第 5 步,回答「为什么未必更合适」的四条实质原因:
- 多一跳网络:计算存储分离让「最少一次 RPC」成为下限;单机 MySQL 和 Redis 都在一个进程内完成。1ms 的预算里,RPC 就吃掉大半。
- P99 才是这类场景的生死线:单行点查的 SLA 通常看 P99/P999。TiDB 的毛刺源(Region 调度、Leader 迁移、TSO、GC、副本补齐)比单机 MySQL 多得多;Redis 毛刺源最少。
- 架构定位不同:TiDB 是「带 ACID 的分布式关系数据库」,它给你的是强一致 + 分布式事务 + 弹性;Redis 是「内存数据结构缓存」,它给你的是亚毫秒 + 极高空闲 QPS,代价是没有 SQL、没有跨命令事务(只有 MULTI/EXEC 的弱保证)、数据结构要你自己设计。
- 成本方向相反:为了 <1ms 把 TiDB 的 TiDB/TiKV 部署在同机房低延迟网络、甚至上 NVMe + 高配,这笔钱花下去,点查延迟仍然不如一个 4GB 内存的 Redis。
第 6 步,给出这类场景的正解架构(别把答案写成「TiDB 不行」):
请求 → Redis(热点对象,命中率目标 > 95%,亚毫秒)
↑ miss 时回填,写路径「先更新 TiDB/MySQL,再删缓存」
→ TiDB(持久化 + SQL 灵活性 + 分布式事务 + 多条件查询)
TiDB 负责「算得对、存得下、撑得住增长」;Redis 负责「读得快」。
两者不互相替代 —— 上了 TiDB 也照样要缓存层。第 7 步,如果确实要在 TiDB 上压点查延迟,按性价比排序的手段(都有具体开关,别只会加机器):
-- ① 让读走最新但不必严格线性一致(v6.3+,会话/全局):适合「能容忍极短陈旧」的展示类读
SET SESSION tidb_replica_read = 'read-latest'; -- 可选值:strict(默认)/only-replica/adaptive/read-latest
-- ② 把热点表的 Leader 拉到离应用近的节点(第 4 章 Placement)
CREATE PLACEMENT POLICY near_app LEADER_CONSTRAINTS="[+zone=shanghai]" FOLLOWERS=2;
ALTER TABLE t_3x_kv PLACEMENT POLICY=near_app;
-- 注意:playground 只有一台 TiKV 且没打 zone label,上面的策略会处于「无法满足」状态:
-- SHOW PLACEMENT 能看到绑定关系,但 scheduling_state 会一直停在 PENDING/INPROGRESS(凑不齐副本);
-- 真实效果要在多 TiKV + 打过 label 的集群上看
-- ③ 缩短 SQL 解析与事务链路:用 PointGet 形态(主键/唯一索引等值),避免 SELECT *,
-- 事务尽量短(避免长事务持锁 + 撞 GC 生命周期,第 5 章)
-- ④ 定位瓶颈看数据别看感觉:Dashboard → SQL 语句分析,或命令行
SELECT DIGEST_TEXT, EXEC_COUNT, AVG_LATENCY/1000 AS avg_us, MAX_LATENCY/1000 AS max_us
FROM information_schema.statements_summary
WHERE SCHEMA_NAME = 'test' AND DIGEST_TEXT LIKE '%t_3x_kv%'
ORDER BY AVG_LATENCY DESC LIMIT 5;第 8 步,面试式结论(一句话背下来):
「TiDB 换掉的是分库分表的复杂度,不是缓存层的必要性。单行点查它已经做了
PointGet短路,但计算存储分离让至少一次跨节点 RPC 成为延迟下限,P99 还受 Region 调度影响;所以 <1ms 的缓存前置场景仍然是 Redis 更合适,TiDB 放在缓存后面的持久化 + 强一致这一层。」
第 4 题:用自己的话回答面试题:「我们都上 TiDB 了,为什么还要关心业务单号用雪花 ID 而不是数据库自增?」
第 1 步,先把「TiDB 自增只保证唯一」这件事说清(引用第 2 章 Q1 的实测,别只讲道理):
CREATE TABLE t_2x_id_def (id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(10));
CREATE TABLE t_2x_id_c1 (id BIGINT PRIMARY KEY AUTO_INCREMENT AUTO_ID_CACHE = 1, name VARCHAR(10));
INSERT INTO t_2x_id_def (name) VALUES ('a'),('b'),('c'),('d'),('e'),('f');
INSERT INTO t_2x_id_c1 (name) VALUES ('a'),('b'),('c'),('d'),('e'),('f');
SELECT MAX(id) AS def_max, (SELECT MAX(id) FROM t_2x_id_c1) AS c1_max FROM t_2x_id_def;
-- 然后重启 TiDB 实例(tiup 同 tag 重启)后再各插一行
INSERT INTO t_2x_id_def (name) VALUES ('g');
INSERT INTO t_2x_id_c1 (name) VALUES ('g');
SELECT id, name FROM t_2x_id_def ORDER BY id;
SELECT id, name FROM t_2x_id_c1 ORDER BY id;# 默认 AUTO_ID_CACHE(批量 30000):重启后号直接跳到 30001
def_max=6 → 重启后插入的 g 得到 30001
# AUTO_ID_CACHE=1:连续
def_max=6 → 重启后插入的 g 得到 7→ 默认配置的 TiDB 自增:全局唯一 ✓、连续 ✗、单调递增 ✗(多 TiDB 实例各自持有一段号,见第 2 章 --db 2 实验)。而 MySQL 单机自增在你的直觉里是「连续的」,很多人把直觉带进了 TiDB 就出事。
第 2 步,说清业务单号和主键是两个东西(这是本题的题眼):
| 维度 | 技术主键 id | 业务单号 order_no |
|---|---|---|
| 面向对象 | 数据库自己(聚簇、索引、外键、分片定位) | 人、下游系统、对账、客服、发票 |
| 需要可读 | 不需要 | 需要(2026010112345678 带日期、渠道) |
| 需要幂等 | 无所谓 | 需要:重复下单靠 order_no 唯一索引挡住 |
| 需要跨系统一致 | 不需要 | 需要:出库单、支付流水、退款都引用同一个号 |
| 生命周期 | 建库才有 | 下单瞬间就存在,甚至先落库前就要返回给前端 |
关键推论:order_no 天然要在事务提交之前就生成(前端要立刻回显、支付要先建流水)——数据库自增做不到(它是 INSERT 之后才知道值)。这一条和 TiDB 无关,和 MySQL 也无关。
第 3 步,给「连续」标个价(成本视角,3.4 的延伸):想要全局连续/单调,TiDB 侧只有 AUTO_ID_CACHE = 1:
SHOW CREATE TABLE t_2x_id_c1\G -- 能看到 AUTO_ID_CACHE=1AUTO_ID_CACHE=1意味着不能本地缓存号段,发号要走中心分配器(v6.4+)→ 高并发写入时发号变成串行瓶颈,吞吐明显低于默认批量分配。- 所以「要连续」不是免费的:你要用吞吐换语义。业务真的需要连续编号(发票号、凭证号)时,正确做法是应用层号段表 + 悲观锁/乐观锁发号,而不是把主键自增调成连续。
第 4 步,说清 TiDB 自增/AUTO_RANDOM 的一个隐蔽坑:值可能超过 JS 安全整数(这一条答出来面试基本就稳了):
CREATE TABLE t_3x_ar (id BIGINT PRIMARY KEY AUTO_RANDOM(5), name VARCHAR(10));
INSERT INTO t_3x_ar (name) VALUES ('a'),('b'),('c');
SELECT id, name, (id > 9007199254740991) AS exceeds_js_safe FROM t_3x_ar ORDER BY id;id | name | exceeds_js_safe
1152921504606846978 | a | 1
1152921504606846979 | b | 1
...
(具体 id 由随机分片位决定,必然远大于 2^53,以你实测为准)AUTO_RANDOM为了打散,把高位留给 shard 位 → 生成的值天生就是 10^18 量级,而Number.MAX_SAFE_INTEGER = 9007199254740991(约 9×10^15)。- 后果:如果这个 id 被当作
order_no直接 JSON 返回给前端,浏览器解析时会丢精度(...6978变成...6800),前端拿着丢精度的号来回传 → 查不到/改错单。Java 侧Long没事,所以后端测试全绿,到前端才炸,非常难查。 - 对策(任选,别裸奔):
// ① Jackson:Long 序列化成字符串(全站一刀切,最稳)
@JsonSerialize(using = ToStringSerializer.class)
private Long id;
// ② 或业务单号根本不用数据库 id:应用层雪花/号段 + 日期渠道前缀,天然可读、可控长度
String orderNo = "20260101" + channelCode + snowflake.nextIdStr();第 5 步,再补一个多活/数据回流的理由(第 7 章视角):
· TiCDC / DM 做「TiDB → 下游」或双向同步时,如果发号权在数据库:
下游库的自增序列不知道上游用了哪些号 → 会撞;回流场景尤其明显。
· 应用层雪花把 workerId/datacenterId 按机房分配 → 天然分域不撞,
这也是你在 yunlan 第九章做全局 ID 的原始动机,上 TiDB 后这个动机依然成立。第 6 步,30 秒答案(可直接说出口):
「三个层次。第一,语义不同:
id是给数据库用的,order_no是给人和下游系统用的,业务单号必须在下单瞬间就生成、要带日期渠道、要靠唯一索引做幂等——数据库自增是 INSERT 之后才拿到值,时序上就不满足。第二,TiDB 自增只保证全局唯一,不保证连续也不保证单调(默认批量 30000,重启直接跳号,我实测过 6 → 30001);要连续得AUTO_ID_CACHE=1,那是拿吞吐换语义。第三,防热点用的AUTO_RANDOM会生成 10^18 量级的值,超过 JS 的 2^53 安全整数,直接当前端单号会丢精度。所以业务单号继续用应用层雪花,数据库这边用AUTO_RANDOM做聚簇主键打散,两者各干各的事。」
这题(第 4 题)的坑:
| 坑 | 现象 | 说明 |
|---|---|---|
把 AUTO_RANDOM 的 id 直接当业务单号返给前端 | 前端展示的号被四舍五入,按号查询失败 | 超过 2^53 必丢精度,Long 序列化成 String |
| 以为「TiDB 自增和 MySQL 一样连续」 | 对账/发票校验发现断号就报错 | 只有 AUTO_ID_CACHE=1 才连续,且有吞吐代价 |
用自增 id 做游标分页 WHERE id > ? | 翻页结果重复或漏行 | 多 TiDB 实例并发写时 id 不保证全局单调(各自持有号段),游标应该用「业务时间 + 唯一键」复合排序 |
显式往 AUTO_RANDOM 列插值 | 报 Error 1062/1064 或被静默分配 | 显式插入需 SET GLOBAL allow_auto_random_explicit_insert = TRUE(第 4 章) |
