訊息佇列——數十萬級訊息的消費方案

爱为斯坦發表於2024-10-14

背景:

​ 下游平臺透過訊息佇列上報監控訊息,但是訊息量很大,在三分鐘左右可以達到百萬級別,而對於我的服務來說,我需要對這些訊息進行一些業務處理,然後再存入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 {
    }

}

存在的問題:

  1. 若訊息數量一直沒達到閾值,就一直不會儲存到es

  2. 存在併發問題,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);
    }
}

相關文章