并发申请业务号
起因
有个表,打个比方,申请表,就是说,客户申请的时候,填写记录到申请记录表,表里新增了一条记录,当初设计的时候,表里没有业务号这个字段,只有id,然后一堆信息,然后新需求来了,导出这个记录表到excel里,然后把excel这个文件传到其他系统里,后台人员定时的去取这个文件填写。然后这个文件又回写到这个系统里。那么问题来了,导出到excel的时候,excel的每条记录是没有id的,也不允许导出id,这个时候,你如何确定这个excel的这条记录就是你数据库的这条的呢?
讨论后,决定用新增个字段,叫业务号。是不是熟悉起来了,用业务号标识这个记录。新增记录就是申请一个新的业务号。
业务号有这个几个要求。要求是这种格式的,QT2_202012_00000012 ,QT2标识这个业务是什么类型的,202012标识这个业务的日期月,后续的是一个7位数有序递增号,不允许重复(毕竟这个业务号标识了数据库的一条记录,重复了那还了得),然后允许跳数,就是说1以后可以是3.并不一定是2。
https://juejin.cn/post/6903124890589757453
处理思路
不管什么,首先是表里新增字段,业务号这个字段。然后表里的数据肯定是没有业务号的,当然要我们要么用sql语句更新要么用程序更新。
我这里是选择了sql更新。
ALTER TABLE ito_delivery_apply
ADD COLUMN apply_no
varchar(100) NULL COMMENT ‘货期查询业务号’ AFTER id
; //新增业务号字段
update ito_delivery_apply INNER JOIN
(SELECT @rowno:=@rowno+1 as rowno,r.* from ito_delivery_apply r ,(select @rowno:=0) t ORDER BY r.create_date)A
on ito_delivery_apply.id =A.id
set ito_delivery_apply.apply_no = concat(“OP2_”,SUBSTRING(A.create_date, 1,4),SUBSTRING(A.create_date, 6,2),"_",SUBSTRING(A.rowno+10000000,2))
//更新业务号,思路也很简单,选择处理表新增个rowno,代表是第几个,然后,连自己,更新记录,拼接字段成一个业务号进行更新。
//注意排序,随时间递增的
生成业务号
然后重头戏来了,怎么生成业务号,最重要的是不能重复。当时想的也是很简单,
方案1.业务号加个unique约束,然后申请业务号的时候,查询最近的一次业务号,然后递增1然后插入。这个问题也贼大,你每一个插入操作,去数据库查询1次,数据量大了怎么办?行吧,也行,但是并发量也上不去的。
方案2.新建个序列表,(百度mysql的序列表),思路也很简单,弄个表,然后每次插入的时候,去查一下这个表的下个值,然后插入。但是问题来了,序列号何时清空。这个是每个月清空一次。那想想看,现在到了月初,该清序列表了,你怎么保证自己是第一个清序列的请求。
打个比方,
你来要序列号,你发现,今天是月初诶,我要去清下序列表,清好了,我美滋滋的去申请了个1,后来的兄弟不用谢,我帮你清好了。
第二个请求来要序列号,你发现,今天是月初诶,我要去清下序列表,清好了,我美滋滋的去申请了个1,后来的兄弟不用谢,我帮你清好了。
…
你会发现,月初的每个序列都是1,我去你大爷的。
那加个判断,今天是月初诶,我去申请下序列号,发现不是1诶,说明没人清过,那我去清。
然后这个时候又来请求了,发现今天是月初诶,我去申请下序列号,发现不是1,是2诶,说明没人清过,那我去清。
还是不行。
其实这种往往是配合定时任务,月初0点去清。
也挺蠢的。
换个方案。
方案3.那我用redis来拿序列号不行吗。redis不是有个autoincr吗。我用这个,思路是,用qt2_202012做个key去找redis要值,顺便+1.这样下个请求拿到的就不是我的值了,我还考虑到get and set不是原子操作(百度原子操作),这样我用redis的RedisAtomicLong ,我厉害吧,这样就能拿到正确的序列号了吧。
思路是没错的,但是写法要注意哦,我随便百度了一篇。https://blog.csdn.net/zuokaopuqingnian/article/details/85207280
//自增方法
public Long incr(String key, long liveTime) {
RedisAtomicLong entityIdCounter = new RedisAtomicLong(key, redisTemplate.getConnectionFactory());
Long increment = entityIdCounter.getAndIncrement();
//初始设置过期时间
if ((null == increment || increment.longValue() == 0) && liveTime > 0) {
//liveTime为秒数
entityIdCounter.expire(liveTime, TimeUnit.SECONDS);
}
return increment;
}
//生成批次号
private String getOrder() {
String order = "";
//incr初始值为0,一般需求初始值为1,所以进行判断0的操作
Long incr = this.incr("key" , 5);
if (incr == 0) {
//设置自增key key 5秒后过期, 过期时间的设定只在新增key时有效
incr = this.incr("key" , 5);
}
DecimalFormat df = new DecimalFormat("00000");
order = df.format(incr);
return order;
}
乍一看,没啥问题的样子,但是问题还是很多的,第一,RedisAtomicLong与设置过期时间不是原子操作,容易把key设为永不过期的。(没大问题)第二。
这个生成批次号的时候,当获取到incr为0的时候,这里问题很大哦,这里如果并发高的话,想想看,
第一个请求来申请的时候,发现值为0,然后不对啊,我要1啊,那我自增1.
第二个请求来申请的时候,发现值为1,对哦,我可以要1,那我完事了,你自增为2吧。
然后发现第二个请求比第一个快,他把值设置为2了,然后第一个请求去自增0为1的时候,其实是自增2为3,于是第一个请求就是3了。
可喜可贺,1丢失了。
用redis的原子自增,问题没问题的,但是写法要注意点的。就是当置为0的时候,改怎么写的问题。
还有就是redis的原子自增,有个问题是,步长(就是每次加多少设置不了)。跟set配合的时候进行初始化的时候,注意初始化的并发问题。
方案4.用lua脚本实现redis的cas。
这个思路跟3一样,不过他的赋值是lua来做的。先看demo吧。
package com.xy.ito.product.utils;
import com.google.common.collect.ImmutableList;
import com.xy.ito.product.config.MyRedisConfig;
import com.xy.microservice.common.utils.StringUtil;
import com.xy.microservice.common.utils.StringUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.stereotype.Component;
import java.io.Serializable;
/**
* @description: 构建的执行redis的rua脚本的工具类
* @author: wzy
* @create: 2020-12-25 17:23
**/
@Component
@Slf4j
public class RedisLuaUtil {
@Autowired
private RedisTemplate<String, Serializable> limitRedisTemplate;
@Autowired
private MyRedisConfig myRedisConfig;
//包装key
public String wrapKey(MyRedisConfig keyPrefix, String key){
if(keyPrefix==null || StringUtil.isEmptyString(keyPrefix.getCurr())){
return key;
}
return new StringBuilder(keyPrefix.getModule()).append(":").append(keyPrefix.getCurr()).append(":").append(key).toString();
}
private String buildTestScript() {
StringBuilder lua = new StringBuilder();
lua.append("local c");
lua.append("\nlocal old ");
lua.append("\nlocal new ");
lua.append("\nc = redis.call('EXISTS',KEYS[1])");
lua.append("\nif c and c==0 and tonumber(ARGV[1]) == nil then");
lua.append("\n redis.call('set',KEYS[1],tonumber(ARGV[2]))");
lua.append("\nreturn tonumber(ARGV[2]);");
lua.append("\nend");
lua.append("\nc = redis.call('get',KEYS[1])");
// 如果redis中值为原值,则设为新值
lua.append("\nif c and tonumber(c) == tonumber(ARGV[1]) then");
lua.append("\n redis.call('set',KEYS[1],tonumber(ARGV[2]))");
lua.append("\nreturn tonumber(ARGV[2]);");
lua.append("\nend");
// 执行计算器自加
lua.append("\nreturn ;");
return lua.toString();
}
/**
* @description 编写Redis cas脚本
*/
private String buildCompareAndSwapScript() {
StringBuilder lua = new StringBuilder();
lua.append("local c");
lua.append("\nlocal old ");
lua.append("\nlocal new ");
lua.append("\nc = redis.call('EXISTS',KEYS[1])");
lua.append("\nif c and c==0 and tonumber(ARGV[1]) == nil then");
lua.append("\n redis.call('set',KEYS[1],tonumber(ARGV[2]))");
lua.append("\nreturn tonumber(ARGV[2]);");
lua.append("\nend");
lua.append("\nc = redis.call('get',KEYS[1])");
// 如果redis中值为原值,则设为新值
lua.append("\nif c and tonumber(c) == tonumber(ARGV[1]) then");
lua.append("\n redis.call('set',KEYS[1],tonumber(ARGV[2]))");
lua.append("\nreturn tonumber(ARGV[2]);");
lua.append("\nend");
// 执行计算器自加
lua.append("\nreturn ;");
return lua.toString();
}
/**
* @description 编写Redis 自增脚本
*/
private String buildAutoIncrLuaScript() {
StringBuilder lua = new StringBuilder();
lua.append("local c");
lua.append("\nc = redis.call('get',KEYS[1])");
// 调用超过最大值,则直接返回
lua.append("\nif c then");
lua.append("\nc = redis.call('incr',KEYS[1])");
lua.append("\nelse");
// 执行计算器自加
lua.append("\nc = redis.call('set',KEYS[1],1)");
lua.append("\nend");
lua.append("\nreturn c;");
return lua.toString();
}
public Long test(String key,Long oldValue,Long newValue){
String luaScript = buildTestScript();
key=wrapKey(myRedisConfig,key);
ImmutableList<String> keys = ImmutableList.of( key);
RedisScript<Number> redisScript = new DefaultRedisScript<>(luaScript, Number.class);
Number count = limitRedisTemplate.execute(redisScript, keys, oldValue,newValue);
// if(count!=null && !"null".equals(count.toString())){
// log.info("当前缓存内key is {} 值为={}", key, count);
// }
return count==null?null:count.longValue();
}
public Long compareAndSwap(String key,Long oldValue,Long newValue){
String luaScript = buildCompareAndSwapScript();
key=wrapKey(myRedisConfig,key);
ImmutableList<String> keys = ImmutableList.of( key);
RedisScript<Number> redisScript = new DefaultRedisScript<>(luaScript, Number.class);
Number count = limitRedisTemplate.execute(redisScript, keys, oldValue,newValue);
log.info("当前缓存内key is {} 值为={}", key, count);
return count==null?null:count.longValue();
}
public Long getAndIncrement(String key){
String luaScript = buildAutoIncrLuaScript();
RedisScript<Number> redisScript = new DefaultRedisScript<>(luaScript, Number.class);
key=wrapKey(myRedisConfig,key);
ImmutableList<String> keys = ImmutableList.of( key);
Number count = limitRedisTemplate.execute(redisScript, keys,1);
log.info("当前缓存内key is {} for 值为={}", key, count);
return count.longValue();
}
}
这个是redis的lua的函数工具类,封装lua脚本的函数。里面主要有两个主要的方法,compareAndSwap,getAndIncrement。
getAndIncrement没啥用,就用来实现方案3的。主要还是看compareAndSwap。看名字也猜的出来,就是实现CAS(compare and swap)
https://blog.csdn.net/ln_6am/article/details/85642853
也是平平无奇的调用lua脚本,不过lua脚本的作用是,如果key不存在,而且old也是nil,则设置值为new。
如果key存在,且等于old值,则设置值为new。否则返回空。
@Override
public String getApplyNo() {
Date date = new Date();
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyyMM");
String now = simpleDateFormat.format(date);
String key="OP2_"+now;
Long applyNo =0L;
Long nextApplyNo=1L;
Long aLong = null;
while(aLong==null){
String applyNumber = redisUtil.get(key);
if(applyNumber==null){
aLong= redisLuaUtil.compareAndSwap(key,null,1L);
continue;
}else{
applyNo=Long.valueOf(applyNumber);
nextApplyNo=applyNo+1;
}
aLong = redisLuaUtil.compareAndSwap(key, applyNo, nextApplyNo);
}
return key+String.valueOf(aLong);
}
获取业务号的写法。
获取为空的话,可以从数据库中获取该月的id最大值,
这样的话,如果是新的月中redis的key被误删,会去取月中的id,或者是新增业务号,(就我这种情况,本来没有业务号的,redis肯定也没这个key的值的,那取数据库的最大id)
而每个月的话,你从数据库拿到的也是null,那也是从0开始
然后是测试,
@GetMapping("/f/test")
public Result test() {
String applyNo = itoDeliverApplyService.getApplyNo();
System.out.println("当前业务值为"+applyNo);
return Result.ok(applyNo);
}
我用jmeter去测试,开了1000个线程去跑这个接口,结果都是返回980个业务号,左右,我本以为这个是我写错了,但是我不知道哪里写错了,然后就去看请求,发现有的请求是返回Could not return the resource to the pool。我估摸着是线程一直没拿到,然后一直在自旋,导致连接池不够的原因。
调大连接池后,如愿的获取到了1000个业务号。
从例子里也可以看出。这个方法吃reids的连接数,这就是CAS的通病,容易自旋,耗费资源,ABA的问题倒是不在这里考虑。
这个写法比较适合一些并发量不高的了。
方案5.同事说,既然不需要顺序递增,你每个请求去那1000个值,然后取里面的一个,那这样只要不是每秒1000个以上请求,那基本也不会有问题,
这个好像跟阿里的分布式id写法差不多,但是就不考虑了,因为这里是每个月的刷新,你弄每秒,7位数不够用吧。