解决K8s Pod机器ID重复问题
一、雪花算法介绍
雪花算法(Snowflake)是 Twitter 于 2010 年开源的一种分布式全局唯一 ID 生成算法,核心思想是将一个 64 位整数拆分为多个字段,通过时间戳 + 机器标识 + 序列号的组合来保证 ID 的全局唯一性。
1.1 64 位 ID 的结构
整个 ID 由 4 个部分组成,像拼图一样拼接在一起:
| 部分 | 位数 | 作用 |
|---|---|---|
| 符号位 | 1 位 | 固定为 0,保证 ID 是正数 |
| 时间戳 | 41 位 | 记录当前时间(毫秒级),可使用约 69 年 |
| 机器ID | 10 位 | 区分不同节点(5位数据中心ID + 5位工作机器ID),最多支持 1024 个节点 |
| 序列号 | 12 位 | 同一毫秒内自增,每毫秒最多生成 4096 个 ID |
1.2 生成流程
每次生成 ID 时,算法按以下步骤执行:
- 获取当前时间戳(毫秒级),减去一个自定义的起始时间(Epoch),得到相对时间差
- 判断是否同一毫秒:
- 如果是同一毫秒 → 序列号 +1;若序列号达到 4095(用尽),则阻塞等待下一毫秒
- 如果是新的毫秒 → 序列号重置为 0
- 时钟回拨检测:如果当前时间 < 上次生成时间,说明时钟被回拨了,直接抛异常拒绝生成
- 位运算拼接:通过左移(
<<)和按位或(|)将各部分拼成一个 64 位整数
核心拼接逻辑(伪代码):
ID = (时间戳差值 << 22) | (数据中心ID << 17) | (机器ID << 12) | 序列号
1.3 为什么能保证全局唯一?
- 不同毫秒 → 时间戳不同,ID 自然不同
- 同一毫秒、不同机器 → 机器 ID 不同,ID 不同
- 同一毫秒、同一机器 → 序列号自增,ID 不同
三重保障,确保任何情况下都不会产生重复 ID。
1.4 核心优势
- 高性能:纯内存位运算,不依赖数据库或网络通信,单机每秒可生成数百万个 ID
- 趋势递增:ID 按时间大致递增,对数据库 B+ 树索引友好,插入性能好
- 无中心依赖:每个节点独立生成,没有单点故障问题
- 可解析:ID 中嵌入了时间戳信息,可以直接反推出 ID 的生成时间
1.5 已知问题与应对
时钟回拨
服务器时间被 NTP 同步或手动修改导致时钟"倒退",可能产生重复 ID。常见应对方案:
- 短时间回拨 → 等待时钟追上
- 长时间回拨 → 切换到备用 workerId 或备用生成服务
- 严格禁止手动修改服务器时间
机器 ID 分配
容器化部署时 workerId 容易冲突。通常通过 ZooKeeper、Consul 等注册中心动态分配,或基于 K8s 的 downward API 自动注入。
前端精度丢失
JavaScript 的 Number 最大安全整数为 2^53 - 1,而雪花 ID 是 64 位,前端接收时可能丢失精度。解决办法是后端将 ID 序列化为字符串返回。
1.6 与其他方案对比
| 方案 | 优点 | 缺点 |
|---|---|---|
| 雪花算法 | 高性能、趋势递增、无依赖 | 依赖时钟、机器ID管理复杂 |
| UUID | 无依赖、生成简单 | 无序、索引性能差 |
| 数据库自增ID | 实现简单 | 单点故障、性能瓶颈 |
| 号段模式(Leaf) | 不依赖时钟、索引友好 | 依赖数据库 |
二、K8s 中遇到的问题
2.1 问题现象
在 K8s 环境下使用雪花算法时,发现生成过相同的 ID,最终排查发现是机器 ID(machineId)相同导致的。
2.2 根因分析
问题出在雪花算法的 workerId 分配机制上。原有实现中,getMachineId() 方法存在多个严重 Bug:
Bug 1(致命):Redis Key 未区分 Pod 实例
String machineKey = applicationName + ":machineKey:" + dataCenterId;
这个 key 只由 应用名 + dataCenterId 组成,所有同应用的 Pod 共享同一个 key。当多个 Pod 同时启动时,它们对同一个 key 执行 incr,看似能拿到不同值,但问题在于——
Bug 2(致命):machineId 超过 31 后直接重置为 0
this.machineId = redisUtil.incr(machineKey, 1);
if (machineId > 31) {
machineId = 0L;
redisUtil.set(machineKey, 1); // 把计数器也重置了!
}
这里有两个问题:
- 取值范围错误:
SnowflakeIdGenerator中 machineId 占 5 位,合法范围是 0~31。但incr返回的值可能已经超过 31,此时直接重置为 0,意味着多个 Pod 会拿到相同的 machineId = 0。 - 重置计数器:
redisUtil.set(machineKey, 1)把 Redis 计数器也重置了,后续启动的 Pod 又会从 1 开始分配,与之前的 Pod 产生冲突。
举例:假设已有 31 个 Pod 在用 machineId 1~31,第 32 个 Pod 启动时拿到 32,触发重置,machineId 变成 0,Redis 计数器也变成 1。第 33 个 Pod 启动拿到 2——但 machineId=2 可能早就被某个还在运行的 Pod 占用了。冲突必然发生。
Bug 3(致命):Pod 重启后未释放 machineId
Pod 被 K8s 调度重启后,Redis 中的计数器不会回退,但旧的 machineId 也没有被释放。随着 Pod 不断重启,计数器持续递增,很快就会触发 Bug 2 的重置逻辑,导致新一轮冲突。
Bug 4(逻辑错误):条件判断无效
Object value = redisUtil.get(machineKey);
if (value instanceof String && "1".equals(value)) {
redisUtil.del(machineKey);
redisUtil.set(machineKey, 1);
}
先 del 再 set(1),等价于直接 set(1),没有任何实际意义。而且这段逻辑只在值为 "1" 时触发,条件过于狭窄,基本不会命中。
Bug 5(设计缺陷):构造函数中 new 了自身但没用返回值
SnowflakeIdGenerator(long dataCenterId, long machineId) {
long currentTimestamp = this.getCurrentTimestamp();
startTimestamp = currentTimestamp;
new SnowflakeIdGenerator(currentTimestamp, dataCenterId, machineId); // 创建了对象但丢弃了
}
这里 new SnowflakeIdGenerator(...) 创建了一个新实例但没有赋值给任何变量,相当于白调了一次。
2.3 问题总结
| 问题 | 原因 | 影响 |
|---|---|---|
| Redis key 不区分实例 | 所有 Pod 共用一个计数器 | 不同 Pod 可能拿到相同 ID |
| machineId > 31 重置为 0 | 5 位只能存 0~31,溢出后粗暴重置 | 必然产生重复 |
| 无租约/释放机制 | Pod 重启后旧 ID 不释放 | 计数器持续膨胀,加速冲突 |
三、使用 Redis 租约的解决方案
3.1 "带租约"概念解释
"带租约"的核心思想类比租房:
- 签合同(获取锁):签了一年的租房合同,拿到钥匙,这套房子就是你的了
- 合同有期限(TTL):合同写明了租期,到期后如果不续租,房东可以把房子租给别人
- 续约(心跳续约):你还在住,每隔几个月就跟房东续签一次,延长租期
- 退租(释放锁):搬走时把钥匙还给房东,房子就空出来了
"租约"就是那份有期限的合同。它保证了:
- 你住的时候,别人不能进来(排他性)
- 你突然消失(进程崩溃),合同到期后房子会自动空出来(不会永久占用)
3.2 Redis 租约工作原理
在场景中,"租约"通过 Redis 的 SETNX + EX(过期时间)实现:
Redis 收到命令:SET occupyKey "Pod标识" NX EX 3600
| 部分 | 含义 |
|---|---|
SET key value |
往 Redis 写一个键值对 |
NX |
Only set if Not eXists——只有这个 key 不存在时才写入,保证原子性 |
EX 3600 |
设置 3600 秒(1小时)后自动过期删除,这就是"租约期限" |
执行结果:
- Pod-A 成功写入 → 抢到了 machineId=5,"租约"生效,1小时内这个 ID 归 Pod-A 独占
- Pod-B 也尝试抢 machineId=5 → 发现 key 已存在,
NX条件不满足,写入失败 → 去尝试 machineId=6
3.3 为什么需要"租约"而不是永久占用?
不用租约(没有 EX 过期时间):
Pod-A 启动 → 抢到 machineId=5 → 写入 Redis(永久)
Pod-A 被 K8s 强杀(kill -9)→ 没来得及删除 Redis 中的 key
→ machineId=5 永远被"死掉的 Pod"占着
→ 新启动的 Pod 永远拿不到 5
→ 32 个 ID 很快被耗尽 → 冲突
有了租约之后:
Pod-A 启动 → 抢到 machineId=5 → 写入 Redis(1小时后自动过期)
Pod-A 正常运行 → 每 20 分钟续约一次(延长过期时间)
Pod-A 被强杀 → 没人续约 → 1小时后 key 自动消失 → machineId=5 被释放
→ 新 Pod 可以重新抢到 machineId=5
3.4 完整生命周期
Pod 启动
│
├─ getMachineId() → SETNX 抢占 machineId=5(1小时过期)
│
├─ 每 20 分钟 → expire key 3600(续约)
│
├─ 正常运行 10 小时 → 续约 30 次,key 始终有效
│
└─ 正常关闭 → @PreDestroy → DEL key(主动释放)
Pod 崩溃(kill -9)
│
└─ 续约线程停止 → 没人续约
→ 1小时后 key 自动过期 → machineId=5 被释放
四、修复后的完整代码
4.1 DmsIdGenerator.java
package com.longfor.dms.starter.common.id;
import com.longfor.gaia.gfs.core.exception.LFBizException;
import com.longfor.dms.starter.common.util.RedisUtil;
import jakarta.annotation.PreDestroy;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.net.InetAddress;
import java.util.Objects;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
@Component
@Slf4j
public class DmsIdGenerator implements InitializingBean {
private Long machineId = null;
@Value("${spring.application.name}")
private String applicationName;
@Value("${dms.service.id.generate.dataCenter:2}")
private long dataCenterId;
private SnowflakeIdGenerator snowflakeIdGenerator;
@Resource
private RedisUtil redisUtil;
/**
* 续约线程池(daemon 线程,不阻止 JVM 退出)
*/
private ScheduledExecutorService renewExecutor;
/**
* 租约时长(秒)
*/
private static final long LEASE_SECONDS = 3600L;
/**
* 续约间隔(秒),建议取租约时长的 1/3
*/
private static final long RENEW_INTERVAL_SECONDS = 1200L;
@Override
public void afterPropertiesSet() {
// 1. 抢占一个唯一的 machineId
getMachineId();
// 2. 初始化雪花算法生成器
snowflakeIdGenerator = new SnowflakeIdGenerator(1483200000000L, dataCenterId, machineId);
// 3. 启动后台续约线程,定期刷新 Redis 中 machineId 的过期时间
startRenewTask();
log.info("{} 服务启动完成, dataCenterId={}, machineId={}", applicationName, dataCenterId, machineId);
}
/**
* 获取分布式单个 ID
*/
public Long nextUniqueId() {
return snowflakeIdGenerator.nextId();
}
public String nextStrUniqueId() {
return snowflakeIdGenerator.nextId().toString();
}
/**
* 获取分布式多个 ID
*/
public Long[] nextUniqueIds(int count) {
return snowflakeIdGenerator.nextIds(count);
}
public String[] nextStrUniqueIds(int count) {
Long[] ids = nextUniqueIds(count);
String[] strIds = new String[ids.length];
for (int i = 0; i < ids.length; i++) {
strIds[i] = ids[i].toString();
}
return strIds;
}
/**
* 启动续约线程
* 每隔 RENEW_INTERVAL_SECONDS 秒刷新一次 Redis 中 machineId 的过期时间,
* 防止 Pod 正常运行期间 machineId 被意外释放。
*/
private void startRenewTask() {
renewExecutor = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "machineId-renew-" + applicationName);
t.setDaemon(true); // 设为守护线程,JVM 退出时不会阻塞
return t;
});
renewExecutor.scheduleAtFixedRate(() -> {
try {
if (machineId == null) {
return;
}
String occupyKey = buildOccupyKey(machineId);
boolean refreshed = redisUtil.expire(occupyKey, LEASE_SECONDS);
if (refreshed) {
log.debug("{} machineId={} 续约成功, 租约延长至 {} 秒",
applicationName, machineId, LEASE_SECONDS);
} else {
// 续约失败,说明 key 已不存在(可能被其他服务误删或 Redis 异常)
// 极端情况下需要告警,但不应直接抛异常影响业务
log.warn("{} machineId={} 续约失败, key 可能已失效: {}",
applicationName, machineId, occupyKey);
}
} catch (Exception e) {
log.error("{} machineId={} 续约异常", applicationName, machineId, e);
}
}, RENEW_INTERVAL_SECONDS, RENEW_INTERVAL_SECONDS, TimeUnit.SECONDS);
}
/**
* 获取机器 ID
* 使用 Redis SETNX + 租约机制,确保每个 Pod 拿到全局唯一且不会冲突的 machineId。
*/
private long getMachineId() {
if (Objects.nonNull(machineId)) {
return machineId;
}
synchronized (this) {
if (Objects.nonNull(machineId)) {
return machineId;
}
String podIdentifier = getPodIdentifier();
log.info("{} 尝试分配 machineId, podIdentifier={}", applicationName, podIdentifier);
// 从 0~31 中找一个空闲的 machineId
for (long id = 0; id <= 31; id++) {
String occupyKey = buildOccupyKey(id);
// SETNX + 租约:只有 key 不存在时才写入,并设置过期时间
boolean acquired = redisUtil.setIfAbsent(occupyKey, podIdentifier, LEASE_SECONDS);
if (acquired) {
this.machineId = id;
log.info("{} 服务启动, 成功分配 machineId={}, podIdentifier={}",
applicationName, machineId, podIdentifier);
return machineId;
}
}
// 0~31 全部被占用,说明集群规模超过了雪花算法 5 位 machineId 的上限
throw new LFBizException(
String.format("无法分配 machineId,所有 0~31 的 ID 均已被占用。" +
"当前 dataCenterId=%d, applicationName=%s。" +
"请检查是否存在大量僵尸 Pod 占用 ID,或考虑扩展 machineId 位数。",
dataCenterId, applicationName)
);
}
}
/**
* 构建 Redis 中 machineId 的占用 key
*/
private String buildOccupyKey(long machineId) {
return applicationName + ":machineIdPool:" + dataCenterId + ":" + machineId;
}
/**
* 获取当前 Pod 的唯一标识
* 优先使用 K8s 注入的 POD_NAME 环境变量,降级使用 IP + 进程 ID。
*/
private String getPodIdentifier() {
// 优先方案:K8s Downward API 注入的 Pod 名称
String podName = System.getenv("POD_NAME");
if (podName != null && !podName.isEmpty()) {
return podName;
}
// 降级方案 1:IP + 进程 ID
try {
String ip = InetAddress.getLocalHost().getHostAddress();
long pid = ProcessHandle.current().pid();
return ip + ":" + pid;
} catch (Exception e) {
log.warn("获取 IP 失败,使用 UUID 作为 Pod 标识", e);
}
// 降级方案 2:UUID(兜底,不推荐,因为重启后 UUID 会变)
return java.util.UUID.randomUUID().toString();
}
/**
* 服务关闭时主动释放 machineId,并停止续约线程
*/
@PreDestroy
public void destroy() {
// 1. 停止续约线程
if (renewExecutor != null) {
renewExecutor.shutdown();
try {
if (!renewExecutor.awaitTermination(3, TimeUnit.SECONDS)) {
renewExecutor.shutdownNow();
}
} catch (InterruptedException e) {
renewExecutor.shutdownNow();
Thread.currentThread().interrupt();
}
}
// 2. 主动释放 machineId
releaseMachineId();
}
/**
* 释放当前 Pod 占用的 machineId
*/
private void releaseMachineId() {
if (machineId == null) {
return;
}
try {
String occupyKey = buildOccupyKey(machineId);
String currentValue = (String) redisUtil.get(occupyKey);
String podIdentifier = getPodIdentifier();
// 只有当前 Pod 自己占用的 key 才删除,防止误删其他 Pod 的 key
if (podIdentifier.equals(currentValue)) {
redisUtil.del(occupyKey);
log.info("{} 服务关闭, 成功释放 machineId={}", applicationName, machineId);
} else {
log.warn("{} 服务关闭, machineId={} 的占用者({})与当前 Pod({})不一致, 跳过释放",
applicationName, machineId, currentValue, podIdentifier);
}
} catch (Exception e) {
log.error("{} 释放 machineId={} 异常", applicationName, machineId, e);
}
}
}
4.2 SnowflakeIdGenerator.java
package com.longfor.dms.starter.common.id;
import com.longfor.gaia.gfs.core.exception.LFBizException;
public class SnowflakeIdGenerator {
private long dataCenterId;
private long machineId;
private long sequence = 0L;
private long startTimestamp;
private long lastTimestamp = -1L;
SnowflakeIdGenerator(long dataCenterId, long machineId) {
long currentTimestamp = this.getCurrentTimestamp();
startTimestamp = currentTimestamp;
new SnowflakeIdGenerator(currentTimestamp, dataCenterId, machineId);
}
SnowflakeIdGenerator(long startTimestamp, long dataCenterId, long machineId) {
long currentTimestamp = this.getCurrentTimestamp();
if (startTimestamp > currentTimestamp) {
throw new LFBizException(String.format("Start timestamp can't be greater than %d", 31L));
} else if (dataCenterId <= 31L && dataCenterId >= 0L) {
if (machineId <= 31L && machineId >= 0L) {
this.startTimestamp = startTimestamp == currentTimestamp ? startTimestamp - 1L : startTimestamp;
this.dataCenterId = dataCenterId;
this.machineId = machineId;
} else {
throw new LFBizException(String.format("Machine id can't be greater than %d or less than 0", 31L));
}
} else {
throw new LFBizException(String.format("Data center id can't be greater than %d or less than 0", 31L));
}
}
Long[] nextIds(int count) {
if (count > 0 && count <= 100000) {
Long[] ids = new Long[count];
for (int i = 0; i < count; ++i) {
ids[i] = this.nextId();
}
return ids;
} else {
throw new LFBizException(String.format("Count can't be greater than %d or less than 0", 100000));
}
}
synchronized Long nextId() {
long currentTimestamp = this.getCurrentTimestamp();
if (currentTimestamp < this.lastTimestamp) {
throw new RuntimeException(String.format("Clock moved backwards. Refusing to generate id for %d milliseconds", this.lastTimestamp - currentTimestamp));
} else {
if (this.lastTimestamp == currentTimestamp) {
this.sequence = this.sequence + 1L & 4095L;
if (this.sequence == 0L) {
currentTimestamp = this.getNextTimestamp(this.lastTimestamp);
}
} else {
this.sequence = 0L;
}
this.lastTimestamp = currentTimestamp;
return currentTimestamp - this.startTimestamp << 22 | this.dataCenterId << 17 | this.machineId << 12 | this.sequence;
}
}
private long getNextTimestamp(long lastTimestamp) {
long currentTimestamp;
for (currentTimestamp = this.getCurrentTimestamp(); currentTimestamp <= lastTimestamp; currentTimestamp = this.getCurrentTimestamp()) {
}
return currentTimestamp;
}
private long getCurrentTimestamp() {
return System.currentTimeMillis();
}
}
五、使用前的 K8s 配置
在 Deployment YAML 中注入 POD_NAME 环境变量(这样 getPodIdentifier() 才能拿到稳定的 Pod 名称):
apiVersion: apps/v1
kind: Deployment
metadata:
name: dms-service
spec:
template:
spec:
containers:
- name: dms-service
image: your-image:tag
env:
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: SPRING_APPLICATION_NAME
value: dms-service
- name: DMS_SERVICE_ID_GENERATE_DATACENTER
value: "2"
六、关键参数说明
| 参数 | 默认值 | 说明 |
|---|---|---|
LEASE_SECONDS |
3600(1小时) | 租约时长。Pod 正常运行时靠续约保持,异常退出后 1 小时自动释放 |
RENEW_INTERVAL_SECONDS |
1200(20分钟) | 续约间隔。取租约的 1/3,即使连续 2 次续约失败,仍有 40 分钟缓冲 |
| machineId 范围 | 0~31 | 5 位二进制,最多支持 32 个 Pod 同时运行 |
续约时间配置原则
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 租约期限(EX) | 3600 秒(1小时) | 足够长,避免频繁续约 |
| 续约间隔 | 20 分钟 | 租约的 1/3,留足缓冲 |
| 安全余量 | 40 分钟 | 即使续约失败,还有 40 分钟才过期 |
核心原则:续约间隔 < 租约期限 / 2,确保即使某次续约失败,也有足够时间重试。
七、注意事项
-
Pod 数量上限:5 位 machineId 最多支持 32 个实例。如果单个服务副本数可能超过 32,需要扩展 machineId 位数(改为 6 位或更多),同时调整
SnowflakeIdGenerator中的位移逻辑。 -
Redis 可用性:如果 Redis 不可用,服务启动时会抛异常。建议确保 Redis 集群高可用,或在
getMachineId()中增加重试机制。 -
时钟回拨:
SnowflakeIdGenerator中已有时钟回拨检测(currentTimestamp < lastTimestamp时抛异常),生产环境建议开启 NTP 时间同步。 -
优雅关闭:
@PreDestroy中实现了主动释放 machineId 的逻辑,确保服务正常下线时不会浪费 ID 资源。但如果 Pod 被kill -9强杀,则依赖租约过期自动释放。 -
Pod 标识降级策略:
getPodIdentifier()优先使用 K8s 注入的POD_NAME环境变量,降级使用 IP + 进程 ID,最后兜底使用 UUID。建议始终配置POD_NAME以获得最稳定的标识。 -
租约续约谈错处理:续约失败时仅记录 warn 日志,不抛异常,避免影响正常业务。但建议配合监控告警,及时发现 Redis 异常。
八、总结
| 问题 | 原因 | 影响 |
|---|---|---|
| Redis key 不区分实例 | 所有 Pod 共用一个计数器 | 不同 Pod 可能拿到相同 ID |
| machineId > 31 重置为 0 | 5 位只能存 0~31,溢出后粗暴重置 | 必然产生重复 |
| 无租约/释放机制 | Pod 重启后旧 ID 不释放 | 计数器持续膨胀,加速冲突 |
根因:多个 Pod 共用同一个 Redis 计数器,溢出后又被重置为 0,导致不同 Pod 拿到了相同的 machineId。
修复方案核心:用 Redis 的 SETNX(互斥)+ EX(过期时间)来分配 machineId,抢到的人独占使用,定期续约保活,崩溃后自动释放。同时配合 K8s Downward API 注入 Pod 名称作为唯一标识,确保每个 Pod 拿到全局唯一且不会冲突的 machineId。

浙公网安备 33010602011771号