开篇引入
"用什么做主键?"这个问题,每个DBA都被问过无数遍。
自增ID?UUID?业务ID?还是复合主键?选择错了,数据迁移痛苦、索引效率低下、主从复制出问题。选择对了,数据增长平稳、查询高效、扩展无忧。
《高性能MySQL》第6章专门讨论了标识符设计,这篇文章帮你做出正确的选择。
主键的基本原则
主键必须满足
- 唯一性:不能有重复值
- 非空:NOT NULL
- 不变性:值不应该改变
- 简洁性:越小越好,越简单越好
主键的选择标准
优先级顺序:
1. 整型 > 字符串 > 复合类型
2. 自增 > 有序UUID > 随机UUID
3. 单列 > 多列
4. 无业务含义 > 有业务含义
整型主键:自增ID
为什么整型最好
-- 整型主键的优点:
-- 1. 比较操作快(CPU原生支持)
-- 2. 占用空间小(INT=4字节,BIGINT=8字节)
-- 3. 索引效率高
-- 4. B+树插入稳定(顺序增长)
CREATE TABLE orders (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, -- 推荐BIGINT
...
);
AUTO_INCREMENT的配置
-- 查看自增值
SHOW CREATE TABLE orders;
-- AUTO_INCREMENT=1
-- 设置自增值起始点
ALTER TABLE orders AUTO_INCREMENT = 10000;
-- 配置最大自增值
SHOW VARIABLES LIKE 'auto_increment%';
-- auto_increment_increment: 自增步长
-- auto_increment_offset: 起始偏移量
自增ID的缺点
1. 分布式环境下不唯一
-- 多主复制时,自增ID可能冲突
-- 解决方案:设置不同的步长和偏移量
-- 主库1: auto_increment_offset=1, auto_increment_increment=2
-- 主库2: auto_increment_offset=2, auto_increment_increment=2
2. 无法在插入前获取
-- 如果需要提前知道ID(用于关联表)
-- 方案1:先插入获取ID
INSERT INTO orders (...) VALUES (...);
SET @new_id = 3172850;
-- 方案2:使用应用生成的唯一ID
UUID:通用唯一标识符
UUID的类型
-- UUID v1: 基于时间和MAC地址
-- UUID v4: 随机生成
-- UUID v5: 基于命名空间的SHA-1哈希
-- MySQL存储UUID的方式
CREATE TABLE sessions (
id CHAR(36) PRIMARY KEY, -- 标准UUID格式
data TEXT
);
-- 更高效的存储方式:去除连字符
CREATE TABLE sessions_v2 (
id VARCHAR(32) PRIMARY KEY,
data TEXT
);
-- 存储为二进制(节省一半空间)
CREATE TABLE sessions_v3 (
id BINARY(16) PRIMARY KEY,
data TEXT
);
随机UUID的问题
-- 随机UUID插入性能差
INSERT INTO orders VALUES (UUID(), ...);
-- 问题:随机UUID插入B+树是随机的
-- 结果:频繁的页分裂
-- 结果:索引碎片化
-- 结果:插入性能下降
有序UUID:UUID v7
MySQL 8.0.17+支持UUID v7(时间有序):
-- UUID v7:时间戳+随机数
SELECT UUID_TO_BIN(UUID(), 1);
-- 或者在MySQL 8.0.30+
SELECT BIN_TO_UUID(UUID_TO_BIN(UUID(), TRUE), TRUE);
应用层生成有序ID
如果MySQL不支持UUID v7,可以在应用层生成:
// Snowflake算法的Go实现
// ID结构:timestamp(41位) + machine(10位) + sequence(12位)
// 64位有符号整数
func GenerateSnowflake() int64 {
// 时间戳(毫秒)
timestamp := time.Now().UnixNano() / 1000000
// 机器ID
machineId := getMachineId()
// 序列号
sequence := getNextSequence()
return (timestamp - EPOCH) << 22 |
machineId << 12 |
sequence
}
业务主键:自然键
什么时候用业务主键
-- 适合用业务主键的场景:
-- 1. 业务上已经有唯一标识
-- 2. 需要在关联查询中直接使用
-- 3. 表之间有明确的业务关系
-- 用户表:用用户名或邮箱
CREATE TABLE users (
username VARCHAR(50) PRIMARY KEY,
email VARCHAR(100) NOT NULL UNIQUE,
...
);
-- 国家表:用ISO代码
CREATE TABLE countries (
country_code CHAR(2) PRIMARY KEY,
country_name VARCHAR(100) NOT NULL,
...
);
-- 银行表:用SWIFT代码
CREATE TABLE banks (
swift_code CHAR(11) PRIMARY KEY,
bank_name VARCHAR(100) NOT NULL,
...
);
业务主键的优点
- 查询时可以直接使用
- 无需先查询获取ID
- 业务含义清晰
业务主键的缺点
- 可能是字符串(比较慢)
- 可能很长(占用空间)
- 可能变化(违反不变性原则)
复合主键
什么时候用复合主键
-- 适合复合主键的场景:
-- 1. 两列或多列共同唯一
-- 2. 这几列天然是关联关系
-- 3. 避免创建额外的自增ID
-- 订单明细表
CREATE TABLE order_items (
order_id INT NOT NULL,
product_id INT NOT NULL,
quantity INT NOT NULL,
price DECIMAL(10,2) NOT NULL,
PRIMARY KEY (order_id, product_id)
);
-- 用户角色关联表
CREATE TABLE user_roles (
user_id INT NOT NULL,
role_id INT NOT NULL,
assigned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, role_id)
);
复合主键的问题
-- 问题1:主键太长
-- 如果有10列做主键,主键索引会非常大
-- 问题2:外键引用复杂
-- 外键必须包含所有主键列
-- 如果以后要改主键,外键也要改
-- 问题3:查询效率
-- 单列主键可以直接比较
-- 复合主键需要比较多个列
推荐方案
场景1:普通业务表
-- 推荐:自增BIGINT主键
CREATE TABLE users (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) NOT NULL UNIQUE,
email VARCHAR(100) NOT NULL UNIQUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
...
) ENGINE=InnoDB;
场景2:需要分布式唯一ID
-- 推荐:应用层生成或UUID v7
CREATE TABLE orders (
id VARCHAR(32) PRIMARY KEY, -- 雪花ID或UUID v7
user_id BIGINT UNSIGNED NOT NULL,
order_no VARCHAR(32) NOT NULL UNIQUE, -- 业务订单号
amount DECIMAL(15,2) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_id (user_id)
) ENGINE=InnoDB;
场景3:关联表(多对多)
-- 推荐:复合主键或单独ID
CREATE TABLE user_roles (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, -- 单独ID更灵活
user_id BIGINT UNSIGNED NOT NULL,
role_id INT NOT NULL,
assigned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_user_role (user_id, role_id), -- 唯一约束
INDEX idx_user_id (user_id),
INDEX idx_role_id (role_id)
) ENGINE=InnoDB;
场景4:历史数据表/日志表
-- 推荐:时间序ID(避免自增ID用完)
CREATE TABLE access_logs (
id BIGINT UNSIGNED PRIMARY KEY, -- 雪花ID
user_id INT UNSIGNED,
action VARCHAR(50) NOT NULL,
created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6), -- 微秒精度
INDEX idx_created_at (created_at),
INDEX idx_user_action (user_id, action, created_at)
) ENGINE=InnoDB;
外键设计
外键是否必要
支持外键的理由:
- 数据完整性自动保证
- 级联更新/删除
- 开发时减少数据校验
反对外键的理由:
- 插入、更新变慢(需要检查约束)
- 级联操作难以追踪
- 跨数据库迁移困难
- 分库分表时无法使用外键
建议:
- 小型项目、单机数据库:可以使用外键
- 大型项目、分布式架构:不使用外键,在应用层保证
-- 如果使用外键,注意级联选项
CREATE TABLE orders (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
...
FOREIGN KEY (user_id) REFERENCES users(id)
ON DELETE RESTRICT -- 不允许删除有订单的用户
ON UPDATE CASCADE -- 用户ID变更自动更新
);
主键与性能
主键与查询效率
-- 主键查询最快
SELECT * FROM users WHERE id = 12345;
-- 如果主键是UUID,查询会变慢
SELECT * FROM users WHERE id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
-- 解决方案:使用INT/BIGINT做主键,业务UUID做唯一索引
CREATE TABLE users (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
uuid CHAR(36) NOT NULL UNIQUE,
...
);
主键与索引
-- InnoDB表必须有主键
-- 如果没有显式主键,InnoDB会生成隐式主键
-- 隐式主键是6字节的整型,不可控
-- 建议:始终显式定义主键
CREATE TABLE logs (
-- 没有主键,InnoDB会自动创建
);
主键与存储
-- 主键越小,次级索引越小
-- 次级索引存储的是主键值
-- BIGINT(8字节) vs CHAR(36字节)
-- 如果有10个次级索引,每个索引都存主键值
-- CHAR主键比BIGINT多占用 36/8 = 4.5倍 的空间
小结
- 首选自增BIGINT主键:整型比较快,占用空间小
- 分布式环境用雪花ID或UUID v7:保证全局唯一且有序
- 业务唯一标识用UNIQUE约束:不影响主键效率
- 避免复合主键:除非真的需要多列唯一
- 永远不要用随机UUID做主键:会导致严重的索引碎片
- 主键应NOT NULL:InnoDB的隐式主键不支持NULL
- 主键越小越好:次级索引都存储主键值
- 外键根据场景决定:小型单体项目可以用,大型分布式项目不用
主键选择是Schema设计的核心决策,选对了后期维护成本大大降低。
延伸阅读
- 《高性能MySQL》第6章 schema设计与管理
- UUID v7 Specification: www.ietf.org/archive/id/…
- Snowflake Algorithm: Twitter's ID Generation