分布式ID生成方案详解
为什么需要分布式ID
在传统单体应用架构中,我们通常依赖数据库的自增主键来生成唯一标识符,这种方式简单高效且能保证全局唯一。然而,随着业务规模的快速增长,当单表数据量突破千万级甚至亿级时,单库单表的性能瓶颈愈发明显。此时,分库分表成为必然选择。
在分库分表场景下,数据被分散存储到多个数据库实例的多张物理表中。假设订单表被水平拆分为16个分片,每个分片独立维护自增ID,就会出现不同分片生成相同ID的情况,导致全局唯一性被破坏。因此,我们需要一套能够跨库跨表生成全局唯一标识的方案,这就是分布式ID的核心价值。
分布式ID的核心要求
一个优秀的分布式ID生成方案应当满足以下关键特性:
全局唯一性:这是最基本也是最核心的要求。无论在哪个数据分片、哪台应用服务器上生成的ID,都必须保证在整个分布式系统中绝对唯一,不能出现任何重复。
高性能与高可用:ID生成服务往往是系统的核心基础组件,几乎所有业务操作都依赖它。因此必须保证极高的响应速度(通常要求毫秒级)和接近100%的服务可用性,不能成为系统瓶颈。
趋势递增:根据不同的业务场景,对递增性有不同层次的要求:
- 基础要求:整体呈现递增趋势,新生成的ID大概率比旧ID大
- 中级要求:单个服务节点内保证严格递增
- 高级要求:全局范围内保证严格单调递增
为什么需要递增特性?主要有两个原因:一是MySQL的InnoDB引擎使用B+树索引,递增ID能够避免页分裂,显著提升写入性能;二是递增ID便于进行范围查询和数据归档。
主流分布式ID生成方案
UUID方案
UUID(Universally Unique Identifier)全局唯一标识符,是一种在单机环境下就能生成全局唯一ID的算法。标准UUID由32位16进制数字组成,格式为xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx,长度为36个字符(包含4个连字符)。
UUID的生成依赖多种因素的组合:当前时间戳、时钟序列、网卡MAC地址等信息,通过特定算法计算得出。这些因素的组合确保了在不同时间、不同机器上生成的UUID几乎不可能重复。
实践案例
在文件存储系统中,我们可以使用UUID作为文件的唯一标识:
@Service
public class FileStorageService {
@Autowired
private OSSClient ossClient;
/**
* 上传文件并生成唯一标识
*/
public String uploadFile(MultipartFile file) {
// 生成UUID作为文件唯一标识
String fileId = UUID.randomUUID().toString();
// 获取原始文件扩展名
String originalFilename = file.getOriginalFilename();
String extension = originalFilename.substring(
originalFilename.lastIndexOf(".")
);
// 构造存储文件名
String storageFileName = fileId + extension;
// 存储文件到云存储服务
ossClient.putObject(bucketName, storageFileName,
file.getInputStream());
return fileId;
}
/**
* 根据文件ID下载文件
*/
public InputStream downloadFile(String fileId, String extension) {
String storageFileName = fileId + extension;
return ossClient.getObject(bucketName, storageFileName);
}
}
优缺点分析
优势所在:
- 本地生成,性能极高,单机每秒可生成数百万个UUID
- 无需依赖外部服务,不存在网络开销和单点故障风险
- 实现简单,Java标准库即可直接使用
明显缺陷:
- 长度过长:36个字符作为主键会占用较多存储空间,且影响索引效率
- 无业务语义:类似
550e8400-e29b-41d4-a716-446655440000的字符串完全无法体现业务含义,给问题排查和运维带来困难 - 不支持递增:无序的UUID作为主键会导致MySQL InnoDB引擎频繁的页分裂,严重影响写入性能
- 不便于展示:在用户界面显示UUID不够友好,用户体验较差
数据库自增方案
利用MySQL等关系型数据库的AUTO_INCREMENT特性来生成全局唯一ID,是最直观的实现方式。
基础实现
创建一个专门用于生成ID的数据表:
CREATE TABLE id_generator (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
business_type VARCHAR(50) NOT NULL COMMENT '业务类型',
created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;
应用程序通过插入并获取自增ID:
@Service
public class DbIdGeneratorService {
@Autowired
private JdbcTemplate jdbcTemplate;
/**
* 生成唯一ID
*/
public Long generateId(String businessType) {
// 插入一条记录
jdbcTemplate.update(
"INSERT INTO id_generator(business_type) VALUES(?)",
businessType
);
// 获取刚插入的自增ID
Long id = jdbcTemplate.queryForObject(
"SELECT LAST_INSERT_ID()",
Long.class
);
return id;
}
}
高可用改进
单库方案存在明显的单点故障问题。改进方案是部署主从架构,并设置不同的自增步长:
-- 主库配置
SET @@auto_increment_increment = 2; -- 步长为2
SET @@auto_increment_offset = 1; -- 起始值为1,生成1,3,5,7...
-- 从库配置
SET @@auto_increment_increment = 2; -- 步长为2
SET @@auto_increment_offset = 2; -- 起始值为2,生成2,4,6,8...
这种方案的问题在于扩容困难,如果需要再增加一个节点,就必须调整所有节点的步长配置,系统风险较高。
优缺点总结:
- 优势:ID单调递增,实现简单,符合DBA运维习惯
- 劣势:存在单点故障风险,性能受限于数据库,扩展性差
号段模式
号段模式是对数据库自增方案的性能优化,核心思想是批量获取ID,减少数据库交互次数。
工作原理
每次应用服务器启动时,不是每次生成ID都去访问数据库,而是一次性从数据库中获取一个号段(比如1000个连续的ID),缓存在本地内存中。当需要生成ID时,直接从内存中的号段分配,这一批用完后再去数据库获取下一个号段。
为了防止多个服务实例之间发生冲突,采用号段隔离的方式:
- 服务实例A获取号段:1 ~ 1000
- 服务实例B获取号段:1001 ~ 2000
- 服务实例C获取号段:2001 ~ 3000
- 当实例A的号段用完后,再次获取时得到:3001 ~ 4000
架构设计
数据库设计
CREATE TABLE id_segment (
biz_tag VARCHAR(50) NOT NULL PRIMARY KEY COMMENT '业务标识',
max_id BIGINT NOT NULL DEFAULT 0 COMMENT '当前已分配的最大ID',
step INT NOT NULL DEFAULT 1000 COMMENT '号段步长',
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB;
代码实现
@Service
public class SegmentIdGenerator {
@Autowired
private JdbcTemplate jdbcTemplate;
// 本地缓存的号段信息
private static class Segment {
private long currentId;
private long maxId;
}
private final ConcurrentHashMap<String, Segment> segmentMap = new ConcurrentHashMap<>();
/**
* 生成分布式ID
*/
public synchronized long nextId(String bizTag) {
Segment segment = segmentMap.get(bizTag);
// 首次获取或号段已用尽
if (segment == null || segment.currentId >= segment.maxId) {
segment = loadNewSegment(bizTag);
segmentMap.put(bizTag, segment);
}
return ++segment.currentId;
}
/**
* 从数据库加载新的号段
*/
private Segment loadNewSegment(String bizTag) {
// 使用数据库行锁保证并发安全
Long newMaxId = jdbcTemplate.queryForObject(
"UPDATE id_segment SET max_id = max_id + step " +
"WHERE biz_tag = ? " +
"RETURNING max_id",
Long.class,
bizTag
);
Integer step = jdbcTemplate.queryForObject(
"SELECT step FROM id_segment WHERE biz_tag = ?",
Integer.class,
bizTag
);
Segment segment = new Segment();
segment.currentId = newMaxId - step;
segment.maxId = newMaxId;
return segment;
}
}
优缺点总结
优势:
- 大幅减少数据库访问次数,性能远超直接数据库自增
- 单个服务实例内生成的ID严格递增
- 即使数据库短时间不可用,本地缓存的号段仍可继续使用
劣势:
- 无法保证全局严格递增,只能保证趋势递增
- 服务重启会导致部分号段浪费
- 仍然依赖数据库,存在单点故障风险(但影响已大幅降低)
许多分库分表中间件如TDDL Sequence就采用号段模式生成主键ID。
Redis实现方案
利用Redis的INCR命令实现原子性自增,也是一种高性能的分布式ID生成方案。
实现方式
@Service
public class RedisIdGenerator {
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 生成分布式ID
*/
public Long generateId(String businessKey) {
String redisKey = "distributed:id:" + businessKey;
// Redis INCR命令保证原子性
Long id = redisTemplate.opsForValue().increment(redisKey);
return id;
}
/**
* 带日期前缀的ID生成
*/
public String generateIdWithDate(String businessKey) {
String datePrefix = LocalDate.now().format(
DateTimeFormatter.ofPattern("yyyyMMdd")
);
String redisKey = "distributed:id:" + businessKey + ":" + datePrefix;
// 每天重新计数,并设置过期时间
Long seq = redisTemplate.opsForValue().increment(redisKey);
redisTemplate.expire(redisKey, 2, TimeUnit.DAYS);
// 生成格式:20231215000001
return datePrefix + String.format("%06d", seq);
}
}
优缺点分析
优势:
- 基于内存操作,性能极高,单机QPS可达10万+
- 通过Redis集群可以解决单点故障问题
- 实现简单,易于理解和维护
劣势:
- 依赖Redis的持久化机制,无论是RDB快照还是AOF日志,都无法完全避免数据丢失
- 如果Redis发生故障恢复,可能会出现ID重复的风险
- 网络开销相比本地生成略高
Snowflake雪花算法
Snowflake是Twitter开源的分布式ID生成算法,它能够在分布式环境下生成全局唯一、趋势递增的64位长整型ID。
算法结构
雪花算法生成的64位ID结构如下:
┌──────────────────────── 64位雪花算法ID结构 ────────────────────────┐
│ 1位 │ 41位 │ 5位 │ 5位 │ 12位 │
├──────────┼──────────────────────────┼─────────┼─────────┼────────────────┤
│ 0 │ 0000...0000 (时间戳) │ 00000 │ 00000 │ 000000000000 │
└──────────┴──────────────────────────┴─────────┴─────────┴────────────────┘
↓ ↓ ↓ ↓ ↓
符号位 时间戳 机器ID 服务ID 序列号
固定为0 精确到毫秒 最多32台 最多32个 每毫秒4096个
ID为正数 可使用约69年
各部分详解:
- 符号位(1 bit):预留的符号位,始终为0,保证生成的ID为正数
- 时间戳(41 bit):精确到毫秒级别,41位时间戳可以使用69年(2^41 / (365 * 24 * 60 * 60 * 1000) ≈ 69)
- 数据中心标识(5 bit):支持最多32个不同的数据中心(2^5 = 32)
- 机器标识(5 bit):每个数据中心内支持最多32台机器(2^5 = 32)
- 序列号(12 bit):同一毫秒内可以生成4096个不同的ID(2^12 = 4096)
唯一性保证机制
雪花算法通过多个维度的组合来保证ID的全局唯一性:
时间戳维度:时间戳位于ID的最高位,保证新生成的ID比旧的ID大。在不同的毫秒内,时间戳肯定不一样,因此ID必然不同。
空间隔离维度:引入数据中心标识和机器标识,这两个标识都是手动配置的,确保不同的数据中心和不同的机器生成的ID在这两个维度上有所区别。
序列号维度:用来解决同一毫秒内多次生成ID的问题。每次生成ID时序列号都会自增,因此同一台机器在同一毫秒内生成的ID,也能通过序列号区分开来。
综合来看:基于时间戳 + 数据中心标识 + 机器标识 + 序列号的组合,就保证了在不同进程中ID的全局唯一性,以及在相同进程中ID的有序性。
代码实现
public class SnowflakeIdGenerator {
// 起始时间戳(2023-01-01 00:00:00)
private final long epoch = 1672531200000L;
// 各部分的位数
private final long workerIdBits = 5L;
private final long datacenterIdBits = 5L;
private final long sequenceBits = 12L;
// 各部分的最大值
private final long maxWorkerId = ~(-1L << workerIdBits);
private final long maxDatacenterId = ~(-1L << datacenterIdBits);
private final long sequenceMask = ~(-1L << sequenceBits);
// 各部分的位移量
private final long workerIdShift = sequenceBits;
private final long datacenterIdShift = sequenceBits + workerIdBits;
private final long timestampShift = sequenceBits + workerIdBits + datacenterIdBits;
private final long workerId;
private final long datacenterId;
private long sequence = 0L;
private long lastTimestamp = -1L;
public SnowflakeIdGenerator(long workerId, long datacenterId) {
if (workerId > maxWorkerId || workerId < 0) {
throw new IllegalArgumentException("Worker ID 超出范围");
}
if (datacenterId > maxDatacenterId || datacenterId < 0) {
throw new IllegalArgumentException("Datacenter ID 超出范围");
}
this.workerId = workerId;
this.datacenterId = datacenterId;
}
/**
* 生成分布式ID
*/
public synchronized long nextId() {
long timestamp = currentTimeMillis();
// 处理时钟回拨
if (timestamp < lastTimestamp) {
throw new RuntimeException(
"时钟回拨异常,拒绝生成ID。回拨时长:" + (lastTimestamp - timestamp) + "ms"
);
}
// 同一毫秒内,序列号自增
if (timestamp == lastTimestamp) {
sequence = (sequence + 1) & sequenceMask;
// 序列号溢出,等待下一毫秒
if (sequence == 0) {
timestamp = waitNextMillis(lastTimestamp);
}
} else {
// 不同毫秒,序列号重置为0
sequence = 0L;
}
lastTimestamp = timestamp;
// 组装64位ID
return ((timestamp - epoch) << timestampShift)
| (datacenterId << datacenterIdShift)
| (workerId << workerIdShift)
| sequence;
}
/**
* 等待下一毫秒
*/
private long waitNextMillis(long lastTimestamp) {
long timestamp = currentTimeMillis();
while (timestamp <= lastTimestamp) {
timestamp = currentTimeMillis();
}
return timestamp;
}
private long currentTimeMillis() {
return System.currentTimeMillis();
}
}
使用示例
@Configuration
public class IdGeneratorConfig {
@Bean
public SnowflakeIdGenerator snowflakeIdGenerator() {
// 从配置中心获取当前机器的workerId和datacenterId
long workerId = getWorkerIdFromConfig();
long datacenterId = getDatacenterIdFromConfig();
return new SnowflakeIdGenerator(workerId, datacenterId);
}
}
@Service
public class TradeService {
@Autowired
private SnowflakeIdGenerator idGenerator;
/**
* 创建交易订单
*/
public String createTradeOrder(TradeRequest request) {
// 生成唯一的订单ID
long orderId = idGenerator.nextId();
TradeOrder order = new TradeOrder();
order.setOrderId(orderId);
order.setAmount(request.getAmount());
order.setUserId(request.getUserId());
tradeRepository.save(order);
return String.valueOf(orderId);
}
}
优势分析
高性能与高可用:完全在内存中生成,不依赖于任何外部服务,单机每秒可生成数百万个ID,且不存在单点故障。
趋势递增:由于时间戳在高位,生成的ID整体呈现递增趋势。在单个进程内,可以保证严格递增,适合作为MySQL的主键使用。
信息丰富:从ID中可以解析出生成时间、机器位置等信息,便于问题排查和数据分析。
局限性分析
机器ID配置复杂:在Snowflake原始实现中,每个节点的机器ID和数据中心ID都需要硬编码在代码中,而且这些ID必须全局唯一。当某个节点故障或需要扩容时,就需要更改配置、重新编译代码、重新部署系统,运维成本较高。如果配置错误,将两个节点设置成相同的机器ID,就会导致ID重复。
依赖时间一致性:雪花算法强依赖于系统时间的准确性和单调性。如果系统时间被回拨(比如NTP时间同步导致时间倒退),可能会造成ID重复。这是雪花算法最严重的问题。
时钟回拨问题详解
什么是时钟回拨
时钟回拨是指生成分布式ID的服务器上的系统时间,由于某种原因,突然相比之前的时间发生倒退。
产生原因
网络时间协议(NTP)同步:这是最常见的原因。操作系统会自动与NTP服务器同步时间,如果本地时间快了,NTP会强制将时间回调。例如,服务器时间是13:00:05,而NTP标准时间是13:00:00,同步时就会发生5秒的时钟回拨。
人工手动修改:运维人员误操作,手动将系统时间改错了。虽然现代运维规范中很少发生,但仍然是一个潜在风险。
闰秒调整:极少数情况下,操作系统处理闰秒时可能采用"倒退一秒"的策略。不过这种情况非常罕见。
危害分析
当前时间的时间戳作为雪花算法中的重要组成部分,一旦时间戳倒退,就意味着单台机器上生成的ID可能会出现不顺序递增的情况。更严重的是可能发生ID重复。
因为对于同一台机器来说,数据中心标识、机器标识都是固定不变的。如果时间戳又出现了相同的情况,那么一旦序列号也恰好一致,就会生成完全相同的ID,导致数据冲突。时钟回拨会使ID重复的概率大大升高。
时钟回拨场景示例
时钟回拨解决方案
方案一:等待法
如果回拨时间非常短(比如几百毫秒左右),我们可以选择等待一小段时间,让系统时间追上来。实现方式就是让当前线程sleep,等待时钟恢复到上次记录的时间之后再继续。
public synchronized long nextId() {
long currentTimestamp = System.currentTimeMillis();
// 检测到时钟回拨
if (currentTimestamp < lastTimestamp) {
long offset = lastTimestamp - currentTimestamp;
// 回拨时间较短,等待恢复
if (offset <= 5) {
try {
Thread.sleep(offset << 1);
currentTimestamp = System.currentTimeMillis();
// 等待后仍未恢复,抛出异常
if (currentTimestamp < lastTimestamp) {
throw new RuntimeException("时钟回拨异常");
}
} catch (InterruptedException e) {
throw new RuntimeException("等待时钟恢复被中断", e);
}
} else {
// 回拨时间过长,直接拒绝
throw new RuntimeException("时钟回拨时间过长: " + offset + "ms");
}
}
// ... 后续ID生成逻辑
}
适用场景:回拨时间在几毫秒到几秒之间,且对系统可用性要求不是特别严格的场景。
优点:实现简单,能够自动恢复。
缺点:会阻塞ID生成请求,影响系统性能;如果回拨时间过长,等待不可接受。
方案二:动态切换机器标识
如果发生时钟回拨,可以通过动态切换机器标识或数据中心标识的方式,来避免生成重复ID。
因为机器ID有5个bit,理论上支持32台机器。我们可以提前预留一些备用的机器ID,当出现时钟回拨时,就切换到备用的机器ID。这样即使时间戳和序列号都相同,由于机器ID变了,整个生成的ID也就不一样了。
public class AdaptiveSnowflakeGenerator {
private long workerId;
private final long baseWorkerId;
private final long maxWorkerId = 31;
public AdaptiveSnowflakeGenerator(long workerId) {
this.baseWorkerId = workerId;
this.workerId = workerId;
}
public synchronized long nextId() {
long currentTimestamp = System.currentTimeMillis();
// 检测到时钟回拨
if (currentTimestamp < lastTimestamp) {
// 切换到备用workerId
workerId = (workerId + 1) % maxWorkerId;
// 如果所有workerId都用过了,抛出异常
if (workerId == baseWorkerId) {
throw new RuntimeException("所有备用Worker ID已耗尽");
}
// 记录切换日志
logger.warn("检测到时钟回拨,切换WorkerId: {} -> {}",
workerId - 1, workerId);
}
// ... 后续ID生成逻辑
}
}
适用场景:机器数量未达到上限,有富余的机器ID可以使用。
优点:不需要等待,能够立即恢复服务。
缺点:机器ID资源有限,多次回拨后可能耗尽;单机生成的ID不再严格递增。
方案三:直接抛异常
这种看似不像方案的方案其实用得也挺多的,比如美团的Leaf、百度的UidGenerator都采用过。
因为时钟回拨并不常见,而且就算发生了,也不代表生成的ID一定会重复。所以重复其实是个低概率事件。而且,就算时钟不回拨,雪花算法在极端情况下也有重复的可能(并发极高,在同一台机器、时间戳相同、序列号也刚好相同的情况)。
因此,遇到时钟回拨的情况,直接阻断流程并告警,让运维人员介入处理,这也是一种务实的方案。至少能避免数据重复导致更大的业务损失。
public synchronized long nextId() {
long currentTimestamp = System.currentTimeMillis();
// 检测到时钟回拨,直接拒绝服务
if (currentTimestamp < lastTimestamp) {
long offset = lastTimestamp - currentTimestamp;
// 记录告警日志
logger.error("严重告警:检测到时钟回拨 {}ms,拒绝生成ID", offset);
// 发送告警通知
alertService.sendAlert("时钟回拨告警",
"服务器: " + serverIp + ", 回拨时长: " + offset + "ms");
// 抛出异常,拒绝生成ID
throw new ClockBackwardException(
"时钟回拨异常,回拨时长: " + offset + "ms"
);
}
// ... 后续ID生成逻辑
}
适用场景:对数据一致性要求极高,宁可服务不可用也不能出现ID重复的场景。
优点:彻底避免因时钟回拨导致的ID重复问题。
缺点:会导致服务短暂不可用,需要人工介入处理。
方案四:提前生成ID备用
在前面方案的基础上,如果时钟回拨的时间比较长,也可以考虑提前生成一批ID,存储在Redis等缓存中。一旦出现时钟回拨,就从缓存中取出之前生成好的ID来使用。
@Service
public class IdBufferService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private SnowflakeIdGenerator snowflakeGenerator;
/**
* 定时任务:预生成ID存入缓存
*/
@Scheduled(fixedRate = 1000)
public void preGenerateIds() {
String bufferKey = "id:buffer:snowflake";
// 保持缓存中有10000个备用ID
Long bufferSize = redisTemplate.opsForList().size(bufferKey);
if (bufferSize < 10000) {
List<String> ids = new ArrayList<>();
for (int i = 0; i < 5000; i++) {
long id = snowflakeGenerator.nextId();
ids.add(String.valueOf(id));
}
redisTemplate.opsForList().rightPushAll(bufferKey, ids);
}
}
/**
* 从缓存获取ID(时钟回拨时使用)
*/
public Long getIdFromBuffer() {
String bufferKey = "id:buffer:snowflake";
String id = redisTemplate.opsForList().leftPop(bufferKey);
if (id == null) {
throw new RuntimeException("备用ID缓存已耗尽");
}
return Long.parseLong(id);
}
}
适用场景:允许短期内ID不严格递增,但必须保证服务持续可用。
优点:即使时钟回拨也能继续提供服务,可用性高。
缺点:增加了系统复杂度;单机生成的ID不再严格递增;依赖Redis等外部存储。
美团Leaf框架详解
Leaf是美团开源的分布式ID生成框架,提供了两种生成模式:Segment(号段模式)和Snowflake(雪花模式)。Leaf在原有方案的基础上进行了大量优化,解决了很多实际生产环境中的痛点问题。
Segment号段模式
Leaf的Segment模式是对传统号段模式的改进和增强。
核心优化:双Buffer机制
传统号段模式存在一个问题:当一个号段用尽时,需要同步等待数据库返回新的号段,这段时间内无法生成ID,会导致服务暂时阻塞。
Leaf引入了双Buffer(双缓冲)机制来解决这个问题:
工作流程:
- 系统同时维护两个Buffer,一个正在使用(Current Buffer),一个处于备用状态(Next Buffer)
- 当Current Buffer的使用率达到某个阈值(比如90%)时,异步触发加载下一个号段到Next Buffer
- 当Current Buffer完全用尽时,立即切换到Next Buffer,原来的Next Buffer变成Current Buffer
- 同时异步加载新的号段作为新的Next Buffer
优势分析
高可用性:即使数据库短时间不可用(比如1-2秒),由于有双Buffer缓存,系统仍然可以继续生成ID,不会影响业务。
性能平滑:消除了号段切换时的等待时间,ID生成性能更加平滑稳定。
不依赖时钟:完全不依赖系统时间,不存在时钟回拨问题。
实现示例
public class LeafSegmentGenerator {
private static class SegmentBuffer {
private final Segment[] segments = new Segment[2];
private volatile int currentIndex = 0;
private volatile boolean nextReady = false;
public Segment getCurrent() {
return segments[currentIndex];
}
public Segment getNext() {
return segments[1 - currentIndex];
}
public void switchBuffer() {
currentIndex = 1 - currentIndex;
}
}
private static class Segment {
private final AtomicLong value = new AtomicLong(0);
private volatile long max;
private volatile int step;
public long getAndIncrement() {
long val = value.getAndIncrement();
if (val > max) {
return -1; // 号段已用尽
}
return val;
}
public boolean isNeedLoadNext() {
// 使用超过90%时需要加载下一个号段
return value.get() >= (max - step * 0.1);
}
}
private final SegmentBuffer buffer = new SegmentBuffer();
/**
* 生成ID
*/
public long nextId(String bizTag) {
while (true) {
Segment current = buffer.getCurrent();
// 检查是否需要异步加载下一个号段
if (current.isNeedLoadNext() && !buffer.nextReady) {
synchronized (this) {
if (!buffer.nextReady) {
loadNextSegment(bizTag);
buffer.nextReady = true;
}
}
}
long id = current.getAndIncrement();
// 当前号段未用尽,直接返回
if (id >= 0) {
return id;
}
// 当前号段已用尽,切换到下一个号段
synchronized (this) {
if (buffer.getCurrent() == current) {
buffer.switchBuffer();
buffer.nextReady = false;
}
}
}
}
/**
* 异步加载下一个号段
*/
private void loadNextSegment(String bizTag) {
// 从数据库获取新号段
// ... 数据库操作
}
}
Snowflake雪花模式
Leaf的Snowflake模式在Twitter原始实现的基础上进行了多项优化。
优化点一:WorkerId自动分配
原始Snowflake需要手动配置每台机器的WorkerId,容易出错且扩容困难。Leaf通过ZooKeeper实现WorkerId的自动分配:
优化点二:时钟回拨处理
Leaf采用ZooKeeper持久化存储每个节点上次生成ID的时间戳。启动时会进行两次时钟校验:
- 本地时间校验:比较当前系统时间与上次生成ID的时间,如果回拨超过阈值(比如5秒),启动失败
- 全局时间校验:从ZooKeeper读取所有节点上报的时间戳,计算平均值,如果本机时间与平均值偏差过大,启动失败
public class LeafSnowflakeGenerator {
@Autowired
private CuratorFramework zkClient;
private final String ZK_TIME_PATH = "/leaf/snowflake/time";
/**
* 启动时检查时钟
*/
public void init() throws Exception {
long lastTimestamp = loadLastTimestamp();
long currentTime = System.currentTimeMillis();
// 检查本地时钟回拨
if (currentTime < lastTimestamp) {
long offset = lastTimestamp - currentTime;
if (offset > 5000) { // 回拨超过5秒
throw new RuntimeException(
"严重的时钟回拨,回拨时长: " + offset + "ms,启动失败"
);
}
}
// 检查与集群时间的偏差
long avgClusterTime = getAvgClusterTime();
long timeOffset = Math.abs(currentTime - avgClusterTime);
if (timeOffset > 10000) { // 偏差超过10秒
throw new RuntimeException(
"本机时间与集群平均时间偏差过大: " + timeOffset + "ms"
);
}
// 定时上报时间戳到ZK
startTimeReportTask();
}
/**
* 获取集群平均时间
*/
private long getAvgClusterTime() throws Exception {
List<String> children = zkClient.getChildren()
.forPath(ZK_TIME_PATH);
long sum = 0;
for (String child : children) {
byte[] data = zkClient.getData()
.forPath(ZK_TIME_PATH + "/" + child);
sum += Long.parseLong(new String(data));
}
return sum / children.size();
}
}
优化点三:号段预分配降低ZK依赖
为了减轻对ZooKeeper的依赖,Leaf引入了号段概念:每次从ZooKeeper获取一段时间范围(比如10秒),在这个时间范围内产生ID,避免了每次生成ID都要访问ZK。
public class LeafSnowflake {
private long timeSegmentStart; // 时间段起始
private long timeSegmentEnd; // 时间段结束
private final long SEGMENT_DURATION = 10000; // 10秒一个时间段
public synchronized long nextId() {
long currentTime = System.currentTimeMillis();
// 当前时间段已用尽,获取新的时间段
if (currentTime >= timeSegmentEnd) {
acquireNewTimeSegment();
}
// ... 正常的雪花算法ID生成逻辑
}
private void acquireNewTimeSegment() {
// 从ZK获取新的时间段授权
timeSegmentStart = System.currentTimeMillis();
timeSegmentEnd = timeSegmentStart + SEGMENT_DURATION;
// 持久化到ZK
saveTimeSegmentToZK(timeSegmentEnd);
}
}
其他分布式ID框架
除了美团Leaf,业界还有其他优秀的分布式ID生成框架:
百度UidGenerator:也是基于Snowflake算法,特点是将时间戳和序列号的位数进行了调整,序列号扩展到了22位,使得单机并发能力大幅提升。同时支持自定义WorkerId生成策略。
滴滴Tinyid:采用号段模式,提供了HTTP和Tinyid-client两种获取方式,支持多DB(号段ID可存储在多个数据库中),使用简单,适合小规模应用快速接入。
方案选型建议
不同的业务场景对分布式ID的要求不同,选择合适的方案至关重要:
选型决策树
具体场景建议
文件系统、日志追踪等场景:使用UUID即可,无需递增,简单可靠。
电商订单号:建议使用号段模式或雪花算法,既保证唯一性又具备递增特性,便于数据库索引优化。同时可以在ID中加入业务前缀,如日期、业务类型等。
高并发秒杀场景:优先选择雪花算法或美团Leaf的Snowflake模式,完全内存生成,性能极高,单机可支持百万级QPS。
金融交易流水号:建议使用成熟的分布式ID框架(如Leaf、UidGenerator),这些框架经过大规模生产验证,稳定性和可靠性更有保障。
多数据中心部署:必须选择支持数据中心标识的方案,如雪花算法,通过数据中心ID实现物理隔离。
通过对不同方案特点的深入理解,结合业务实际需求,选择最适合的分布式ID生成方案,才能在保证系统稳定性的同时,实现最优的性能表现。