第2章 MySQL 兼容性与差异清单
🎯 本章学习目标
- 建立正确预期:TiDB 高度兼容 MySQL 5.7/8.0 协议与常用语法,绝大多数 CRUD、索引、事务、
JOIN、窗口函数都能直接用;但有一批对象根本不支持、一批特性行为不同。 - 能背出「完全不支持清单」并说出对应替代方案:存储过程/函数、触发器、事件调度器、外键约束、FULLTEXT、SPATIAL/GIS、自定义函数 UDF。
- 讲清「行为不同」里最容易坑 Java 程序员的几项:自增 ID 跳号与
AUTO_ID_CACHE、AUTO_INCREMENT兼容模式、默认排序规则utf8mb4_bin、事务隔离级别 RC/SI、视图不可更新。 - 知道 DDL 是「在线异步」:不像 MySQL 秒级完成,TiDB 加索引/改表后台逐 Region 执行,大表要等,且一条
ALTER不能对同一列做多个操作。 - 理解 GC(垃圾回收)机制:TiDB 多版本数据靠 GC 清理,默认保留期 10 分钟,长事务/长查询会因快照被回收而报错。
- 形成一张迁移前兼容性核对清单,能在动手前把业务 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 START | TiDB 内部本就两阶段提交做分布式事务,无需你手动 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 实例严格递增。
- 因为 TiDB Server 无状态、可多实例,各实例会各自预取一段号(默认
- 建议不要混用「显式插入的自定义值」与「系统自动分配值」,否则可能撞
Duplicate entry。
-- 查看/设置本表的自增分配缓存
-- 不写 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想让自增更接近 MySQL 的兼容行为,开 MySQL 兼容模式(v5.2+):
# TiDB 配置或启动参数
experimental = "enable-mysql-compatibility"
# 或用系统变量控制步长/偏移,模拟 MySQL 的 auto_increment_increment/offset对照分库分表经验(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) | RC | TiDB 默认。每条语句一个新快照 |
REPEATABLE-READ(RR) | SI(快照隔离) | TiDB 把 RR 映射为 SI;多数场景等价,极少数边界行为不同 |
SERIALIZABLE | 也是 SI 之上加强 | 非完全串行,谨慎依赖 |
差异记忆:MySQL 默认 RR、InnoDB 靠间隙锁防幻读;TiDB 默认 RC、靠 MVCC 快照 + Percolator 两阶段提交,没有间隙锁(这与站内 PostgreSQL 第四章的结论如出一辙——现代 MVCC 引擎的共同选择)。
事务模式(TiDB 独有,分库分表里没有的概念):TiDB 事务分乐观与悲观两种,默认是悲观事务:
SELECT @@tidb_txn_mode; -- 查看当前默认事务模式,一般 pessimistic(悲观)
SET tidb_txn_mode = 'optimistic'; -- 会话级改为乐观
-- 悲观事务:写冲突时加锁等待,更接近你在 MySQL 里的手感,推荐业务使用
-- 乐观事务:提交时才检测冲突、冲突则整个事务回滚重试,高冲突场景性能差- 悲观模式下
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章)2.7 本章小结
- 能用:CRUD、JOIN、子查询、窗口函数、CTE、事务、常用类型、MySQL 生态工具——绝大多数业务无痛兼容。
- 不支持:存储过程/函数/触发器/事件/UDF、FULLTEXT 索引、GIS、
CREATE TABLE AS SELECT、XA语法、降序索引/SKIP LOCKED/列级权限、CHECK/REPAIR/OPTIMIZE TABLE等——逻辑上移应用层、检索外包给 ES。外键是个例外:v6.6 实验、v8.5.0 起正式支持,但受foreign_key_checks与分区表等限制。 - 行为不同:自增会跳号(
AUTO_ID_CACHE/兼容模式)、默认隔离 RC(RR→SI)、悲观/乐观双事务模式(默认悲观)、默认utf8mb4_bin、视图不可写、GROUP BY 不保证顺序、DDL 在线异步、性能监控走 Prometheus/Dashboard、GC 默认 10 分钟。 - 迁移前一定用 2.6 清单把业务 SQL 过筛,重点排「存储过程/触发器/全文索引」三颗硬雷,外加「外键看版本」这一颗软雷。
✏️ 课后练习
- 在 playground 里建一张带
AUTO_INCREMENT的表,连续插入后杀掉并重启 TiDB Server,观察 id 是否跳号,体会AUTO_ID_CACHE的作用。 - 建一对父子表并加
FOREIGN KEY ... ON DELETE CASCADE,先验证它真的拦(插入孤儿行、删除被引用行各看一次报错),再用SET foreign_key_checks = OFF验证它可以不拦,理解「外键不生效」这句老话在当今到底对不对。 - 执行
SET @@tidb_txn_mode='optimistic';与pessimistic分别在两个会话并发更新同一行,观察冲突表现差异(报错重试 vs 锁等待)。 - 用 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 的方式起集群(这是本题能做成与否的关键):
tiup --tag tidbdemo playground v8.5.0 --tiflash 1
# 以后重启请永远用同一条命令(同 tag),数据会落在 ~/.tiup/data/tidbdemo 下第 2 步,建两张只差一个表级属性的对照表:
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第 3 步,两张表各插 5 行(不指定 id),此时两边完全一样,看不出差异:
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,同样连续第 4 步,先不重启,再插一行,确认同实例同批次内不会跳:
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(隐式分配可以用它拿回当前会话刚分的号)第 5 步,在 playground 那个终端按 Ctrl+C 停集群,再用第 1 步同一条命令重起,然后各插一行。奇迹(坑)就出现了:
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 ← 不缓存,重启后接着上一行继续第 6 步,把这两个数字对账说清楚(本题真正要背的模型):
默认(不写 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」,**不是**「不缓存」——这是最容易记反的一个点第 7 步(多实例版,可选但推荐):不靠重启也能看到跳号,而且这个现象跟生产一模一样:
tiup --tag tidbdemo2 playground v8.5.0 --db 2 -- 两个 TiDB 实例,端口 4000 与 4001-- 会话 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 还小,所以叫「不全局单调」第 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 步,先确认你在哪个版本上(本题结论完全取决于它):
SELECT version(); -- 8.0.11-TiDB-v8.5.x → 外键是正式功能,会真拦截
SHOW VARIABLES LIKE 'foreign_key_checks'; -- 默认 ON第 2 步,建父子表。两个细节不写就建不成:父表被引用列必须有索引(这里是主键);子表外键列自己建不建都可(不建会自动建一个与约束同名的索引),但父表没索引直接报错:
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第 3 步,三个报错原文(本题最该抄进笔记的东西):
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 步会看到它能删)第 4 步,确认约束真的存在(三张系统表都能查,养成用元数据说话的习惯):
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)第 5 步,验证级联(对账用行数,不用眼睛):
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 个语句」,子行是数据库自己干的这一句就是「外键真的生效」的硬核证据:你只发了一个
DELETE,子表少了 2 行。在 v6.6 之前的 TiDB 上这个实验结果是「子表一行不少」。
第 6 步,把约束关掉(这才是旧教程说「不生效」的现代等价物):
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关掉后级联也一起失效:删父行时子行不会动。这就是为什么 DM / Lightning 导入时关 foreign_key_checks 很危险——导完必须保证写入顺序先父后子,否则留下一堆孤儿。
第 7 步,验证「不能想删父表就删父表」:
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; 再删表第 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 步,先看三个变量的现值(本题后面全部依赖它们):
SELECT @@tidb_txn_mode, @@transaction_isolation, @@innodb_lock_wait_timeout;
-- +-----------------+-----------------------+--------------------------+
-- | pessimistic | READ-COMMITTED | 50 |
-- +-----------------+-----------------------+--------------------------+
-- 三个默认值一起记:TiDB 默认悲观事务 + 默认 RC 隔离 + 锁等待 50 秒第 2 步,建账户表并播两行(金额可以手算对账):
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第 3 步,悲观模式(默认)——开两个 mysql 会话。会话 A:
-- 会话 A(先把它自己的等待上限调小,不然要干等 50 秒)
SET SESSION innodb_lock_wait_timeout = 5;
BEGIN;
UPDATE t_2x_acct SET bal = bal - 10 WHERE id = 1; -- 立即拿到行锁,Query OK,不提交
-- 先挂在这里,去做会话 B会话 B(也要先把等待上限调小,否则要干等默认的 50 秒):
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手边只一个终端?用
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,验证最终一致(对账核心):
-- 会话 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,说明悲观锁保证了串行化效果第 5 步,乐观模式——两个会话都先改模式,关键是不报错、不阻塞,直到 COMMIT 才爆炸:
-- 会话 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 的具体值每次不同,以你实测为准)第 6 步,把「为什么有时你看不到 9007」这个问题扫掉(很多人的实验在这里假成功):
SELECT @@tidb_retry_limit, @@tidb_disable_txn_auto_retry; -- 10 / 1(ON)① 悲观模式下,autocommit 的单条 UPDATE 会先拿乐观方式提交,遇冲突后自动重以悲观提交重试
→ 所以上面第 3 步如果 A、B 都不开 BEGIN,你可能根本看不到报错,只看到耗时略长
② 显式事务里的写冲突 TiDB 不替你重试(tidb_disable_txn_auto_retry 默认 ON)→ 报错上抛
③ 从 v8.0.0 起 tidb_disable_txn_auto_retry 已废弃,不再做乐观事务自动重试
→ 乐观事务的冲突重试确定性地交给应用层(第 5 章的重试骨架就是为它写的)第 7 步,补一个锁定读对比(与 MySQL 手感对齐程度):
-- 会话 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 清单里那一行),不要指望它做队列第 8 步,总结成一张可以贴在工位上的对比表:
| 对比项 | 悲观 pessimistic(默认) | 乐观 optimistic |
|---|---|---|
| 加锁时机 | 写/锁定读时就拿锁 | 提交时才发现冲突,不拿锁 |
| 冲突表现 | ERROR 1205 锁等待超时(等得住)、死锁 ERROR 1213 | ERROR 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 第九章的分片配置核心就三行:
# 来自动手实验 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;下面的结论不随库表数变化第 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 张,列定义不变):
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 互斥了,二选一第 5 步,合并前必做的撞键体检(最容易漏的一条,在源端 MySQL 上跑):
-- 把各分片库都查一遍,看同一个 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第 6 步,输出你的迁移结论(一句话说清就行,面试官爱听这个):
约束调整:① 分片键体系整体删除(路由、绑表、广播表不再需要);
② 全局 ID 保留雪花(不依赖数据库),因此合并无撞键风险(但要跑第 5 步体检坐实);
③ order_no 从「分片内唯一」升级为「全局唯一索引」;
④ collation 从 general_ci 变 bin,必须建表时显式指定,否则登录单号/用户名查询会因大小写不匹配而查不到;
⑤ 外键:本来就没用,也不打算上(性能考虑),继续在 Service 层保证一致性;
⑥ 报表 SQL 补显式 ORDER BY,其余 CRUD 代码零改动。| 容易漏项 | 后果 | 验证方法 |
|---|---|---|
没查 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 驱动 |
