采集任务的幂等设计:重复执行不重复、不漏、不脏的完整实践
采集系统最怕的三个字:跑重了。任务重复执行,要么数据重复、要么重复扣费、要么状态错乱。幂等(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 同时抢同一个任务,只有一个能认领成功——这就是「防重复执行」。
第四步:重试也要幂等
重试是「重复执行」的温床。重试幂等的原则:
- 先认领后请求:确认自己是这个任务的执行者再发请求
- 请求结果幂等写入:无论重试几次,写入走唯一键 + IGNORE
- 重试不重复扣费怎么办: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"])
幂等的三层模型(沉淀)
- 写入幂等:唯一键 +
INSERT OR IGNORE——数据不重复 - 执行幂等:状态机 + 原子认领——任务不重复执行
- 重试幂等:认领后再请求 + 结果幂等写入——重试不制造问题
三层都做,采集任务「跑多少遍都安全」。
踩坑记录
坑 1:唯一键没含时间维度。 (keyword, url) 跨天互相覆盖。加上 day 才正确。
坑 2:认领和写入不是原子的。 先写数据再标记 done,两个 worker 都写了。改成「先认领(原子)再执行」。
坑 3:失败任务没状态。 任务失败后没标记 failed,补跑时全量重跑,重复写入靠唯一键挡了一部分,但请求成本翻倍。失败要落状态。
坑 4:重试没有幂等保护。 重试直接重新请求,即使另一个 worker 已完成,也重复跑。认领机制解决。
工程清单沉淀
- 幂等键用业务维度(keyword+day 等),别用自增 id
- 唯一约束 +
INSERT OR IGNORE做写入幂等 - 状态机 + 原子认领做执行幂等
- 重试:认领后再执行,结果幂等写入
- 补偿任务只补 failed/pending,走同一套幂等
幂等不是「加个唯一键」这么简单,它是写入、执行、重试三层设计。三层都做扎实,采集任务就成了「怎么跑都安全」的基础设施——这是采集系统可靠性的地基。
接口的退款机制(失败/超时自动退 credits)在 SerpBase 官方文档 里有说明,它让「重试 + 幂等」的成本归零。你的采集任务幂等做到第几层了?评论区聊聊。

浙公网安备 33010602011771号