目录
- 前言
- [一、为什么不直接使用 UUID?](#一、为什么不直接使用 UUID?)
- 二、为什么不直接使用数据库自增主键?
- 三、本文实现目标
-
- [1. 支持自定义业务前缀](#1. 支持自定义业务前缀)
- [2. 序列号固定9位](#2. 序列号固定9位)
- [3. 支持多线程并发生成](#3. 支持多线程并发生成)
- [4. 支持多实例部署](#4. 支持多实例部署)
- 五、实现方案
- [六、service 实现类](#六、service 实现类)
- [七、Mapper 、建表语句 和 xml](#七、Mapper 、建表语句 和 xml)
前言
在企业级应用开发过程中,我们经常会遇到需要生成唯一业务编号的场景,例如:
- 财务系统中的凭证号、批次号、请求流水号;
- 订单系统中的订单编号;
- 支付系统中的交易流水号;
- 文件上传系统中的文件编号;
- 第三方接口调用时要求的唯一请求ID。
这些业务编号通常具有以下特点:
-
必须保证全局唯一
同一个业务场景下,生成的编号不能重复,否则可能导致数据覆盖、财务账务错误、接口幂等失败等严重问题。
-
需要具备一定业务含义
很多业务系统并不希望使用完全随机的 UUID,而是希望编号中包含一些业务信息。
例如:HX44030010001686XAc000000001
其中:
HX44030010001686XAc:业务前缀,可以表示公司、业务类型、日期等信息;000000001:递增序列号,用于区分同一批次下的不同数据。
-
需要支持高并发环境
在生产环境中,应用通常会部署多个实例。
例如:
tex
服务实例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:
sql
AUTO_INCREMENT
确实可以生成:
1
2
3
4
但是它通常只适合作为数据库内部主键。
业务编号往往需要:
- 指定前缀;
- 按业务维度重新计数;
- 不同业务使用不同序列。
例如:
公司A:
COMPANY_A000000001
COMPANY_A000000002
公司B:
COMPANY_B000000001
COMPANY_B000000002
数据库自增无法满足这种需求。
三、本文实现目标
本文设计并实现一个基于 MySQL 的业务序列号生成器,满足以下要求:
1. 支持自定义业务前缀
例如:
输入:
HX44030010001686XAc
生成:
HX44030010001686XAc000000001
2. 序列号固定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事务
实现数据库级别的分布式序列控制。
核心思想:
每一个业务前缀对应数据库中的一条序列记录:
例如:
text
seq_key current_value
HX44030010001686XAc 100
表示:
HX44030010001686XAc
已经生成到第100个编号
生成编号时:
执行:
sql
SELECT current_value
FROM generator_seq_id
WHERE seq_key = ?
FOR UPDATE
锁定当前序列记录。
当前事务内:
current_value + 1
更新数据库。
格式化生成:
000000101
最终拼接:
HX44030010001686XAc000000101
为什么选择数据库锁?
相比 Redis 分布式锁或者第三方 ID 服务:
数据库方案具有:
- 实现简单;
- 无需额外中间件;
- 数据可靠持久化;
- 与业务数据库保持一致。
对于:
- 财务系统;
- ERP系统;
- 内部管理系统;
这种对唯一性要求高,但生成压力不是百万级 QPS 的场景,非常适合。
六、service 实现类
java
package com.somedemo.service;
import com.somedemo.mapper.SeqIdGeneratorMapper;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.util.Locale;
/**
* 生成seqId生成器。
*
* 规则:
*
* 前缀长度:1 ~ 20位
* 序列长度:固定9位
* 序列范围:000000001 ~ 999999999
*
* 示例:
*
* prefix:
* HX44030010001686XAc
*
* 第一次:
* HX44030010001686XAc000000001
*
* 第二次:
* HX44030010001686XAc000000002
*/
@Slf4j
@Component
public class SeqIdGeneratorService {
/**
* 前缀最大长度。
*/
private static final int PREFIX_MAX_LENGTH = 20;
/**
* 后缀序列固定长度。
*/
private static final int SEQUENCE_LENGTH = 9;
/**
* 后缀序列最大值。
*/
private static final int MAX_SEQUENCE_VALUE = 999_999_999;
@Autowired
private SeqIdGeneratorMapper seqIdGeneratorMapper;
/**
* 生成seqId
*
* 使用独立事务的目的:
*
* 1. 序列号生成事务与外部业务事务隔离;
* 2. 一个序列一旦成功生成,就不因为外层业务回滚而重新使用;
* 3. 避免出现重复 seqId
*/
@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
public String generateSeqId(String prefix) {
/*
* 1. 校验前缀
*/
if (StringUtils.isBlank(prefix)) {
throw new IllegalArgumentException("生成seqId失败,前缀不能为空");
}
String sequenceKey = prefix.trim();
if (sequenceKey.length() > PREFIX_MAX_LENGTH) {
throw new IllegalArgumentException("生成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之间互不影响。
*/
Integer currentValue = seqIdGeneratorMapper.selectCurrentValueForUpdate(sequenceKey);
if (currentValue == null) {
throw new IllegalStateException("生成SEQID失败,未查询到序列记录,sequenceKey=" + sequenceKey);
}
/*
* 4. 检查序列是否已经达到最大值。
*
* 这里先判断,再执行+1。
*
* 比下面这种写法更加安全:
*
* int nextValue = currentValue + 1;
*
* 因为理论上如果数据库数据异常,currentValue已经达到Integer.MAX_VALUE,直接+1会发生整数溢出。
*
*/
if (currentValue >= MAX_SEQUENCE_VALUE) {
throw new IllegalStateException("SEQID序列已用完,sequenceKey=" + sequenceKey
+ ",currentValue=" + currentValue + ",maxValue=" + MAX_SEQUENCE_VALUE);
}
/*
* 5. 序列+1
*/
int nextValue = currentValue + 1;
/*
* 6. 更新数据库。
*/
int updateCount = seqIdGeneratorMapper.updateCurrentValue(sequenceKey, nextValue);
if (updateCount != 1) {
throw new IllegalStateException("更新SEQID序列失败,sequenceKey=" + sequenceKey + ",nextValue=" + nextValue);
}
/*
* 7. 固定格式化成9位数字。
* 例如:
* 1 -> 000000001
* 111 -> 000000111
* 123456789 -> 123456789
*/
String sequence = String.format(Locale.ROOT, "%0" + SEQUENCE_LENGTH + "d", nextValue);
String seqId = sequenceKey + sequence;
log.info("生成财务在线SEQID成功,sequenceKey:{},currentValue:{},SEQID:{}", sequenceKey, nextValue, seqId);
return seqId;
}
}
七、Mapper 、建表语句 和 xml
java
package com.somedemo.mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SeqIdGeneratorMapper {
/**
* 如果序列不存在,则初始化为0。
*/
int insertIfAbsent(@Param("seqKey") String seqKey);
/**
* 查询当前序列值,并对当前记录加排他锁。
*/
Integer selectCurrentValueForUpdate(@Param("seqKey") String seqKey);
/**
* 更新当前序列值。
*/
int updateCurrentValue(@Param("seqKey") String seqKey, @Param("currentValue") Integer currentValue);
}
建表语句:
sql
CREATE TABLE generator_seq_id (
seq_key VARCHAR(32) NOT NULL COMMENT '序列键/前缀,例如HX44030010001686XAc',
current_value INT NOT NULL DEFAULT 0 COMMENT '当前序列值,最大999999999',
update_time DATETIME NOT NULL COMMENT '更新时间',
PRIMARY KEY (seq_key)
) ENGINE=InnoDB COMMENT='用于生成序列号ID';
xml:
xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.somedemo.mapper.SeqIdGeneratorMapper">
<!--
序列不存在时初始化。
不存在: current_value = 0
已存在: INSERT IGNORE忽略主键冲突。
在并发情况下,即使多个线程同时执行, 因为seq_key是主键,也只会存在一条记录。
-->
<insert id="insertIfAbsent">
INSERT
IGNORE INTO generator_seq_id
(seq_key, current_value, update_time)
VALUES
(#{seqKey},0,NOW())
</insert>
<!--
查询当前序列,并对这一行加排他锁。
注意: SELECT FOR UPDATE必须在事务中执行。
-->
<select id="selectCurrentValueForUpdate" resultType="java.lang.Integer">
SELECT current_value
FROM generator_seq_id
WHERE seq_key = #{seqKey} FOR UPDATE
</select>
<!-- 更新当前序列值。 -->
<update id="updateCurrentValue">
UPDATE generator_seq_id
SET current_value = #{currentValue},
update_time = NOW()
WHERE seq_key = #{seqKey}
</update>
</mapper>