
一、为什么我们需要分布式ID?
拆分成微服务、按业务分库分表之后,原来靠MySQL AUTO_INCREMENT 保序的日子一去不复返了。如果不同分片各自生成自增ID,数据合并时就会发生严重冲突。一个优秀的分布式ID生成器必须同时满足:
- 全局唯一:绝对底线,任何两个ID都不能相同
- 高性能:每毫秒能支撑成千上万个ID,延迟微秒级
- 高可用:不能有单点故障,故障恢复后绝不产生重复ID
- 趋势递增:对数据库B+Tree索引友好,减少页分裂
- 信息安全:不轻易暴露业务量(如订单号不能被遍历)
- 64位长整型优先:存储空间小,索引性能最优
接下来,我们深入两大工业级方案,并给出PHP实现。
二、方案横评:为什么我们最终选雪花与号段?
| 方案 | 唯一性 | 递增性 | 性能 | 依赖 | 安全性 | 备注 |
|---|---|---|---|---|---|---|
| UUID v4 | ✅ | ❌ 完全无序 | 极高 | 无 | ✅ | 字符串,36字符,索引灾难 |
| 数据库多主自增 | ✅ | ✅ 严格递增 | 低(DB瓶颈) | DB | ❌ 可推算 | 步长设计,扩容难 |
| Redis INCR | ✅ | ✅ 单调递增 | 高 | Redis | ❌ 可推算 | 持久化可能回退 |
| 雪花算法 | ✅ | ✅ 趋势递增 | 极高(本地生成) | 无(需分配机器ID) | ✅ | 强依赖时钟,需处理回拨 |
| 号段模式 | ✅ | ✅ 严格递增 | 高(内存分发) | DB | ❌ 可推算 | DB宕机可撑一段时间 |
经典方案各有拥趸,但真正经受住大规模生产考验的,主要是雪花算法(Snowflake)和号段模式(Leaf-Segment)。前者凭借纯本地生成的极致性能,后者以绝对递增和零时钟依赖见长。下面我们逐个击破。
三、深度解析雪花算法(Snowflake)及PHP实现
3.1 经典64位结构
0 - 0000000000 0000000000 0000000000 0000000000 0 - 00000 - 00000 - 000000000000
| <----------- 41位毫秒时间戳 -----------> | <10位机器> <12位序列号>- 1位保留,恒为0(保证ID为正数)
- 41位毫秒级时间戳,可用69年
- 10位工作机器ID,支持1024个节点
- 12位序列号,同毫秒内可生成4096个不同ID
理论单节点峰值409.6万/s,完全能满足绝大多数业务。
3.2 PHP实现骨架
在64位PHP环境下,我们可以直接使用整数的位运算来组装ID。下面是一个高度可配置的雪花算法实现,重点处理了时钟回拨。
<?php
class Snowflake
{
// 开始时间戳(2024-01-01 00:00:00),可自定义
const EPOCH = 1704067200000;
// 位数分配(可根据业务调整)
const WORKER_ID_BITS = 10;
const SEQUENCE_BITS = 12;
const MAX_WORKER_ID = (1 << self::WORKER_ID_BITS) - 1; // 1023
const MAX_SEQUENCE = (1 << self::SEQUENCE_BITS) - 1; // 4095
// 偏移量
const WORKER_ID_SHIFT = self::SEQUENCE_BITS;
const TIMESTAMP_SHIFT = self::SEQUENCE_BITS + self::WORKER_ID_BITS;
private int $workerId;
private int $sequence = 0;
private int $lastTimestamp = -1;
private int $maxClockBackwardsMs = 2000; // 最大容忍时钟回拨毫秒数
/**
* @param int $workerId 机器ID(0~1023)
*/
public function __construct(int $workerId)
{
if ($workerId < 0 || $workerId > self::MAX_WORKER_ID) {
throw new \InvalidArgumentException("Worker ID must be between 0 and " . self::MAX_WORKER_ID);
}
$this->workerId = $workerId;
}
/**
* 生成下一个ID
* @return int 64位长整型ID
*/
public function nextId(): int
{
$timestamp = $this->currentTimeMillis();
// 处理时钟回拨
if ($timestamp < $this->lastTimestamp) {
$drift = $this->lastTimestamp - $timestamp;
if ($drift > $this->maxClockBackwardsMs) {
throw new \RuntimeException(
"Clock moved backwards too much. Refusing to generate id for {$drift}ms"
);
}
// 短期回拨:沿用上一次时间戳,继续累加序列号
$timestamp = $this->lastTimestamp;
}
if ($timestamp === $this->lastTimestamp) {
// 同一毫秒内:序列号自增
$this->sequence = ($this->sequence + 1) & self::MAX_SEQUENCE;
if ($this->sequence === 0) {
// 序列号用尽:必须等到下一毫秒
$timestamp = $this->waitNextMillisecond(
$this->lastTimestamp === $timestamp ? $this->lastTimestamp : $timestamp
);
}
} else {
// 时间前进:序列号归零
$this->sequence = 0;
}
$this->lastTimestamp = $timestamp;
return (($timestamp - self::EPOCH) << self::TIMESTAMP_SHIFT)
| ($this->workerId << self::WORKER_ID_SHIFT)
| $this->sequence;
}
/**
* 获取当前毫秒时间戳
*/
private function currentTimeMillis(): int
{
return (int)(microtime(true) * 1000);
}
/**
* 阻塞到下一毫秒
*/
private function waitNextMillisecond(int $lastTimestamp): int
{
$timestamp = $this->currentTimeMillis();
while ($timestamp <= $lastTimestamp) {
$timestamp = $this->currentTimeMillis();
// 可适当加入 usleep 避免 CPU 空转
usleep(100);
}
return $timestamp;
}
/**
* 从ID中解析出时间戳(用于调试)
*/
public static function extractTimestamp(int $id): int
{
return ($id >> self::TIMESTAMP_SHIFT) + self::EPOCH;
}
}3.3 时钟回拨三大防线
上述代码中,我们默认将“时钟回拨”纳入了正常处理:
- 短期回拨(≤2秒):直接沿用
lastTimestamp,并继续递增序列号。这样ID中的时间戳部分不会倒退,整体依然趋势递增,且保证唯一。 - 序列号耗尽保护:即使沿用旧时间戳,序列号也会递增,当同毫秒序列号用完(4096个),
waitNextMillisecond会强制等待真实时间追上,彻底杜绝重复。 - 超长回拨报警:一旦回拨超过阈值(例如2秒),直接抛出异常,触发人工介入,避免产生意料之外的ID。
这套策略使得生成器对NTP轻微调整、虚拟机暂停等具备极强的免疫力。
3.4 机器ID自动分配思路
容器化部署时,workerId 不能写死在配置里。推荐两种方式:
- 数据库注册表:维护一张
worker_node表,启动时通过UPDATE ... SET status=1 WHERE id IN (SELECT min(id) FROM worker_node WHERE status=0)拿到空闲ID,定时心跳续约。 - ZooKeeper/Etcd临时顺序节点:每个实例在
/snowflake/workers/下创建临时顺序节点,序号即workerId,宕机后节点自动消失,ID回收。
在本文示例中,为聚焦核心生成逻辑,我们直接在构造函数中接收 workerId,实际落地可搭配上述自动化方案。
四、另一种可靠选择:号段模式(Segment)及PHP实现
雪花算法虽好,但依然依赖时钟且趋势递增而非绝对递增。如果你的业务必须严格递增(如账务流水),且能接受对数据库的轻量依赖,那么号段模式是更稳妥的选择。
4.1 原理
数据库内维护一张号段表:
CREATE TABLE `id_segment` (
`biz_tag` varchar(64) NOT NULL COMMENT '业务标识',
`max_id` bigint(20) NOT NULL DEFAULT '0' COMMENT '当前已分配的最大ID',
`step` int(11) NOT NULL DEFAULT '2000' COMMENT '每次分配的号段长度',
`update_time` datetime NOT NULL,
PRIMARY KEY (`biz_tag`)
) ENGINE=InnoDB;业务服务内存中持有 [current, max] 号段,直接从内存取用,用完或快用完时,再从DB申请下一个 step 大小的号段。申请时用事务 + 行级锁保证原子性:
UPDATE id_segment SET max_id = max_id + step WHERE biz_tag = 'order';
-- 事务提交后,本次分配范围:(old_max_id + 1) ~ (old_max_id + step)4.2 双Buffer优化
为了避免申请号段时阻塞业务线程,经典的做法是维护双Buffer:当前段消费到一定比例(如90%)时,异步启动另一个线程去拉取下一段,当前段用尽后立刻切换到新段。整个过程业务线程零等待。
4.3 PHP实现示例(简化单Buffer)
我们演示一个最简版本,重点展示原子分配与DB交互。生产环境建议加上双Buffer、失败重试等。
<?php
class SegmentIdGenerator
{
private PDO $pdo;
private string $bizTag;
private int $step;
private int $currentId;
private int $maxId;
private int $bufferThreshold; // 0~1,触发异步加载的阈值
public function __construct(PDO $pdo, string $bizTag, int $step = 2000, float $bufferThreshold = 0.9)
{
$this->pdo = $pdo;
$this->bizTag = $bizTag;
$this->step = $step;
$this->bufferThreshold = $bufferThreshold;
// 初始化时加载第一个号段
$this->loadNextSegment();
}
/**
* 从数据库获取下一个号段
*/
private function loadNextSegment(): void
{
$this->pdo->beginTransaction();
try {
// 行级锁获取当前max_id并更新
$stmt = $this->pdo->prepare("SELECT max_id FROM id_segment WHERE biz_tag = ? FOR UPDATE");
$stmt->execute([$this->bizTag]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
// 如果业务标志不存在,需要先初始化(示例简化,实际应插入并重试)
$this->pdo->prepare("INSERT INTO id_segment (biz_tag, max_id, step, update_time) VALUES (?, 0, ?, NOW())")
->execute([$this->bizTag, $this->step]);
$currentMax = 0;
} else {
$currentMax = (int)$row['max_id'];
}
$newMax = $currentMax + $this->step;
$this->pdo->prepare("UPDATE id_segment SET max_id = ?, update_time = NOW() WHERE biz_tag = ?")
->execute([$newMax, $this->bizTag]);
$this->pdo->commit();
// 设置内存中的号段范围:[currentId, maxId]
$this->currentId = $currentMax + 1;
$this->maxId = $newMax;
} catch (\Exception $e) {
$this->pdo->rollBack();
throw new \RuntimeException("Load segment failed: " . $e->getMessage());
}
}
/**
* 获取一个ID
*/
public function nextId(): int
{
// 如果当前号段已用完,加载下一段
if ($this->currentId > $this->maxId) {
$this->loadNextSegment();
}
$id = $this->currentId;
$this->currentId++;
// 异步预加载:当前段消费超过阈值时,触发后台拉取下一段
$consumed = $this->currentId - ($this->maxId - $this->step + 1);
if ($consumed >= $this->step * $this->bufferThreshold) {
// 实际项目应放入异步任务/协程,这里简化为同步
// 可使用Swoole\Coroutine::create 或 ReactPHP等
}
return $id;
}
}注意:号段模式生成的ID是绝对递增的数字,安全性较弱。如需隐藏业务量,可在应用层对ID做加密混淆(如Hashids),或用
(id * 某个大质数) % 另一个大质数等简单映射,但需保证解密能还原。
五、生产落地最佳实践与选型建议
选型决策树
需要严格递增吗?
├─ 是 → 暴露业务量敏感吗?
│ ├─ 不敏感 → 号段模式(双Buffer + DB)
│ └─ 敏感 → 号段模式 + ID加解密混淆
└─ 否(趋势递增即可) → 对时钟回拨零容忍?
├─ 否 → 雪花算法(PHP实现带容错)
└─ 是 → 号段模式或基于Redis的原子自增通用实践清单
- 监控:ID生成速率、号段消耗速度、时钟偏差(雪花)、DB号段剩余量
- 性能压测:务必用实际机器验证序列号位是否充裕;PHP常驻进程(如Swoole/Workerman)下雪花算法性能更高,传统FPM模式下需注意生成器实例复用
- 高可用:雪花算法多节点部署,每个节点唯一
workerId;号段模式可对DB做读写分离,异常时本地号段仍能支撑 - 位数规划:预估业务生命周期,确保时间戳位不会耗尽,同时为未来扩容预留机器ID位
- ID类型:建议数据库字段统一使用
BIGINT,索引效率最高
六、总结
本文以PHP代码为载体,深入剖析了分布式ID生成的两大核心方案:
- 雪花算法:用
41位毫秒时间戳+10位机器+12位序列的本地运算,达到百万级QPS,并通过“时间戳沿用+序列号递增”机制巧妙化解时钟回拨,是目前最通用的高性能ID方案。 - 号段模式:借DB一行记录批量预分配号段,内存分发,绝对递增,无时钟依赖,适合对递增性要求严苛的场景。
两种方案并非二选一的死局,实践中常根据业务线混合使用:订单、用户等核心表用号段确保绝对递增;流水、日志等用雪花算法追求极致性能。同时,你完全可以在号段模式之上封装一层雪花算法风格的编码,将单调数字转为更安全的ID样式。
所有的设计与代码都已准备就绪,你可以直接复制上文的PHP类,配置好机器ID或数据库连接,立刻获得一个生产级的分布式ID生成器。技术的深度决定了系统的稳健度,希望这篇分享能为你的架构设计提供坚实底座。
评论(0)