这是 Beta 探索课程,内容结构、实验步骤和示例可能会继续调整。
优化方案
原始雪花算法有一些限制,各大公司进行了优化。
特点
- 解决时钟回拨问题
- 支持双向生成
- 性能优化
实现
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 混用同一个索引。
特点
- 号段模式
- 双 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. 更大的序列号
使用更多位数表示序列号,提高并发能力。
根据业务需求选择合适的方案。
了解了优化方案后,我们学习号段模式。
👉 下一章:号段模式
发现内容有问题?
告诉我们哪里需要改进。
