背景:
下游平臺透過訊息佇列上報監控訊息,但是訊息量很大,在三分鐘左右可以達到百萬級別,而對於我的服務來說,我需要對這些訊息進行一些業務處理,然後再存入es中。(為了簡化場景,以下對於訊息的處理只是單純的儲存到es中)
服務啟動不到10s,es中寫入的資料
青銅方案:
MQ只要收到訊息,就直接呼叫es進行儲存。
虛擬碼如下:
// 虛擬碼版本
public void processRequestMessage(MessageInfo info) {
// 將接收到的資訊物件複製為一個新物件(例如,監控資料物件)
MonitorData monitorData = Util.copyProperties(info, MonitorData.class);
// 將新物件的 JSON 字串索引到 Elasticsearch 中
elasticClient.index("monitor_index", info.getId(), convertToJson(monitorData), false);
}
存在的問題:
不難發現,這樣的實現方式,會導致訊息消費速度非常慢,甚至導致訊息積壓和服務掛掉,因為這裡對es的呼叫次數=訊息條數,透過在本地的測試中也可以發現,即使在關閉掉訊息生產者後,還是需要很長一段時間才能將訊息消費完全消費掉。
白銀方案:
透過瓶頸,可以很自然的想到使用es的批次增加,那麼只需要實現一個緩衝池,將訊息暫存到緩衝池中,在達到一定大小的時候再統一在es儲存
虛擬碼如下:
@Component
@Slf4j
public class ESOperationMonitorBuffer{
private static final int BUFFER_SIZE = 100; // 緩衝池大小
private List<ElasticDoc> buffer; // 用於儲存訊息的緩衝池
@Autowired
private ElasticClient elasticClient;
private String indexName = EsConstans.NODE_MONITOR_INDEX; // Elasticsearch 索引名稱
public ESOperationMonitorBuffer() {
this.buffer = new ArrayList<>();
}
// 新增訊息到緩衝池
public void addMessage(WlwMessageShareInfo message) {
ElasticDoc elasticDoc = new ElasticDoc();
elasticDoc.setIndex(indexName);
MonitorESData monitorESData = BeanUtil.copyProperties(message, MonitorESData.class);
elasticDoc.setDoc(JSONObject.toJSONString(monitorESData));
buffer.add(elasticDoc);
if(buffer.size() > BUFFER_SIZE){
flush();
}
}
/**
* 執行 flush 操作
*/
private void flush() {
log.info("開始批次插入 Elasticsearch,共 {} 條資料", buffer.size());
if (buffer.isEmpty()) {
return; // 如果緩衝池為空,不執行操作
}
BulkResponse index = elasticClient.index(buffer, false);// 批次插入
if (index.hasFailures()) {
log.error("批次插入 Elasticsearch 失敗,失敗原因:{}", index.buildFailureMessage());
}
// 清空緩衝池
buffer.clear();
}
// 如果程式關閉前有剩餘資料,執行 flush 操作
public void close() throws IOException {
}
}
存在的問題:
-
若訊息數量一直沒達到閾值,就一直不會儲存到es
-
存在併發問題,ConcurrentModificationException(併發修改異常),是基於java集合中的 快速失敗(fail-fast) 機制產生的,在使用迭代器遍歷一個集合物件時,如果遍歷過程中對集合物件的內容進行了增刪改,就會丟擲該異常。快速失敗機制使得java的集合類不能在多執行緒下併發修改,也不能在迭代過程中被修改。在上面場景中的表現就是在flush操作中時,又有訊息進入到了buffer中。
黃金方案:
解決問題1:可以開啟一個定時任務去執行flush方法
解決問題2:可能大家第一時間會想到對buffer加鎖,但是這樣又會導致在存入buffer的時候速度慢,所以不難想到可以對 flush() 方法加鎖, 但是這樣一來還是無法解決buffer存在的併發問題,怎麼辦呢?其實很簡單,我們可以用兩個buffer來分別給add()方法和flush()方法使用,這樣一來,就可以避免併發問題,並且繼續對flush()方法加鎖,避免和定時任務同時執行,導致資料重複。
虛擬碼如下:
@Component
@Slf4j
public class ESOperationMonitorBuffer implements CommandLineRunner {
private static final int BUFFER_SIZE = 100; // 緩衝池大小
private List<ElasticDoc> buffer; // 用於儲存訊息的緩衝池
private List<ElasticDoc> temBuffer; // 用於儲存臨時訊息的緩衝池
@Autowired
private ElasticClient elasticClient;
private String indexName = EsConstans.NODE_MONITOR_INDEX; // Elasticsearch 索引名稱
private Lock lock = new ReentrantLock();
public ESOperationMonitorBuffer() {
this.buffer = new ArrayList<>();
this.temBuffer = new ArrayList<>();
}
// 新增訊息到緩衝池
public void addMessage(WlwMessageShareInfo message) {
ElasticDoc elasticDoc = new ElasticDoc();
elasticDoc.setIndex(indexName);
MonitorESData monitorESData = BeanUtil.copyProperties(message, MonitorESData.class);
elasticDoc.setDoc(JSONObject.toJSONString(monitorESData));
temBuffer.add(elasticDoc);
// 當緩衝池達到設定大小時,批次插入到 Elasticsearch
if (temBuffer.size() >= BUFFER_SIZE) {
lock.lock();
try{
buffer.addAll(temBuffer);
temBuffer.clear();
}catch(Exception e){
log.error("新增訊息到緩衝池失敗",e);
}finally {
lock.unlock();
}
flush();
}
}
/**
* 執行 flush 操作
*/
private void flush() {
lock.lock();
try{
log.info("開始批次插入 Elasticsearch,共 {} 條資料", buffer.size());
if (buffer.isEmpty()) {
return; // 如果緩衝池為空,不執行操作
}
BulkResponse index = elasticClient.index(buffer, false);// 批次插入
if (index.hasFailures()) {
log.error("批次插入 Elasticsearch 失敗,失敗原因:{}", index.buildFailureMessage());
}
// 清空緩衝池
buffer.clear();
}catch (Exception e){
log.info("批次插入 Elasticsearch 失敗",e);
}finally {
lock.unlock();
}
}
// 如果程式關閉前有剩餘資料,執行 flush 操作
public void close() throws IOException {
}
@Override
public void run(String... args) throws Exception {
log.info("啟動 ESOperationMonitorBuffer 緩衝池,開啟執行緒池定時執行flush操作");
// 定時執行 flush 操作
ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(1);
scheduledExecutorService.scheduleAtFixedRate(()->{
try{
flush();
}catch (Exception e){
log.error("定時執行 flush 操作失敗",e);
}
},1,5, TimeUnit.SECONDS);
}
}