2023 年 10 月中旬,crates.io 团队收到一封用户反馈:他们维护的某个 crate,放在 README 里的 shields.io 徽章不显示了。
这件事乍看毫不起眼。shields.io 徽章时不时出问题很正常,原因五花八门。但这次的问题链,一旦顺着拉下去,牵出的东西让整个 crates.io 团队都没想到。
本文基于 Rust 官方博客 2023 年 10 月 26 日发布的《A tale of broken badges and 23,000 features》整理撰写,作者为 Tobias Bieniek,代表 crates.io 团队。
一个徽章,一条 20 MB 的 API 响应
这位用户在提 Issue 时已经自己做了初步定位,发现问题的根源在 shields.io 向 crates.io 发出的那个 API 请求:
这个 crate 大量使用了 feature flag,导致 API 响应的体积极度膨胀。
具体来说,shields.io 调用的是 crates.io 的 /api/v1/crates/{crate_name} 接口------这个接口会一次性返回该 crate 所有已发布版本的完整元数据,包括每个版本的全部 feature 列表。
问题是,这个 crate 叫做 icondata,它的 API 响应已经超过了 20 MB。shields.io 拿到这个响应之后直接超时,徽章自然也就坏了。
20 MB 是什么概念?这个 crate 当时一共才发布了 9 个版本。
9 个版本,为什么会有 20 MB?
答案是:这个 crate 有将近 2.3 万个 Cargo feature。
icondata 是一个为 Rust 网页应用提供 SVG 图标的库。它把每一个图标都对应设计成一个独立的 Cargo feature,这样用户在编译时只需要启用自己用到的图标,最终打包出的 WebAssembly 产物就不会把成千上万个用不到的图标都塞进去。
从 crate 作者的角度看,这个设计完全合理。SVG 图标库本来就有成千上万个图标,按需引入是最自然的做法,Cargo 的 feature 机制也正是为此而生。cargo 不会报错,crates.io 也不会给出任何警告。
但没有人告诉这个 crate 的作者:2.3 万个 feature,已经把 crates.io 的某些内部机制压垮了。
问题一:API 响应永远不分页
crates.io 的 /api/v1/crates/{name} 接口有一个长期的设计问题:它返回所有版本的完整数据,没有分页。
对于只有几个版本、每个版本 feature 寥寥无几的普通 crate,这不是问题。但 icondata 有 2.3 万个 feature,哪怕只有 9 个版本,每次 API 调用都需要把 9 × 2.3 万条 feature 数据一并吐出来,响应体积随之爆炸。
团队知道分页是正确的方向,但这是一个破坏性变更------已有无数工具和集成依赖当前的响应结构。这个问题被搁置了很久,直到这次事件才让团队意识到必须提上日程。
问题二:索引文件也在膨胀
crates.io 的稀疏索引(sparse index)是另一个受害者。
Cargo 在解析依赖时,需要从索引中获取每个 crate 的元数据。索引文件中存储的内容包括该 crate 每个版本的依赖和 feature 列表。对于普通 crate,这个文件很小,Cargo 可以快速拉取和解析。
但 icondata 的索引文件因为包含 2.3 万个 feature 的重复记录,体积同样极为庞大。每次有用户或 CI 环境需要解析这个 crate 的依赖时,都需要下载和处理这个异常大的文件。
问题三:数据库查询承压
crates.io 的后端使用 PostgreSQL 存储 crate 的元数据。feature 数据以关联记录的形式存储在数据库表中,查询某个 crate 的完整信息时,需要通过 JOIN 把这些记录拼回来。
2.3 万条 feature 记录的存在,让原本为普通规模设计的查询开始出现性能问题。这类查询对绝大多数 crate 可以在毫秒级完成,但遇到 icondata 这种异常情况,耗时会急剧上升。
团队的应对
面对这次事件,crates.io 团队采取了几项措施。
立即生效的限制:新发布的 crate 版本,feature 数量上限为 300 个。 这一限制已记录在 Cargo 的官方文档中。对于有特殊需求的 crate,团队表示会逐案评估是否给予豁免。
这个数字的选择有一定的工程判断成分:300 个 feature 对绝大多数用例来说绰绰有余,同时也能将 API 响应体积和数据库查询压力控制在可接受的范围内。
API 分页的问题,团队明确表示将被列为近期必须解决的工作,这次事件提供了足够的推动力。
一个值得深想的细节
这件事有一个值得单独拿出来说的侧面:整个过程中,icondata 的作者没有做任何"错误"的事。
用 feature 来控制编译粒度,是 Rust 生态里成熟的做法。cargo 文档里有这个模式,众多知名 crate 也都在用。没有任何工具链工具会在你的 feature 数量超过某个阈值时发出警告,crates.io 在接受发布时也不会提示任何异常。
问题出在基础设施的隐性假设上:API 的设计者从未预料到会有 crate 拥有数万个 feature;数据库查询的设计者也没有为这种极端情况预留余量。这类问题不到被触发,几乎不可能在事前被发现。
对于写基础设施代码的人来说,这是一个标准的"边界条件"教训:系统在正常范围内运行良好,在正常范围之外,假设就开始逐一失效。
如果你现在就想用 v0 只包含需要的图标......
icondata 最终将自己拆分为多个子 crate,每个子 crate 对应一个图标库(如 Bootstrap Icons、Remix Icons 等),每个子 crate 各自包含该库的图标 feature。这样每个 crate 的 feature 数量就控制在了合理范围内,同时保留了按需引入的设计意图。
这是一个在遭遇系统限制之后的工程权衡:不改变设计理念,但改变实现的边界划分。
小结
一个坏掉的 shields.io 徽章,揭开了 crates.io 在超出预期规模时的多个薄弱点:无分页的 API、膨胀的索引文件、未能为极端情况设计的数据库查询。
团队的修复是务实的:先设一个上限防止同样问题再次发生,再逐步处理更深层的架构问题。现在,Cargo 的文档里白纸黑字写着:每个 crate 最多 300 个 feature,特殊情况逐案审批。
这条规则的背后,是 2.3 万个 SVG 图标和一个坏掉的版本徽章。