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