1. 问题描述
接口介绍:为实现某个功能。有个批量入库接口支持批量数据入库。接口功能:每条数据要是在库中则更新原有数据。要是不在库中则插入。接口维护一属性updateVerstion,该属性要求根据接收的数据生成单调递增的自增ID(要求为数字)。该接口请求量较大,需要考虑性能问题。。。
问题:每次接收批量数据入库,updateVerstion属性生成方式MongoDB插入时间戳精确到毫秒级别(14位时间戳)。在测试服务器测试发现每批入库的数据大约有十几个,二十几 updateVerstion值一样。这就说明,接口批量入库在一毫秒内能够入库十几条数据。
以下是当时入库时的设计代码(简写版本):
// 1. 将对象转换为Document
private Document convertToBson(LayoutFile layoutFile) {
Document newdoc = new Document();
newdoc.put("fileId", layoutFile.getFileId());
// 略过每个属性
return new Document("$set", newdoc)
.append("$setOnInsert", new
Document("createtime",layoutFile.getCreatetime())); //插入数据时生成的creatime
.append("$currentDate",new BasicDBObject("updateVer",true));//mongo维护的updateVer
}
// 2. 入库代码
UpdateOptions updateOptions = new UpdateOptions().upsert(true);
List<UpdateOneModel<LayoutFile>> updates = new ArrayList<UpdateOneModel<LayoutFile>>();
for (LayoutFile layoutFile : layoutFiles) {
Bson filter = new Document("fileId", layoutFile.getFileId());
if (log.isDebugEnabled()) {
log.info("filedId:" + layoutFile.getFileId());
}
UpdateOneModel<LayoutFile> updateOneModel = new UpdateOneModel(filter, convertToBson(layoutFile), updateOptions);
updates.add(updateOneModel);
}
try {
bulkWriteResult = collection.bulkWrite(session, updates, new BulkWriteOptions().ordered(false));
} catch (Exception e) {
log.error("insetLayoutFileList error message {}", e.getMessage(), e);
throw e;
}
该方式是依靠MongoDB入库时间生成的updateVerstion。避免了在分布式的情况下生成重复的版本。但是由于性能的原因,导致updateVerstion有重复的,每毫秒内可以支持入库好几十条。
解决方案:根据业务场景选择了方案。
(1) updateVersion是有14位数字组成前八位是当前日期例如(20190217)后六位是redis自增主键(key),当主键不足六位时,用零在前面补足六位。
(2)Key 设置过期时间,每天凌晨自动过期。
(3)根据某个属性分类(例如本次开发的分库,每个库单独使用一个单调递增的主键 如内网,外网等)
实现方式:
在上述代码中去掉:
.append("$currentDate",new BasicDBObject("updateVer",true));//mongo维护的updateVe
构建Docment的时候调用jedisUtil.getUpdateVerson(key) 生成为了唯一的单调递增的主键。
@Slf4j
@Component
public class JedisUtil {
@Resource
private JedisPool jedisPool;
private final static SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMMdd");
public String getUpdateVerson(String key) {
String updateversion = "";
try {
long incr = setIncr(key, Integer.valueOf(DateUtil.getSecsToEndOfCurrentDay().toString()));
updateversion = getUPdateVersion(incr);
} catch (IOException e) {
e.printStackTrace();
log.error("getUpdateVerson error IOException", e.getMessage());
} catch (ParseException e) {
log.error("getUpdateVerson error ParseException", e.getMessage());
}
return updateversion;
}
/**
* 序号时当前时间+四位当掉递增数字(数字不足6位用零筹齐)
*
* @param incr
* @return
*/
private String getUPdateVersion(long incr) {
String curDate = dateFormat.format(new Date());
int length = String.valueOf(incr).length();
String prefix = "";
for (int j = 0; j < 6 - length; j++) {
prefix += "0";
}
return curDate + prefix + incr;
}
/**
* 对某个键的值自增
*
* @param key 键
* @param cacheSeconds 超时时间,0为不超时
* @return
*/
private long setIncr(String key, int cacheSeconds) throws IOException {
long result = 0;
XyJedis jedis = null;
try {
jedis = jedisPool.getResource();
// 如果 key 不存在,那么 key 的值会先被初始化为 0 ,然后再执行 INCR 操作,且将key的有效时间设置为长期有效.
result = jedis.incr(key);
if (cacheSeconds != 0 || "1".equals(result)) {
jedis.expire(key, cacheSeconds);
}
log.debug("setIncr " + key + " = " + result);
} catch (Exception e) {
log.error("setIncr Errot" + key + "result", e.getMessage());
} finally {
jedis.close();
}
return result;
}
}
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
/**
* 日期检测工具
* 判断参数的格式是否为“yyyyMMddHHmm”格式的合法日期字符串
*/
public class DateUtil {
public static boolean isValidDate(String str) {
boolean convertSuccess = true;
SimpleDateFormat format = new SimpleDateFormat("yyyyMMddHHmmss");
try {
format.setLenient(false);
format.parse(str);
} catch (ParseException e) {
// 如果throw java.text.ParseException或者NullPointerException,就说明格式不对
convertSuccess = false;
}
return convertSuccess;
}
/**
* 获取第二天凌晨0点毫秒数
* @return
*/
public static Date nextDayFirstDate() throws ParseException {
Calendar cal = Calendar.getInstance();
cal.setTime(new Date());
cal.add(Calendar.DAY_OF_YEAR, 1);
cal.set(Calendar.HOUR_OF_DAY, 00);
cal.set(Calendar.MINUTE, 0);
cal.set(Calendar.SECOND, 0);
return cal.getTime();
}
/**
* 获取当前时间到明天凌晨0点相差秒数
* @return
*/
public static Long getSecsToEndOfCurrentDay() throws ParseException {
Long secsOfNextDay = nextDayFirstDate().getTime();
//将当前时间转为毫秒数
Long secsOfCurrentTime = new Date().getTime();
//将时间转为秒数
Long distance = (secsOfNextDay - secsOfCurrentTime)/1000;
if (distance > 0 && distance != null){
return distance;
}
return new Long(0);
}
}
通过这样updateVersion 就可以生成了。
分析/总结:
1. 其中是利用redis 单调递增属性,规避分布式的情况下生成的重复的ID主键的问题。
2. 设置日期(年月日)为开始updateVersion,以及数据凌晨过期。
为了应对Redis主键万一某天撑爆。或者是Redis宏碁的情况下主键数据错误导致大批量数据updateVersion重复。每天key过期使得数据可靠性提升。而且updateVersion前八位都是入库的当前时间有利于数据帅选。
预留六位redis自增主键也有一定的风险。
(1)风险在于当每天某个库入库条件大于一百万时,updateVersion 会超过14位数的限制,导致出问题。
(2)分布式部署的时候,当有其中两个服务节点时间不一致的情况下,会造成生成的主键不一致。比如说,有AB两个服务节点。A节点时间正常,B节点慢一分钟,redis服务器时间正常。当凌晨的时候A节点主键为凌晨之后的时间+ redis 001的自增主键。由于B节点还没有到凌晨时间,使用的redis自增主键为 002 ,时间前缀还是前一天的时间,这就导致了生成的主键有重复的风险。
解决办法:(1)上线是明确服务器时间,跟互联网时间保持一致。(有夸区域跨国的不太适用,因为时区不一样)
(2) 优化主键设计。比如将redis key设置不过期,或将设置过期时间设置的长一些,按月。将key提升到10位数字。这样减少服务结点数据不同步带来的麻烦
3. 性能损耗点。比量入库的性能损耗点之一就是每次构建入库信息时,每条数据需要需要设置过期时间秒数。
扩展:
redis生成自增主键还有另一种方式实现,不过由于一些原因没有使用。
一下是简洁版的实现方式(ps : 以下代码参照别的网上资源改写):
/**
*自增主键工具
*/
public class IncUtis {
@Autowired
RedisTemplate<String, Object> redisTemplate;
public String getInceId() {
SimpleDateFormat sdf=new SimpleDateFormat("yyyyMMdd");
Date date=new Date();
String formatDate=sdf.format(date);
String key=formatDate+"key";
Long incr = getIncr(key, getCurrent2TodayEndMillisTime());
if(incr==0) {
incr = getIncr(key, getCurrent2TodayEndMillisTime());//从000001开始
}
DecimalFormat df=new DecimalFormat("000000");//序列号
return formatDate+df.format(incr);
}
public Long getIncr(String key, long liveTime) {
RedisAtomicLong entityIdCounter = new RedisAtomicLong(key, redisTemplate.getConnectionFactory());
Long increment = entityIdCounter.getAndIncrement();
if ((null == increment || increment.longValue() == 0) && liveTime > 0) {//初始设置过期时间
entityIdCounter.expire(liveTime, TimeUnit.MILLISECONDS);//单位毫秒
}
return increment;
}
//现在到今天结束的毫秒数
public Long getCurrent2TodayEndMillisTime() {
Calendar todayEnd = Calendar.getInstance();
// Calendar.HOUR 12小时制
// HOUR_OF_DAY 24小时制
todayEnd.set(Calendar.HOUR_OF_DAY, 23);
todayEnd.set(Calendar.MINUTE, 59);
todayEnd.set(Calendar.SECOND, 59);
todayEnd.set(Calendar.MILLISECOND, 999);
return todayEnd.getTimeInMillis()-new Date().getTime();
}
}
使用代码时需要添加pom.xml 依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<version>2.1.3.RELEASE</version>
</dependency>
1. RedisAtomicLong 实现自动递增。RedisAtomicLong类使用起来更加简洁方便。(性能方便未测试过,不知道哪个方式更加好。)