☰
从Axios到Airflow再到ps ax:一文看懂三层ax调度体系
2026/9/26 16:16:17 网站建设 项目流程

如果你在技术群里随手发一个“ax”,至少会有三种人同时抬头:前端会以为是 Axios 的简写,数据工程师会想到 Airflow,运维则会直接敲出ps ax看进程。我不是在玩文字梗,而是在说一件更实际的事——这三样东西刚好代表了一套完整链路里的三层调度:页面请求的调度、服务端任务的调度、操作系统对进程的调度。这也是“ax调度”这个词最近越来越多被提起的原因,它不是一个固定产品的名字,而是大家在使用中形成的混合称呼。这篇文章我就把这三层一次讲透,结合我实际踩过的坑和能直接抄走的代码,帮你把从请求到任务再到进程的调度逻辑整个串起来。适合做前端、后端、运维、以及总在“一个人干全栈”的独立开发者看。

1. 为什么技术圈里到处是“ax”:先把这个词看透

1.1 前端场景的“ax”:Axios 请求库的默认代称

前端项目里,Axios 被简写成ax是再常见不过的事。很多代码仓库里你会看到ax.get()、ax.post()、http.ts这类命名,本质上都是对 Axios 实例的引用。这个简写没有官方依据,纯粹是大家在写工具函数、注释、脚手架模板时自然而然地形成了习惯。

Axios 解决的痛点其实很朴素:浏览器原生的XMLHttpRequest写起来啰嗦,fetch虽然原生支持 Promise,但拦截器、超时、取消这些能力都得自己封装。Axios 把这几个能力打包好了,所以成了前端请求层的事实标准。当我们说“前端这层 ax 调度”时,指的不是 Axios 本身,而是基于它构建的请求调度策略——并发上限、排队机制、超时重试、拦截器执行顺序。这些内容会在第二章详细展开。

1.2 数据工程里的“ax”:Airflow 工作流编排平台

Airflow 是 Apache 顶级项目,基于 Python 生态,核心思路是用 DAG(有向无环图)定义任务之间的依赖关系,再由内置的 Scheduler 按时间触发执行。社区和自建系统里,把 Airflow 简写成ax的确实存在,虽然不那么严谨,但并不罕见。

“ax调度”在热词语境下,最接近的就是这一层含义:分布式任务调度、工作流编排、定时任务管理。它解决的典型问题是:几十个 Cron 脚本堆在一起,互相之间偶尔有前后依赖,失败了没有重试,执行历史只能靠日志翻。Airflow 把这些痛点统一成了“任务实例 + 依赖关系 + 调度时间”三个概念,让调度变得可描述、可追踪、可重放。

1.3 运维眼底的“ax”:ps ax 的进程快照视角

ps ax是 Linux 下查看全部进程的经典命令。a表示显示所有进程(包含其他用户的),x表示不限于当前终端关联的进程,合并使用就能拿到系统的完整进程快照。排障场景里,“ax调度”完全可以理解成“通过进程状态列 STAT 观察操作系统的调度状态”。

这个解读偏冷门,但非常实用。处理线上故障时,ps ax输出里的 D(不可中断睡眠)、R(运行)、S(可中断睡眠)、Z(僵尸)状态,直接反映了进程在 CPU 调度器眼中的处境。很多时候服务“假死”不是代码问题,而是底层 IO 把进程挂住了,一眼扫过去全是大写 D,问题定位就有了方向。

把这三层放在一起看,你会发现它们不是三个不相干的词,而是一条链:用户在页面上点击产生请求,应用层把请求或任务交给调度器,最终落到操作系统按进程调度。把这条链完整盘一遍,“ax调度”这个说法才算真正落地。

2. 前端这层“ax 调度”:并发请求到底是怎么排队的

2.1 Axios 本身没有并发控制能力吗:先澄清一个常见误解

先说结论:Axios 本身不限制并发数。浏览器里你同时发 50 个请求,它会全部发出去,只是浏览器对同一域名的 TCP 连接数默认限制在 6 个左右,多出来的请求会在浏览器内核层面排队,这个排队行为不是 Axios 控制的。而在 Node 端,没有浏览器那层限制,50 个请求就是 50 个并发连接,全部涌出去。

我踩过最典型的一次坑是这样的:写了一个批量同步脚本,从老接口抓 5000 条数据写到新平台,循环里直接axios.get(),一秒钟之内几百个请求同时到达,直接把对方接口打挂了,对面运维找过来的时候我还一脸懵。从那之后我养成了习惯,凡是批量请求,一律加并发闸门。

2.2 用 p-limit 造一个请求闸门:原理与可抄代码

前端并发控制最省事的方案是p-limit,它是一个极小的信号量实现,核心逻辑简单到让人佩服:内部维护一个任务队列和一个计数器,每执行完一个任务就从队列里再取一个补上,始终让进行中的任务数不超过设定上限。

直接看我实际在用的封装:

const pLimit = require('p-limit'); const axios = require('axios'); const limit = pLimit(10); async function requestWithLimit(config) { return limit(async () => { const res = await axios({ timeout: 5000, ...config }); return res.data; }); } async function fetchAll(urls) { const tasks = urls.map((url) => requestWithLimit({ url })); const results = await Promise.allSettled(tasks); return results.map((r, i) => ({ index: i, ...r })); }

这里有一个特别容易翻车的细节:p-limit接收的是一个返回 Promise 的函数,不是 Promise 本身。如果你写成limit(axios.get(url)),请求在那一瞬间就已经发出去了,p-limit 只是在帮一个已经发出的请求计数,完全失去了闸门作用。我第一次用的时候就栽在这个地方,排查了半天才发现axios.get(url)在传参阶段就开始了请求。换成() => axios.get(url)这种工厂函数形式才恢复正常。

Promise.allSettled也是必须的。批量任务里只要有一个请求失败,Promise.all就会整体 reject,导致所有结果都拿不到;而allSettled会把每个任务的成功和失败单独返回,你再根据status字段决定是重试还是跳过。

2.3 拦截器调度链:请求在发出前后到底按什么顺序执行

Axios 的拦截器是理解请求调度顺序的关键入口。请求拦截器按注册的逆序执行——也就是最后添加的最先执行;响应拦截器按注册的正序执行。这个反直觉的顺序是 Axios 内部用栈结构管理拦截器队列导致的。实际写代码时不需要强行记,但心里要有这条链,否则多写几个拦截器之后很容易乱。

我现在的标准配置是三个拦截器:认证拦截器(往 header 里塞 token)、日志拦截器(给请求生成 traceId)、错误拦截器(统一处理 401 跳登录)。示例代码:

axios.interceptors.request.use((config) => { config.headers['X-Trace-Id'] = crypto.randomUUID(); const token = getToken(); if (token) config.headers['Authorization'] = `Bearer ${token}`; return config; }); axios.interceptors.response.use( (response) => { logResponse(response.config.headers['X-Trace-Id'], response.status); return response; }, (error) => { if (error.response?.status === 401) { redirectToLogin(); } return Promise.reject(error); } );

这里面的实用技巧是:把 traceId 挂在config.headers上,响应拦截器里通过response.config.headers取回来,这样日志里就能把请求和响应串成一条链路。响应拦截器的第二个参数接收的是错误回调,不是把错误丢给别处处理,而是统一在这里做兜底,注意不要重复处理同一类错误。

拦截器执行顺序还有一个没说透的坑:如果某个请求拦截器里做了异步操作(比如刷新 token),它返回的 Promise 会阻塞后续拦截器执行。这本身不是问题,但我见过有人把耗时操作写进拦截器导致每个请求都慢几百毫秒。拦截器里只放轻逻辑,重操作放到真正发起请求之前做。

3. 服务端这层“ax 调度”:把定时任务和工作流编排成 DAG

3.1 Cron 堆脚本的极限:依赖、重试、可观测性三座大山

很多团队的第一步是从 Cron 开始的,我也一样。但脚本数量超过两位数之后,Cron 的局限性会非常明显地暴露出来。

第一是依赖没法表达。A 任务必须等 B 任务跑完才能开始,Cron 里只能通过“预估时间 + sleep + 轮询”硬凑,B 一旦延迟,A 就跟着出错,整个链路的时序全靠运气。第二是重试只能靠脚本自己写。一个脚本因为数据库瞬时抖动失败,手动重跑一次还能接受,但凌晨三点失败的任务通常要等第二天早上才能发现。第三是可观测性缺失,任务到底跑了没有、跑了多久、消耗了多少资源,完全没有记录。这时候你需要的就不是“定时触发脚本”,而是一个真正的调度系统。

判断自己是否该上调度器的标准,我用得很具体:只要出现“任务 A 结束后必须执行任务 B”这种依赖,就不该继续用 Cron 硬扛了。

3.2 一个最小可用的调度核心:状态表、加锁抢占、指数退避

在不引入任何框架的前提下,一个最小调度器可以由三部分组成:一张任务实例表、一个调度循环、一个重试策略。

任务实例表大概是这样的结构:

字段说明
task_name任务唯一标识
queue任务所属队列,用于区分业务线
statuspending / running / success / failed
priority优先级,数字越小优先级越高
scheduled_at计划执行时间
started_at / finished_at实际开始和结束时间
retry_count / max_retries已重试次数 / 最大重试次数
last_error最近一次失败原因

调度循环的伪代码其实很短:扫描所有到达计划执行时间且状态为 pending 的任务,抢占并置为 running,执行任务体,成功后更新为 success,失败则判断重试次数,未超过上限就按指数退避重新安排执行时间。

多实例部署时最关键的坑是“抢占”。多个调度进程同时扫到同一个 pending 任务,就会重复执行。我用 PostgreSQL 的话会这样加锁:

SELECT * FROM task_instance WHERE status = 'pending' AND scheduled_at <= NOW() ORDER BY priority, scheduled_at LIMIT 10 FOR UPDATE SKIP LOCKED;

FOR UPDATE SKIP LOCKED会让被别的进程锁住的行直接跳过,而不是阻塞等待。这样多个调度实例可以安全地并行瓜分任务,不会重复执行。这是我在生产环境验证过多次的方案,比 Redis 分布式锁简单可靠得多。

重试策略我固定用指数退避:第 N 次重试的延迟是base * 2^N加上随机扰动。随机扰动很重要,否则同一时间失败的一批任务会在同一时刻集体重试,形成二次峰值。退避基数我常用 30 秒,最大重试次数按任务重要性区分,普通数据任务 5 次,核心对账任务 10 次。

3.3 落到 Airflow 后的三个容易翻车的点

当任务规模超出单机调度器的承载,我建议直接迁移到 Airflow。给一个最小 DAG 例子:

from datetime import datetime, timedelta from airflow import DAG from airflow.operators.python import PythonOperator def extract(): print("extract") def transform(): print("transform") def load(): print("load") with DAG( dag_id="example_ax_dag", start_date=datetime(2024, 1, 1), schedule="*/5 * * * *", catchup=False, default_args={"retries": 2, "retry_delay": timedelta(minutes=1)}, ) as dag: t1 = PythonOperator(task_id="extract", python_callable=extract) t2 = PythonOperator(task_id="transform", python_callable=transform) t3 = PythonOperator(task_id="load", python_callable=load) t1 >> t2 >> t3

这个例子本身没什么好说的,真正容易翻车的是这三个点:

第一个是catchup。start_date设成过去时间后,如果不加catchup=False,Airflow 会把从start_date到当前时间之间所有错过的调度周期全部补跑一遍。我见过有人配了一个每周任务,结果部署当天一次性补跑了半年的数据,直接把下游打蒙。第二个点是时区。Airflow 默认用 UTC,schedule里的 Cron 表达式也是 UTC 时间,国内业务如果不做配置,凌晨 2 点的任务实际会在上午 10 点跑。需要在airflow.cfg里设置default_timezone,并且让调度器、Web Server、Worker 都加载同一份配置。第三个点是 DAG 文件不要放耗时逻辑。Airflow 的 Scheduler 会周期性扫描并重新解析 DAG 文件,如果你在模块顶层写数据库连接检查或重量级初始化,每一次 heartbeat 都会拖慢整个调度集群。

4. 系统这层“ax”:用 ps ax 读进程调度状态

4.1 ps ax / ps aux / ps -A:命令细节与常见状态表

先厘清命令细节。ps ax和ps -A都显示所有进程,区别是ps ax会包含那些没有控制终端的进程,而ps -A是 POSIX 风格的等价写法。ps aux则是在ps ax基础上加了u参数,按用户可读的格式展示 CPU、内存、启动时间等信息。排查时我通常先用ps aux --sort=-%cpu看资源占用,再用ps ax -o pid,stat,wchan:30,cmd看进程状态。

STAT 列的常见取值必须记熟:

状态含义常见场景
R运行中或可运行CPU 密集任务
S可中断睡眠等待事件、等待 IO 完成
D不可中断睡眠等磁盘/网络 IO,无法被 kill -9 打断
T停止被 Ctrl+Z 或 SIGSTOP 挂起
Z僵尸进程子进程已退出但父进程未回收
<高优先级常配合前几种状态出现
N低优先级通过 nice 调低了优先级
l多线程进程内存在多个线程

补充一点:状态列可能组合出现,比如D<表示不可中断睡眠且高优先级,SNl表示可中断睡眠、低优先级、多线程。排查不能只看第一位。

4.2 D 状态进程堆积:一次 NFS 卡死的完整排查记录

有一次线上服务突然大面积不可用,load average飙到 30 以上,但top里 CPU 占用并不高。我第一反应就是看进程状态,执行:

ps ax -o pid,stat,wchan:30,cmd | grep D

结果齐刷刷十几个 D 状态进程,wchan一列显示的是nfs相关内核函数。D 状态意味着进程在内核态等待某个不可中断的 IO 操作完成,此时kill -9都没用,因为进程根本收不到信号。我继续查:

cat /proc/<PID>/stack cat /proc/<PID>/wchan

确认卡在 NFS 文件系统上。回头看挂载状态,发现 NFS 服务端已经无响应,客户端挂载点变成了硬挂载(hard mount),IO 请求在内核里无限等待。解决方案是先把挂载改成软挂载加超时,并检查网络链路;处理完底层问题后,D 状态进程才逐渐退出,服务恢复。

这里有个经验值得强调:看到 D 状态不要急着重启服务,先看wchan和/proc/PID/stack定位阻塞源头,解决底层 IO 问题后进程自然恢复;盲目kill -9不仅无效,还可能造成数据不一致。

死锁场景则是另一种表现:进程状态通常是 S 或 R,但业务无响应。排查时先top -Hp <PID>找到卡住的线程,再根据语言栈拿线程快照,Java 用jstack,C/C++ 用gdb attach。多数死锁的根因是多个线程以不同顺序获取同一组锁,分析锁顺序就能定位到具体代码。

4.3 nice、renice 与 systemd:怎么给关键任务让路

Linux 的 nice 值范围是 -20 到 19,数值越小优先级越高,普通进程默认 0。调整运行中进程用renice:

renice -n -5 -p 12345

但我现在很少手动 renice,更推荐用 systemd 直接声明资源约束。在 service 单元里加两行:

[Service] CPUQuota=50% Nice=5

这样备份、离线计算这类后台任务会被限制在半个 CPU 核以内,同时降低调度优先级,避免和在线业务抢资源。调整后执行systemctl daemon-reload && systemctl restart <service>生效。

实际项目里我会把任务分成两级:在线链路(用户请求触发的任务)用默认优先级,后台批处理统一降低 nice 值并限制 CPUQuota。目标是让在线请求在系统繁忙时仍然能得到调度器及时响应,而不是靠运气。

5. 三层调度背后的同一个通用模型:队列、状态机与幂等

5.1 四个通用概念:队列、状态机、重试幂等

把三层“ax”放在一个维度看,抽象模型高度一致。前端请求调度本质上是一个并发队列,限制的是“同时进行中的请求数”;服务端任务调度是队列加状态机,推进的是任务从创建到结束的生命周期;操作系统进程调度则是内核维护的 runqueue 和优先级队列。队列控制了并发,状态机控制了生命周期,这两件事是所有调度的骨架。

重试和幂等是保证可靠性的两个轮子。重试解决的是“任务临时失败”的问题,指数退避和扰动就是为了让重试不造成雪崩。幂等解决的是“重试会不会产生副作用”的问题,设计上我遵循一个原则:所有被调度的任务都携带全局唯一 ID,执行前先查重,执行中用事务写入唯一索引,结果表用 upsert。这样即使调度器重复触发,下游收到的也是一份干净的结果。

5.2 接到调度需求先问五个问题

每次接到一个“定时任务”需求,先用五分钟问一圈问题,基本就能确定方案选型:

问题判断方向
任务有没有先后依赖?有依赖 -> 上工作流框架;无依赖 -> 单层调度即可
能不能允许乱序执行?必须严格串行 -> 队列消费;允许乱序 -> 并发调度
失败能不能重试?幂等任务随便重试;有副作用的任务要谨慎
延迟容忍度是多少?秒级 -> 消息队列;分钟级 -> 调度框架;小时级 -> Cron 足够
任务规模是单机还是多机?单机 -> 简单调度器;多机 -> 分布式调度框架

这套提问法让我避免了很多过度设计。比如“每天统计一次报表”这种需求,零依赖、可重跑、小时级延迟,Cron 加一个审计表就足够了,硬上 Airflow 反而是增加运维负担。

5.3 落地节奏:先小步落地,再谈分布式

我个人的习惯是分层推进。第一步,给现有脚本统一加审计表,记录每次执行的开始时间、结束时间、结果状态和 error 信息,这五分钟能做的事会把排查效率提升一大截。第二步,在请求层和脚本层加并发闸门,用 p-limit 限制批量请求,给关键脚本加重试和退避。第三步,出现依赖关系或跨机器需求时,再迁移到 Airflow 这类正式调度器。

踩过几次坑之后,我现在的排查顺序固定成了三层地图:先判断这个问题发生在哪一层,是页面请求调度、服务端任务调度、还是底层进程调度,然后才决定用哪个工具。如果只是定时抓数据,Cron 加监控就够;如果任务有依赖和重试需求,就上真正的调度器;如果服务和存储卡死,第一反应应该是ps ax看状态,而不是重启大法。这套逻辑用到今天,帮我少熬了无数个凌晨。

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

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

立即咨询