news 2026/9/18 16:31:21

MySQL 存量表补主键:InnoDB 聚簇索引、数据清洗与在线 DDL 实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MySQL 存量表补主键:InnoDB 聚簇索引、数据清洗与在线 DDL 实战

给一张已经跑了两年的 MySQL 表补主键,听上去就是一句ALTER TABLE ADD PRIMARY KEY的事,但真动手的时候,卡住的人比想象中多得多——表里有 NULL、有重复值、ALTER 直接被拒,然后人就不知道下一步该干嘛了。更隐蔽的是那些"没主键也跑得好好的"表,表面上风平浪静,等到基于行的复制找不到行、批量更新锁到天亮、主从延迟飙上去的时候,才发现根子就在缺一个 PRIMARY KEY。

这篇文章把 MySQL 加主键这件事从原理到落地完整走一遍:InnoDB 为什么对主键这么执着、建表阶段怎么定、存量表怎么补、数据脏了怎么修、加完之后哪些地方还会反悔。不管你是刚学 MySQL 的学生,还是在维护几十张线上表的运维,应该都能从里面找到自己需要的那一段。

1. 先搞清楚 InnoDB 为什么非要一个主键

1.1 表在磁盘上不是"按插入顺序堆着"的

很多人对表的直觉是"一个 Excel 表格,一行一行往下排"。InnoDB 完全不是这样,它是索引组织表(index organized table):所有行数据本身就挂在聚簇索引(clustered index)的 B+ 树叶子节点上,而聚簇索引就是主键索引。换句话说,主键不是"另外建的一个索引",它就是表的物理存储结构本身。

打个比方,图书馆按书号排架,你要找《深入理解计算机系统》,先查书号,然后直奔那个书架,一次定位。如果图书馆的书是随手扔的,你就得一排一排翻。主键就是那个书号。

这带来三个直接后果。第一,按主键做范围查询(WHERE id BETWEEN 1000 AND 2000)极快,因为数据物理上就是连着的。第二,ORDER BY 主键通常不需要额外排序,直接顺着 B+ 树叶子链表读就行。第三,也是很多人忽略的——主键一旦定了,行在磁盘上的位置就定了,改主键意味着整张表重新搬一遍。

1.2 二级索引的叶子节点存的是主键值,主键长度不是小事

InnoDB 的二级索引(你平时建的那些普通索引、唯一索引)叶子节点里存的不是行地址,而是主键值。要取完整行,得拿主键值回聚簇索引再查一次,这就是"回表"。

这个设计意味着主键的长度会被每个二级索引复制一份。算笔账:一张一亿行的表,上面挂了 5 个二级索引。主键从 8 字节的 BIGINT 换成 36 字节的 CHAR(36) UUID,光索引条目就多出(36 - 8) × 1亿 × 5 = 140 亿字节,约 13 GB。再算上 B+ 树页分裂导致的填充率下降、页目录开销,实际膨胀往往比这个数字还大,磁盘、备份、网络传输全都跟着涨。

所以别把主键当"随便挑一列"的事。它是会影响到每一个索引、每一次备份、每一份主从流量的基础设施决策。

1.3 没有显式主键时,InnoDB 会自己挑一个,或者干脆造一个

InnoDB 选聚簇索引的顺序是固定的:

  1. 有显式PRIMARY KEY,用它。
  2. 没有主键,就找第一个所有列都是 NOT NULL 的唯一索引,把它当聚簇索引。
  3. 前两条都不满足,InnoDB 自己生成一个隐藏的聚簇索引,名字叫GEN_CLUST_INDEX,用一个 6 字节的DB_ROW_ID作为行标识。

听起来第三条还挺贴心?实际是灾难现场。首先这个DB_ROW_ID来自一个全局共享的计数器,高并发插入时会互相争抢,插入性能掉得很难看。更要命的是,这个值只在当前实例的内存里递增,不写进 binlog。主库和从库各自给同一批数据分配不同的 row_id,基于行的复制在从库上回放 UPDATE/DELETE 时,就只能靠全表扫描去找匹配的行;如果表里恰好有内容完全相同的重复行,甚至可能改错行。

怎么发现这类表?直接查 InnoDB 的元数据:

SELECT t.name AS table_name, i.name AS index_name FROM information_schema.INNODB_INDEXES i JOIN information_schema.INNODB_TABLES t ON i.table_id = t.table_id WHERE i.name = 'GEN_CLUST_INDEX';

只要这条语句有结果,说明你库里就有表正在用隐藏聚簇索引。我接手一套陌生库的时候,第一件事就是跑这条 SQL,比看慢查询日志还管用。

1.4 主键和唯一索引,别当成一回事

新手最容易混淆的一对概念。它们的差异不止"一个能重复一个不能"这么简单:

对比项PRIMARY KEYUNIQUE KEY
是否允许 NULL不允许,隐式 NOT NULL允许,且多行 NULL 不冲突
每张表能有几个只能一个可以有多个
在 InnoDB 中是否是聚簇索引是(不指定主键时唯一非空索引顶上)通常不是
约束名能否自定义MySQL 里会被忽略,恒为 PRIMARY可以自定义

这里有个特别容易踩的点:MySQL 的 UNIQUE 索引允许多行 NULL,因为 NULL 不等于 NULL。这跟标准 SQL 的语义有出入,很多人第一次看到也很意外。所以你想用唯一索引来"保证某列不重复"的时候,如果没有额外加 NOT NULL,NULL 会直接绕过去。这也是为什么给表补主键时,通常建议顺手把列改成 NOT NULL。

2. 建表时就把主键定下来:写法、类型与取舍

2.1 三种写法,对应三种业务形态

写法一:代理键 + 业务唯一索引。这是绝大多数业务表的做法,主键与业务无关,专门用来定位行。

CREATE TABLE `order_flow` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL, `amount` DECIMAL(12,2) NOT NULL DEFAULT 0.00, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

写法二:业务字段直接做主键。适合字典表、配置表这种行数少、键值稳定、几乎不会改的场合。

CREATE TABLE `dict_item` ( `item_code` VARCHAR(32) NOT NULL, `item_name` VARCHAR(64) NOT NULL, PRIMARY KEY (`item_code`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

写法三:联合主键。典型的场景就是学生课程成绩表——一个学生对一门课只有一条记录,天然的复合唯一性。

CREATE TABLE `stu_score` ( `student_id` BIGINT UNSIGNED NOT NULL, `course_id` INT UNSIGNED NOT NULL, `score` DECIMAL(5,2) NOT NULL DEFAULT 0.00, PRIMARY KEY (`student_id`, `course_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

怎么选?我的判断标准是三条:这个键会不会变(学号会变、订单号基本不变)、这个键有多长(业务编号经常很长)、这个键要不要参与分片。三条里有一条不合格,就用自增代理键,把业务唯一性交给唯一索引。

2.2 自增主键用 INT 还是 BIGINT,算一算就知道

INT UNSIGNED的上限是 4294967295,约 42.9 亿。按业务增长速度算:

  • 每天 10 万行:42949 天,约 117 年,INT 完全够。
  • 每天 100 万行:4294 天,约 11.7 年,勉强够,但你得考虑业务增长。
  • 每天 1000 万行:429 天,约 1.2 年,不够,必须 BIGINT。
  • 每天 1 亿行:43 天,别犹豫,BIGINT UNSIGNED

而且理论值还得再打折。回滚、INSERT IGNOREREPLACEINSERT ... ON DUPLICATE KEY UPDATE、批量导入失败,这些都会消耗自增值但不产生行,也就是"自增空洞"。真实业务里空洞率 10% 到 50% 都见过。BIGINT UNSIGNED上限是 18446744073709551615,多花 4 个字节换一个"这辈子不用再想这事",性价比极高。我见过太多项目在上线第三年被迫改主键类型,那才是真的痛苦。

2.3 UUID 做主键的代价,以及怎么把代价压下去

UUID 看起来很香:全局唯一、客户端生成、不用回查数据库。但直接拿CHAR(36)存 UUID v4 做主键,是性能杀手。

原因在于 UUID v4 是随机的,而聚簇索引要求有序。随机插入意味着每次都往 B+ 树中间某个随机位置塞数据,页分裂频繁,页填充率可能掉到 50%~70%,写放大严重,索引文件虚胖。同时前面算过,36 字节的主键会被每个二级索引复制一份。

如果业务上确实需要 UUID,有三个缓解手段。

第一,用UUID_TO_BIN(uuid, 1)把字符串 UUID 转成 16 字节二进制,并且把 v1 UUID 的时间低位挪到前面,让二进制大致有序:

CREATE TABLE `user_uuid` ( `id` BINARY(16) NOT NULL, `name` VARCHAR(64) NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB; INSERT INTO user_uuid (id, name) VALUES (UUID_TO_BIN(UUID(), 1), 'tom'); SELECT BIN_TO_UUID(id, 1) AS id_str, name FROM user_uuid;

注意第二个参数swap_flag只有在 v1 UUID 上才有意义,v4 换不换都一样随机。

第二,换成有序 ID 生成方案,比如雪花算法、号段模式,生成出来的 ID 是趋势递增的,插入基本落在 B+ 树尾部。

第三,也是最实用的:让 UUID 去当唯一索引,主键还是用自增。业务对外暴露 UUID,内部关联和索引全走自增 ID,两边的优点都拿到了。

2.4 联合主键的列顺序,不是随便排的

PRIMARY KEY (student_id, course_id)PRIMARY KEY (course_id, student_id)是两个完全不同的东西。联合聚簇索引遵循最左前缀:前者支持WHERE student_id = ?WHERE student_id = ? AND course_id = ?,但单独WHERE course_id = ?走不了,得额外建索引。

那是不是"选择性高的列放前面"就一定对?不一定。联合主键决定了数据物理排列顺序,所以真正的判断依据是你的主要访问模式。如果业务主要按学生查成绩、偶尔按学生+课程定位,那就student_id在前,数据按学生聚在一起,一个学生的所有成绩在磁盘上是连续的,一次范围读就全拿到了。如果反过来主要按课程统计,那数据按课程聚簇反而更划算,学生维度的查询另建索引。

选错顺序的代价是实打实的随机 IO,不是"慢一点"的问题。

3. 表已经跑在线上:给存量表补主键的完整动作

3.1 先判断走哪条路:补列还是清洗数据

这是最关键的一步,很多人一上来就ALTER TABLE t ADD PRIMARY KEY (col),结果报错才开始慌。实际情况分两类:

  • 路径 A:表里已经有一个天然的候选列(比如order_no),想直接拿它做主键。这条路要先清洗数据,因为列里可能有 NULL、有重复。
  • 路径 B:表里没有任何合适的列,需要新增一个自增 ID 列再把它设为主键。这条路几乎不需要清洗数据,因为自增列会自动填值。

路径 B 的写法很省事,一条语句搞定:

ALTER TABLE order_flow ADD COLUMN id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY FIRST;

注意FIRST把新列放到最前面。如果你不在乎列顺序,去掉它也行。这条语句在 MySQL 8.0 上会重建表,但不需要你预处理任何数据。绝大多数"无主键老表"我都推荐走路径 B,因为路径 A 的数据清洗成本经常比想象中高得多,而且业务主键随时可能变。

3.2 走路径 A 之前的四项体检

如果确定要用已有列做主键,动手前把这四条都跑一遍。

-- 1. 确认当前确实没有主键 SHOW CREATE TABLE order_flow\G SELECT CONSTRAINT_NAME, COLUMN_NAME FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = 'biz' AND TABLE_NAME = 'order_flow' AND CONSTRAINT_NAME = 'PRIMARY'; -- 2. 查 NULL SELECT COUNT(*) AS null_cnt FROM order_flow WHERE order_no IS NULL; -- 3. 查重复 SELECT order_no, COUNT(*) AS c FROM order_flow GROUP BY order_no HAVING c > 1 ORDER BY c DESC LIMIT 20; -- 4. 查空串——这条最容易被漏 SELECT COUNT(*) FROM order_flow WHERE order_no = '';

第 4 条为什么单独拎出来?因为太多人把空串和 NULL 混为一谈。空串是合法值,两个空串放在唯一索引里照样冲突。清洗 NULL 的时候如果只处理IS NULL而漏了空串,ALTER 的时候还是会报Duplicate entry ''

3.3 ALTER 语句的几种写法和执行代价

基本写法:

ALTER TABLE order_flow ADD PRIMARY KEY (order_no);

显式补齐 NOT NULL 和自增(推荐,避免隐式行为):

ALTER TABLE order_flow MODIFY COLUMN id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, ADD PRIMARY KEY (id);

复合主键:

ALTER TABLE stu_score ADD PRIMARY KEY (student_id, course_id);

带约束名的写法(MySQL 里约束名会被忽略,实际还是 PRIMARY,别指望改掉它):

ALTER TABLE order_flow ADD CONSTRAINT pk_order_flow PRIMARY KEY (id);

关于执行代价,有几个要点必须提前知道。添加主键属于"重建表"的操作——即使 MySQL 5.7/8.0 支持ALGORITHM=INPLACE, LOCK=NONE(并发 DML 不阻塞),表本身还是会被完整重建一遍,期间会临时占用一份新表空间。

ALTER TABLE order_flow ADD PRIMARY KEY (id), ALGORITHM=INPLACE, LOCK=NONE;

估算额外空间的经验值:原表数据 + 索引大小 × 1.5 ~ 2,因为还有 undo、redo、binlog 和中转文件。先看清楚表有多大:

SELECT table_name, ROUND(data_length / 1024 / 1024 / 1024, 2) AS data_gb, ROUND(index_length / 1024 / 1024 / 1024, 2) AS index_gb FROM information_schema.TABLES WHERE table_schema = 'biz' AND table_name = 'order_flow';

时间估不准就只能实测,在等量数据的测试库上跑一遍是最靠谱的办法。

3.4 大表怎么办:gh-ost 这次帮不上忙

平时改大表结构,大家习惯了 gh-ost。但给无主键表加主键这个场景,gh-ost用不了——它的工作原理是按主键或唯一非空索引分片、再追 binlog,表上没有这个前提它就没法安全分片。所以这个场景你只有三条路:

  1. 低峰期直接 ALTER。表在 10 GB 以内、业务能接受几分钟抖动的话,这是最简单的选择。开LOCK=NONE,挑凌晨执行,提前把磁盘和主从延迟监控打开。
  2. 用 pt-online-schema-change。它是触发器方案,能跑无主键表,但 chunk 分片会退化成全表扫描,速度慢,而且触发器的额外开销在高写入表上很明显。
  3. 新建完整结构的表 + 分批搬数据 + RENAME 切换。最可控,也最费事。流程是先建一张带主键的目标表,用INSERT INTO ... SELECT ... WHERE id > ? ORDER BY id LIMIT 5000的方式分批搬,边搬边同步增量,最后在业务低峰RENAME TABLE切换。切换需要短暂停写或者双写,但整个过程对线上几乎没有影响。

我自己的选择顺序是:小于 10 GB 走第 1 条,10~100 GB 走第 3 条,中间地带看业务容忍度。第 2 条我一般只在没有其他选择的时候用。

3.5 盯着主从延迟,别只盯主库

ALTER 期间产生的是一个大事务(整个表的重建会写进 binlog),从库在回放这个事务时是原子的,这个期间从库的延迟会一直往上涨,直到事务回放完才一次性追平。所以大表 ALTER 之前一定要做几件事:确认从库开了并行复制(slave_parallel_workers/replica_parallel_workers大于 0),把max_binlog_cache_sizebinlog_cache_size调够,否则大事务可能直接报Multi-statement transaction required more than 'max_binlog_cache_size' bytes of storage。执行过程中用SHOW PROCESSLIST看主库的 State,用SHOW SLAVE STATUSSeconds_Behind_Master

4. 数据脏了怎么修:从报错反推的排查链路

4.1 报错一:Duplicate entry 'xxx' for key 'PRIMARY'

这个报错说明待加主键的列里存在重复值。排查链路是这样的。

第一步,看报错里的值是什么。如果是'0',先别急着找重复的 0,很可能真相是 NULL 被隐式转成了 0(后面 4.2 会展开)。如果是正常业务值,那就是真重复。

第二步,用GROUP BY定位重复组。MySQL 8.0 可以用窗口函数把"每组保留哪一条"标出来:

WITH dup AS ( SELECT id, order_no, ROW_NUMBER() OVER (PARTITION BY order_no ORDER BY updated_at DESC, id DESC) AS rn FROM order_flow ) SELECT * FROM dup WHERE rn > 1;

MySQL 5.7 不支持 CTE 和窗口函数,得用派生表:

SELECT * FROM ( SELECT id, order_no, @rn := IF(@prev = order_no, @rn + 1, 1) AS rn, @prev := order_no FROM order_flow, (SELECT @rn := 0, @prev := '') init ORDER BY order_no, updated_at DESC, id DESC ) x WHERE x.rn > 1;

第三步,怎么处理重复行,必须让业务方拍板。技术上有几种典型做法:保留id最小的(最早的那条)、保留updated_at最新的、或者按某个业务规则合并字段后删掉多余的。不要自己决定删哪条,订单流水这种表删错一行的后果你扛不住。

删除保留rn > 1的那些行,写的时候注意别在 MySQL 里直接对同一张表做子查询删除:

DELETE t FROM order_flow t JOIN ( SELECT id FROM ( SELECT id, ROW_NUMBER() OVER (PARTITION BY order_no ORDER BY updated_at DESC, id DESC) AS rn FROM order_flow ) x WHERE rn > 1 ) d ON t.id = d.id;

还有一个隐蔽的坑:排序规则的大小写敏感性。如果列用的是utf8mb4_general_ci这类_ci(case insensitive)排序规则,'ABC''abc'在比较时被视为相同,加主键时会报Duplicate entry 'abc',但如果你之前用COLLATE utf8mb4_bin手动查过重复,就会漏掉这些。查重复时用和列定义一致的默认排序规则,别手动加 COLLATE。

4.2 报错二:Invalid use of NULL value,以及那个诡异的 '0'

ALTER TABLE ... ADD PRIMARY KEY的时候,MySQL 会尝试把目标列隐式改成 NOT NULL。如果列里存在 NULL 值,就会报:

ERROR 1138 (22004): Invalid use of NULL value

在非严格模式的老版本上,你看到的可能是另一个报错:Duplicate entry '0' for key 'PRIMARY'。这不是"表里有两个 0",而是 NULL 在被转成 NOT NULL 的过程中被写成了 0,然后又撞上了另一个 0。两个完全不同的报错,指向的是同一件事。所以只要看到'0'出现在主键冲突里,先查一遍 NULL:

SELECT COUNT(*) FROM order_flow WHERE order_no IS NULL; SELECT COUNT(*) FROM order_flow WHERE order_no = '0';

修数据的时候,补值规则一定要跟业务确认。临时方案可以用主键加前缀:

UPDATE order_flow SET order_no = CONCAT('LEGACY', id) WHERE order_no IS NULL LIMIT 5000;

反复执行直到affected rows变成 0。为什么加LIMIT?因为一条全表UPDATE是个大事务,会撑爆 undo、拉长锁持有时间、把从库延迟顶上去。批大小我习惯取 5000 到 20000,看单行大小调整,每批之间停个几百毫秒再继续。

4.3 给存量数据"赋值主键"这件事,别用老写法

网上能搜到这种写法:用用户变量给每行编个号,然后当成主键。

SET @rn := 0; UPDATE order_flow SET id = (@rn := @rn + 1) ORDER BY created_at;

别用。MySQL 8.0 文档里明确说了,在UPDATE中给用户变量赋值、又在同一语句里读它,求值顺序是不保证的。8.0 之前能跑出正确结果,8.0 之后行为可能变,而且这种语句会把这个表锁成一个大事务。

正确做法就是用自增列让 MySQL 自己填值:

ALTER TABLE order_flow ADD COLUMN id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY FIRST;

如果你必须自己指定一批有序值(比如要预留号段),那就分批写,每批一个明确的区间,别用变量累加。

补值的时候还要注意字符串列的头尾空格和不可见字符。视觉上一样的两个值,用HEX()打出来可能完全不同:

SELECT id, HEX(order_no), LENGTH(order_no), order_no FROM order_flow WHERE order_no LIKE 'AB%' LIMIT 20;

Tab、回车、全角空格这些字符肉眼看不见,但唯一索引分得清清楚楚。

4.4 修完之后的三步验证

数据清完了、ALTER 也跑完了,别急着收工,按这三步验一遍。

-- 1. 主键真的建上了吗 SHOW CREATE TABLE order_flow\G SELECT * FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = 'biz' AND TABLE_NAME = 'order_flow' AND INDEX_NAME = 'PRIMARY';
-- 2. 库里还有没有表在用隐藏聚簇索引 SELECT t.name AS table_name FROM information_schema.INNODB_INDEXES i JOIN information_schema.INNODB_TABLES t ON i.table_id = t.table_id WHERE i.name = 'GEN_CLUST_INDEX';
-- 3. 行数对得上吗 SELECT COUNT(*) FROM order_flow;

第 3 步里有个东西要慎用:CHECKSUM TABLE。它对比前后校验和确实能发现数据错乱,但 InnoDB 上是全表扫描,大表在生产高峰跑一次能把自己跑出事故。我一般只在测试环境用,生产环境就用行数 + 关键字段抽样校验代替。

5. 主键加上之后还可能反悔:修改、删除和高频误操作

5.1 换主键列:一次 ALTER 干完,别分两步

有人想换主键,会这么写:

ALTER TABLE t DROP PRIMARY KEY; ALTER TABLE t ADD PRIMARY KEY (new_col);

这两条语句意味着两次全表重建,而且中间那段时间表是"裸"的——没有任何主键,InnoDB 立刻退回GEN_CLUST_INDEX,这个窗口期如果有复制流量进来,就是前面说的那些麻烦事。

正确写法是一句话:

ALTER TABLE t DROP PRIMARY KEY, ADD PRIMARY KEY (new_col);

MySQL 会做一次表重建,中间不出现"无主键"状态。差别是几个小时甚至十几个小时的事情。

5.2 删主键为什么要先摘掉 AUTO_INCREMENT

直接删会报这个错:

ERROR 1075 (42000): Incorrect table definition; there can be only one auto column and it must be defined as a key

原因是自增列必须是某个索引的一部分,你把主键删了,它就"悬空"了。所以顺序必须是先摘掉自增属性,再删主键:

ALTER TABLE t MODIFY id BIGINT UNSIGNED NOT NULL; -- 先去掉 AUTO_INCREMENT ALTER TABLE t DROP PRIMARY KEY; -- 再删主键

这两步其实可以合并成一条语句,同样只重建一次:

ALTER TABLE t MODIFY id BIGINT UNSIGNED NOT NULL, DROP PRIMARY KEY;

另外提醒一句:别在生产库上留着一张大表没有主键过夜。删完主键立刻补上新的,或者当天就补。表越大,越容易被复制延迟和全表扫描找上门。

5.3 自增空洞和重置:什么时候该管,什么时候别管

回滚、INSERT IGNOREREPLACE、批量插入失败都会消耗自增值,产生空洞。这是正常现象,不影响任何正确性,不需要修复。我见过有人为了"ID 连续"去手动重置自增,结果主从 ID 冲突、关联数据错乱,得不偿失。

重置自增的语法是:

ALTER TABLE t AUTO_INCREMENT = 1000000;

但它只能往了调。如果你填一个比当前max(id)还小的值,MySQL 会忽略它,下一行还是从max(id) + 1开始。另外从 MySQL 8.0 开始,自增值是持久化的,重启实例不会像 5.7 及之前那样"回到最大值+1"了,这个变化在做数据迁移脚本的时候要留意。

5.4 主键选型对后续运维的长期影响

主键这件事,影响的不只是查询性能,还有日常运维的方方面面。

主键短,备份就小。一亿行表主键从 36 字节缩到 8 字节,mysqldump 出来的文件、跨机房传输的流量、备份恢复的时间,全都跟着降。

主键有序,归档删除才能走索引。按时间归档历史数据是最常见的运维动作。如果主键是自增的,DELETE FROM t WHERE id < ?能按范围快速定位并批量删;如果主键是随机 UUID,同样的删除会变成大量随机 IO,慢十倍不止。

主键稳定,就不用担心改键。改一次主键就是重建一次表。所以选主键的时候多问一句"这列十年后会不会变",能省掉未来很多麻烦。

6. 还有几个边角情况值得单独说

6.1 设计评审时最容易扯皮的两件事

第一件是ER 图里主键怎么标。传统记号法(Chen 记号)里,主键属性下面画下划线;弱实体的主键画虚线或者双线。现代建模工具(Workbench、Navicat、dbdiagram 之类)一般不画下划线了,直接在字段行标个 PK 或者小钥匙图标。图本身怎么标不重要,重要的是评审的时候大家得在同一个模型上说话

第二件是逻辑主键还是代理主键。这是评审会上最容易吵起来的点。我的习惯是分两套:逻辑模型上标业务唯一键(学号、订单号这种),物理模型上一定是自增/有序代理键加业务唯一索引。评审时重点盯三件事——这个键会不会变、会不会很长、要不要参与分片。三件事里有任何一件答不上来,就说明设计还没想透。

6.2 分库分表场景下,主键的选法要换一套思路

单库自增在分片之后就不再全局唯一了。常见方案有四种:号段模式(数据库存一个号段表,服务一次取一批)、雪花算法(时间戳 + 机器位 + 序列号)、Redis 的INCR、以及直接用分片键参与主键。

这里有个容易被忽略的关联:主键和分片键的关系。如果表是按user_id分片的,主键最好让user_id参与进去,这样路由查询的时候不用跨分片。但如果主键直接用user_id,一个用户只能有一条记录,业务上通常不成立。所以更常见的是"雪花 ID 做主键,user_id做分片键",两边各司其职。

不管用哪种方案,主键的"短、有序、不变"三个原则在分片场景下只会更重要,因为数据量更大、跨节点操作更多。

6.3 常见问题快查

现象大概率原因处理方向
ADD PRIMARY KEY 报 Duplicate entry目标列存在重复值GROUP BY 定位,业务确认后去重
报 Invalid use of NULL value列里有 NULL,MySQL 要隐式改 NOT NULL先分批 UPDATE 补值
报 Duplicate entry '0'NULL 被隐式转成 0,或本来就存在 0先查 NULL 再查 0
DROP PRIMARY KEY 报 1075自增属性还在先 MODIFY 去掉 AUTO_INCREMENT
加了主键写入还是很慢主键是随机值(UUID v4)换有序 ID 或 UUID_TO_BIN(...,1)
ALTER 期间主从延迟飙升大事务 + 单线程回放低峰执行、开并行复制、拆步骤
SHOW CREATE TABLE 没有 PRIMARY KEY 但表能用用了唯一非空索引,或走了 GEN_CLUST_INDEX查 INNODB_INDEXES 确认
报 Multi-statement transaction required more than 'max_binlog_cache_size'大事务超过了 binlog cache 上限临时调大该参数再执行

另外补一个判断索引有没有被真正用上小技巧,MySQL 8.0 上可以直接查:

SELECT * FROM sys.schema_unused_indexes; SELECT * FROM performance_schema.table_io_waits_summary_by_index_usage WHERE INDEX_NAME = 'PRIMARY';

如果某张表的 PRIMARY 从上线到现在一次都没被读到过,那可能说明这张表的访问模式和你当初的设计假设完全不一样,值得回头看看。

最后说个我自己的习惯:接手一套陌生库的时候,我第一件事不是看慢查询日志,而是先跑一遍找GEN_CLUST_INDEX的语句,再跑一遍sys.schema_unused_indexes。这两条能在一分钟内告诉我这套库的设计有没有"欠账"。至于给存量表加主键,如果条件允许,我永远优先选"新加自增列再设为主键"这条路——数据不用清洗、不用等业务方确认去重规则、出错概率最低,多花的那几十 GB 磁盘,远比让业务停下来开三个会拍板要便宜。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/18 16:31:20

AWS实战指南:从核心服务选型到CLI部署与事件驱动架构

简介&#xff1a;这是一份《云计算》第三版配套课件《Amazon云计算AWS介绍》PPT&#xff0c;面向高校师生、云计算初学者及方案架构师&#xff0c;系统梳理AWS核心服务与典型应用场景。资源共1个pptx&#xff0c;容量2.85MB&#xff0c;内容覆盖基础存储架构Dynamo、弹性计算云…

作者头像 李华
网站建设 2026/9/18 16:29:24

拆解企业架构PPT:从咨询幻灯片到可执行架构资产

简介&#xff1a;本资源为埃森哲企业架构方法论核心课件&#xff0c;面向IT架构师、数字化转型从业者及企业战略规划人员&#xff0c;系统讲解如何通过结构化框架支撑业务与技术对齐。课件深度解析“四横五纵”企业架构模型&#xff1a;四横涵盖策略层、管理层、设计层与实施层…

作者头像 李华
网站建设 2026/9/18 16:27:41

高职网络工程毕设拓扑与方案怎么写?专科用智一刻一键生成

在高等职业专科院校计算机网络技术、网络规划与优化及信息安全技术等专业的毕业设计中&#xff0c;“中小型企业网络组网方案设计与实施”是最经典的选题方向。 很多专科同学动手能力很强&#xff0c;在华为 eNSP 或 Cisco 模拟器中能够熟练搭建核心层、汇聚层与接入层三层架构…

作者头像 李华
网站建设 2026/9/18 16:27:32

Modbus TCP通讯中的Unit ID之谜:一个字节导致的故障排查实录

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/18 16:27:17

自动化脚本开发实战:从Bash到Python的高效运维

1. 自动化与脚本技术概述十年前我第一次接触自动化脚本时&#xff0c;还是个需要手动重复点击上百次按钮的运维新手。直到某天深夜加班&#xff0c;看着屏幕上闪烁的光标&#xff0c;突然意识到&#xff1a;这些重复劳动完全可以用几行代码解决。从此便踏上了自动化脚本开发的不…

作者头像 李华