最近我整理后台任务脚本时发现一个特别普遍的现象:凡是拿 API 做批量处理的脚本,几乎没有一次能从头到尾安稳跑完。不是跑到第 300 条突然报错,就是半夜挂在某个网络超时上,第二天早上打开终端,发现日志还停在上一条成功记录的后面。一句话总结就是:脚本又“跑一半就挂”了。
我最早也把这个问题归结为运气不好,后来踩的坑多了才明白,批量脚本中断几乎都可以归因到几个固定环节:限流、超时、脏数据、本地资源耗尽。只要把这几类原因识别清楚,再给脚本加上重试、幂等和断点续传的能力,大部分“跑一半就挂”都能从偶发故障变成可预期、可恢复的工程问题。
这篇文章适合所有用 Shell、Python 写过批量处理脚本的人,不管你是用 API 批量改文件名、批量查询在线状态,还是调用大模型接口批量生成内容。我会把中断的典型原因、排查思路、改造步骤和真实案例都串一遍,尽量给你可以直接抄作业的代码和方案。
1. 先给“跑一半就挂”分个类:四种死法最常见
很多人一上来就去翻脚本代码,想找到那个“致命的 bug”,但批量脚本中断往往不是一行代码写错,而是整个运行过程中某个条件发生了变化。我把这几年遇到的中断原因归成四类,你在排查时按这个框架走,基本不会跑偏。
1.1 死在限流上:一会 200,一会 429
外部 API 基本都有 QPS 或并发限制,批量脚本一开跑就是高频请求,非常容易触发限流。服务端的表现通常是:
- 前几十个请求返回正常,后面开始出现
429 Too Many Requests; - 有些网关用
503 Service Unavailable来限流,语义上是服务不可用,实际就是让你别打了; - 响应头里可能带
Retry-After,告诉你需要等多少秒。
很多脚本遇到429时的默认行为是什么都不做,下次循环继续发。你以为是网络抖动,其实是请求频率已经超过了服务端阈值,越急越触发限流。
有个细节经常被忽略:限流不一定只限并发,还限每分钟总请求数。即使你的脚本是单线程 for 循环,如果完全没有间隔,也可能在某个时间窗口内把配额用光,然后被限流策略直接断开连接。
1.2 死在网络超时上:连接超时、读取超时各不同
Shell 里用 curl 发请求,最常见的两种错误是:
curl: (28) Operation timed out after 30000 milliseconds with 0 bytes receivedcurl: (7) Failed to connect to ...
前者是连接建立成功但响应迟迟没回来,后者是连接根本没建立起来。区别很关键:连接超时通常说明目标地址不可达或防火墙拦截;读取超时则说明服务端可能已经接收请求,但处理时间超过了你的等待上限。
批量脚本的特点是请求量大、运行时间长,中间可能跨越一次网络切换、一次 DHCP 租约更新、一次 DNS 缓存失效。任何一次网络瞬断,都会让脚本挂在一个本以为稳得不能再稳的连接上。
1.3 死在输入数据的“害群之马”上
这一条最容易被忽略。批量任务的数据通常来自 CSV、Excel、数据库导出或者文件列表,里面总会混进一些你没想到的字段:
- 某个字段是空字符串,API 直接返回
400; - 某个字段特别长,超出了模型的最大上下文长度;
- 某个文件名里带空格、换行符,被 Shell 的 for 循环拆成了两半;
- CSV 编码不是 UTF-8,中文乱码传给 API,导致服务端无法解析。
这类问题最典型的特征是脚本每次都在同一个位置挂掉。比如我之前处理一批文档摘要任务,每次跑到第 173 条就报api error: 400,后来发现是那条文档的文本长度超过了模型支持的最大 token 数。这是数据问题,不是程序逻辑问题,更不是网络问题。
1.4 死在本地资源耗尽上:进程没了,但你没看到报错
脚本可能不是被外部 API 打挂的,而是自己把自己耗死的。常见场景有:
- 批量处理大量文件时,文件句柄没有释放,报
Too many open files; - Python 脚本里用
requests不停创建新连接,没有复用 Session,TCP 连接数暴涨; - 日志越写越大,最后磁盘满了,写入失败;
- 脚本在后台跑,你合上笔记本盖子,系统休眠后网络和线程全断了;
- 批量并发开得太大,内存占用过高,进程被系统 OOM Killer 干掉。
这一类的特点是:日志里看不到业务错误,进程莫名其妙消失,或者最后几条任务没有记录。排查时要重点看系统日志和资源使用情况。
2. 挂掉之前,脚本其实给过你信号:怎么从日志里找真相
很多人说脚本“一点征兆都没有就挂了”,我几乎每次都会反问一句:你的脚本有“日志”吗?准确地说,有“每一条任务独立记录”的日志吗?
2.1 你缺的不是日志,而是逐条可追踪的日志
Shell 的 for 循环最容易犯的错,就是把 curl 的输出打到屏幕上,跑完也只剩最后几条记录。正确做法是每处理一条任务,就记录当前任务编号、开始时间、HTTP 状态码、耗时,以及失败了是什么原因。一个可以抄的写法是这样:
while IFS= read -r item; do log_file="batch_$(date +%Y%m%d).log" { echo "[$(date '+%F %T')] START item=$item" curl -sS -o "/tmp/resp_${item}.json" \ -w "HTTP_CODE=%{http_code} TIME=%{time_total}s\n" \ "$API_URL" || echo "CURL_EXIT=$?" echo "[$(date '+%F %T')] END item=$item" } >> "$log_file" 2>&1 done < input.txt这样一跑,脚本挂在哪条、是哪一步出的问题,一眼就能定位。没有逐条日志的批量脚本,就像蒙着眼睛开车,出事后连方向盘在哪个方向都不知道。
2.2 把“命令退出码”和“HTTP 状态码”分开看
一个很常见的误解是把 curl 的退出码当成 HTTP 状态码。curl 的退出码表示 curl 自身是否成功完成了请求,不代表服务端返回了 200。我整理过一张对照表,排查时很管用:
| curl 退出码 | 含义 | 是否需要重试 |
|---|---|---|
| 0 | curl 完成请求,但不代表 HTTP 一定是 2xx | 看状态码决定 |
| 6 | DNS 解析失败 | 可短暂重试 |
| 7 | 连接建立失败 | 可短暂重试 |
| 28 | 操作超时 | 可重试 |
| 35 | SSL 连接失败 | 检查证书,通常不用重试 |
| 56 | 接收数据失败 | 可重试 |
HTTP 状态码也要分类处理。4xx是请求参数有问题,重试一万次还是400;429是限流,必须等待;5xx是服务端问题,可以按退避策略重试。
记住一个原则:不区分错误类型就盲目重试,是最快的自我限流方式。
2.3 用 request_id 把每次请求串起来
很多 API 服务端会在响应头里返回x-request-id或类似的字段。这个字段非常重要。遇到 5xx 或异常时,记录这个 ID,你能拿着它和服务商排查具体请求;没有它,你只能空口说自己“发了请求但失败了”。
在 Python 里获取它非常容易:
resp = session.post(api_url, json=payload) request_id = resp.headers.get("x-request-id", "unknown") print(f"task={task_id} status={resp.status_code} request_id={request_id}")我通常会在日志里把任务 ID 和 request_id 打在同一行,这样后续无论是自查还是反馈给服务商,都能快速定位到具体请求。
3. 真正能救命的三个设计:重试、幂等、断点
日志能让你知道“挂在哪”,但要想不挂,或者挂了之后快速恢复,需要给脚本加上三个关键能力:重试、幂等、断点。这三个词听起来很工程化,落地其实不难。
3.1 重试不是“报错了再来一次”
最简单的重试是curl --retry 3,但它有几个问题:
- 固定间隔的重试在限流场景下不适用,因为服务端刚让你等 30 秒,你 2 秒后又打过来了;
- 权重相同的重试会把所有请求聚焦在同一时间点,造成“重试风暴”;
- 重试次数用完之后,脚本照样中断。
更合理的是指数退避加抖动。指数退避保证等待时间逐步拉长,抖动防止多个并发任务在同一时刻一起重试。Python 里可以这样写:
import random import time def call_with_retry(api_func, max_retries=5, base_delay=1): for attempt in range(max_retries + 1): try: return api_func() except RateLimitError as e: delay = e.retry_after if e.retry_after else base_delay * (2 ** attempt) time.sleep(delay + random.uniform(0, 0.5)) except TransientError: if attempt == max_retries: raise delay = base_delay * (2 ** attempt) + random.uniform(0, 1) time.sleep(delay)如果响应头里有Retry-After,优先用它;没有的话再用默认的指数退避。这样每次重试都不会撞在同一个瞬间。
3.2 幂等:让重复执行不产生副作用
批量脚本一旦断点续传,就必然会出现“同一任务可能被处理两次”的情况。如果 API 不是幂等的,一次重复调用可能产生重复订单、重复扣费、重复数据。所以每个批量任务在开始前都要问一个问题:这个任务重复执行,结果是否相同?
如果你只是调用一个文本生成 API,重复调用会浪费 token;如果你在批量修改文件名,重复执行可能因为目标文件已存在而报错。设计上可以用“先判后做”的思路:
import hashlib def task_id(payload): return hashlib.sha256(payload.encode()).hexdigest() uid = task_id(item) if uid in done_set: # 已经成功过,直接跳过 continue一些 API 支持幂等键,也就是你可以在请求头里传一个Idempotency-Key,服务端对于相同 key 的重复请求只处理一次。如果你的服务商支持这个能力,一定要用上。
3.3 断点:把进度落到磁盘或者数据库
没有断点的脚本,一旦中断,就要从头再跑。500 条任务还好,5000 条任务谁受得了?
最简单的断点是状态文件。用一个 JSON 文件记录已完成的任务 ID,启动时读进来:
import json import os done_file = "done.json" done_set = set() if os.path.exists(done_file): done_set = set(json.load(open(done_file))) # 每次成功后 done_set.add(task_id(item)) json.dump(list(done_set), open(done_file, "w"))任务量再大一点,建议直接用 SQLite,把每个任务的状态独立管理:
CREATE TABLE tasks ( id TEXT PRIMARY KEY, payload TEXT, status TEXT, retries INTEGER DEFAULT 0, error TEXT, updated_at TEXT );每次启动时只捞取status != 'done'的任务,跑完后更新状态。这样即使进程被 kill,重启后也能接着跑,已经完成的任务不会重复,失败的任务也能单独重试。
4. 从“能跑”到“不容易挂”:改造批量任务的具体操作
知道原理之后,下一步就是动手改。这里我不讲特别高深的框架,只讲大家日常写脚本时最常用、性价比最高的几种改造。
4.1 别在 Shell 里写长循环,必要时换 Python
Shell 脚本的优点是简单,批量请求几十条、一两百条,用 curl 加 for 循环完全够用。但一旦需要处理以下情况,Shell 就会变得很别扭:
- 每条任务状态需要持久化;
- 需要按指数退避重试;
- 需要控制并发但又不想写复杂的后台进程管理;
- 需要处理 Unicode 和特殊字符的文件名。
这时候换成 Python,代码量可能差不多,但可维护性完全不同。以并发控制为例,Python 里用ThreadPoolExecutor加Semaphore就能控制同时运行的请求数量:
import concurrent.futures import threading import time import requests session = requests.Session() semaphore = threading.Semaphore(4) def call_one(task_id, payload): with semaphore: for attempt in range(5): try: resp = session.post( API_URL, json=payload, timeout=(5, 30) ) if resp.status_code == 429: retry_after = float(resp.headers.get("Retry-After", 1)) time.sleep(retry_after + 0.5) continue resp.raise_for_status() return task_id, True except Exception as e: if attempt == 4: return task_id, False time.sleep(2 ** attempt + 0.5) with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor: futures = [ executor.submit(call_one, tid, payload) for tid, payload in tasks ] for future in concurrent.futures.as_completed(futures): tid, ok = future.result() print(tid, "OK" if ok else "FAIL")Semaphore(4)是全局并发上限,线程池再大也不会超过 4 个请求同时打出去。requests.Session会复用底层连接,避免每次请求都重新握手,既稳定又省资源。
4.2 控制并发比提高并发更重要
很多人以为批量任务慢是因为并发不够,把max_workers从 4 改成 20,结果脚本挂得更快。原因很简单:外部 API 的限流策略是动态的,你把并发提上去,触发限流后被限制得更严,整体吞吐反而下降。
我听过的经验值是:外部无状态 API,单机并发控制在 2 到 8 之间比较稳;如果拿不准,从 1 开始,一点点往上加,直到出现429,再退回上一个档位。你的目标不是“压榨 API 极限”,而是“稳定跑完”。
如果你用的是大模型类 API,更要克制并发。这类服务响应时间长,连接的累计占用也大,并发过高时很容易出现连接池耗尽或者读取超时。
4.3 给任务建一张表,而不是给脚本写一堆 if
改造到一定程度后,你会发现“批量脚本”本质上就是一个极简的任务队列系统。每个任务有状态、有重试次数、有错误信息、有更新时间。你用 SQLite 就能把它管理起来:
-- 查询待处理任务 SELECT * FROM tasks WHERE status = 'pending' OR (status = 'failed' AND retries < 3) ORDER BY created_at LIMIT 100; -- 单条失败后标记,等待下次重试 UPDATE tasks SET status = 'failed', error = ?, retries = retries + 1, updated_at = ? WHERE id = ?; -- 下次启动前把失败的重新放回 pending UPDATE tasks SET status = 'pending' WHERE status = 'failed' AND retries < 3;这个表的存在,让“中断恢复”不再是玄学,而是读一次数据库就能继续。进程死了就重启,任务还在表里,进度不会丢。
4.4 让进程在后台活着,别被终端“带走”
很多时候脚本不是被 API 挂掉的,是被终端会话中断带走的。SSH 断开了、命令行窗口关了、电脑休眠了,都会给脚本发送挂断信号。用nohup或setsid把脚本从当前会话里剥离出来,是最基本的操作:
setsid nohup python3 batch.py > run.log 2>&1 &但要注意,nohup只能屏蔽 SIGHUP,不能保证脚本内部不崩。更稳的方式是在脚本里设置trap,在收到退出信号时优雅地保存进度:
trap 'touch stop.flag' TERM INT while read -r item; do if [ -f stop.flag ]; then echo "检测到停止信号,退出循环" break fi process_item "$item" done < input.txt这样即使你想手动终止脚本,也能留出处理当前任务和保存进度的间隙,而不是硬生生打断。
5. 一次真实改造的复盘:500 条任务从 60% 到 100%
理论讲再多,不如看一组真实改造前后的对比。这是我之前处理 500 条文档摘要批量任务时的情况,API 是外部大模型接口,限流大约是 10 QPS。
5.1 改造前:能跑,但不敢离开电脑
原来的脚本基本就是 Shell for 循环加 curl,没有重试,没有日志,没有断点。第一次跑,所有任务一次性并发出去,前 100 条还很顺利,到第 200 条时开始出现大量429,接着是超时,最后进程挂在了某个连接错误上。我数了一下,500 条任务只成功 317 条,而且因为没记录进度,重跑只能从第 1 条开始。
第二次我在循环里加了sleep 0.5,情况好了一点,但依然会在某个异常数据上卡死。一遇到400,curl 默认不会报错退出,脚本只是默默把响应写进文件,然后继续跑,但那些响应其实都是错误信息。看起来跑了很热闹,实际成功的不多。
5.2 改造后:可以扔在后台,第二天看结果
后来我按这篇文章的思路做了几件事:
- 每条任务先写入 SQLite,状态初始为
pending; - 用 Python
requests.Session复用连接; - 全局并发控制在 5;
- 遇到
429按Retry-After等待,遇到网络异常按指数退避重试; - 每次成功都更新任务状态为
done,失败则记录错误并把retries加一; - 日志里同时记录任务 ID、HTTP 状态码、耗时和 request_id。
改造后同一批 500 条任务,完成度是 100%,全程没有人工干预。总耗时大约从原来预计的几小时压缩到 20 分钟左右,中途我还故意 kill 了一次进程验证恢复能力,重启后脚本自动跳过已完成的条目,继续处理剩余任务。
改造前后的对比大概是这样的:
| 指标 | 改造前 | 改造后 |
|---|---|---|
| 成功率 | 317/500,约 63% | 500/500,100% |
| 中断恢复 | 不支持,只能从头跑 | 支持,重启后从断点继续 |
| 错误定位 | 靠肉眼翻屏幕 | 日志里逐条可查 |
| 人工介入 | 几乎全程盯守 | 0 次 |
| 限流处理 | 无策略,硬撞 | 指数退避加抖动 |
这不是什么魔法,只是把“脚本”变成了一个具备基本韧性的处理系统。
5.3 几条实测下来才懂的小经验
第一,遇到429时,优先相信响应头里的Retry-After,哪怕它给的值看起来很大。有些服务商是按“窗口”限流的,你提前重试反而会拉长整个惩罚窗口。
第二,400类错误不要盲目重试。把出错的请求体原样存下来,跑完后集中看一次。绝大多数400都是输入数据问题,改掉那条数据比改代码有效得多。
第三,如果你的服务商提供官方批量接口,优先用官方批量,而不是自己写循环。自己写批量要处理限流、超时、幂等、断点,官方批量接口往往已经帮你把这些问题封装好了。
第四,不要小看“每处理 50 条打一个 checkpoint”这种土办法。即使用了数据库,偶尔也会遇到进程连数据库状态都来不及更新的极端情况,checkpoint 文件相当于最后一道保险。
我现在的习惯是所有批量任务脚本默认带上三件套:日志、重试、断点。哪怕只是一个只有几十条的小任务,也会加上状态记录和退出码检查。毕竟“跑一半就挂”这件事,真的不是 API 的错,而是脚本在设计时就没考虑“外部世界会出问题”这个事实。所谓的稳定性,不是让脚本永远不犯错,而是让它犯了错也能知道自己错在哪,并且能体面地继续。