解决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 时,算法按以下步骤执行:

  1. 获取当前时间戳(毫秒级),减去一个自定义的起始时间(Epoch),得到相对时间差
  2. 判断是否同一毫秒
    • 如果是同一毫秒 → 序列号 +1;若序列号达到 4095(用尽),则阻塞等待下一毫秒
    • 如果是新的毫秒 → 序列号重置为 0
  3. 时钟回拨检测:如果当前时间 < 上次生成时间,说明时钟被回拨了,直接抛异常拒绝生成
  4. 位运算拼接:通过左移(<<)和按位或(|)将各部分拼成一个 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);
}

delset(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 "带租约"概念解释

"带租约"的核心思想类比租房:

  1. 签合同(获取锁):签了一年的租房合同,拿到钥匙,这套房子就是你的了
  2. 合同有期限(TTL):合同写明了租期,到期后如果不续租,房东可以把房子租给别人
  3. 续约(心跳续约):你还在住,每隔几个月就跟房东续签一次,延长租期
  4. 退租(释放锁):搬走时把钥匙还给房东,房子就空出来了

"租约"就是那份有期限的合同。它保证了:

  • 你住的时候,别人不能进来(排他性)
  • 你突然消失(进程崩溃),合同到期后房子会自动空出来(不会永久占用)

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,确保即使某次续约失败,也有足够时间重试。


七、注意事项

  1. Pod 数量上限:5 位 machineId 最多支持 32 个实例。如果单个服务副本数可能超过 32,需要扩展 machineId 位数(改为 6 位或更多),同时调整 SnowflakeIdGenerator 中的位移逻辑。

  2. Redis 可用性:如果 Redis 不可用,服务启动时会抛异常。建议确保 Redis 集群高可用,或在 getMachineId() 中增加重试机制。

  3. 时钟回拨SnowflakeIdGenerator 中已有时钟回拨检测(currentTimestamp < lastTimestamp 时抛异常),生产环境建议开启 NTP 时间同步。

  4. 优雅关闭@PreDestroy 中实现了主动释放 machineId 的逻辑,确保服务正常下线时不会浪费 ID 资源。但如果 Pod 被 kill -9 强杀,则依赖租约过期自动释放。

  5. Pod 标识降级策略getPodIdentifier() 优先使用 K8s 注入的 POD_NAME 环境变量,降级使用 IP + 进程 ID,最后兜底使用 UUID。建议始终配置 POD_NAME 以获得最稳定的标识。

  6. 租约续约谈错处理:续约失败时仅记录 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。

posted @ 2026-09-16 16:20  r1-12king  阅读(10)  评论(0)    收藏  举报