來源:公衆號 工匠小豬豬的技術世界

一位網友之前面試淘點點的時候被問倒得一個問題至今牽掛,工作兩年,由於工作環境的限制,沒能接觸到一些大數據量的併發工作,也沒能有機遇參與複雜系統的設計,而學習複雜或高併發系統的唯一途徑就是閱讀源碼,慚愧的是,至今也只閱讀了Tomcat的部分源碼,於是他在oschina上貼出問題與互聯網猿一同分析

http://www.oschina.net/question/926166_2137672


問題描述:讓您做一個電商平臺,您如何設置一個在買家下訂單後的”第60秒“發短信通知賣家發貨,您需要考慮的是 像淘寶一樣的大併發量的訂單。

1、具有排序功能的隊列

2、Redis+定時器

思路 1

原理:第一種思路是延遲隊列實現的原理,其就是一個按時間排好序的隊列,每次put的時候排序,然後take的時候就計算時間是否過期,如果過期則返回隊列第一個元素,否則當前線程阻塞X秒,這個也是JDK 自帶 DelayQueue 的思路。

思路 2

原理:第二種思路(來自java架構沉思錄)需要利用Redis的有序集合Sorted Set,說到使用 Redis 就不得不考慮Score的設計,因爲它直接決定你代碼的複雜度,通過精確到秒的時間做Score(去掉毫秒),然後使用線程每一秒掃一次,使用當前時間作爲zrangeBysocre命令的Score去查詢。

業務場景:按京東一天500萬的成交量,一天主要成交時間爲8小時,計算得出每秒173個訂單,當然實際上訂單不能均勻分佈在每秒,但我們主要爲了論證思想的可行性。

代碼實現:這裏首先簡單的利用Spring Scheduled作爲訂單的生產者,每一秒製造170個訂單,放入Redis,注意Score的生成,爲當前時間的後60秒,removeMillis()生成去掉毫秒的時間戳作爲Rredis的Zadd方法的 Score。

淘寶面試中遇到的架構問題


第二步:同樣利用Spring Scheduled 一秒鐘心跳一次,每次利用當前時間作爲Key 從Redis 取數據。

淘寶面試中遇到的架構問題


經過測試,沒有出現漏單的情況,這只是簡單的實現,很多地方可以優化,在實際中用也可能會出現很多問題,需要不斷完善,此案例只是提供思路,另外我覺得JDK的 DelayQueue 相對於Redis來說沒有那麼好,因爲Queue畢竟每次取一個,如果同一時間的比較多可能不能符合當前這種時間嚴謹的需求。

以上是原作者的回答。

關於第二種思路我們再補充一下:

Sorted Set可以把任務的描述序列化成字符串,放在Sorted Set的value中,然後把任務的執行時間戳作爲score,利用Sorted Set天然的排序特性,執行時刻越早的會排在越前面。這樣一來,我們只要開一個或多個定時線程,每隔一段時間去查一下這個Sorted Set中score小於或等於當前時間戳的元素(這可以通過zrangebyscore命令實現),然後再執行元素對應的任務即可。當然,執行完任務後,還要將元素從Sorted Set中刪除,避免任務重複執行。如果是多個線程去輪詢這個Sorted Set,還有考慮併發問題,假如說一個任務到期了,也被多個線程拿到了,這個時候必須保證只有一個線程能執行這個任務,這可以通過zrem命令來實現,只有刪除成功了,才能執行任務,這樣就能保證任務不被多個任務重複執行了。

關於這個問題我們再深入思考一下,感興趣的可以留言。這兩個方案更多是偏單機的,如果在分佈式環境下,又該如何實現?

思路 3

方案:RabbitMq延遲隊列

原理:RabbitMQ本身沒有直接支持延遲隊列功能,但是我們可以根據其特性Per-Queue Message TTL和 Dead Letter Exchanges實現延時隊列。也可以通過改特性設置消息的優先級。

  • 特性1、Time To Live(TTL)
RabbitMQ可以針對Queue設置x-expires 或者 針對Message設置 x-message-ttl,來控制消息的生存時間,如果超時(兩者同時設置以最先到期的時間爲準),則消息變爲dead letter(死信)RabbitMQ針對隊列中的消息過期時間有兩種方法可以設置。
  • A: 通過隊列屬性設置,隊列中所有消息都有相同的過期時間。
  • B: 對消息進行單獨設置,每條消息TTL可以不同。
如果同時使用,則消息的過期時間以兩者之間TTL較小的那個數值爲準。消息在隊列的生存時間一旦超過設置的TTL值,就成爲dead letter
  • 特性2、Dead Letter Exchanges(DLX)
RabbitMQ的Queue可以配置x-dead-letter-exchange 和x-dead-letter-routing-key(可選)兩個參數,如果隊列內出現了dead letter,則按照這兩個參數重新路由轉發到指定的隊列。
  • x-dead-letter-exchange:出現dead letter之後將dead letter重新發送到指定exchange
  • x-dead-letter-routing-key:出現dead letter之後將dead letter重新按照指定的routing-key發送
隊列出現dead letter的情況有:
  • 消息或者隊列的TTL過期
  • 隊列達到最大長度
  • 消息被消費端拒絕(basic.reject or basic.nack)並且requeue=false

綜合上述兩個特性,設置了TTL規則之後當消息在一個隊列中變成死信時,利用DLX特性它能被重新轉發到另一個Exchange或者Routing Key,這時候消息就可以重新被消費了。

實現延遲隊列方案1

延遲任務通過消息的TTL和Dead Letter Exchange來實現。我們需要建立2個隊列,一個用於發送消息,一個用於消息過期後的轉發目標隊列。

淘寶面試中遇到的架構問題


生產者輸出消息到Queue1,並且這個消息是設置有有效時間的,比如3分鐘。消息會在Queue1中等待3分鐘,如果沒有消費者收掉的話,它就是被轉發到Queue2,Queue2有消費者,收到,處理延遲任務。

該方法主要有三步:

第一步:設置TTL產生死信,創建一個隊列,隊列的消息過期時間爲N分鐘(這個隊列N分鐘內沒有消費者消費消息則刪除,刪除後隊列內的消息變爲死信)

第二步:設置死信的轉發規則(如果沒有任何規則,則直接丟棄死信)

第三步:配置延時路由規則,需要延時的消息到exchange後先路由到指定的延時隊列

實現延遲隊列方案2

在rabbitmq 3.5.7及以上的版本提供了一個插件(rabbitmq-delayed-message-exchange)來實現延遲隊列功能。同時插件依賴Erlang/OPT 18.0及以上。

但是rabbitmq像淘寶那樣的量每天流轉幾千億條消息,雙十一大促,是搞不定阿里的問題的。

思路 4

方案:時間輪(TimingWheel)& 層級時間輪

原理:該方案的靈感來自於Kafka,JDK的Timer和DelayQueue插入和刪除操作的平均時間複雜度爲O(nlog(n)),Kafka基於時間輪可以將插入和刪除操作的時間複雜度都降爲O(1)。Kafka的原理請參照《Rabbitmq實戰》作者朱忠華老先生的Kafka解惑之時間輪(TimingWheel)。

時間輪分爲單級時間輪和層級時間輪。

時間輪簡介:時間輪方案將現實生活中的時鐘概念引入到軟件設計中,主要思路是定義一個時鐘週期(比如時鐘的12小時)和步長(比如時鐘的一秒走一次),當指針每走一步的時候,會獲取當前時鐘刻度上掛載的任務並執行:

1.單層時間輪設計:

淘寶面試中遇到的架構問題


以上圖爲例,假設一個格子爲1秒,整個一圈表示的時間爲12秒,此時需要添加5秒後執行的任務,則此時改任務一個放到第(1+5=6)的格子內,如果此時添加13秒後執行任務,此時該任務應該等轉完一圈後round爲1 放到第二格子中,指針每轉動一個一格,獲取當前round爲0的任務執行,格子上的其他任務round減1

問題: 當時間跨度很大,數量很大時,單層的時間輪造成的round很大,一個格子中鏈很長,所以衍生出多級時間輪的設計方案

2.多級時間輪設計方案:

淘寶面試中遇到的架構問題


最小輪子走一圈,它的上層輪子走一格

假設圖中每層輪子爲20個格子,第一層輪子最小時間間隔爲1ms,第二層爲20ms,第三層爲400ms,此時添加5ms後執行的任務,此時應該添加到第一層的第5格子中。如果此時添加445ms後執行的任務,則第一層表示的時間跨度不夠,第二層表示的時間跨度也不夠,第三層表示的時間跨度足夠,該任務應該放到第三層輪子第二格子中,該輪子指針指到第二格子中時,計算離任務啓動時間還有多長時間,慢慢將該任務移動到底層輪子上,最終任務到期執行。

關於更多如何在MQ中實現支持任意延遲的消息?建議看一下這篇文章https://www.cnblogs.com/hzmark/p/mq-delay-msg.html,需要說明的是

淘寶面試中遇到的架構問題


阿里雲上對業界MQ功能的對比,其中開源產品中只有阿里的RocketMQ支持延遲消息,且是固定的18個Level。固定Level的含義是延遲是特定級別的,比如支持3秒、5秒的Level,那麼用戶只能發送3秒延遲或者5秒延遲,不能發送8秒延遲的消息。消息隊列RocketMQ的阿里雲版本(收費版本)才支持到精確到秒級別的延遲消息(沒有特定Level的限制)。

對支持任意延遲的需求確實不強,因爲:

  1. 延遲並不是MQ場景的核心功能,業務單獨做一個替代方案的成本不大
  2. 業務上一般對延遲的需求都是固定的,比如下單後半小時check是否付款,發貨後7天check是否收貨


支持任意延遲意味着消息是需要在服務端進行排序的。如何處理排序和消息存儲,但是如何更牛逼的進行任意級別的延遲,進行海量的數據落盤呢?我們來看思路5。

思路 5

方案:單層文件時間輪

原理:

該圖是開源版本RocketMQ支持18個Level的方案簡圖

淘寶面試中遇到的架構問題


結合RocketMQ的做法,但是又不同於它。

我瞎想(趕緊留言噴)一下(後面有高手要發系統性的文章,我拋磚引玉),由於大量堆積一定要1⃣️落盤,另外結合一下rabbit的2⃣️延時隊列+Kafka的3⃣️TimingWheel,來打造一個支持任意級別的延遲的工具。

第一步,CommitLog需要區分是否是延遲,而非延遲進入正常消費隊列。

第二步,延遲的CommitLog剝離出來,按照消息順序落盤,由於面對海量數據,需要進行落盤和消息備份,這裏可以和流式計算Jstorm合作提升效能

第三步,TimeWheel加載延遲時間臨近的消息到內存進行處理

思路 5

方案:其他

好了,讓我們看看其他網友針對這個問題的看法:

淘寶面試中遇到的架構問題


淘寶面試中遇到的架構問題


淘寶面試中遇到的架構問題


淘寶面試中遇到的架構問題


淘寶面試中遇到的架構問題


淘寶面試中遇到的架構問題


淘寶面試中遇到的架構問題


最後補充一點:

單層文件時間輪,存文件用時間輪去轉, 單層是爲了防止多層中降級引起的讀寫衝突。無消耗,文件追加,kafka就是文件追加,常用句柄或者最近要用到的句柄加載進來 其他的不用加載,到點就轉發到實際的topic裏,時間快到就輪到他。rocket裏,延遲commitlog修改文件名,到期加載,排序都不用。站在了kafka的思維上 rocket可以修改commitLog。。。得虧它們沒有一致性的東西。

相关文章