Crawlee 长任务断点续抓:中断后恢复任务并避免重复写入

结合 Crawlee 持久队列和 SQLite 幂等写入,演示写入后未确认时中断、重启继续、失败补采与结果对账,明确单进程恢复边界。

亿牛云技术团队2026年10月28日6 分钟阅读

长任务中最危险的中断窗口,是数据已经写入、请求却还没被队列确认。重启后请求可能再次执行;如果输出只能追加,就会出现重复记录。反过来,先确认请求再写数据,又可能在中断时永久漏掉记录。

本文以两页图书目录为例,把持久请求队列与 SQLite 主键更新组合起来。正文包含一次明确的中断演示开关,用来说明重放为什么应该安全。示例面向单机、单进程任务,不宣称分布式 exactly-once。

准备输入地址

先在浏览器找到下列页面并确认内容,再从地址栏复制完整地址。服务或仓库地址从相应账户设置、官方项目的接入说明或克隆菜单取得。不要从示例输出中反推真实地址。

  • BOOKS_LIST_URL:Books to Scrape 总目录第一页地址。

在执行后续命令的同一个 Bash/WSL 终端中设置:

export BOOKS_LIST_URL='<paste-the-actual-books-list-url>'

下文 JSON 或任务参数中的 <变量名> 是待替换标记,提交前填写刚复制的实际值;JSON 不会自动展开环境变量。结果示例中的 example.16yun.cn 只表示来源字段的形状,不是本文部署的演示站。

安装并把状态放进同一任务目录

使用 Node.js 24。它提供本文所需的 node:sqlite DatabaseSync;某些补丁版本可能显示模块实验性提示,具体能力以 Node SQLite 文档 为准。

mkdir resumable-catalog
cd resumable-catalog
npm init -y
npm install crawlee@3.18.2
export CRAWLEE_STORAGE_DIR="$PWD/resume-store"
export CRAWLEE_PURGE_ON_START=0

同一任务恢复时保留同一目录、队列名和数据库。不要每次启动生成一个新目录,再把从头抓称作恢复。存储配置见 Crawlee 配置文档。

队列状态与商品状态分别保存

队列记录待处理或已处理的请求;数据库按 UPC 保存业务商品。请求 uniqueKey 控制调度身份,UPC 控制商品身份。它们不能互相替代。

本例把来源范围也写进数据库,防止无意中换入口后继续使用旧任务状态。只能有一个进程使用这份本地任务目录;并行部署需要重新设计队列与存储协调。

完整恢复示例

创建 resume.mts:

import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { CheerioCrawler, RequestQueue, NonRetryableError } from 'crawlee';
 
const storage = process.env.CRAWLEE_STORAGE_DIR;
if (!storage) throw new Error('请固定 CRAWLEE_STORAGE_DIR');
mkdirSync(storage, { recursive: true });
const db = new DatabaseSync(join(storage, 'catalog.sqlite'));
db.exec(`
CREATE TABLE IF NOT EXISTS products(
  upc TEXT PRIMARY KEY,title TEXT NOT NULL,price_display TEXT NOT NULL,source_url TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS settings(k TEXT PRIMARY KEY,v TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS failures(
  url TEXT PRIMARY KEY,label TEXT NOT NULL,page_no INTEGER,error_type TEXT NOT NULL
);
`);
const start = process.env.BOOKS_LIST_URL;
if (!start) throw new Error('Set BOOKS_LIST_URL before running');
const scope = JSON.stringify({ start, maxListPages: 2 });
const priorScope = db.prepare("SELECT v FROM settings WHERE k='scope'").get();
if (priorScope && priorScope.v !== scope) throw new Error('任务范围已变,请使用独立任务目录');
db.prepare("INSERT OR IGNORE INTO settings VALUES ('scope',?)").run(scope);
const queue = await RequestQueue.open('catalog-resume');
await queue.addRequest({ url: start, label: 'LIST', userData: { page: 1 } });
 
const repair = process.env.REPAIR_RUN;
if (repair) {
    for (const row of db.prepare('SELECT * FROM failures').all()) {
        await queue.addRequest({
            url: String(row.url), label: String(row.label),
            userData: { page: row.page_no },
            uniqueKey: 'repair:' + repair + ':' + row.url,
        });
    }
}
const crawler = new CheerioCrawler({
    requestQueue: queue,
    maxConcurrency: 1,
    maxRequestsPerCrawl: 100,
    maxRequestRetries: 1,
    async requestHandler({ request, $, enqueueLinks }) {
        if (request.label === 'LIST') {
            if (!$('article.product_pod h3 a').length) {
                throw new NonRetryableError('列表没有商品');
            }
            await enqueueLinks({ selector: 'article.product_pod h3 a', label: 'DETAIL' });
            if (Number(request.userData.page) < 2) {
                if (!$('li.next a').length) throw new NonRetryableError('缺少下一页');
                await enqueueLinks({
                    selector: 'li.next a', label: 'LIST', userData: { page: 2 },
                });
            }
            db.prepare('DELETE FROM failures WHERE url=?').run(request.url);
            return;
        }
        if (request.label !== 'DETAIL') throw new NonRetryableError('未知请求标签');
        const fields = new Map<string, string>();
        $('table.table-striped tr').each((_, row) => {
            fields.set($(row).find('th').text().trim(), $(row).find('td').text().trim());
        });
        const upc = fields.get('UPC');
        const title = $('.product_main h1').text().trim();
        const price = fields.get('Price (incl. tax)');
        if (!upc || !title || !price) throw new NonRetryableError('商品字段缺失');
        const crash = process.argv.includes('--crash-once')
            && !db.prepare("SELECT v FROM settings WHERE k='crash_done'").get();
        db.exec('BEGIN IMMEDIATE');
        try {
            db.prepare(`INSERT INTO products VALUES (?,?,?,?)
                ON CONFLICT(upc) DO UPDATE SET title=excluded.title,
                price_display=excluded.price_display,source_url=excluded.source_url`)
                .run(upc, title, price, request.loadedUrl ?? request.url);
            db.prepare('DELETE FROM failures WHERE url=?').run(request.url);
            if (crash) db.prepare("INSERT INTO settings VALUES ('crash_done','1')").run();
            db.exec('COMMIT');
        } catch (error) {
            db.exec('ROLLBACK');
            throw error;
        }
        if (crash) {
            console.log('模拟中断:数据已提交,处理函数尚未返回');
            process.exit(99);
        }
    },
    async failedRequestHandler({ request }, error) {
        db.prepare(`INSERT INTO failures VALUES (?,?,?,?)
            ON CONFLICT(url) DO UPDATE SET label=excluded.label,
            page_no=excluded.page_no,error_type=excluded.error_type`)
            .run(request.url, request.label ?? 'LIST',
                Number(request.userData.page) || null, error.name);
    },
});
 
try {
    await crawler.run();
    const products = db.prepare('SELECT count(*) AS count FROM products').get();
    const failures = db.prepare('SELECT count(*) AS count FROM failures').get();
    const finished = await queue.isFinished();
    console.log({ products, failures, queue_finished: finished });
    if (!finished || Number(failures?.count) > 0) process.exitCode = 1;
} finally {
    db.close();
}

正常运行可以直接执行 node resume.mts。若要观察本文讨论的中断窗口,在全新的独立任务目录按下面步骤进行:

node resume.mts --crash-once
node resume.mts

第一条会在首次商品提交后以 99 退出;这是读者可自行执行的演示,不是本文的实测日志。第二条保留相同环境与目录,再继续处理。

为什么写入要先于请求确认

处理函数返回后,Crawlee 才按自己的流程确认请求。SQLite 先完成提交,因此中断后最坏情况是同一详情重放;主键更新把重复执行变成覆盖同一商品,而不是增加一行。

这不是把队列与数据库放进同一分布式事务。保证来自任务允许重放、业务写入幂等以及提交顺序。若处理还会发邮件、创建订单或扣费,必须分别解决那些副作用,不能只依靠数据库 UPC 主键。

中断后队列中的请求可能还受锁或租约影响,未必立即重新分配。检查待处理状态并按存储机制等待,不要为了看见进度新建队列,或者直接把所有请求标成已处理。队列接口见 RequestQueue 文档。

怎样确认恢复完成

练习目录若仍为每页二十本且无重复,业务结果应覆盖前两页的四十个 UPC。但真实检查还要看第二页样本、必填字段、失败表和 queue_finished,不能只凭总行数。

crash_done 标记和第一条商品在同一事务内保存。重启时这份演示状态不会要求再次中断;数据库里只有一条相同 UPC 的记录。不同任务要用不同目录,不能删掉商品表后保留旧的已处理队列。

程序再次运行而没有新请求,是队列已经完成的正常情况。需要更新商品内容时,应创建新的采集批次或明确补采,而不是期待已处理请求自动重新抓取。

失败补采与意外中断不是一回事

用尽重试后进入 failures 的请求,不能仅靠重启就假设会重新执行。修复网络或字段规则后,显式提供补采批次:

REPAIR_RUN=repair-1 node resume.mts

代码为失败 URL 添加带补采批次的新 uniqueKey,保留原标签和列表页信息。同一 repair-1 重复启动不会无限添加同一补采请求;还需再次补查时使用新的明确批次。

成功后删除对应失败记录,失败则继续保留。SQLite 主键仍保护商品行不重复。不要只清空 failures 表让报告变绿,那会丢掉仍未处理的来源。

常见问题与修复

重启后像新任务。 检查工作目录、CRAWLEE_STORAGE_DIR、队列名与清理设置。先定位状态,不删除目录重试。

队列完成但数据库为空。 检查是否误删数据库或切换文件。队列与结果需要作为同一任务状态保留,不能各自独立恢复到不同时间。

数据库重复。 检查业务键是否稳定,以及实际存储是否仍只追加。请求 URL 去重无法代替业务写入幂等。

失败地址没有重跑。 使用显式补采批次,不把正常重启当成失败重置。

进程启动后暂时没有工作。 检查请求锁、存储配置和待处理状态。不要以一段安静日志推断所有商品已完成。

多个进程共享后行为异常。 本文约定单进程。先恢复独占目录,再设计适合多 Worker 的队列和事务方案,不在同一目录反复加锁碰运气。

可靠恢复允许重复执行,却不允许重复改变业务结果。把任务身份、提交顺序和补采规则都写清楚,比保存一个 processed_urls 文件更接近完整的长任务方案。

需要企业代理方案?

我们可根据目标站点、并发规模与稳定性目标提供定制方案。