我們都用過消息中間件,它的作用自不必多說。但對於消費者卻一直有一些權衡,就是使用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(); } } }
以上,就是本次分享的小輪子了。我們拋卻了消息系統中的一個重要且複雜的環節:存儲。供參考。