预见猿份
主题
首页面试题模拟面试SQL练习在线工具关于我们老苗一对一私教学员评价
实战项目
零基础学习路线项目前置基础创新WMS项目Java微服务框架与实战云岚到家项目闪聚支付项目学成在线项目青橙电商项目JVM原理与实战调优分布式事务专题Java高频面试题MySQL从入门到精通达梦数据库从入门到实战MongoDB从入门到实战PostgreSQL从入门到实战TiDB从入门到实战Java数据结构与算法Java 并发编程(JUC)实战课程老苗一对一私教学员评价
blog我的会员
  • TiDB从入门到实战|做过分库分表的Java程序员的分布式SQL课程

    • 课程介绍
    • 第1章 认识TiDB与整体架构
    • 第2章 MySQL兼容性与差异清单
    • 第3章 TiDB vs 分库分表全面对比
    • 第4章 数据建模与分布式特性
    • 第5章 Java集成实战
    • 第6章 HTAP、执行计划与调优
    • 第7章 迁移与日常运维







----- 到底线了 -----

第2章 MySQL 兼容性与差异清单 ​

🎯 本章学习目标 ​

  1. 建立正确预期:TiDB 高度兼容 MySQL 5.7/8.0 协议与常用语法,绝大多数 CRUD、索引、事务、JOIN、窗口函数都能直接用;但有一批对象根本不支持、一批特性行为不同。
  2. 能背出「完全不支持清单」并说出对应替代方案:存储过程/函数、触发器、事件调度器、外键约束、FULLTEXT、SPATIAL/GIS、自定义函数 UDF。
  3. 讲清「行为不同」里最容易坑 Java 程序员的几项:自增 ID 跳号与 AUTO_ID_CACHE、AUTO_INCREMENT 兼容模式、默认排序规则 utf8mb4_bin、事务隔离级别 RC/SI、视图不可更新。
  4. 知道 DDL 是「在线异步」:不像 MySQL 秒级完成,TiDB 加索引/改表后台逐 Region 执行,大表要等,且一条 ALTER 不能对同一列做多个操作。
  5. 理解 GC(垃圾回收)机制:TiDB 多版本数据靠 GC 清理,默认保留期 10 分钟,长事务/长查询会因快照被回收而报错。
  6. 形成一张迁移前兼容性核对清单,能在动手前把业务 SQL 过一遍筛。

2.1 兼容性总览:什么能直接用 ​

先给「定心丸」——以下你天天用的,TiDB 基本照单全收:

  • 标准 DDL:CREATE TABLE / INDEX / VIEW(视图查询可用)、ALTER TABLE(在线,见 2.5)。
  • DML:INSERT / UPDATE / DELETE、REPLACE、INSERT ... ON DUPLICATE KEY UPDATE、批量插入。
  • 查询:多表 JOIN、子查询、UNION、GROUP BY / HAVING、ORDER BY、LIMIT、CTE(WITH)、窗口函数(8.0 那套基本可用)。
  • 数据类型:常用标量类型、JSON、DECIMAL、ENUM、时间类型等。
  • 事务:START TRANSACTION / COMMIT / ROLLBACK、分布式事务、SELECT ... FOR UPDATE(悲观模式)。
  • 权限:GRANT / REVOKE、角色(8.0 语义),MySQL 生态工具(mysqldump、Navicat、DBeaver、MySQL Workbench)。

一句判断口诀:「CRUD 和查询分析类」几乎无痛兼容;「程序性对象(存储过程/触发器/事件)」必须搬走,「强约束(外键/CHECK)」要看版本与开关。 下面分别展开。

2.2 完全不支持的功能清单(迁移前必须先排雷) ​

这是从 MySQL 迁 TiDB 最硬核的一条差异。TiDB 不支持下列 MySQL 特性,评估业务时逐个排查:

不支持项影响TiDB 侧替代方案
存储过程 / 函数大量业务逻辑若写在 DB 里会直接失效把逻辑上移到 Java 应用层(Service 编排),或用外部脚本
触发器 Trigger依赖触发器做审计/联动的要改应用层实现;数据联动/同步用 TiCDC(第7章)
事件调度器 Event库内定时任务失效外部定时:Spring @Scheduled、XXL-JOB、cron
自定义函数 UDF无法注册 DB 层函数应用层实现
外键约束 FOREIGN KEY⚠️ v6.6 起支持(实验)、v8.5.0 起为正式功能,但旧集群/旧教程里它是「不生效」的代名词;且分区表、BLOB/TEXT 列、虚拟生成列上不支持新版:直接用,并评估锁冲突;旧版/重度依赖:仍建议应用层保证一致性 + 事务
全文索引 FULLTEXT中文/关键词全文检索失效用 ES 等外部检索( yunlan 项目正是 ES 做搜索);或 LIKE(受限)
空间类型 GIS/GEOMETRY地理类型/空间索引不支持外部地理方案;坐标可存 DECIMAL/JSON 自算
XA 语法不能像 MySQL 那样手写 XA STARTTiDB 内部本就两阶段提交做分布式事务,无需你手动 XA
CREATE TABLE ... AS SELECT该语法不支持先 CREATE TABLE 再 INSERT ... SELECT(否则报 ERROR 1105 (HY000): 'CREATE TABLE ... SELECT' is not implemented yet)
OPTIMIZE/CHECK/CHECKSUM/REPAIR TABLE这些 MySQL 维护语句无意义/不支持TiDB 用 ADMIN CHECK TABLE 等专有命令;无需 OPTIMIZE
列级权限 / 降序索引 / SKIP LOCKED受限或不支持权限用库表级;锁等待策略在应用层设计

外键:从「绝对不生效」改成「看版本、看开关」

很多 MySQL 老系统靠外键 + ON DELETE CASCADE 保证级联与完整性。TiDB 的外键经历史三个阶段:v6.6 以前真的只解析不执行(老教程里「TiDB 不支持外键」就来自这个阶段);v6.6~v8.4 为实验特性;v8.5.0(LTS)起为正式功能,约束与级联都会真执行。但三件事仍要注意:① 会话/全局变量 foreign_key_checks 一旦为 OFF,检查与级联全部失效(DM 导入、Lightning 导入默认就会关掉它);② 删除被引用的父表、乱序建表都依赖这个开关;③ 外键检查在悲观事务里默认对父表行加排他锁,子表高并发写会有明显锁冲突(可用 tidb_foreign_key_check_in_shared_lock = ON 改共享锁,v8.5.6+)。结论:能用,但不要把业务一致性全赌在它身上,性能敏感路径依旧应用层兼顾。

2.3 自增 ID:唯一但可能跳号(Java 程序员的头号差异) ​

MySQL 里 AUTO_INCREMENT 你习惯了「连续、单调、不跳号」。TiDB 不同:

  • TiDB 的自增列保证唯一、在单个 TiDB Server 内递增,但:
    • 因为 TiDB Server 无状态、可多实例,各实例会各自预取一段号(默认 AUTO_ID_CACHE),所以不保证全局连续,会出现跳号;
    • 也不保证跨多个 TiDB 实例严格递增。
  • 建议不要混用「显式插入的自定义值」与「系统自动分配值」,否则可能撞 Duplicate entry。
sql
-- 查看/设置本表的自增分配缓存
-- 不写 AUTO_ID_CACHE 时,TiDB 默认按批次分配 3 万个号(AUTO_ID_CACHE=0 就是「用默认 30000」)
-- 实例 A 缓存 [1,30000]、实例 B 缓存 [30001,60000],所以重启/切实例就会看到跳号
CREATE TABLE t (id BIGINT PRIMARY KEY AUTO_INCREMENT) AUTO_ID_CACHE = 1;
-- AUTO_ID_CACHE=1:不再本地缓存,每次向集群内的中心分配服务要号(v6.4.0+)
-- → 跨 TiDB 实例也**全局连续递增**,行为最接近 MySQL,代价是高并发插入时多一跳 RPC
1
2
3
4
5
6

想让自增更接近 MySQL 的兼容行为,开 MySQL 兼容模式(v5.2+):

ini
# TiDB 配置或启动参数
experimental = "enable-mysql-compatibility"
# 或用系统变量控制步长/偏移,模拟 MySQL 的 auto_increment_increment/offset
1
2
3

对照分库分表经验(yunlan 第九章):分库分表时你必须自己上雪花/号段造全局 ID,因为多库各自自增会撞。TiDB 这边普通 AUTO_INCREMENT 就全局唯一(跳号但不重复),大多数业务直接够用;若还担心单调/热点,用 BIGINT + 应用层雪花,或下一节的 AUTO_RANDOM。

实践建议:① 金额/订单号这类业务单号,无论 MySQL/TiDB 都建议应用层生成(雪花/号段),别依赖数据库自增——这条你分库分表时的经验在 TiDB 上同样成立。② 纯技术主键用 AUTO_INCREMENT 或 AUTO_RANDOM(第 4 章)即可。

2.4 隔离级别与事务模型:默认 RC + 两种事务模式 ​

隔离级别(务必校准,别想当然按 InnoDB 的 RR 默认):

你在 SQL 里设的TiDB 实际执行说明
READ-COMMITTED(RC)RCTiDB 默认。每条语句一个新快照
REPEATABLE-READ(RR)SI(快照隔离)TiDB 把 RR 映射为 SI;多数场景等价,极少数边界行为不同
SERIALIZABLE也是 SI 之上加强非完全串行,谨慎依赖

差异记忆:MySQL 默认 RR、InnoDB 靠间隙锁防幻读;TiDB 默认 RC、靠 MVCC 快照 + Percolator 两阶段提交,没有间隙锁(这与站内 PostgreSQL 第四章的结论如出一辙——现代 MVCC 引擎的共同选择)。

事务模式(TiDB 独有,分库分表里没有的概念):TiDB 事务分乐观与悲观两种,默认是悲观事务:

sql
SELECT @@tidb_txn_mode;          -- 查看当前默认事务模式,一般 pessimistic(悲观)
SET tidb_txn_mode = 'optimistic'; -- 会话级改为乐观
-- 悲观事务:写冲突时加锁等待,更接近你在 MySQL 里的手感,推荐业务使用
-- 乐观事务:提交时才检测冲突、冲突则整个事务回滚重试,高冲突场景性能差
1
2
3
4
  • 悲观模式下 SELECT ... FOR UPDATE / FOR SHARE 行为接近 MySQL;乐观模式下这类显式加锁语义弱,且冲突会返回可重试错误(第 5 章错误码)。
  • 大事务有上限:TiDB 对单事务大小有限制(如 txn-total-size-limit,默认约 100MB 级),超大事务(你在分库分表里习惯的一次改几十万行)要拆分。

2.5 其余高频「行为不同」点 ​

  • 排序规则默认不同:TiDB 里 utf8mb4 默认 utf8mb4_bin(二进制、区分大小写),MySQL 5.7 默认 utf8mb4_general_ci(不区分)。→ WHERE name='Alice' 可能因大小写查不到。建表显式指定 COLLATE utf8mb4_general_ci(需开启「新排序规则」new_collations_enabled_on_first_bootstrap)更接近 MySQL。
  • 视图不可更新:TiDB 视图只能查,不能对视图做 INSERT/UPDATE/DELETE(MySQL 部分可更新视图在 TiDB 不行)。
  • GROUP BY 结果顺序:MySQL 5.7 的 GROUP BY 隐含按分组列排序,TiDB 不保证——要顺序请显式 ORDER BY。
  • DDL 是异步在线:CREATE INDEX/ALTER TABLE 在 TiDB 后台逐 Region 补数据,大表可能跑很久;进度用 ADMIN SHOW DDL;(当前任务)与 ADMIN SHOW DDL JOBS;(历史任务,看 STATE/ROW_COMMITTED/WARNINGS)查,或看 Dashboard「任务管理 / DDL」(MySQL 的 SHOW ADMIN RECOVER INDEX 那套在 TiDB 里不存在)。且一条 ALTER 不能对同一列同时做多个操作(如同时 MODIFY c1, DROP c1 报错)。
  • performance_schema 基本空:TiDB 用 Prometheus + Grafana + TiDB Dashboard 做监控(第 6/7 章),别指望 MySQL 那套 P_S 视图。
  • 执行计划 EXPLAIN 格式不同:与 MySQL 的 type/key/rows 那套输出结构差异大,需学 TiDB 的算子(TableFullScan/Coprocessor/IndexLookUp 等,第 6 章)。
  • GC 保留期:多版本数据默认保留 tidb_gc_life_time = 10m。但从 v4.0 起,运行中且不超过 24 小时的事务会阻塞 GC,所以普通长查询不会撞上报错;真正常见的反而是在一个已经开始的快照上读历史(闪回查询、一致性导出、长跑的批任务读旧快照)时报 ERROR 9006 (HY000): GC life time is shorter than transaction duration。需要读历史时先调大它(第 7 章 7.6)。

2.6 迁移前兼容性核对清单(照着过一遍业务 SQL) ​

动手前,把现有 MySQL/分库分表工程按这张表逐条打钩:

□ 是否用到存储过程/函数/触发器/事件?  → 有则评估上移到 Java/外部调度的改造量
□ 是否依赖外键做级联/完整性校验?        → 先查集群版本:v8.5+ 真执行(并评估锁冲突);旧版/工具链会关检查,仍需应用层补
□ 是否用 FULLTEXT 全文索引?            → 改 ES(参考 yunlan 第九章搜索方案)
□ 是否依赖 AUTO_INCREMENT 的"连续不跳号"?→ 改应用层发号,或 AUTO_ID_CACHE=1 + 兼容模式
□ 是否大量写死大小写不敏感匹配?          → 显式指定 collate,避免 utf8mb4_bin 坑
□ 是否有超过事务大小上限的巨型事务?       → 拆分批处理
□ 是否对视图做写操作?                   → 改为查基表
□ 是否依赖 GROUP BY 隐式排序?           → 补显式 ORDER BY
□ 是否有跨库 JOIN 的中间件改造?           → TiDB 原生支持,可简化(第3章)
1
2
3
4
5
6
7
8
9

2.7 本章小结 ​

  1. 能用:CRUD、JOIN、子查询、窗口函数、CTE、事务、常用类型、MySQL 生态工具——绝大多数业务无痛兼容。
  2. 不支持:存储过程/函数/触发器/事件/UDF、FULLTEXT 索引、GIS、CREATE TABLE AS SELECT、XA 语法、降序索引/SKIP LOCKED/列级权限、CHECK/REPAIR/OPTIMIZE TABLE 等——逻辑上移应用层、检索外包给 ES。外键是个例外:v6.6 实验、v8.5.0 起正式支持,但受 foreign_key_checks 与分区表等限制。
  3. 行为不同:自增会跳号(AUTO_ID_CACHE/兼容模式)、默认隔离 RC(RR→SI)、悲观/乐观双事务模式(默认悲观)、默认 utf8mb4_bin、视图不可写、GROUP BY 不保证顺序、DDL 在线异步、性能监控走 Prometheus/Dashboard、GC 默认 10 分钟。
  4. 迁移前一定用 2.6 清单把业务 SQL 过筛,重点排「存储过程/触发器/全文索引」三颗硬雷,外加「外键看版本」这一颗软雷。

✏️ 课后练习 ​

  1. 在 playground 里建一张带 AUTO_INCREMENT 的表,连续插入后杀掉并重启 TiDB Server,观察 id 是否跳号,体会 AUTO_ID_CACHE 的作用。
  2. 建一对父子表并加 FOREIGN KEY ... ON DELETE CASCADE,先验证它真的拦(插入孤儿行、删除被引用行各看一次报错),再用 SET foreign_key_checks = OFF 验证它可以不拦,理解「外键不生效」这句老话在当今到底对不对。
  3. 执行 SET @@tidb_txn_mode='optimistic'; 与 pessimistic 分别在两个会话并发更新同一行,观察冲突表现差异(报错重试 vs 锁等待)。
  4. 用 2.6 清单,翻一翻 yunlan 第九章 的订单表设计,指出若迁 TiDB,哪些约束(外键/全局 ID/分片键)需要调整。

📖 参考答案 ​

本章题目全部是「行为验证」类,只需要一张能重启的集群。第 1 题要重启 TiDB,必须用 1.4.1 的方案①(WSL2 + tiup)并加 --tag,否则一重启数据就没了,实验直接白做;第 2、3 题在方案②③也能跑(单容器镜像与 TiDB Cloud 都是真 SQL 层);第 4 题是纸上核对。

本章示例表统一用 t_ 前缀 + 题号(如 t_2x_id_def),每个小节自包含,从建表开始贴就能直接跑。

第 1 题:建带 AUTO_INCREMENT 的表,连续插入后重启 TiDB Server,观察跳号并体会 AUTO_ID_CACHE

第 1 步,用带 tag 的方式起集群(这是本题能做成与否的关键):

bash
tiup --tag tidbdemo playground v8.5.0 --tiflash 1
# 以后重启请永远用同一条命令(同 tag),数据会落在 ~/.tiup/data/tidbdemo 下
1
2

第 2 步,建两张只差一个表级属性的对照表:

sql
CREATE TABLE t_2x_id_def (id BIGINT PRIMARY KEY AUTO_INCREMENT, note VARCHAR(20));
CREATE TABLE t_2x_id_c1  (id BIGINT PRIMARY KEY AUTO_INCREMENT, note VARCHAR(20)) AUTO_ID_CACHE = 1;

SHOW CREATE TABLE t_2x_id_def\G   -- 不写就没有任何 AUTO_ID_CACHE 字样(用默认值)
SHOW CREATE TABLE t_2x_id_c1\G    -- 结尾会看到 ... AUTO_ID_CACHE=1
1
2
3
4
5

第 3 步,两张表各插 5 行(不指定 id),此时两边完全一样,看不出差异:

sql
INSERT INTO t_2x_id_def(note) VALUES ('a'),('b'),('c'),('d'),('e');
INSERT INTO t_2x_id_c1 (note) VALUES ('a'),('b'),('c'),('d'),('e');

SELECT id, note FROM t_2x_id_def ORDER BY id;
-- +----+------+
-- | id | note |
-- +----+------+
-- |  1 | a    |
-- |  2 | b    |
-- |  3 | c    |
-- |  4 | d    |
-- |  5 | e    |
-- +----+------+
SELECT MAX(id) FROM t_2x_id_c1;   -- 5,同样连续
1
2
3
4
5
6
7
8
9
10
11
12
13
14

第 4 步,先不重启,再插一行,确认同实例同批次内不会跳:

sql
INSERT INTO t_2x_id_def(note) VALUES ('f');
SELECT id FROM t_2x_id_def WHERE note = 'f';   -- 6,完全符合 MySQL 直觉
SELECT last_insert_id();                        -- 6(隐式分配可以用它拿回当前会话刚分的号)
1
2
3

第 5 步,在 playground 那个终端按 Ctrl+C 停集群,再用第 1 步同一条命令重起,然后各插一行。奇迹(坑)就出现了:

sql
INSERT INTO t_2x_id_def(note) VALUES ('g');
INSERT INTO t_2x_id_c1 (note) VALUES ('g');

SELECT id, note FROM t_2x_id_def ORDER BY id;
-- +-------+------+
-- | id    | note |
-- +-------+------+
-- |     1 | a    |
-- |     2 | b    |
-- |     3 | c    |
-- |     4 | d    |
-- |     5 | e    |
-- |     6 | f    |
-- | 30001 | g    |   ← 从 6 直接跳到一个新的批次开头
-- +-------+------+

SELECT id, note FROM t_2x_id_c1 ORDER BY id;
-- 1,2,3,4,5,6,7            ← 不缓存,重启后接着上一行继续
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

第 6 步,把这两个数字对账说清楚(本题真正要背的模型):

text
默认(不写 AUTO_ID_CACHE):TiDB 向存储引擎按批取号,一批 30000 个,实例 A 拿 [1,30000]、实例 B 拿 [30001,60000]
→ 你只用到前 6 个就重启了,剩下 29994 个号永久作废,新进程只能拿下一批 → 第一个号就是 30001
→ 所以:不连续 ≠ 数据丢了;id 仍保证唯一与单调递增(单实例内)
AUTO_ID_CACHE=1:不本地缓存,向集群内的中心分配服务要号(v6.4.0+ 实现,内存操作不走事务)
→ 得到 MySQL 那样的全局连续递增;代价是高并发插入多一跳 RPC
AUTO_ID_CACHE=0:表示「用默认 30000」,**不是**「不缓存」——这是最容易记反的一个点
1
2
3
4
5
6

第 7 步(多实例版,可选但推荐):不靠重启也能看到跳号,而且这个现象跟生产一模一样:

bash
tiup --tag tidbdemo2 playground v8.5.0 --db 2     -- 两个 TiDB 实例,端口 4000 与 4001
1
sql
-- 会话 1(连 4000):
INSERT INTO t_2x_id_def(note) VALUES ('from4000');   -- id = 1
-- 会话 2(连 4001):
INSERT INTO t_2x_id_def(note) VALUES ('from4001');   -- id = 30001(第二个实例自己拿了下一批)
-- 回到会话 1 再插:id = 2  ← 比刚才那个 30001 还小,所以叫「不全局单调」
1
2
3
4
5

第 8 步,工程结论(与 2.3 的建议呼应):

你真的需要做法
只是主键、不给人看普通 AUTO_INCREMENT 即可,接受跳号;高频写大表改 AUTO_RANDOM(第 4 章)
跳号会出问题(对账、分页 cursor)建表时 AUTO_ID_CACHE = 1(v6.4+ 代价小),或开 TiDB 配置 experimental = "enable-mysql-compatibility"
业务单号(订单号/交易流水)永远应用层发号(雪花/号段),不依赖数据库,这是分库分表时代经验在 TiDB 上仍成立的一条

坑表:

现象原因处理
重启后表不见了忘加 --tag,playground 默认退出即毁数据所有要重启的实验都用 tiup --tag xxx playground ...
重启后 id 仍是 7,没跳表建了 AUTO_ID_CACHE=1,或上一批还没用完就重启(同进程内不重置)确认 SHOW CREATE TABLE 里没有 AUTO_ID_CACHE=1
Duplicate entry '1' for key 'PRIMARY'混用了显式插入与隐式分配:你手动塞了一个未来批次的 id,而缓存里刚好也要发它同一列不要混用两种赋值;实在要导数据,完后 ALTER TABLE t AUTO_INCREMENT = <max+1>;
把 AUTO_ID_CACHE=0 当成「不缓存」它的意思是「用默认值 30000」不缓存要写 = 1

第 2 题:父子表 + ON DELETE CASCADE,先验证它真的拦,再验证 foreign_key_checks=OFF 能不拦

第 1 步,先确认你在哪个版本上(本题结论完全取决于它):

sql
SELECT version();     -- 8.0.11-TiDB-v8.5.x → 外键是正式功能,会真拦截
SHOW VARIABLES LIKE 'foreign_key_checks';   -- 默认 ON
1
2

第 2 步,建父子表。两个细节不写就建不成:父表被引用列必须有索引(这里是主键);子表外键列自己建不建都可(不建会自动建一个与约束同名的索引),但父表没索引直接报错:

sql
CREATE TABLE t_2x_parent (
  id   INT PRIMARY KEY,
  name VARCHAR(20)
);

CREATE TABLE t_2x_child (
  id  INT PRIMARY KEY AUTO_INCREMENT,
  pid INT,
  INDEX idx_pid (pid),                                        -- ← 不要省
  CONSTRAINT fk_child_parent FOREIGN KEY (pid) REFERENCES t_2x_parent(id) ON DELETE CASCADE
);

-- 故意再建一张父表没有索引的,体验 TiDB 的外键限制
CREATE TABLE t_2x_noidx (id INT, name VARCHAR(20));        -- ← 关键:它没有索引
CREATE TABLE t_2x_bad_child (id INT, pid INT, INDEX i(pid),
  CONSTRAINT fk_bad FOREIGN KEY (pid) REFERENCES t_2x_noidx(id));   -- → 报 ERROR 1822
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

第 3 步,三个报错原文(本题最该抄进笔记的东西):

text
ERROR 1822 (HY000): Failed to add the foreign key constraint. Missing index for constraint 'fk_bad' in the referenced table 't_2x_noidx'
-- ↑ 父表没索引。TiDB 的外键检查要能走索引,否则每插一行就全表扫父表

INSERT INTO t_2x_parent VALUES (1, 'A'), (2, 'B');       -- 正常两行父数据
INSERT INTO t_2x_child (pid) VALUES (1), (2), (99);      -- ↑ 99 不存在
ERROR 1452 (23000): Cannot add or update a child row: a foreign key constraint fails
  (`test`.`t_2x_child`, CONSTRAINT `fk_child_parent` FOREIGN KEY (`pid`) REFERENCES `t_2x_parent` (`id`) ON DELETE CASCADE)
-- ↑ 「脏数据插不进来」的铁证。注意整句 INSERT 都失败(3 行一行也没进去),不是只丢那一行

DELETE FROM t_2x_parent WHERE id = 1;                    -- 先不级联、直接删父行(后面第 5 步会看到它能删)
1
2
3
4
5
6
7
8
9
10

第 4 步,确认约束真的存在(三张系统表都能查,养成用元数据说话的习惯):

sql
SHOW CREATE TABLE t_2x_child\G
-- Create Table: CREATE TABLE `t_2x_child` (
--   `id` int NOT NULL AUTO_INCREMENT,
--   `pid` int DEFAULT NULL,
--   PRIMARY KEY (`id`) /*T![clustered_index] CLUSTERED */,
--   KEY `idx_pid` (`pid`),
--   CONSTRAINT `fk_child_parent` FOREIGN KEY (`pid`) REFERENCES `t_2x_parent` (`id`) ON DELETE CASCADE
-- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin AUTO_INCREMENT=100001

SELECT CONSTRAINT_NAME, TABLE_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME
  FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
 WHERE REFERENCED_TABLE_SCHEMA = 'test';
-- fk_child_parent | t_2x_child | pid | t_2x_parent | id

SELECT CONSTRAINT_NAME, DELETE_RULE, UPDATE_RULE
  FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = 'test';
-- fk_child_parent | CASCADE | NO ACTION     ← 没写 ON UPDATE 时默认就是 NO ACTION(行为同 RESTRICT)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

第 5 步,验证级联(对账用行数,不用眼睛):

sql
INSERT INTO t_2x_child(pid) VALUES (1), (1), (2);        -- 现在 child 共 5 行(第 3 步进了 1 和 2)
SELECT COUNT(*) FROM t_2x_child;                          -- 5
DELETE FROM t_2x_parent WHERE id = 1;                     -- 只删 1 行父数据
SELECT COUNT(*) FROM t_2x_child;                          -- 3   ← 两条 pid=1 的子行被 CASCADE 带删了
SELECT COUNT(*) FROM t_2x_parent;                         -- 1
SELECT ROW_COUNT();                                       -- 可用它确认「服务端只执行了 1 个语句」,子行是数据库自己干的
1
2
3
4
5
6

这一句就是「外键真的生效」的硬核证据:你只发了一个 DELETE,子表少了 2 行。在 v6.6 之前的 TiDB 上这个实验结果是「子表一行不少」。

第 6 步,把约束关掉(这才是旧教程说「不生效」的现代等价物):

sql
SET SESSION foreign_key_checks = OFF;
INSERT INTO t_2x_child(pid) VALUES (999);     -- ✅ 成功!孤儿行进去了
SELECT COUNT(*) FROM t_2x_child WHERE pid NOT IN (SELECT id FROM t_2x_parent);
-- 1                                          ← 脏数据现形(注:如果父表已清空要改用 NOT EXISTS)
SET SESSION foreign_key_checks = ON;
INSERT INTO t_2x_child(pid) VALUES (998);     -- ❌ 又报 ERROR 1452
1
2
3
4
5
6

关掉后级联也一起失效:删父行时子行不会动。这就是为什么 DM / Lightning 导入时关 foreign_key_checks 很危险——导完必须保证写入顺序先父后子,否则留下一堆孤儿。

第 7 步,验证「不能想删父表就删父表」:

sql
DROP TABLE t_2x_parent;
-- ERROR 3730 (HY000): Cannot drop table 't_2x_parent' referenced by a foreign key constraint 'fk_child_parent' on table 't_2x_child'.
-- 正确做法:先 ALTER TABLE t_2x_child DROP FOREIGN KEY fk_child_parent; 再删表
1
2
3

第 8 步,结论回扣本章:

你看到的说明迁 MySQL 时怎么写迁移工具会做什么
v8.5 外键会报 1452/1451、会 CASCADE外键已是正式功能可以保留外键,但高并发写子表要评估锁(可上共享锁变量)DM v8.5.6+ 才实验支持同步带外键的表
foreign_key_checks=OFF 时一行都不拦开关粒度到会话导入前先父后子、导完补一遍孤儿校验DM/Lightning 导入默认会关它换速度
v6.5 及以前:建表不报错、也不拦那时真的只解析必须应用层保证一致性这也是很多存量系统脏数据的根因

坑表:

现象原因处理
在分区表上建外键不支持先去掉分区或去掉外键(第 4 章分区表)
pid 写成 TEXT 列不支持在 BLOB/TEXT 列建外键改 VARCHAR 或另存一张映射表
ON DELETE SET NULL 报错外键列必须是可空列去掉 NOT NULL
子表高并发写入大量锁等待外键检查默认对父表行加排他锁v8.5.6+:SET GLOBAL tidb_foreign_key_check_in_shared_lock = ON;
应用报「偶发性 1452」但人工手操不重现并发事务里父行还未提交外键只保证存储层一致性,不解决业务时序问题

第 3 题:分别在两个会话里用 optimistic 与 pessimistic 并发更新同一行,观察冲突表现差异

第 1 步,先看三个变量的现值(本题后面全部依赖它们):

sql
SELECT @@tidb_txn_mode, @@transaction_isolation, @@innodb_lock_wait_timeout;
-- +-----------------+-----------------------+--------------------------+
-- | pessimistic     | READ-COMMITTED        |                       50 |
-- +-----------------+-----------------------+--------------------------+
-- 三个默认值一起记:TiDB 默认悲观事务 + 默认 RC 隔离 + 锁等待 50 秒
1
2
3
4
5

第 2 步,建账户表并播两行(金额可以手算对账):

sql
CREATE TABLE t_2x_acct (id INT PRIMARY KEY, bal DECIMAL(10,2) NOT NULL);
INSERT INTO t_2x_acct VALUES (1, 100.00), (2, 50.00);
-- 后面每一轮都拿这两个值做基准:扣 10 加 10,总和永远是 150.00
1
2
3

第 3 步,悲观模式(默认)——开两个 mysql 会话。会话 A:

sql
-- 会话 A(先把它自己的等待上限调小,不然要干等 50 秒)
SET SESSION innodb_lock_wait_timeout = 5;
BEGIN;
UPDATE t_2x_acct SET bal = bal - 10 WHERE id = 1;      -- 立即拿到行锁,Query OK,不提交
-- 先挂在这里,去做会话 B
1
2
3
4
5

会话 B(也要先把等待上限调小,否则要干等默认的 50 秒):

sql
SET SESSION innodb_lock_wait_timeout = 5;
UPDATE t_2x_acct SET bal = bal - 10 WHERE id = 1;      -- 卡住不动(这就是锁等待,不是死锁)
-- 5 秒后:
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
-- 注:锁等待超时 TiDB 直接复用 MySQL 的错误码 1205(日志/旧文档里偶尔看到同名的内部错误类 8028)
-- 如果你真正碰上是死锁(两事务互等),报错是 1213 Deadlock found when trying to get lock
1
2
3
4
5
6

手边只一个终端?用 mysql -e 开两个后台进程也能复现:mysql -h127.0.0.1 -P4000 -uroot test -e "BEGIN; UPDATE t_2x_acct SET bal=bal-10 WHERE id=1; SELECT SLEEP(20);" &

第 4 步,回到会话 A 提交,再跑会话 B,验证最终一致(对账核心):

sql
-- 会话 A:COMMIT;
-- 会话 B:重跑一次 UPDATE,这次会成功
SELECT id, bal FROM t_2x_acct ORDER BY id;
-- +----+--------+
-- | 1  |  80.00 |      ← 被两个事务各扣 10,100 → 80,一行也没丢
-- | 2  |  50.00 |
-- +----+--------+
-- 总和对得上 130.00 = 150.00 - 10 - 10,说明悲观锁保证了串行化效果
1
2
3
4
5
6
7
8

第 5 步,乐观模式——两个会话都先改模式,关键是不报错、不阻塞,直到 COMMIT 才爆炸:

sql
-- 会话 A 与 B 都执行:
SET tidb_txn_mode = 'optimistic';
-- 会话 A:BEGIN; UPDATE t_2x_acct SET bal = bal - 10 WHERE id = 1;   ← 秒回,不拿锁
-- 会话 B:BEGIN; UPDATE t_2x_acct SET bal = bal - 10 WHERE id = 1;   ← 也秒回(这就是与悲观最大的体感差异)
-- 会话 A:COMMIT;                                                    ← 成功
-- 会话 B:COMMIT;
ERROR 9007 (HY000): Write conflict, txnStartTS=444970617266225157, conflictStartTS=444970619205738497,
  conflictCommitTS=444970620456845313, key={tableID=85, handle=1}, primary={tableID=85, handle=1}
-- ↑ 字段名比数字重要:txnStartTS 是你的快照,conflict*TS 是对手事务的时间戳,key 告诉你撞的是哪一行
-- (TS 的具体值每次不同,以你实测为准)
1
2
3
4
5
6
7
8
9
10

第 6 步,把「为什么有时你看不到 9007」这个问题扫掉(很多人的实验在这里假成功):

sql
SELECT @@tidb_retry_limit, @@tidb_disable_txn_auto_retry;   -- 10 / 1(ON)
1
text
① 悲观模式下,autocommit 的单条 UPDATE 会先拿乐观方式提交,遇冲突后自动重以悲观提交重试
   → 所以上面第 3 步如果 A、B 都不开 BEGIN,你可能根本看不到报错,只看到耗时略长
② 显式事务里的写冲突 TiDB 不替你重试(tidb_disable_txn_auto_retry 默认 ON)→ 报错上抛
③ 从 v8.0.0 起 tidb_disable_txn_auto_retry 已废弃,不再做乐观事务自动重试
   → 乐观事务的冲突重试确定性地交给应用层(第 5 章的重试骨架就是为它写的)
1
2
3
4
5

第 7 步,补一个锁定读对比(与 MySQL 手感对齐程度):

sql
-- 会话 A(悲观):BEGIN; SELECT * FROM t_2x_acct WHERE id=1 FOR UPDATE;   → 拿锁
-- 会话 B(悲观):SELECT * FROM t_2x_acct WHERE id=1 FOR UPDATE;         → 阻塞等待(跟 MySQL 一样)
-- 会话 B 如果不想等:SELECT * FROM t_2x_acct WHERE id=1 FOR UPDATE NOWAIT;  → 立即报错
-- 但 SKIP LOCKED 在 TiDB 不支持(2.2 清单里那一行),不要指望它做队列
1
2
3
4

第 8 步,总结成一张可以贴在工位上的对比表:

对比项悲观 pessimistic(默认)乐观 optimistic
加锁时机写/锁定读时就拿锁提交时才发现冲突,不拿锁
冲突表现ERROR 1205 锁等待超时(等得住)、死锁 ERROR 1213ERROR 9007 Write Conflict(整个事务已回滚)
对应用的要求基本同 MySQL,短事务即可必须自己写重试(指数退避 + 抖动,第 5 章)
适合高冲突、金融/库存、行级抢锁低冲突 + 长事务(读写集不重叠),或外部已做串行化
风险持锁时间长;死锁由检测器自动断环(终止其中一个事务)高冲突下反复重试,吞吐雪崩
排查工具v5.1+ 的 Lock View:information_schema.data_lock_waits、tidb_trx、deadlocks看 statements_summary 与报错里的 conflictStartTS
选型建议拿不准就用默认悲观,更接近 MySQL 手感确认冲突率极低再考虑

第 4 题:用 2.6 清单过一遍 yunlan 第九章的订单表设计,指出迁 TiDB 要调整什么

第 1 步,先把原设计的事实抄清楚(别看印象,去翻 yaml)。yunlan 第九章的分片配置核心就三行:

yaml
# 来自动手实验 chapter9 的 ShardingSphere-JDBC 配置(入门示例是 2 库 × 2 表)
t_order:
  actualDataNodes: db-orders-${0..1}.t_order_${1..2}
  databaseStrategy:
    standard:
      shardingColumn: user_id
      shardingAlgorithmName: order-db-hash        # algorithm-expression: db-orders-${user_id % 2}
  tableStrategy:
    standard:
      shardingColumn: id
      shardingAlgorithmName: order-tb-hash        # algorithm-expression: t_order_${id % 2 + 1}
  keyGenerateStrategy:
    column: id
    keyGeneratorName: snowflake                    # ShardingSphere 内置 SNOWFLAKE
# 思路与 4 库 × N 表完全一致,只是 % 2 换成 % 4;下面的结论不随库表数变化
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

第 2 步,逐条打钩 2.6 清单(左列是清单原项,右列是针对本项目的具体结论):

2.6 清单项yunlan 订单库的实际情况迁 TiDB 要做什么
存储过程/函数/触发器/事件?无(逻辑全在 MyBatis + Service 里)零改造,这是 Java 项目的天然优势
依赖外键做级联/完整性?无外键,靠 user_id/order_id 应用层关联不必补外键(v8.5 虽支持,但订单表高并发写不建议);一致性继续应保证
用 FULLTEXT 全文索引?无,搜索走 ES(课程里单独做了 ES 方案)ES 链路原封不动,第 3 章讨论的只是要不要把 ES 换成 TiFlash
依赖自增「连续不跳号」?不依赖(id 已是雪花,应用层发号)继续用雪花;TiDB 自增只是备选(第 2 题已看到跳号现场)
写死了大小写不敏感匹配?MySQL 5.7 默认 utf8mb4_general_ci,WHERE order_no='Abc' 不区分TiDB 默认 utf8mb4_bin 会区分 → 建表时显式 COLLATE utf8mb4_general_ci,或把入口参数统一 LOWER()
有超事务上限的巨型事务?批量导入/定时归档可能一次改十几万行txn-total-size-limit 默认约 100GB 但单事务内存/性能不经济,仍按 500~1000 行分批(第 5 章)
对视图做写操作?无视图无
依赖 GROUP BY 隐式排序?报表 SQL 从 MySQL 搬过来,很可能依赖补显式 ORDER BY(这是典型的「不报错但结果不对」类坑)
有跨库 JOIN 的中间件改造?有:绑表/广播表、按 user_id 强制路由、按 order_no 查需全分片扇出TiDB 删掉整套路由规则;order_no 直接建普通二级索引即可

第 3 步,把分片键相关的四件负担逐条对应到 TiDB 原生能力(本题真正要产出的一张表):

你当时做的为什么必须做TiDB 帮你做什么迁完后的代码动作
所有查询尽量带 user_id不带就全分片扇出无分片键概念,按主键/RowID 自动切 Region删掉「必须带分片键」的约束与对应单测
id 用 ShardingSphere SNOWFLAKE多库各自自增会撞自增即全局唯一(可不连续),或用 AUTO_RANDOM雪花建议继续用(单号不可枚举、不暴露业务量)
分表键用 id、分库键用 user_id两个维度各自模 2不需要分表/分库删配置;注意这种双键设计本身在 MySQL 上就很难做「按 id 又按 user 定位」,迁 TiDB 后这个纠结直接消失
扩容要搬数据 + 双写 + 校验加库必须重算路由加 TiKV 节点,PD 自动 rebalance扩容从「一个项目」变成「一条命令」(第 7 章)

第 4 步,迁移后的目标表结构(4 张分片表合成 1 张,列定义不变):

sql
CREATE TABLE t_order (
  id          BIGINT       NOT NULL,           -- 沿用雪花:全局唯一,合并不会撞键
  user_id     BIGINT       NOT NULL,
  order_no    VARCHAR(64)  NOT NULL,
  amount      DECIMAL(10,2) NOT NULL,
  status      TINYINT      NOT NULL DEFAULT 0,
  created     DATETIME     NOT NULL,
  PRIMARY KEY (id),
  UNIQUE KEY uk_order_no (order_no),           -- ★ 以前靠「分片内唯一」,合表后必须全局唯一
  KEY idx_user (user_id),
  KEY idx_created (created)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;   -- ← 把 collate 显式写回 MySQL 的习惯
-- 高频写的大表可把主键换成 AUTO_RANDOM(5)(第 4 章),但那样就跟原有雪花 id 互斥了,二选一
1
2
3
4
5
6
7
8
9
10
11
12
13

第 5 步,合并前必做的撞键体检(最容易漏的一条,在源端 MySQL 上跑):

sql
-- 把各分片库都查一遍,看同一个 id / order_no 是否出现在多张物理表(合并主键必撞)
SELECT id, COUNT(*) c FROM (
  SELECT id FROM `db-orders-0`.t_order_1 UNION ALL SELECT id FROM `db-orders-0`.t_order_2
  UNION ALL SELECT id FROM `db-orders-1`.t_order_1 UNION ALL SELECT id FROM `db-orders-1`.t_order_2
) x GROUP BY id HAVING c > 1;      -- 空集 = 安全;非空 = 先修数据再上 DM
-- 同理把 id 换成 order_no 再跑一遍,因为目标表上有 UNIQUE KEY uk_order_no
1
2
3
4
5
6

第 6 步,输出你的迁移结论(一句话说清就行,面试官爱听这个):

text
约束调整:① 分片键体系整体删除(路由、绑表、广播表不再需要);
② 全局 ID 保留雪花(不依赖数据库),因此合并无撞键风险(但要跑第 5 步体检坐实);
③ order_no 从「分片内唯一」升级为「全局唯一索引」;
④ collation 从 general_ci 变 bin,必须建表时显式指定,否则登录单号/用户名查询会因大小写不匹配而查不到;
⑤ 外键:本来就没用,也不打算上(性能考虑),继续在 Service 层保证一致性;
⑥ 报表 SQL 补显式 ORDER BY,其余 CRUD 代码零改动。
1
2
3
4
5
6
容易漏项后果验证方法
没查 order_no 全局重复DM 合表时 Duplicate entry 任务中断第 5 步的 UNION ALL 体检
没写 COLLATE utf8mb4_general_ci存量业务「以前查得到、现在查不到」SHOW CREATE TABLE + 一条大小写混合的 SELECT
以为 TiDB 自动收集统计计划差、报表变慢导入完 ANALYZE TABLE t_order;(第 6 章)
直接把 ShardingSphere 配置指向 TiDB双重分片,数据进不去切流时把中间件从依赖里移除,只留普通 MySQL 驱动
← 第1章 认识TiDB与整体架构第3章 TiDB vs 分库分表全面对比 →








如果发现文档内容有错误或排版错乱,请及时联系站长老苗修改,不胜感激。联系我们
关于我们 | 隐私政策 | 豫ICP备2026003386号-4 | 豫公网安备41010202004008号
目录

本页无章节