采集任务的幂等设计:重复执行不重复、不漏、不脏的完整实践

采集系统最怕的三个字:跑重了。任务重复执行,要么数据重复、要么重复扣费、要么状态错乱。幂等(idempotency)就是解决「同一件事执行多少次,结果都一样」的工程手段。这篇把采集任务幂等设计讲透:幂等键、去重表、状态机、重试幂等,一套完整方案。

为什么采集任务必须有幂等

采集场景天然容易「跑重」:

  • 定时任务重复触发(调度器重启、cron 多注册一条)
  • 手动补跑和自动任务撞车
  • 失败重试导致同一请求发两次

没有幂等,这些场景会:数据重复、credits 多扣、下游分析被脏数据污染。幂等的目标:同一关键词同一天的采集,无论跑几次,结果一致、成本一致。

第一步:幂等键设计

幂等的核心是「唯一键」。采集数据的天然幂等键是业务键

  • 网页采集:(keyword, day)
  • 地图采集:(keyword, lat, lng, day)
  • 详情采集:(feature_id, day)

唯一键要「能代表这条数据」,而不是用「自增 id」或「时间戳」(每次不同,无法去重)。

# 伪代码:幂等键
def idempotency_key(record):
    # 按业务维度拼键,同一逻辑数据必然同键
    return f"{record['keyword']}:{record['day']}"

第二步:去重表 + INSERT OR IGNORE

最直接的幂等实现:数据库唯一约束 + 忽略重复写入:

import sqlite3

con = sqlite3.connect("serp.db")
con.execute("""CREATE TABLE IF NOT EXISTS snapshots (
    keyword TEXT,
    day TEXT,
    rank INTEGER,
    title TEXT,
    url TEXT,
    PRIMARY KEY (keyword, day, url)   -- 唯一约束 = 幂等保证
)""")

def write_snapshot(rec):
    con.execute(
        "INSERT OR IGNORE INTO snapshots VALUES (?, ?, ?, ?, ?)",
        (rec["keyword"], rec["day"], rec["rank"],
         rec["title"], rec["url"]),
    )
    con.commit()

INSERT OR IGNORE + 唯一键:重复写入自动忽略,无论任务跑几遍,数据只有一份

第三步:任务状态机(防「重复执行」而非只是「重复写入」)

去重表防止「数据重复」,状态机防止「任务重复执行」。任务表用状态驱动:

CREATE TABLE crawl_tasks (
  keyword TEXT PRIMARY KEY,
  day TEXT,
  status TEXT NOT NULL DEFAULT 'pending',   -- pending/running/done/failed
  updated_at TEXT
);

采集前先「占位」:

def claim_task(keyword, day):
    # 原子更新:只有 pending 才能被认领,防止两个 worker 同时跑同一个任务
    cur = con.execute(
        "UPDATE crawl_tasks SET status='running', updated_at=?"
        " WHERE keyword=? AND day=? AND status='pending'",
        (now, keyword, day),
    )
    return cur.rowcount == 1   # 只有成功认领的 worker 才继续

if claim_task(keyword, day):
    data = fetch(keyword)
    write_snapshot(data)
    mark_done(keyword, day)

关键:认领是原子的UPDATE ... WHERE status='pending' 只影响一行),两个 worker 同时抢同一个任务,只有一个能认领成功——这就是「防重复执行」。

第四步:重试也要幂等

重试是「重复执行」的温床。重试幂等的原则:

  1. 先认领后请求:确认自己是这个任务的执行者再发请求
  2. 请求结果幂等写入:无论重试几次,写入走唯一键 + IGNORE
  3. 重试不重复扣费怎么办:SerpBase 失败/超时自动退 credits,所以「重试失败的请求」不产生额外成本;成功那次写入幂等,不产生重复数据
def crawl_with_idempotent_retry(keyword, day, retries=3):
    for attempt in range(retries):
        if claim_task(keyword, day):        # 认领成功才执行
            data = fetch(keyword)
            write_snapshot_from(data)       # 幂等写入
            mark_done(keyword, day)
            return True
        else:
            # 已被别的 worker 认领:这个任务不归我,退出
            return False
    return False

第五步:补偿任务的幂等

补跑失败任务时,也要走幂等:只补 status='failed' 或缺失的任务,补跑的结果仍然走唯一键写入——补跑永远不会制造重复数据

def compensate():
    # 只处理 failed / 缺数据的任务
    for task in con.execute(
        "SELECT keyword, day FROM crawl_tasks WHERE status IN ('failed', 'pending')"
    ):
        crawl_with_idempotent_retry(task["keyword"], task["day"])

幂等的三层模型(沉淀)

  1. 写入幂等:唯一键 + INSERT OR IGNORE——数据不重复
  2. 执行幂等:状态机 + 原子认领——任务不重复执行
  3. 重试幂等:认领后再请求 + 结果幂等写入——重试不制造问题

三层都做,采集任务「跑多少遍都安全」。

踩坑记录

坑 1:唯一键没含时间维度。 (keyword, url) 跨天互相覆盖。加上 day 才正确。

坑 2:认领和写入不是原子的。 先写数据再标记 done,两个 worker 都写了。改成「先认领(原子)再执行」。

坑 3:失败任务没状态。 任务失败后没标记 failed,补跑时全量重跑,重复写入靠唯一键挡了一部分,但请求成本翻倍。失败要落状态。

坑 4:重试没有幂等保护。 重试直接重新请求,即使另一个 worker 已完成,也重复跑。认领机制解决。

工程清单沉淀

  1. 幂等键用业务维度(keyword+day 等),别用自增 id
  2. 唯一约束 + INSERT OR IGNORE 做写入幂等
  3. 状态机 + 原子认领做执行幂等
  4. 重试:认领后再执行,结果幂等写入
  5. 补偿任务只补 failed/pending,走同一套幂等

幂等不是「加个唯一键」这么简单,它是写入、执行、重试三层设计。三层都做扎实,采集任务就成了「怎么跑都安全」的基础设施——这是采集系统可靠性的地基。

接口的退款机制(失败/超时自动退 credits)在 SerpBase 官方文档 里有说明,它让「重试 + 幂等」的成本归零。你的采集任务幂等做到第几层了?评论区聊聊。

posted @ 2026-09-06 19:53  蜘蛛人  阅读(4)  评论(0)    收藏  举报