采集脚本的三个稳定性细节:超时、重试、幂等

举报
yd_217517564 发表于 2026/10/26 13:44:43 2026/10/26
【摘要】 采集脚本的三个稳定性细节:超时、重试、幂等写一个能跑一次的采集脚本大概二十分钟;写一个能跑三个月的,问题全在这三件事上。 一、超时:每个外部调用都要有上限,包括"看起来不会卡"的那个requests 默认不设超时——不是"默认 30 秒",是不设。DNS 卡住、对端半开连接、代理静默丢包,任何一种都能让脚本永久挂起。正确写法是区分连接超时和读取超时:import requestsresp ...

采集脚本的三个稳定性细节:超时、重试、幂等

写一个能跑一次的采集脚本大概二十分钟;写一个能跑三个月的,问题全在这三件事上。

一、超时:每个外部调用都要有上限,包括"看起来不会卡"的那个

requests 默认不设超时——不是"默认 30 秒",是不设。DNS 卡住、对端半开连接、代理静默丢包,任何一种都能让脚本永久挂起。正确写法是区分连接超时和读取超时:

import requests

resp = requests.get(url, timeout=(3.05, 15))

3.05 是连接超时,15 是两次读取之间的最大间隔。只写一个数字的写法(timeout=15)对连接和读取都用同一个值,通常不是想要的。

浏览器渲染的场景更麻烦:page.goto() 的超时和"页面渲染完成"是两件事。经验值是导航超时 30 秒,然后轮询目标元素,再给一个整体截止时间:

import time

deadline = time.time() + 60
while time.time() < deadline:
    if js("document.querySelector('#target')"):
        break
    time.sleep(1)
else:
    raise TimeoutError("target element not found in 60s")

二、重试:只重试"可能变好"的错误

把重试写成 for i in range(3): try: ... except: pass 是最常见的错误——它会把"权限不足"、"参数错误"这种确定性失败也重试三遍,浪费时间还掩盖问题。

分类原则:

  • 重试:连接超时、5xx、429、临时 DNS 失败
  • 不重试:4xx(除 429)、解析失败、字段缺失、鉴权失败
import time
import requests

RETRYABLE = (requests.ConnectionError, requests.Timeout)

def fetch(url, attempts=4):
    delay = 1.0
    for i in range(attempts):
        try:
            r = requests.get(url, timeout=(3.05, 15))
            if r.status_code == 429 or 500 <= r.status_code < 600:
                raise requests.HTTPError(f"retryable {r.status_code}")
            r.raise_for_status()
            return r
        except (RETRYABLE, requests.HTTPError):
            if i == attempts - 1:
                raise
            time.sleep(delay)
            delay *= 2

指数退避加一个上限(比如 30 秒),避免把上游按在地上摩擦;如果服务端给了 Retry-After,优先用它。

三、幂等:跑第二遍不会产生第二份数据

采集任务最怕的是"跑到一半失败,重跑后数据库里有半份脏数据"。三条实践:

1)以业务主键去重,而不是自增 ID

import sqlite3

conn = sqlite3.connect("data.db")
conn.execute("CREATE TABLE IF NOT EXISTS item (key TEXT PRIMARY KEY, payload TEXT, fetched_at TEXT)")
conn.execute("INSERT INTO item(key, payload, fetched_at) VALUES(?,?,datetime('now')) "
             "ON CONFLICT(key) DO UPDATE SET payload=excluded.payload, fetched_at=excluded.fetched_at",
             (key, payload))
conn.commit()

2)先写临时表,成功后原子替换

分页抓取时,把这一轮的结果写进 item_staging,全部页面成功后再 INSERT ... SELECT 到正式表并清空暂存。中途失败,正式表不受影响。

3)记录"已完成到哪一页"

state = {"cursor": 0}
# 每页成功后立刻持久化 cursor
json.dump(state, open("cursor.json", "w"))

重跑从 cursor 继续,而不是从头再来。这一条对限流的站点尤其重要——重复抓取会直接触发封禁。

四、一个可以直接抄的骨架

def run_once():
    state = load_state()
    while True:
        page = fetch_page(state["cursor"])     # 内部已带超时与退避
        if not page["items"]:
            break
        with tx():                              # 临时表 + 原子替换
            upsert(page["items"])
            state["cursor"] = page["next"]
        if page["next"] is None:
            break
    save_state(state)
    return summary(state)

配合外层 set -euo pipefail 的 wrapper 和 systemd timer(Type=oneshot,天然串行),整个任务就有了可预期的行为:卡住会被超时打断、失败会退避重试、重跑不会重复、进度不会丢。

小结

超时解决"卡住",重试解决"抖动",幂等解决"重跑"。这三件事都不难,难的是在写第一版的时候就留出位置——否则等到线上开始丢数据,补起来的成本要高得多。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。