直播短影片原始碼,延遲任務的解決方法

云豹科技-苏凌霄發表於2024-09-21

直播短影片原始碼,延遲任務的解決方法

在直播短影片原始碼中,我們有時候會遇到這樣的場景,比如下單之後超過30分鐘未支付自動取消訂單,還有就比如過期/生效通知等等,這些場景一般有兩種方法解決:
第一種可以透過定時任務掃描符合條件的去執行;
第二種就是提前透過訊息佇列傳送延遲訊息到期自動消費。

本文我要介紹的就是透過第二種方式來實現這種業務邏輯。

一、延遲佇列RDelayedQueue的簡單用法

生產者端

1、透過redissonClient的getBlockingDeque方法指定佇列名稱獲得RBlockingDeque物件
2、然後再透過redissonClient的getDelayedQueue方法傳入RBlockingDeque物件獲得RDelayedQueue物件
3、最後呼叫RDelayedQueue物件的offer方法就可以將訊息指定延遲時間傳送到延遲佇列了

@Component
public class DelayQueueKit {

    // 注入RedissonClient例項
    @Resource
    private RedissonClient redissonClient;

    /**
     * 新增訊息到延遲佇列
     *
     * @param queueCode 佇列唯一KEY
     * @param msg       訊息
     * @param delay     延遲時間
     * @param timeUnit  時間單位
     */
    public <T> void addDelayQueue(String queueCode, T msg, long delay, TimeUnit timeUnit) {
        RBlockingDeque<T> blockingDeque = redissonClient.getBlockingDeque(queueCode);
        RDelayedQueue<T> delayedQueue = redissonClient.getDelayedQueue(blockingDeque);
        // 這一步透過offer插入到佇列
        delayedQueue.offer(msg, delay, timeUnit);
    }
}

消費者端

1、透過redissonClient獲取RBlockingDeque物件
2、透過RBlockingDeque物件獲取RDelayedQueue
3、之後RBlockingDeque再透過自旋呼叫take方法獲取到期的訊息,沒有訊息時會阻塞的。
Tip:一般情況下我們在直播短影片原始碼剛啟動時非同步開一個執行緒去自旋消費佇列訊息的

@Component
public class DelayQueueKit {

    // 注入RedissonClient例項
    @Resource
    private RedissonClient redissonClient;

    public <T> void consumeQueueMsg(String queueCode) {
        RBlockingDeque<T> delayQueue = redissonClient.getBlockingDeque(queueCode);
        RDelayedQueue<T> delayedQueue = redissonClient.getDelayedQueue(blockingDeque);
        log.info("【佇列-{}】- 監聽佇列成功", queueCode);
        while (true) {
            T message = null;
            try {
                message = delayQueue.take();
                // 處理自己的業務
                handleMessage(message);
                log.info("【佇列-{}】- 處理元素成功 - ele = {}", queueCode, ele);
            } catch (Exception e) {
                log.error("【佇列-{}】- 處理元素失敗 - ele = {}", queueCode, ele, e);
            }
        }
    }
}

二、資料結構設計

Redission實現延遲佇列訊息用到了四個資料結構:

redisson_delay_queue_timeout:{queue_name} 定期佇列,ZSET結構(value為訊息,score為過期時間),這樣就可以知道當前過期的訊息。
redisson_delay_queue:{queue_name} 順序佇列,LIST結構,按照訊息新增順序儲存,移除訊息時可以按照新增順序刪除。
redisson_delay_queue_channel:{queue_name} 釋出訂閱channel主題,用於通知客戶端定時器從定期佇列轉移到期的訊息到目標佇列。
{queue_name} 目標佇列,LIST結構,儲存實際到期可以被消費的訊息供消費者拉取消費。

三、訊息生產原始碼分析

1、透過redissonClient.getDelayedQueue獲取RDelayedQueue物件

2、然後delayedQueue呼叫offer方法去儲存訊息

3、最後真正的儲存邏輯是由RedissonDelayedQueue執行offerAsync方法呼叫的lua指令碼

public class RedissonDelayedQueue<V> extends RedissonExpirable implements RDelayedQueue<V> {
    @Override
    public RFuture<Void> offerAsync(V e, long delay, TimeUnit timeUnit) {
        if (delay < 0) {
            throw new IllegalArgumentException("Delay can't be negative");
        }
        long delayInMs = timeUnit.toMillis(delay);
        // 訊息過期時間 = 當前時間 + 延遲時間
        long timeout = System.currentTimeMillis() + delayInMs;
        // 生成隨機id,應該是為了允許插入到zset重複的訊息
        long randomId = ThreadLocalRandom.current().nextLong();
        // 執行指令碼
        return commandExecutor.evalWriteAsync(getName(), codec, RedisCommands.EVAL_VOID,
            // 將訊息打包成二進位制的, 打包的訊息 = 隨機數 + 訊息,有了隨機數意味著訊息就可以重複
            "local value = struct.pack('dLc0', tonumber(ARGV[2]), string.len(ARGV[3]), ARGV[3]);"
            // 將 打包的訊息和過期時間 插入redisson_delay_queue_timeout佇列
            + "redis.call('zadd', KEYS[2], ARGV[1], value);"
            // 順序插入redisson_delay_queue佇列
            + "redis.call('rpush', KEYS[3], value);"
            // 如果剛插入的訊息就是timeout佇列的最前面,即剛插入的訊息最近要到期
            + "local v = redis.call('zrange', KEYS[2], 0, 0); "
            + "if v[1] == value then "
            // 釋出訊息通知客戶端訊息到期時間,讓它定期執行轉移操作
            + "redis.call('publish', KEYS[4], ARGV[1]); "
            + "end;",
            Arrays.<Object>asList(getName(), timeoutSetName, queueName, channelName),
            // 三個引數:1-過期時間 2-隨機數 3-訊息
            timeout, randomId, encode(e));
    }
}

四、定時器轉移訊息原始碼分析

大家如果僅僅使用而沒有看過原始碼的可能不太容易知道redission究竟哪裡執行的定時器去定時轉移到期訊息的,我也是最近看原始碼才知道,其實就是在呼叫redissonClient.getDelayedQueue獲取RDelayedQueue物件時建立的:

1、透過redissonClient.getDelayedQueue獲取RDelayedQueue物件

2、然後會執行RedissonDelayedQueue的建構函式方法

3、在這個構造方法裡就會新建QueueTransferTask這個物件去執行轉移操作

public class Redisson implements RedissonClient {
    @Override
    public <V> RDelayedQueue<V> getDelayedQueue(RQueue<V> destinationQueue) {
        if (destinationQueue == null) {
            throw new NullPointerException();
        }
        // 執行RedissonDelayedQueue構造方法
        return new RedissonDelayedQueue<V>(queueTransferService, destinationQueue.getCodec(), connectionManager.getCommandExecutor(), destinationQueue.getName());
    }
}
public class RedissonDelayedQueue<V> extends RedissonExpirable implements RDelayedQueue<V> {
    protected RedissonDelayedQueue(QueueTransferService queueTransferService, Codec codec, final CommandAsyncExecutor commandExecutor, String name) {
        ...
        QueueTransferTask task = new QueueTransferTask(commandExecutor.getConnectionManager()) {
            @Override
            protected RFuture<Long> pushTaskAsync() {
                return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_LONG,
                    // 從redisson_delay_queue_timeout佇列獲取100個到期的訊息
                    "local expiredValues = redis.call('zrangebyscore', KEYS[2], 0, ARGV[1], 'limit', 0, ARGV[2]); "
                    + "if #expiredValues > 0 then "
                    + "for i, v in ipairs(expiredValues) do "
                    // 將包裝的訊息執行解包操作,隨機數 + 原訊息        
                    + "local randomId, value = struct.unpack('dLc0', v);"
                    // 將原訊息插入到{queue_name}佇列,就可以被消費了        
                    + "redis.call('rpush', KEYS[1], value);"
                    + "redis.call('lrem', KEYS[3], 1, v);"
                    + "end; "
                    // 轉移後redisson_delay_queue_timeout佇列也移除這些訊息        
                    + "redis.call('zrem', KEYS[2], unpack(expiredValues));"
                    + "end; "
                    // 從定時佇列獲取最近到期時間然後供定時器到時間再執行
                    + "local v = redis.call('zrange', KEYS[2], 0, 0, 'WITHSCORES'); "
                    + "if v[1] ~= nil then "
                    + "return v[2]; "
                    + "end "
                    + "return nil;",
                    Arrays.<Object>asList(getName(), timeoutSetName, queueName),
                    System.currentTimeMillis(), 100);
            }
            // 主題redisson_delay_queue_channel:{queue_name}註冊釋出/訂命令執行閱監聽器
            @Override
            protected RTopic getTopic() {
                return new RedissonTopic(LongCodec.INSTANCE, commandExecutor, channelName);
            }
        };
        // 將定時器命令執行邏輯註冊到釋出/訂閱主題,這樣就可以在收到訂閱時執行轉移操作了
        queueTransferService.schedule(queueName, task);
        ...
    }
}

五、訊息消費原始碼分析

訊息消費的邏輯就比較簡單了,從RBlockingDeque使用take方法獲取訊息時,直接呼叫的就是redis中List的BLPOP命令。

Redis Blpop 命令移出並獲取列表的第一個元素, 如果列表沒有元素會阻塞列表直到等待超時或發現可彈出元素為止。

public class RedissonBlockingQueue<V> extends RedissonQueue<V> implements RBlockingQueue<V> {
    @Override
    public RFuture<V> takeAsync() {
        // 執行redis中List的BLPOP命令,從{queue_name}佇列阻塞取出元素
        return commandExecutor.writeAsync(getName(), codec, RedisCommands.BLPOP_VALUE, getName(), 0);
    }
}

以上就是直播短影片原始碼,延遲任務的解決方法, 更多內容歡迎關注之後的文章

相關文章