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

2023/11/18閱讀時間約 16 分鐘

提到後端工程師,似乎就只是開發 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": "[email protected]"
}


這個 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": "[email protected]"
"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

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

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

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

extendable workflow

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



問題回覆

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


後記

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



51會員
100內容數
這是從 Medium 開始的一個專題,主要是想用輕鬆閒談的方式,分享這幾年軟體開發的心得,原本比較侷限於軟體架構,但這幾年的文章不僅限於架構,也聊不少流程相關的心得,所以趁換平台,順勢換成閒談軟體設計。
留言0
查看全部
發表第一個留言支持創作者!