目录
- 前言
- 一、为什么不直接使用 UUID?
- 二、为什么不直接使用数据库自增主键?
- 三、本文实现目标
- 1. 支持自定义业务前缀
- 2. 序列号固定9位
- 3. 支持多线程并发生成
- 4. 支持多实例部署
- 五、实现方案
- 六、service 实现类
- 七、Mapper 、建表语句 和 xml
前言
在企业级应用开发过程中,我们经常会遇到需要生成唯一业务编号的场景,例如:
- 财务系统中的凭证号、批次号、请求流水号;
- 订单系统中的订单编号;
- 支付系统中的交易流水号;
- 文件上传系统中的文件编号;
- 第三方接口调用时要求的唯一请求ID。
这些业务编号通常具有以下特点:
必须保证全局唯一
同一个业务场景下,生成的编号不能重复,否则可能导致数据覆盖、财务账务错误、接口幂等失败等严重问题。
需要具备一定业务含义
很多业务系统并不希望使用完全随机的 UUID,而是希望编号中包含一些业务信息。
例如:HX44030010001686XAc000000001
其中:
HX44030010001686XAc:业务前缀,可以表示公司、业务类型、日期等信息;000000001:递增序列号,用于区分同一批次下的不同数据。
需要支持高并发环境
在生产环境中,应用通常会部署多个实例。
例如:
服务实例A | | 服务实例B ---> 生成业务编号 | | 服务实例C多个线程、多个服务节点可能同时请求生成编号,如果没有正确的并发控制,很容易产生重复编号。
一、为什么不直接使用 UUID?
很多开发人员第一时间想到 UUID:
例如:550e8400-e29b-41d4-a716-446655440000
UUID 确实可以保证较高概率唯一,但是在很多业务场景下并不适合。
原因包括:
(1) . 可读性较差
业务人员看到 UUID 无法快速判断:
- 哪个系统生成;
- 哪一天生成;
- 属于哪个业务批次。
而业务编号:HX44030010001686XAc000000001
可以携带一定业务信息。
(2). 长度较长
UUID:36位
而很多财务系统、第三方接口对于编号长度存在限制。
例如:
- 最大长度20位
- 最大长度32位
- 最大长度64位
UUID 可能不符合要求。
(3). 无法保证递增
很多业务希望编号具有递增特征,例如:
- 000000001
- 000000002
- 000000003
方便:
- 查询;
- 排查问题;
- 对账;
- 日志追踪。
UUID 不具备这种特性。
二、为什么不直接使用数据库自增主键?
MySQL 自增 ID:
AUTO_INCREMENT确实可以生成:
1 2 3 4但是它通常只适合作为数据库内部主键。
业务编号往往需要:
- 指定前缀;
- 按业务维度重新计数;
- 不同业务使用不同序列。
例如:
公司A:
COMPANY_A000000001 COMPANY_A000000002公司B:
COMPANY_B000000001 COMPANY_B000000002数据库自增无法满足这种需求。
三、本文实现目标
本文设计并实现一个基于 MySQL 的业务序列号生成器,满足以下要求:
1. 支持自定义业务前缀
例如:
输入:
HX44030010001686XAc生成:
HX44030010001686XAc0000000012. 序列号固定9位
规则:
1 -> 000000001 111 -> 000000111 123456789 -> 123456789最大支持:
999999999即同一个业务前缀最多可以生成:
999999999个唯一编号。
3. 支持多线程并发生成
例如:
100个线程同时请求:
generateSeqId("HX44030010001686XAc")最终结果:
HX44030010001686XAc000000001 HX44030010001686XAc000000002 HX44030010001686XAc000000003 ... HX44030010001686XAc000000100不会出现重复。
4. 支持多实例部署
即使应用部署:
服务器A 服务器B 服务器C多个节点同时生成编号,也能够保证唯一。
五、实现方案
本文采用:
MySQL + InnoDB + SELECT FOR UPDATE + Spring事务实现数据库级别的分布式序列控制。
核心思想:
每一个业务前缀对应数据库中的一条序列记录:
例如:
seq_key current_value HX44030010001686XAc 100表示:
HX44030010001686XAc 已经生成到第100个编号生成编号时:
执行:
SELECTcurrent_valueFROMgenerator_seq_idWHEREseq_key=?FORUPDATE锁定当前序列记录。
当前事务内:
current_value + 1更新数据库。
格式化生成:
000000101最终拼接:
HX44030010001686XAc000000101为什么选择数据库锁?
相比 Redis 分布式锁或者第三方 ID 服务:
数据库方案具有:
- 实现简单;
- 无需额外中间件;
- 数据可靠持久化;
- 与业务数据库保持一致。
对于:
- 财务系统;
- ERP系统;
- 内部管理系统;
这种对唯一性要求高,但生成压力不是百万级 QPS 的场景,非常适合。
六、service 实现类
packagecom.somedemo.service;importcom.somedemo.mapper.SeqIdGeneratorMapper;importlombok.extern.slf4j.Slf4j;importorg.apache.commons.lang3.StringUtils;importorg.springframework.beans.factory.annotation.Autowired;importorg.springframework.stereotype.Component;importorg.springframework.transaction.annotation.Propagation;importorg.springframework.transaction.annotation.Transactional;importjava.util.Locale;/** * 生成seqId生成器。 * * 规则: * * 前缀长度:1 ~ 20位 * 序列长度:固定9位 * 序列范围:000000001 ~ 999999999 * * 示例: * * prefix: * HX44030010001686XAc * * 第一次: * HX44030010001686XAc000000001 * * 第二次: * HX44030010001686XAc000000002 */@Slf4j@ComponentpublicclassSeqIdGeneratorService{/** * 前缀最大长度。 */privatestaticfinalintPREFIX_MAX_LENGTH=20;/** * 后缀序列固定长度。 */privatestaticfinalintSEQUENCE_LENGTH=9;/** * 后缀序列最大值。 */privatestaticfinalintMAX_SEQUENCE_VALUE=999_999_999;@AutowiredprivateSeqIdGeneratorMapperseqIdGeneratorMapper;/** * 生成seqId * * 使用独立事务的目的: * * 1. 序列号生成事务与外部业务事务隔离; * 2. 一个序列一旦成功生成,就不因为外层业务回滚而重新使用; * 3. 避免出现重复 seqId */@Transactional(propagation=Propagation.REQUIRES_NEW,rollbackFor=Exception.class)publicStringgenerateSeqId(Stringprefix){/* * 1. 校验前缀 */if(StringUtils.isBlank(prefix)){thrownewIllegalArgumentException("生成seqId失败,前缀不能为空");}StringsequenceKey=prefix.trim();if(sequenceKey.length()>PREFIX_MAX_LENGTH){thrownewIllegalArgumentException("生成SEQID失败,前缀长度不能超过"+PREFIX_MAX_LENGTH+"位,当前长度="+sequenceKey.length());}/* * 2. 初始化序列。 * * 如果不存在: INSERT current_value = 0 * * 如果已经存在:INSERT IGNORE忽略主键冲突。 * * 并发情况下: * 多个线程同时插入相同seq_key时, * 最终数据库中也只会存在一条记录。 */seqIdGeneratorMapper.insertIfAbsent(sequenceKey);/* * 3. SELECT ... FOR UPDATE * * 对当前 seq_key 对应的数据行加排他锁。 * * 事务提交之前,其他事务针对相同 seq_key 的SELECT FOR UPDATE会等待。 * * 不同seq_key之间互不影响。 */IntegercurrentValue=seqIdGeneratorMapper.selectCurrentValueForUpdate(sequenceKey);if(currentValue==null){thrownewIllegalStateException("生成SEQID失败,未查询到序列记录,sequenceKey="+sequenceKey);}/* * 4. 检查序列是否已经达到最大值。 * * 这里先判断,再执行+1。 * * 比下面这种写法更加安全: * * int nextValue = currentValue + 1; * * 因为理论上如果数据库数据异常,currentValue已经达到Integer.MAX_VALUE,直接+1会发生整数溢出。 * */if(currentValue>=MAX_SEQUENCE_VALUE){thrownewIllegalStateException("SEQID序列已用完,sequenceKey="+sequenceKey+",currentValue="+currentValue+",maxValue="+MAX_SEQUENCE_VALUE);}/* * 5. 序列+1 */intnextValue=currentValue+1;/* * 6. 更新数据库。 */intupdateCount=seqIdGeneratorMapper.updateCurrentValue(sequenceKey,nextValue);if(updateCount!=1){thrownewIllegalStateException("更新SEQID序列失败,sequenceKey="+sequenceKey+",nextValue="+nextValue);}/* * 7. 固定格式化成9位数字。 * 例如: * 1 -> 000000001 * 111 -> 000000111 * 123456789 -> 123456789 */Stringsequence=String.format(Locale.ROOT,"%0"+SEQUENCE_LENGTH+"d",nextValue);StringseqId=sequenceKey+sequence;log.info("生成财务在线SEQID成功,sequenceKey:{},currentValue:{},SEQID:{}",sequenceKey,nextValue,seqId);returnseqId;}}七、Mapper 、建表语句 和 xml
packagecom.somedemo.mapper;importorg.apache.ibatis.annotations.Param;importorg.apache.ibatis.annotations.Mapper;@MapperpublicinterfaceSeqIdGeneratorMapper{/** * 如果序列不存在,则初始化为0。 */intinsertIfAbsent(@Param("seqKey")StringseqKey);/** * 查询当前序列值,并对当前记录加排他锁。 */IntegerselectCurrentValueForUpdate(@Param("seqKey")StringseqKey);/** * 更新当前序列值。 */intupdateCurrentValue(@Param("seqKey")StringseqKey,@Param("currentValue")IntegercurrentValue);}建表语句:
CREATETABLEgenerator_seq_id(seq_keyVARCHAR(32)NOTNULLCOMMENT'序列键/前缀,例如HX44030010001686XAc',current_valueINTNOTNULLDEFAULT0COMMENT'当前序列值,最大999999999',update_timeDATETIMENOTNULLCOMMENT'更新时间',PRIMARYKEY(seq_key))ENGINE=InnoDBCOMMENT='用于生成序列号ID';xml:
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPEmapperPUBLIC"-//mybatis.org//DTD Mapper 3.0//EN""http://mybatis.org/dtd/mybatis-3-mapper.dtd"><mappernamespace="com.somedemo.mapper.SeqIdGeneratorMapper"><!-- 序列不存在时初始化。 不存在: current_value = 0 已存在: INSERT IGNORE忽略主键冲突。 在并发情况下,即使多个线程同时执行, 因为seq_key是主键,也只会存在一条记录。 --><insertid="insertIfAbsent">INSERT IGNORE INTO generator_seq_id (seq_key, current_value, update_time) VALUES (#{seqKey},0,NOW())</insert><!-- 查询当前序列,并对这一行加排他锁。 注意: SELECT FOR UPDATE必须在事务中执行。 --><selectid="selectCurrentValueForUpdate"resultType="java.lang.Integer">SELECT current_value FROM generator_seq_id WHERE seq_key = #{seqKey} FOR UPDATE</select><!-- 更新当前序列值。 --><updateid="updateCurrentValue">UPDATE generator_seq_id SET current_value = #{currentValue}, update_time = NOW() WHERE seq_key = #{seqKey}</update></mapper>