跳到主要内容

自增主键与空间回收

两个看起来很小、但追问能挖很深的话题。

自增值存在哪​

引擎存储位置重启后
MyISAM数据文件里保持
InnoDB 8.0 之前只在内存里重启后用 max(id) + 1 重新计算
InnoDB 8.0 起变更写入 redo log从 redo log 恢复,保持

8.0 之前的 bug:

-- 表里有 id = 1, 2, 3
DELETE FROM t WHERE id = 3; -- 自增值仍是 4(不回退)
-- 重启 MySQL
INSERT INTO t ... -- 得到 id = 3,不是 4!

重启后自增值被重算成 max(id)+1 = 3。如果 id=3 这行曾被外部系统引用过,就会出现ID 复用导致的数据错乱。8.0 把自增值变更记入 redo log 后彻底修好。

🔥 自增值为什么会不连续​

三个原因,面试常问「自增 ID 一定连续吗」:

1. 唯一键冲突​

-- 当前自增值 = 3
INSERT INTO t VALUES (null, 1, 1); -- 假设 c 上有唯一索引且 c=1 已存在
-- 报 Duplicate entry 错误,但自增值已经变成 4
INSERT INTO t VALUES (null, 2, 2); -- 得到 id = 4,3 被跳过

执行流程是:先申请自增值 → 再去判断唯一键冲突。申请完就把自增值改了,后面失败也不回退。

2. 事务回滚​

BEGIN;
INSERT INTO t VALUES (null, 5, 5); -- 申请到 id=3,自增值变 4
ROLLBACK;
INSERT INTO t VALUES (null, 5, 5); -- 得到 id=4

为什么不设计成「失败就回退」​

因为并发。假设允许回退:

事务 A 申请到 id=3
事务 B 申请到 id=4
事务 A 回滚,把自增值退回 3
事务 C 申请,拿到 3 → 和事务 B 的 4 没冲突,但如果 B 也回滚、D 再申请...

要保证回退后不出现重复,就必须在申请自增值时加锁判断「这个值有没有被别人用过」,意味着自增值的申请从无锁变成串行。MySQL 选择了「允许不连续」来换取并发性能 —— 这个取舍的解释是本题的得分点。

3. 批量插入的双倍申请策略​

INSERT ... SELECT、REPLACE ... SELECT、LOAD DATA 这类语句事先不知道要插入多少行,一行一行申请自增值太慢。InnoDB 的策略是每次申请的数量翻倍:

第 1 次申请 1 个
第 2 次申请 2 个
第 3 次申请 4 个
第 4 次申请 8 个 ...

所以插入 4 行时,第三次申请了 4 个但只用了 1 个,剩下 3 个被浪费,下一条语句从更大的值开始。

innodb_autoinc_lock_mode​

控制自增锁的释放时机,三个值:

值行为并发binlog 安全性
0语句执行结束才释放锁(表级锁)最差statement 格式安全
1(5.x 默认)普通 INSERT 申请完立即释放;INSERT...SELECT 等批量语句仍等到语句结束中statement 格式安全
2(8.0 默认)一律申请完立即释放最好statement 格式不安全

为什么 mode=2 + statement binlog 会主从不一致?

两个并发的 INSERT ... SELECT 交叉申请自增值,主库上得到的 id 是交错的(如 A 拿到 1,3,5,B 拿到 2,4,6)。但 binlog 以 statement 格式记录的是两条原始 SQL 语句,备库重放时串行执行,A 会连续拿到 1,2,3,B 拿到 4,5,6 —— 主备数据不一致。

所以 MySQL 8.0 同时把两个默认值都改了:innodb_autoinc_lock_mode=2 + binlog_format=ROW。两者必须配套,这个关联是高频追问点。

🔥 自增 ID 用完了会怎样​

分两种情况,差别巨大:

显式定义了自增主键​

int unsigned 上限是 4294967295(约 42.9 亿),int signed 是约 21.4 亿。达到上限后,自增值保持在上限不再增长,下一次插入会报:

ERROR 1062 (23000): Duplicate entry '4294967295' for key 'PRIMARY'

报错了,所以能发现。应对:一开始就用 bigint unsigned(上限 1844 亿亿,按每秒插入 100 万行算要 58 万年)。

没有定义主键(依赖隐藏的 row_id)​

InnoDB 会生成一个 6 字节的隐藏 row_id,但只用低 48 位。它是整个实例共享的全局计数器。

达到 2^48 - 1 之后,从 0 重新开始 —— 新插入的行会覆盖掉 row_id 相同的老数据,而且:

:::danger 不报错,静默覆盖 没有任何错误、没有任何告警,数据就那么被覆盖了。这是「必须显式定义主键」最硬的理由 —— 不是为了性能或规范,是为了防止静默数据丢失。

虽然 2^48 很大(281 万亿),但这是整个实例所有无主键表共用的计数器。 :::

其他 ID 的上限(面试偶尔深问):

ID位数用完后
row_id48归 0,覆盖数据
Xid(binlog 事务 ID)64理论上重复,但需要 MySQL 重启 2^64 次
trx_id48归 0,可能导致 MVCC 可见性判断错误(实际不可达)
thread_id32归 0,复用已释放的 ID(无害)

只读事务不分配真实 trx_id —— 它用一个很大的临时值(2^48 + thread_id)充数,避免消耗全局 trx_id。所以在 innodb_trx 里看到特别大的事务 ID,那是只读事务。

为什么 DELETE 不释放磁盘空间​

DELETE FROM t; -- 删掉所有数据
-- 但 t.ibd 文件大小不变!

原因:DELETE 只是把记录标记为「可复用」,这些位置会留给后续插入使用,但不会返还给操作系统。这些可复用但未使用的空间就是空洞(碎片)。

空洞的三个来源:

  1. 删除 —— 记录或整页被标记为可复用
  2. 插入导致的页分裂 —— 往已满的页中间插入时,页一分为二,两个页都只有一半满
  3. 更新 —— InnoDB 的 update 对变长字段实际是「标记删除 + 插入新记录」,同样产生空洞

随机插入的危害在这里体现得最清楚:主键是随机值(如 UUID)时,插入位置随机,页分裂频繁,表空间里到处是半满的页,实际占用可能是数据量的 2~3 倍。

重建表与 Online DDL​

回收空间的办法是重建表:

ALTER TABLE t ENGINE = InnoDB; -- 等价于 ALTER TABLE t ENGINE=InnoDB, ALGORITHM=INPLACE
OPTIMIZE TABLE t; -- = recreate + analyze

重建的原理是建一个新表,按主键顺序把数据一行行插入,这样页是紧凑的,最后用新表替换旧表。

5.6 之前:阻塞 DML​

老版本的流程是:建临时表 tmp_table(在 server 层)→ 逐行拷贝 → 替换。拷贝期间不能有写入,否则数据会丢。大表上这就是一次停机。

5.6+:Online DDL​

关键改进是引入 row log:

1. 拿 MDL 写锁
2. 降级为 MDL 读锁 ← 从这里开始允许 DML
3. 建临时文件(在 InnoDB 内部),扫描原表主键树,逐页写入临时文件
期间所有对原表的写操作都记入 row log
4. 临时文件生成后,把 row log 的内容应用上去
5. 升级回 MDL 写锁,用临时文件替换原表数据文件
6. 释放 MDL 锁

MDL 写锁只在开头和结尾短暂持有,中间的长时间拷贝过程持有的是读锁,所以不阻塞读写。

:::warning Online 和 inplace 是两个不同的概念 两个词经常被混用,但含义不同:

  • inplace —— 操作在 InnoDB 内部完成,不经过 server 层的临时表。相对的是 COPY 算法(经 server 层拷贝)
  • online —— 不阻塞 DML

两者不等价的例子:

操作inplaceonline
重建表 ALTER TABLE t ENGINE=InnoDB✅✅
加全文索引 / 空间索引✅❌ 会阻塞 DML
改列类型 ALTER ... MODIFY COLUMN❌ 走 COPY❌

「加全文索引是 inplace 但不是 online」是这个区分的标准例子。

ALGORITHM=INPLACE, LOCK=NONE 可以显式声明要求,不满足时直接报错而不是悄悄降级为阻塞操作 —— 生产改表应该总是加上这个声明。 :::

重建表的两个代价​

  1. 需要额外的磁盘空间 —— 重建期间新旧两份数据同时存在,磁盘至少要有表大小的 1 倍余量(加上 row log)
  2. 重建后表可能更大 —— 因为 InnoDB 重建时故意给每页留 1/16 的空闲空间(为了后续更新不立刻分裂)。所以对一张刚插入完、页本来就很满的表执行重建,结果反而变大

MySQL 8.0 的 Instant DDL​

8.0.12 引入 ALGORITHM=INSTANT,在表末尾加列只改元数据,秒级完成,完全不重建表、不拷贝数据。

限制:

  • 只能加在最后一列(8.0.29 起放宽,支持任意位置)
  • 不能和其他 ALTER 操作一起写
  • 不能用于加索引、改类型

:::tip 生产改表的决策顺序

  1. 加列且 MySQL ≥ 8.0.12 → ALGORITHM=INSTANT,秒级完成
  2. 其他 online DDL 支持的操作 → ALGORITHM=INPLACE, LOCK=NONE,但要先确认无长事务(否则 MDL 排队,见锁机制的 MDL 事故模式)
  3. 不支持 online 的操作(改列类型、改字符集)或超大表 → gh-ost / pt-online-schema-change
  4. 任何情况下都在低峰期执行,并先在和生产等量级的测试库上计时 :::

ANALYZE 和 OPTIMIZE 的区别​

命令做什么
ALTER TABLE t ENGINE=InnoDB重建表(recreate),回收空间
ANALYZE TABLE t不动数据,只重新采样索引统计信息
OPTIMIZE TABLE trecreate + analyze

索引统计信息不准会导致优化器选错索引,此时 ANALYZE TABLE 是最轻量的修复手段(不动数据、很快)。详见生产排查。