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