用雪花 ID 和 UUID 做 MySQL 主键,被领导怼了

前些天在做订单表的设计时,我将雪花 ID 设为主键,并且顺带加了一句:“如果以后需要跨系统的同步的话,也可以用 UUID 来代替雪花 ID,反正都可以保证全局唯一。””

领导看完之后就只问了一个问题:

唯一就够了吗?你有没有想过 InnoDB 是如何存储数据的呢?

这句话听上去有些怼人,但是问题确实触及到了要害。在设计 MySQL 主键的时候,并不是只有 ID 是否会重复这么一个考虑,还需要考虑到写入是否有序、索引占用的空间有多大、ID 生成器出现故障之后如何处理等等。

为什么随机 UUID 不适合直接做主键

图片
InnoDB 使用 B+树来组织索引,而主键索引又属于聚簇索引。聚簇索引的叶子节点保存完整的行数据,所以主键值不仅仅是一个业务编号,还决定着数据大概会写入到哪个位置。

如果订单表直接用字符串 UUID 的话:

 CREATE TABLE orders_uuid (
    id CHAR(36) NOT NULL,
    user_id BIGINTNOT NULL,
    status TINYINT NOT NULL,
    created_at DATETIME NOT NULL,
PRIMARY KEY (id),
    KEY idx_user_id (user_id)
) ENGINE = InnoDB;
UUID v4 类似于以下内容:

 8a46bd38-f0f8-4a7c-92f7-69592be92667
1f376f44-c4c4-43a7-b857-fccbb24af3b8
f702d80e-00a9-47eb-9448-dfa50807b991
该组数据不存在递增的趋势。每次插入一条记录的时候,InnoDB 都有可能会定位到不同的数据页。如果目标页面的空间不够用的话,就会出现页分裂的情况;持续随机写入会带来更多的碎片以及缓存抖动。

另外一个容易被忽视的问题是:InnoDB 的二级索引叶子节点会保存对应主键的值。

上面的 idx_user_id 并不是只存储 user_id 的东西,它还带有一个 id。如果主键用的是 CHAR(36),那么每一条二级索引都会重复地存储着这个很长的主键。表中数据量越大、二级索引越多,则额外的空间以及缓存的成本就会越高。

因此,下面这样一种设计虽然可以运行,但是一般情况下并不建议使用:

 id CHAR(36) PRIMARY KEY
如果业务必须要用到 UUID 的话,那么至少可以把 BINARY(16) 改成:

 CREATE TABLE orders_uuid_bin (
    id BINARY(16) NOT NULL,
    user_id BIGINTNOT NULL,
    status TINYINT NOT NULL,
    created_at DATETIME NOT NULL,
PRIMARY KEY (id),
    KEY idx_user_id (user_id)
) ENGINE = InnoDB;
在 MySQL 中可以使用转换函数来实现 UUID 的写入和读取:

 INSERT INTO orders_uuid_bin (id, user_id, status, created_at)
VALUES (UUID_TO_BIN(UUID()), 10001, 0, NOW());

SELECT BIN_TO_UUID(id) AS id, user_id, status
FROM orders_uuid_bin;
BINARY(16) 可以解决字符串 UUID 占用空间过大问题,但是如果使用的是仍然完全随机的 UUID,那么它并不能从根本上解决随机写入的问题。更合适的方案是使用具有时间顺序特点的 UUID,比如 UUIDv7,在此基础上用 16 字节二进制的形式来存储。

雪花 ID 比 UUID 更适合,但也不是万能答案

图片
经典的雪花算法产生一个 64 位的整数,一般是由以下几个部分组成的:

 符号位 | 时间戳 | 机器标识 | 毫秒内序列号
由于高位包含了时间戳,雪花 ID 整体上会随着时间的增长而增长。它用的是 BIGINT 来存储,只需要 8 字节的空间,比 CHAR(36) UUID 要小很多,也比随机 UUID 更适合 InnoDB 的追加写入模式。

订单表的设计如下:

 CREATE TABLE orders_snowflake (
    id BIGINT UNSIGNED NOT NULL,
    user_id BIGINT UNSIGNED NOT NULL,
    order_no VARCHAR(32) NOT NULL,
    status TINYINT NOT NULLDEFAULT0,
    created_at DATETIME(3) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_order_no (order_no),
    KEY idx_user_id_created_at (user_id, created_at)
) ENGINE = InnoDB;
在 Java 中可以先生成 ID,在写入到数据库中去:

 publicfinalclassSnowflakeIdGenerator {
privatestaticfinallongSEQUENCE_BITS=12L;
privatestaticfinallongWORKER_ID_BITS=10L;
privatestaticfinallongMAX_SEQUENCE= (1L << SEQUENCE_BITS) - 1;

privatefinallong workerId;
privatelonglastTimestamp= -1L;
privatelongsequence=0L;

publicSnowflakeIdGenerator(long workerId) {
if (workerId < 0 || workerId >= (1L << WORKER_ID_BITS)) {
thrownewIllegalArgumentException("workerId 超出范围");
        }
this.workerId = workerId;
    }

publicsynchronizedlongnextId() {
longtimestamp= System.currentTimeMillis();

if (timestamp < lastTimestamp) {
thrownewIllegalStateException("检测到时钟回拨,拒绝生成 ID");
        }

if (timestamp == lastTimestamp) {
            sequence = (sequence + 1) & MAX_SEQUENCE;
if (sequence == 0) {
                timestamp = waitNextMillis(lastTimestamp);
            }
        } else {
            sequence = 0L;
        }

        lastTimestamp = timestamp;
return (timestamp << (WORKER_ID_BITS + SEQUENCE_BITS))
                | (workerId << SEQUENCE_BITS)
                | sequence;
    }

privatelongwaitNextMillis(long previousTimestamp) {
longtimestamp= System.currentTimeMillis();
while (timestamp <= previousTimestamp) {
            timestamp = System.currentTimeMillis();
        }
return timestamp;
    }
}
调用方式如下:

 SnowflakeIdGeneratorgenerator=newSnowflakeIdGenerator(12);
longorderId= generator.nextId();

Stringsql="""
    INSERT INTO orders_snowflake
        (id, user_id, order_no, status, created_at)
    VALUES
        (?, ?, ?, ?, NOW(3))
    """;
但是雪花 ID 只是“趋势递增”,并不能确定它是严格的连续的。同一个毫秒内可能会产生多个节点的 ID,不同机器产生的 ID 之间会有重叠。

另外还有三个要重视的问题:

  • 每一个实例的 workerId 都要唯一,否则不同的节点可能会产生相同的 ID;
  • 当服务器时钟回拨的时候,生成器要等待、出错或者切换备用策略;
  • ID 暴露了大概生成时间以及业务的增长趋势,并不适宜直接用在需要防止被猜测的公开接口上。
所以不能因为雪花 ID 性能优于随机 UUID,就忽视了它运维的成本。

自增主键为什么仍然是单库首选

如果业务只需要写入一个 MySQL 主库,并且没有离线生成 ID 的要求的话,那么 BIGINT AUTO_INCREMENT 通常就是最简单的、可靠的解决方案了:

 CREATE TABLE orders (
    id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    order_no VARCHAR(32) NOT NULL,
    user_id BIGINT UNSIGNED NOT NULL,
    status TINYINT NOT NULLDEFAULT0,
    created_at DATETIME(3) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_order_no (order_no),
    KEY idx_user_id_created_at (user_id, created_at)
) ENGINE = InnoDB;
主键短且递增,新数据一般追加到 B+Tree 的右侧,局部性比较好。应用本身也不用去维护机器编号、处理时钟回拨等问题,也不需要部署独立的 ID 服务。

在插入数据的时候,数据库会自动生成主键:

 INSERT INTO orders (order_no, user_id, status, created_at)
VALUES ('O202608010001', 10001, 0, NOW(3));

SELECT LAST_INSERT_ID();
自增 ID 的优点也十分明显,在分库分表、多写入节点或者需要提前拿到 ID 的情况下,单库自增很难直接满足全局唯一的要求。另外,它也容易被外界用户枚举,因此不应该默认使用数据库主键作为公开订单号。

物理主键和业务编号应该拆开

图片
很多关于主键的争论,其实质就是把“数据库怎样存储数据”与“业务怎样去辨认对象”混为一谈。

比较稳妥的订单模式为:

 CREATE TABLE orders_final (
    id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '数据库物理主键',
    order_no VARCHAR(32) NOT NULL COMMENT '对外业务编号',
    request_id BINARY(16) DEFAULTNULL COMMENT '跨系统请求标识',
    user_id BIGINT UNSIGNED NOT NULL,
    amount DECIMAL(12, 2) NOT NULL,
    status TINYINT NOT NULLDEFAULT0,
    created_at DATETIME(3) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_order_no (order_no),
UNIQUE KEY uk_request_id (request_id),
    KEY idx_user_id_created_at (user_id, created_at)
) ENGINE = InnoDB;
这三个 ID 各司其职:

  • id
     用于 InnoDB 存储和表内的关联,选择短小、递增的整数;
  • order_no
     为用户提供查询服务、客服交流以及接口展示;
  • request_id
     用于跨系统的追踪或者幂等控制,可以用到 UUID。
接口对外返回的是 order_no 而不是暴露物理主键:

 {
"orderNo":"O202608010001",
"status":"CREATED",
"amount":"99.00"
}
这样既能够保持 InnoDB 所要求的紧凑、有序主键,又不会迫使一个字段同时承担存储、分布式唯一、接口展示和安全防枚举等各种职责。

到底应该怎么选

图片
对于单库或者单写的情况,可以优先使用 BIGINT AUTO_INCREMENT。它并不是最先进方案,但是通常是最简单的方案,并且最容易被写入。

分库分表、多个服务独立生成 ID,或者业务必须在写库之前拿到主键的时候,可以使用雪花 ID 并用 BIGINT 来存储。但是上线之前要解决节点编号、时钟回拨以及生成器高可用的问题。

在跨语言、跨公司或者离线环境下需要单独生成全局标识的时候,就可以用到 UUID。如果要将 UUID 转换为 MySQL 索引中的值,则应该先将其存储为 BINARY(16);如果有选择版本的话,优先选择具有时间顺序特征的 UUID,而不能直接用随机 UUIDv4 来做聚簇主键。

领导真正怼的是雪花 ID 或者 UUID,并不是全局唯一的雪花 ID 或者 UUID,也不是适合做主键的雪花 ID 或者 UUID。以后在设计主键的时候,至少要回答好四个问题:占用多少个字节、写入时是否有序、二级索引会产生多大的成本,并且当 ID 生成服务出现故障之后该怎么办。

扫码领红包

微信赞赏支付宝扫码领红包

发表回复

后才能评论