Python超时先留出口

一次 Python 定时任务凌晨卡住,第二天大家才发现报表没生成。进程还活着,CPU 不高,日志停在“开始拉取数据”。排查后发现某个 HTTP 请求没有设置 timeout,连接一直挂着,后面的任务全部排队等待。这个问题修起来只加了一个参数,复盘却暴露了更大的问题:任务没有总超时,没有取消出口,也没有足够日志说明卡在哪里。
Python 很适合写脚本、爬取、数据处理和后台胶水任务。也正因为写起来快,很多任务一开始就缺少边界。网络请求默认等待、数据库查询没有上限、线程池里的任务无法取消、重试没有停止条件。任务成功时没人注意,任务卡住时就变成一团雾。
一个 timeout 参数不等于超时体系
requests timeout=5 是好习惯,但这只是单个网络请求的超时。一个任务通常由多个步骤组成:读取配置、拉取数据、转换、写库、上传文件、发通知。每一步都要有自己的边界,整个任务还要有总预算。
总超时回答的是“这个任务最多占用系统多久”。单步超时回答的是“某个依赖最多等多久”。重试间隔回答的是“失败后多久再试”。这三者混在一起,任务就会失控。比如单次请求 10 秒、重试 5 次、每步都有 3 个接口,最后总耗时可能远超调度窗口。
在同步脚本里,可以用简单的时间预算对象记录剩余时间。每一步执行前检查预算,传给外部请求的 timeout 也不要超过剩余预算。这样任务越接近尾声,越不会发起长时间操作。
  1. import time

  2. class Budget:
  3. def __init__(self, seconds):
  4. self.deadline = time.monotonic() + seconds

  5. def left(self):
  6. return max(0.1, self.deadline - time.monotonic())

  7. def expired(self):
  8. return time.monotonic() >= self.deadline
python
这段代码很朴素,但能提醒你:超时不是散落在各处的数字,而是一份预算。
取消出口要提前设计
很多 Python 任务的问题是“开始了就停不下来”。线程池里的函数不检查取消标记,协程里吞掉 CancelledError,子进程没有超时回收,信号处理只打印日志不改变状态。结果发布、重启、人工停止都变得很粗暴。
如果是 asyncio,取消要沿着 await 链传递,不要随手捕获所有 Exception。CancelledError 在新版本 Python 中有自己的继承关系,写宽泛异常时要格外注意。对于线程任务,无法强杀线程,就要让任务内部定期检查停止标记。对于子进程,要用 timeout kill 兜底。
取消不是失败,它是一个单独结果。用户取消、系统停机、预算耗尽、依赖超时,应该写成不同状态。否则任务平台只看到一堆 failure,无法判断是业务错误还是系统保护。
重试要区分可恢复和不可恢复
Python 脚本常见写法是 except sleep 再来一次。这个逻辑对网络抖动有用,对参数错误没用,对权限错误甚至有害。重试之前要判断错误类型:连接超时、服务端 502、限流可以重试;字段缺失、认证失败、SQL 语法错误不该重试。
重试还要带退避和上限。连续快速重试会把依赖打得更糟,也会让日志膨胀。更好的方式是指数退避加随机抖动,并在每次重试日志里记录 attempt、error_type、next_delay。这样后续排查能看到任务是在恢复,还是在原地打转。
日志要能回答“卡在哪”
脚本日志不要只写“开始”和“结束”。每个关键步骤都要记录开始、结束、耗时、输入规模、输出数量和错误类型。尤其是循环处理大量数据时,要周期性记录进度。否则任务卡住后,你不知道它是卡在第 3 条,还是第 30000 条。
结构化日志比长句子更有用。比如 task_id、step、cost_ms、attempt、rows、error_type。它们能被搜索和聚合,适合告警和复盘。不要把所有上下文揉进一段自然语言里。
上线前故意让它失败一次
一个任务是否可靠,不是看正常路径跑通,而是看失败时能不能停、能不能报、能不能重跑。上线前可以故意让接口超时、数据库拒绝、文件上传失败、进程收到终止信号。观察任务状态、日志、告警和清理动作是否符合预期。
如果失败后只能靠人工登录机器 kill 进程,说明出口还没留好。如果失败后不知道处理了多少数据,说明证据还不够。如果重跑会重复写入,说明幂等也要补。
最后的判断标准很简单:一个 Python 后台任务,应该能在超时、取消、依赖失败和进程重启时给出清楚状态。成功只是基本能力,失败得可控才是工程能力。
如果任务处理的是批量数据,最好记录批次和游标。失败后从哪里继续,不应该靠人工猜。游标记录得越清楚,任务越敢失败,因为失败不会变成从头再来的惩罚。
如果任务处理的是批量数据,最好记录批次和游标。失败后从哪里继续,不应该靠人工猜。游标记录得越清楚,任务越敢失败,因为失败不会变成从头再来的惩罚。
如果任务处理的是批量数据,最好记录批次和游标。失败后从哪里继续,不应该靠人工猜。游标记录得越清楚,任务越敢失败,因为失败不会变成从头再来的惩罚。
如果任务处理的是批量数据,最好记录批次和游标。失败后从哪里继续,不应该靠人工猜。游标记录得越清楚,任务越敢失败,因为失败不会变成从头再来的惩罚。
如果任务处理的是批量数据,最好记录批次和游标。失败后从哪里继续,不应该靠人工猜。游标记录得越清楚,任务越敢失败,因为失败不会变成从头再来的惩罚。
如果任务处理的是批量数据,最好记录批次和游标。失败后从哪里继续,不应该靠人工猜。游标记录得越清楚,任务越敢失败,因为失败不会变成从头再来的惩罚。
如果任务处理的是批量数据,最好记录批次和游标。失败后从哪里继续,不应该靠人工猜。游标记录得越清楚,任务越敢失败,因为失败不会变成从头再来的惩罚。
如果任务处理的是批量数据,最好记录批次和游标。失败后从哪里继续,不应该靠人工猜。游标记录得越清楚,任务越敢失败,因为失败不会变成从头再来的惩罚。
如果任务处理的是批量数据,最好记录批次和游标。失败后从哪里继续,不应该靠人工猜。游标记录得越清楚,任务越敢失败,因为失败不会变成从头再来的惩罚。
如果任务处理的是批量数据,最好记录批次和游标。失败后从哪里继续,不应该靠人工猜。游标记录得越清楚,任务越敢失败,因为失败不会变成从头再来的惩罚。
如果任务处理的是批量数据,最好记录批次和游标。失败后从哪里继续,不应该靠人工猜。游标记录得越清楚,任务越敢失败,因为失败不会变成从头再来的惩罚。
如果任务处理的是批量数据,最好记录批次和游标。失败后从哪里继续,不应该靠人工猜。游标记录得越清楚,任务越敢失败,因为失败不会变成从头再来的惩罚。
如果任务处理的是批量数据,最好记录批次和游标。失败后从哪里继续,不应该靠人工猜。游标记录得越清楚,任务越敢失败,因为失败不会变成从头再来的惩罚。
如果任务处理的是批量数据,最好记录批次和游标。失败后从哪里继续,不应该靠人工猜。游标记录得越清楚,任务越敢失败,因为失败不会变成从头再来的惩罚。
如果任务处理的是批量数据,最好记录批次和游标。失败后从哪里继续,不应该靠人工猜。游标记录得越清楚,任务越敢失败,因为失败不会变成从头再来的惩罚。
如果任务处理的是批量数据,最好记录批次和游标。失败后从哪里继续,不应该靠人工猜。游标记录得越清楚,任务越敢失败,因为失败不会变成从头再来的惩罚。
如果任务处理的是批量数据,最好记录批次和游标。失败后从哪里继续,不应该靠人工猜。游标记录得越清楚,任务越敢失败,因为失败不会变成从头再来的惩罚。
如果任务处理的是批量数据,最好记录批次和游标。失败后从哪里继续,不应该靠人工猜。游标记录得越清楚,任务越敢失败,因为失败不会变成从头再来的惩罚。
如果任务处理的是批量数据,最好记录批次和游标。失败后从哪里继续,不应该靠人工猜。游标记录得越清楚,任务越敢失败,因为失败不会变成从头再来的惩罚。
如果任务处理的是批量数据,最好记录批次和游标。失败后从哪里继续,不应该靠人工猜。游标记录得越清楚,任务越敢失败,因为失败不会变成从头再来的惩罚。
如果任务处理的是批量数据,最好记录批次和游标。失败后从哪里继续,不应该靠人工猜。游标记录得越清楚,任务越敢失败,因为失败不会变成从头再来的惩罚。
如果任务处理的是批量数据,最好记录批次和游标。失败后从哪里继续,不应该靠人工猜。游标记录得越清楚,任务越敢失败,因为失败不会变成从头再来的惩罚。
如果任务处理的是批量数据,最好记录批次和游标。失败后从哪里继续,不应该靠人工猜。游标记录得越清楚,任务越敢失败,因为失败不会变成从头再来的惩罚。
如果任务处理的是批量数据,最好记录批次和游标。失败后从哪里继续,不应该靠人工猜。游标记录得越清楚,任务越敢失败,因为失败不会变成从头再来的惩罚。
如果任务处理的是批量数据,最好记录批次和游标。失败后从哪里继续,不应该靠人工猜。游标记录得越清楚,任务越敢失败,因为失败不会变成从头再来的惩罚。
如果任务处理的是批量数据,最好记录批次和游标。失败后从哪里继续,不应该靠人工猜。游标记录得越清楚,任务越敢失败,因为失败不会变成从头再来的惩罚。
如果任务处理的是批量数据,最好记录批次和游标。失败后从哪里继续,不应该靠人工猜。游标记录得越清楚,任务越敢失败,因为失败不会变成从头再来的惩罚。