ERP系统按配置规则生成单据号
- 背景
在ERP系统中,不同业务单号的生成规则可能是不一样的,如果在代码中把每个业务的单号规则写死,缺少灵活性和可维护性,所以需要有一个编号规则的管理
- 编号规则设计
在MagicErp系统中,编号规则如下图
- 业务类型:入库单、出库单、销售订单等具体的业务
- 生成规则方式:不重置、每年重置编号、每月重置编号、每天重置编号。例如每天重置顺序号,第二天就又从1开始生成顺序号
- 开始顺序号:顺序号从哪个数字开始生成
- 规则类型:常量、当前年、当前年月、当前年月日日、顺序号、分部编码。可以添加任意多个规则进行组合。
- 按上图规则生成的第一个编号为:RK+20240320/00120--{部门id}
- 顺序号实现方案
因为顺序号需要保证连续性和不重复,这里着重说一下顺序号的生成:
- 使用redis的increase方法自增
这种方式来实现简单,大部分场景不会有问题,但如果想要保证顺序号生成的稳定性,就需要解决以下问题:redis缓存丢失问题、顺序号需要按规则进行重置、编号不重复问题,如果想要把功能实现完善保证100%没问题逻辑还是很复杂的,而且需要依赖数据库和分布式锁
- 使用乐观锁+自旋的方式
实现原理是,给编号规则表加version字段,先查询该规则最新的编号,将其+1得到生成的编号,更新时对比version字段,如果更新成功则发号成功,并发情况下更新失败则进行自旋重试
- 乐观锁+自旋具体实现
接下来,以MagicErp为例,具体说明乐观锁+自旋的顺序号的实现方式
1.编号规则类的lockVersion字段上增加mybatisplus乐观锁注解@Version
public class NoGenerateRuleDO extends BaseDO {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.ASSIGN_ID)
@ApiModelProperty(hidden = true)
private Long id;
@ApiModelProperty(name = "type", value = "业务类型")
private NoBusinessTypeEnum type;
@ApiModelProperty(name = "name", value = "规则名称")
private String name;
@ApiModelProperty(name = "seq_generate_type", value = "顺序号的重置规则")
private NoSeqResetTypeEnum seqGenerateType;
@ApiModelProperty(name = "seq_begin_number", value = "顺序号的起始值")
private Long seqBeginNumber;
@ApiModelProperty(name = "open_flag", value = "是否启用该规则")
private Boolean openFlag;
@ApiModelProperty(name = "item_list", value = "规则项列表")
@TableField(typeHandler = JacksonTypeHandler.class)
private RuleItemList itemList;
@ApiModelProperty(name = "curr_seq_number", value = "当前顺序号")
private Long currSeqNumber;
@ApiModelProperty(name = "seq_last_generate_time", value = "最后一次生成顺序号的时间")
private Date seqLastGenerateTime;
@Version
@ApiModelProperty(value = "乐观锁版本号", hidden = true)
private Long lockVersion;
public static class RuleItemList extends ArrayList<NoGenerateRuleItem>{
}
}
2.生成顺序号的代码
/**
* 数据库乐观锁 + 失败重试 生成顺序号
*
* @param id 编号规则id
* @return 顺序号
*/
private Long createSeq(Long id) {
NoGenerateRuleDO ruleDO = noGenerateRuleManager.getById(id);
// 如果需要重置,则使用起始值,否则使用递增的顺序号
Long currSeqNumber = needReset(ruleDO) ? ruleDO.getSeqBeginNumber() : ruleDO.getCurrSeqNumber();
// 顺序号+1
ruleDO.setCurrSeqNumber(currSeqNumber + 1);
// 最后一次生成顺序号的时间
ruleDO.setSeqLastGenerateTime(new Date());
boolean updateResult = noGenerateRuleManager.updateById(ruleDO);
// 乐观锁更新成功,则直接返回结果
if (updateResult) {
return currSeqNumber;
} else {
// 乐观锁更新失败进行重试
// 数据库引擎必须使用MyISAM,让这张表的事务失效,否则事务隔离级别为可重复读,读取的数据永远是旧数据
log.debug("编号规则:{}乐观锁更新失败,进行重试", id);
ThreadUtil.sleep(200);
return createSeq(id);
}
}
/**
* 判断这一时刻是否需要重置顺序号
*
* @param ruleDO 编号规则
* @return true:需要重置顺序号
*/
private boolean needReset(NoGenerateRuleDO ruleDO) {
// 重置类型
NoSeqResetTypeEnum seqResetType = ruleDO.getSeqGenerateType();
// 不重置
if (seqResetType == NoSeqResetTypeEnum.Normal) {
return false;
}
String formatDate;
if (seqResetType == NoSeqResetTypeEnum.Year) {
formatDate = "yyyy";
} else if (seqResetType == NoSeqResetTypeEnum.Month) {
formatDate = "yyyyMM";
} else {
formatDate = "yyyyMMdd";
}
// 当前时间的日期
String currDateInfo = DateUtil.format(new Date(), formatDate);
// 最后一次生成顺序号的日期
String lastDateInfo = DateUtil.format(ruleDO.getSeqLastGenerateTime(), formatDate);
// 如果不相等,则代表是今天(今月/今年)第一次生成,需要重置顺序号
return !currDateInfo.equals(lastDateInfo);
}
- 遇到的问题
上述代码需要被其他业务调用,其他业务如果加了事务,会导致自旋重试时出现死循环,因为mysql的默认隔离级别为可重复读,在同一个事务中第一次查询会生成读快照,就会导致每次重试时查询到的都是旧的数据从而导致死循环。
解决方案:
因为本系统用到的时注解式事务,只控制本段代码不走事务不太可行(Spring的事务隔离级别全部尝试过无效),最终解决方案为将该表的引擎由Innodb改为MyISAM,这张表的事务就不会生效了,也就解决了事务的读快照导致的死循环问题