Dyno-queues 分散式延遲佇列 之 生產消費

羅西的思考發表於2021-02-21

Dyno-queues 分散式延遲佇列 之 生產消費

0x00 摘要

本系列我們會以設計分散式延遲佇列時重點考慮的模組為主線,穿插灌輸一些訊息佇列的特性實現方法,通過分析Dyno-queues 分散式延遲佇列的原始碼來具體看看設計實現一個分散式延遲佇列的方方面面。

0x01 前情回顧

Dyno-queues 是 Netflix 實現的基於 Dynomite 和 Redis 構建的佇列。Dynomite是一種通用的實現,可以與許多不同的key-value儲存引擎一起使用。目前它提供了對Redis序列化協議(RESP)和Memcached寫協議的支援。

上文我們介紹了 Dyno-queues 分散式延遲佇列 的設計思路,本文我們繼續介紹訊息的產生和消費。

首先我們回顧下設計目標和選型思路。

1.1 設計目標

具體設計目標依據業務系統不同而不同。

Dyno-queues的業務背景是:在Netflix的平臺上執行著許多的業務流程,這些流程的任務是通過非同步編排進行驅動,現在要實現一個分散式延遲佇列,這個延遲佇列具有如下特點:

  • 分散式;
  • 不用外部的鎖機制;
  • 高併發;
  • 至少一次語義交付;
  • 不遵循嚴格的FIFO;
  • 延遲佇列(訊息在將來某個時間之前不會從佇列中取出);
  • 優先順序;

1.2 選型思路

Netflix選擇Dynomite,是因為:

  • 其具有效能,多資料中心複製和高可用性的特點;
  • Dynomite提供分片和可插拔的資料儲存引擎,允許在資料需求增加垂直和水平擴充套件;

Netflix選擇Redis作為構建佇列的儲存引擎是因為:

  • Redis架構通過提供構建佇列所需的資料結構很好地支援了佇列設計,同時Redis的效能也非常優秀,具備低延遲的特性
  • Dynomite在Redis之上提供了高可用性、對等複製以及一致性等特性,用於構建分散式叢集佇列。

下面我們具體看看如何生產和消費訊息。

0x02 產生訊息

Dyno-queues 使用者使用push方法來向redis中投放訊息。

List<Message> payloads = new ArrayList<>();
payloads.add(new Message("id1", "searchable payload123"));
payloads.add(new Message("id2", "payload 2"));

DynoQueue V1Queue = queues.get("simpleQueue");

// Clear the queue in case the server already has the above key.
V1Queue.clear();

// Test push() API
List pushed_msgs = V1Queue.push(payloads);

push邏輯為:

  • 根據訊息超時(延遲佇列)和優先順序計算得分;
  • 新增到佇列的有序集合;
  • 將Message物件到Hash集合中,key是messageId;

具體程式碼如下:

@Override
public List<String> push(final List<Message> messages) {
    Stopwatch sw = monitor.start(monitor.push, messages.size());
    try {
        execute("push", "(a shard in) " + queueName, () -> {
            for (Message message : messages) {
                String json = om.writeValueAsString(message);
                
                quorumConn.hset(messageStoreKey, message.getId(), json);
                
                double priority = message.getPriority() / 100.0;
                double score = Long.valueOf(clock.millis() + message.getTimeout()).doubleValue() + priority;
                String shard = shardingStrategy.getNextShard(allShards, message);
                String queueShard = getQueueShardKey(queueName, shard);
                
                quorumConn.zadd(queueShard, score, message.getId());
            }
            return messages;
        });
        return messages.stream().map(msg -> msg.getId()).collect(Collectors.toList());
    } finally {
        sw.stop();
    }
}

2.1 設定超時

setTimeout 函式 用來重新設定超時時間。

配置超時時間的目的是 :事件在訊息佇列中的排列順序是由一個演算法確定的,如果超時時間越近,則這個事件在 zset 的score 越低,就越可能被優先分發:

double score = Long.valueOf(clock.millis() + message.getTimeout()).doubleValue() + priority;

具體程式碼如下:

public boolean setTimeout(String messageId, long timeout) {

    return execute("setTimeout", "(a shard in) " + queueName, () -> {

        String json = nonQuorumConn.hget(messageStoreKey, messageId);
        if (json == null) {
            return false;
        }
        Message message = om.readValue(json, Message.class);
        message.setTimeout(timeout);

        for (String shard : allShards) {

            String queueShard = getQueueShardKey(queueName, shard);
            Double score = quorumConn.zscore(queueShard, messageId);
            if (score != null) {
                double priorityd = message.getPriority() / 100;
                double newScore = Long.valueOf(clock.millis() + timeout).doubleValue() + priorityd;
                ZAddParams params = ZAddParams.zAddParams().xx();
                quorumConn.zadd(queueShard, newScore, messageId, params);
                json = om.writeValueAsString(message);
                quorumConn.hset(messageStoreKey, message.getId(), json);
                return true;
            }
        }
        return false;
    });
}

2.2 設定優先順序

Dyno-queues 的訊息是有優先順序的,具體程式碼如下:

    /**
     * Sets the message priority.  Higher priority message is retrieved ahead of lower priority ones
     * @param priority priority for the message.
     */
    public void setPriority(int priority) {
        if (priority < 0 || priority > 99) {
            throw new IllegalArgumentException("priority MUST be between 0 and 99 (inclusive)");
        }
        this.priority = priority;
    }

優先順序是在 0 和 99 之間,而且越大優先順序越高。這裡就有一個疑問:

double score = Long.valueOf(clock.millis() + message.getTimeout()).doubleValue() + priority;

因為使用的是zset,score 是 timeout + priority,而 zset 的score應該是低的優先獲取。為什麼 priority 反而是高的優先處理?

看程式碼你會發現,priority 最後被轉換成一個小數。而score的整數部分是時間戳。

即 score = timestamp + Priority() / 100。

double priorityd = message.getPriority() / 100;

double newScore = Long.valueOf(clock.millis() + timeout).doubleValue() + priorityd;

ZAddParams params = ZAddParams.zAddParams().xx();

quorumConn.zadd(queueShard, newScore, messageId, params);

這樣如果使用者指定 "時間戳 + 1" 為截止時間來獲取,則同一個時間戳的訊息會被一併取出。

具體這些同一個時間戳的訊息如何內部再排優先順序?就可以按照小數點之後的數字排序,這樣就能保證同一個時間戳的訊息內部,按照優先順序排序。這個排序是使用者自己在user function做的

0x03 消費訊息

有幾個不同的消費方法,舉例如下:

Message poppedWithPredicate = V1Queue.popMsgWithPredicate("searchable pay*", false);

V1Queue.popWithMsgId(payloads.get(i).getId());

V1Queue.unsafePopWithMsgIdAllShards(payloads.get(i).getId());

List<Message> popped_msgs = V1Queue.pop(1, 1000, TimeUnit.MILLISECONDS);

List<Message> pop_all_msgs = V1Queue.unsafePopAllShards(7, 1000, TimeUnit.MILLISECONDS);

我們以pop為例說明。

3.1 預取

Dyno-queues 使用了預取來完成消費。預取是因為如註釋所示:

    // We prefetch message IDs here first before attempting to pop them off the sorted set.
    // The reason we do this (as opposed to just popping from the head of the sorted set),
    // is that due to the eventually consistent nature of Dynomite, the different replicas of the same
    // sorted set _may_ not look exactly the same at any given time, i.e. they may have a different number of
    // items due to replication lag.
    // So, we first peek into the sorted set to find the list of message IDs that we know for sure are
    // replicated across all replicas and then attempt to pop them based on those message IDs.

大致為:

  • 預取是因為最終一致性(eventual consistency)。
  • 因為replication lag,在某一時刻不同分片的資料可能不一樣,所以需要先預取。

這就需要使用nonQuorumConn來預取,因為本地redis的資料才是正確的。

  • @param quorumConn Dyno connection with dc_quorum enabled,就是採用了Quorum的Redis;
  • @param nonQuorumConn Dyno connection to local Redis,就是本地Redis;

預取如下:

/**
 * Prefetch message IDs from the local shard.
 */
private void prefetchIds() {
    double now = Long.valueOf(clock.millis() + 1).doubleValue();
    int numPrefetched = doPrefetchIdsHelper(localQueueShard, numIdsToPrefetch, prefetchedIds, now);
    if (numPrefetched == 0) {
        numIdsToPrefetch.set(0);
    }
}

這裡可以看到,使用了nonQuorumConn。

private Set<String> doPeekIdsFromShardHelper(final String queueShardName, final double peekTillTs, final int offset,final int count) {
    return nonQuorumConn.zrangeByScore(queueShardName, 0, peekTillTs, offset, count);
}

3.2 實際操作

實際操作是在_pop中完成。

_pop的邏輯如下:

  • 計算當前時間為最大分數。
  • 獲取分數在0和最大分數之間的訊息。
  • 將messageID新增到unack集合中,並從佇列的有序集中刪除這個messageID。
  • 如果上一步成功,則根據messageID從Redis集合中檢索訊息。

具體程式碼如下:

private List<Message> _pop(String shard, int messageCount,
                           ConcurrentLinkedQueue<String> prefetchedIdQueue) {
    String queueShardName = getQueueShardKey(queueName, shard);
    String unackShardName = getUnackKey(queueName, shard);
    double unackScore = Long.valueOf(clock.millis() + unackTime).doubleValue();

    // NX option indicates add only if it doesn't exist.
    // https://redis.io/commands/zadd#zadd-options-redis-302-or-greater
    ZAddParams zParams = ZAddParams.zAddParams().nx();

    List<Message> popped = new LinkedList<>();
    for (;popped.size() != messageCount;) {
        String msgId = prefetchedIdQueue.poll();
        if(msgId == null) {
            break;
        }

        long added = quorumConn.zadd(unackShardName, unackScore, msgId, zParams);
        if(added == 0){
            monitor.misses.increment();
            continue;
        }

        long removed = quorumConn.zrem(queueShardName, msgId);
        if (removed == 0) {
            monitor.misses.increment();
            continue;
        }

        String json = quorumConn.hget(messageStoreKey, msgId);
        if (json == null) {
            monitor.misses.increment();
            continue;
        }
        Message msg = om.readValue(json, Message.class);
        popped.add(msg);

        if (popped.size() == messageCount) {
            return popped;
        }
    }
    return popped;
}

0x4 即時消費

雖然是延遲佇列,一般來說,無論是使用方或者作者,都希望對於訊息,可以做到即時消費。

但是對於即時消費,Dyno-queues 並沒有做太多保證。

4.1 阻塞式彈出

即時消費,網上所流傳的方法是使用Redis中list的操作BLPOP或BRPOP,即列表的阻塞式(blocking)彈出。
讓我們來看看阻塞式彈出的使用方式:

BRPOP key [key ...] timeout

此命令的說明是:

1、當給定列表內沒有任何元素可供彈出的時候,連線將被 BRPOP 命令阻塞,直到等待超時或發現可彈出元素為止。

2、當給定多個key引數時,按引數 key 的先後順序依次檢查各個列表,彈出第一個非空列表的尾部元素。

另外,BRPOP 除了彈出元素的位置和 BLPOP 不同之外,其他表現一致。

以此來看,列表的阻塞式彈出有兩個特點:

1、如果list中沒有任務的時候,該連線將會被阻塞

2、連線的阻塞有一個超時時間,當超時時間設定為0時,即可無限等待,直到彈出訊息

由此看來,此方式是可行的。

但此為傳統的觀察者模式,業務簡單則可使用,如A的任務只由B去執行。但如果A和Z的任務,B和C都能執行,那使用這種方式就相形見肘。這個時候就應該使用訂閱/釋出模式,使業務系統更加清晰。

4.2 超時處理

Dyno-queues 並沒有利用列表的阻塞式彈出機制,而是使用了超時處理,保證在一定時間內儘量完成操作。

List<Message> popped_msgs = V1Queue.pop(1, 1000, TimeUnit.MILLISECONDS);

List<Message> pop_all_msgs = V1Queue.unsafePopAllShards(7, 1000, TimeUnit.MILLISECONDS);

具體程式碼如下:

@Override
public List<Message> pop(int messageCount, int wait, TimeUnit unit) {
        long start = clock.millis();
        long waitFor = unit.toMillis(wait);
        numIdsToPrefetch.addAndGet(messageCount);
        prefetchIds();
        while (prefetchedIds.size() < messageCount && ((clock.millis() - start) < waitFor)) {
            // 會等待,然後再次嘗試
            Uninterruptibles.sleepUninterruptibly(200, TimeUnit.MILLISECONDS);
            prefetchIds();
        }
        return _pop(shardName, messageCount, prefetchedIds);

0xFF 參考

乾貨分享 | 如何從零開始設計一個訊息佇列

訊息佇列的理解,幾種常見訊息佇列對比,新手也能看得懂!----分散式中介軟體訊息佇列

訊息佇列設計精要

有贊延遲佇列設計

基於Dynomite的分散式延遲佇列

http://blog.mikebabineau.com/2013/02/09/delay-queues-in-redis/

http://stackoverflow.com/questions/17014584/how-to-create-a-delayed-queue-in-rabbitmq

http://activemq.apache.org/delay-and-schedule-message-delivery.html

原始碼分析] Dynomite 分散式儲存引擎 之 DynoJedisClient(1)

原始碼分析] Dynomite 分散式儲存引擎 之 DynoJedisClient(2)

原創 Amazon Dynamo系統架構

Netlix Dynomite效能基準測試,基於AWS和Redis

為什麼分散式一定要有延時任務?

相關文章