优化方案

原始雪花算法有一些限制,各大公司进行了优化。

百度 UidGenerator

特点

  • 解决时钟回拨问题
  • 支持双向生成
  • 性能优化

实现

UidGenerator 保留了雪花算法的骨架(时间戳 + 机器号 + 序列号),但改掉了两个最容易出问题的地方:时钟回拨和序列号耗尽时的自旋等待。

Java 实现:

/**
 * 百度 UidGenerator 的核心结构。
 *
 * 相比经典雪花算法,它做了三件事:
 * 1. 把数据中心 ID 和机器 ID 打包成一个 uid(10 位)
 * 2. 时钟小幅回拨时不抛异常,沿用上次的时间戳继续发号
 * 3. 支持反向生成,让正序和倒序的 ID 能共存于同一张表
 */
public class UidGenerator {

    /** 起始时间:2020-01-01 00:00:00。 */
    private static final long START_TIMESTAMP = 1577836800000L;
    /** 时间戳位数。 */
    private static final int TIMESTAMP_BITS = 41;
    /** uid(数据中心 + 机器号)位数。 */
    private static final int UID_BITS = 10;
    /** 序列号位数。 */
    private static final int SEQUENCE_BITS = 12;

    private static final long MAX_SEQUENCE = (1L << SEQUENCE_BITS) - 1;

    /** 允许的最大时钟回拨毫秒数。 */
    private static final long TOLERATE_TIME_DIFF = 2000L;

    private final long uid;

    private long lastTimestamp = -1L;
    private long sequence = 0L;

    public UidGenerator(long dataCenterId, long workerId) {
        this.uid = (dataCenterId << 5) | workerId;
    }

    /**
     * 生成下一个 UID。
     */
    public synchronized long nextUid() {
        long timestamp = currentTimestamp();

        if (timestamp < lastTimestamp) {
            long difference = lastTimestamp - timestamp;
            if (difference > TOLERATE_TIME_DIFF) {
                throw new IllegalStateException("时钟回拨过大,拒绝发号: " + difference + "ms");
            }
            // 小幅回拨:沿用上次的时间戳,靠序列号把额度补回来
            timestamp = lastTimestamp;
        }

        if (timestamp == lastTimestamp) {
            sequence = (sequence + 1) & MAX_SEQUENCE;
            if (sequence == 0) {
                // 同一毫秒的额度用完,等到下一毫秒
                timestamp = nextMillis(lastTimestamp);
            }
        } else {
            sequence = 0L;
        }

        lastTimestamp = timestamp;
        return (timestamp << TIMESTAMP_BITS) | (uid << SEQUENCE_BITS) | sequence;
    }

    /** 相对起始时间的毫秒数。 */
    private long currentTimestamp() {
        return System.currentTimeMillis() - START_TIMESTAMP;
    }

    /** 自旋等待到下一毫秒。 */
    private long nextMillis(long lastTimestamp) {
        long timestamp = currentTimestamp();
        while (timestamp <= lastTimestamp) {
            timestamp = currentTimestamp();
        }
        return timestamp;
    }
}

三个改进各有代价:

  • 时钟回拨容忍:回拨幅度在 2 秒以内时沿用上次的时间戳,靠序列号把额度补回来,代价是这段时间内发出来的 ID 时间戳不再严格递增;回拨幅度超过阈值就直接抛异常,宁可报错也不发重复 ID。
  • 序列号耗尽:同一毫秒的 4096 个 ID 用完之后自旋等待下一毫秒,而不是像经典实现那样把序列号一直加到溢出。
  • 双向生成:把时间戳部分按位取反,就得到一个倒序的 UID,适合「最新数据排最前」的场景。反向 UID 不能和正向 UID 混用同一个索引。

美团 Leaf

特点

  • 号段模式
  • 双 Buffer 机制
  • 高可用

实现

Leaf 的核心是号段模式:数据库只维护一条「号段进度」记录,应用一次领走一整段,在本地发放。配合双 Buffer,切换时不用等数据库。

号段表:

CREATE TABLE id_segment (
    id            BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    business_type VARCHAR(64)     NOT NULL,
    max_id        BIGINT UNSIGNED NOT NULL DEFAULT 0,
    step          INT             NOT NULL DEFAULT 1000,
    version       INT             NOT NULL DEFAULT 0,
    created_at    TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at    TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    UNIQUE KEY uk_business_type (business_type)
) ENGINE=InnoDB;

Java 实现:

/**
 * 美团 Leaf 号段模式的 DAO:一次预占一整段号段。
 */
public class LeafSegmentDao {

    /** 把号段进度往后推一整段。 */
    private static final String UPDATE_SQL =
            "UPDATE id_segment SET max_id = max_id + step, version = version + 1 "
                    + "WHERE business_type = ?";

    /** 读回推进后的水位。 */
    private static final String SELECT_SQL =
            "SELECT max_id, step FROM id_segment WHERE business_type = ?";

    private final DataSource dataSource;

    public LeafSegmentDao(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    /**
     * 预占一段号段,返回区间的左右端点(闭区间)。
     */
    public long[] fetchSegment(String businessType) throws SQLException {
        try (Connection conn = dataSource.getConnection()) {
            conn.setAutoCommit(false);
            try {
                // UPDATE 持有的行锁会一直保持到事务结束,
                // 所以后面读到的就是本次预占推进出来的水位
                try (PreparedStatement ps = conn.prepareStatement(UPDATE_SQL)) {
                    ps.setString(1, businessType);
                    ps.executeUpdate();
                }

                long newMax;
                long step;
                try (PreparedStatement ps = conn.prepareStatement(SELECT_SQL)) {
                    ps.setString(1, businessType);
                    try (ResultSet rs = ps.executeQuery()) {
                        if (!rs.next()) {
                            throw new IllegalStateException("号段记录不存在: " + businessType);
                        }
                        newMax = rs.getLong("max_id");
                        step = rs.getLong("step");
                    }
                }
                conn.commit();

                return new long[] { newMax - step + 1, newMax };
            } catch (SQLException e) {
                conn.rollback();
                throw e;
            }
        }
    }
}

Leaf 这个方案里值得注意的几点:

  • 号段是连续的,不像集群模式那样按步长错开。发号器拿到区间后逐个发放,ID 在号段内严格连续。
  • step 配置在号段表里,不同业务类型可以配不同的号段长度:核心业务用 100 这种小步长减少浪费,日志类业务用 10000 这种大步长减少访问数据库。
  • version 每次预占都加一,排查问题时能看出号段是在哪一次预占中推进的。
  • 发号侧再套一层双 Buffer,号段用完之前就提前把下一段取回来,切换时只是一次内存操作。

其他优化

1. 更长的时间戳

使用更多位数表示时间戳,延长使用年限。

2. 更多的节点

使用更多位数表示节点 ID,支持更多节点。

3. 更大的序列号

使用更多位数表示序列号,提高并发能力。


选择建议

根据业务需求选择合适的方案。


下一步

了解了优化方案后,我们学习号段模式。

👉 下一章:号段模式

发现内容有问题?

告诉我们哪里需要改进。