报错日志
error:Cannot index a document due to seq_no+primary_term conflict; nested exception is [test_drive_info/q3NTzXrORpmbFc2sR2MN-w][[test_drive_info][2]] ElasticsearchStatusException[Elasticsearch exception [type=version_conflict_engine_exception, reason=[1817623245505155073_order]: version conflict, required seqNo [6173], primary term [30]. current document has seqNo [6174] and primary term [30]]]
org.springframework.dao.OptimisticLockingFailureException: Cannot index a document due to seq_no+primary_term conflict; nested exception is [test_drive_info/q3NTzXrORpmbFc2sR2MN-w][[test_drive_info][2]] ElasticsearchStatusException[Elasticsearch exception [type=version_conflict_engine_exception, reason=[1817623245505155073_order]: version conflict, required seqNo [6173], primary term [30]. current document has seqNo [6174] and primary term [30]]]
问题描述
从上面的报错日志中可以看到一些关键字 seq_no+primary_term 这就是ES的版本号字段,我们不难得出结论,其实就是ES并发更新报错了,
seq_no:记录分片上发生操作的顺序,配合primary_term一起解决分片数据同步的一致性问题
primary_term:primary_term也和seq_no都是一个整数,每当Primary Shard发生重新分配时,比如重启,Primary选举等,primary_term会递增1
这两个关键字的相关信息可以了解下下面这篇文章:
Elasticsearch中version与seqNo+primaryTerm分析
以下是更新的代码:
OrderInfoDocument orderInfoDocumentNew = createOrderInfoDocument(orderInfoDocument);
UpdateQuery updateQuery = UpdateQuery.builder(orderInfoDocument.getId())
.withDocument(Document.from(JSON.parseObject(JSON.toJSONString(orderInfoDocumentNew))))
.withIfSeqNo((int) document.getSeqNo())
.withIfPrimaryTerm((int) document.getPrimaryTerm())
.withRefresh(UpdateQuery.Refresh.True)
.build();
原因分析:
系统架构: MySQL + DTS + RocketMQ + Elasticsearch
同步的数据主要是订单相关数据,因为系统现在很庞大,同一个操作可能会对订单更新多次,所以就导致了并发更新的问题
解决方案:
方案一:retry策略
Elasticsearch再次获取document数据和最新版本号,成功就更新,失败再试;比如,设置尝试5次更新,retry_on_conflict=5,代表着最大更新5次,5次后不再尝试写入并且抛出异常。需要注意的是,retry策略在增量操作的无关顺序的场景中更适用,比如计数操作,数据写入的先后顺序对最终结果关系不大。其它的一些场景,比如库存的变化,订单的状态,直接更新为指定数值的,retry策略都不适用。除非可以在业务中将数据写入由顺序有关转化成顺序无关,才可以使用retry策略。因为我们当前主要是订单相关数据,跟顺序相关,所以不满足
方案二:延时写入
直接回调写入会造成更多的版本冲突发生,虽然可以解决问题但依然存在风险。结合对Elasticsearch的写入机制的深入理解,尝试跳出Elasticsearch本身,在业务侧解决Elasticsearch频繁更新同一document时出现的版本冲突异常。延时写入,将写入请求延迟处理。
一、使用Redis作为中间缓存,将一段时间内的同一document的写入请求缓存,key为document ID,value为变更字段的k-v格式。在这段时间内,后写入的覆盖先写入的,也就是将写入请求合并,只更新一次。
二、将写入失败的数据发送到rocketmq的队列中,按照document ID分区,做延时写入,顺序消费。
优点:
实现顺序写入,写入频次降低,大大降低并发冲突的发生。
将数据存储在缓存和消息队列,保证数据不会丢失。
缺点:
在本就存在显示延迟的基础上,加大了延迟,具体的延迟指标需要结合集群规格,数据量级等综合考虑。
容易造成消息堆积,如果消费出现异常需要重新消费。
方案三:回调写入
如果遇到status=409的异常直接回调当前写入方法重新写入,直到写入成功为止。以java代码为例,捕获到的异常为ElasticsearchStatusException。本以为当前方案会对Elasticsearch集群造成一定不良影响,但经过一周的观察,Elasticsearch集群的监控指标(包含GC频次)并无异常,但不能保证在更大的数据量级中不会发生其它异常。
优点:
开发成本低,可以解决retry策略无法解决的顺序写入的问题,回调写入方法即可。
缺点:
尝试写入的次数很高,且无法预估在大数据量级下中的直接回调写入会对集群性能造成何种影响,即使对集群进行扩容升级,也会变相提高成本。线程消亡或者等待队列溢出会造成数据丢失。
如果当前写入执行了多次的回调写入,那么势必会影响分配到当前线程的其它写入,造成数据延迟,当然也可以通过加大线程池、升级服务器的方式提高性能,但毕竟是治标不治本的方法,一旦产生业务侧不可接受的延迟依然很麻烦。
方案:外部版本号version + Redisson + RocketMQ
因为ES对于更新确实不是很友好,从ES本身无法更好的解决,那如果跳出ES本身从其他的维度是否能寻找到更好的解决方案
整体思路:
1、通过数据库的version字段判断,低版本的数据不再进行更新(保证顺序更新,数据不会覆写)
2、通过Redisson对某个订单id进行上锁 ,在并发的时候,如果没有没有拿到锁的消息重新入队
3、如果说在更新时还是存在并发更新的问题,那么还是将消息重新入队,从seq_no的原理来看,可能还是会存在这样的情况
下面就是根据以上思路实现的代码:
@Component
@Slf4j
public class OrderInfoDocumentStrategy extends DocumentStrategy {
@Autowired
private ElasticsearchOperations elasticsearchOperations;
@Autowired
private OrderInfoDocumentService orderInfoDocumentService;
@Override
public boolean filter(DataModel canalDataModel) {
return canalDataModel.getDatabase().equalsIgnoreCase("sale_manage")
&& canalDataModel.getTable().equalsIgnoreCase("order_info");
}
@Override
protected void insert(DataModel canalDataModel) {
orderInfoDocumentService.orderInfoSyncToEs(canalDataModel);
}
@Override
protected void update(DataModel canalDataModel) {
//todo
}
@Override
protected void delete(DataModel canalDataModel) {
//todo
}
}
public void orderInfoSyncToEs( DataModel canalDataModel) {
Map<String, Object> dataMap = Optional.ofNullable(canalDataModel.getData()).orElse(new ArrayList<>()).stream().findFirst().orElseGet(null);
if (dataMap == null){
return;
}
OrderInfoDocument orderInfoDocument = JSON.parseObject(JSON.toJSONString(dataMap), OrderInfoDocument.class);
//并发获取最新的es文档版本号
Document document = elasticsearchOperations.get(orderInfoDocument.getId(), Document.class,
IndexCoordinates.of(DocumentIndexConstants.SALE_ORDER_INFO));
OrderInfoDocument orderInfoDocumentOld = JSON.parseObject(JSON.toJSONString(document), OrderInfoDocument.class);
// 旧的为空就创建
if (Objects.isNull(orderInfoDocumentOld)){
elasticsearchOperations.save(createOrderInfoDocument(orderInfoDocument));
return;
}
// 订单主信息更新,比对订单版本号,版本号小于及相等忽略
if (orderInfoDocument.getVersion() < orderInfoDocumentOld.getVersion()) {
log.info("{}<={}数据版本信息过低,忽略, orderId:{}", orderInfoDocument.getVersion(), orderInfoDocumentOld.getVersion(), orderInfoDocument.getId());
return;
}
// 订单粒度的锁,解决es并发报错问题
String key = ORDER_DATA_ES_SYNC_LOCK+orderInfoDocument.getId();
boolean lock = saleDashboardDistributedLocker.tryLock(key, TimeUnit.SECONDS, 3, 3);
if (!lock){
log.warn("updateOrderIndexCommon fail orderId:{},orderNo:{}",orderInfoDocument.getId(),orderInfoDocument.getOrderNo());
// 这里重新入队队尾
downLoadCenterProducer.sendSyncMQ(canalDataModel);
}
try {
OrderInfoDocument orderInfoDocumentNew = createOrderInfoDocument(orderInfoDocument);
UpdateQuery updateQuery = UpdateQuery.builder(orderInfoDocument.getId())
.withDocument(Document.from(JSON.parseObject(JSON.toJSONString(orderInfoDocumentNew))))
.withIfSeqNo((int) document.getSeqNo())
.withIfPrimaryTerm((int) document.getPrimaryTerm())
.withRefresh(UpdateQuery.Refresh.True)
.build();
elasticsearchOperations.update(updateQuery, IndexCoordinates.of(DocumentIndexConstants.SALE_ORDER_INFO));
} catch (Exception e) {
// 判断还是并发错误的话 直接重新入队尾
downLoadCenterProducer.sendSyncMQ(canalDataModel);
// 其他错误抛出异常 走mq重试逻辑
throw new BusinessException(BusinessExceptionEnum.CONCURRENT_ERROR);
} finally {
saleDashboardDistributedLocker.unlock(key);
}
}