更新於 2024/11/17閱讀時間約 16 分鐘

閒談軟體設計:多種 work 類型

提到後端工程師,似乎就只是開發 API,但一個複雜的系統其實不太可能只透過 API 就能完成,例如一個簡單的功能,註冊會員,其實是由好幾個不同類型的工作互相配合,您才能收到開通信,才確保資料庫不會有一堆未開通帳號等。所以今天就來聊聊一個系統有幾種不同執行方式的工作。

以下的工作類型名稱是我個人的用法,如果有人有找到更正式的說法,請留言告知,感謝。

Synchronous request

這是最直接,也是大家最直覺的工作類型,就是要求系統做事情,系統收到請求後立即開始進行,並將結果回傳。大多數的 API 就是屬於這種類型。以剛剛提到的註冊為例,前端送出 email 與 password,系統先是檢查該 email 是否已經註冊過,若有則立即回傳錯誤;若沒有則在系統資料庫建立一筆帳號,然後回傳結果。前端則是會等待結果回傳為止,因此稱作 synchronous。

開發這類的工作,要注意的便是處理時間要盡可能短,由於呼叫者會等待結果,即便 UI 能搭配一些動畫,讓使用者知道系統正在處理中,沒有掛掉,但使用者總是希望等待的時間越短越好。這是為什麼 API response time 是後端工程師時常錙銖必較的原因。

那如果一個工作無法在數百毫秒或是數十秒內完成,那怎麼辦?例如註冊帳號時,通常會發一封開通信或是發送驗證碼,好確認發出請求的使用者是該 email 的擁有者,假設發信件是透過第三方服務發送,於是在註冊帳號的流程中直接呼叫第三方的 API 嗎?如果第三方花很長的時間,那我們的 API 請求也必須等待很長的時間。

又或是請求一個月帳單報表,這個報表可能要從資料庫的好幾個資料表拉出許多資料,並加以計算與整理,這可能要花上數十秒到數分鐘,總不可能無限等待下去。因此,接下來要探討在背景默默處理事情的服務。

Background service

就像是作業系統啟動後也有很多背景服務,默默地在處理某些事情。一個後端系統,也會有好幾個背景服務,不像是 API 有個公開的端點讓前端呼叫,一種常見的做法,是透過 queue 來觸發背景服務做事,不管是 push 還是 pull,背景服務會接收到一個 X,這個 X 在語意上的差異,讓背景服務分成 job-based 與 event-based 兩種。

Job-based

顧名思義,在 job-based 的背景服務收到的 X 就是一個 job,什麼是 job?就是目的或作法明確的任務,以剛剛的報表為例,這個 job 可能長這樣:

{
"id": "453e99da-2192-4dab-800d-b084e55625af",
"job": "generate-report",
"report": "monthly-revenue",
"month": "2023-11",
"company": "bc9ba483-740d-4b7b-8a16-d56c32d4570d",
"requester": "8f2fd748-e375-436a-95db-33d58e929083",
"destination": "someone@email.com"
}


這個 job 很明確,工作是某人 (requester) 要求產生報表 (generate-report),報表類型是某公司 (company) 的月收入報表 (month-revenue),月份是 2023 年的 11 月,產生的報表要送到指定的目的地 (destination)。也就是背景服務會執行指定的工作,沒有歧異。

一般來說,job-based 背景服務,發送者對於「完成」有比較高的要求。畢竟使用者已經允許系統用較長的時間來執行,但不可避免的,確實會有無法完成的時候,這時候背景服務可以自發的重試 (retry),或是發送通知可以請求者,告知指定的 job 因故無法完成,讓請求者決定要如何處理。

Event-based

因此,event-based 背景服務收到的 X 便是一個 event,所謂的 event 是指過去發生過的事情,它並不是指定要做什麼,因此一個 event 可能像這樣:

{
"id": "fe07c644-2483-4eca-b7e7-c342b6dc7ad2",
"event": "account-created",
"account": "newbie@abc.com"
"timestamp": "2023-11-18T07:33:47.091Z"
}


這個 event 只是說明過去 (timestamp) 曾經發生過 account-created 這件事,可以從 account 知道被建立的帳號為何,慣例上,事件的名稱都會是過去式,畢竟是過去發生的事,那收到後到底要做什麼事?實際的工作內容由各別的背景服務決定,假設有兩個不同的背景服務都接收帳號的事件,一個服務會發送驗證碼,另一個服務則是發送 webhook 給註冊的第三方服務。

有人可能會問,那是否可以把 event 轉成 job,技術上可以,但實務上卻不是好的設計,因為這會讓核心的邏輯充滿許多枝節的細節,以創建帳號為例,核心的邏輯應該只有判斷帳號能否建立,在能建立的前提下建立帳號,發送驗證碼、發送 webhook 都是其他的枝節,假設今天有第三件事想在帳號建立後執行,那就必須修改核心邏輯,但如果是用 event,只需要多加一個背景服務,接收到 event 後執行想增加的事情,核心邏輯並不需要知道第三件事是什麼。

用圖來解說,因為 job 很明確,對應到的 background service 也只會處理指定 job,不會做其他事,因此 job-based background service 與 job queue 是一對一的關係。但 event 則是相反,由接收 event 的 background service 決定要做的事情,不是唯一的關係,event queue 與 backgroud service 能形成一對多的關係。

圖中一個 background service 的方塊是一個相同服務的群,也就是一個方塊內可以有多個實體,來達成平行處理的效果。想要有多個實體,最好選擇有 exactly once 保證的 queue,不然一個 job 可能被執行超過一次,某些 job 可能還可以,但若和金錢有關,肯定會出問題。

job-based vs. event-based background service

因為 event 可以有多個 background service,發送者對於接收者比較是 best effort 的態度,也不會要求所有接收者都完成才算是完成。那如果發送驗證碼的背景服務沒有成功發送驗證碼怎麼辦?可以重新發送 event 嗎?大家先想想,等等再說我的想法。

Timing job

在背景執行任務的還有一種是根據時間觸發,有點像是設定鬧鐘,時間一到就執行某個任務,但不像是背景服務那樣,一直在背景等待,timing service 在執行完指定的任務後,該程式就結束了。根據鬧鐘的設定,又大致分成周期性與單次排程兩種。

Cronjob

週期性工作,基本上就是 cronjob,透過設定,可以指定每分鐘、每小時,甚至是每月的第一天的幾點啟動,不少後端框架都提供支援,甚至是雲端服務也有支援,只要寫好程式,加上設定檔,後端框架或雲端服務就會在指定的時間,執行指定的程式。

相較 API 可以透過 request body 指定輸入,job 和 event 本身就是一種輸入,由框架或雲端服務啟動的程式,比較難動態地提供輸入,因此程式需要的輸入資料要從別的地方來,一般是從資料庫來。例如,可以提供一個 cronjob,設定成每日凌晨一點執行,從資料庫中撈出「超過 24 小時尚未完成啟用」的帳號,然後進行清理。

在寫這類的程式時,可能要考慮幾件事:

  • 能否有 overlap 的情況發生?假設有個每五分鐘執行一次的 cronjob,如果 16:00 啟動的 cronjob 尚未執行完畢,那 16:05 能否啟動第二個 cronjob?如果不行,那 16:10 啟動的 cronjob 要補救 16:05 的工作嗎?
  • 能否接受延遲?如果設定成每小時的整點執行,但因為一些原因,框架或服務遲了三分鐘才啟動 cronjob,那程式能正確運作嗎?
  • 如何切割資料?和 overlap 有點相關,一般來說,會用啟動時間來讀取資料庫中尚未被處理的資料,例如,16:00 啟動的 cronjob 尚未結束,16:05 啟動的 cronjob 是否會讀到已經被 16:00 載入但尚未處理完的資料?

Scheduled task

和 cronjob 週期性執行單一任務不同,有時系統就是需要一種指定某個時間執行某個任務,有些後端框架有提供這樣的支援,可以動態產生 task,然後向框架註冊在某個時間點執行。使用框架的支援要注意:若框架在指定的時間前被重新啟動,已註冊的 task 是否還會執行?如果會,延遲是否是可以接受的?

Sprint framework 提供的 scheduled task,概念比較像是 cronjob。

盡可能使用框架提供的支援或是雲端服務原生的支援,不得以真的要自己搭建一個 task scheduler,要怎麼做呢?一種方式建立一個 job-based 的background service 作為 task scheduler。

background service as a task scheduler

Job 可以類似下面的格式,runAt 指定要執行的時間,scheduler 在收到 Job 後,檢查時間是否已經到了,若還沒,就把 Job 在丟回 queue 中,若已經到了,就看註冊的 executor 中有誰能處理該 task,把 task 交由該 executor 執行。

{
"id": "8d7af310-93fe-4e37-af08-25501112a04e",
"runAt": "2023-11-18T07:33:47.091Z",
"task": {
"type": "make-call",
"phone": "+886987654321",
"content": "https://somewhere.com/voice/something.mp3"
}
}


這作法不見得是有效率的做法,特別是對於要很久以後才執行的 task,會被一直取出再丟回 queue 中,此外,不要太期望這種做法的時間顆粒度有多細,能到秒就很不錯了,也不要期望這種做法的時間準確度,數秒到數十秒的誤差都是常有的。

用 cronjob 實作 task scheduler 也是一種可能的方式,但要非常小心 cronjob 一節所提到的注意事項。

如果想要解耦 task scheduler 和 task executor,可以再加入一層 task queue。task scheduler 並不需要知道有哪些 executor,只要把可以執行的 task 送往下一層的 task queue 即可。

decouple task scheduler and task executors

雖然解耦合有不少好處,例如 task scheduler 的邏輯更簡單、每個 task executor 能有各自的平行處理能力等等,但除非真的需要,不然複雜的設計也會帶來較高的維護與除錯成本。

Batch job

最後一個是最常被忽略的一個,它平凡無奇,而且往往不需要高深的技術或是複雜的設計,主要任務就是批次地完成大量的工作,為什麼我不把它歸納在第一個 synchronous request 中呢?主要是它的執行時間通常很長,數分鐘、數小時到數天都有可能。常見的例子:匯入大量資料到系統中,可能會執行數十分鐘。

有時候更可憐的是,它只會被執行一次,因此也可稱作是 one-time job。例如,系統出現問題後,資料可能出現不一致的情況,這時就可能寫個腳本大批量地修復資料。

另一個例子是database schema migration,當一個資料表已經有上千萬筆資料時,突然要加個欄位,如果直接用 SQL 在新增欄位時加預設值,這 migration 恐怕會鎖住資料表非常長的一段時間。因此,migration 可以只新增欄位,但不給定預設值,然後執行腳本小批量地填上預設值,最後在執行一次 SQL 將預設值的設定加回去。

這類工作耗時很久,不可避免地就可能遇到中斷,例如在本機上跑,卻遇到休眠,在雲端上跑,卻遇到記憶體不足中斷,因此在寫這類程式時,要注意是否可以重複執行

這些腳本或程式,看似沒什麼技術,卻是讓系統減少停機時間很重要的程式。

組合技

如開場所提的,通常一個複雜的系統不會只有一種工作,像是幕之內一步厲害的連續技:肝臟攻擊、羚羊拳加上輪擺式位移完美結合。一個複雜的系統也會由多種不同的工作來完成,就好像註冊帳號,有 synchronous 的API、發送驗證碼的 event-based background service,以及清理資料的 cronjob。

組合的方式其實可以有很多種,有時候可以簡單一點,有時候可以彈性 (複雜) 一點,例如先前提到的報表服務,它其實做了兩件事,一個是產生報表,另一個是將報表寄到指定的郵件信箱,那如果想在報表產生後不見郵件,而是串接到第三方服務呢?

簡化 reporting service,只產生報表,放在某個空間,接著發出 report-generated 事件,要寄 email 就串上 send report 的 service,要串接第三方服務,就串上另一個 service,那要如何產生 job 呢?前面加上 API,如此使用者便可以透過介面請求產生報表,又或者,可以新增一個 cronjob,每天早上產生 job,達到每日寄送報表的功能。

extendable workflow

如此一來,圖中的每個方塊,責任都很專一 (high cohesion),之間的耦合也不高 (low coupling),形成容易調整跟擴充的系統。善用這幾種工作類型,設計出更好的系統。



問題回覆

在前面有提到,若發送驗證碼失敗,是要再次發送一次 event 嗎?個人建議是不要,由於 event-based 的系統,很難確保會有多少 service 接收該 event,再次發送 event 可能會導致重複執行。確實,多數的 event queue 並不保證 exactly once,通常背景服務會處理短時間內接收到相同事件的問題。但刻意再發送一次,會有語意上的誤會,是真的建立兩次帳號嗎?因此,我個人是偏向,有個 API 重置驗證碼,提高安全性,然後發出 validation-code-reset 的事件 (reset 的過去式還是 reset),然後背景服務在收到該事件後再次寄送驗證碼。


後記

沒想到第一神拳已經連載 34 年了 (1989 開始連載),雖然我只看動畫,但還是覺得很熱血,最後附上幕之內一步對千堂使出肝臟攻擊、羚羊拳加上輪擺式位移的連續技。



分享至
成為作者繼續創作的動力吧!
© 2024 vocus All rights reserved.