FastAPI 生产部署中的后台任务:从进程内定时器到独立 Worker FastAPI 的lifespan很适合管理进程级资源也经常被用来启动后台协程。进入多进程和多副本部署后需要重新确认这些任务到底应该每进程、每实例还是全集群执行一次。这里的复制不是 FastAPI “特殊地复制了任务”而是谁启动了应用实例谁就会执行一遍lifespan。开发环境中一个进程既提供 HTTP又每 60 秒恢复冷却、刷新凭证配置很简单。Uvicorn 调到 4 个 Worker 后每个进程都会执行启动钩子若没有选主、共享锁、幂等或分片recover_cooldowns和refresh_keys也会各跑 4 份可能放大 OAuth 刷新流量。本文分析进程内定时器为何随 HTTP 进程复制并比较lifespan协程、带选主的调度器、独立 Worker 和托管定时任务。文中的「同一镜像、两个启动命令」是一种工程选择不是 FastAPI 生产部署的标准答案。适用场景FastAPI 服务已经包含周期扫描冷却恢复、OAuth 刷新、对账、清缓存准备启用多 worker 或进行水平扩容。先说结论若周期任务要求全集群只执行一次直接放进lifespan会被 Uvicorn workers 和 Deployment 副本复制。可以把它移到独立 Worker也可以保留在进程内并增加分布式选主或幂等执行前者隔离更清楚后者组件更少。任务本身很轻、可重入、幂等且允许重复时留在应用进程内也可能是最省心的方案。本文参考形态是双运行时、单镜像uvicorn负责 HTTPpython worker_main.py负责轻量周期扫描。本地 Compose 可用两个 serviceKubernetes 中既可以同 Pod 双容器也可以两个 Deployment。前者版本绑定和本地性更强后者能独立扩缩、发布和设置故障域。后台任务的并发需求和 HTTP 吞吐不是同一维度。示例默认UVICORN_WORKERS1并通过副本扩展 HTTP以保持节点心跳每实例一份。若心跳已按进程生成唯一身份或由独立选主组件负责也可以使用多个 Uvicorn worker。判断时可以问两点这份工作要求每进程、每实例还是全集群执行它是否会阻塞或放大 HTTP 进程要求单例且成本较高时独立调度通常更清晰任务短小、幂等且允许重复时保留在lifespan可能更经济。进程内定时器会随 HTTP 进程一起复制Uvicorn--workers 4会起 4 个进程。每个进程都会执行一遍lifespanKubernetes 扩成多个 Pod 时每个 Pod 里的应用实例也各自执行。于是冷却恢复同时跑 4 份同一批 OAuth 被重复 refresh在没有选主、幂等和共享节流保护的前提下供应商侧出现按实例数放大的 refresh 风暴运行时锁能挡住一部分双写挡不住四倍探测就算把 workers 固定为 1HTTP 扩容时仍会把后台任务复制出去。Deployment 从 1 副本改到 3lifespan 循环就变成 3 份。你加副本是为了扛分配 QPS扫描并不需要变成 3 倍。如果节点心跳以 Pod 为身份、又没有进程内选主可以采用下面的保守配置UVICORN_WORKERS 设为 1通过 Pod 副本扩展 HTTP 后台周期任务由独立 Worker 负责。环境变量钉死这两项# 示例API 与 Worker 共用镜像且节点心跳没有进程内选主。 UVICORN_WORKERS1 BACKGROUND_TASK_INTERVAL_SECONDS60同一镜像方案可以把 HTTP 设为默认命令再由编排覆盖 Worker 命令FROM python:3.13-slim-bookworm WORKDIR /app EXPOSE 8000 # 示例默认运行 APIWorker 由编排覆盖 command。 CMD [sh, -c, uvicorn main:app --host 0.0.0.0 --port ${PORT:-8000} --app-dir src --workers ${UVICORN_WORKERS:-1}]同一镜像便于共享依赖版本、保持协议随同一发布节奏演进也不容易出现“API 升了、Worker 还没升”的漂移代价是 API 与 Worker 的依赖、权限边界、漏洞面和镜像体积被绑在一起调试与监控维度也更混。依赖差异大、最小权限要求强、镜像体积敏感或希望分别发布/回滚时更适合拆成两个镜像。默认命令也可以设为无操作并要求编排显式指定以减少误启动错误角色。lifespan里适合保留什么下面是一种拆分后的 API 启动钩子不再自启冷却恢复和凭证刷新只保留启动期依赖与可选的网关注册客户端asynccontextmanagerasyncdeflifespan(app:FastAPI):settingsapp.state.settings write_engine,read_engineresolve_engines(app)ifsettings.runtime_modelocal:awaitbootstrap_sqlite(settings.local_sqlite_path,write_engine)else:redis_cacheresolve_redis(app)awaitbootstrap_postgres(settings.database_write_url,write_engine)gateway_taskNonestop_eventasyncio.Event()gateway_configbuild_gateway_client_config(settings)ifgateway_configisnotNone:gateway_taskasyncio.create_task(run_gateway_client(gateway_config,stop_event))try:yieldfinally:stop_event.set()ifgateway_taskisnotNone:gateway_task.cancel()withsuppress(asyncio.CancelledError):awaitgateway_taskawaitwrite_engine.dispose()ifread_engineisnotwrite_engine:awaitread_engine.dispose()这段值得盯的点create_app把lifespanlifespan绑死。Uvicorn 每个 worker 进程都会跑一遍。示例把网关心跳放在 API 进程并让多个进程共享NODE_ID。在这个前提下UVICORN_WORKERS1会产生重复心跳因此示例取单 Worker。替代方案包括每进程独立 ID、Pod 内选主、sidecar 探测 API 后代报或直接使用编排平台的服务发现。参考实现把后台扫描移走。若重新放回 lifespan需要同时限制副本数或增加选主、幂等键和探测限流否则重复执行问题会再次出现。把扫描放进 lifespan 时典型写法是stop_eventasyncio.Event()bg_taskasyncio.create_task(run_worker_loop(key_service,interval_seconds,stop_event))try:yieldfinally:stop_event.set()bg_task.cancel()开发单进程--reload完全能跑。一开--workers 44 个事件循环4 个 60 秒 ticker4 路刷新同时打 OAuth token endpoint供应商按 IP 限流池子自己把自己打进限流进程收到 SIGTERM 时HTTP 请求和扫表抢着退出状态更新写一半凭证池这类服务里扫描是最终一致的晚 60 秒恢复一把 key 可以接受API 分配路径晚 200ms 不可接受。两者不该共享失败域也不该共享进程数。后台任务和请求生命周期冲突周期任务如果和请求跑在同一事件循环一次慢的全表刷新可能拖住 event loop。若每次探活超时 12 秒几十把 key 串行处理可能持续数分钟。异步 I/O 本身不会阻塞事件循环但同步 SDK、CPU 计算或过大的并发回调会影响请求 P99应通过异步客户端、有界并发和独立资源池验证。健康检查失败时编排系统重启的是「API 扫描」整体。扫描进度丢了还好正在处理的 HTTP 分配也会断。扩容、缩容、发布都以 HTTP 副本数为准扫描被绑着走。运行时锁可以避免同一行被并发提交但是否能减少重复探测取决于加锁位置先探测后加锁仍会放大上游 QPS持锁探测可以抑制重复请求却会延长锁时间并降低吞吐。若任务留在lifespan还需要选主、分片或全流程锁而不能只保护最后一次写库。参考实现只给 API 暴露 HTTP 健康检查router.get(/health)asyncdefhealth(request:Request)-JSONResponse:status_code,bodyawaitrun_health_checks(request)returnJSONResponse(status_codestatus_code,contentbody)示例的 API 检查 app、database 和 RedisWorker 不提供 HTTP。也可以让 Worker 暴露独立健康端点或写入心跳记录。扫描与 API 同进程时共享健康检查和退出生命周期拆开后应分别定义 API 可达、调度循环存活、最近一次成功迭代是否在 SLA 间隔内以及外部依赖是否可达避免“进程存活”等同于“任务正常推进”。Worker 活着但 10 分钟都没完成一轮恢复/刷新也不应算健康。这个语义很重要只有在分配热路径不强依赖 Worker 的实时结果或已经有缓存/降级策略时分配才可以在 worker 死掉时继续进行。示例里 Lua 热路径仍能把到期冷却当成可用因此体验会差一点管理面容量低估、OAuth 可能过期但不会把「扫表失败」升级成「整个节点不能租 key」。若分配必须依赖 Worker 提供的强探活、短期 token 或唯一调度锁Worker 不可用就会直接影响业务面。进程内、独立 Worker 与托管调度本文的独立 Worker 形态一种单镜像部署 ├─ uvicorn main:app --app-dir src # HTTP └─ python src/worker_main.py # 周期扫描可以记这几条边界双运行时、单镜像适合 API 与 Worker 依赖接近、希望版本同步的场景部署单元按约束选择本地可用一个 Compose stack生产可选同 Pod 双容器或独立 Deployment后台任务归 worker冷却恢复与凭证刷新由 sidecar 周期执行API lifespan 不再自启后台循环共享真相源worker 保持无状态只通过现有 PostgreSQL Redis 工作它不是唯一部署方式lifespan后台协程适合每进程任务、短任务或允许重复且有幂等保护的任务收益是零额外服务代价是随 HTTP 进程复制并共享事件循环。进程内任务 分布式选主适合希望少一个部署对象、又要求全集群单例的任务收益是组件集中代价是选主租约、脑裂和故障切换需要验证。本地锁或进程内锁只能约束单进程并发不能替代全集群单例语义想做到“整个集群只跑一份”仍需依赖分布式锁、选主、分片或外部调度系统。独立 Worker适合扫描较重、执行频率与 HTTP 扩缩无关的任务收益是资源和失败域独立代价是增加部署、探活与监控对象。Celery Beat、APScheduler 独立调度器或云定时服务适合已有任务平台或需要日历式调度的系统收益是调度能力完整代价是额外基础设施与投递语义。Worker 入口保持无状态Sidecar worker 进程入口importasynciofromcontainerimportcreate_container,get_settingsfromworkers.backgroundimportrun_worker_loopdefbuild_key_service(settings):containercreate_container(settings)returncontainer.resolve(KeyService)asyncdef_run_worker(settings):key_servicebuild_key_service(settings)stop_eventasyncio.Event()awaitrun_worker_loop(key_service,settings.background_task_interval_seconds,stop_event,)defmain():settingsget_settings()asyncio.run(_run_worker(settings))if__name____main__:main()示例让 API 与 Worker 复用同一依赖容器和 Redis 键规范以减少协议漂移。Worker 也可以使用独立客户端、连接池和最小权限账号这更利于资源与权限隔离但键前缀、序列化和租约脚本需要抽成共享协议包或通过契约测试保持一致。循环实现asyncdefrun_worker_iteration(key_service)-tuple[int,int]:recovered,refreshed0,0try:recoveredawaitkey_service.recover_cooldowns()exceptExceptionasexc:logger.warning(phaserecover_cooldowns error%s,exc)try:refreshedawaitkey_service.refresh_keys()exceptExceptionasexc:logger.warning(phaserefresh_keys error%s,exc)returnrecovered,refreshedasyncdefrun_worker_loop(key_service,interval_seconds:int,stop_event:asyncio.Event):whilenotstop_event.is_set():recovered,refreshedawaitrun_worker_iteration(key_service)ifrecoveredorrefreshed:logger.info(recovered%s refreshed%s,recovered,refreshed)try:awaitasyncio.wait_for(stop_event.wait(),timeoutinterval_seconds)exceptasyncio.TimeoutError:passexceptasyncio.CancelledError:break要点示例的一次迭代只做恢复与刷新。Worker 可以暴露管理或健康 HTTP但若同时承载业务分配 API就会重新耦合扩缩容与资源预算需要单独评估。两段独立 try。恢复失败不阻断刷新刷新失败不让进程退出。契约测试要求恢复抛错后仍跑刷新。间隔来自配置。测试断言main()把 settings 里的秒数原样传进循环避免有人在入口写死 60。等待用stop_event便于接入 SIGTERM。生产入口应显式注册信号处理、设置终止宽限期并让当前迭代在“完成、回滚或可安全重试”后退出只依赖运行时取消可能中断写入。优雅停止本身并不自动保证正确性单次迭代仍应具有清晰事务边界、幂等写入和可重试语义否则只是把“突然死掉”换成“半写入退出”。本地开发两个命令并排开cp.env.example .env uvicorn main:app--reload--app-dir src python src/worker_main.py示例只给 API 使用--reload。Worker 若也启用热重载需要让单次刷新具备事务性或可重入性并接受开发期间任务被取消生产环境通常由编排系统做受控滚动。Compose一个 stack 里的两个服务name:keyflowservices:keyflow-api:build:context:.dockerfile:docker/Dockerfilecommand:[sh,-c,uvicorn main:app --host 0.0.0.0 --port ${PORT:-8000} --app-dir src --workers ${UVICORN_WORKERS:-1},]env_file:[.env]environment:DATABASE_URL:${DATABASE_URL:-postgresqlasyncpg://keyflow:keyflowpostgres:5432/keyflow}REDIS_URL:${REDIS_URL:-redis://redis:6379/0}ports:-${PORT:-8000}:${PORT:-8000}healthcheck:test:[CMD,python,-c,import os,urllib.request; pos.environ.get(PORT,8000); urllib.request.urlopen(fhttp://127.0.0.1:{p}/health, timeout3),]interval:15stimeout:5sretries:3keyflow-worker:build:context:.dockerfile:docker/Dockerfiledepends_on:keyflow-api:condition:service_healthycommand:[python,src/worker_main.py]env_file:[.env]environment:DATABASE_URL:${DATABASE_URL:-postgresqlasyncpg://keyflow:keyflowpostgres:5432/keyflow}REDIS_URL:${REDIS_URL:-redis://redis:6379/0}对照着看同一 Dockerfile。构建一次两个 service 用同一镜像若 API 与 Worker 依赖差异较大也可用多阶段构建产出两个受版本约束的镜像。启动命令不同。API 走 uvicornworker 走python src/worker_main.py。这就是「同一镜像两个启动命令」在 Compose 里的落点。同一份.env。数据库、Redis、扫描间隔不会在两边各写一套。Worker 不对外暴露业务端口。它仍可以提供仅集群内可见的健康或指标端口也可以用进程探针和最后成功迭代时间监控。示例用depends_on等 API healthy。这是因为示例把库表 bootstrap 放在 API lifespan。若迁移由独立命令、init 容器或 Job 完成Worker 应等待数据库 schema 就绪而不是 API/health启动依赖应指向真正前置条件。示例的 API 使用单 Worker。这是因为示例仍把部分进程内任务如共享NODE_ID的心跳保留在 API 进程里。若这些任务已改为每进程身份、完成选主或完全外移则UVICORN_WORKERS1不再是原则而只是该示例约束下的保守配置。启动dockercompose --env-file .env up-d--buildcurlhttp://localhost:8000/healthdockercomposepsps里应该能看到 API 和 Worker 都 Up。只有 API、没有 Worker 时若系统还实现了分配路径的机会主义恢复活跃凭证仍可逐步恢复空闲凭证的管理面状态则可能保持在旧值。排障时可先确认 Worker 是否运行再检查状态机。Kubernetes同 Pod 还是独立 Deployment如果 API 与 Worker 需要锁定同版本、共享节点本地资源或按节点一一配对可以部署为同 Pod 双容器Deployment/keyflow-node Pod container: api command: uvicorn main:app ... --workers 1 container: worker command: python src/worker_main.py volume: 模型别名 ConfigMap只读 env: 同一份 Secret / ConfigMap这种选择的收益与代价当一个节点一个出口 IP、一份本地凭证库存是部署单元时同 Pod 能让 API 和 Worker 共享网络命名空间及版本生命周期。若只共享远程 PostgreSQL 和 Redis则不要求同 Pod。同 Pod 会一起调度和重启版本同步简单但无法独立扩缩。两个 Deployment 可以让 API 扩到 5、Worker 保持 1也能分别发布代价是需要显式管理协议兼容和镜像版本。网络策略、资源限额按节点收口。worker 要限 CPU/内存避免刷新风暴挤掉分配延迟API 要限内存避免 uvicorn 把节点打满。在同 Pod 一一配对方案中副本数对应节点数5 个 Pod 各包含 API 与 Worker。若多个 Pod 连接同一套库周期任务需要分区、选主或幂等锁来避免重复探测。若任务本来是全集群级独立单副本 Worker Deployment 往往更直接。网关是另一条轴。子节点可选注册GATEWAY_URLhttp://keyflow-gateway:8001 GATEWAY_REGISTER_KEYchange-me NODE_IDnode-shanghai-01 NODE_PUBLIC_BASE_URLhttp://keyflow-node-01:8000示例在变量不完整时禁用心跳本地分配仍可工作。网关既可以独立部署获得独立扩缩和故障域也可以在边缘单节点场景中与 API 同 Pod减少网络与运维对象。选择取决于控制面是否服务多个节点以及网关故障是否应影响本地分配。在示例中API 的分配热路径不依赖 Worker RPC因此 Worker 短时不可用时仍可分配只是冷却恢复和刷新会延迟。若策略要求每次分配前强探活或 Worker 持有唯一调度锁API 就存在运行时依赖发布与探针设计需要相应调整健康检查分开。API 可探活/healthapp/db/redisWorker 可提供独立 liveness/readiness、写入心跳表或暴露最后成功迭代时间。仅检查“进程还在”实现简单但检测不到循环卡死。可以先更 worker 再更 API或反过来不必绑在同一次不可用窗口。共享的是数据不是内存中的调度队列。独立容器可以分别设置资源 request/limitWorker 按扫描规模、并发探活和出站连接估算API 按请求 QPS 与连接数估算。同进程方案则需要合并峰值预算并验证扫描期间的延迟和 OOM 风险。本地单容器的适用边界给无 Redis、无 Postgres 的单机演示有人会写一个脚本把 API 和 worker 塞回同一个容器python src/worker_main.pyworker_pid$!uvicorn main:app\--host0.0.0.0\--port${PORT:-8000}\--app-dir src\--workers${UVICORN_WORKERS:-2}api_pid$!shutdown(){kill$api_pid$worker_pid2/dev/null||truewait$api_pid$worker_pid2/dev/null||true}trapshutdownINTTERMwait$api_pidstatus$?shutdownexit$status这里 Worker 是后台进程Uvicorn 另起扫描没有被 lifespan 复制。UVICORN_WORKERS2仍会复制进程内心跳如果心跳共享同一NODE_ID需要降为单 Worker 或改造身份模型。该脚本适合本地演示和单机工具生产使用时则要补进程监管、日志、退出超时和健康检查。脚本先waitAPI再向 Worker 发 SIGTERM因此 API 退出会带着扫描一起停止。这种共同生命周期适合本地演示或希望两者成对运行的单机部署需要独立可用性时应交给进程管理器或编排平台分别监管。两个 Deployment 若只靠人工同步镜像 tag确实容易出现协议版本漂移。可以通过 GitOps 同一变更集、兼容性版本字段、契约测试和分阶段发布控制而不必仅依靠同 Pod 绑定。进程内方案什么时候还能用以下条件大体满足时可以考虑留在lifespan明确单进程单副本或已有可靠的选主/分片机制任务可重入重复执行对业务无害或有幂等键扫描成本低不会堵住 event loop凭证分配、OAuth 刷新、按供应商拉额度通常带外部副作用是否留在进程内取决于幂等能力和执行频率。开发模式可用同一进程方便调试生产可选择独立 Worker、选主调度器或托管定时服务。示例把网关心跳保留在 API lifespan以表达「这个 HTTP 入口还活着」。若多个 Uvicorn Worker 共享同一节点 ID需要每 Pod 选主、把心跳移到独立 sidecar或让每个进程注册独立身份否则会发生覆盖或重复心跳。pending 校验是另一处asyncio.create_task创建 key 的 API 进程里丢一个一次性后台任务。这是可丢失的worker 的刷新会把过期 pending 再捞起来。这种短任务留在请求进程可以周期全表扫描不行。账单、用户 webhook 这类请求派生任务可以和请求同进程并接受进程故障丢失也可以用 Outbox 或消息队列持久化。周期扫描同样可以留在进程内但需要明确副本基数两类任务的差别在交付语义和调度基数而不是名称是否叫“后台任务”。停止与部署顺序API 是否依赖 Worker 取决于分配协议。示例用 Redis Lua 直接分配并允许到期冷却在热路径恢复因此没有同步 RPC 依赖但长期缺少刷新会让状态变旧。若 Worker 负责强探活、签发短期 token 或唯一调度则 API 需要相应的降级、拒绝或缓存策略。示例 Worker 只依赖数据库和 RedisCompose 的depends_on: api仅用于等待 bootstrap。若 Worker 通过 API 获取配置或提交结果则两者确有运行时依赖。Kubernetes 探针应检查真实依赖没有 API 依赖时不宜等待 API/health有依赖时则应定义超时、缓存与降级行为。子节点注册网关的顺序是另一条先启动网关控制面再启动子节点。子节点主动注册和心跳网关不扫描发现。子节点先启动会按重试逻辑继续尝试但正式部署应先让网关可达。这和 API/Worker 拆分正交控制面负责节点注册Worker 负责本节点 key 的周期维护。当心跳和周期任务都采用每 Pod 单例语义时可以让 API 保持单 Uvicorn Worker通过 Pod 副本扩容当这些职责已按进程隔离或外移后也可以增加进程数。Worker 是每节点一份、全集群一份还是按分片多份应由凭证库存边界和调度基数决定不能仅从 HTTP 副本数推导。发布前可以按所选部署模型检查单镜像方案核对同一 tag 与不同命令双镜像方案核对协议兼容版本API/health只表示分配面worker 进程在日志有迭代把 worker 停掉allocate 仍能返回 key短窗口增加 API Worker 后确认心跳按预期表现为每进程、每 Pod 或选主单例刷新风暴期间分配 P99 不应跟着涨——它们不在同一个 loop怎么验证这套是通的lifespan 不再启扫描。只起 API、不起 worker日志里不应出现recovered/refreshed。等过一个间隔DB 里到期的冷却号仍停在临时态分配热路径可能把 HASH 改了但全表恢复不应发生。worker 单独能跑。只起 workerAPI 不起。一轮之后到期冷却应回到 availableRedis HASH 同步。分配接口 没人听但扫描不受影响。恢复失败不阻断刷新。给恢复打桩抛错刷新仍要被调用。进程不退出。间隔来自配置。把间隔改成 23 秒断言传进循环的就是 23不是写死的 60。Compose 两个服务。ps里 api 和 worker 都 Up。只起 api 时空闲冷却号管理面一直显示限流。多 Worker 验证。在预发环境把 Uvicorn Worker 从 1 调到 4观察扫描、心跳与上游 refresh QPS 是否符合设计基数。无需故意把生产代码改回错误拓扑可以用测试钩子或计数器验证 lifespan 每进程执行这一事实。SIGTERM。停 Worker 容器时当前一轮可能被取消。单条更新应使用事务、条件更新或幂等键确保中断后可以在下一轮安全重试。常见故障与排查线索锁范围与探测位置不匹配。若先获取全流程锁再探测多副本通常不会放大同一凭证的上游 QPS但会增加锁竞争和空转连接若探测后才锁写入上游请求仍会按副本数放大。高吞吐场景可按凭证分片让不同 Worker 处理不重叠集合。worker 带了--reload。改一行配置正在跑的 OAuth refresh 被杀掉token 写一半。双镜像缺少兼容性管理。API 与 Worker 分镜像本身可以减少依赖和权限面风险来自协议版本未约束。可通过镜像标签矩阵、数据库 schema 兼容窗口和契约测试控制。worker 的探针写成等 API health。API 滚动 30 秒扫描跟着停 30 秒冷却到期的号堆着。把入口心跳交给扫描 Worker。如果心跳语义是 HTTP 入口可用扫描 Worker 存活并不能证明 API 可达。可以由 API 自报、sidecar 探测 API 后代报或直接使用 Kubernetes readiness/service discovery。多进程复用同一节点身份。当心跳以NODE_ID为唯一键时多个 API 进程会互相覆盖。解决方式是单进程、每进程 ID、Pod 内选主或让 sidecar 代表 Pod 注册而不是把某个固定 Worker 数视为通用答案。扫描和分配抢同一个线程池。就算拆了进程如果共享一个超小的出站 HTTP 连接池刷新仍能把分配拖死。worker 用自己的 httpx client。写在最后FastAPI 后台任务没有单一部署答案可以记三项判断任务的执行基数是每进程、每实例还是全集群进程内、选主、独立 Worker 和托管调度的收益与代价幂等锁解决写入冲突限流与分片还要控制外部调用风暴把执行基数和失败边界写清楚扩展 HTTP 副本时才不会意外放大刷新流量。超时扫描、死信归档、账单对账可以独立运行也可以在具备选主、幂等和资源隔离条件时留在应用部署中。最小部署应先明确任务执行基数。单进程、幂等且允许重复的周期任务可以保留在lifespan要求全集群单例时可以选择进程内选主、独立 Worker 或托管调度。Compose 双服务、同 Pod 双容器和独立 Deployment 分别偏向本地简化、版本绑定和独立扩缩没有固定升级顺序。无论采用哪种形态都应在扩副本测试中验证扫描与外部调用次数不会意外放大。