一、从一个真实的小问题说起

我前几年帮一个朋友维护过他的个人博客网站,说是博客,其实算一个精简版的内容管理系统。那时候文章表里放了一个自增ID,用来当主键。一开始访问量不大,一切正常。后来他做了个小程序,用户也能发表文章,还带评论功能,结果没到一个月就出问题了:数据库写入变得很慢,好几个地方出现了主键冲突。我一看才发现,他为了缓解数据库压力,把文章表拆成了两个库,结果两边自增ID都是从1开始,文章A和文章B的ID撞车了。从那天起,我就意识到,在内容管理系统这种需要到处生成标识符的场景里,“分布式ID”是一个绕不开的话题。

分布式ID,说白了就是在一个系统里生成一个全局唯一的数字或字符串,让每一次新增的内容都能被准确区分。下面我会结合CMS场景,从应用场景、常见方案、实践代码、优缺点和注意事项几个方面,把这块内容讲透。

二、内容管理系统里的应用场景

2.1 文章ID和评论ID

先看最基本的场景。一篇文章需要有一个文章ID,一条评论也需要有一个评论ID。在单库单表时候,用数据库自增没问题。但CMS一旦做集群部署,或者分了多库多表,每个表自己的自增ID就不管用了。你从两个库里分别查出ID为100的文章,但它们其实是两篇不同的文章。这时候,我们就需要一个全局唯一的ID生成服务来给文章和评论发号。

2.2 操作日志和审核记录

CMS后台通常记录编辑人员的一举一动,比如“谁在什么时间改过哪篇文章”。这些日志记录如果也用自增ID,在日志量很大的情况下,不仅会撞号,还会因为跨多个日志表导致查询困难。更麻烦的是,日志数据可能需要定期备份和清理,如果用时间有序的分布式ID来作为主键,就很容易按ID范围去归档。

2.3 分库分表后的数据路由

很多CMS发展到一定阶段,会把文章、评论、用户分别放到不同的数据库集群里。比如按照用户ID取模分到四个库。这时需要对用户ID这个全局标识做路由。如果用户ID是数据库自增的,分库后依然会冲突。如果使用分布式ID,它的唯一性就能保证数据在多个库里不会打架,而且还能携带一些额外信息,比如时间戳,方便做冷热数据分离。

三、几种常见的分布式ID方案

3.1 最省事的UUID

UUID是一种由十六进制字符组成的ID,比如“f47ac10b-58cc-4372-a567-0e02b2c3d479”。它太容易生成了,随便一台机器都能自己搞出来,完全不需要网络协调。但缺点也很明显:太长,占数据库空间;没有顺序,在MySQL的InnoDB里作为主键时,随机写入会导致页分裂,性能下降。所以UUID适合对存储空间和查询性能不敏感的场景,CMS中用得不多。

3.2 数据库号段模式

这种方案是把“每次从数据库取一个ID”改成“每次取一批ID”。比如有一张专门的号段表,多个应用节点同时过来,数据库负责把一段连续的号码发给某个节点,然后节点在本地慢慢使用。它的优点是非常灵活,ID是数字类型,对索引和排序都很友好。缺点是如果某个节点拿到了号段却没使用完就重启,这段号码就浪费了,而且系统还是依赖数据库,数据库挂了,ID服务也跟着挂。

3.3 Redis自增

如果你正好在CMS里用了Redis,那可以借助Redis的INCR命令来生成ID。Redis是单线程执行的,所以当多个请求同时来,它能保证每个请求拿到的数字都不重复。这个方案实现起来很简单,性能也很高。但需要考虑Redis持久化的问题。万一Redis宕机,重启后没有备份,可能导致ID重复。所以更适合对ID连续性要求不高的场景。

3.4 雪花算法

雪花算法可能是目前最流行的一种方案。它生成的ID是一个64位的long型整数,一般由以下几个部分组成:1位符号位(固定是0)、41位时间戳、10位机器标识、12位序列号。这样在同一毫秒内,同一台机器上可以生成4096个不重复的ID。这种ID的顺序性很好,整体趋势递增,而且不用依赖外部服务,纯本地算就行。CMS这种互联网应用,用雪花算法非常合适。下面我就用一个完整的Java示例,讲一下怎么把它落到项目里。

四、在CMS中落地实践

4.1 技术栈说明

下面的示例统一使用Java语言,并配合JDK 8以上版本。为了贴合CMS项目场景,我在代码里模拟了“Spring Boot + MyBatis”的常见写法。你只需要关注ID生成部分的逻辑,不需要额外引入其他技术栈。

4.2 自定义雪花算法工具类

我先写一个最简单的雪花算法生成器。它需要三个额外的参数:数据中心ID、机器ID、以及一个静态变量来记录序列号。

/**
 * 雪花算法ID生成器
 * 适用于内容管理系统中的文章ID、评论ID等
 */
public class SnowflakeIdWorker {

    /**
     * 起始时间戳:2020-01-01 00:00:00
     * 这个值可以按所在项目上线时间调整
     */
    private final long startTime = 1577808000000L;

    /**
     * 每一部分占用的位宽
     */
    private final long dataCenterIdBits = 5L;  // 数据中心占5位
    private final long workerIdBits = 5L;      // 机器占5位
    private final long sequenceBits = 12L;     // 序列号占12位

    /**
     * 这些位宽对应的最大值
     */
    private final long maxDataCenterId = -1L ^ (-1L << dataCenterIdBits); // 最多31个数据中心
    private final long maxWorkerId = -1L ^ (-1L << workerIdBits);         // 每个数据最多31台机器
    private final long sequenceMask = -1L ^ (-1L << sequenceBits);        // 每毫秒最多4096个ID

    /**
     * 向左移动的位数
     */
    private final long workerIdShift = sequenceBits;                       // 序列号左移12位
    private final long dataCenterIdShift = sequenceBits + workerIdBits;    // 数据中心左移17位
    private final long timestampShift = sequenceBits + workerIdBits + dataCenterIdBits; // 时间戳左移22位

    /**
     * 传入的数据中心ID和机器ID
     */
    private final long dataCenterId;
    private final long workerId;

    /**
     * 上一次生成ID的时间戳
     */
    private long lastTimestamp = -1L;

    /**
     * 当前毫秒内的序列号
     */
    private long sequence = 0L;

    /**
     * 构造方法,给当前节点分配数据中心ID和机器ID
     */
    public SnowflakeIdWorker(long dataCenterId, long workerId) {
        // 简单校验一下范围
        if (dataCenterId > maxDataCenterId || dataCenterId < 0) {
            throw new IllegalArgumentException("数据中心ID超出范围,最大为 " + maxDataCenterId);
        }
        if (workerId > maxWorkerId || workerId < 0) {
            throw new IllegalArgumentException("机器ID超出范围,最大为 " + maxWorkerId);
        }
        this.dataCenterId = dataCenterId;
        this.workerId = workerId;
    }

    /**
     * 生成下一个ID
     *
     * @return 全局唯一的long型ID
     */
    public synchronized long nextId() {
        long currentTimestamp = System.currentTimeMillis();

        // 时钟回拨场景:当前时间比上次生成ID的时间还早
        if (currentTimestamp < lastTimestamp) {
            // 这里可以做等待,也可以直接抛异常
            throw new RuntimeException("服务上的时间被回拨,请先校准时间");
        }

        if (currentTimestamp == lastTimestamp) {
            // 同一毫秒内,序列号递增
            sequence = (sequence + 1) & sequenceMask;
            if (sequence == 0) {
                // 序列号用完了,等待下一毫秒再来
                currentTimestamp = waitNextMillis(currentTimestamp);
            }
        } else {
            // 新的一毫秒,序列号重新从0开始
            sequence = 0L;
        }

        lastTimestamp = currentTimestamp;

        // 把时间戳、数据中心ID、机器ID、序列号拼到对应的位上
        return ((currentTimestamp - startTime) << timestampShift)
                | (dataCenterId << dataCenterIdShift)
                | (workerId << workerIdShift)
                | sequence;
    }

    /**
     * 等待到下一个毫秒
     */
    private long waitNextMillis(long currentTimestamp) {
        while (currentTimestamp <= lastTimestamp) {
            currentTimestamp = System.currentTimeMillis();
        }
        return currentTimestamp;
    }

    /**
     * 简单测试一下
     */
    public static void main(String[] args) {
        SnowflakeIdWorker worker = new SnowflakeIdWorker(1L, 1L);
        for (int i = 0; i < 5; i++) {
            System.out.println(worker.nextId());
        }
    }
}

上面这段代码就完成了一个非常基础的雪花算法工具。注意我用了synchronized来保证并发下不会拿到同一个序列号。这个工具类可以放到CMS项目里的公共模块,所有服务共用同一个类,但每个微服务节点需要分配不同的dataCenterId和workerId。

4.3 在插入文章时使用生成的ID

接下来,我把这个工具类和CMS的数据访问层结合起来。我们通常会在保存文章的时候,先通过这个工具获取一个ID,然后再插入数据库。

/**
 * 文章服务
 * 负责处理文章的新增、修改和查询
 */
@Service
public class ArticleService {

    /**
     * 注入雪花算法生成器
     * 在Spring Boot项目里,可以用@Bean在启动时创建并放入容器
     */
    private final SnowflakeIdWorker snowflakeIdWorker = new SnowflakeIdWorker(1L, 1L);

    /**
     * 注入mapper,模拟MyBatis的数据访问接口
     */
    private final ArticleMapper articleMapper;

    /**
     * 构造器注入,方便测试
     */
    public ArticleService(ArticleMapper articleMapper) {
        this.articleMapper = articleMapper;
    }

    /**
     * 保存一篇新文章
     */
    public void saveArticle(Article article) {
        // 用雪花算法生成一个文章ID
        article.setArticleId(snowflakeIdWorker.nextId());

        // 补充创建时间等公共字段
        article.setCreatedAt(System.currentTimeMillis());

        // 调用mapper插入数据库
        articleMapper.insert(article);
    }
}

这里对应的MyBatis映射文件大致长这样:

<?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.example.cms.mapper.ArticleMapper">

    <!-- 插入一条文章记录
         articleId 来自应用层,而不是数据库自增
     -->
    <insert id="insert" parameterType="com.example.cms.entity.Article">
        INSERT INTO t_article (
            article_id,
            title,
            content,
            created_at
        ) VALUES (
            #{articleId},
            #{title},
            #{content},
            #{createdAt}
        )
    </insert>
</mapper>

通过这种方式,原本数据库里的AUTO_INCREMENT就可以去掉了。主键完全由应用层控制,数据库只需要负责存储即可。多库多表环境下,只要每个节点的机器ID配置得不重复,生成的ID就永远不会重复。

4.4 关于时钟回拨的处理

时钟回拨是雪花算法最典型的坑。大家的服务器有时候会通过NTP同步时间,一旦同步把时间调回去了,就会导致生成出来的时间戳比之前的还小。如果不去处理,就可能生成重复的ID。

上面的示例里我用了抛异常的方式。实际生产环境中还可以这样处理:先把lastTimestamp保存下来,如果发现当前时间小于它,就等待一段时间,让系统时间自己追回来。一般情况下回拨都是毫秒级别的,等待几十毫秒就能过去。如果回拨超过1秒,说明服务器时钟异常,建议直接拒绝服务。

/**
 * 一个更温和的时钟回拨处理思路
 */
public long nextIdWithWait() {
    long currentTimestamp = System.currentTimeMillis();

    if (currentTimestamp < lastTimestamp) {
        // 计算回拨了多少毫秒
        long offset = lastTimestamp - currentTimestamp;

        // 如果回拨小于100毫秒,就等等
        if (offset < 100L) {
            try {
                Thread.sleep(offset + 20L);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                throw new RuntimeException("等待时钟恢复时被中断", e);
            }
            currentTimestamp = System.currentTimeMillis();
        } else {
            // 回拨太多,直接报错
            throw new RuntimeException("系统时间回拨超过100毫秒,拒绝生成ID");
        }
    }
    // 然后继续执行后面的位运算逻辑
    return this.nextId();
}

这只是一个简单的处理思路。更复杂的方案是在内存里把上一次的时间戳临时抬高,不过对大多数CMS来说,等待加报警已经足够了。

五、各方案的优缺点对比

在内容和CMS场景下,我们来看看上面提到的几种方案的实际表现:

方案 优点 缺点 适合CMS的哪些环节
UUID 零协调、本地生成、扩展容易 太长、无序、影响数据库索引性能 适合日志追踪,不适合做主键
数据库号段 实现简单、ID连续 依赖数据库,有号段浪费问题 内部系统数据导入
Redis自增 性能好、容易理解 Redis故障可能丢ID 评论计数、临时编号
雪花算法 高性能、趋势递增、不依赖外部服务 需要处理时钟回拨 文章、评论、用户主键

从表格可以看出,雪花算法在“趋势递增”和“不依赖外部服务”这两个方面很有吸引力。CMS的核心业务表通常用雪花算法最稳妥。

六、容易踩的坑和注意事项

6.1 机器ID不能随便配

很多人会把workerId写死在配置文件里,结果两台机器的配置一样,生成的ID就可能重复。建议通过环境变量、注册中心或者启动脚本分别注入。比如在启动时候传参--worker.id=1,不同节点传不同的值。

6.2 关注时间戳的回拨

前面已经提到过,除了等待和抛异常,你还要加一个监控。可以把每次生成ID时的时间戳和上一次的时间戳的差值记录下来,如果发现回拨,立刻报警。因为在正常生意下,服务器时间不会莫名其妙往回跳。

6.3 不要把生成ID和业务逻辑耦合在一起

有些同事会把SnowflakeIdWorker写成静态方法,到处调用,这倒没什么。但要注意别把workerId、dataCenterId这些参数放到业务表里,它们只是为了区分机器,跟文章内容没有半毛钱关系。

6.4 注意初始时间戳的选择

比如上面代码里选的是2020年1月1日。如果不小心选了个2024年1月1日,那前几年生成的ID会变成负数,虽然没错,但会让很多人误会。建议选择项目上线前的一个稳定日期作为起始时间,保证所有节点使用同一个起始时间。

七、文章总结

对于一个内容管理系统来说,分布式ID并不是一个炫技的功能,而是支撑系统成长的基础设施。从最开始的单库自增,到分库分表后的全局唯一,ID设计方案的选择会直接影响开发效率、数据库性能甚至系统的稳定性。数据库自增适合单库小规模,UUID适合对存储不敏感的辅助信息,Redis自增适合强依赖Redis的项目,而雪花算法因为性能高、无外部依赖、趋势递增,成为了CMS应用中最主流的选择。

在实际落地时,不需要把这个事情想得太复杂。先准备一个像样点的雪花算法工具类,给每个部署节点分配好机器ID,然后再把生成逻辑嵌入到文章、评论、日志的保存流程里就可以了。至于时钟回拨、机器ID管理这些细节,只要按照上面说的思路去处理,基本不会出大问题。希望这篇文章能让正在被ID问题困扰的朋友少走一些弯路。