前言
作为开源项目维护者,想知道自己项目的 Star 增长趋势。市面上有不少工具可以生成 Star 趋势图,但当遇到像 kubernetes/kubernetes(10w+ Stars)这样的大型仓库时,这些工具往往会遇到问题:GitHub API 请求超限。
本文将分享我是如何通过采样算法,在保证图表准确性的前提下,将 API 请求从数百次降至约 15 次,成功渲染出大型仓库的 Star 趋势图。
问题分析
GitHub API 的限制
GitHub Stargazers API 的设计如下:
GET /repos/{owner}/{repo}/stargazers
关键限制:
- 每页最多 100 条数据(
per_page=100) - 认证用户每小时 5000 次请求
- 未认证用户每小时 60 次请求
问题规模计算
以一个 42000 Stars 的仓库为例:
总页数 = ceil(42000 / 100) = 420 页
所需请求 = 420 次
如果要获取完整数据,单个大型仓库就会消耗大量 API 配额。更糟糕的是,如果服务同时处理多个请求,很快就会触发 Rate Limit。
解决方案:采样模式
核心思路
既然无法获取所有数据点,那就均匀采样!
关键洞察:Star 趋势图的价值在于展示增长趋势,而非精确到每一颗 Star。通过在时间线上均匀采样,可以用少量数据点还原整体趋势。
架构设计
┌─────────────────────────────────────────────────────────────────┐
│ 请求流程 │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────┐
│ 请求第一页数据 │
│ 解析 Link Header │
└────────┬────────┘
│
▼
┌─────────────────┐
│ 获取总页数 N │
└────────┬────────┘
│
┌──────────────┴──────────────┐
│ │
▼ ▼
┌────────────────┐ ┌────────────────┐
│ N ≤ 15 页? │ │ N > 15 页? │
│ (约1500 Stars) │ │ 触发采样模式 │
└───────┬────────┘ └───────┬────────┘
│ │
▼ ▼
┌────────────────┐ ┌────────────────┐
│ 全量获取模式 │ │ 计算采样页码 │
│ 并发获取所有页 │ │ 均匀分布 15 点 │
└───────┬────────┘ └───────┬────────┘
│ │
│ ▼
│ ┌────────────────┐
│ │ 并发获取采样页 │
│ │ 提取数据点 │
│ └───────┬────────┘
│ │
└──────────────┬─────────────┘
│
▼
┌─────────────────┐
│ 渲染 SVG 图表 │
└─────────────────┘
采样算法详解
Step 1: 解析 Link Header 获取总页数
GitHub API 响应会包含 Link Header:
Link: <https://api.github.com/repos/owner/repo/stargazers?page=2>; rel="next",
<https://api.github.com/repos/owner/repo/stargazers?page=420>; rel="last"
通过正则表达式提取 last 页码:
var linkLastPageRegex = regexp.MustCompile(`[&?]page=(\d+)[^>]*>;\s*rel="last"`)
func (gh *GitHub) parseLastPageFromLink(linkHeader string) int {
matches := linkLastPageRegex.FindStringSubmatch(linkHeader)
if len(matches) < 2 {
return 0
}
lastPage, _ := strconv.Atoi(matches[1])
return lastPage
}
Step 2: 计算均匀采样页码
func (gh *GitHub) calculateSamplePages(totalPages, maxSamples int) []int {
pages := make([]int, 0, maxSamples)
for i := 1; i <= maxSamples; i++ {
// 均匀分布的页码计算
page := int(math.Round(float64(i*totalPages) / float64(maxSamples)))
if page < 1 {
page = 1
}
if page > totalPages {
page = totalPages
}
pages = append(pages, page)
}
// 确保第一页被包含(起始时间点很重要)
if len(pages) > 0 && pages[0] != 1 {
pages[0] = 1
}
return uniquePages(pages) // 去重处理
}
以 420 页为例,采样页码为:
[1, 28, 56, 84, 112, 140, 168, 196, 224, 252, 280, 308, 336, 364, 392, 420]
Step 3: 并发获取采样数据
func (gh *GitHub) getSampledStargazers(ctx context.Context, repo Repository,
firstPageStars []Stargazer, lastPage int) ([]Stargazer, error) {
samplePages := gh.calculateSamplePages(lastPage, gh.maxSamplePages)
var (
wg errgroup.Group
lock sync.Mutex
results []pageResult
)
// 控制并发数,避免触发 Rate Limit
wg.SetLimit(5)
for _, page := range samplePages {
page := page
wg.Go(func() error {
result, err := gh.getStargazersPage(ctx, repo, page)
if err != nil {
return err
}
// 计算该数据点的实际 Star 数量
// 第 N 页第 1 个 Star = (N-1) * pageSize + 1
starCount := (page-1)*gh.pageSize + 1
lock.Lock()
results = append(results, pageResult{
page: page,
star: result[0],
starCount: starCount,
})
lock.Unlock()
return nil
})
}
wg.Wait()
// ... 处理结果
}
Step 4: 补全最后一个数据点
为了确保图表延伸到当前时间,我们添加一个虚拟数据点:
// 添加当前时间 + 总 Star 数作为终点
stars = append(stars, Stargazer{
StarredAt: time.Now(),
Count: repo.StargazersCount,
})
数据对比
| 指标 | 全量模式 | 采样模式 | 优化比例 |
|---|---|---|---|
| API 请求数 | 420 次 | 15 次 | 96.4% ↓ |
| 响应时间 | ~30s | ~2s | 93.3% ↓ |
| 数据精度 | 100% | ~95% | 可接受 |
配置说明
项目支持通过环境变量灵活配置:
# Redis 缓存(配合 ETag 机制进一步减少请求)
export REDIS_URL="redis://localhost:6379"
# GitHub Token(支持多个,自动轮换)
export GITHUB_TOKENS="token1,token2,token3"
# 触发采样的阈值(页数)
export GITHUB_MAX_SAMPLE_PAGES=15
# 每页数据量
export GITHUB_PAGE_SIZE=100
# 服务监听地址
export LISTEN="0.0.0.0:3000"
效果展示
采样模式生成的 Star 趋势图:
可以看到,即使是 4w+ Stars 的大型仓库,采样后的趋势图依然能够准确反映项目的增长轨迹:
- ✅ 早期缓慢增长阶段
- ✅ 中期爆发式增长
- ✅ 后期稳定增长趋势
其他优化
1. ETag 缓存机制
GitHub API 支持 ETag 条件请求,对于未变化的数据返回 304 Not Modified:
// 请求时携带上次的 ETag
req.Header.Add("If-None-Match", cachedEtag)
// 304 响应不消耗 Rate Limit 配额!
if resp.StatusCode == http.StatusNotModified {
return cachedData, nil
}
2. Token 轮换
支持配置多个 GitHub Token,自动检测并跳过已耗尽配额的 Token:
func (gh *GitHub) authorizedDo(req *http.Request, try int) (*http.Response, error) {
token, _ := gh.tokens.Pick() // Round-Robin 选择
if err := gh.checkToken(token); err != nil {
return gh.authorizedDo(req, try+1) // 尝试下一个 Token
}
req.Header.Add("Authorization", "token " + token.Key())
return http.DefaultClient.Do(req)
}
总结
通过采样算法,我们成功解决了大型仓库 Star 趋势图渲染的 API 限制问题:
- 从 Link Header 获取总页数,避免盲目请求
- 均匀采样算法,用 15 个数据点还原完整趋势
- 并发控制 + ETag 缓存,进一步优化性能
- Token 轮换机制,提高服务可用性
这套方案不仅适用于 Star 趋势图,也可以推广到其他需要处理大量分页数据的场景。
项目地址
欢迎 Star ⭐
GitHub: github.com/lik0914/sta…
如果这篇文章对你有帮助,欢迎点赞 👍 收藏 ⭐ 关注 ➕