基於long pull實現簡易的訊息系統參考

等你歸去來發表於2022-03-24

  我們都用過訊息中介軟體,它的作用自不必多說。但對於消費者卻一直有一些權衡,就是使用push,還是pull模式的問題,這當然是各有優劣。當然,這並不是本文想討論的問題。我們想在不使用長連線的情意下,如何實現實時的訊息消費,而不至於讓server端壓力過大。大體上來說,這是一種主動拉取pull的方式。具體情況如何,且看且聽。

 

1. 架構示意圖

  既然是一個訊息中間的作用,我們必須得模擬一個生產消費者模型,如下:

 

 

   生產者叢集->訊息中心叢集->消費者叢集

  只是這裡的生產和訊息中心也許我們可以合二為一,為簡單起見,可能我們消費者只是想知道資料發生了變化。

  以上是一個通用模型,接下來再說說我如何以long pull訊息消費,其流程圖如下:

 

 

   消費者一直請求連線->訊息中心->有資料到來或者超時->消費者處理資料->傳送ack確認->繼續請求連線

  如此一來,我們基本上就實現了一個消費模型了。但是有個問題,我們一直在不停地請求server,這會不會讓server疲於奔命?是的,如果按照正常的http請求,就是不停地建立連線,處理資料,關閉連線等等。在沒有訊息到來之前,可以說,server會一直被這無用功跑死,它的qps越高,壓力也越大。所以,我們使用了一種long pull的方式,讓server端不要那麼快返回沒有意義的資料。但,這可能不是一件容易的事。

 

2. long pull的實現方式

  long pull從原理上來說就是,必要的時候hold住連線,直到某個時機才返回。這和長連結有點類似。

  至於為什麼不用長連線實現,我想至少有兩個原因:一是long pull一般基於http協議,實現簡單且通用,而如果要基於長連結則需要了解太多的通訊細節太複雜;二是埠複用問題,long pull可以直接基於業務埠實現,而長連線則必須要另外開一個通訊埠,這在實際運維過程中也許不那麼好操作,主要原因可能是我們往往不是真正的中介軟體,還達不到與架構或運維pk埠標準的資本。

  說回正題,如何實現long pull?這其實和你使用的框架有關。但簡單來說都可以這樣幹,請求進來後,我只要一直不返回即可。而且這也許是許多框架或語言的唯一選擇。

  如果我們們是java語言且基於spring系列框架,則可以用另外一種非同步的方式。用上一種通用的實現方式的缺點是:當一個請求一直不返回後,必然佔用主連線池,從而影響其他業務介面的請求處理,就是說只要你多接入幾個這種請求,業務就別想有好日子過了。所以,我們選擇非同步的方式。非同步,聽起來是個好名詞,但又該如何實現呢?我們普通非同步,可能是直接丟到一個佇列去,然後由後臺執行緒一直處理即可,聽起來不錯。但這種請求至少兩個問題:一是當我們提交到任務佇列之後,連線還存在嗎?二是我們敢讓請求排隊嗎?因為如果排隊有新資料進來,可就不面對實時的承諾了。

  所以,針對上面的問題,spring系列有了解決方案。使用非同步 servlet(async servlet),其操作步驟如下:

1 controller中返回非同步例項callable;
2 在servlet中配置非同步支援標識(統一配置);

 

  比如下面的demo:

// controller
    @GetMapping(value = "/consumeData")
    public Object consumeData(@RequestParam String topicName,
                              @RequestParam Long offset,
                              @RequestParam Long maxWait) {
        // 必要的時候需要在 web.xml中配置 <async-supported>true</async-supported>
        Callable<String> callable = () -> {
            SleepUtil.sleepMillis(10_000L);
            System.out.println("data come in, got out.");
            return "ok";
        };

        return callable;
    }
// web.xml
    // 所有需要的filter和servlet中,新增
    <async-supported>true</async-supported>

  具體的框架版本各自具體配置可能不一樣,自行查詢資料即可。

  以上,就解決了long pull的問題了。

 

3. 主鍵id的實現

  主鍵id至少有兩個作用:一是可用於唯一定位一條訊息;二是可以用於去重做冪等;其實一般還有一個目的就是用於確認訊息的先後順序;

  所以主鍵id很重要,往往需要經過精心的設計。但,我們這裡可以簡單的基於redis的自增key來處理即可。既保證了效能,又保證了唯一性,還保證了先後順序問題。這就為後續訊息的儲存帶來了方便。比如可以用zset儲存這個訊息id。

 

4. 資料到來的檢測實現

  在server端hold連線的同時,它又是如何發現資料已經到來了呢?

  最簡單的,可以讓每個請求每隔一定時間,去查詢一次資料,如果有則返回。但這個實現既不優雅也不經濟也不實時,但是簡單,可以適當考慮。

  好點的方式,使用wait/notify機制,簡單來說比如使用一個CountDownLatch,沒有資料時則進行wait,資料到來時進行notify。這樣下不來,不用每個請求反覆查詢資料,導致server壓力變大,同時也讓系統排程壓力減小了,而且能夠做到實時感知資料,可以說是很棒的選擇。只是,這必然有很多的細節問題需要處理,稍有不慎,可能就是一個坑。比如:死鎖問題,多節點問題,網路問題。。。 隨便來一個,也許就jj了。

  好好處理這個問題,總是好的。

 

5. 訊息中心實現demo

 

5.1. 消費者生產者controller

  兩個簡單方法入口,生產+消費 。

@RestController
@RequestMapping("/simpleMessageCenter")
public class SimpleMessageCenterController {

    @Resource
    private MessageService messageService;

    // 消費訊息
    @GetMapping(value = "/consumeData")
    public Object consumeData(@RequestParam String topicName,
                              @RequestParam Long offset,
                              @RequestParam Long maxWait) {
        // 必要的時候需要在 web.xml中配置 <async-supported>true</async-supported>
        Callable<String> callable = () -> {
            try {
                Object data = messageService.consumeData(topicName, offset, maxWait);
                return JSONObject.toJSONString(data);
            }
            catch (Exception e){
                e.printStackTrace();
                return "error";
            }
        };

        return callable;
    }

    // 傳送訊息
    @GetMapping(value = "/sendMsg")
    public Object sendMsg(@RequestParam String topicName,
                          @RequestParam String extraId,
                          @RequestParam String data) {
        messageService.sendMsg(topicName, extraId, data);
        return "ok";
    }
}

 

5.2. 核心service簡化版

  由redis作為儲存,展示各模組間的協作。

@Service
public class MessageService {

    @Resource
    private RedisTemplate<String, String> redisTemplate;

    // 消費閉鎖
    private volatile ConcurrentHashMap<String, CountDownLatch>
            consumeLatchContainer = new ConcurrentHashMap<>();

    // 消費資料介面
    public List<Map<String, Object>> consumeData(String topic,
                                                 Long offset,
                                                 Long maxWait) throws InterruptedException {
        long startTime = System.currentTimeMillis();
        final CountDownLatch myLatch = getOrCreateConsumeLatch(topic);
        List<Map<String, Object>> result = new ArrayList<>();
        do {
            ZSetOperations<String, String> queueHolder
                    = redisTemplate.opsForZSet();
            Set<ZSetOperations.TypedTuple<String>> nextData
                    = queueHolder.rangeByScoreWithScores(topic, offset, offset + 100);
            if(nextData == null || nextData.isEmpty()) {
                long timeRemain = maxWait - (System.currentTimeMillis() - startTime);
                myLatch.await(timeRemain, TimeUnit.MILLISECONDS);
                continue;
            }
            for (ZSetOperations.TypedTuple<String> queue1 : nextData) {
                Map<String, Object> queueWrapped = new HashMap<>();
                queueWrapped.put(queue1.getValue(), queue1.getScore());
                result.add(queueWrapped);
            }
            break;
        } while (System.currentTimeMillis() - startTime <= maxWait);
        return result;
    }

    // 獲取topic級別的鎖
    private CountDownLatch getOrCreateConsumeLatch(String topicName) {
        return consumeLatchContainer.computeIfAbsent(
                    topicName, k -> new CountDownLatch(1));
    }

    // 接收到訊息儲存請求
    public void sendMsg(String topic, String extraIdSign, String data) {
        ValueOperations<String, String> strOp = redisTemplate.opsForValue();
        Long msgId = strOp.increment(topic + ".counter");
        // todo: 1. save real data
        // 2. 加入通知佇列
        ZSetOperations<String, String> zsetOp = redisTemplate.opsForZSet();
        zsetOp.add(topic, extraIdSign, msgId);
        wakeupConsumers(topic, extraIdSign);
    }

    // 喚醒消費者,一般是有新資料到來
    private void wakeupConsumers(String topic, String extraIdSign) {
        CountDownLatch consumeLatch = getOrCreateConsumeLatch(topic);
        consumeLatch.countDown();
        rolloverConsumeLatch(topic, extraIdSign);
    }

    // 產生新一輪的鎖
    private void rolloverConsumeLatch(String topic, String extraIdSign) {
        consumeLatchContainer.put(topic, new CountDownLatch(1));
    }
}

  

5.3. 功能測試

  因為是使用http介面實現,所以,可以直接通過瀏覽器實現功能測試。一個地址開啟生產者連結,一個開啟消費者連結。

// 1. 先訪問消費者
http://localhost:8081/simpleMessageCenter/consumeData?topicName=q&offset=19&maxWait=50000
// 2. 再訪問生產者
http://localhost:8081/simpleMessageCenter/sendMsg?topicName=q&extraId=d3&data=aaaaaaaaaaa

  在生產者沒有資料進來前,消費者會一直在等待,而生產者產生資料後,消費者就立即展示結果了。我們要實現的,不就是這個效果嗎?

 

5.4. 消費者一直請求樣例

  在瀏覽器上我們看到的只是一次請求,但如果真正想實現,一直消費資料,則必須有一種訂閱的感覺。其實就是不停的請求,處理,再請求的過程。

public class SimpleMessageCenterTest {

    @Test
    public void testConsumerSubscribe() {
        long offset = 0;
        String urlPrefix = "http://localhost:8081/simpleMessageCenter/consumeData?topicName=q&maxWait=50000&offset=";
        while (!Thread.interrupted()) {
            String dataListStr = HttpUtils.doGet(urlPrefix + offset);
            System.out.println("offsetStart: " + offset + ", got data:" + dataListStr);
            List<Object> dataListParsed = JSONObject.parseArray(dataListStr);
            // 不解析最終的offset了,大概就是根據最後一次offset再發起請求即可
            offset += dataListParsed.size();
        }
    }
}

  以上,就是本次分享的小輪子了。我們拋卻了訊息系統中的一個重要且複雜的環節:儲存。供參考。

相關文章