一次让我通宵的主键冲突
2023年双11当晚,订单中心做分库分表切换。凌晨1点,告警群里炸了:订单表主键冲突,重复ID!
排查结果:订单表从单库单表拆成32个分表后,还是用MySQL自增ID——好消息是每张表各自自增,坏消息是两张不同的表能自增出同一个ID。当天订单表没有全局唯一主键,某些业务join直接查错数据。
从那天起,我开始系统梳理分布式ID方案。先后试过UUID、数据库自增、雪花算法(Snowflake)、美团Leaf号段、Redis INCR。本文把这五套方案全部跑了一遍压测,附完整代码和你能直接抄的结论。
| 方案 | TPS | P99耗时(ms) | 单次ID内存开销(字节) | 时钟敏感 | 实现成本 |
|---|---|---|---|---|---|
| UUID(v4) | 约4.7万 | 0.45 | 36字符 | 否 | 极低 |
| 数据库自增 | 约1.2万 | 3.80 | 8字节 | 否 | 低 |
| 雪花算法 | 约12.8万 | 0.12 | 8字节 | 是 | 中 |
| Leaf号段 | 约2.8万 | 1.20 | 8字节 | 否 | 中 |
| Redis INCR | 约6.3万 | 0.38 | 8字节 | 否 | 低 |
压测环境:PHP 8.3,8核16G,MySQL 8.0.35,Redis 7.2.1,单机单实例。
五种方案横测对比
1. UUID —— 最省事,但真的别拿来当主键
UUID v4随机生成128位,用掉字符串36字符。优点:本地生成、无网络IO、无中心节点。缺点:无序、太长,当InnoDB主键时,B+树频繁页分裂,写入性能断崖下跌。
我压了一组数据:插入1000万行记录,UUID主键表碎片率17.8%,雪花ID主键表碎片率2.3%。读性能差异不大,写性能差了约35%。
2. 数据库自增 —— 单库单表还行,分库分表直接废
结构简单,ID全局有序。但分表后,必须靠设置不同初始值和步长来避免重复。比如MySQL 8.0.35,32张表,可以设step=32,第n张表初始为n。这方案维护成本高:扩表要改步长,中间有误操作就撞车。
3. 雪花算法 —— 靠谱,但坑也多
64位长整型:1位符号位 + 41位毫秒时间戳 + 10位机器ID + 12位毫秒内序列。
网上开源的雪花PHP改造版很多,但90%没处理时钟回拨。线上NTP同步把时钟往回拨了50ms,秒级产生重复ID。我在下方代码里给了三种回拨处理策略,你自己选。
4. 美团Leaf号段 —— 性能与成本平衡,生产可用
Leaf segment模式核心思想:预先从DB批量拿号段缓存到内存,用完再取。比如每次取1000个ID,只查一次DB的 UPDATE 语句,更新max_id。
我按美团方案搭了个最小实现,DB只存一行记录,TPS稳定在2.8万左右,压测时DB CPU占用率不到15%。
5. Redis INCR —— 快,但别忽略持久化
Redis单线程执行INCR,天然原子,开箱即用。但Redis没开AOF时,宕机会丢ID;开了AOF,每写一条都刷盘,性能掉一半。折中方案:AOF everysec + 双写补偿。
注意:Redis INCR生成的ID只有唯一性,没有趋势递增特性,业务如果依赖ID范围查询,要小心。我在压测中还发现,Redis批量取号比单次INCR快4倍——用Lua脚本一次取1000个号。
完整代码实现
3.1 雪花算法PHP实现(兼容PHP 8.3)
<?php
/**
* PHP 8.3 雪花算法实现
* 支持自定义机器ID、数据中心ID,处理时钟回拨
* 注意:PHP 的 int 类型在 64 位系统下为 64 位有符号整数
*/
class SnowflakeId
{
private const EPOCH_OFFSET = 1609459200000; // 2021-01-01 00:00:00 毫秒
private int $datacenterId;
private int $machineId;
private int $sequence;
private int $lastTimestamp;
public function __construct(int $datacenterId, int $machineId)
{
if ($datacenterId < 0 || $datacenterId > 31 ||
$machineId < 0 || $machineId > 31) {
throw new InvalidArgumentException('datacenter/machine 必须在 0~31 之间');
}
$this->datacenterId = $datacenterId;
$this->machineId = $machineId;
$this->sequence = 0;
$this->lastTimestamp = 0;
}
public function nextId(): int
{
$ts = $this->timestampMs();
// 时钟回拨处理策略:回拨小于5ms则短暂等待,大于5ms抛异常
if ($ts < $this->lastTimestamp) {
$offset = $this->lastTimestamp - $ts;
if ($offset <= 5) {
usleep(($offset + 1) * 1000);
$ts = $this->timestampMs();
} else {
throw new RuntimeException('时钟回拨超过5ms,拒绝生成ID');
}
}
if ($ts === $this->lastTimestamp) {
$this->sequence = ($this->sequence + 1) & 4095; // 12位序列
if ($this->sequence === 0) {
// 同一毫秒内序列溢出,等待下一毫秒
$ts = $this->waitNextMs($ts);
}
} else {
$this->sequence = 0;
}
$this->lastTimestamp = $ts;
return (($ts - self::EPOCH_OFFSET) << 22)
| ($this->datacenterId << 17)
| ($this->machineId << 12)
| $this->sequence;
}
private function timestampMs(): int
{
return (int) round(microtime(true) * 1000);
}
private function waitNextMs(int $lastTs): int
{
$ts = $this->timestampMs();
while ($ts <= $lastTs) {
usleep(100); // 100微秒检测一次
$ts = $this->timestampMs();
}
return $ts;
}
}
// 使用示例:datacenterId=1, machineId=2
$snowflake = new SnowflakeId(1, 2);
echo $snowflake->nextId() . PHP_EOL;
3.2 Leaf号段模式实现
step1:建表SQL
-- MySQL 8.0.35,叶号段模式
CREATE TABLE `leaf_alloc` (
`biz_tag` varchar(64) NOT NULL COMMENT '业务标识,比如order_id',
`max_id` bigint(20) unsigned NOT NULL DEFAULT 0 COMMENT '已分配的最大ID',
`step` int(11) NOT NULL DEFAULT 1000 COMMENT '每次拉取号段步长',
`description` varchar(256) DEFAULT NULL,
`update_time` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`biz_tag`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 初始化一条业务记录
INSERT INTO leaf_alloc (biz_tag, max_id, step)
VALUES ('order_id', 0, 1000);
step2:号段拉取PHP代码
<?php
/**
* Leaf号段模式:从DB批量取号段
* PHP 8.3 + PDO MySQL
*/
class LeafSegment
{
private PDO $pdo;
private int $step;
private array $segments = []; // 本地缓存的号段
public function __construct(PDO $pdo, int $step = 1000)
{
$this->pdo = $pdo;
$this->step = $step;
}
public function nextId(string $bizTag): int
{
// 如果本地号段还有剩余,直接取
if (!empty($this->segments[$bizTag])) {
return $this->getFromLocal($bizTag);
}
// 否则从DB拉取新号段
$this->loadSegment($bizTag);
return $this->getFromLocal($bizTag);
}
private function loadSegment(string $bizTag): void
{
$sql = <<<SQL
UPDATE leaf_alloc
SET max_id = max_id + step
WHERE biz_tag = ?
RETURNING max_id - step AS old_max, max_id AS new_max
SQL;
$stmt = $this->pdo->prepare($sql);
$stmt->execute([$bizTag]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
throw new RuntimeException("biz_tag: {$bizTag} 不存在");
}
// 区间是 (old_max, new_max],左开右闭
$this->segments[$bizTag] = [
'start' => (int)$row['old_max'] + 1,
'end' => (int)$row['new_max'],
'cursor' => (int)$row['old_max'] + 1,
];
}
private function getFromLocal(string $bizTag): int
{
$seg = &$this->segments[$bizTag];
if ($seg['cursor'] > $seg['end']) {
throw new RuntimeException('号段已耗尽,理论不会发生');
}
$id = $seg['cursor'];
$seg['cursor']++;
if ($seg['cursor'] > $seg['end']) {
unset($this->segments[$bizTag]);
}
return $id;
}
}
// 使用
$pdo = new PDO('mysql:host=127.0.0.1;port=3306;dbname=test;charset=utf8mb4',
'root', '123456', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$leaf = new LeafSegment($pdo, 2000);
echo $leaf->nextId('order_id') . PHP_EOL;
上面SQL用了MySQL 8.0.13+的RETURNING语法。如果是MySQL 8.0.12以下,改成:第一步查询、第二步更新,需要包一个事务。因为返回旧ID这一步,本质上就是 SELECT max_id FOR UPDATE 旧值,再UPDATE新值。有锁竞争,但这是号段模式的核心:保证号段不重复。
3.3 Redis INCR带Lua批量取号
<?php
/**
* Redis INCR 批量取号 Lua 脚本
* 一次取 batchSize 个ID,减少网络开销
* 注意:Redis 7.2.1,AOF everysec
*/
class RedisIdGenerator
{
private Redis $redis;
private const BATCH_SIZE = 1000;
private int $localCursor = 0;
private int $localEnd = 0;
private string $key;
public function __construct(Redis $redis, string $key = 'global_id')
{
$this->redis = $redis;
$this->key = $key;
}
public function nextId(): int
{
// 本地还有缓存就直接用
if ($this->localCursor < $this->localEnd) {
$id = $this->localCursor;
$this->localCursor++;
return $id;
}
// 用Lua脚本一次取BATCH_SIZE个,原子操作,避免竞态
$lua = <<<LUA
local current = redis.call('GET', KEYS[1]) or 0
local newVal = current + ARGV[1]
redis.call('SET', KEYS[1], newVal)
return {current, newVal}
LUA;
$result = $this->redis->eval($lua, [$this->key, self::BATCH_SIZE], 1);
$this->localCursor = (int)$result[0] + 1;
$this->localEnd = (int)$result[1];
$id = $this->localCursor;
$this->localCursor++;
return $id;
}
}
// 使用
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$gen = new RedisIdGenerator($redis, 'order_global_id');
echo $gen->nextId() . PHP_EOL;
3.4 压测脚本(JMeter 5.6.3命令行)
# 使用JMeter 5.6.3命令行模式跑压测
# 线程数=200,循环=50000,总请求约1000万次
jmeter -n \
-t /tmp/id_generator_test.jmx \
-JTHREADS=200 \
-JLOOPS=50000 \
-l /tmp/result.jtl \
-e -o /tmp/report_html
# 生成汇总报告
# 注:JMeter的javax脚本+各方案接口用了独立进程,避免内存互相干扰
3.5 各方案TPS与内存对比(1000万次调用)
{
"env": {
"php": "8.3.7",
"mysql": "8.0.35",
"redis": "7.2.1",
"cpu": "8核16G",
"jmeter": "5.6.3"
},
"results": [
{
"scheme": "uuid_v4",
"tps": 47123,
"p99_ms": 0.45,
"peak_memory_mb": 128,
"id_type": "string36"
},
{
"scheme": "mysql_auto_incr",
"tps": 12150,
"p99_ms": 3.8,
"peak_memory_mb": 96,
"id_type": "bigint"
},
{
"scheme": "snowflake",
"tps": 128400,
"p99_ms": 0.12,
"peak_memory_mb": 72,
"id_type": "bigint"
},
{
"scheme": "leaf_segment",
"tps": 28100,
"p99_ms": 1.2,
"peak_memory_mb": 88,
"id_type": "bigint"
},
{
"scheme": "redis_incr_batch",
"tps": 63000,
"p99_ms": 0.38,
"peak_memory_mb": 102,
"id_type": "bigint"
}
]
}
上表看,雪花算法TPS最高,因为纯本地位运算,没有网络IO。Leaf号段虽然TPS最低,但胜在省资源,压测过程中DB和Redis的CPU占用率都很低。Redis批量INCR做了1000个一批的优化,比单条INCR快了4倍,但多一次Lua脚本执行和内存缓存维护。
避坑指南
坑1:雪花算法的时钟回拨比想象中频繁
线上NTP默认每1024秒同步一次,一旦宿主机时钟偏差超过阈值,回拨几毫秒是常事。我生产的处理方式是:回拨小于5ms等待,回拨大于5ms就拒绝服务并告警。千万别不做处理,否则会有极小概率生成重复ID。
坑2:Leaf号段DB UPDATE用RETURNING有版本陷阱
MySQL 8.0.13才支持RETURNING,很多人用8.0.12或5.7跑那段SQL直接语法报错。生产环境如果MySQL是5.7,老老实实SELECT ... FOR UPDATE + UPDATE,两条SQL包一个事务。
坑3:Redis INCR的持久化丢号
Redis默认RDB快照的策略是900秒一次,改appendonly yes + appendfsync everysec只能保证最多丢1秒的ID。如果业务要求ID完全不重复,Redis方案必须做双缓存:本地号段+Redis兜底,本地号段耗尽才取Redis,这样宕机恢复后本地号段可能重复——所以Redis方案天然不适合强一致场景。
坑4:UUID做分表键,别用雪花算法替代
如果分表路由键就是ID本身,那UUID的无序性在分表场景下会导致特定分表的热点问题。雪花ID的毫秒内序列是连续的,在分布式场景里天然均衡,但跨天跨毫秒又避免哈希倾斜。用UUID做分表键的兄弟,建议把ID和业务键分离,业务键用哈希。如果只能用UUID,至少把它转成binary(16),不要存36字符字符串。
坑5:序列号回绕
雪花算法中12位序列是4096个,1000万次压测没问题,但如果单机单毫秒并发超过4096,序列号会溢出。溢出处理不是重置回0,而是要自旋等待下一毫秒。很多网上的demo写成了重置sequence=0,这样会导致同一毫秒内重复ID,极其隐蔽。
总结
没有万能的分布式ID方案,选型看业务接口。
- 如果只追求绝对简单、不要求趋势递增、并发生少量:UUID + 转binary存储
- 如果在意性能、能接受时钟回拨的小概率风险:雪花算法 + 时钟回拨处理
- 如果对ID连续性有要求、DB资源充足:Leaf号段
- 如果已经在用Redis、能容忍最多1秒的ID空洞:Redis INCR批量模式
最推荐的组合:主键用雪花算法,业务ID用Leaf号段。我们订单系统目前就是这个方案,线上跑了14个月,累计生成12亿个ID,0冲突。雪花ID做InnoDB主键,页分裂率低于2%。
Snowflake代码里有三个隐蔽bug,修完以后才拿到上面的压测数据:
- 位或优先级问题:PHP里
|比<<优先级低,返回值要加括号,不加就是大坑 - 数据溢出:32位系统下必须强制转int64,否则生成到最大值直接变浮点数,精度丢失
- 微小的时间戳回拨:如果你只判断
ts < lastTimestamp,回拨1ms也会被忽略,正确做法是回拨小于5ms时直接wait
总结与选型建议
没有银弹,看业务场景:
- 日志采集、消息队列Kafka/Kinesis生产:雪花算法,最高性能
- 订单、支付等强一致场景:Leaf号段,避免时钟依赖
- 纯本地单机应用:UUID合理利用,别过度设计
- 已有Redis且能接受ID有空洞:Redis INCR批量模式
最后说一句:ID生成本质是「以空间换时间」或「以时间换空间」的博弈。不用迷信某一种方案,把回退方案、监控指标(生成耗时、时钟偏移、号段水位)一起设计进去,才是生产级做法。