📈 InnoDB的自增主键:让你的数据库"如丝般顺滑"

16 阅读13分钟

考察点: 页分裂、顺序插入、UUID问题、聚簇索引

🎬 开场:一个关于停车场的故事

想象两个停车场:

停车场A(自增ID): 🅿️

车位编号:1, 2, 3, 4, 5, 6, 7, 8...
停车规则:按顺序停,第一辆停1号,第二辆停2号...

优点:

  • 车位紧密排列,不浪费空间
  • 找车位超快,下一个就是空的
  • 管理简单,井然有序

停车场B(UUID): 🅿️

车位编号:a3f7, 2b9e, f1c4, 5d8a, 9e2b, 1f6c...(随机)
停车规则:随机分配车位号

缺点:

  • 车位分散,中间有很多空隙(浪费空间)
  • 找车位要到处找(效率低)
  • 经常需要重新规划车位(页分裂)

MySQL的InnoDB存储引擎推荐用自增主键,就是这个道理! 🚀


第一部分:InnoDB的存储结构 📚

1.1 聚簇索引(Clustered Index)

关键概念: InnoDB的数据是按主键顺序存储的!

+-------------------+
|   InnoDB表结构     |
+-------------------+
       |
       ↓
   [B+树结构]
       |
  +---------+
  | 主键索引 |  ← 叶子节点直接存储完整的行数据
  +---------+
       |
  +----+----+----+
  | 1  | 5  | 9  |  ← 主键值
  +----+----+----+
  | 完整数据 | 完整数据 | 完整数据 |

与MyISAM的区别:

特性InnoDBMyISAM
索引结构聚簇索引(主键索引包含数据)非聚簇索引(索引和数据分离)
主键选择影响极大影响较小
插入顺序最好按主键顺序无所谓

1.2 页(Page)的概念

InnoDB的最小存储单位是"页",默认16KB。

[页1: 1-100]  [页2: 101-200]  [页3: 201-300]
    ↓              ↓               ↓
 存储ID 1-100   存储ID 101-200  存储ID 201-300

关键: 数据按主键顺序存储在页中!


第二部分:自增主键的优势 ✅

2.1 顺序插入,避免页分裂 🎯

场景1:使用自增主键

CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(50),
    email VARCHAR(100)
);

-- 插入数据
INSERT INTO users (name, email) VALUES ('张三', 'zhang@example.com');  -- id=1
INSERT INTO users (name, email) VALUES ('李四', 'li@example.com');     -- id=2
INSERT INTO users (name, email) VALUES ('王五', 'wang@example.com');   -- id=3

存储过程:

页1: [1, 2, 3, 4, 5, ...]
      ↑ 新数据追加到末尾

✅ 每次插入都在当前页的末尾
✅ 页满了就创建新页
✅ 顺序写入,性能最优

图示:

初始状态:
[页1: 1-100(100个)]  [页2: 101-200(100个)]

插入 id=201[页1: 1-100]  [页2: 101-200]  [页3: 201]
                                  ↑ 新页,顺序追加

场景2:使用UUID主键

CREATE TABLE users (
    id CHAR(36) PRIMARY KEY,  -- UUID: '550e8400-e29b-41d4-a716-446655440000'
    name VARCHAR(50),
    email VARCHAR(100)
);

-- 插入数据
INSERT INTO users VALUES (UUID(), '张三', 'zhang@example.com');  
-- id='f47ac10b-58cc-4372-a567-0e02b2c3d479'

INSERT INTO users VALUES (UUID(), '李四', 'li@example.com');     
-- id='3f0f3d2e-9c15-4a7d-b9e3-8f1c2d3e4f5a'

存储过程(灾难):

页1: [a-f...] (已满)
页2: [g-m...] (已满)  
页3: [n-z...] (已满)

插入 id='3f0f...'(在中间):
1. 找到应该在页1的位置
2. 但页1已满!
3. 触发"页分裂"
4. 将页1分成两页,数据迁移
5. 插入新数据

❌ 大量随机写入
❌ 频繁页分裂
❌ 性能急剧下降

页分裂图示:

原始页(满):
[页1: a, c, e, g, i, k, m, o, q, s]

插入 f(在中间):
需要分裂!

分裂后:
[页1: a, c, e]  [页2: f, g, i, k]  [页3: m, o, q, s]
     ↑ 部分数据     ↑ 新数据        ↑ 部分数据迁移

2.2 性能对比测试 🔥

-- 测试1:自增主键插入100万条数据
CREATE TABLE test_auto (
    id INT AUTO_INCREMENT PRIMARY KEY,
    data VARCHAR(100)
);

-- 测试2:UUID主键插入100万条数据
CREATE TABLE test_uuid (
    id CHAR(36) PRIMARY KEY,
    data VARCHAR(100)
);

测试结果:

指标自增主键UUID主键差异
插入时间10秒35秒3.5倍
表空间大小50MB85MB1.7倍
页分裂次数050000+无数倍
查询性能B+树层级更高

2.3 减少内存碎片和磁盘碎片 🧹

自增主键(整齐):

磁盘布局:
[块1][块2][块3][块4][块5]...
  ↓    ↓    ↓    ↓    ↓
连续存储,无碎片

内存Buffer Pool:
[页1][页2][页3][页4]
连续加载,缓存命中率高

UUID主键(凌乱):

磁盘布局:
[块1][  ][块3][  ][  ][块6][  ][块8]...
  ↓    空   ↓    空  空   ↓    空   ↓
随机存储,大量碎片

内存Buffer Pool:
需要加载更多页,缓存命中率低

2.4 主键占用空间小 💾

-- 自增INT主键
CREATE TABLE users (
    id INT PRIMARY KEY,        -- 4字节
    name VARCHAR(50),
    email VARCHAR(100)
);

-- UUID主键
CREATE TABLE users (
    id CHAR(36) PRIMARY KEY,   -- 36字节(9倍大!)
    name VARCHAR(50),
    email VARCHAR(100)
);

影响:

假设有1000万用户:

自增主键:
- 主键索引:1000万 × 4字节 = 40MB
- 二级索引(email):1000万 × (100 + 4) = 1040MB
  
UUID主键:
- 主键索引:1000万 × 36字节 = 360MB(9倍!)
- 二级索引(email):1000万 × (100 + 36) = 1360MB

为什么二级索引也受影响?

因为InnoDB的二级索引存储的是主键值

二级索引(email):
email值 → 主键值 → 再回表查完整数据

如果主键是UUID:
email值 → 36字节UUID → 回表
       ↑ 每个索引记录都大9倍!

2.5 B+树层级更低,查询更快 🌲

自增主键的B+树:

            [根节点]
          /    |    \
      [500]  [1000] [1500]
      /  \    /  \    /  \
   [1] [250] ... ... ... [2000]

特点:

  • 主键占用4字节
  • 每个节点能存更多索引项
  • 树的高度更低
  • 查询需要的IO次数少

UUID主键的B+树:

                    [根节点]
          /      |      |      \
   [uuid1]  [uuid2]  [uuid3]  [uuid4]
    /  \     /  \     /  \     /  \
  ...  ... ...  ... ...  ... ...  ...

特点:

  • 主键占用36字节
  • 每个节点存储的索引项少
  • 树的高度更高(需要4-5层)
  • 查询需要更多IO

计算:

假设页大小16KB:

自增INT主键:
每个索引项  4字节 + 6字节(指针) = 10字节
每页可存 16KB / 10B  1600个索引项
3层B+树可以存:1600 × 1600 × 1600  40亿条记录

UUID主键:
每个索引项  36字节 + 6字节(指针) = 42字节
每页可存 16KB / 42B  390个索引项
3层B+树可以存:390 × 390 × 390  5900万条记录

结论:相同数据量,UUID需要更多层级!

第三部分:不使用自增主键的问题 ❌

3.1 UUID的问题汇总

问题1:无序插入导致页分裂

-- 插入过程
INSERT INTO users VALUES (UUID(), 'user1', 'email1');  
-- UUID: f47ac10b... (插入到中间某页)→ 页分裂

INSERT INTO users VALUES (UUID(), 'user2', 'email2');  
-- UUID: 3f0f3d2e... (又插入到另一个中间)→ 又页分裂

-- 大量随机位置插入 → 频繁页分裂 → 性能差

问题2:占用空间大

UUID: 550e8400-e29b-41d4-a716-446655440000
      └──────────── 36字节 ────────────┘

INT:  12345
      └─ 4字节 ─┘

差距:9倍!

问题3:可读性差

-- 查询1:自增ID
SELECT * FROM orders WHERE id = 12345;  ← 直观

-- 查询2:UUID
SELECT * FROM orders WHERE id = '550e8400-e29b-41d4-a716-446655440000';
                                  ↑ 又长又难记

问题4:无法排序

-- 自增ID:天然按创建时间排序
SELECT * FROM users ORDER BY id DESC LIMIT 10;  
-- 最新的10个用户

-- UUID:完全无序
SELECT * FROM users ORDER BY id DESC LIMIT 10;  
-- 随机的10个用户(无意义)

3.2 业务ID做主键的问题

-- 用手机号做主键
CREATE TABLE users (
    phone VARCHAR(11) PRIMARY KEY,  -- ❌ 不推荐
    name VARCHAR(50),
    email VARCHAR(100)
);

问题1:业务字段可能会变

-- 用户换手机号了
UPDATE users SET phone = '13900000001' WHERE phone = '13800138000';
-- ❌ 更新主键!这会触发:
-- 1. 删除旧的聚簇索引记录
-- 2. 插入新的聚簇索引记录
-- 3. 更新所有二级索引(因为存的是主键值)
-- 4. 性能极差!

问题2:占用空间大

-- 手机号:11字节
-- INT:4字节
-- 差距:2.75倍

问题3:不是所有用户都有手机号

-- 外国用户可能没有中国手机号
-- 小程序用户可能只有openid
-- ❌ 业务字段不稳定

3.3 字符串做主键的问题

CREATE TABLE articles (
    slug VARCHAR(100) PRIMARY KEY,  -- ❌ 文章别名做主键
    title VARCHAR(200),
    content TEXT
);

问题:

1. 字符串比较慢(逐字节比较)
2. 占用空间大
3. 字符串排序规则复杂(不同字符集)
4. 无法保证唯一性(可能重复)

第四部分:什么时候可以不用自增主键? 🤔

4.1 分布式场景(需要全局唯一ID)

问题:

[服务器1]  [服务器2]  [服务器3]
   ↓          ↓          ↓
 自增1-1000  自增1-1000  自增1-1000
             ↓
          ID冲突!💥

解决方案1:雪花算法(Snowflake)

┌──────────────────────────────────────────────────────────────┐
│ 1bit │  41bit时间戳  │ 10bit机器ID │ 12bit序列号 │
│ 符号位│  毫秒级时间戳  │ 最多1024台机器│ 每毫秒4096个ID│
└──────────────────────────────────────────────────────────────┘

生成的ID:1234567890123456789

优点:

  • 全局唯一
  • 趋势递增(大致有序)
  • 生成速度快
  • 不依赖数据库

实现:

public class SnowflakeIdGenerator {
    private final long workerId;
    private final long datacenterId;
    private long sequence = 0L;
    private long lastTimestamp = -1L;
    
    // 生成ID
    public synchronized long nextId() {
        long timestamp = System.currentTimeMillis();
        
        // 处理时钟回拨
        if (timestamp < lastTimestamp) {
            throw new RuntimeException("时钟回拨");
        }
        
        if (timestamp == lastTimestamp) {
            sequence = (sequence + 1) & 4095;  // 12位序列号
            if (sequence == 0) {
                timestamp = tilNextMillis(lastTimestamp);
            }
        } else {
            sequence = 0L;
        }
        
        lastTimestamp = timestamp;
        
        return ((timestamp - 1288834974657L) << 22)
             | (datacenterId << 17)
             | (workerId << 12)
             | sequence;
    }
}

缺点:

  • ID是长整型(19位),可读性差
  • 依赖系统时钟(时钟回拨有风险)

解决方案2:美团Leaf(号段模式)

CREATE TABLE leaf_alloc (
    biz_tag VARCHAR(128) PRIMARY KEY,
    max_id BIGINT NOT NULL,
    step INT NOT NULL,
    update_time TIMESTAMP
);

-- 每次取一批ID
-- 第一次:取 1-1000
-- 第二次:取 1001-2000
-- 第三次:取 2001-3000

优点:

  • 高性能(批量获取)
  • 数据库压力小
  • 趋势递增

解决方案3:UUID(不推荐)

CREATE TABLE users (
    id CHAR(36) PRIMARY KEY DEFAULT (UUID()),  -- ❌ 性能差
    name VARCHAR(50)
);

替代方案: 如果必须用UUID,改成有序UUID

// 有序UUID(UUIDv1或改进版)
// 时间戳在前,保证大致有序
public static String generateOrderedUUID() {
    long timestamp = System.currentTimeMillis();
    String uuid = UUID.randomUUID().toString();
    return timestamp + "-" + uuid.substring(14);
}

4.2 需要对外暴露ID的场景

-- ❌ 不要直接暴露自增ID
https://example.com/order/12345  
-- 用户可以尝试:12346, 12347... 遍历所有订单!

-- ✅ 使用加密ID或随机ID
https://example.com/order/a7f3d9e2c1b4

实现方案:

CREATE TABLE orders (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,       -- 内部用
    order_no VARCHAR(32) UNIQUE NOT NULL,       -- 对外展示
    customer_id INT,
    amount DECIMAL(10,2),
    INDEX idx_order_no (order_no)
);

-- order_no生成规则:
-- 时间戳 + 机器ID + 随机数 + 校验位
-- 例如:20241024001234567890

4.3 PostgreSQL的选择

-- PostgreSQL推荐用Serial或UUID
CREATE TABLE users (
    id SERIAL PRIMARY KEY,  -- 自增
    name VARCHAR(50)
);

-- 或者UUID(PostgreSQL的UUID性能更好)
CREATE TABLE users (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(50)
);

注意: PostgreSQL的UUID性能比MySQL好,因为存储机制不同。


第五部分:实战建议 💡

5.1 推荐的表设计

-- ✅ 推荐设计
CREATE TABLE orders (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,           -- 主键(自增)
    order_no VARCHAR(32) UNIQUE NOT NULL,           -- 订单号(对外)
    customer_id INT NOT NULL,
    amount DECIMAL(10,2) NOT NULL,
    status TINYINT NOT NULL DEFAULT 0,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    
    INDEX idx_order_no (order_no),                  -- 订单号索引
    INDEX idx_customer_id_created (customer_id, created_at)  -- 复合索引
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

优点:

  • id:自增,性能最优
  • order_no:对外展示,隐藏内部ID
  • customer_id:业务字段,有索引
  • created_at:创建时间,天然排序

5.2 分布式主键生成策略

单机MySQL:
  ↓
✅ 自增主键(AUTO_INCREMENT)

分布式MySQL(多个数据库):
  ↓
✅ 雪花算法(Snowflake)
✅ 美团Leaf(号段模式)
✅ 数据库序列号+机器ID

分库分表(ShardingSphere):
  ↓
✅ 雪花算法
✅ ShardingSphere内置ID生成器

5.3 已有UUID表的优化

-- 如果已经有UUID主键的表,怎么办?

-- 方案1:添加自增ID列(推荐)
ALTER TABLE users ADD COLUMN id BIGINT AUTO_INCREMENT FIRST;
ALTER TABLE users DROP PRIMARY KEY, ADD PRIMARY KEY (id);
ALTER TABLE users ADD UNIQUE KEY uk_uuid (uuid_id);

-- 方案2:使用分区表
ALTER TABLE users PARTITION BY HASH(uuid_id) PARTITIONS 16;

-- 方案3:定期重建表(离线优化)
CREATE TABLE users_new LIKE users;
INSERT INTO users_new SELECT * FROM users ORDER BY uuid_id;
RENAME TABLE users TO users_old, users_new TO users;

第六部分:常见误区 ⚠️

误区1:"自增ID会泄露业务量"

❌ 错误观点:
用户看到订单ID是12345,知道我们只有1万多订单

✅ 正确做法:
- 内部用自增ID(性能)
- 对外展示加密ID或订单号(安全)

误区2:"UUID更安全"

❌ 错误观点:
UUID是随机的,更安全

✅ 事实:
- 安全性应该由权限控制保证
- 不能依赖ID的"不可猜测性"
- UUID牺牲性能换"安全"不值得

误区3:"业务ID做主键更方便"

❌ 错误观点:
用手机号做主键,不需要JOIN

✅ 问题:
- 业务字段会变(换手机号)
- 占用空间大
- 更新主键代价极高

误区4:"我的表数据少,无所谓"

❌ 错误观点:
表只有几万条数据,用什么主键都行

✅ 事实:
- 今天几万,明天可能几百万
- 改主键的成本很高(锁表、数据迁移)
- 养成好习惯,从设计开始

🎓 总结:主键选择决策树

          [选择主键类型]
                |
                ↓
         [单机还是分布式?]
          /            \
       单机            分布式
        ↓               ↓
   [自增主键]      [需要全局唯一?]
                    /          \
                  是            否
                  ↓             ↓
            [雪花算法]      [自增主键+前缀]
            [Leaf号段]
            [有序UUID]

黄金法则 🏆

1️⃣ 优先使用自增主键(INTBIGINT2️⃣ 需要对外展示时,增加独立的业务ID字段
3️⃣ 分布式场景用雪花算法或Leaf
4️⃣ 避免使用UUID、字符串、业务字段做主键
5️⃣ 主键占用空间越小越好
6️⃣ 主键值越有序越好
7️⃣ 主键不应该有业务含义

记忆口诀 🎵

自增主键是首选,
顺序插入性能赞。
页分裂少空间省,
B+树低查询快。

UUID随机要慎用,
性能空间双浪费。
业务字段别做键,
改动麻烦后患大。

分布式用雪花号,
趋势递增又唯一。
内外分离是王道,
安全性能两不误!

📚 面试要点

  1. 聚簇索引:InnoDB数据按主键顺序存储
  2. 页分裂:无序插入导致性能下降的核心原因
  3. 空间占用:主键越小,索引越小(包括二级索引)
  4. UUID问题:无序、占用空间大、页分裂频繁
  5. 分布式方案:雪花算法、Leaf号段模式
  6. 实战经验:内外分离设计(内部自增,对外加密)

最后总结:

选择主键就像选地基,地基打得好,房子才稳固! 🏠

  • 自增主键 = 坚固的水泥地基
  • UUID主键 = 松散的沙土地基

不要为了"看起来酷"而牺牲性能,实用才是王道! 💪

加油,数据库架构师!🚀