业务背景

系统的业务单据基本都会带有一个单据编号字段,编号规则如下:

  • 一般以租户维度下唯一,即允许不同租户的相同单据类型出现单据编号重复。
  • 编号形式一般为:固定前缀 + 日期 + 自然位数 。
  • 部分单据编号的形式要求包含随机数,用于打乱自然增长序号。
  • 部分单据编号的前缀不是唯一的,可能会根据用户输入的前缀决定。

实现原理

关于业务背景的需求,其实主要麻烦的是自然位数的实现,特别是在考虑多种附加场景下时,例如:自然位数尽可能从1递增、系统是分布式服务、不同租户下序列号隔离。

因此,一般需要借助其他工具实现,例如:

  1. 数据库的序列(Sequence),数据库序列是一种数据库对象,用于生成唯一的递增或递减序列值。
  2. Redis 的 INCR 命令,用来原子地递增一个键的值,使用 Redis 的字符串类型来存储递增的序列值。
  3. 分布式ID生成器,例如Snowflake算法,可以生成全局唯一的递增ID。
  4. 分布式缓存和计数器,使用分布式缓存(例如Memcached)和计数器,通过原子递增操作实现递增序列。

在工作中,我使用的是 MySQL 数据库的唯一索引特性 + InnoDB引擎的事务特性,通过事务和唯一索引的锁机制,实现自然位数的自然递增。

数据表设计

数据表主要是依赖一张表,用于存储自然位数。

1
2
3
4
5
6
7
8
9
10
11
12
create table sys_business_sn
(
id bigint auto_increment comment '主键id'
primary key,
tenant_id bigint default 0 not null comment '租户id',
business_type int not null comment '业务类型',
business_date date not null comment '业务日期',
business_sn int not null comment '业务编号 (一般根据 业务类型+业务日期 唯一)',
row_create_time datetime default CURRENT_TIMESTAMP not null comment '数据创建时间',
row_update_time datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '数据更新时间',
constraint uniq_tenant_type_date unique (tenant_id, business_type, business_date)
) comment '系统业务编号表';

其中 tenant_id 表示租户id,实现租户隔离;business_type 表示业务类型,指不同业务单据对应的自然位数是隔离的;business_date 表示业务日期,指不同日期下的自然位数是隔离的;business_sn 表示自然位数,它通过 租户id + 业务类型 + 业务日期 实现唯一,通过 uniq_tenant_type_date 唯一索引建立关系。

至于如何通过这张表获取单据编号(其实主要是获取业务编号),则依赖下面的 sql 语句:

1
2
3
insert into sys_business_sn(tenant_id, business_type, business_date, business_sn)
values (#{tenantId}, #{businessType}, #{businessDate}, 1) on duplicate key
update business_sn = business_sn + 1;

这句 sql 语句采用 inset into xxx values xxx on duplicate key update xxx 语法。当“租户id + 业务类型 + 业务日期”拼接的值不存在对应的行数据时,默认插入一条数据,业务编号初始为1;当存在对应的行数据时,则将业务编号自增加1。

代码设计

通过数据表设计,在 Java 代码设计一个 DAO 层,用于生成指定租户、业务类型、业务日期的单据编号。

getBusinessSnByType 方法用于获取指定的自然位数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public Integer getBusinessSnByType(Integer businessType, Date date, Long companyId) {
// 拿取到真正存储到数据库的tenantId, 不能为null
String realTenantId;
if (companyId == null) {
Long tenantId = UserUtils.getTenantId();
if (tenantId == null) {
throw new IllegalStateException("token信息异常");
}
realTenantId = tenantId.toString();
} else {
realTenantId = companyId.toString();
}
String dateStr = BUSINESS_DATE_FORMAT.format(date);

DefaultTransactionDefinition transactionDefinition = new DefaultTransactionDefinition();
transactionDefinition.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
TransactionStatus transactionStatus = transactionManager.getTransaction(transactionDefinition);
baseMapper.incrementBusinessSn(realTenantId, businessType, dateStr);

SysBusinessSn entity = lambdaQuery()
.eq(SysBusinessSn::getTenantId, realTenantId)
.eq(SysBusinessSn::getBusinessType, businessType)
.eq(SysBusinessSn::getBusinessDate, dateStr)
.select(SysBusinessSn::getBusinessSn)
.last("limit 1")
.one();

transactionManager.commit(transactionStatus);

return entity.getBusinessSn();
}

上面代码的关键在于 TransactionStatus 控制数据库事务的开始和提交,在事务中做数据更新和数据查询操作,根据 InnoDB 的ACID原则,拿取更新后的业务编号。

在这个方法的上层方法(generateDefaultFullBusinessSnByCustomized),则是拼接最终的单据编号:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public String generateDefaultFullBusinessSnByCustomized(SysBusinessSnEnum sysBusinessSnEnum, String dateFormat, DecimalFormat decimalFormat, Long companyId) {
Date date = new Date();
StringBuilder sb = new StringBuilder(sysBusinessSnEnum.getPrefix());

SimpleDateFormat simpleDateFormat = new SimpleDateFormat(dateFormat);
sb.append(simpleDateFormat.format(date));

if (sysBusinessSnEnum.getRandomLen() > 0) {
String randomNumbers = RandomUtil.randomNumbers(sysBusinessSnEnum.getRandomLen());
sb.append(randomNumbers);
}

Integer sn = this.getBusinessSnByType(sysBusinessSnEnum.getCode(), date, companyId);

return sb.append(decimalFormat.format(sn)).toString();
}

完整的代码示例地址:https://github.com/wangfarui/work-report/tree/main/business-sn

总结

因为实现原理借助的工具不同,所以优缺点有很多都是显而易见的,是代码很难解决的。先说优点:

  1. 有效保障分布式服务下业务编号递增规则。
  2. 可以基于数据表了解到各租户下各单据类型的编号使用状况。

缺点:

  1. 因为数据库的原因,所以如果数据库奔溃等原因,就会影响所有涉及到业务的正常流程。
  2. 一般这个表会跟业务表共存在一个数据库,会影响数据库的吞吐量、连接数等。
  3. 业务编号可能不保持连续递增,由于业务系统的业务异常等原因。