Node.js 設計模式筆記 —— 消息中間件及其應用模式(任務分發)

將高成本的任務委派給多個工作節點,這種類型的應用並不適合由 Pub/Sub 模式實現。因爲我們並不想同一個任務被多個消費者收到,相反我們更需要一種類似負載均衡的消息分發模式。在消息系統術語中,也被稱爲 competing consumersfanout distributionventilator
與 HTTP 負載均衡器不同的是,任務分發系統中的消費者是一種更活躍的角色。絕大多數時候都是消費者連接到任務隊列,請求新的任務。這一點在可擴展系統中非常關鍵,允許我們在不修改生產者部分的情況下,直接平滑地增加工作節點的數量。
此外,在一個通用的消息系統中,我們沒有必要強調生產者和消費者之間的請求/響應通信。多數情況下,更優先的選擇是使用單向的異步通信,從而獲得更優異的並行能力和擴展性。消息基本上總是沿着一個方向流動,這樣的管道允許我們構建複雜的信息處理架構,又不必承受同步通信帶來的開銷。

ZeroMQ Fanout/Fanin 模式

分佈式 hashsum 破解器

需要以下組件實現一個標準的並行管線:

  • 一個協調節點負責在多個工作節點間分發任務
  • 多個工作節點承擔具體的計算任務
  • 一個用於收集計算結果的節點

即一個節點負責生成所有可能的字符串組合,並將它們分發給不同的工作節點;工作節點則負責計算接收到的字符串,比較 hash 值;最後一個節點負責收集暴力破解的結果。

實現 producer

爲了表示所有可能的字符組合,這裏使用 N 維索引樹。每個節點包含一個當前位置下可能出現的字母,比如只有 ab 兩個字母的話,長度爲 3 的字符串組合共有圖示的以下幾種:

indexed-string-variation 包可以幫助我們由索引計算出對應的字符串,這項工作可以在工作節點完成,因此 producer 這裏只需要將分好組的索引值分發給工作節點。
generateTasks.js:

export function* generateTasks(searchHash, alphabet,
    maxWordLength, batchSize) {
    let nVariations = 0
    for (let n = 1; n <= maxWordLength; n++) {
        nVariations += Math.pow(alphabet.length, n)
    }

    console.log('Finding the hashsum source string over ' +
        `${nVariations} possible variations`)

    let batchStart = 1
    while (batchStart <= nVariations) {
        const batchEnd = Math.min(
            batchStart + batchSize - 1, nVariations)
        yield {
            searchHash,
            alphabet: alphabet,
            batchStart,
            batchEnd
        }

        batchStart = batchEnd + 1
    }
}

producer.js:

import zmq from 'zeromq'
import delay from 'delay'
import { generateTasks } from './generateTasks.js'

const ALPHABET = 'abcdefghijklmnopqrstuvwxyz'
const BATCH_SIZE = 10000

const [, , maxLength, searchHash] = process.argv

async function main() {
    const ventilator = new zmq.Push()
    await ventilator.bind('tcp://*:5016')
    await delay(1000)

    const generatorObj = generateTasks(searchHash, ALPHABET, maxLength, BATCH_SIZE)
    for (const task of generatorObj) {
        await ventilator.send(JSON.stringify(task))
    }
}

main().catch(err => console.log(err))
  • 創建一個 PUSH socket 並綁定給本地的 5016 端口,工作節點的 PULL socket 會連接到此端口並接收任務
  • 將每一個生成的任務字符串化,通過 PUSH socket 的 send() 方法發送給工作節點。工作節點以輪詢的方式接收不同的任務
實現 worker

process Task.js:

import isv from 'indexed-string-variation'
import { createHash } from 'crypto'

export function processTask(task) {
    const variationGen = isv.generator(task.alphabet)
    console.log('processing from ' +
        `${variationGen(task.batchStart)} (${task.batchStart})` +
        `to ${variationGen(task.batchEnd)} (${task.batchEnd}`)

    for (let idx = task.batchStart; idx <= task.batchEnd; idx++) {
        const word = variationGen(idx)
        const shasum = createHash('sha1')
        shasum.update(word)
        const digest = shasum.digest('hex')

        if (digest === task.searchHash) {
            return word
        }
    }
}

processTask() 遍歷給定區間內的所有索引值,對每一個索引生成對應的字符串,再計算其 SHA1 值,與傳入的 task 對象中的 searchHash 比較。

worker.js:

import zmq from 'zeromq'
import { processTask } from './processTask.js'

async function main() {
    const fromVentilator = new zmq.Pull()
    const toSink = new zmq.Push()

    fromVentilator.connect('tcp://localhost:5016')
    toSink.connect('tcp://localhost:5017')

    for await (const rawMessage of fromVentilator) {
        const found = processTask(JSON.parse(rawMessage.toString()))
        if (found) {
            console.log(`Found! => ${found}`)
            await toSink.send(`Found: $found`)
        }
    }
}

main().catch(err => console.error(err))

worker.js 創建了兩個 socket。PULL socket 負責連接到任務發佈方(Ventilator),接收任務;PUSH socket 負責連接到結果收集方(sink),傳遞任務執行的結果。

實現 results collector

collector.js:

import zmq from 'zeromq'

async function main() {
    const sink = new zmq.Pull()
    await sink.bind('tcp://*:5017')

    for await (const rawMessage of sink) {
        console.log('Message from worker: ', rawMessage.toString())
    }
}

main().catch(err => console.error(err))

運行以下命令測試結果:

node worker.js
node worker.js
node collector.js
node producer.js 4 f8e966d1e207d02c44511a58dccff2f5429e9a3b

AMQP 實現 pipeline 和 competing consumers

像前面那樣在點對點的模式下,實現 pipeline 是非常直觀的。假設我們需要藉助 AMQP 這類系統實現任務分配模式,就必須確保每條消息都只會被一個消費者接收到。
可以直接將任務發佈到目標 queue,不經過 exchange。避免了 exchange 有可能綁定了多個 queue 的情況。之後,多個消費者同時監聽這一個 queue,消息即會以 fanout 的方式均勻地分發給所有的消費者。

hashsum 破解器的 AMQP 實現

producer-amqp.js:

import amqp from 'amqplib'
import { generateTasks } from './generateTasks.js'

const ALPHABET = 'abcdefghijklmnopqrstuvwxyz'
const BATCH_SIZE = 10000

const [, , maxLength, searchHash] = process.argv

async function main() {
    const connection = await amqp.connect('amqp://localhost')
    const channel = await connection.createConfirmChannel()
    await channel.assertQueue('tasks_queue')

    const generatorObj = generateTasks(searchHash, ALPHABET,
        maxLength, BATCH_SIZE)
    for (const task of generatorObj) {
        channel.sendToQueue('tasks_queue', Buffer.from(JSON.stringify(task)))
    }

    await channel.waitForConfirms()
    channel.close()
    connection.close()
}

main().catch(err => console.error(err))
  • 此處創建的是一個 confirmChannel,它提供了一個 waitForConfirms() 函數,可以在 broker 確認收到消息前等待,確保應用不會過早地關閉到 broker 的連接
  • channel.sendToQueue() 負責將一條消息直接發送給某個 queue,跳過任何 exchange 或者路由

worker-amqp.js:

import amqp from 'amqplib'
import { processTask } from './processTask.js'

async function main() {
    const connection = await amqp.connect('amqp://localhost')
    const channel = await connection.createChannel()
    const { queue } = await channel.assertQueue('tasks_queue')
    channel.consume(queue, async (rawMessage) => {
        const found = processTask(
            JSON.parse(rawMessage.content.toString()))
        if (found) {
            console.log(`Found! => ${found}`)
            await channel.sendToQueue('results_queue',
                Buffer.from(`Found: ${found}`))
        }
        await channel.ack(rawMessage)
    })
}

main().catch(err => console.error(err))

collector-amqp.js:

import amqp from 'amqplib'

async function main() {
    const connection = await amqp.connect('amqp://localhost')
    const channel = await connection.createChannel()
    const { queue } = await channel.assertQueue('results_queue')
    channel.consume(queue, msg => {
        console.log(`Message from worker: ${msg.content.toString()}`)
    })
}

main().catch(err => console.error(err))

運行如下命令測試效果:

node worker-amqp.js
node worker-amqp.js
node collector-amqp.js
node producer-amqp.js 4 f8e966d1e207d02c44511a58dccff2f5429e9a3b

通過 Redis Streams 實現任務分發

Redis Stream 可以藉助一種叫做 consumer groups 的特性實現任務分發模式。Consumer group 是一個有狀態的實體,由一組名稱標識的消費者組成,組中的消費者會以 round-robin 的方式接收記錄。
每條記錄都必須被顯式地確認,否則該記錄會一直處於 pending 狀態。每個消費者都只能訪問它自己的 pending 記錄,假如消費者突然崩潰,在其回到線上後會先嚐試獲取其 pending 的記錄。

Consumer group 也會記錄其讀取的上一條消息的 ID,因而在連續的讀取操作中,consumer group 知道下一條要讀取的記錄時是哪個。

producer-redis.js:

import Redis from 'ioredis'
import { generateTasks } from './generateTasks.js'

const ALPHABET = 'abcdefghijklmnopqrstuvwxyz'
const BATCH_SIZE = 10000
const redisClient = new Redis()

const [, , maxLength, searchHash] = process.argv

async function main() {
    const generatorObj = generateTasks(searchHash, ALPHABET,
        maxLength, BATCH_SIZE)
    for (const task of generatorObj) {
        await redisClient.xadd('tasks_stream', '*',
            'task', JSON.stringify(task))
    }

    redisClient.disconnect()
}

main().catch(err => console.error(err))

worker-redis.js:

import Redis from 'ioredis'
import { processTask } from './processTask.js'

const redisClient = new Redis()
const [, , consumerName] = process.argv

async function main() {
    await redisClient.xgroup('CREATE', 'tasks_stream',
        'workers_group', '$', 'MKSTREAM')
        .catch(() => console.log('Consumer group already exists'))

    const [[, records]] = await redisClient.xreadgroup(
        'GROUP', 'workers_group', consumerName, 'STREAMS',
        'tasks_stream', '0')
    for (const [recordId, [, rawTask]] of records) {
        await processAndAck(recordId, rawTask)
    }

    while (true) {
        const [[, records]] = await redisClient.xreadgroup(
            'GROUP', 'workers_group', consumerName, 'BLOCK', '0',
            'COUNT', '1', 'STREAMS', 'tasks_stream', '>')
        for (const [recordId, [, rawTask]] of records) {
            await processAndAck(recordId, rawTask)
        }
    }
}

async function processAndAck(recordId, rawTask) {
    const found = processTask(JSON.parse(rawTask))
    if (found) {
        console.log(`Found! => ${found}`)
        await redisClient.xadd('results_stream', '*', 'result',
            `Found: ${found}`)
    }

    await redisClient.xack('tasks_stream', 'workers_group', recordId)
}

main().catch(err => console.error(err))
  • xgroup 命令用來確保 consumer group 存在。
    • CREATE 表示我們希望創建一個 consumer group
    • tasks_stream 表示我們想要讀取的 stream 的名字
    • workers_group 是 consumer group 的名字
    • 第四個參數表示 consumer group 開始讀取的記錄的位置。$ 表示當前 stream 中最後一條記錄的 ID
    • MKSTREAM 表示如果 stream 不存在則創建它
  • 通過 xreadgroup 命令讀取屬於當前 consumer 的所有 pending 的記錄。
    • 'GROUP''workers_group'consumerName 用來指代 consumer group 和 consumer 的名字
    • STREAMStasks_stream 用來指代我們想要讀取的 stream 的名字
    • 0 用來表示我們想要開始讀取的記錄的位置。這裏表示從屬於當前 consumer 的第一條記錄開始,讀取所有 pending 的消息
  • 通過另外一條 xreadgroup 命令讀取 stream 裏新增加的記錄。
    • 'BLOCK''0' 兩個參數表示如果沒有新的消息,就一直阻塞等待。'0' 具體表示一直等待永不超時
    • 'COUNT''1' 表示一次請求只獲取一條記錄
    • 特殊 ID > 表示只獲取還沒有被當前的 consumer group 處理過的消息
  • processAndAck() 函數負責當 xreadgroup() 返回的記錄被處理完成時,調用 xack 命令進行確認,將該記錄從當前 consumer 的 pending 列表裏移除

collector-redis.js:

import Redis from 'ioredis'

const redisClient = new Redis()

async function main() {
    let lastRecordId = '$'
    while (true) {
        const data = await redisClient.xread(
            'BLOCK', '0', 'STREAMS', 'results_stream', lastRecordId)
        for (const [, logs] of data) {
            for (const [recordId, [, message]] of logs) {
                console.log(`Message from worker: ${message}`)
                lastRecordId = recordId
            }
        }
    }
}

main().catch(err => console.error(err))

運行程序測試效果:

node worker-redis.js workerA
node worker-redis.js workerB
node collector-redis.js
node producer-redis.js 4 f8e966d1e207d02c44511a58dccff2f5429e9a3b

參考資料

Node.js Design Patterns: Design and implement production-grade Node.js applications using proven patterns and techniques, 3rd Edition

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章