
如果你在技术群里随手发一个“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 调度”把定时任务和工作流编排成 DAG3.1 Cron 堆脚本的极限依赖、重试、可观测性三座大山很多团队的第一步是从 Cron 开始的我也一样。但脚本数量超过两位数之后Cron 的局限性会非常明显地暴露出来。第一是依赖没法表达。A 任务必须等 B 任务跑完才能开始Cron 里只能通过“预估时间 sleep 轮询”硬凑B 一旦延迟A 就跟着出错整个链路的时序全靠运气。第二是重试只能靠脚本自己写。一个脚本因为数据库瞬时抖动失败手动重跑一次还能接受但凌晨三点失败的任务通常要等第二天早上才能发现。第三是可观测性缺失任务到底跑了没有、跑了多久、消耗了多少资源完全没有记录。这时候你需要的就不是“定时触发脚本”而是一个真正的调度系统。判断自己是否该上调度器的标准我用得很具体只要出现“任务 A 结束后必须执行任务 B”这种依赖就不该继续用 Cron 硬扛了。3.2 一个最小可用的调度核心状态表、加锁抢占、指数退避在不引入任何框架的前提下一个最小调度器可以由三部分组成一张任务实例表、一个调度循环、一个重试策略。任务实例表大概是这样的结构字段说明task_name任务唯一标识queue任务所属队列用于区分业务线statuspending / running / success / failedpriority优先级数字越小优先级越高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_idexample_ax_dag, start_datedatetime(2024, 1, 1), schedule*/5 * * * *, catchupFalse, default_args{retries: 2, retry_delay: timedelta(minutes1)}, ) as dag: t1 PythonOperator(task_idextract, python_callableextract) t2 PythonOperator(task_idtransform, python_callabletransform) t3 PythonOperator(task_idload, python_callableload) t1 t2 t3这个例子本身没什么好说的真正容易翻车的是这三个点第一个是catchup。start_date设成过去时间后如果不加catchupFalseAirflow 会把从start_date到当前时间之间所有错过的调度周期全部补跑一遍。我见过有人配了一个每周任务结果部署当天一次性补跑了半年的数据直接把下游打蒙。第二个点是时区。Airflow 默认用 UTCschedule里的 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停止被 CtrlZ 或 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 mountIO 请求在内核里无限等待。解决方案是先把挂载改成软挂载加超时并检查网络链路处理完底层问题后D 状态进程才逐渐退出服务恢复。这里有个经验值得强调看到 D 状态不要急着重启服务先看wchan和/proc/PID/stack定位阻塞源头解决底层 IO 问题后进程自然恢复盲目kill -9不仅无效还可能造成数据不一致。死锁场景则是另一种表现进程状态通常是 S 或 R但业务无响应。排查时先top -Hp PID找到卡住的线程再根据语言栈拿线程快照Java 用jstackC/C 用gdb attach。多数死锁的根因是多个线程以不同顺序获取同一组锁分析锁顺序就能定位到具体代码。4.3 nice、renice 与 systemd怎么给关键任务让路Linux 的 nice 值范围是 -20 到 19数值越小优先级越高普通进程默认 0。调整运行中进程用renicerenice -n -5 -p 12345但我现在很少手动 renice更推荐用 systemd 直接声明资源约束。在 service 单元里加两行[Service] CPUQuota50% Nice5这样备份、离线计算这类后台任务会被限制在半个 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看状态而不是重启大法。这套逻辑用到今天帮我少熬了无数个凌晨。