分布式ID自增策略(二)

分布式id发号器实现

项目源码:hllypi/code-hub: technical solution code

上面有提到发号器的是基于本地+数据库实现,再开始之前我们需要建张表来维护这个id值

  1. 数据库配置

创建数据库:

sql 复制代码
CREATE DATABASE common CHARACTER set utf8mb3 COLLATE=utf8_bin;

设计发号器表结构:

sql 复制代码
CREATE TABLE `t_id_generate_config` (
  `id` int NOT NULL AUTO_INCREMENT COMMENT '主键id',
  `remark` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注',
  `next_threshold` bigint DEFAULT NULL COMMENT '当前id所在阶段的阈值',
  `init_num` bigint DEFAULT NULL COMMENT '初始化值',
  `current_start` bigint DEFAULT NULL COMMENT '当前id所在阶段的开始值',
  `step` int DEFAULT NULL COMMENT 'id递增区间',
  `is_seq` tinyint DEFAULT NULL COMMENT '是否有序(0无序,1有序)',
  `id_prefix` varchar(60) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'id前缀',
  `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号',
  `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

持久化对象:

arduino 复制代码
@TableName(value ="t_id_generate_config")
@Data
public class IdGeneratePO implements Serializable {

    /**
     * 主键id
     */
    @TableId(type = IdType.AUTO)
    private Integer id;

    /**
     * 备注
     */
    private String remark;

    /**
     * 当前id所在阶段的阈值
     */
    private Long next_threshold;

    /**
     * 初始化值
     */
    private Long init_num;

    /**
     * 当前id所在阶段的开始值
     */
    private Long current_start;

    /**
     * id递增区间
     */
    private Integer step;

    /**
     * 是否有序(0无序,1有序)
     */
    private Integer is_seq;

    /**
     * id前缀
     */
    private String id_prefix;

    /**
     * 乐观锁版本号
     */
    private Integer version;

    /**
     * 创建时间
     */
    private Date create_time;

    /**
     * 更新时间
     */
    private Date update_time;

    @TableField(exist = false)
    private static final long serialVersionUID = 1L;
}

项目的初始化以及依赖可以自行实现,都是一些比较简单的操作,下面我将按照平时的开发习惯,一步一步的去完成发号器的底层业务代码;

首先,我们需要定义一个分布式id的服务,服务里面主要提供两个功能,一个是提供有序的id,另外一个提供无序的id

LocalSeqIdBO业务对象:

kotlin 复制代码
**
 * 功能:
 * 作者:lc
 * 日期:2025/12/2 12:37
 */
@Data
public class LocalSeqIdBO {

    private Integer id;

    private AtomicLong currentNum;


}

BO中包含内存中当前这个id值所所记录的该id段的当前值currentNum以及对应的业务id;

TIdGenerateConfigService服务接口:

java 复制代码
/**
 * @author 罗超
 * @description 针对表【t_id_generate_config】的数据库操作Service
 * @createDate 2025-12-02 12:30:47
 */
public interface TIdGenerateConfigService extends IService<IdGeneratePO> {

       Long getSeqId(Integer seqId);

       Long getUnSeqId(Integer seqId);

}

TIdGenerateConfigService服务实现类:

java 复制代码
/**
 * @author 罗超
 * @description 针对表【t_id_generate_config】的数据库操作Service实现
 * @createDate 2025-12-02 12:30:47
 */
@Service
@Slf4j
public class TIdGenerateConfigServiceImpl extends ServiceImpl<TIdGenerateConfigMapper, IdGeneratePO>
        implements TIdGenerateConfigService, InitializingBean {


    private static Map<Integer, LocalSeqIdBO> localSeqIdBOMap=new ConcurrentHashMap<>();




    @Override
    public Long getSeqId(Integer id) {
        if(id==null){
            log.error("id:{} is null.", id);
            return null;
        }

        LocalSeqIdBO localSeqIdBO = localSeqIdBOMap.get(id);
        if(localSeqIdBO==  null){
            log.error("localSeqIdBO:{} is null.", id);
            return null;
        }
        long resultId = localSeqIdBO.getCurrentNum().getAndIncrement();
        return resultId;
    }

    public Long getUnSeqId(Integer seqId) {
        return null;
    }

    @Override
    public void afterPropertiesSet() throws Exception {

        for(IdGeneratePO idGeneratePO: baseMapper.selectList( null)){
            LocalSeqIdBO localSeqIdBO = new LocalSeqIdBO();
            AtomicLong atomicLong = new AtomicLong(idGeneratePO.getCurrent_start());
            localSeqIdBO.setCurrentNum(atomicLong);
            localSeqIdBO.setSeqId(idGeneratePO.getId());
            localSeqIdBOMap.put(idGeneratePO.getId(), localSeqIdBO);
        }

    }
}

id所对应的是一种分布式id生成策略,比如用户服务生成id,订单生成服务id,消息生成服务id。而这个策略在前面的结构有提到是缓存在本地内存的,这里我们采用Map<Integer, LocalSeqIdBO> localSeqIdBOMap来进行缓存,我们就可以从map中通过id取出对应的对象,来获取有序id;

到了这里,大家可能会有一个小小的疑惑,你Map只是实例化了,但是没有数据呀。别慌,我们这里是在TIdGenerateConfigServiceImpl类初始化时来给我们的map进行初始化。这里我们可以通过InitializingBean这个回调接口来做一下初始化。然后我们只需要去数据库中做一次表的查询,将数据塞入到map中即可;

bean初始化会对调到这里

scss 复制代码
 @Override
    public void afterPropertiesSet() throws Exception {

        for(IdGeneratePO idGeneratePO: baseMapper.selectList( null)){
            LocalSeqIdBO localSeqIdBO = new LocalSeqIdBO();
            AtomicLong atomicLong = new AtomicLong(idGeneratePO.getCurrent_start());
            localSeqIdBO.setCurrentNum(atomicLong);
            localSeqIdBO.setSeqId(idGeneratePO.getId());
            localSeqIdBOMap.put(idGeneratePO.getId(), localSeqIdBO);
        }

在上面的有一个小细节,currentNum的数据类型我没有使用long这里样的类型,而是使用的是原子类AtomicLong,这是因为在当前的这个场景中是存在并发并且需要对currentNum+1的这个操作,使用long是不合理的。

上面就是我们的分布式id发号器的一个非常简单的实现了,那么上面的设计有没有什么问题呢?很明显,我们每次在缓存的只是一个seq步长的区间段,如果我们将区间中的id都发完了怎么办?其次我们在分布式下可能会部署多台机器,那么就会出现将相同的id重复发放,出现id碰撞。

这里我们为了保证id段不会出现重复的情况,我们在进行数据库与本地内存同步的,采用乐观锁的方式来实现来

这里的乐观锁的性能相对于使用redis的分布式锁,可能性能会低一点,但是考虑到出现竞争的情况是比较小的,所以采用这种方式实现。

乐观锁的实现:

ini 复制代码
UPDATE t_id_generate_config 
SET 
    next_threshold = next_threshold + step,
    current_start = current_start + step,
    version = version + 1 
WHERE id = ? AND version = ?

持久层:

less 复制代码
 @Update(""" 
            UPDATE t_id_generate_config 
            SET next_threshold = next_threshold + step,current_start = current_start + step,version = version + 1 
            WHERE id = ? AND version = ? 
            """)
    int updateIdCountAndVersion(@Param("id") int id, @Param("version") int version);

基于mybatis-plus的实现,自行实现都行。

现在我们需要修改TIdGenerateConfigService中的回调方法,由于这块的逻辑比较长我们简单封装一下,需要注意的是在重试时,需要重新去查一次数据库的idGeneratePO

ini 复制代码
/**
     * 同步本地数据前,告诉数据库抢占成功,防止重复抢占
     *
     * @param idGeneratePO
     */
    public void tryUpdateMysqlRecord(IdGeneratePO idGeneratePO) {

        int result = idGenerateMapper.updateIdCountAndVersion(idGeneratePO.getId(), idGeneratePO.getVersion());
        // 未占用,同步本地内存
        if (result > 0) {
            LocalSeqIdBO localSeqIdBO = new LocalSeqIdBO();
            AtomicLong atomicLong = new AtomicLong(idGeneratePO.getCurrentStart());
            localSeqIdBO.setCurrentNum(atomicLong);
            localSeqIdBO.setId(idGeneratePO.getId());
            localSeqIdBO.setCurrentStart(idGeneratePO.getCurrentStart());
            localSeqIdBO.setNextThreshold(idGeneratePO.getNextThreshold());
            localSeqIdBOMap.put(idGeneratePO.getId(), localSeqIdBO);
            return;
        }

        // 重试3次,失败抛出异常
        for (int i = 0; i < 3; i++) {
            idGeneratePO = baseMapper.selectById(idGeneratePO.getId());
            result = idGenerateMapper.updateIdCountAndVersion(idGeneratePO.getId(), idGeneratePO.getVersion());
            // 抢占成功,更新返回
            if (result > 0) {
                LocalSeqIdBO localSeqIdBO = new LocalSeqIdBO();
                AtomicLong atomicLong = new AtomicLong(idGeneratePO.getCurrentStart());
                localSeqIdBO.setCurrentNum(atomicLong);
                localSeqIdBO.setId(idGeneratePO.getId());
                localSeqIdBO.setCurrentStart(idGeneratePO.getCurrentStart());
                localSeqIdBO.setNextThreshold(idGeneratePO.getNextThreshold());
                localSeqIdBOMap.put(idGeneratePO.getId(), localSeqIdBO);
                return;
            }
        }
        throw new RuntimeException("抢占id段 fail,id is " + idGeneratePO.getId());
    }

现在呢我们即使部署多个实例,在多个应用启动的时候会告诉mysql要占用这个id段,也是不会出现id段重复的情况了,接着上游在调用我们的getSeqId()时就可以在我们的本地map中通过id来匹配到对应的LocalSeqIdBO对象,拿到currentNum,从而拿到id,来保证我们的上游拿到的值是唯一和有序的

上面的实现还有没有问题呢?

同样很明显,我们的id段是有限,用完以后怎么呢?这时我们需要重新去数据库中抢占id段。

也就是会重复前面实现的本地map的同步,这个过程包括重新获取当前PO,然后进行更新,然后更新失败会有一个3次的重试,而这个过程实际上是有多次的网络io,针对于高并发的情况下,这是会出现阻塞的情况。但凡下游的出现点阻塞就很容易出现上游的奔溃,甚至导致雪崩

我能想到的解决方案呢,是提前去获取新的id段,而不是在id段分发完以后再去获取新的i段,为了不阻塞正常的的id分发,通过异步的方式方式去实现;

异步更新本地id段:

java 复制代码
 // 更新阈值比例
    private static final float UPDATE_RATE = 0.75f;

    // 创建线程池
    private static final ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(8, 16,
            3, TimeUnit.SECONDS, new ArrayBlockingQueue<>(100), r -> {
        Thread thread = new Thread(r);
        thread.setName("id-generate-thread-" + ThreadLocalRandom.current().nextInt(1000));
        return thread;
    });


  @Override
    public Long getSeqId(Integer id) {
        if (id == null) {
            log.error("id:{} is null.", id);
            return null;
        }

        LocalSeqIdBO localSeqIdBO = localSeqIdBOMap.get(id);
        if (localSeqIdBO == null) {
            log.error("localSeqIdBO:{} is null.", id);
            return null;
        }

        // 异步获取id段
        this.refreshLocalSeqId(localSeqIdBO);

        long resultId = localSeqIdBO.getCurrentNum().getAndIncrement();
        return resultId;
    }


    /**
     * 当id分发超过75%,异步更新id段
     *
     * @param localSeqIdBO
     */
    private void refreshLocalSeqId(LocalSeqIdBO localSeqIdBO) {
        long seq = (localSeqIdBO.getNextThreshold() - localSeqIdBO.getCurrentStart());
        // 判断是否需要重新抢占
        if (localSeqIdBO.getCurrentNum().getAndIncrement() - localSeqIdBO.getCurrentStart() < seq * UPDATE_RATE) {
            threadPoolExecutor.execute(() -> {
                IdGeneratePO idGeneratePO = idGenerateMapper.selectById(localSeqIdBO.getId());
                tryUpdateMysqlRecord(idGeneratePO);
            });
        }
    }

上面的代码就完成了异步任务,但是我们这里会有一个bug,当我们的发号器的数量超过75%,就会出发异步请求,现在的代码会导致,系统在发剩下的25%时,如果更新没有那么及时,就会多次触发异步任务,导致重复执行异步任务,而且我们只希望执行一次。这里我们就可以使用分布式锁或者信号量来实现。但是建议选择信号量更简单,不用多引入依赖多维护一个组件,并且限流场景也更适合使用信号量

定义semaphoreMap集合和修改数据初始化:

java 复制代码
 // 更新限流
    private static Map<Integer, Semaphore> semaphoreMap = new HashMap<>();

    @Override
    public void afterPropertiesSet() throws Exception {

        for (IdGeneratePO idGeneratePO : baseMapper.selectList(null)) {
            this.tryUpdateMysqlRecord(idGeneratePO);
            // 针对不同策略,初始化不同限流工具
            semaphoreMap.put(idGeneratePO.getId(), new Semaphore(1));
        }
    }

修改refreshLocalSeqId方法:

ini 复制代码
    /**
     * 当id分发超过75%,异步更新id段
     *
     * @param localSeqIdBO
     */
    private void refreshLocalSeqId(LocalSeqIdBO localSeqIdBO) {
        long seq = (localSeqIdBO.getNextThreshold() - localSeqIdBO.getCurrentStart());
        // 判断是否需要重新抢占
        if (localSeqIdBO.getCurrentNum().getAndIncrement() - localSeqIdBO.getCurrentStart() < seq * UPDATE_RATE) {
            Semaphore semaphore = semaphoreMap.get(localSeqIdBO.getId());
            if (semaphore == null) {
                log.error("semaphore:{} is null.", localSeqIdBO.getId());
                return;
            }
            boolean tryAcquireStatus = semaphore.tryAcquire();
            if (!tryAcquireStatus) {
                threadPoolExecutor.execute(() -> {
                    try {
                        IdGeneratePO idGeneratePO = idGenerateMapper.selectById(localSeqIdBO.getId());
                        tryUpdateMysqlRecord(idGeneratePO);
                    } finally {
                        semaphore.release();
                    }
                });
            }

        }
    }

为了避免线程阻塞导致重复请求数据库和性能问题,我们选择使用tryAcquire(),并且我们需要在抢占完成以后将semaphore关闭,不然会会在下次更新时出现无法获得许可情况。

上面虽已经提前刷新,但不排除极端情况下,出现id发放超出进行校验一下,对于快速失败还是同步刷新,请自己思考一下。

kotlin 复制代码
// 提供保护机制(思考这里是快速失败还是同步刷新)
        if (localSeqIdBO.getCurrentNum().get() > localSeqIdBO.getNextThreshold()) {
            log.error("localSeqIdBO:{} is full.id generation failed", id);
            return null;
        }

测试大家可以自己打上日志,然后在本地跑跑,上面的代码有几处小问题。

虽然前面我们通过异步方式去防止阻塞线程,通过阈值提前去获取刷新id段,去尽可能的提高性能,去解决高并发的场景,但仅仅做好这些是完全不够的,因为决定我们发号的能力的主要是step的大小,他决定了我们机器能有多大的qps,步长越大这块性能越大,但不是越大越好的。所以要分析好系统的实际情况,合理设置步长

这里简单说说,正常来说一台机器能抗500qps并发我觉得是已经是极限了

500并发--->一台机器

正常系统0.几ms就能完成一次发号,假设1ms这里------》500次请求------》500*1ms=500ms

假设极端情况假如------》更新和查询2s,所以需要满足2s内没有发完id段,所以需要留1000id在内存中

25%-》1000

100%-》4000

上面已经完成了分布式id的有序id的代码实现

无序id的生成与有序id的逻辑基本相同,只需要注意一下细节即可。

创建业务类LocalUnSeqIdBO:

java 复制代码
@Data
public class LocalUnSeqIdBO {

    // id
    private int id;

    // 缓存当前值
    private ConcurrentLinkedQueue<Long> idQueue;

    // 下一次阈值
    private long nextThreshold;

    // 当前开始值
    private long currentStart;

}

我们这里用ConcurrentLinkedQueue来存储无序id;

修改本地缓存初始化代码:

ini 复制代码
    /**
     * 处理缓存本地id段
     *
     * @param idGeneratePO
     */
    private void localIdBOHandler(IdGeneratePO idGeneratePO) {
        if (idGeneratePO.getIsSeq() == ORDER_SEQ) {
            LocalSeqIdBO localSeqIdBO = new LocalSeqIdBO();
            AtomicLong atomicLong = new AtomicLong(idGeneratePO.getCurrentStart());
            localSeqIdBO.setCurrentNum(atomicLong);
            localSeqIdBO.setId(idGeneratePO.getId());
            localSeqIdBO.setCurrentStart(idGeneratePO.getCurrentStart());
            localSeqIdBO.setNextThreshold(idGeneratePO.getNextThreshold());
            localSeqIdBOMap.put(idGeneratePO.getId(), localSeqIdBO);
            log.info("抢占成功:{}__{}", idGeneratePO.getCurrentStart(), idGeneratePO.getNextThreshold());
        } else {
            LocalUnSeqIdBO localUnSeqIdBO = new LocalUnSeqIdBO();
            localUnSeqIdBO.setId(idGeneratePO.getId());
            localUnSeqIdBO.setCurrentStart(idGeneratePO.getCurrentStart());
            localUnSeqIdBO.setNextThreshold(idGeneratePO.getNextThreshold());
            // 缓存无序id到队列
            long start = idGeneratePO.getCurrentStart();
            long end = idGeneratePO.getNextThreshold();
            ArrayList<Long> idsList = new ArrayList<>();
            for (; start < end; start++) {
                idsList.add(start);
            }
            // 打乱数组元素数据
            Collections.shuffle(idsList);
            ConcurrentLinkedQueue<Long> newQueue = new ConcurrentLinkedQueue<>(idsList);
            localUnSeqIdBO.setIdQueue(newQueue);
            localUnSeqIdBOMap.put(idGeneratePO.getId(), localUnSeqIdBO);
        }

    }

我们将代码封装一下,添加了一个判断来区分是否是有序的,对于无序的我们先将其存入一个缓存数组中,然后打乱后加入队列

刷新id段与有序id生成方式的刷新几乎一样

ini 复制代码
 /**
     * 当id分发超过75%,异步更新id段
     *
     * @param localUnSeqIdBO
     */
    private void refreshLocalUnSeqId(LocalUnSeqIdBO localUnSeqIdBO) {
        long start = localUnSeqIdBO.getCurrentStart();
        long end = localUnSeqIdBO.getNextThreshold();
        int size = localUnSeqIdBO.getIdQueue().size();
        if(size >= (end - start) * UPDATE_RATE){
            Semaphore semaphore = semaphoreMap.get(localUnSeqIdBO.getId());
            if (semaphore == null) {
                log.error("semaphore:{} is null.", localUnSeqIdBO.getId());
                return;
            }
            boolean tryAcquireStatus = semaphore.tryAcquire();// tryAcquire不论是否成功都只请求一次,避免重复请求和阻塞

            if (tryAcquireStatus) {
                threadPoolExecutor.execute(() -> {
                    try {
                        IdGeneratePO idGeneratePO = idGenerateMapper.selectById(localUnSeqIdBO.getId());
                        tryUpdateMysqlRecord(idGeneratePO);
                    } catch (Exception e){
                        log.error("更新id段失败",e);
                    } finally {
                        semaphore.release();
                    }
                });
            }
        }
    }

对外暴露方法:

kotlin 复制代码
 @Override
    public Long getUnSeqId(Integer id) {
        if (id == null) {
            log.error("[getSeqId] id is error,id is {}", id);
            return null;
        }
        LocalUnSeqIdBO localUnSeqIdBO = localUnSeqIdBOMap.get(id);
        if (localUnSeqIdBO == null) {
            log.error("[getUnSeqId] localUnSeqIdBO is null,id is {}", id);
            return null;
        }
        Long returnId = localUnSeqIdBO.getIdQueue().poll();
        if (returnId == null) {
            log.error("[getUnSeqId] returnId is null,id is {}", id);
            return null;
        }
        this.refreshLocalUnSeqId(localUnSeqIdBO);
        return returnId;
    }

上面的代码实现也有一处小小问题

如果没有发现问题,可以fork项目进行对比,项目源码 hllypi/code-hub: technical solution code,制作不易,欢迎star关注~

相关推荐
ZePingPingZe1 小时前
Spring boot2.x-第05讲番外篇:常用端点说明
java·spring boot·后端
陌上倾城落蝶雨1 小时前
django基础命令
后端·python·django
侠客在xingkeit家top1 小时前
SpringCloudAlibaba高并发仿斗鱼直播平台实战(完结)
后端
Han.miracle1 小时前
Maven 基础与 Spring Boot 入门:环境搭建、项目开发及常见问题排查
java·spring boot·后端
Gopher_HBo1 小时前
Go语言数据结构和算法(二十四)基数排序算法
后端
麻辣烫不加辣1 小时前
跑批调额系统说明文档
java·后端
速易达网络1 小时前
ASP.NET MVC 前后端商城系统介绍
后端·asp.net·mvc
inrgihc1 小时前
Spring Boot 注册 Servlet 的五种方法
spring boot·后端·servlet
ldmd2841 小时前
Go语言实战:入门篇-6:锁、测试、反射和低级编程
开发语言·后端·golang