尝试为 lychee 添加递归的五年
递归(Recursion)一直是 lychee 存续时间最长的开放问题。它已经在那里悬而未决地放了五年多。
如果你以前没接触过,lychee 是一个用 Rust 编写的快速异步链接检查器(顺便一提)。你可以让它检查自己的网站、文档、README 或 Markdown 文件。
我在 2020 年因为在家无聊而开始了这个项目。如今,大约有 4 万个 GitHub 仓库依赖它。Google、AWS、Microsoft、Cloudflare 以及许多其他公司都用它检查文档中的链接。
lychee 获得了 NLnet 通过其面向开放、可信基础设施的 NGI Zero 计划 提供的资助。
这笔资金让我们能够投入认真而专注的时间开发项目,而不是深夜写代码。1 如今资助即将结束,此时写下这篇文章似乎正合适。
我能说的最诚实的话是:呼声最高的功能——递归——至今仍未发布。:,( 但这其中有充分的理由!当然,归根结底就是“这很难”,不过我们还是深入看看吧。
故事的起点
2020 年 12 月 14 日,一位名为 @styfle 的用户提交了 issue #78:

这要求非常合理!当时,lychee 已经是一个速度很快、支持并发、功能丰富的链接检查器。肯定只要加上一个简单的 --recursive 标志,让它跟踪某个域名内的链接,用一天正经的工作时间就能完成吧?
然而,五年、四次认真实现以及若干被放弃的拉取请求之后,递归仍然没有合并。这个 issue 被标记为 v1.0 里程碑的一部分,我们仍然希望在达到该里程碑前发布它。但不知不觉间,它成了 lychee 的白鲸。
最初的架构让事情变得困难
要理解为什么添加递归如此困难,你需要先了解 lychee 是如何处理数据的。下面是 2020 年末的处理流程:
基本上就是一条大流水线:从输入 URL 到链接提取,再到链接检查,最后进行输出格式化。
@styfle 提交 issue 后,我几乎立刻发现了核心问题:
提取器没有回连。
这个缺失的反馈环路(从已检查的响应回到输入队列)就是问题的本质。lychee 的流水线被设计成一次性、单向的流程:输入从一端进入,结果从另一端出来,输入流结束时程序停止。递归需要一个循环:响应必须能够生成新的输入。而在异步、基于通道的流水线中,循环正是恶龙盘踞之处。🐲
我从第一天就知道这一点。只是严重低估了我们会找到多少种把这个循环做错的方式。
尝试一:简单计数器(2021 年 2 月至 12 月)
我的第一次尝试刻意做得很小。我不想重构架构,只想让递归跑起来!于是我直接在 main.rs 中加入了处理逻辑。思路是:
- 收到响应后,如果它来自最初输入的某个域名,就从中提取链接。
- 把这些新链接重新推回请求通道。
- 持续统计预期请求总数与已完成请求数。
- 在
completed == total时停止。
我添加了一个 recurse() 函数,在成功响应上调用 collector::collect_links(),启动一个任务把新请求发送到通道,并返回它创建的新请求数。一个普通的 HashSet<String> 充当“已见缓存”,这样同一个 URL 就不会被检查两次。
除此之外,还有:
Request和Response结构体中的recursion_level字段--recursive/-r标志- 用于设置最大递归深度的
--depth选项 - 域名过滤,以限制在输入域名范围内
很直截了当,对吧?
错了
程序不会终止。
终止逻辑是一个 while curr < total_requests 循环:
let mut curr = 0;
while curr < total_requests {
curr += 1;
let response = recv_resp.recv().await.context("Receive channel closed")?;
// ... process response, potentially incrementing total_requests
}响应到达并生成新请求时,total_requests 会增加。到这里都没问题。但提取、发送和接收分别在不同任务中并发进行,因此计数可能失去同步。
其实我当时就对它不太满意:
说实话,我现在已经不太满意当前的实现了,因为我会统计队列中的链接数量,然后在所有链接都检查完后关闭通道。我觉得这可能导致细微的 bug。一定有更好的办法。
没错,过去的 Matthias(马蒂亚斯),这个计数器很脆弱,原因在于:
- 新链接是异步发现的,因此
total_requests可能在循环已经决定退出之后才增加。 - 只要计数哪怕差一位,你要么永远卡住(计数过高),要么过早退出(计数过低)。
- 更糟的是,每个边界情况都会让计数逻辑变得更加复杂:缓存响应、失败响应、空页面……
@pawroman 在这里给出了非常彻底的评审,包括仔细分析 HashSet 缓存的内存使用情况(支持数百万链接也没问题)、建议使用带符号的深度值来表示无限递归,以及提醒补充集成测试。这些反馈很有价值,只是无法修复真正出错的地方——整个终止方案本身。
致命一击
2021 年 9 月,我们决定进行一次更大规模的重写:采用基于流的架构(PR #330)来改善并发性。它将 Collector::collect_links 的返回值从 Vec 改为 Stream,移除了 ClientPool 抽象,并重新调整了任务之间的通信方式。这是一次很棒的改进,因为它让收集器变成了惰性的,我们不再需要分配很大的请求 Vec。但这也意味着递归分支坏掉了,脚下的地毯被整个抽走。
我们开始在 #330 中实现基于流的方案,因此我会再次暂时搁置这个 PR;新方案可能很快就会取代这个分支。向所有等待递归支持合并的人道歉,但我希望把它做好,而不是过早合并一个有 bug 的方案。
PR #165 于 2021 年 12 月关闭。流重构顺利合并,并让速度提升了 35–50%。不错!这大概就是取舍吧。
经验总结
- 在异步流水线中统计未完成的工作很脆弱。分布式计数只要差一位,就会导致死锁或提前退出。
- 大型重构和功能分支合不来。流重写让递归分支在准备就绪前就过时了。
- 递归会触及几乎每一层。这不是可以简单外挂上去的功能。
顺便诚实回答一个经常有人问我的语言问题:这里的计数问题不是 Rust 的错。使用 goroutine 和通道的 Go 版本,或使用 Python asyncio 的版本,同样会遇到这些差一位 bug。“响应已处理”和“发现新请求”之间的竞态,是所有并发递归爬虫固有的问题。Rust 的 Stream trait 以及它与所有权的配合,让基于流的架构显得很自然,而正是这一点使之前的工作失效。所以这或许算是 Rust 特有的一点。
尝试二:通过通道把结果反馈回去(2022 年 1 月至 7 月)
流架构就位后,我又试了一次。这次,我不再手动统计请求,而是通过一个连接到收集器的通道把发现的 URL 反馈回去。
收集器从输入通道读取数据,并将收到的内容转换为请求流。递归就意味着把新发现的 URL 发送到这个通道。(看,一个反馈环路!)通道关闭时,流也会自然关闭。
我还尝试统一输入类型,让同一个方法既能接收 Vec,也能接收 Stream:
pub enum InputType {
Stream(Pin<Box<dyn Stream<Item = Input>>>),
Seq(Vec<Input>),
}它又卡住了。不过这次原因完全不同。
反馈环路造成了循环依赖:
- 收集器从输入通道读取数据,并生成请求流。
- 检查器读取请求并生成响应。
- 递归处理器读取响应,并将新输入发送回收集器的通道。
看出问题了吗?
要让收集器的流结束,输入通道必须关闭。要关闭通道,所有发送端都必须被丢弃。但递归处理器持有一个发送端;它需要这个发送端把发现的 URL 推回去。而递归处理器只有在没有更多响应时才会停止;没有更多请求时才不会有更多响应;只有收集器的流结束时才不会有更多请求。又一个导致死锁的循环依赖。
我当时也说明了这一点:
到目前为止我没多少时间研究这个问题,但它会卡住,是因为输入通道没有被丢弃,导致连接悬空。我以为
futures::StreamExt::for_each_concurrent完成后,通道会自动关闭(并被丢弃)。
@untitaker 确认了这一点,并且在非常简单的情况下也复现了死锁:
你是想在没有任何待处理工作后丢弃
sender,对吧?但for_each_concurrent不会因为你还没这么做而永远卡住吗?(而且你也做不到,因为还需要这个 sender 来继续克隆。)
即使在空目录中执行
time lychee --offline -b . '**/*.htm*' -T1,我也能复现死锁。
这就是使用通道进行循环数据流的核心问题:通道把“最后一个发送端被丢弃”作为终止信号,但在循环中,你永远无法丢弃所有发送端,因为每个阶段都必须持有一个发送端来维持循环。
我把问题带到了 Tokio Discord,得到的建议是:“别再为此使用通道了。改用信号量配合 tokio::spawn。”
还有性能问题
即使不考虑死锁,还有第二个问题。新的 from_chan 方法基准测试结果大约比现有的 from 方法慢 30%。额外的通道间接层带来了成本,而且即使在非递归场景下也会产生这种成本,而这恰恰是几乎所有用户使用的场景。
经验总结
- 通道不是循环流水线的正确工具。它们“最后一个发送端丢弃后关闭”的语义,从根本上就与反馈环路相冲突。
for_each_concurrent看起来完美,实际上并不是。它能并发处理流,却没有办法把数据重新反馈进去。- 常用路径不能变慢。如果递归功能会拖慢从不使用它的所有人,那它就毫无价值。
通道循环死锁是任何基于通道的系统固有的问题。Go 通道也有同样的问题。关闭通道意味着你必须知道再也不会有人发送数据,而循环让这件事无法实现。Erlang/OTP 使用进程监控而不是通道语义,绕开了这个问题。不过 30% 的性能回退确实有 Rust 方面的因素。Rust 的零成本抽象文化意味着人们(包括我)期望不为自己不用的功能付出任何代价。在运行时开销较重的语言中,未使用路径回退 30% 可能还能接受;但在 Rust 中,“不用就不付费”几乎是一种道德立场,因此对我来说,这种回退无法接受。
尝试三:信号量(2022 年 2 月)
我的尝试
我彻底放弃了用通道实现递归循环,转而使用:
Arc<Semaphore>来限制并发(取代通道天然提供的背压)- 为每个工作单元使用
tokio::spawn(取代for_each_concurrent) - 将
OwnedSemaphorePermit交给每个任务,这样生成递归子任务时就能“转移”工作许可
这个原型说实话相当简洁:
const MAX_CONCURRENCY: usize = 10;
fn recurse(permit: OwnedSemaphorePermit, i: usize) -> JoinHandle<()> {
tokio::spawn(async move {
handle_input(permit, i).await;
})
}
async fn handle_input(permit: OwnedSemaphorePermit, i: usize) {
println!("got = {i}");
if i % 9 == 0 {
recurse(permit, 10).await.unwrap();
}
}不过你大概已经能看出问题了:它仍然会锁死。
当我试图把这个模型引入真实代码库时,所有权要求很快变得难看起来。链接检查器需要客户端配置、缓存、进度条、统计信息以及其他一些东西。为了在生成的任务之间共享所有这些内容,它们都得被包装进 Arc<RwLock<State>>。我在该分支上尝试了这种模型,但由于所有权和 Send,代码变得相当丑陋。
信号量还不够
信号量解决了限制并发的问题,却完全没有解决终止问题。使用 tokio::spawn 时,没有内置办法知道所有生成的任务——包括递归生成的任务——何时都已完成。你需要另一个协调机制。换句话说,你会重新发明尝试一中的计数器,只不过现在它分散在数量不受限制的生成任务中。我们又绕回了我试图逃离的原点。
工作许可还有一个微妙之处。用原始的 tokio::spawn 替换 for_each_concurrent 后,通道免费提供的有界并发也随之消失了。信号量可以把它加回来,但你必须小心管理许可。如果一个任务获取许可、生成子任务,然后把许可转移出去,父任务就无法继续工作。如果它克隆许可,又可能突破并发上限。要准确管理许可的生命周期非常棘手。
经验总结
- 信号量解决并发,不解决终止。你仍然需要某种机制告诉你“所有工作都完成了”。
Arc<RwLock<State>>是异步 Rust 中的代码异味。当你开始把一切都包进锁时,你是在对抗所有权模型,而不是利用它。这可能会损失大量性能,因为每次访问都要在所有线程之间进行一次加锁操作。- 真正的问题从来不是“如何递归?”。而是“我怎么知道递归什么时候结束?”
这是所有尝试中最具 Rust 特色的一次失败。在 Go 中,信号量方案很符合惯用做法。使用 sync.WaitGroup 加信号量通道,并通过 sync.Mutex 在 goroutine 之间共享状态,这就是你在 Golang 中会采用的方式,因为它拥有绿色线程,以及负责管理 goroutine 生命周期的运行时。
但在 Rust 中,tokio::spawn 的 Send + 'static 约束、借用检查器对共享可变状态的排斥,以及 Arc<RwLock<T>> 的成本,都构成了阻碍。Rust 让“把所有东西包进 Arc 和 Mutex”这个逃生舱变得如此痛苦,以至于它最终成了死路。
2022–2024 😴
两年多以来,递归 issue 不断收到想要这一功能的用户留言。有人提出了变通方案(通过 xargs 传递 sitemap URL 是一种很受欢迎的做法)。最初提交 issue 的那个人后来构建了自己的工具并继续前行,我完全能够理解。
有人悬赏 100 欧元。还有人指出了已经支持递归检查的 muffet。这些年 lychee 并没有停滞不前;大量工作投入到了性能、缓存、速率限制和其他功能上。但递归始终是房间里的大象。
尝试四:Gwenn(格温)试了一把(2025 年 1 月至 3 月)
2024 年末,一位社区贡献者 @gwennlbh 接过了挑战。她的方案回到了基于通道的模型,但做了一个变化:她没有试图通过关闭通道来终止,而是使用了 Arc<AtomicUsize> 计数器。和尝试一一样,只不过是原子计数器,并且在任务之间共享!
它看起来简直优雅:
- 保留现有的两个 mpsc 通道(请求和响应)。
- 收到响应后,从响应正文中提取链接,并将它们作为新请求发送出去。
- 使用
Arc<AtomicUsize>跟踪剩余工作——发送新请求(包括递归请求)时增加计数,处理响应时减少计数,计数归零时跳出接收循环。 - 利用现有缓存避免循环(不重新检查已经见过的 URL)。
这是迄今为止功能最完整的一次尝试。它在真实网站上真的跑通了:
lychee -R https://endler.dev \
--recursed-domains endler.dev看着它逐渐成形,我非常兴奋,也一路尝试提供有用的设计建议:
- 默认递归深度为 5
- 严格的域名匹配(不检查子域名)
- 速率限制延后到单独的 PR
- 接受对
lychee-lib公共 API 的破坏性修改
它在哪里出问题
然后,它从多个方向同时撞上了同一堵墙。
1. 通道背压死锁
递归发现大量链接时,响应处理器会尝试把新请求发送到请求通道。但如果通道已满(由 max_concurrency 限制),发送操作就会阻塞。响应处理器被阻塞,就意味着没有响应会被处理,也就意味着没有请求槽位会被释放。典型的背压死锁。
@gwennlbh 的解决办法是通过单独的 tokio::spawn 将“发送新请求”的工作生成到后台,从而把响应处理与请求发送解耦。这样确实能运行,但这意味着后台任务可以不断堆积,不再有数量限制(同时也会使用无界内存)。
2. 重复请求
由于请求是并行处理的,同一个 URL 可能被多个页面发现,并在其中任何一个页面将其写入缓存之前就被发送到通道中。缓存检查发生得太晚:请求已经在执行之后才检查。没有针对每个 URL 的同步机制来阻止并发重复:
由于请求到响应任务的并行特性,在我看来,很难阻止同一个请求两次发送到通道。我基本上尝试在所有地方添加保护措施……但似乎仍然会得到重复请求。
作为临时措施,Stats::insert 中加入了去重检查,但它只能阻止重复报告,不能阻止重复检查。真正的修复要晚得多才会出现:HostPool 针对每个 URI 的 active_requests 互斥锁。但当时还没有这套机制。
3. 又是计数器
Arc<AtomicUsize> 计数器从本质上说仍是尝试一的同一个思路,因此也带来了同样的脆弱性。使用 Ordering::Relaxed(最弱的内存顺序)时,多个线程上的增加和减少操作可能被重新排序,于是计数器可能在工作实际完成之前短暂读到零。在 Wikipedia 上使用 --max-depth=0 时,它会在最后一个 URL 上锁死。
4. 到处都要改动
向 Response 类型添加 subsequent_uris(发现的链接列表),意味着几乎所有创建或使用 Response 的文件都要修改。每个 Response::new() 调用都需要新增两个参数(非递归场景使用 vec![] 和 0)。
5. 绕过了收集器
为了从响应正文中提取链接,代码在检查器内部直接新建了一个 Collector,绕过了经过配置、能够遵循用户 --exclude、--include 和片段检查等选项的收集器。
这条路的尽头
2025 年 1 月的一阵冲刺之后,进展慢了下来。合并冲突越积越多。CI lint 规则在分支开发过程中发生了变化。@gwennlbh 转而使用 Windows,结果无法构建 OpenSSL 依赖。2025 年 3 月,她诚实地写道:
尽管我有点不愿承认,但很明显,我已经失去了继续处理这个问题的动力……对不起 T_T
我不希望她道歉。作为一名志愿者,她在一个困难的功能、一个复杂的异步代码库中,取得了比任何人都远的进展。相反,我很感谢她投入时间推动事情向前发展。
经验总结
- 原子计数器就是披着风衣的手动计数器。它有同样的失败模式。
- 如果你需要在每个
Response::new()调用中都添加vec![]和0,那就是抽象泄漏。 - 外部贡献者会面临额外阻力。构建环境差异、不断变化的目标带来的冲突,以及大型异步代码库本身的认知负担,让这个功能尤其难以贡献。
这些问题有多少是 Rust 特有的?我会说大约一半。背压只是问题空间的一部分,任何语言中的并发爬虫都会遇到它。Ordering::Relaxed 陷阱在某种程度上具有 Rust 特性,因为 Rust 要求你选择内存顺序(Go 的 sync/atomic 也要求如此,但大多数 Go 开发者会改用 sync.WaitGroup)。
所以,这到底为什么这么难?
五年四次尝试。如果退一步看,我认为困难大致可以归为几类:
知道什么时候结束
每个实现都面对同一个问题:你怎么知道自己已经完成了?
在非递归流水线中,答案很简单。输入流耗尽且正在执行的请求全部完成时,就结束了。关闭通道发送端,排空接收端,事情就大功告成了。
在递归流水线中,输入流永远不会真正耗尽,因为每个响应都可能生成新的输入。你需要另一个办法来检测静止态(quiescence):没有任何工作在进行,也不会再生成新工作的状态。
原来,分布式系统中早就有这个问题的名称:✨分布式终止检测(distributed termination detection)。✨
经典方案(Dijkstra–Scholten、令牌传递)就是不太适合 Tokio 基于通道的世界。
循环
lychee 的架构从根本上说是一个 DAG。输入沿着一个方向流过各个阶段。递归引入了循环。而基于通道的系统中的循环会死锁,因为通道把“所有发送端都被丢弃”作为完成信号,而在循环中,这个条件不会自行满足。
背压
有界通道提供了天然的背压:如果检查器速度较慢,发送端就会阻塞,直到有空位。这很美妙,直到你需要递归。现在,响应处理器需要向请求通道发送数据。如果通道已满,响应处理器就会阻塞;它一旦阻塞,就不会再消费响应;没有响应被消费,就不会释放请求槽位。
去重竞态
我们会并发检查链接,这意味着多个页面可能同时持有同一个链接。如果没有同步机制,多个任务就会发现同一个 URL,并在任何一个任务将其标记为“已见”之前提交它。尝试一到四中,缓存没有救我们,因为缓存条目是在检查之后写入的,而不是在提交之前。
抽象泄漏
递归意识希望存在于“所有地方”。响应需要携带发现的链接,请求需要携带深度,收集器需要理解递归输入,统计信息和格式化器需要处理重复项。
这其中有多少是 Rust 的错?
我想这正是阅读我博客的人真正想知道的问题,所以我直说吧。我的诚实估计是……大约 30%?终止问题、循环问题和背压问题都只是问题空间本身的一部分。任何并发递归爬虫,无论用 Go、Python、Java 还是 Erlang 编写,都必须解决这些问题。在某个时候,Scrapy、Colly 以及其他成熟的爬取框架,都必须实现分布式终止检测和背压管理。
Rust 增加的是实现层面的阻力:
- 所有权和
Send约束让跨生成任务共享状态变得更加困难。在 Go 中,你只要在 goroutine 闭包中捕获变量就行了。在 Rust 中,异步环境中的一切都想被包装成Arc,并满足Send + 'static。 - 对原子操作明确指定内存顺序,迫使你思考并发正确性,同时也让“嗯,直接用 relaxed 吧”成为一种诱人却危险的选择。
- Tokio 的通道终止语义比某些其他生态更严格。Go 的
context.Context提供了独立于通道的取消机制,而 Tokio 通道原生没有这个机制。(在 Tokio 中,你会使用 CancellationToken 来实现。)
但另一方面,Rust 也阻止了许多问题:
- 编译器捕获了所有不安全地共享可变状态的尝试。在 Go 中,那些可能会变成微妙的运行时 bug,直到上线后才被我发现,或者只能靠竞态检测器发现。
- 利用类型系统,我们可以让正确的做法成为最顺手的做法。
换句话说,Rust 让错误的方案以响亮而痛苦的方式失败,比如编译器错误(测试中的死锁也算),同时让正确的方案更加稳固、更加易用。
新的希望
尽管之前的尝试都失败了,但在 2025–2026 年间,这个问题的基础悄悄发生了变化。一系列工作,其中大部分甚至与递归无关,让真正实现递归终于看起来触手可及。
按主机进行速率限制(2025 年 12 月)
没有速率限制的递归是危险的。Gwenn 亲身经历了这一点:递归检查 Wikipedia 时,她不小心对自家的 WiFi 路由器发起了 DDoS。😬 PR #1929 中合并的按主机速率限制,让递归爬取能够遵守服务器限制。我之前曾把这视为“超出范围”,但在实践中它极其重要。
底层 issue(#1605)是我在 2025 年 1 月 6 日提交的,正好与 PR #1603(尝试四)在同一周开启。这个时间点并非巧合。我们真正尝试递归的那一刻,缺少按主机速率限制的问题就变成了一个明显的缺口。它导致同一主机上的并发请求触发 429,因竞态导致缓存在高并发下失效(issue #1593),还导致全局并发设置对于同时分布在许多主机上的工作负载来说过于粗糙。
修复方案引入了 HostPool,这是一个按主机划分的请求队列,支持可配置的速率限制、延迟和并发请求上限。每个主机都有自己的桶和设置,可以通过 lychee.toml 配置:
[hosts."github.com"]
max_concurrent_requests = 10
request_delay = "100ms"HostPool 后来成为一个核心抽象。PR #2100 正是复用了同一个 HostPool,将输入获取与链接检查统一起来;这意味着如今所有 HTTP 请求都通过它这个单一入口流转。
这对递归很重要,因为 HostPool 提供了按主机的速率限制、去重(通过每个 Host 针对 URI 的 active_requests 互斥锁和 HostCache)以及粒度恰当的缓存,使递归爬取能够成为一个合格的网络公民(遵守速率限制响应头,并在遇到 429 时退避)。
WaitGroup(2026 年 2 月)
最近最重要的一项工作是由 Kait(凯特) 贡献、并在 PR #2046 中合并的 WaitGroup 原语。它是解决终止问题的一步。
WaitGroup 是一种用于等待动态任务集合的机制,而这些任务本身还可以生成更多任务。它由两部分组成:
WaitGroup:所有工作完成时触发的单个等待器。WaitGuard:由每个任务持有的、可克隆的守卫。当最后一个守卫被丢弃时,等待器完成。
关键在于 WaitGuard 可以被克隆。任务可以生成子任务(也就是递归),同时保持这样一个不变量:只有当每一个守卫——包括递归子任务持有的守卫——都被丢弃后,WaitGroup 才会完成。
这干净地解决了终止问题:
let (waiter, guard) = WaitGroup::new();
// Each request carries a guard clone
send_req.send((guard.clone(), request)).await;
// In the response handler, if recursing:
// the guard is cloned for each new request
for new_request in discovered_links {
send_req.send((guard.clone(), new_request)).await;
}
// The original guard is dropped when the response is fully processed.
// When ALL guards are dropped (no more work), waiter.wait() returns.它已经接入 lychee 的主检查循环。collect_responses 函数使用 take_until(waiter.wait()),在工作完成时停止接收。当前代码中甚至有一条注释,正好预示了这一点:
// unused for now, but will be used for recursion eventually. by holding
// an extra `send_req` endpoint, we prevent the natural termination when
// each channel finishes and closes. instead, we rely on the WaitGroup to
// break the cyclic channels.
let _ = send_req;这正是之前尝试所缺少的那一块。
统一请求处理(PR #2100,2026 年 3 月合并)
PR #2100 使用链接检查器的 HostPool,统一了输入 URL 获取与链接检查。在此之前,CLI 输入 URL 经过的是一个单独的 reqwest::Client,它与检查器不共享用户代理、速率限制、TLS 设置等配置。这导致了真实的 bug:由于没有设置用户代理,Wikipedia 对输入 URL 返回 403。
现在,输入获取和链接检查都经过同一个池。对于递归来说,这很重要,因为递归发现的页面需要被获取并解析,而且应该使用与其他请求相同的客户端配置。
Sitemap 支持(2026 年 2 月)
Sitemap 支持 是许多递归使用场景的部分解决方案。通过解析 sitemap.xml,lychee 无需递归爬取就能发现网站上的每个页面。它并不能替代真正的递归(无法帮助没有 sitemap 的网站,也找不到动态链接的页面),但可以解决很多使用场景。
完善的递归可能是什么样子
有了这些基础,剩下的工作如下。引人注目的是,其中大部分已经完成:
- 通过
WaitGroup解决了知道爬取何时结束的问题。 - 通过生成后续工作而不是在满通道上阻塞,避免了死锁。
- 按主机划分的池已经能够控制请求速度,因此我们不会压垮服务器。
- lychee 已经会跳过见过的 URL;当每个页面都链接到相同的导航栏和页脚时,这一点非常重要。
- 取回页面内容是唯一尚未解决的问题。lychee 在检查页面后会丢弃页面,但递归需要 HTML 来寻找更多链接。它仍然保存在刚刚检查时使用的缓存中,因此我们可以免费再次取出它。(前提是请求方法为 GET,而不是不会返回正文的 HEAD。)
这些完成后,真正的递归只需要几行代码。当已检查页面位于允许的域名中且未超过深度限制时,从缓存中取出其内容,提取链接,并像新请求一样通过同一条流水线发送回去:
if recursive && is_same_domain(&response, &recursion_domains) && depth < max_depth {
let content = resolver.url_contents(response.url()).await?; // cache hit
let links = extractor.extract(&content);
for req in request::create(links, ...) {
send_req.send((guard.clone(), Ok(req))).await;
}
}困难的部分(知道何时停止、不发生死锁、不压垮服务器)已经由那些原本与递归无关的工作解决了。递归成为良好架构的副产品,而不再是强行外挂到一条从未为递归设计的流水线上的特殊情况。
那么,我们失败了吗……?
很长一段时间里,我都告诉自己我们失败了。四次尝试,五年时间,看起来什么都没有发布。
但把这一切写出来之后,我改变了看法。每次尝试都撞上了通道终止语义、背压死锁、所有权易用性和分布式终止检测的某种组合。这些都不是 lychee 特有的问题,而是困难的并发系统问题。我们只是缺少谈论它们的词汇;而在我没有注意的时候,那些基础原语已经被构建出来了。有时候,你为某个功能编写的最重要的代码,恰恰是那些完全没有提到该功能的代码。
所以,不,我不认为我们失败了。我们是在跌跌撞撞中找对了方向,并取得了进展。
感谢 NLnet 为 lychee 的开发提供资助,也感谢这些年来为递归工作作出贡献的每个人——无论是贡献代码、提供设计反馈,还是给予精神支持。这是一段漫长的路,但我们比以往任何时候都更接近终点。
不过公平地说,我还是会深夜写代码。但这只是我的天性如此。↩
随机一篇博客