☰
Prefect工作流编排实战:从定时脚本到可观测的调度系统
2026/10/13 21:30:54 网站建设 项目流程

真正让我下决心好好研究工作流编排,是因为某次跑批脚本把整条数据链路带崩了。当时那套任务挂在服务器上,定时触发,看起来没什么问题,可上游接口一超时,脚本就卡死在半路,后续所有依赖这个结果的报表一起挂掉,一挂就是大半天。排查下来发现,问题根本不在接口,而在于任务从头到尾都是单线程的“裸脚本”,没有状态管理,没有重试,没有依赖感知。后来我把链路整体迁到 Prefect 上,用 Flow 和 Task 重新梳理了一遍,整个调度才真正可控起来。这篇文章就从我的实际使用经验出发,拆一拆 Prefect 到底解决了什么问题、它的核心概念怎么用、一个能上线的调度任务该怎么搭,以及我在实操中踩过的那些坑。

1. 工作流编排的本质与 Prefect 的设计哲学

1.1 定时脚本为什么撑不起正式业务

很多人一开始对“工作流编排”这个词没感觉,觉得不就是几个定时任务嘛。我一度也这么想。但等任务量上来之后,你会立刻碰到几类问题:任务依赖怎么表达?失败了要不要重试?重试之后数据怎么保证不重复?某个中间步骤跑挂了,上下游的状态你从哪看?如果是多个脚本互相调用,你怎么知道现在到底卡在哪一步?

这些问题本身就是“编排”要解决的。不是说你不能用 cron 加 shell 脚本硬凑,而是凑出来的东西在规模变大之后会非常脆弱。比如你可以写一个 bash 脚本,前面拉数据,后面跑清洗,再后面入库,看起来是一条直线,但一旦清洗失败了,你得人工去看日志、手动修数据、再手动重跑后续步骤。这种模式在今天的数据链路里可以说是灾难区的常态。

Prefect 之所以吸引我,是它对“流程”这件事的处理方式和别的不一样。它允许你用原生的 Python 语法去定义一个工作流:函数还是普通函数,循环还是普通循环,只是加一个装饰器,整个流程就变成了可观测、可重试、可缓存、可调度的对象。我不用去学一套奇怪的配置语言,也不用把逻辑拆到 DAG 文件里,代码的形态和业务逻辑的形态是一致的,这就是“回归本质”的意思。

1.2 Prefect 的核心设计取向

Prefect 的设计取向可以归纳成三句话。第一,流程是代码,不是配置。第二,状态是显式的,不是隐式的。第三,任务应该能感知自己的上下文,而不是孤立的函数。

先说流程是代码这一点。很多调度框架会要求你把每个节点单独写清楚,再用一份配置文件把节点串起来。好处是可视化清晰,坏处是代码结构频繁在业务逻辑和框架语法之间切换,心智负担很重。Prefect 用装饰器(@flow、@task)把函数包进去,函数的入参和返回值自然成了任务间的依赖信息,你不需要额外声明“这个任务等那个任务”,运行时调度器会从实际调用关系里自动推断。

再说状态显式。Prefect 里每个任务一次运行的最终结果都有一个 State,比如Completed、Failed、Retrying、Cached。这不是后置的打日志,而是执行引擎内部会真的把状态流转记录下来,调度器可以依据这些状态决定下一步动作。比如步骤 A 失败了,步骤 B 就不会被触发;步骤 A 重试成功了,步骤 C 就正常启动。整条链路的可观测性,就是建立在状态机模型上的。

第三点“任务能感知上下文”听起来有点抽象,落到现实中就是:你能在任务里拿到当前运行 ID、重试次数、任务名称、参数值,还能通过cache_key_fn决定这个任务是否可以从缓存里直接取结果,而不用真的执行。这些能力让“分布式执行”这件事从口号变成了可调用的参数,普通开发者也能轻松上手。

2. 核心概念拆解:Flow、Task 与状态机

2.1 Flow 和 Task:把业务拆成最小执行单元

Prefect 里最基本的两个概念就是 Flow(流程)和 Task(任务)。Flow 是编排的容器,Task 是真正干活的单元,一个 Flow 里可以包含多个 Task,Task 之间可以依赖,也可以并行。官方推荐的做法是:Task 负责具体的原子操作,比如拉一次接口、写一张表、算一个指标;Flow 负责编排调度,比如先跑 A 再跑 B、失败重试、动态传参。

代码上看,Task 和 Flow 都是用装饰器定义的:

from prefect import flow, task @task(retries=2, retry_delay_seconds=30) def fetch_data(offset: int): # 拉取某公开接口的分页数据 ... @task def clean_data(raw): # 清洗字段、去重、格式规范化 ... @flow def etl_pipeline(offset: int = 0): raw = fetch_data(offset) cleaned = clean_data(raw) return cleaned

这里有几个关键点。第一,fetch_data函数一但被@task装饰,就不再是普通的 Python 函数,至少在执行层面,Prefect 会在你调用它的时候创建一个 Task Run 对象,并跟踪它的状态变化。第二,Flow 内部调用 Task 的返回值,会在上游任务完成后才真正传给下游,这就是依赖自动推导的体现。

实际业务里,我习惯把 Flow 再分层。最外层是入口编排,中间层是业务域的子流程,最底层的 Task 只做基础能力。比如一个报表生成链路,最外层 Flow 负责“拉数 + 算指标 + 发邮件”,子流程可能是一个“按渠道拆分的清洗流程”,底层 Task 就是单次 HTTP 请求、单张表写入这些原子操作。这样拆分之后,重跑某一段、跳过某一段、单独调试某一段都变得很容易。

2.2 状态机:谁在背后管着任务的“生老病死”

Prefect 的状态机设计可以说是整个框架的灵魂。简单说,任一次 Task Run 或 Flow Run 都会有状态,常见的有Pending(等待执行)、Running(正在执行)、Completed(成功)、Failed(失败)、Retrying(等待重试)、Cached(命中缓存)、Cancelled(被取消)、Scheduled(等待调度触发)。

状态之间不是随便跳的。比如Pending可以变Running,Running可以变Completed或Failed,Failed如果设置了重试,就会先变Retrying,然后重新进入Pending。这种流转规则看起来简单,但它是编排逻辑的基石。没有状态机,你就只能自己在每个函数里写 try-except,然后通过返回值判断有没有成功;有了状态机,调度器天然知道什么状态允许继续,什么状态需要暂停,什么状态应该触发下游。

我个人的理解是:可以把每个 Task Run 看作一张“身份证”,状态就是这张身份证上的当前户籍。什么时候能去下游,取决于户籍状态是否符合条件——只有Completed的结果才会被下游消费。这样即使某个中间任务重试了三次,下游拿到的也始终是最终成功那次的输出,不会出现半截脏数据传下去的情况。

还需要提一下 Prefect 的持久化。每一次状态变化都会被记录下来,写入 Prefect 的服务端数据库或者本地 SQLite 数据库,UI 上能看到每个任务的历史轨迹。这意味着即使运行结束,你依然可以回头排查某次任务“为什么失败”“失败前卡了多久”“重试了几次”。这种审计能力,是普通脚本没有的。

2.3 为什么用 Python 原生语法而不是 DSL

不少调度框架会设计专门的配置语言或者声明式 DAG 文件,比如 YAML、XML 之类。Prefect 反其道而行,提供了一个“用代码描述流程”的方式。这么做有一个直接好处:动态逻辑不需要绕路。

举个实际例子。某个接口是分页返回的,第一次请求会返回总页数,之后每一页都要带不同的页号去请求。这种逻辑如果用静态 DAG 配置,你需要想办法在配置里表达“根据上游结果决定下游生成多少实例”,非常别扭。但在 Prefect 里,你可以直接在 Flow 里面写一个 for 循环,循环次数由上游 Task 返回的总页数决定:

@flow def fetch_all_pages(): first_page = fetch_page(1) total_pages = first_page["total_pages"] results = [] for page in range(1, total_pages + 1): data = fetch_page(page) results.extend(data["items"]) store_results(results)

这就是 Python 原生流程表达框架的意义:业务上怎么想,代码上就怎么写。从维护者的角度看,看到这个 Flow 就能理解整条链路的执行顺序,不需要再去翻配置文件比对节点之间的连线关系。

不过这也意味着 Prefect 对使用者有一定的 Python 功底要求。代码该怎么组织、参数该怎么传、异常怎么处理,这些都靠你自己写。框架帮你处理的是调度、重试、状态、缓存这些“流程治理”问题,而不是替你写业务逻辑。

3. 从零搭建一个可上线的 Prefect 工作流

3.1 安装与前置准备

先解决安装问题。这里以 Python 3.9 及以上版本为例,直接通过 pip 安装:

pip install -U prefect

装完之后可以验证版本:

prefect version

如果是新项目,我建议在虚拟环境里装,避免依赖冲突。Prefect 自身依赖不算少,包括httpx、typer、pydantic等库,如果和项目里的旧依赖混在同一个环境,容易出现版本打架的情况,用虚拟环境隔离是最稳妥的做法。

Prefect 的使用模式有两种。一种是本地直接跑,不需要启动任何服务端,Flow 运行的记录会存在本地工作目录下,适合开发和单机定时场景;另一种是启动一个调度服务(官方叫 Prefect server),然后把 Flow 部署成可以长期调度的任务,所有运行记录统一落到服务端,适合多团队、多机协作。这篇以本地模式为主,但我会在第 4 节把部署到调度服务的方式讲清楚,因为只有部署之后才算真正“可上线”。

3.2 第一个工作流:抓取、清洗、入库

这里我给一个完整的例子,模拟一个常见的“抓取公开接口数据 - 清洗 - 写入 SQLite”链路。注意这是一个演示项目,接口地址用占位符代替。

from datetime import timedelta from pathlib import Path from prefect import flow, task from prefect.tasks import task_input_hash @task(retries=2, retry_delay_seconds=30, cache_key_fn=task_input_hash, cache_expiration=timedelta(hours=1)) def fetch_data(date_str: str) -> list: """拉取某公开数据源的原始数据""" import httpx url = f"https://api.example.com/data?date={date_str}" resp = httpx.get(url, timeout=30) resp.raise_for_status() data = resp.json() return data["items"] # 假设返回结构是 {"items": [...]} @task def clean_data(items: list) -> list: """去重、过滤空值、规范化字段""" seen = set() cleaned = [] for item in items: item_id = item.get("id") if item_id is None or item_id in seen: continue item["date"] = item.get("date", "")[:10] cleaned.append(item) seen.add(item_id) return cleaned @task def write_to_sqlite(items: list, db_path: str) -> None: """写入本地 SQLite 数据库""" import sqlite3 conn = sqlite3.connect(db_path) cur = conn.cursor() cur.execute(""" CREATE TABLE IF NOT EXISTS demo_items ( id TEXT PRIMARY KEY, date TEXT, name TEXT, value REAL ) """) cur.executemany( "INSERT OR IGNORE INTO demo_items (id, date, name, value) VALUES (:id, :date, :name, :value)", items, ) conn.commit() conn.close() @flow(name="etl-demo-flow") def etl_pipeline(date_str: str = "2025-01-01"): db_path = str(Path(__file__).parent / "demo.db") raw_items = fetch_data(date_str) cleaned_items = clean_data(raw_items) write_to_sqlite(cleaned_items, db_path) return len(cleaned_items) if __name__ == "__main__": etl_pipeline(date_str="2025-01-01")

这个例子里有几个设计值得展开。第一是cache_key_fn=task_input_hash,它表示这个任务在入参相同且未超过cache_expiration时,会直接用之前成功运行的结果,不再真正发起 HTTP 请求。这个特性在业务上非常实用,比如你重跑某天数据时,接口数据没变,任务直接命中缓存,几秒钟就完成了,而不是把所有请求重打一遍。

第二是把httpx.get放在任务函数内部导入,而不是模块顶部导入。这样做的好处是任务运行时的模块依赖会更独立,后续如果要做分布式执行或打包分发,不会因为某个第三方库导入失败导致整个 Flow 注册失败。我习惯把重一点的第三方依赖都放在函数内部导入。

第三是使用了INSERT OR IGNORE,保证重复执行时数据不会重复写入。很多人只设置重试、不设置幂等,结果失败重跑时数据翻倍,这是非常常见的坑。

运行这个脚本,你会在终端看到 Prefect 输出每一步的状态和耗时的日志。运行结束后,本地会生成一个.prefect目录,里面记录了这个 Flow 的历史运行信息。如果你在环境中启动了prefect server start,那么这些记录会出现在 UI 的 Flow Runs 页面里。

3.3 Deployment 与本地试运行

上面只是“运行一次”,要让它变成可以被管理、被调度的任务,还需要做 Deployment(部署)。简单说,Deployment 就是把一个 Flow 的代码位置、参数默认值、运行环境、触发时间表等信息打包成一个可复用的“部署单元”。

在 Prefect 2.x 之后的版本里,最简单的方式是在flow.serve():

if __name__ == "__main__": etl_pipeline.serve( name="etl-daily-deployment", cron="0 2 * * *", tags=["etl", "demo"], parameters={"date_str": "dynamic"} )

也可以单独用命令行部署:

prefect deploy etl_pipeline:etl_pipeline --name etl-daily-deployment --cron "0 2 * * *"

这里cron参数就是标准 Cron 表达式,0 2 * * *表示每天凌晨 2 点触发一次。如果只是在本地跑,运行etl_pipeline.serve(...)之后,这个进程会一直挂在那里,等待时间表触发,同时也会创建一个本地工作队列,进程自己就是执行 worker。

要真正跑调度,你需要一个事件循环进程。你的入口脚本负责把 Deployment 注册给服务端,之后由一个正式的 worker/agent 来消费任务。在实际部署中,我会先写一个deploy.py,专门用来注册 Deployment,然后单独启动 worker。这样代码职责清楚,调试的时候也很容易定位。

4. 调度与依赖:让工作流真正“自动”起来

4.1 时间表:Cron 表达式与间隔调度

调度是工作流编排的核心场景之一。Prefect 支持多种时间表,最常用的有 Interval(固定间隔)和 Cron(日历时间)。

Interval 适合“每 N 分钟执行一次”的场景:

etl_pipeline.serve(name="etl-interval", interval=3600)

Cron 适合“每天固定几点执行”的场景,比如报表通常都是凌晨生成,你不可能用 interval 从程序启动时刻开始计算,而是需要在每天凌晨 2 点准点触发:

etl_pipeline.serve(name="etl-daily-cron", cron="0 2 * * *")

要注意时区问题。默认情况下,Cron 表达式按 UTC 时间解析。很多国内团队凌晨跑批,写的是北京时间,结果发现任务总是提前 8 小时触发或者压根没动静,排查半天才知道是时区没设置。正确做法是显式指定timezone参数:

etl_pipeline.serve(name="etl-daily-cron", cron="0 2 * * *", timezone="Asia/Shanghai")

另一个容易忽略的点是 Cron 的秒和分钟语义。标准 Cron 是 6 位(分、时、日、月、周),Prefect 的 Cron 时间表默认没有秒字段,所以"0 2 * * *"是没问题的,但如果你看到"*/5 * * * *"这样的写法,意味着每 5 分钟执行一次,而不是每秒。我第一次用的时候在这里迷糊过,日志里任务疯狂触发,还以为挂了个死循环。

4.2 依赖传递与动态参数

除了时间触发,工作流里更常见的是“上游完成后自动触发下游”这种事件驱动式的依赖关系。Prefect 在这方面做得比较自然,因为 Flow 内部的调用本身就是依赖关系。

  • 上游任务失败,下游任务不会启动。
  • 上游任务进入Retrying状态,下游任务保持等待。
  • 上游任务最终失败并重试耗尽,下游任务被标记为NotRun。

这种机制意味着你不用在每个 Task 里检查上游结果,编排引擎已经替你判断了。

动态参数方面,Prefect 支持在 Flow 之间传参。比如有一个 Flow 计算出某天的日期范围,把它传递给另一个 Flow 作为输入;或者用parameters从外部传入一个 JSON 配置。这些参数会在 Flow Run 创建时固化下来,并体现在运行记录中。从运维视角看,这非常重要——你回看历史运行时,能清楚知道每次运行用的是哪一批参数,而不是靠猜。

如果你有多个彼此独立的 Flow,需要互相接力,那就用“子流程”的方式。在一个顶层 Flow 里调用另一个@flow装饰的函数,Prefect 会把它作为一个 subflow run 来记录。这样做的好处是:层级关系在 UI 上清晰可见,排查问题时能顺着主流程展开每一个子流程的状态。

4.3 工作队列和 Agent 的配合

当你部署了多个 Flow,并且希望它们能在不同机器上执行时,就需要理解工作队列(Work Queue)和执行器(Agent/Worker)的角色。

可以把工作队列理解成一个“领号窗口”。Deployment 被触发后,会产生一个 Flow Run,这个运行请求被扔进指定的工作队列,等待一个空闲的执行器来领取。执行器(Agent 或 Worker)则是真正跑任务的进程,它订阅一个或者多个工作队列,发现有新任务就拉起执行环境来跑。

这么设计的好处是把“调度决策”和“任务执行”解耦了。队列归队列,执行归执行,你可以同时开多个 worker,也可以开在不同的机器上,还可以按队列划分资源池。比如>prefect agent start --work-queue "data-pipeline"

或者在新版中使用 worker:

prefect worker start --pool "data-pipeline"

如果你只是单机跑,直接serve()也能工作,因为它会隐式创建一个本地的 worker。但一旦涉及多机,还是建议用显式的 worker 方式,这样队列分配、资源限制、失败任务回收都有明确的配置入口。

5. 常见问题与排查技巧实录

5.1 任务挂起与重试陷阱

我在本地测试时的第一个问题是:任务一直停留在Pending状态,怎么都不触发。后来发现是因为我启动了serve()但没有启动任何 worker,而serve()本身只负责往队列里塞运行请求,不保证消费。这个逻辑在不同版本里略有差异,但排查思路是一样的——先看有没有 worker 在对应队列上拉任务,再看队列名是否匹配。

另一个高频问题是重试机制配置得过于“无脑”。比如设置了retries=3,但任务本身不具备幂等性,每次重试都会执行一次副作用操作(比如插入一条记录),结果失败重试四次,数据库里插了四次重复数据。解决方向有两个:一是尽量让任务保持幂等,写入操作使用INSERT OR IGNORE、UPDATE这类可重复执行的语句;二是把不稳定的外部依赖单独拆成一个 Task,只对它设置专门的重试策略,而不是对整条链路一刀切开重试。

5.2 API 连接、并发数与配置问题

对接外部接口时,最典型的问题就是超时和限流。很多人只在业务代码里调用httpx.get(...),没有设置超时,接口一慢就把整个 Task 挂住,直到调度器判定任务失败。正确做法是显式设置timeout,并且配合重试策略一起使用。超时时间不用太长,一般 30 到 60 秒就够。

并发方面,Prefect 默认的任务执行是顺序的,并发数并没有你想象中那么大。如果 Flow 里循环派生了大量独立 Task,又希望同时跑,需要给 Flow 指定task_runner。比如用ConcurrentTaskRunner开启并发执行:

from prefect.task_runners import ConcurrentTaskRunner @flow(task_runner=ConcurrentTaskRunner()) def process_many(items): for item in items: process_item(item)

这里要注意,并发不是免费的。并发数高了之后,内存和数据库连接池都可能成为瓶颈,实际使用时我会先把并发上限压低,观察正常跑通后再逐步放开。

还有一个容易被忽略的配置问题:服务端地址。如果启动了 Prefect server,但客户端仍然连接默认的本地数据库,你会在 UI 上看不到运行记录。需要用prefect config set PREFECT_API_URL=http://127.0.0.1:4200/api把客户端指向服务端,或者直接在环境变量里配置。

5.3 小白避坑速查表

下面这个表是我把日常运维中遇到的高频问题整理成的一个速查表,碰到类似情况可以直接对照排查。

现象大概率原因排查/解决思路
任务一直 Pending没有 worker 消费队列检查 worker 是否启动、工作队列名是否匹配
重试导致数据重复任务不幂等写入用幂等语句;增设缓存减轻副作用
缓存一直不生效cache key 未考虑入参变化用task_input_hash或自定义缓存 key
Cron 触发时刻不对未指定时区显式设置timezone="Asia/Shanghai"
接口任务长时间卡住未设置超时时间给 HTTP 客户端加timeout,并设重试
运行记录在 UI 看不到客户端未连到服务端检查PREFECT_API_URL配置
任务并发没有提升默认是顺序 runner为 Flow 配置ConcurrentTaskRunner
部署后无法触发Deployment 名称或入口路径错误用prefect deployment inspect查看信息

排查这些问题的通用方法有两个。一个是打开 Prefect 的日志级别,把日志调到DEBUG,能看到调度器内部的很多关键动作;另一个是善用 UI 里的 Task Run 详情页,里面会记录每次状态变化的完整时间线,以及失败时的异常堆栈。不要一上来就猜配置,先看日志和数据,定位会快很多。

最后再分享一个小技巧。我习惯在每个 Task 的函数开头打印一行带上下文信息的日志,比如运行 ID、重试次数、关键入参。这样做一开始觉得啰嗦,但真的遇到问题排查时,帮助非常大——尤其是多个 worker 并行消耗任务的时候,没有上下文日志,你基本无法确认某个状态到底是哪一次运行、哪一次重试留下的。Prefect 本身也提供了prefect.context.get_run_context()这样的 API 可以拿到当前运行信息,可以直接把它拼进日志里。

真正把 Prefect 用顺之后,我最大的感受是:工作流编排不是给代码加一层华丽的外壳,而是把任务执行过程中那些不可控的因素——失败、重试、等待、缓存、并发、依赖关系——交给框架去管理,让开发者把精力放回业务本身。这套思路在本地小流程和多服务大链路里都适用。如果你还在用裸脚本做定时任务,建议找一个规模合适的流程先迁过来试试,逐步体会状态机和部署模型带来的变化。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询