← 返回博客

UUID 主键对数据库性能的影响分析

2026-04-17 · 5 分钟阅读

B-Tree 索引与随机插入的性能问题

MySQL InnoDB 和 PostgreSQL 的主键索引都使用 B-Tree(平衡二叉树)结构,数据按主键顺序存储在聚集索引(InnoDB)或堆表上的索引(PostgreSQL)中。自增整数主键的插入总是发生在索引的最右侧(最大值),新页只在末尾添加,几乎不发生页分裂。UUID v4(随机)的插入位置是随机的:新 UUID 可能落在索引树的任意位置,需要在现有数据中间插入,导致频繁的页分裂(Page Split)。页分裂的代价:需要读取旧页、分配新页、复制部分数据、更新父节点指针,开销远大于普通插入,同时产生大量碎片,降低页面利用率(从接近 100% 降至约 50%)。

性能测试数据参考

根据多项数据库性能测试的典型结果:在 MySQL InnoDB 中,随机 UUID 主键比自增整数主键的写入性能慢约 30%-50%(1000 万条记录规模);在高写入压力下(每秒数千次插入),性能差距可能达到 2-5 倍;索引碎片率:UUID v4 主键表经过大量插入后,碎片率通常在 30%-60%,而自增主键几乎无碎片;存储开销:UUID 主键(varchar(36) 或 char(36))比整数(bigint,8 字节)大 4-4.5 倍,在有多个二级索引的表中,每个二级索引都包含主键,存储放大效果更明显。以上数字因数据库版本、硬件配置和工作负载类型而异,具体场景应进行基准测试。

解决方案一:使用 BINARY(16) 存储

将 UUID 存储为 16 字节的 BINARY(16) 而不是 varchar(36),可以减少存储开销约 56%(16 vs 36 字节),减少索引大小,间接提高 B-Tree 的扇出率(每个节点可以存更多键),降低树的高度,改善读取性能。在 MySQL 中,可以使用 UUID_TO_BIN() 和 BIN_TO_UUID() 函数进行转换。PostgreSQL 原生 UUID 类型本身就是 16 字节存储,无需额外处理。

-- MySQL:BINARY(16) 方案
CREATE TABLE users (
    id BINARY(16) PRIMARY KEY DEFAULT (UUID_TO_BIN(UUID())),
    name VARCHAR(100),
    email VARCHAR(200) UNIQUE
);

-- 插入
INSERT INTO users (name, email) VALUES ('Alice', '[email protected]');

-- 查询
SELECT BIN_TO_UUID(id) as id, name
FROM users
WHERE id = UUID_TO_BIN('550e8400-e29b-41d4-a716-446655440000');

-- PostgreSQL:直接使用 UUID 类型(16字节)
CREATE TABLE users (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name TEXT,
    email TEXT UNIQUE
);

解决方案二:有序 UUID(UUID v7 或重排 UUID v1)

-- MySQL 8.0+:重排 UUID v1 使其有序
CREATE TABLE orders (
    -- UUID_TO_BIN(UUID(), 1) 的第二个参数 1 表示重排时间字段
    -- 使时间戳部分排在高位,保证插入顺序性
    id BINARY(16) PRIMARY KEY DEFAULT (UUID_TO_BIN(UUID(), 1)),
    customer_id INT NOT NULL,
    total DECIMAL(10,2)
);

-- 验证有序性(连续生成的 UUID 应该递增)
SELECT BIN_TO_UUID(id, 1) FROM orders ORDER BY id LIMIT 10;

-- 应用层使用 UUID v7(推荐新项目)
-- Python
# pip install uuid7
import uuid7
id = uuid7.uuid7()
print(id)  # 时序有序的 UUID v7

-- JavaScript
// npm install uuid
import { v7 as uuidv7 } from 'uuid';
console.log(uuidv7()); // 有序 UUID v7

解决方案三:双主键方案

对于需要兼顾性能和安全性的场景,可以采用双主键方案:内部使用自增整数主键(数据库自动管理,索引性能最优),外部使用 UUID 作为 public_id 列(加唯一索引),对外 API 只暴露 UUID。这个方案的代价是额外的存储空间(每行多 20-28 字节)和一个额外的索引。但换来了最优的索引性能(自增主键)和安全性(不暴露顺序)。适合已经在使用自增主键、需要逐步迁移的场景。

CREATE TABLE products (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,  -- 内部 ID,性能最优
    public_id CHAR(36) NOT NULL UNIQUE,     -- 对外 UUID
    name VARCHAR(200),
    price DECIMAL(10,2),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 插入时在应用层生成 public_id
INSERT INTO products (public_id, name, price)
VALUES (UUID(), 'Product A', 99.99);

-- 对外 API 使用 public_id 查询
SELECT * FROM products WHERE public_id = ?;

-- 内部联表使用 id(性能更好)
SELECT p.*, c.name as category_name
FROM products p JOIN categories c ON p.category_id = c.id
WHERE p.id = 12345;

定期维护:重建碎片化索引

-- MySQL:优化(重建)碎片化的 UUID 主键表
OPTIMIZE TABLE users;  -- 重建表和所有索引

-- 或者使用 ALTER TABLE 重建(在线操作,不锁表)
ALTER TABLE users ENGINE=InnoDB;

-- PostgreSQL:重建索引
REINDEX INDEX users_pkey;  -- 重建特定索引
REINDEX TABLE users;       -- 重建表上所有索引
VACUUM FULL users;         -- 回收碎片空间(会锁表,谨慎使用)
VACUUM ANALYZE users;      -- 不锁表的版本

-- 查看索引碎片率(MySQL)
SELECT
  table_name,
  data_free / (data_length + index_length) * 100 as fragmentation_pct
FROM information_schema.tables
WHERE table_schema = 'your_db' AND table_name = 'users';

立即免费使用相关工具

免费使用 →