部落格技術

在 Railway 上依 Postgres 佇列自動調整 worker 數量

我們把轉譜從 Web 伺服器上拆出來,交給共用一個 PostgreSQL 佇列、在 Railway 上自動調整數量的 worker。這是實作筆記,也記下了最先出問題的那個 API 呼叫。

先說結論

2026年10月7日,我們把 ScoreStarling 原本唯一的 Railway 服務一分為二:網站只處理請求,1 到 4 個 worker 複本負責轉譜。佇列仍是 PostgreSQL 資料庫中的一張資料表:worker 在交易層級 advisory lock 的保護下認領最早的任務,每 15 秒回報一次;任何任務沉寂兩分鐘,就會被另一個 worker 收回。Railway 不會自己增加複本,所以由擔任 leader 的 worker 依佇列狀況,透過 Railway API 設定複本數;不過第一版查詢了一個 API 中根本不存在的欄位。

為什麼要把轉譜從 Web 伺服器上拆出去?

因為 Web 伺服器同時也是唯一的轉譜 worker,想擴充只能換更大的容器。10月7日之前,一個 2 vCPU、8 GB 的 Railway 複本既要負責網站、API 和 MCP 端點,又要一個接一個執行所有轉譜。只有持有工作階段層級 advisory lock 的那個執行個體才會處理佇列,所以多加一個複本也不會增加任何轉譜能力。

現在,同一個映像檔透過一項角色設定,以兩個 Railway 服務執行:web 從不接任務,worker 沒有對外網址。一個任務就是一次轉譜(ScoreStarling 能把錄音變成樂譜):在我們抽樣的 10月2日至7日正式環境執行紀錄中,每個任務佔用 worker 17 到 266 秒。

網站只負責把任務寫進 PostgreSQL 的一張資料表。1 到 4 個 worker 複本領取任務,每個複本同一時間只處理一個;虛線框的複本只在有任務等待時執行。擔任 leader 的 worker 也會透過 Railway API 設定複本數,Railway 依此啟動或停止複本,不必重新部署。

為什麼把佇列放在 Postgres,而不用訊息代理?

因為任務那一列本身就存著訊息代理(broker)只能指向的東西:歸屬、狀態、點數預扣、價格和檔案。用訊息代理就多了一份需要保持同步的儲存;而且大多數訊息代理只保證至少傳遞一次,而樂團轉譜是對外部服務送出的付費請求,絕不能送出兩次。無論如何,我們都得自己訂出任務歸屬規則。

負載也不大:排隊的任務最多幾十個,每個耗時幾秒到幾分鐘。Railway 的佇列指南說,如果你已經在用 Postgres,拿它做佇列需要的元件比較少,只是吞吐量不如 Redis。pg-boss 這類佇列函式庫則會再多出一張任務資料表。等到每秒幾十個任務時,我們再重新考慮。

多個 worker 共用一個 Postgres 佇列,要怎麼避免領到同一個任務?

每次認領都是一個短交易,整個佇列共用一把鎖。閒置的 worker 先不加鎖檢查有沒有排隊的任務,沒有的話兩秒後再看:

-- One claim, slightly simplified
SELECT 1 FROM jobs WHERE status = 'queued' LIMIT 1;  -- nothing queued: stop here
SELECT pg_advisory_xact_lock(<queue key>);           -- held until COMMIT
SELECT * FROM jobs WHERE status = 'queued' ORDER BY created_at LIMIT 1;
UPDATE jobs SET status = 'running', worker_id = $1, heartbeat_at = $2
 WHERE id = $3;
COMMIT;

第二個 worker 會在鎖上等待,它的下一個查詢要等第一個 worker 提交之後才開始。在 PostgreSQL 預設的 Read Committed 隔離等級下,查詢能看到它開始前已提交的資料,所以這個任務已經顯示為執行中。在我們的測試中,6 個 worker 用各自獨立的連線處理完 40 個排隊任務,每個任務都恰好只被領取一次。

worker 很多時,常見做法是 SELECT … FOR UPDATE SKIP LOCKED,PostgreSQL 文件也建議在類似佇列的資料表上這麼做。我們只用一把鎖,是因為上傳、點數異動和任務復原本來就要用它(在本機的 SQLite 版本中對應的是 BEGIN IMMEDIATE),這樣認領就會和這些操作輪流進行。這段等待時間我們還沒有測量。佇列也不需要專屬的資料庫工作階段:不用 LISTEN(PgBouncer 的 transaction pooling 模式不支援它),也不用我們以前那個唯一的 leader 所持有的工作階段層級鎖。

worker 停止時,它手上的任務會怎樣?

  1. 排隊中網站儲存上傳的檔案,並新增一列任務資料。
  2. 已認領閒置的 worker 在鎖的保護下領取最早的任務。
  3. 執行中worker 每 15 秒回報一次,附上目前進行到的階段。
  4. 已完成樂譜儲存完畢,worker 再去找下一個任務。
一個任務的生命週期。每一步都是修改該任務在 PostgreSQL 中的那一列,網站也是從這一列讀取進度。

任務會被復原,付費請求也絕不會送出兩次。被主動停止的 worker(例如部署時)會自己把手上的免費任務放回佇列。當掉的 worker 則只是從此沒了動靜。worker 每 15 秒把目前時間和所處階段寫進任務列;每個 worker 每 30 秒會在同一把鎖下檢查一次,找出 120 秒租約已到期卻沒有回報的執行中任務。免費轉譜會被放回佇列一次,再次中斷就判定失敗。樂團轉譜會從儲存下來的請求繼續;沒有儲存請求的,就等待人工審查。如果某個 worker 只是比較慢,發現自己的任務已經交給別人,它會終止自己的轉譜 process,不寫入任何結果。

只能執行一次的工作(例如控制器)在 leader 上執行:leader 是過去 45 秒內曾向 workers 資料表回報的 worker 中 ID 最小的那個,每個 worker 每 15 秒回報一次。整個過程不持有任何鎖,所以 leader 掛掉後,45 秒內就會有新的 leader 接手。

為什麼依佇列深度、而不是 CPU 來調整 worker 數量?

因為一個 worker 同一時間只執行一個任務,需要的 worker 數就等於排隊中加上執行中的任務數。忙碌 worker 的 CPU 只能說明它在忙,說明不了在等的是一個任務還是二十個;Railway 的自動擴展指南也把佇列深度列為 worker 的擴展訊號。Railway 會自行把容器擴充到 CPU 和記憶體上限,但複本數會一直維持在你設定的值;指南中的控制器擴展時一步到位,縮減時逐步遞減。

我們的縮減方式不同。API 只接受一個複本數,要停掉哪個複本由 Railway 決定,所以我們只在沒有排隊和執行中的任務、也沒有 worker 在忙時才縮減,而且直接縮到一個。

控制器的決策規則(2026年10月7日的設定)
規則數值原因
檢查佇列的間隔30 秒只有 leader 會檢查,所以每個決定只做一次。
保留的 worker 數與上限1 到 4 個留一個隨時接下一個任務;上限用來控制帳單。
已有 worker 看似閒置時,任務要等多久才申請新 worker30 秒閒置的 worker 通常 2 秒內就會領走任務;沒有閒置的 worker 時,下一次檢查就會申請。
兩次擴展之間的間隔90 秒給新 worker 留出啟動時間,再進行下一次申請。
等待新 worker 出現的時間300 秒在此之前,控制器不會再次申請。
縮回一個之前的閒置時間600 秒既沒有任務、也沒有忙碌的 worker 時,Railway 停掉的複本必然是閒置的。

有任務在排隊時,控制器會依每個排隊中或執行中的任務各一個 worker 來申請,至少比現有的多一個,最多四個。

正式環境出了什麼問題?

問題出在控制器第一次呼叫 Railway 的真實 API;我們的測試用的是一個對任何查詢都照單全收的測試替身。啟用控制器的變更合併五分鐘後,每次檢查佇列都失敗了。worker 照常領取任務,但日誌和 Sentry 裡只有 RailwayError:我們的日誌不記錄例外訊息。

不帶權杖送出同一個查詢,就能重現。Railway 先依 schema 驗證查詢,回傳了 HTTP 400 和 GRAPHQL_VALIDATION_FAILED。我們的服務依區域部署,所以寫入時設定的是 multiRegionConfig,更新操作的輸入型別接受這個欄位;但讀取時卻向服務執行個體查詢這個欄位,而服務執行個體根本沒有它。修正後改從環境設定中讀取複本數,我們的 Railway 設定檔和 railway scale 改的也是這份設定。

# Before: rejected with GRAPHQL_VALIDATION_FAILED
query ($service: String!, $environment: String!) {
  serviceInstance(serviceId: $service, environmentId: $environment) {
    numReplicas multiRegionConfig
  }
}

# After: the count is at services.<service id>.deploy.multiRegionConfig
query ($environment: String!) {
  environment(id: $environment) { config(decryptVariables: false) }
}

# The write, unchanged; input: {"multiRegionConfig": {"<region>": {"numReplicas": 2}}}
mutation ($service: String!, $environment: String!, $input: ServiceInstanceUpdateInput!) {
  serviceInstanceUpdate(serviceId: $service, environmentId: $environment, input: $input)
}

現在日誌會記錄 Railway 自己回傳的錯誤訊息(其中從不包含權杖),因為權杖被拒和查詢被拒回傳的都是 HTTP 400。測試替身現在也會拒絕舊的查詢。我們還加了一項新檢查:用佔位 ID、不帶權杖,把兩個操作送到 Railway 實際運作的 schema。GraphQL 伺服器會先驗證再執行,所以欄位寫錯會在驗證階段失敗,而有效的查詢會一路走到 Railway 回傳「Not Authorized」。

  1. 佇列可容納的等待任務數提高為原本的數倍,但仍然只有一個 worker 在處理。

  2. 合併:worker 共用佇列,網站旁新增一個 worker 服務。測試中,兩個上傳同時執行。

  3. 合併:網站不再接任務,並啟用控制器。

  4. 每次檢查佇列都以 RailwayError 失敗。任務照常執行。

  5. 合併:改從環境設定中讀取複本數。

  6. 第一次從 Railway 讀到複本數:一個 worker,狀態穩定。

  7. 同時來了兩個任務。控制器申請第二個 worker,17 秒後它就啟動了。

  8. 閒置 602 秒後縮回一個。直到 06:28 都沒有錯誤。

上線過程,摘自我們的驗證紀錄。時間皆為 UTC。

同一天,leader 開始儲存每次檢查的結果,供網站的健康狀態頁面使用:Railway 拒絕權杖,或兩分鐘內沒有任何檢查,狀態頁面都會發出警告。網站本身的健康檢查不再涵蓋 worker,所以 worker 故障現在會反映在每五分鐘一次的服務檢查中;某個服務連續兩次狀態不佳,Sentry 就會寄電子郵件通知我們。

還沒驗證的部分,以及成本

  • worker 上跑過的只有免費轉譜;樂團轉譜在 worker 上的執行,以及 worker 遺失後的復原,都還沒測過。
  • 我們的測試帳號最多只能同時處理兩份樂譜,而且我們跳過了原本規劃的幾天 dry-run 日誌觀察,所以控制器只經歷過一次小規模的突發流量和一小時的閒置。三到四個 worker、90 秒的擴展間隔、停掉忙碌的複本,這些都還沒在正式環境中發生過。
  • 正式環境沒有記錄任務被領取的時間,所以我們還說不出排隊等待縮短了多少。在擴展測試中,新 worker 啟動後無事可做:第一個 worker 已經領走了那個等待中的任務。
  • 閒置十分鐘後 worker 池會縮回一個,所以突發的任務最多要等 30 秒才輪到下一次檢查,接著還要等新 worker 啟動:在我們唯一一次的測試中是 17 秒。
  • 達到上限時,同時執行四個任務,其餘的排隊等待。
  • 每次部署都會重新啟動所有 worker,執行中的免費轉譜會被放回佇列一次;CI 每次套用我們的 Railway 設定時,也會把複本數重設為一,直到下一次檢查。

Railway 按分鐘計費,每 vCPU 每月 US$20,每 GB 記憶體每月 US$10(2026年10月7日確認)。我們估算,一個 worker 以 2 vCPU、1.5 GB 轉譜時每小時約 US$0.08,等待時則少得多。Railway 上的硬性用量上限一旦達到,所有工作負載都會停止運作,所以調高 worker 上限時,也要同時調高這類用量上限。

如果你也想自己做

  • 除非需要訊息代理的路由能力或吞吐量,否則就把佇列和任務狀態放在一起。
  • 在一個短交易中完成認領;worker 多時用 SKIP LOCKED。
  • 佇列不要依賴工作階段狀態:不用 LISTEN,也不用工作階段層級的鎖。
  • 為每個執行中的任務指定歸屬和心跳;任務被收回的 worker 要直接停止,不寫入任何結果。付費的外部請求絕不重送,而是憑儲存的 ID 繼續。
  • 依排隊中加執行中的任務數調整規模;如果要停掉哪個複本由平台決定,就只在沒有 worker 忙碌時縮減。
  • 用平台實際運作的 schema 測試你的 API 呼叫;確認錯誤訊息不含機密後,再把它們寫進日誌。
  • 給控制器一個回報狀態的地方。

參考資料

  1. 13.3. 明確鎖定:13.3.5 Advisory Locks,PostgreSQL 18 說明文件 — PostgreSQL 全球開發小組
  2. 9.28. 系統管理函式:9.28.10 Advisory Lock 函式,PostgreSQL 18 說明文件 — PostgreSQL 全球開發小組
  3. SELECT:鎖定子句,PostgreSQL 18 說明文件 — PostgreSQL 全球開發小組
  4. 13.2. 交易隔離:13.2.1 Read Committed 隔離等級,PostgreSQL 18 說明文件 — PostgreSQL 全球開發小組
  5. PgBouncer 功能 — PgBouncer
  6. 依負載自動水平擴展服務 — Railway
  7. 排程工作、背景 worker 與佇列,該選哪一個 — Railway
  8. 價格方案 — Railway
  9. 成本控管 — Railway
  10. GraphQL 規範(2021年10月版):6.1.1 驗證請求 — GraphQL 基金會

常見問題

Railway 會自動水平擴展複本嗎?

截至 2026年10月7日不會。Railway 會在上限內給容器更多 CPU 和記憶體,但服務的複本數會一直維持在你設定的值。它的自動擴展指南要你自己執行一個控制器,讀取負載訊號,再呼叫 API 的 serviceInstanceUpdate。

Postgres 任務佇列該用 SKIP LOCKED 還是 advisory lock?

FOR UPDATE SKIP LOCKED 能讓多個 worker 同時認領不同的資料列,PostgreSQL 文件也建議在類似佇列的資料表上使用它。用一把交易層級的 advisory lock,則會讓認領輪流進行;worker 不多時這樣比較簡單,尤其是這把鎖本來就在保護你的其他寫入時。

為什麼 Railway API 對 multiRegionConfig 回傳 GRAPHQL_VALIDATION_FAILED?

我們遇到的原因是:查詢要求服務執行個體回傳 multiRegionConfig,但這個欄位只有更新操作的輸入型別才有。應該從 environment(id) { config } 中的 services.<service id>.deploy.multiRegionConfig 讀取複本數,再用 serviceInstanceUpdate 寫入。

移除 worker 複本時,正在執行的任務會怎樣?

我們的控制器只在沒有 worker 忙碌時才移除複本。如果忙碌的 worker 仍被停掉(例如部署時),它會把免費轉譜任務放回佇列一次;如果它直接消失,另一個 worker 會在 120 秒沒收到回報後把任務收回。付費請求絕不會重送。

在 Railway 上多一個 worker 要花多少錢?

Railway 按分鐘計費,每 vCPU 每月 US$20,每 GB 記憶體每月 US$10(2026年10月7日確認)。我們估算,一個 worker 以 2 vCPU、1.5 GB 轉譜時每小時約 US$0.08,等待時則少得多。我們最多只開四個 worker。

帶來任何音樂帶走一份樂譜