从 2500 次请求到 24 次:重构无 Stargazers 依赖的 Star History
维护者查看 star history,是为了解答一个实际问题:发布、版本或事件是否改变了项目的走势?要从 GitHub API 重构这条曲线,工具必须请求 stargazer 记录的分页数据(包含每位当前 star 该仓库用户及其 star 时间),保留时间戳,然后丢弃身份信息。
久而久之,这种设计在两端都造成了问题。身份信息可以被批量获取,于是恶意行为者开始抓取公开的 stargazer 列表用于垃圾邮件和网络钓鱼。对于 star 数极高的仓库,该接口还可能需要数据库处理数千条记录才能返回单页数据。
修复方案看似明确:返回数量而非人员。但少返回数据并未让查询成本变得更低。
如今维护者有了获取曲线的直接途径:我们构建了一个接口,可在单次请求中返回最多 30 周的每日 star 计数,且不附带任何身份信息。保持低成本服务的秘诀在于减少数据库处理的数据量,而非仅仅减少返回量,该接口通过在计数前按时间过滤 star 记录来实现这一点。
维护者对 star history 的需求
我们首先通过限制 stargazer 和 watcher 列表仅对仓库管理员和协作者开放,来解决对开发者和数据库的即时风险。访问权限变更后,缺乏相应权限的调用方将收到空响应或 403 Forbidden 响应。
这一变更也切断了那些依赖旧响应计算 star history 的工具,这些工具通常并不展示或使用返回的用户名。因此我们与维护者和工具开发者交流,了解他们的核心需求。他们将 star history 视为衡量兴趣的大致信号:用于比较版本发布前后的活跃度,或确认公告是否触达了新开发者。他们真正关心的是这条曲线。
旧的 API 响应把用户身份当成了这份“契约”的一部分,因为每条时间戳都伴随着一个用户返回。但实际上,像 Star History 这样的工具需要的只是数量和日期,而不是用户名或其他用户数据。既然需求里根本不需要用户身份,我们就可以为真正的目标设计一个聚合接口:以合理的粒度获取 star 数量随时间的变化。
当 API 变动影响到 Star History 时,用户们纷纷站出来支持这个项目。GitHub 的社区团队认真倾听,并帮我们对接了产品工程团队。他们深入研究了我们的使用场景,和我们并肩设计出一个专门打造的接口。一场危机变成了 GitHub、Star History 和整个社区之间富有建设性的合作,最终催生了更好的解决方案。
Tianzhou Chen,star-history.com 维护者、Bytebase 联合创始人
为什么原来的接口成本这么高
搞清楚维护者的需求后,下一步是弄明白为什么原来的设计无法低成本地满足它。原来的 GET /repos/{owner}/{repo}/stargazers 接口是为了回答一个简单的问题:谁 star 了这个仓库?它返回一个分页的用户列表,如果请求指定了自定义 media type,每条记录还会附带 star 的时间。
star 的数据模型也是为同样的目标设计的。它记录的是当前 star 了这个仓库的所有人,这让展示 star 数量或 stargazer 列表变得很简单。但它并不是一份历史事件日志:一旦有人取消 star,对应的记录就会被删除。当工具开始把这份列表当作时间序列来用时,要重建增长曲线就意味着必须获取每个用户的身份,并处理现有的每一条 star。由于每页最多只有 100 条记录,这项工作会随着仓库的热度水涨船高:给拥有约 25.1 万 star 的 react/react 构建完整曲线,大约需要 2,511 次请求,因此工具经常撞上速率限制,或者只能抽样读取部分页面,而不是全部遍历。
我们考虑的方案
移除身份信息解决了隐私合规要求。更棘手的是如何避免每个请求都扫描仓库当前所有的 Star 集合。保留事件但移除身份信息
我们的首个原型为每个 Star 保留一条记录,对于没有协作者权限的调用方,仅返回starred_at 时间戳,而不暴露 Star 用户的身份信息。起初,这看似是最快的路径,因为它直接满足了工具开发者提出的需求。同时,它对现有的序列化和授权流程改动较小,即可切断身份信息的泄露途径。
先聚合,后分页
聚合似乎是显而易见的下一步。查询不再返回每颗 Star 对应的一行数据,而是将 Star 按天或周分组,对分组结果排序,并对此大幅缩减的结果集进行分页。难点在于分页逻辑的位置。在标准的 SQL 查询中,数据库首先查找仓库的所有 Star,将其分组并排序。随后才应用 LIMIT(控制返回的分组数量)和 OFFSET(确定起始位置):
所有匹配 star → 按天或周分桶 → 排序每个桶 → OFFSET/LIMIT → 返回响应
对于热门仓库,匿名请求仍可能扫描并聚合数万条索引条目,只为了返回少量桶。减少返回的桶数可以缩小 JSON 响应体积,但无法降低数据库已经执行的昂贵分组和排序开销。以 react/react 为例,按这种方式读取完整历史大约需要检查 600 万行记录,因为 24 个请求中的每一个都会重新处理其全部 star。为了降低查询成本,我们必须在分组开始前缩小行数范围。
分组前先按时间过滤
我们上线的设计方案按日历周对 star 历史进行分页。每次请求会把 page 和 per_page 转换为索引化的 created_at 范围,过滤出对应周内的 star,再把匹配的记录聚合为按天计数。响应最多包含 30 周数据,增大 page 会向仓库历史的更早方向回溯。
索引化日期范围 → 对该范围内的行分组 → 按天分桶 → 返回响应
先应用日期范围后,每次请求只处理正在返回的那些周的数据,而不是仓库当前所有的 star。对工具而言,这意味着一次请求最多覆盖 30 周历史,无论仓库有多少 star。对于 react/react,这大约需要 24 个请求(而非约 2,511 个),且数据库在完整历史上每个 star 只被检查约一次(总计约 251,000 行)。这符合主要使用场景,因为维护者通常按周查看增长趋势,或围绕已知发布、上线或事件节点进行检查。按周划定的范围也与其他仓库活动 API 保持一致:响应结构与 weekly commit activity endpoint 相同,即 GET /repos/{owner}/{repo}/stats/commit_activity。
基于时间范围的设计将每次请求限制在固定周期内,但并不限制该周期内 star 的数量。因此,star 数量异常多的周仍可能产生缓慢或高成本的查询。常规请求路径不再查询所有当前 star,但密度极高的周依然是扩展性瓶颈。
对比新旧 API 路径的性能时,我们还发现了 GitHub 其他需要实时统计 star 的地方。GitHub.com 上的仓库 stargazers 页面也存在同样的问题,于是我们改为使用仓库缓存的 star 数。这个页面此前每周会产生数百条慢查询,如今已降为零。最近我们也对其他 stargazer API 端点做了类似修复。
下图按来源展示了每周的 stargazer 慢查询和被终止的查询数量。
我们学到了什么
- 返回结果更少,不代表查询成本更低。
LIMIT和OFFSET减少了返回的结果数量,但数据库此前已完成的分组和排序并没有因此减少。我们必须从工作发生的顺序入手。 - 先弄清用户真正想做什么。 维护者需要的是对比趋势,而一份身份列表只是旧端点恰好提供的底层数据形式。
- 只返回用例所需的数据。 多给的数据都可能被滥用。理解了实际需求后,我们就可以不再返回具体身份,同时不损失维护者依赖的信息。
- 明确告知仍然存在的取舍。 新端点更安全、更可预测,但日历边界、被取消的 star 以及异常密集的周仍会影响返回结果。这些细节也是 API 契约的一部分。
限制原端点解决了眼前的问题,而倾听维护者的需求让我们做出了比原先更好的东西:一套更易获取、服务成本更低、对每一位 star 过项目的用户都更安全的 star 历史功能。