从单机到高并发:手搓唯一编号的生成方案

关注我的公众号:【编程朝花夕拾】,可获取首发内容。

01 引言

你可曾遇到订单编号的生成、分布式ID的生成等业务业务功能。成熟的轮子如:UUID、雪花算法、美团Leaf、百度UidGenerator、滴滴Tinyid等。但是如果想要加入自己项目的业务参数,可能就没有那么通用了。

我们就需要设计属于自己唯一编码的生成规则。我们将分别从单机到高并发环境下拆解不同的思路。

02 设计思路

唯一编码设计的核心是平衡唯一性、有序性、性能、可用性与存储效率。主要的分为三种:

  • 中心化强一致:借助数据库/Redis原子发号,保证严格递增,并通过号段预取缓解性能瓶颈。
  • 去中心化组合式(如雪花算法):节点用"时间戳+机器ID+序列号"本地拼装,高性能且趋势递增,但依赖时钟。
  • 概率型 (如UUID):随机生成,无协调开销,但无序且存储较大。

设计本质是在"无协调本地生成"与"有协调中心发号"间权衡,用位分配或号段预取打破性能与唯一性的僵局。

03 单机设计方案

大多数公司的架构都是分布式架构,单机设计方案可能用的比较少,但是单机设计方案是基础。其他复杂的设计方案都是在其基础上演变而来。

3.1 设计思想

不依赖任何外部中间件,完全在 JVM 进程内完成发号。通过时间戳位移保证宏观有序,通过PID 与随机数混合区分进程,通过 AtomicLong 保证线程安全。

3.2 代码案例

java 复制代码
public final class UniqueIdUtils {

    private static final AtomicLong SEQ = new AtomicLong(0);

    // 时间戳左移 20 位,腾出低位给自增与随机部分
    private static final long TIME_SHIFT = 20L;

    // 进程指纹:PID ^ 20 位随机数
    private static final long PROCESS_SEED;

    static {
        // 获取进程(如:53032@shsx-dell-0001)
        String name = ManagementFactory.getRuntimeMXBean().getName();
        // 获取PID
        long pid = name.contains("@") ? Long.parseLong(name.split("@")[0]) : 0L;
        // 2^20 = 1,048,576, 获取[0, 1,048,576)随机数
        long rnd = ThreadLocalRandom.current().nextLong(1L << 20);
        // 混合特征保留低20位
        PROCESS_SEED = (pid ^ rnd) & ((1L << 20) - 1);
    }

    private UniqueIdUtils() {}

    /**
     * 生成唯一序列号(Long 型)
     */
    public static long nextId() {
        long timePart = System.currentTimeMillis() << TIME_SHIFT;
        long seq = SEQ.incrementAndGet() & ((1L << 20) - 1);
        return timePart | PROCESS_SEED | seq;
    }

    /**
     * 生成唯一序列号(String 型,便于日志与接口传输)
     */
    public static String nextIdStr() {
        return Long.toUnsignedString(nextId());
    }

    public static void main(String[] args) {
        System.out.println(nextIdStr());
        System.out.println(nextIdStr());
    }
}

当然还有更简单的,世界使用毫秒值再加上随机数。

3.3 优缺点

  • 优点:自然毋庸置疑,零外部依赖;代码极简;纯内存操作性能极高;宏观时间有序。
  • 缺点:多机同 PID 场景下理论上存在冲突;重启后无状态,无法追溯历史;分布式系统自然无法使用

04 分布式高并发方案

4.1 设计思想

采用中心化强一致的设计思路,利用 Redis单线程执行模型 ,通过 INCR / INCRBY 实现全局原子递增。为降低 Redis 访问频率,引入号段模式:每次从 Redis 批量申请一段号(如 1000 个),缓存在本地内存中,后续直接在本地原子分配。

4.2 代码案例

java 复制代码
public class RedisSegmentSequence {

    private final StringRedisTemplate redisTemplate;
    private final String redisKey;
    private final long step;

    // 本地号段缓存
    private volatile long localMax = -1;
    private final AtomicLong localSeq = new AtomicLong(-1);

    public RedisSegmentSequence(StringRedisTemplate redisTemplate,
                                String redisKey,
                                long step) {
        this.redisTemplate = redisTemplate;
        this.redisKey = redisKey;
        this.step = step;
    }

    /**
     * 获取下一个唯一 ID
     */
    public synchronized long nextId() {
        long current = localSeq.incrementAndGet();
        if (current > localMax) {
            // 本地号段耗尽,重新向 Redis 申请
            fetchNextSegment();
            current = localSeq.incrementAndGet();
        }
        return current;
    }

    private void fetchNextSegment() {
        // Redis 原子自增 step,返回的是新的最大值
        Long max = redisTemplate.opsForValue().increment(redisKey, step);
        if (max == null) {
            throw new IllegalStateException("Redis increment failed");
        }
        this.localMax = max;
        // 本地从 max - step 开始分配
        this.localSeq.set(max - step);
    }
}

Spring Bean 配置示例:

java 复制代码
@Configuration
public class SequenceConfig {

    @Bean
    public RedisSegmentSequence orderSequence(StringRedisTemplate redisTemplate) {
        // step = 1000:每次向 Redis 申请 1000 个号,本地无 Redis 调用
        return new RedisSegmentSequence(redisTemplate, "seq:order", 1000);
    }
}

4.3 优缺点

  • 优点:性能极高(本地原子操作,无网络 RTT),减少了直接与Redis的交互。
  • 缺点:宕机/重启时本地未用完的号段会丢失,造成 Redis 号段"跳号"

4.4 优化

号段模式虽然可能存在跳号,但对与业务没有影响。非常适用于高并发场景。并发量少又嫌浪费号段的话,我们可以减少号段的范围,或者直接使用INCR逐条获取,可以有效的防止号段的浪费,但是必然增加于Redis的交互。

来看看我们公司业务设计方案:通过业务码+yyyyMMddHHmmss+3位自增序列

如:UG20260521153035001

markdown 复制代码
UG + 20260521153035 + 001
│      │               │
│      │               └── 3位自增序列 (001-999)
│      └── 14位时间戳 (秒级精度)
└── 2位业务码
  • UG:业务码,根据业务模块定义
  • 20260521153035:时间点,2026-05-21 15:30:35
  • 001:自增序列号,每秒最多999个号,这个根据自己业务的并发设置。

思路:主要通过Redis控制同一秒内的自增序列即可,1s过后之前的序列自动过期。

java 复制代码
Component
public class SerialNumberGenerator {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    // Lua脚本:自增+设置过期时间,原子操作
    private static final String LUA_SCRIPT = 
        "local current = redis.call('incr', KEYS[1]) " +
        "if current == 1 then " +
        "    redis.call('expire', KEYS[1], ARGV[1]) " +
        "end " +
        "return current";
    
    private RedisScript<Long> redisScript = new DefaultRedisScript<>(LUA_SCRIPT, Long.class);
    
    public String generate(String bizCode) {
        LocalDateTime now = LocalDateTime.now();
        String timeStr = now.format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
        String redisKey = String.format("seq:%s:%s", bizCode, timeStr);
        
        // 原子执行:自增+设置过期时间
        Long sequence = redisTemplate.execute(
            redisScript, 
            Collections.singletonList(redisKey), 
            "2"  // 保险起见,可以设置2秒过期
        );
        
        if (sequence == null || sequence > 999) {
            throw new RuntimeException("序列号生成失败或超出上限");
        }
        
        return bizCode + timeStr + String.format("%03d", sequence);
    }
}

05 小结

唯一编码的的场景有很多,如验证码、订单号、分布式ID等,需要唯一编码时,如果需要自己设计,设计的思路基本一致。

老铁们,你们都是怎么设计的?

相关推荐
rick97711 小时前
Avalonia 项目结构详解与开发工具配置(.NET 10)
后端
小禹在努力11 小时前
踩坑记录:brpc Unknown error 1014 协议配置不当引发的自身问题
后端
码上小翔哥11 小时前
Spring AI 学习笔记(第一阶段 — 基础入门)
后端·ai编程
Moment11 小时前
面试官:上下文过长导致语义偏移,工程上怎么优化
前端·后端·面试
AI_大白11 小时前
Kimi 金融数据接入避坑:用 `tool_calls` 查询实时行情,而不是让模型猜价格
后端·架构
阿维的博客日记11 小时前
罗列一下常见幂等操作
java
ma_king11 小时前
后端开发者工程实践指南
java·后端
霸道流氓气质11 小时前
从零理解 Redisson:Java 分布式工具箱的入门与实战
java·开发语言·分布式
IT_陈寒11 小时前
React hooks依赖数组坑得我差点重写整个组件
前端·人工智能·后端