背景:公司自主产品,想在系统内嵌套防伪码导出和验证功能,防止产品被假冒
阶段--目前还处于起步阶段,自主品牌名气也还不太大,所以对于防伪码量级上要求不大,在10万级别上
1.产品内容
产品的需求可以分成2部分:
- 防伪码的批量导出
- 单次导出防伪码数量预估在万级左右
- 导出的防伪码必须是全系统唯一
- 防伪码校验
- 防伪码格式校验前端做,减少http请求消耗
- 单账号次数拦截 - 用手机号做请求次数限制
- 防伪码 首次校验记录时间,之后查询返回第一次校验时间和手机号(这里可以关联系统中)
2.初步设计
第一版:
- DB中预先生成100万条数据
- 需要分配的时候从里面获取
然后二话不错开整,出设计文档,开发文档:
- DB预先生成------增加定时任务定时检查DB中未使用的防伪码储备是否低于阈值40万条,少于就开始创建创建直到储备量到100万
- 分配防伪码-------判断需要分配的数量,先判断是否在 0~ 100万之间 ,然后与数据库储备进行比较
- 需求量小于等于储备量:随机更新防伪码状态为占用,并且加上本次操作的标记----触发一次生成校验
- 需求量大于储备量:触发生成防伪码接口,直到生成100万在释放接口
这一套操作下来的结果如何呢,当单次分配防伪码量大的时候,返回结果极慢,而且这个接口我还做了分布式锁,单次只能有一个请求能分配防伪码,20万数据平均时间在13秒左右,有时候还不止
当碰到这种接口请求时间很长的时候,处理方案有很多
- 通过界面优化加异步处理:点击分配防伪码按钮之后,后端同步返回一个触发成功的提示,然后异步创建任务不断分配,并且记录返回给前端已经分配的数量,让前端能够以进度条完成情况的形式给用户
- 通过对接口进行优化,提高返回速度,保证在同步情况下返回时间能缩短到用户能忍受的2~3秒,当然在难度和挑战性上这种方式会更高
第二版
很显然,男程序员必须迎男而上,gang一波,看能够将这个方法做优化,需求我们前面已经介绍
先分析第一版为什么耗时的原因:
- 生成20万个防伪码:数据库更新20万条数据,这其实是一个耗时的操作,平均执行下来需要7~8秒
- 将分配的20万防伪码查询出来,20万条数据
100*25ms ,大约在2~3秒内
- 防伪码整理到excel中,也是需要3~5秒左右
- 上传到oss上也需要1~2秒,整体算下来就是15秒左右
既然我们已经查到出问题的点,那怎么针对呢:
- 更新20万的防伪码既然如此耗时,那为什么不将这个时间 与 主要流程 异步
- DB中生成防伪码是一个定时任务
- DB中更新需要的防伪码 也是一个任务(该功能必须提前锁定哪一批防伪码,这里就要使用到redis缓存做一个能快速存取的中间件)
- 查询20万数据数据耗时,优化方向:直接通过redis获取,速度能在1~2秒之内
- 防伪码整理到excel 03版本中,每个sheet也最多能保存6万出头,可以通过fork/join思想,6万数据一组,每个sheet单独操作
- 上传oss上可以作成异步,从接口返回结果到去oss上点击下载文件,这中间足够文件上传好了
-------------------------------------------------------------------------------
话不多说--设计思路出来了,写开发文档.......进入开发。。。。
来了,来了,他来了!!!!
测试的时候上限值设置成30万:代码如下
/**
* 防伪码默认最多预先生成 30万
*/
private static final int MAX_NEW_SECURITY_CODES = 300000;
/**
* 防伪码库存中少于 10万 触发生成操作
*/
private static final int CAPACITY_NEW_SECURITY_CODES = 100000;
/**
* 防伪码在redis中默认分成100个key存储
*/
private static final int MAX_SECURITY_CODES_KEYS_SPLIT_NUM = 100;
/**
* db单次入库数据步长
*/
private static final int STEP = 2000;
private static final String SECURITY_CODE_CREATE_LOCK = "security_code_create_lock";
private static final String SECURITY_CODE_CACHE = "security_code_cache_%d";
@Override
@Transactional
public TSecurityCodeInfoDO createSecurityCode(SecurityCodeRequest request) {
Long needCreateCodeNum = request.getCodeNum();
//参数校验
Assert.isTrue(request.getTitle().length() > 0 && request.getTitle().length() < 31, "防伪码标题长度异常");
Assert.isTrue(needCreateCodeNum > 0 && needCreateCodeNum <= MAX_NEW_SECURITY_CODES, "防伪码生成数量异常(单次最多导出 "+ MAX_NEW_SECURITY_CODES +" 条)");
//判断redis 中存储的防伪码数量是否足够
if (!checkCacheCodeNum(needCreateCodeNum)) {
//异步触发生成防伪码操作
threadPoolTaskExecutor.execute(() -> {
createSecurityCodesTask(true);
});
throw new FaceServiceException(10000L, "系统正在生成防伪码,请3分钟后重试");
}
ArrayList<List<String>> codeLists = new ArrayList<>();
//循环redis取
for (int i = 0; i < MAX_SECURITY_CODES_KEYS_SPLIT_NUM; i++) {
String cacheKey = String.format(SECURITY_CODE_CACHE, i);
needCreateCodeNum = getSecurityCodesByKey(codeLists, cacheKey, needCreateCodeNum);
if (needCreateCodeNum <= 0) {
break;
}
}
String ossUrl = createExcelAndUploadOss(request.getTitle(), codeLists);
//4.将防伪码创建信息入库到security_code_info表中
TSecurityCodeInfoDO securityCodeInfoDO = new TSecurityCodeInfoDO();
securityCodeInfoDO.setTitle(request.getTitle());
securityCodeInfoDO.setCodeNum(request.getCodeNum());
securityCodeInfoDO.setOssFileUrl(ossUrl);
securityCodeInfoDO.setOperateUserId(1L);
securityCodeInfoDO.setOperateUserName("哈哈");
securityCodeInfoDO.setCreateTime(LocalDateTime.now());
securityCodeInfoMapper.insertSelective(securityCodeInfoDO);
//将分配的防伪码状态变为 1
codeLists.forEach(codeList -> {
// threadPoolTaskExecutor.execute(() -> {
//批量更新防伪码状态与对应的code
securityCodeMapper.batchUpdate(codeList, securityCodeInfoDO.getId(), SecurityCodeStatusEnum.EXPORT_CODE.getCode());
// });
});
return securityCodeInfoDO;
}
/**
* 将防伪码打印到excel中,上传到oss上
* @param sheetTitle
* @param codeLists
* @return
*/
private String createExcelAndUploadOss(String sheetTitle, ArrayList<List<String>> codeLists){
List<String> headerList = new ArrayList<>();
headerList.add(0, "序号");
headerList.add(1, "防伪码");
String ossUrl = null;
try (InputStream is = ExcelUtil.createExcel(sheetTitle, headerList, codeLists)) {
String key = "dev/security/code/" + DigestUtils.md5Hex(LocalDateTime.now().toString()) + ".xls";
ossFaceClientManager.uploadFile(key, is);
ossUrl = String.format(PIC_OSS_NGINX_IP, key);
} catch (Exception e) {
LoggerUtil.error(logger, e, "上传oss异常");
}
return ossUrl;
}
@Override
public void updateSecurityCode(SecurityCodeRequest request) {
Assert.isTrue(request.getTitle().length() > 0 && request.getTitle().length() < 31, "防伪码标题长度异常");
TSecurityCodeInfoDO securityCodeInfoDO = new TSecurityCodeInfoDO();
securityCodeInfoDO.setId(request.getId());
securityCodeInfoDO.setTitle(request.getTitle());
Assert.isTrue(securityCodeInfoMapper.updateByPrimaryKeySelective(securityCodeInfoDO) > 0, "更新标题失败");
}
@Override
public int getTotal() {
return securityCodeInfoMapper.countTotal();
}
@Override
public List<TSecurityCodeInfoDO> getSecurityCodeInfos(PageQuery query) {
return securityCodeInfoMapper.selectPage(query);
}
@Override
// @Scheduled(cron = "0 0/30 * * * ?") 半小时触发一次
public void createSecurityCodesTask(Boolean autoFlag) {
String lock = (String) redisCacheManager.get(SECURITY_CODE_CREATE_LOCK);
if (StringUtils.isNotBlank(lock)) {
logger.info("已经有生成程序在跑了,不用重复发起");
return;
}
//3分钟内允许允许一次,一次只能有一个在运行
redisCacheManager.set(SECURITY_CODE_CREATE_LOCK, "1", 60 * 3);
Long count = securityCodeMapper.countStatus(SecurityCodeStatusEnum.NEW_CODE.getCode());
Long cacheCodeNum = countCacheCodes();
Long startTime = System.currentTimeMillis();
// 检查缓存数量
// synchronizeCodeCache(STEP, count, cacheCodeNum);
Long partFinishTime = System.currentTimeMillis();
if (!Objects.equals(cacheCodeNum, count)){
System.out.println("【时间统计】- 同步DB数据到缓存中花费时间: " + (partFinishTime - startTime));
}
// 请求来源 && 阈值
if ( !autoFlag && count >= CAPACITY_NEW_SECURITY_CODES) {
return;
}
Long needCreateCounts = MAX_NEW_SECURITY_CODES - count;
//2.数据生成
int insertNum = Math.toIntExact(needCreateCounts);
ArrayList<HashSet<String>> codeSets = new ArrayList<>();
while (insertNum > 0) {
if (insertNum >= STEP) {
codeSets.add(getRandomCodes(STEP));
} else {
codeSets.add(getRandomCodes(insertNum));
}
insertNum -= STEP;
}
System.out.println("【时间统计】- 生成"+ insertNum + " 条数据 花费时间: " + (System.currentTimeMillis() - partFinishTime));
//3.数据入DB, 然后存缓存
for (HashSet<String> codeSet : codeSets) {
Long beforeLastId = securityCodeMapper.selectLastNewCode(SecurityCodeStatusEnum.NEW_CODE.getCode());
securityCodeMapper.batchInsert(codeSet, null);
//查询最新的那条记录的id(sql优化)
List<String> codesTemp = securityCodeMapper.selectCodesFromOldLastId(SecurityCodeStatusEnum.NEW_CODE.getCode(), beforeLastId, STEP);
redisCacheManager.redisStringTemplate.opsForList().rightPushAll(getEmptySecurityCodeKey(), codesTemp);
}
System.out.println("【时间统计】- 定时任务总 花费时间: " + (System.currentTimeMillis() - startTime));
}
@Override
public void cleanSecurityCodeCache() {
for (int i = 0; i < MAX_SECURITY_CODES_KEYS_SPLIT_NUM; i++) {
String cacheKey = String.format(SECURITY_CODE_CACHE, i);
if (redisCacheManager.redisTemplate.opsForList().size(cacheKey) == 0) {
continue;
}
redisCacheManager.remove(cacheKey);
}
}
/**
* 获取缓存中防伪码数量
* @return
*/
private Long countCacheCodes() {
Long cacheCodeNum = 0L;
for (int i = 0; i < MAX_SECURITY_CODES_KEYS_SPLIT_NUM; i++) {
String cacheKey = String.format(SECURITY_CODE_CACHE, i);
cacheCodeNum += redisCacheManager.redisTemplate.opsForList().size(cacheKey);
}
return cacheCodeNum;
}
/**
* 判断 缓存中 防伪码数量是否足够
* @param needCreateCodeNum
* @return true 表示足够
*/
private Boolean checkCacheCodeNum(Long needCreateCodeNum) {
Long cacheCodeNum = 0L;
for (int i = 0; i < MAX_SECURITY_CODES_KEYS_SPLIT_NUM; i++) {
String cacheKey = String.format(SECURITY_CODE_CACHE, i);
cacheCodeNum += redisCacheManager.redisTemplate.opsForList().size(cacheKey);
if (cacheCodeNum >= needCreateCodeNum) {
return true;
}
}
return false;
}
/**
* 查询存防伪码最少的list的key返回
*
* @return
*/
private String getEmptySecurityCodeKey() {
String minNumCacheKey = String.format(SECURITY_CODE_CACHE, 0);
if (redisCacheManager.redisTemplate.opsForList().size(minNumCacheKey) == 0) {
return minNumCacheKey;
}
for (int i = 1; i < MAX_SECURITY_CODES_KEYS_SPLIT_NUM; i++) {
String cacheKey = String.format(SECURITY_CODE_CACHE, i);
if (redisCacheManager.redisTemplate.opsForList().size(cacheKey) == 0) {
return cacheKey;
}
if (redisCacheManager.redisTemplate.opsForList().size(cacheKey) <
redisCacheManager.redisTemplate.opsForList().size(minNumCacheKey)) {
minNumCacheKey = cacheKey;
}
}
return minNumCacheKey;
}
/**
* 从传入的key中取出 需要的数量为 needCodeNum 的数据,如果不够,返回不够多少
*
* @param codeLists
* @param key
* @param needCodeNum
* @return
*/
private long getSecurityCodesByKey(ArrayList<List<String>> codeLists, String key, Long needCodeNum) {
Long size = redisCacheManager.redisTemplate.opsForList().size(key);
if (size == null || size == 0) {
return needCodeNum;
}
//从key对应的组中查询出需要数量的needCodeNum
Long searchSize = 0L;
long nextNeedSearchNum = 0;
if (size <= needCodeNum) {
searchSize = -1L;
nextNeedSearchNum = needCodeNum - size;
} else {
searchSize = needCodeNum;
}
List<String> codeCache = redisCacheManager.redisStringTemplate.opsForList().range(key, 0, searchSize - 1);
if (searchSize > 0) {
redisCacheManager.redisStringTemplate.opsForList().trim(key, searchSize, -1);
} else {
redisCacheManager.remove(key);
}
codeLists.add(codeCache);
return nextNeedSearchNum;
}
/**
* 随机生成16位数值
*
* @param codeNum
* @return
*/
private HashSet<String> getRandomCodes(int codeNum) {
HashSet<String> codes = new HashSet<String>(Math.toIntExact(codeNum));
IntStream.rangeClosed(1, codeNum).forEach(value -> {
codes.add(new StringBuffer()
.append(leftPad(RandomUtils.nextInt(0, 99999999), 8, '0'))
.append(leftPad(RandomUtils.nextInt(0, 99999999), 8, '0')).toString());
});
return codes;
}
/**
* 向左补全数据
*
* @param num
* @param maxLen
* @param filledChar
* @return
*/
private static String leftPad(int num, final int maxLen, char filledChar) {
StringBuffer sb = new StringBuffer();
String str = String.valueOf(num);
for (int i = str.length(); i < maxLen; i++) {
sb.append(filledChar);
}
return sb.append(str).toString();
}
/**
* 同步:redis缓存中的数量与 DB
* @param step 每次查询db的步长
* @param count 数据库中记录数量
* @param cacheCodeNum 缓存中记录数量
*/
private void synchronizeCodeCache(Integer step, Long count, Long cacheCodeNum){
if (count != cacheCodeNum) {
Integer pageNo = 0;
while (count > 0) {
List<Object> dbCodes = securityCodeMapper.selectCodesPage(SecurityCodeStatusEnum.NEW_CODE.getCode(), pageNo, step);
String cacheKey = String.format(SECURITY_CODE_CACHE, pageNo);
redisCacheManager.redisTemplate.opsForList().rightPushAll(cacheKey, dbCodes);
count -= step;
pageNo++;
}
}
}
enum SecurityCodeStatusEnum {
NEW_CODE(0, "新防伪码"),
EXPORT_CODE(1, "已经导出的防伪码"),
CHECKED_CODE(2,"被检验过的防伪码");
private int code;
private String msg;
SecurityCodeStatusEnum(int code, String msg) {
this.code = code;
this.msg = msg;
}
public int getCode() {
return code;
}
public String getMsg() {
return msg;
}
}