业务序列生成器 —— 单体架构版

主键 ID VS 业务 ID

在数据库设计中,除了主键 ID,一般还需要一个具有唯一索引的业务 ID。二者承担的职责不一样,它们共同满足了我们对于 技术实现业务需求 的双重目标

职责分离原则

主键 ID 业务唯一标识 ID
作用 保证数据库层面的唯一性 保证业务层面的唯一性
目标 保证数据存储和关联的可靠性 满足业务规则和外部交互需求
特点 无意义、自增/随机、不可变 有具体业务含义、可读、可暴露

eg:

  1. 商品表的主键 ID 可能是 1、2、3,但商品编码(业务唯一标识)可能是00012517271821。前四位是所处的地区码,中间是随机生成的数字,最后四位是新增这个商品的用户 ID 后四位
  2. 订单表的主键 ID 可能是 1、2、3,但订单编码可能是时间戳拼上今天订单的序号:202505220012

使用场景分析

场景一:防止暴露内部信息

  • 问题 :直接暴露自增主键<font style="color:rgb(199, 37, 78);">ID</font>,可能泄露业务规模(如用户量、订单量),甚至被恶意遍历数据
  • 解决 :使用无规律的业务<font style="color:rgb(199, 37, 78);">ID</font>(如UUID、哈希值)对外暴露,隐藏自增主键

场景二:分库分表需求

  • **问题:**如果需要分库分表,主键<font style="color:rgb(199, 37, 78);">ID</font>就无法保证全局唯一性
  • 解决 :通过业务<font style="color:rgb(199, 37, 78);">ID</font>实现全局唯一(雪花算法生成的分布式 ID、使用自定义序列生成器生成的 ID)

场景三:业务标识符的灵活性

  • 问题:业务唯一标识可能需要动态规则(如订单号包含日期、地区码),而自增主键无法满足
  • 解决 :业务唯一标识<font style="color:rgb(199, 37, 78);">ID</font>按业务规则生成,主键<font style="color:rgb(199, 37, 78);">ID</font>保持默认策略

技术实现对比

主键 ID 业务唯一标识 ID
数据类型 通常为 <font style="color:rgb(199, 37, 78);background-color:rgb(249, 242, 244);">int/bigint</font>(高效索引) 可能是 <font style="color:rgb(199, 37, 78);background-color:rgb(249, 242, 244);">varchar</font>(兼容复杂规则)
唯一性范围 表内唯一 全局唯一(跨表、跨系统)
生成方式 自增/随机 程序生成(UUID、雪花算法、业务规则拼接)
修改性 不可变(与数据生命周期绑定) 可能允许修改(如用户重设唯一用户名)

:::color4 建议:

  1. 主键<font style="color:rgb(199, 37, 78);">ID</font>始终存在:作为数据库的"技术锚点",用于外键关联、索引优化
  2. 业务唯一标识<font style="color:rgb(199, 37, 78);">ID</font>按需设计
    1. 若无需业务唯一标识,可省略
    2. 若需暴露或业务规则复杂,必加,并为其添加唯一索引
  3. 查询优化
    1. 内部关联用<font style="color:rgb(199, 37, 78);">ID</font>(更快)
    2. 对外接口用业务<font style="color:rgb(199, 37, 78);">ID</font>(更安全)

:::


何时不需要用业务 ID ?

  • 纯内部工具表,无暴露需求
  • 业务标识符可直接复用主键(如简单的配置表)

为什么需要自定义序列生成器?

前面有说过业务<font style="color:rgb(199, 37, 78);">ID</font>一般是具有具体业务含义的,我们需要支持根据动态规则来生成具有不同业务属性的业务<font style="color:rgb(199, 37, 78);">ID</font>

:::tips 注意:

自定义序列生成器一般用来生成业务 ID ,但也可以用来生成主键 ID。具体实现方式是由多个维度所决定的。例如:公司觉得主键 ID 使用雪花算法生成的 64 位长整型数字比较占用内存,但是又不想新增一个具备实际业务含义的字段,那就可以选择使用自定义序列生成器生成具备业务属性的主键 ID(合二为一)

:::

下面,我将分别实现 单体架构分布式架构 下的序列生成器。它们最大的区别在于分布式架构下的序列生成器可以保证序列在多个不同的数据库之间也不会出现重复的问题,保证全局唯一性


单体架构实现

实现单体架构的序列生成器较为简单,只要想明白两个注意点:

  1. 由于要支持动态规则,所以需要用一张表来存储不同的业务生成序列的对应规则
  2. 我们需要保证业务 ID 唯一,所以每次要记录生成的最后一次数值,确保下次生成的值具有顺序且不重复

想明白了以上两点,我们就来尝试实现吧

实现步骤:

  1. 定义一张表用来配置不同业务的序列生成规则模板
sql 复制代码
CREATE TABLE `sequence_rule` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '自增列',
  `module_id` varchar(50) NOT NULL COMMENT '模块ID',
  `rule` varchar(100) NOT NULL COMMENT '序列规则',
  `cuid` int(11) NOT NULL COMMENT '当前流水号',
  `pref` varchar(50) NOT NULL COMMENT '规则前缀',
  PRIMARY KEY (`id`),
  UNIQUE KEY `idx_module_id` (`module_id`)
)  COMMENT='序列规则配置';

注意:模块 ID 要单独建立唯一索引,保证唯一性

  1. 定义一张表用来记录不同序列对应生成的值
sql 复制代码
CREATE TABLE `sequence_record` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `sequence_key` varchar(64) NOT NULL COMMENT '序列编码',
  `sequence_value` bigint(20) DEFAULT NULL COMMENT '序列值',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='序列ID记录表';
  1. 定义入口方法 VoucherIdManager.generateIds()
java 复制代码
@Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRES_NEW)
public List<String> generateIds(ModuleEnum moduleEnum, Long length) {
    if (moduleEnum == null ||length == null) {
        throw new BizException(200, "缺失必传参数");
    }

    return this.buildIds(moduleEnum, length);
}
  1. 核心逻辑为 buildIds()
java 复制代码
/**
 * 构建 ID
 *
 * @param moduleEnum 模块枚举
 * @param length 集合长度
 * @return List<String>
 */
private List<String> buildIds(ModuleEnum moduleEnum, Long length) {
    List<String> ids = new ArrayList<>();

    // 1.获取序列规则
    SequenceRule sequenceRule = sequenceRuleService.getByModuleEnum(moduleEnum);
    String rule = sequenceRule.getRule().toUpperCase();

    // 2.生成 ID 前缀
    // ID 规则为: CO[yy][mm][dd][ID000000]  则第二步会生成 CO20230501 这一串前缀
    String idPref = this.generateIdPref(rule);
    log.info("idPref -> [{}]", idPref);

    // 3.生成唯一值
    Matcher matcher = SEQUENCE.matcher(rule);
    if (matcher.find()) {
        // 如果匹配上了,获取 0 的个数  (0 的个数就意味着要生成的随机数的长度)
        int zeroLength = matcher.end() - matcher.start() - 4;

        for (int i = 0; i < length; i++) {
            Long nextSequence = sequenceManager.getNextSequence(idPref);
            ids.add(idPref + String.format("%0" + zeroLength + "d", nextSequence));
        }
    } else {
        throw new BizException(200, "序列规则配置错误");
    }
    return ids;
}
  1. 定义一个类,将数据库中对应序列的属性保存到内存(此处也可替换成 Redis)
java 复制代码
private class SequenceHolder {

    private String key;

    /**
     * 当前序列号,初始化是为 0
     */
    private AtomicLong sequenceValue;

    /**
     * 数据库保存的序列号
     */
    private long dbValue;

    /**
     * 步长,用来判断序列号是否还在给定的步长范围内
     */
    private long step;

    public SequenceHolder(long step) {
        this.step = step;
    }


    public long nextValue() {
        if (sequenceValue == null) {
            // 初始化
            this.init();
        }
        long sequence = sequenceValue.incrementAndGet();
        if (sequence > step) {
            // 意味着分配给它的序列号已经用完,需要重新分配
            this.nextRound();
            return this.nextValue();
        } else {
            return dbValue + sequence;
        }
    }

    private synchronized void nextRound() {
        if (sequenceValue.get() > step) {
            // 重新生成下一个序列号
            dbValue = SequenceManager.this.nextValue(key, step) - step;
            sequenceValue = new AtomicLong(0);
        }
    }

    private synchronized void init() {
        if (sequenceValue != null) {
            return;
        }
        dbValue = SequenceManager.this.nextValue(key, step) - step;
        sequenceValue = new AtomicLong(0);
    }

}

步长 step 的作用是什么?

步长的意思就是一次返回序列号的长度。例如:step=100,则会修改数据库中对应序列的可用值为当前值 + 100,意味着这段区间已经分配给了当前服务。只要 sequenceValue 没有超过这个步长,则可以安全的使用分配给它的这一段区间。如果超过了,则需要重新获取一个新的区间,此区间长度为 step

  1. 实现序列生成逻辑
java 复制代码
/**
 * @Description 序列生成器
 * @Author Mr.Zhang
 * @Date 2025/5/25 19:04
 * @Version 1.0
 */
@Slf4j
@Component
public class SequenceManager {
    @Autowired
    private SequenceRecordService sequenceRecordService;

    private static final Map<String, SequenceHolder> holder = new HashMap<>();

    /**
     * 获取下一个序列  确保唯一性
     *
     * @param identity Key
     * @return
     */
    @Transactional(propagation = Propagation.NOT_SUPPORTED)
    public long getNextSequence(String identity) {
        SequenceHolder sequenceHolder = holder.get(identity);
        if (sequenceHolder == null) {
            synchronized (holder) {
                sequenceHolder = holder.get(identity);
                if (sequenceHolder == null) {
                    sequenceHolder = new SequenceHolder(1);  // 默认为 1
                    sequenceHolder.setKey(identity);
                    sequenceHolder.init();
                    holder.put(identity, sequenceHolder);
                }
            }
        }
        return sequenceHolder.nextValue();
    }

    /**
     * 获取下一个序列  确保唯一性
     *
     * @param sequenceKey Key
     * @return
     */
    private long nextValue(String sequenceKey, long step) {
        for (int i = 0; i < 10; i++) {
            SequenceRecord sequenceRecord = sequenceRecordService.querySequence(sequenceKey);
            int effectRow = sequenceRecordService.nextValue(sequenceRecord.getSequenceValue() + step, sequenceRecord.getSequenceValue(), sequenceKey);
            if (effectRow == 1) {
                return sequenceRecord.getSequenceValue() + step;  // 返回下一个可用值
            }
        }
        throw new BizException(200, "获取序列失败");
    }
}

单体架构的核心代码就是这些。最主要的思路其实是保证序列生成的唯一性。此实现采用步长 + 乐观锁的方式确保不同的服务拿到的是不同的序列值

单体架构实现完整代码已上传到 github 上,感兴趣的朋友可以配合我的讲解看看具体实现代码

github.com/nowtostudey...


本文由博客一文多发平台 OpenWrite 发布!

相关推荐
IT毕设实战小研2 小时前
基于Spring Boot 4s店车辆管理系统 租车管理系统 停车位管理系统 智慧车辆管理系统
java·开发语言·spring boot·后端·spring·毕业设计·课程设计
一只爱撸猫的程序猿3 小时前
使用Spring AI配合MCP(Model Context Protocol)构建一个"智能代码审查助手"
spring boot·aigc·ai编程
甄超锋4 小时前
Java ArrayList的介绍及用法
java·windows·spring boot·python·spring·spring cloud·tomcat
武昌库里写JAVA6 小时前
JAVA面试汇总(四)JVM(一)
java·vue.js·spring boot·sql·学习
Pitayafruit7 小时前
Spring AI 进阶之路03:集成RAG构建高效知识库
spring boot·后端·llm
zru_96027 小时前
Spring Boot 单元测试:@SpyBean 使用教程
spring boot·单元测试·log4j
甄超锋8 小时前
Java Maven更换国内源
java·开发语言·spring boot·spring·spring cloud·tomcat·maven
还是鼠鼠9 小时前
tlias智能学习辅助系统--Maven 高级-私服介绍与资源上传下载
java·spring boot·后端·spring·maven
舒一笑13 小时前
Started TttttApplication in 0.257 seconds (没有 Web 依赖导致 JVM 正常退出)
jvm·spring boot·后端
javadaydayup14 小时前
Apollo 凭什么能 “干掉” 本地配置?
spring boot·后端·spring