博客技术

在 Railway 上按 Postgres 队列自动扩缩容 worker

我们把转谱从 Web 服务器上拆出来,交给共享一个 PostgreSQL 队列、在 Railway 上自动伸缩的 worker。这是实践笔记,也记下了最先出问题的那个 API 调用。

先说结论

2026年10月7日,我们把 ScoreStarling 原本唯一的 Railway 服务一分为二:网站只处理请求,1 到 4 个 worker 副本负责转谱。队列仍然是 PostgreSQL 数据库中的一张表:worker 在事务级咨询锁的保护下认领最早的任务,每 15 秒汇报一次;任何任务沉默两分钟,就会被另一个 worker 收回。Railway 不会自己增加副本,所以由担任 leader 的 worker 根据队列情况,通过 Railway API 设置副本数;不过第一个版本查询了一个 API 里并不存在的字段。

为什么要把转谱从 Web 服务器上拆出去?

因为 Web 服务器同时也是唯一的转谱 worker,想扩容只能换更大的容器。10月7日之前,一个 2 vCPU、8 GB 的 Railway 副本既承载网站、API 和 MCP 端点,又要一个接一个地运行所有转谱任务。只有持有会话级咨询锁的那个实例才处理队列,所以再加一个副本也不会增加任何转谱能力。

现在,同一个镜像靠一项角色配置,作为两个 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 的事务池模式不支持它),也不用我们以前那个唯一的 leader 所持有的会话级锁。

worker 停止时,它手上的任务会怎样?

  1. 排队中网站保存上传的文件,并新增一行任务记录。
  2. 已认领空闲的 worker 在锁的保护下领取最早的任务。
  3. 运行中worker 每 15 秒汇报一次,附上当前所处的阶段。
  4. 已完成乐谱保存完毕,worker 去找下一个任务。
一个任务的生命周期。每一步都是对该任务在 PostgreSQL 中那一行的修改,网站也是从这一行读取进度的。

任务会被恢复,付费请求也绝不会发送两次。被主动停止的 worker(比如部署时)会自己把手上的免费任务放回队列。崩溃的 worker 则只是从此没了动静。worker 每 15 秒把当前时间和所处阶段写进任务行;每个 worker 每 30 秒会在同一把锁下检查一遍,找出 120 秒租约已到期却没有汇报的运行中任务。免费转谱会被放回队列一次,再次中断就判为失败。乐队转谱会从保存下来的请求继续;没有保存请求的,就等待人工审核。如果某个 worker 只是慢了,发现自己的任务已经转给了别人,它会终止自己的进程,不写入任何结果。

只能执行一次的工作(比如控制器)在 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、不带令牌,把两个操作发到线上 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 咨询锁,PostgreSQL 18 文档 — PostgreSQL 全球开发组
  2. 9.28. 系统管理函数:9.28.10 咨询锁函数,PostgreSQL 18 文档 — PostgreSQL 全球开发组
  3. SELECT:锁定子句,PostgreSQL 18 文档 — PostgreSQL 全球开发组
  4. 13.2. 事务隔离:13.2.1 读已提交隔离级别,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 还是咨询锁?

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。

带来任何音乐带走一份乐谱