分布式ID最新最佳实践?UUIDv7介绍

发布于:2025-06-15 ⋅ 阅读:(16) ⋅ 点赞:(0)

欢迎来到啾啾的博客🐱。
记录学习点滴。分享工作思考和实用技巧,偶尔也分享一些杂谈💬。
有很多很多不足的地方,欢迎评论交流,感谢您的阅读和评论😄。

引言

在过去的学习中,我们已经了解到,一个有序的ID往往对插入更友好,如果能带上时间标识,那么对运维也很友好。而在分布式系统中,全局唯一也是必不可少的要求。

关于高效生成有序带时间的全局唯一的ID,我们常见的方案是雪花ID(Snowflake)。
今天,我们一起来了解一个IETF官方标准,宣传自己更符合未来趋势的方案——UUIDv7。

与Snowflake简单对比

特性 Snowflake UUIDv7 推荐选择
ID类型 64位整数 (BIGINT) 128位值 (通常为字符串) 如果你的系统强依赖64位整数,选Snowflake。否则UUIDv7更灵活。
有序性 严格单调递增 (包括同毫秒内) 趋势递增 (同毫秒内乱序) 两者对数据库索引都足够友好。Snowflake的严格递增在某些场景下有优势。
时间可读性 优秀,需反向计算 优秀,更直观 两者都满足你的要求。
运维复杂度 需要管理机器ID,依赖时钟同步 几乎无运维,仅依赖时钟同步 UUIDv7胜出,极大简化了部署。
性能 极高 极高 两者性能都不是瓶颈。
生态与标准 业界事实标准,实现众多 IETF官方标准,未来趋势 UUIDv7是更现代、更标准化的选择。

如果要求ID必须是64位的BIGINT类型,或已经有了一套成熟的机器ID分配和管理机制。Snowflake 依然是一个非常棒的选择。

对于“同一毫秒内也是严格单调递增的”这一要求,UUIDv7的实现库做了优化和扩展,也可以实现。如uuid-creator 库。
其他情况可以考虑使用更直观的UUIDv7。

UUIDv7

官网

https://uuid7.com/
UUIDv7 是基于时间的 UUID 版本之一,与 UUIDv1 类似。然而,它提供了精确的时间戳,具有高达 50 纳秒的分辨率。UUIDv7 的核心特性是其时间可排序性。这意味着新生成的 UUID 值将大于旧的 UUID 值,因此它们可以根据创建时间自然排序。这对于数据库索引特别有用。

核心特性

基础结构

UUIDv7是128位值,结构如下:

[ 48位毫秒时间戳 ] [ 4位版本号'7' ] [ 76位随机数 ]

纳秒级严格单调递增

一些增强库通过维护内部计数器的方式来实现纳秒级的严格单调递增。
比如uuid-creator,在生成ID时会这样做:

  1. 获取当前毫秒时间戳,填充前48位。
  2. 维护一个内部计数器
    • 如果当前毫秒与上一个ID的毫秒相同,它不会生成一个纯随机数,而是将内部计数器加1,并将这个计数值编码到76位随机数区域的最前面几个比特位
    • 如果当前是一个新的毫秒,它会将这个内部计数器重置为0

通过这种“时间戳 + 序列号”的设计,这个库生成的ID在同一台机器上是严格单调递增的,即使在同一毫秒内也是如此。因为当时间戳相同时,ID的大小将由序列号决定。

全局唯一性

UUIDv7相比于Snowflake最大的设计亮点是不需要机器码的全局唯一性。

UUIDv7用“极大的随机空间”取代了Snowflake的“机器码”,从而在概率学上保证了全局唯一性。
理论上可能碰撞,但概率低到在宇宙生命周期内几乎不会发生。

Snowflake的全局唯一设计思想是确定性的、基于分区的隔离思想(基于不同机器码)。
UUIDv7这种是基于概率、统计的保证方法。

在随机数的76位空间中,其可能有2的76次方≈75.5万亿亿的天文数字空间。
发生一次碰撞的概率,比你随机从地球和另外一万个“地球”的所有沙子中,连续两次摸到同一粒沙子还要低。

SpringBoot集成UUIDv7

依赖

在https://mvnrepository.com/能看到使用较多的依赖有java-uuid-generator与uuid-creator,两者区别如下:

特性 uuid-creator (com.github.f4b6a3) java-uuid-generator (com.fasterxml.uuid)
UUIDv7 支持 原生、核心功能 完全不支持
维护状态 活跃开发 (Active) 维护模式 (Largely Inactive)
设计重心 现代、有序UUID (v6, v7, v8),解决数据库性能问题 传统、标准UUID (v1, v3, v4, v5),遵循旧版RFC 4122
历史与流行度 新一代的领导者,在新项目中越来越流行 曾经的王者,拥有巨大的历史下载量
易用性 (for v7) 一行代码搞定: UuidCreator.getTimeOrdered() 无法实现

所以我们使用uuid-creator,使用UUIDv7的唯一选择。

<!-- https://mvnrepository.com/artifact/com.github.f4b6a3/uuid-creator -->
<dependency>
    <groupId>com.github.f4b6a3</groupId>
    <artifactId>uuid-creator</artifactId>
    <version>6.1.1</version>
</dependency>

封装Bean

为我们的服务封装一个UUIDv7工具Bean。

import com.github.f4b6a3.uuid.UuidCreator;
import org.springframework.stereotype.Component;
import java.util.UUID;

/**
 * 专用的ID生成器服务,作为一个可注入的Spring Bean。
 * 默认是单例的。
 */
@Component
public class IdGenerator {

    public UUID newV7Id() {
        // 所有的生成逻辑都封装在这里
        return UuidCreator.getTimeOrdered();
    }

    // 如果未来需要Snowflake,可以增加一个方法
    // public long newSnowflakeId() { ... }
}

UUIDv7存储

存入数据库的最佳方式是: 使用数据库原生的 BINARY(16) (MySQL/MariaDB)或 UUID (PostgreSQL)类型。

存储方式 数据库类型 占用空间 性能问题
最佳实践 BINARY(16) 或 UUID 16 字节 最高效
VARCHAR(32) (去掉-) 32 字节 + 额外开销 性能较差
最差 VARCHAR(36) (带-) 36 字节 + 额外开销 性能最差

UUID本质是128位(bit)的数字。128 位 / 8 = 16 字节。这是它最紧凑、最原始的形态。

JAP映射

@Column(columnDefinition = "BINARY(16)")
private UUID id;

MyBatis映射

需要创建TypeHandler。编写一个Java类,实现MyBatis的TypeHandler<UUID>接口。这个类将负责 java.util.UUID 对象和 byte[] 数组之间的双向转换。


import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;
import org.apache.ibatis.type.MappedJdbcTypes;
import org.apache.ibatis.type.MappedTypes;

import java.nio.ByteBuffer;
import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.UUID;

/**
 * 自定义TypeHandler,用于在 Java 的 UUID 类型和数据库的 BINARY(16) 类型之间进行转换。
 *
 * @MappedTypes注解指定了这个Handler应该处理的Java类型。
 * @MappedJdbcTypes注解指定了这个Handler对应的JDBC类型。
 */
@MappedTypes(UUID.class)
@MappedJdbcTypes(JdbcType.BINARY)
public class UuidTypeHandler extends BaseTypeHandler<UUID> {

    /**
     * 将 Java 的 UUID 对象设置到 PreparedStatement 中,转换为 BINARY(16)
     * @param ps PreparedStatement 对象
     * @param i 参数索引
     * @param parameter UUID 参数
     * @param jdbcType JDBC 类型
     */
    @Override
    public void setNonNullParameter(PreparedStatement ps, int i, UUID parameter, JdbcType jdbcType) throws SQLException {
        ByteBuffer bb = ByteBuffer.wrap(new byte[16]);
        bb.putLong(parameter.getMostSignificantBits());
        bb.putLong(parameter.getLeastSignificantBits());
        ps.setBytes(i, bb.array());
    }

    /**
     * 从 ResultSet 中根据列名获取 BINARY(16) 数据,并转换为 UUID 对象
     * @param rs ResultSet 对象
     * @param columnName 列名
     * @return UUID 对象
     */
    @Override
    public UUID getNullableResult(ResultSet rs, String columnName) throws SQLException {
        byte[] bytes = rs.getBytes(columnName);
        if (bytes == null) {
            return null;
        }
        ByteBuffer bb = ByteBuffer.wrap(bytes);
        long mostSigBits = bb.getLong();
        long leastSigBits = bb.getLong();
        return new UUID(mostSigBits, leastSigBits);
    }

    /**
     * 从 ResultSet 中根据列索引获取 BINARY(16) 数据,并转换为 UUID 对象
     * @param rs ResultSet 对象
     * @param columnIndex 列索引
     * @return UUID 对象
     */
    @Override
    public UUID getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
        byte[] bytes = rs.getBytes(columnIndex);
        if (bytes == null) {
            return null;
        }
        ByteBuffer bb = ByteBuffer.wrap(bytes);
        long mostSigBits = bb.getLong();
        long leastSigBits = bb.getLong();
        return new UUID(mostSigBits, leastSigBits);
    }

    /**
     * 从 CallableStatement 中根据列索引获取 BINARY(16) 数据,并转换为 UUID 对象
     * @param cs CallableStatement 对象
     * @param columnIndex 列索引
     * @return UUID 对象
     */
    @Override
    public UUID getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
        byte[] bytes = cs.getBytes(columnIndex);
        if (bytes == null) {
            return null;
        }
        ByteBuffer bb = ByteBuffer.wrap(bytes);
        long mostSigBits = bb.getLong();
        long leastSigBits = bb.getLong();
        return new UUID(mostSigBits, leastSigBits);
    }
}

然后定义实体

import java.util.UUID;
import java.util.Date;

public class User {
    private UUID id;
    private String username;
    private Date createdAt;

    // Getters and Setters...
}

Mapper接口与xml

// src/main/java/com/yourcompany/project/mapper/UserMapper.java

import com.yourcompany.project.model.User;
import org.apache.ibatis.annotations.Param;
import java.util.UUID;

public interface UserMapper {
    void insert(User user);
    User findById(@Param("id") UUID id);
}

xml

<!-- src/main/resources/mapper/UserMapper.xml -->
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.yourcompany.project.mapper.UserMapper">

    <resultMap id="BaseResultMap" type="com.yourcompany.project.model.User">
        <!-- 
            虽然TypeHandler已经注册,但显式指定javaType可以增强可读性和健壮性。
            当MyBatis看到id列需要映射到java.util.UUID类型时,它会自动查找并使用我们注册的UuidTypeHandler。
        -->
        <id column="id" property="id" javaType="java.util.UUID"/>
        <result column="username" property="username" jdbcType="VARCHAR"/>
        <result column="created_at" property="createdAt" jdbcType="TIMESTAMP"/>
    </resultMap>

    <select id="findById" resultMap="BaseResultMap">
        SELECT id, username, created_at
        FROM `user`
        WHERE id = #{id}
    </select>

    <insert id="insert">
        <!--
            当MyBatis看到#{id}参数是java.util.UUID类型时,
            它会自动调用UuidTypeHandler的setNonNullParameter方法进行转换。
        -->
        INSERT INTO `user` (id, username)
        VALUES (#{id}, #{username})
    </insert>

</mapper>

MyBatis Plus

UuidTypeHandler不做变动,实体类更改。

// src/main/java/com/yourcompany/project/model/User.java
package com.yourcompany.project.model;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import java.util.Date;
import java.util.UUID;

@TableName("user") // 映射到数据库的 'user' 表
public class User {

    /**
     * 主键ID
     * @TableId 注解标识这是主键字段
     *  - value = "id": 对应数据库中的 'id' 列
     *  - type = IdType.INPUT: 这是一个至关重要的设置!
     *    它告诉MyBatis-Plus,这个ID是由用户(也就是我们的应用程序)在插入前手动设置的,
     *    MP不需要为我生成ID。这完美契合了UUIDv7的生成方式。
     */
    @TableId(value = "id", type = IdType.INPUT)
    private UUID id;

    private String username;

    private Date createdAt; // MP会自动处理驼峰命名到下划线的转换

    // Getters and Setters...
}

UUIDv7可读性提升

选择BINARY(16) 是为了极致的存储和索引性能,但却会牺牲运维和开发时的直观性。
我们有几种非常实用的策略来弥补这个短板,让运维和开发人员也能“看到时间”。

在数据库层面增加一个虚拟列(Generated Column)

这是MySQL 5.7+ 和 MariaDB 10.2+ 支持的一个非常优雅的特性。我们可以在表中增加一个不实际占用存储空间的虚拟列,这个列的值是根据 id 列实时计算出来的。
我们id字段为 BINARY(16) 以获得性能优势。创建一个新的虚拟列,比如叫id_text,它的值是将id字段转换为可读字符串的计算结果。

CREATE TABLE `user` (
  `id` BINARY(16) NOT NULL,
  `username` VARCHAR(50) DEFAULT NULL,
  `created_at` DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3),

  -- 重点:创建一个虚拟列用于显示
  `id_text` VARCHAR(36) GENERATED ALWAYS AS (BIN_TO_UUID(id)) VIRTUAL,

  PRIMARY KEY (`id`)
) ENGINE=InnoDB;
  • id_text 列:
    • GENERATED ALWAYS AS (BIN_TO_UUID(id)): 定义了该列的值总是通过 BIN_TO_UUID(id) 函数计算得出。
    • VIRTUAL: 表示这个列不占用物理存储空间。它的值在每次读取时动态计算。如果你希望它被物化以加快读取(以空间换时间),可以使用 STORED 代替 VIRTUAL。对于调试和查看,VIRTUAL 足够了。

直接使用转换工具或者脚本

-- 字符串转二进制 (用于 WHERE 条件)
SELECT * FROM user WHERE id = UUID_TO_BIN('01975a87-bc2f-7105-86c3-087d88ed2031');

-- 二进制转字符串 (用于查看)
SELECT BIN_TO_UUID(id) FROM user WHERE username = 'test-user';

应用层uuid-creator

uuid-creator 库提供了一个核心的工具类 UuidUtil,它包含了从UUID中提取时间戳的方法。
UuidUtil 类提供了 getTimestamp(UUID uuid) 方法。这个方法非常智能,它会自动识别UUID的版本,并根据对应版本的规范来提取时间戳。


import com.github.f4b6a3.uuid.UuidCreator;
import com.github.f4b6a3.uuid.util.UuidUtil;
import java.time.Instant;
import java.util.UUID;

public class UuidExtractorExample {

    public static void main(String[] args) {
        // 1. 生成一个UUIDv7
        UUID uuidV7 = UuidCreator.getTimeOrdered();
        System.out.println("Generated UUIDv7: " + uuidV7);

        // 2. 从UUIDv7中提取时间戳
        try {
            // getTimestamp() 返回的是一个 long 型的 Unix 毫秒时间戳
            long timestampMillis = UuidUtil.getTimestamp(uuidV7);
            System.out.println("Extracted Timestamp (ms): " + timestampMillis);

            // 3. 将时间戳转换为更易读的 Instant 对象
            Instant creationTime = Instant.ofEpochMilli(timestampMillis);
            System.out.println("Extracted Creation Time: " + creationTime);

        } catch (UnsupportedOperationException e) {
            System.err.println("The UUID version does not support timestamp extraction: " + e.getMessage());
        }

        System.out.println("\n--------------------------\n");

        // 演示对于非时间基UUID的情况
        UUID uuidV4 = UuidCreator.getRandomBased();
        System.out.println("Generated UUIDv4: " + uuidV4);

        try {
            long timestampV4 = UuidUtil.getTimestamp(uuidV4);
            // 这行代码不会被执行
        } catch (UnsupportedOperationException e) {
            System.err.println("Error extracting from UUIDv4: " + e.getMessage());
        }
    }
}

然后可以与DTO方法结合

import com.fasterxml.jackson.annotation.JsonProperty;
import com.github.f4b6a3.uuid.util.UuidUtil;
import java.time.Instant;
import java.util.UUID;

public class UserDto {
    private UUID id;
    private String username;

    // ... getters and setters

    @JsonProperty("idGeneratedAt")
    public Instant getIdGeneratedAt() {
        if (this.id == null) {
            return null;
        }
        try {
            // 直接使用 uuid-creator 的工具方法
            return UuidUtil.getInstant(this.id); // UuidUtil 还有一个更方便的 getInstant 方法!
        } catch (UnsupportedOperationException e) {
            // 如果ID可能是其他版本,返回null
            return null;
        }
    }
}