批量脚本总跑一半就挂?从限流、超时到断点续传的完整排查与改造指南 最近我整理后台任务脚本时发现一个特别普遍的现象凡是拿 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 filesPython 脚本里用requests不停创建新连接没有复用 SessionTCP 连接数暴涨日志越写越大最后磁盘满了写入失败脚本在后台跑你合上笔记本盖子系统休眠后网络和线程全断了批量并发开得太大内存占用过高进程被系统 OOM Killer 干掉。这一类的特点是日志里看不到业务错误进程莫名其妙消失或者最后几条任务没有记录。排查时要重点看系统日志和资源使用情况。2. 挂掉之前脚本其实给过你信号怎么从日志里找真相很多人说脚本“一点征兆都没有就挂了”我几乎每次都会反问一句你的脚本有“日志”吗准确地说有“每一条任务独立记录”的日志吗2.1 你缺的不是日志而是逐条可追踪的日志Shell 的 for 循环最容易犯的错就是把 curl 的输出打到屏幕上跑完也只剩最后几条记录。正确做法是每处理一条任务就记录当前任务编号、开始时间、HTTP 状态码、耗时以及失败了是什么原因。一个可以抄的写法是这样while IFS read -r item; do log_filebatch_$(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 21 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, jsonpayload) request_id resp.headers.get(x-request-id, unknown) print(ftask{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_retries5, base_delay1): 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 里写长循环必要时换 PythonShell 脚本的优点是简单批量请求几十条、一两百条用 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, jsonpayload, 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_workers4) 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 21 但要注意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情况好了一点但依然会在某个异常数据上卡死。一遇到400curl 默认不会报错退出脚本只是默默把响应写进文件然后继续跑但那些响应其实都是错误信息。看起来跑了很热闹实际成功的不多。5.2 改造后可以扔在后台第二天看结果后来我按这篇文章的思路做了几件事每条任务先写入 SQLite状态初始为pending用 Pythonrequests.Session复用连接全局并发控制在 5遇到429按Retry-After等待遇到网络异常按指数退避重试每次成功都更新任务状态为done失败则记录错误并把retries加一日志里同时记录任务 ID、HTTP 状态码、耗时和 request_id。改造后同一批 500 条任务完成度是 100%全程没有人工干预。总耗时大约从原来预计的几小时压缩到 20 分钟左右中途我还故意 kill 了一次进程验证恢复能力重启后脚本自动跳过已完成的条目继续处理剩余任务。改造前后的对比大概是这样的指标改造前改造后成功率317/500约 63%500/500100%中断恢复不支持只能从头跑支持重启后从断点继续错误定位靠肉眼翻屏幕日志里逐条可查人工介入几乎全程盯守0 次限流处理无策略硬撞指数退避加抖动这不是什么魔法只是把“脚本”变成了一个具备基本韧性的处理系统。5.3 几条实测下来才懂的小经验第一遇到429时优先相信响应头里的Retry-After哪怕它给的值看起来很大。有些服务商是按“窗口”限流的你提前重试反而会拉长整个惩罚窗口。第二400类错误不要盲目重试。把出错的请求体原样存下来跑完后集中看一次。绝大多数400都是输入数据问题改掉那条数据比改代码有效得多。第三如果你的服务商提供官方批量接口优先用官方批量而不是自己写循环。自己写批量要处理限流、超时、幂等、断点官方批量接口往往已经帮你把这些问题封装好了。第四不要小看“每处理 50 条打一个 checkpoint”这种土办法。即使用了数据库偶尔也会遇到进程连数据库状态都来不及更新的极端情况checkpoint 文件相当于最后一道保险。我现在的习惯是所有批量任务脚本默认带上三件套日志、重试、断点。哪怕只是一个只有几十条的小任务也会加上状态记录和退出码检查。毕竟“跑一半就挂”这件事真的不是 API 的错而是脚本在设计时就没考虑“外部世界会出问题”这个事实。所谓的稳定性不是让脚本永远不犯错而是让它犯了错也能知道自己错在哪并且能体面地继续。