批量脚本总跑一半就挂?从限流、超时到断点续传的完整排查与改造指南
2026/9/21 16:44:03 网站建设 项目流程

最近我整理后台任务脚本时发现一个特别普遍的现象:凡是拿 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 received
  • curl: (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 退出码含义是否需要重试
0curl 完成请求,但不代表 HTTP 一定是 2xx看状态码决定
6DNS 解析失败可短暂重试
7连接建立失败可短暂重试
28操作超时可重试
35SSL 连接失败检查证书,通常不用重试
56接收数据失败可重试

HTTP 状态码也要分类处理。4xx是请求参数有问题,重试一万次还是400429是限流,必须等待;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 里用ThreadPoolExecutorSemaphore就能控制同时运行的请求数量:

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 断开了、命令行窗口关了、电脑休眠了,都会给脚本发送挂断信号。用nohupsetsid把脚本从当前会话里剥离出来,是最基本的操作:

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
  • 用 Pythonrequests.Session复用连接;
  • 全局并发控制在 5;
  • 遇到429Retry-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 的错,而是脚本在设计时就没考虑“外部世界会出问题”这个事实。所谓的稳定性,不是让脚本永远不犯错,而是让它犯了错也能知道自己错在哪,并且能体面地继续。

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

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

立即咨询