参与的项目中有一个业务场景,有两个消费者从kafka中拉取数据消费:
1、订单消费者:从订单队列中拉取订单数据,插入到mongodb,集合名称为order(主要包含id、orderNo、orderName、status)
2、订单状态消费者:从订单状态队列中拉取数据,更新order集合中的状态字段(status)
存在问题:
1、订单状态消费者消费依赖于订单消费者,如果订单消费者消费速度慢了,订单还没有插入到mongodb中,订单状态根据订单号orderNo去更新数据,则会找不到订单数据,导致订单状态更新失败
2、订单量比较大,一天千万级别,需要批量存储到mongodb,否则频繁操作数据库效率会很低
解决方案:
1、订单数据使用mongotemplate bulkOps批量插入(insert)mongo,订单状态数据使用update,如果没有找到订单数据,则将订单状态数据重新扔到kafka中稍后再消费
2、订单数据和订单状态数据都使用bulkOps批量插入或更新(upsert)到mongo,这样先消费订单数据或者先消费订单状态数据都没有问题
方案分析:
1、方案一是可行的,当时考虑kafka默认不支持延迟消息,如果更新订单状态的时候没有找到订单马上放到kafka中,可能还是找不到订单数据,当积压比较大的时候,可能会被重复消费很多次。kafka要支持延迟消息,比如未找到订单数据30秒后再消费,需要自己重新开发,觉得有点麻烦就没有采用。(rocketmq支持延迟消息支持比较好)
2、方案二也是可行的,但是会存在一个并发问题,即mongodb在同一时间upsert 订单号orderNo相同的订单数据和订单状态数据,并发量高了的情况会在mongodb中产生2条数据,这显然不是我们想要的,于是在order集合中将orderNo设置唯一索引。
那么在高并发下设置了唯一索引同时upsert相同的订单数据和订单状态数据会不会出现问题?答案是会的,会偶尔出现订单数据upsert或者订单状态数据upsert的时候报错,报唯一索引冲突。那怎么解决?在代码里面使用失败重试操作,当出现upsert错误的时候,尝试几秒后重新upsert。我项目里面使用的是guava-retrying。
所以我的整个解决思路大概如下:
列举几个里面的代码片段:
/**
* 定义retryer,指定轮询条件,及结束条件
*/
private Retryer<Boolean> retryer = RetryerBuilder.<Boolean>newBuilder()
.retryIfResult(Predicates.<Boolean>isNull())
.retryIfExceptionOfType(Exception.class)
.retryIfRuntimeException()
// 重试5次
.withStopStrategy(StopStrategies.stopAfterAttempt(5))
// 等待100毫秒
.withWaitStrategy(WaitStrategies.fixedWait(100L, TimeUnit.MILLISECONDS))
.build();
/**
* 批量插入更新记录Record
*
* @param recordList
*/
@Override
public void upsertReceiveCollection(List<Record> recordList) {
List<Pair<Query, Update>> updateList = new ArrayList<>(recordList.size());
recordList.forEach(record -> {
Query query = new Query(new
Criteria("orderNo").is(record.getOrderNo()));
Update update = new Update();
update.set("status", record.getStatus());
Pair<Query, Update> updatePair = Pair.of(query, update);
updateList.add(updatePair);
});
// 执行记录upsert操作
doUpsertReceive(updateList);
}
/**
* 执行记录upsert操作
*
* @param updateList
*/
private void doUpsertReceive(List<Pair<Query, Update>> updateList) {
BulkOperations operations = mongoTemplate.bulkOps(BulkOperations.BulkMode.UNORDERED, COLLECTION_NAME);
//callable是定义具体的业务操作,并返回该操作结果的返回值
Callable<Boolean> callable = new Callable<Boolean>() {
@Override
public Boolean call() throws Exception {
operations.upsert(updateList);
BulkWriteResult result = operations.execute();
log.info("upsertReceive result, insertCount: {}, matchCount: {}, modifyCount:{}",
result.getInsertedCount(),
result.getMatchedCount(), result.getModifiedCount());
return true;
}
};
try {
retryer.call(callable);
} catch (RetryException e) {
log.error("receive retry err: {}", e);
} catch (Exception e) {
log.error("receive err: {}", e);
}
}
总结:方案二也不是太严谨,非常极端情况下可能存在多次重试还是报唯一索引冲突情况,由于我的项目中的订单数据允许有一点点误差,几千万数据的情况下有几十条误差也影响不大。
改进方案:
1、可以将多次重试失败的数据入库,使用定时任务处理这些异常数据;
2、如果项目使用的是rocketmq,我会优先使用rocketmq延迟队列处理这种异常数据。