Java 基于 MySQL 行锁实现高并发业务序列号生成器

目录

  • 前言
  • [一、为什么不直接使用 UUID?](#一、为什么不直接使用 UUID?)
  • 二、为什么不直接使用数据库自增主键?
  • 三、本文实现目标
    • [1. 支持自定义业务前缀](#1. 支持自定义业务前缀)
    • [2. 序列号固定9位](#2. 序列号固定9位)
    • [3. 支持多线程并发生成](#3. 支持多线程并发生成)
    • [4. 支持多实例部署](#4. 支持多实例部署)
  • 五、实现方案
  • [六、service 实现类](#六、service 实现类)
  • [七、Mapper 、建表语句 和 xml](#七、Mapper 、建表语句 和 xml)

前言

在企业级应用开发过程中,我们经常会遇到需要生成唯一业务编号的场景,例如:

  • 财务系统中的凭证号、批次号、请求流水号;
  • 订单系统中的订单编号;
  • 支付系统中的交易流水号;
  • 文件上传系统中的文件编号;
  • 第三方接口调用时要求的唯一请求ID。

这些业务编号通常具有以下特点:

  1. 必须保证全局唯一

    同一个业务场景下,生成的编号不能重复,否则可能导致数据覆盖、财务账务错误、接口幂等失败等严重问题。

  2. 需要具备一定业务含义

    很多业务系统并不希望使用完全随机的 UUID,而是希望编号中包含一些业务信息。

    例如:HX44030010001686XAc000000001

    其中:

    • HX44030010001686XAc:业务前缀,可以表示公司、业务类型、日期等信息;
    • 000000001:递增序列号,用于区分同一批次下的不同数据。
  3. 需要支持高并发环境

在生产环境中,应用通常会部署多个实例。

例如:

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>
相关推荐
xieliyu.1 小时前
Java 文件IO :File 文件类、字节流、字符流讲解
java·ide·笔记·javaee
云烟成雨TD1 小时前
Micrometer 系列【28】Spring Boot Actuator | 其他指标速览
java·spring boot·云原生
meilindehuzi_a1 小时前
深入理解 Linux 进程地址空间:从虚拟内存、页表映射到 fork 写时拷贝与 VMA 管理
java·linux·服务器
莫得感情 o2 小时前
设计模式 13 · 桥接模式
java·设计模式·桥接模式
吠品2 小时前
Java中实现拓扑排序的两种方式:Kahn算法和DFS
java·数据库·notepad++
TunerT_TQ2 小时前
企业智能体工程体系v1.1|企业智能体工程卷 · 第5期· 数据飞轮——用观察—分析—计划—执行持续改进
java·运维·网络·#agent工程化·#企业智能体·#多智能体系统·#数据飞轮
ruleslol2 小时前
InnoDB 执行 UPDATE 时的完整操作
mysql
小当家.1052 小时前
工具并行调用原理与实现:CompletableFuture 实战
java·agent·线程池·工具·并行
wangjialelele2 小时前
Spring Cloud 全体系学习笔记(REST、Nacos、Feign、Gateway)
java·spring boot·笔记·学习·spring·spring cloud·gateway