Redis 實戰 —— 09. 實現任務佇列、訊息拉取和檔案分發

滿賦諸機發表於2021-01-28

任務佇列 P133

通過將待執行任務的相關資訊放入佇列裡面,並在之後對佇列進行處理,可以推遲執行那些耗時對操作,這種將工作交給任務處理器來執行對做法被稱為任務佇列 (task queue) 。 P133

先進先出佇列 P133

可以 Redis 的列表結構儲存任務的相關資訊,並使用 RPUSH 將待執行任務的相關資訊推入列表右端,使用阻塞版本的彈出命令 BLPOP 從佇列中彈出待執行任務的相關資訊(因為任務處理器除了執行任務不需要執行其他工作)。 P134

傳送任務

// 將任務引數推入指定任務對應的列表右端
func SendTask(conn redis.Conn, queueName string, param string) (bool, error) {
	count, err := redis.Int(conn.Do("RPUSH", queueName, param))
	if err != nil {
		return false, nil
	}
	// 只有成功推入 1 個才算成功傳送
	return count == 1, nil
}

執行任務

// 不斷從任務對應的列表中獲取任務引數,並執行任務
func RunTask(conn redis.Conn, queueName string, taskHandler func(param string)) {
	for ; ; {
		result, err := redis.Strings(conn.Do("BLPOP", queueName, 10))
		// 如果成功獲取任務資訊,則執行任務
		if err != nil && len(result) == 2 {
			taskHandler(result[1])
		}
	}
}

以上程式碼是任務佇列與 Redis 互動的通用版本,使用方式簡單,只需要將入參資訊序列化成字串傳入即可傳送一個任務,提供一個處理任務的方法回撥即可執行任務。

任務優先順序 P136

在此基礎上可以講原有的先進先出任務佇列改為具有優先順序的任務佇列,即高優先順序的任務需要在低優先順序的任務之前執行。 BLPOP 將彈出第一個非空列表的第一個元素,所以我們只需要將所有任務佇列名陣列按照優先順序降序排序,讓任務佇列名陣列作為 BLPOP 的入參即可實現上述功能(當然這種如果高優先順序任務的生成速率大於消費速率,那麼低優先順序的任務就永遠不會執行)。 P136

優先執行高優先順序任務

// 不斷從任務對應的列表中獲取任務引數,並執行任務
// queueNames 從前往後的優先順序依次降低
func RunTasks(conn redis.Conn, queueNames []string, queueNameToTaskHandler map[string]func(param string)) {
	// 校驗是否所有任務都有對應的處理方法
	for _, queueName := range queueNames {
		if _, exists := queueNameToTaskHandler[queueName]; !exists {
			panic(fmt.Sprintf("queueName(%v) not in queueNameToTaskHandler", queueName))
		}
	}
	// 將所有入參放入同一個陣列
	length := len(queueNames)
	args := make([]interface{}, length + 1)
	for i := 0; i < length; i++ {
		args[i] = queueNames[i]
	}
	args[length] = 10
	for ; ; {
		result, err := redis.Strings(conn.Do("BLPOP", args...))
		// 如果成功獲取任務資訊,則執行任務
		if err != nil && len(result) == 2 {
			// 找到對應的處理方法並執行
			taskHandler := queueNameToTaskHandler[result[0]]
			taskHandler(result[1])
		}
	}
}
延遲任務 P136

實際業務場景中還存在某些任務需要在指定時間進行操作,例如:郵件定時傳送等。此時還需要儲存任務執行的時間,並將可以執行的任務放入剛剛的任務佇列中。可以使用有序集合進行儲存,時間戳作為分值,任務相關資訊及佇列名等資訊的 json 串作為鍵。

傳送延遲任務

// 儲存延遲任務的相關資訊,用於序列化和反序列化
type delayedTaskInfo struct {
	UnixNano  int64  `json:"unixNano"`
	QueueName string `json:"queueName"`
	Param     string `json:"param"`
}
// 傳送一個延遲任務
func SendDelayedTask(conn redis.Conn, queueName string, param string, executeAt time.Time) (bool, error) {
	// 如果已到執行時間,則直接傳送到任務佇列
	if executeAt.UnixNano() <= time.Now().UnixNano() {
		return SendTask(conn, queueName, param)
	}
	// 還未到執行時間,需要放入有序集合
	// 序列化相關資訊
	infoJson, err := json.Marshal(delayedTaskInfo{
		UnixNano: time.Now().UnixNano(),
		QueueName:queueName,
		Param:param,
	})
	if err != nil {
		return false, err
	}
	// 放入有序集合
	count, err := redis.Int(conn.Do("ZADD", "delayed_tasks", infoJson, executeAt.UnixNano()))
	if err != nil {
		return false, err
	}
	// 只有成功加入 1 個才算成功
	return count == 1, nil
}

拉取可執行的延遲任務,放入任務佇列

// 輪詢延遲任務,將可執行的任務放入任務佇列
func PollDelayedTask(conn redis.Conn) {
	for ; ; {
		// 獲取最早需要執行的任務
		infoMap, err := redis.StringMap(conn.Do("ZRANGE", "delayed_tasks", 0, 0, "WITHSCORES"))
		if err != nil || len(infoMap) != 1 {
			// 睡 1ms 再繼續
			time.Sleep(time.Millisecond)
			continue
		}
		for infoJson, unixNano := range infoMap {
			// 已到時間,放入任務佇列
			executeAt, err := strconv.Atoi(unixNano)
			if err != nil {
				log.Errorf("#PollDelayedTask -> convert unixNano to int error, infoJson: %v, unixNano: %v", infoJson, unixNano)
				// 做一些後續處理,例如:刪除該條資訊,防止耽誤其他延遲任務
			}
			if int64(executeAt) <= time.Now().UnixNano() {
				// 反序列化
				info := new(delayedTaskInfo)
				err := json.Unmarshal([]byte(infoJson), info)
				if err != nil {
					log.Errorf("#PollDelayedTask -> infoJson unmarshal error, infoJson: %v, unixNano: %v", infoJson, unixNano)
					// 做一些後續處理,例如:刪除該條資訊,防止耽誤其他延遲任務
				}
				// 從有序集合刪除該資訊,並放入任務佇列
				count, err := redis.Int(conn.Do("ZREM", "delayed_tasks", infoJson))
				if err != nil && count == 1 {
					_, _ = SendTask(conn, info.QueueName, info.Param)
				}
			} else {
				// 未到時間,睡 1ms 再繼續
				time.Sleep(time.Millisecond)
			}
		}
	}
}

有序集合不具備列表的阻塞彈出機制,所以程式需要不斷迴圈,並嘗試從佇列中獲取要被執行的任務,這一操作會增大網路和處理器的負載。可以通過在函式裡面增加一個自適應方法 (adaptive method) ,讓函式在一段時間內都沒有發現可執行的任務時,自動延長休眠時間,或者根據下一個任務的執行時間來決定休眠的時長,並將休眠時長的最大值限制為 100ms ,從而確保任務可以被及時執行。 P138

訊息拉取 P139

兩個或多個客戶端在互相傳送和接收訊息的時候,通常會使用以下兩種方法來傳遞資訊: P139

  • 訊息推送 (push messaging) :即由傳送者來確保所有接受者已經成功接收到了訊息。 Redis 內建了用於進行訊息推送的 PUBLISH 命令和 SUBSCRIBE 命令(05. Redis 其他命令簡介 介紹了這兩個命令的用法和缺陷)
  • 訊息拉取 (pull messaging) :即由接受者自己去獲取儲存的資訊
單個接受者 P140

單個接受者時,只需要將傳送的資訊儲存至每個接收者對應的列表中即可,使用 RPUSH 可以向執行接受者傳送訊息,使用 LTRIM 可以移除列表中的前幾個元素來獲取收到的訊息。 P140

多個接受者 P141

多個接受者的情況類似群組,即群組內的人發訊息,其他人都可以收到。我們可以使用以下幾個資料結構儲存所需資料,以便實現我們的所需的功能:

  • STRING: 群組的訊息自增 id
    • INCR: 實現 id 自增並獲取
  • ZSET: 儲存該群組中的每一條資訊,分值為當前群組內的訊息自增 id
    • ZRANGEBYSCORE: 獲得未獲取的訊息
  • ZSET: 儲存該群組中每個人獲得的最新一條訊息的 id ,所有訊息均未獲取時為 0
    • ZCARD: 獲取群組人數
    • ZRANGE: 經過處理後,可實現哪些訊息成功被哪些人接收了的功能
    • ZRANGE: 獲取 id 最小資料,可實現刪除被所有人獲取過的訊息的功能
  • ZSET: 儲存一個人所有群組獲得的最新一條訊息的 id ,離開群組時自動刪除,加入群組時初始化為 0
    • ZCARD: 獲取所在的群組個數
    • ZRANGE: 經過處理後,可實現批量拉取所有群組的未獲取的訊息的功能

檔案分發 P145

根據地理位置聚合使用者資料 P146

現在擁有每個 ip 每天進行活動的時間和具體操作,現需要計算每天每個城市的人運算元量(類似於統計日活)。

原始資料十分巨大,所以需要分批讀入記憶體進行聚合統計,而聚合後的資料相對來說很小,所以完全可以在記憶體中進行聚合統計,完成後再將結果寫入 Redis 中,可以有效減少程式與 Redis 服務的通訊次數,縮短任務時間。

日誌分發及處理

現在有一臺機器的本地日誌需要交給多個日誌處理器進行不同的分析。

這種場景類似群組,所以我們可以複用上面提到的支援多個接受者的訊息拉取元件。

本地機器:

  1. 將所有日誌傳送至群組,最後再傳送一條結束訊息
  2. 等待所有日誌處理器處理完(群組對應的完成標識 = 群組內的成員數 - 1)
  3. 清理本次傳送的所有日誌

日誌處理器:

  1. 不斷從群組中拉取訊息,並進入相關處理,直至拉取到結束訊息
  2. 對群組對應的完成標識進行 INCR ,表示當前日誌處理器已完成處理

本文首發於公眾號:滿賦諸機(點選檢視原文) 開源在 GitHub :reading-notes/redis-in-action

相關文章