凌晨两点半,同步任务跑到第 47 个仓库的时候整批挂掉了,日志里清一色的 403,报错文本只有干巴巴的一行。第二天早上看监控,前一天晚上 8 点到 12 点之间这个账号发的请求量是 4800 多次,刚好卡在门槛上。这类场景我这些年遇到过太多次——GitHub 速率限制这事情,平时不显山不露水,一旦你的脚本规模稍微上去一点、跑的时间稍微长一点,它就会在最难受的时间点跳出来。
这篇内容想聊的就是这件事:GitHub 速率限制到底是怎么算的、为什么会踩到、踩到之后怎么处理、以及怎么在设计阶段就把额度省下来。适合正在写同步脚本、做数据采集、搭 CI 流水线、或者维护一个跟代码托管平台对接的服务的同学看。不管你是刚被 403 卡住想找应急方案,还是想把现有的集成做得更稳,下面的内容应该都能直接拿去用。我会尽量把踩过的坑和绕过的弯路都摊开说,因为这些东西在官方文档里通常只有一两句话,但真到线上就是能不能睡个整觉的区别。
1. 那 5000 次额度到底是怎么扣的
1.1 配额不是一个池子,是好几套独立的水表
很多人第一次接触这个概念,脑子里只有一个数字:5000。以为所有请求都从这一个池子里扣,扣完就没了。实际上 GitHub 的限流是按"资源类型"分池子的,每个池子独立计数、独立重置,互相不干扰。响应头里有x-ratelimit-resource这个字段,它就是在告诉你这次扣的是哪块表的水。
| 资源池 | 认证后额度 | 未认证额度 | 典型端点 |
|---|---|---|---|
| core | 5000 次/小时 | 60 次/小时(按 IP) | 仓库、Issue、PR、Commit、用户信息 |
| search | 30 次/分钟 | 10 次/分钟 | 代码搜索、Issue 搜索、仓库搜索、用户搜索 |
| graphql | 5000 点/小时 | 一般需认证 | GraphQL 单端点 |
| integration_manifest | 5000 次/小时 | 无 | 应用清单相关 |
这里最容易翻车的地方是 search。core 池子看着还有四千多次,但你的搜索请求早就被限了,因为搜索走的是每分钟 30 次这条线。我见过一个做代码扫描的脚本,作者一直盯着remaining还有多少,纳闷为什么还有余量却一直报错——因为他根本没看x-ratelimit-resource是search。
还有一点,搜索的限制是按分钟算的,跟小时窗口完全是两种节奏。分钟级的窗口意味着你不能攒着不用然后一口气爆发,只要某一分钟内超过 30 次就立刻被挡。
1.2 响应头才是唯一可信的账本
不要去猜还剩多少额度,也不要自己记账。每次请求的响应头里都有完整信息:
x-ratelimit-limit:这个池子的总额度x-ratelimit-remaining:还剩多少x-ratelimit-used:已用多少x-ratelimit-reset:重置时刻的 UTC 纪元秒,注意是时间戳,不是"还剩多少秒"x-ratelimit-resource:这次扣的是哪个池子
x-ratelimit-reset这个字段坑过不少人。它不是"距离重置还有 3600 秒",而是"重置发生在 1735689600 这个时刻"。所以要算等待时长,得自己减一下当前时间戳。而且服务器的时钟和你本地机器的时钟一定存在偏差,哪怕只有一两秒,也足够让重试逻辑在边界上反复撞墙。
我的做法是统一封装一层:等待时长 =reset - now + 2,多加的 2 秒就是给时钟漂移留的余量;再设一个上限,比如最多等 15 分钟,超过就放弃本轮、把这批任务挂到队列里下一轮再跑,避免一个线程傻等一小时把整个任务卡死。
另外还有一个"不花钱"的端点:GET /rate_limit。它本身不消耗额度,返回所有池子的当前状态。这个端点在做健康检查、做监控上报的时候特别好用,后面排查章节还会细说。
1.3 主限流和二次限流是两套完全不同的规则
这是最容易被混淆的一点。GitHub 有两层限制:
主限流(primary rate limit),就是上面说的那些数字,按小时或按分钟计数。超了之后返回 403 或 429,报错文本里明确写着 rate limit exceeded,响应头里remaining是0。
二次限流(secondary rate limit),针对的是"行为模式"而不是"总量"。它管的是并发太高、突发太猛、短时间内创建太多内容(比如连着开一堆 Issue、连着发一堆评论)这类情况。触发时的表现很有意思:x-ratelimit-remaining可能还剩下好几千,但照样给你一个 403,报错文本里会提到 secondary rate limit,而且通常带一个retry-after头告诉你等多久。
区分这两者的方法很简单,我整理成了一张判断表:
| 现象 | 判断 | 处理方式 |
|---|---|---|
| 403/429 且 remaining 为 0 | 主限流 | 等到 reset 时刻再继续 |
| 403/429 且 remaining 仍有余量 | 二次限流 | 读 retry-after,降并发 |
| 响应带 retry-after 且数值很小(几十秒) | 大概率二次限流 | 短暂退避后重试 |
| 连续多次 403 且每次 retry-after 递增 | 二次限流加重 | 必须降速,别硬顶 |
二次限流的具体阈值官方文档给的是"建议值"性质,会调整,我不在这里抄死数字,但方向是明确的:并发请求数不要逼近上限、内容创建类操作要严格控制节奏。经验上,把并发压到个位数、把创建类操作控制到每分钟十几次以内,基本不会碰到这条线。
2. 脚本跑着跑着就 403:几个高频触发点
2.1 分页循环里的乘法效应
单次请求看起来都很便宜,问题是嵌套循环会把请求数乘起来。举个我做过的例子:同步某个组织下所有仓库的 PR 评论。
第一层,列出组织下的仓库,per_page=100,假设有 300 个仓库,这就 3 次请求。第二层,对每个仓库列 PR,一个中等活跃的仓库可能有 500 个 PR,per_page=100就是 5 页,300 个仓库就是 1500 次。第三层,对每个 PR 拉评论,假设平均每个 PR 3 条评论,为了让评论作者信息完整,还要额外请求每个用户的基本信息——这里如果没有去重,就是 4500 个 PR 乘以 3 条评论再乘以 1 次用户请求,一万三千多次。
一个"简单"的同步任务,实际要发一万五千次以上的请求,而你的小时额度是 5000。这个任务根本不可能在一小时内跑完,第一次跑必然撞墙。
问题的关键不在于请求本身,而在于没有做对象去重和层级合并。用户信息那种几乎不变的数据,完全可以本地缓存,一个用户只查一次,第二次直接读缓存。这一步就能砍掉几千次请求。
我的经验是先画一张"请求树",把每一层的对象数量、翻页数、是否需要展开关联对象都写出来,算一遍总数。这个动作花不了十分钟,但能让你提前知道这个任务到底是在 5000 以内还是在一万五以上。如果在一万五,那就得先设计缓存和增量,而不是先写代码再去救火。
2.2 并发数一上去就撞二次限流
第二个高频问题是对"5000 次/小时"的错误理解。很多人会算:5000 除以 3600 约等于每秒 1.39 次,那我把线程池开到 20 个并发,每个线程自己控制一下速度,应该没问题。
问题在于,这 5000 次是按固定窗口统计的,不是严格滑动的,而你开 20 个并发的时候,很容易在某一秒内发出几十个请求。这种突发在总量上完全没超,但行为模式上很扎眼,二次限流就是专门治这个的。
更糟糕的是雪崩效应:20 个线程同时撞上 403,同时开始重试,重试又同时失败,退避时间如果写的都是固定 60 秒,它们会永远同步在一起。我见过一个脚本,日志里错误是一波一波来的,每波都是整齐的 20 条,就是这个原因。
解决办法有两个方向。一是把并发压到 4 到 8 之间,配合每请求之间的微小间隔,让请求在时间上摊匀。二是退避时间必须加随机抖动,比如基础 3 秒加上 0 到 2 秒的随机值,把同步打破。
2.3 CI 里配额池串了,是最隐蔽的一种
自动化流水线里的情况更绕。在 Actions 里,平台会自动注入一个令牌,这个令牌的额度规则和你在本地用的个人令牌完全不是一回事:它是按仓库计算的,每小时 1000 次左右,而不是按用户计算的 5000 次。
于是就出现这种局面:本地测试一切正常,因为本地跑的是个人令牌,5000 次够用;上了流水线之后,每个 job 都在花那 1000 次的额度,一个矩阵构建开了 8 个 job,每个 job 发 150 次请求,加起来 1200 次,直接爆掉。而报错信息看起来和本地完全一样,你压根想不到是额度池换了。
还有一种:来自 fork 的 PR 触发的流水线,注入的令牌权限是只读的,能调的端点和额度又是另一套。有人为了绕开这个问题,把个人令牌塞进仓库密钥里在流水线里用,这属于把风险放大——令牌权限过大、暴露面变宽,而且额度还是共享的,多个 job 并行照样打爆。
我的处理原则是:流水线里的请求量必须先按 1000 次这个量级去规划,能缓存的一律缓存(把元数据存成构建产物或者用缓存机制跨 job 复用),确实需要大额度调用的任务,挪到一个统一的服务里去做,流水线只负责触发。
2.4 无意义的重复拉取
最常见的浪费其实很朴素:每次任务启动都全量拉一遍仓库元数据、每个用户都重新查一次资料、每次都把昨天的 Issue 列表完整翻一遍。
这些东西绝大部分在上一次运行和这一次运行之间根本没变过。条件请求和本地缓存能把这部分请求压到接近于零,具体做法在下一章展开。
3. 把请求量本身降下来,比拼重试有用得多
限流处理的思路排序,我的建议永远是:先减量,再削峰,最后才是退避重试。倒过来做的话,你只是在用更优雅的方式撞墙。
3.1 条件请求:304 不消耗额度
这是收益最高、改动最小的一个优化。GitHub 的 REST 接口在返回 200 的同时会带一个ETag响应头(有时还有Last-Modified)。把这个值存下来,下次请求时带上If-None-Match,如果资源没变,服务端返回 304,而且这个 304不计入你的主限流额度。
import requests session = requests.Session() session.headers.update({ "Authorization": f"Bearer {TOKEN}", "Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", }) def get_with_etag(url, etag_store): headers = {} cached = etag_store.get(url) if cached: headers["If-None-Match"] = cached resp = session.get(url, headers=headers, timeout=15) if resp.status_code == 304: return None # 资源未变,用本地缓存 if resp.status_code == 200: new_etag = resp.headers.get("ETag") if new_etag: etag_store[url] = new_etag return resp.json() resp.raise_for_status()这里有个细节值得说:ETag 是和具体的 URL 绑定的,包括查询参数。所以你的缓存键必须用完整 URL,而不是只有路径。另外,分页 URL 里的page参数一变就是另一个资源,ETag 也不一样,这意味着条件请求对"翻页列表"的效果比不上对"单对象详情"的效果——列表页更容易因为新增一条记录导致整体 ETag 变化。所以在列表场景,更应该用下面的增量方案。
还有一个边界:304 不计额度这个规则针对主限流有效,二次限流的并发限制并不因为 304 就豁免。所以别指望用大量并发条件请求去刷。
3.2 用 GraphQL 把 N 次请求压成 1 次
REST 的结构决定了你很难在一次请求里同时拿到多个仓库的多种数据。GraphQL 就是为了解决这个的:一次请求,按"点数"计费而不是按次计费,而且点数的消耗是按实际返回的节点数计算的。
举个真实场景:你要拿 50 个仓库的 star 数、开放 Issue 数、最近一次提交时间。用 REST 就是 50 次(或者 3 次列表请求但数据不全),用 GraphQL 是一次:
query($cursor: String) { rateLimit { remaining resetAt cost } search(query: "org:my-org", type: REPOSITORY, first: 50, after: $cursor) { nodes { ... on Repository { nameWithOwner stargazerCount issues(states: OPEN) { totalCount } defaultBranchRef { target { ... on Commit { oid committedDate } } } } } pageInfo { hasNextPage endCursor } } }顺手把rateLimit字段也查出来,你就能在同一次响应里知道这次花了多少点、还剩多少、什么时候重置,不需要再单独发一次/rate_limit。
GraphQL 的成本计算是按节点展开的,返回的对象越多、嵌套越深,成本越高。有个常见的误区是以为 GraphQL "无限量",实际上单次查询也有成本上限,超过上限直接报错。我的经验是单次查询控制在返回两三百个对象以内比较稳,太大就拆成两次。
还要注意:GraphQL 同样有二次限流,并发限制和 REST 是分开算的。所以用 GraphQL 之后不要觉得可以随便开并发。
3.3 增量抓取:用水位线代替全量重扫
条件请求解决的是"没变的不重复拉",增量解决的是"变了的只拉新的"。这两个加起来,日常同步的请求量能降一到两个数量级。
具体做法是每个同步对象维护一个时间水位线,比如last_synced_at,下次只拉since=last_synced_at之后更新的记录。Issue 列表、Commit 列表、事件流都支持这类参数。
但这里有个必须知道的坑:since是按"更新时间"过滤的,而更新时间可能出现在你拉取过之后又被改动,导致某些记录被漏掉。比如你在 T1 时刻拉了since=T0的数据,返回了 100 条,但 T1 到 T2 之间有人修改了一条"最后更新时间是 T0 之前"的老记录,那条记录就不会出现在 T2 的增量里。
稳妥的做法是留重叠窗口——水位线往前回退 5 到 10 分钟,用少量重复换取不漏数据。再配合"按 ID upsert"的写入逻辑,重复数据不会造成副作用。这个 5 分钟的重叠窗口,是我在实际项目里反复验证过比较好用的值:再小容易漏,再大浪费请求。
3.4 有些东西压根不该走 API
这一点经常被忽略。仓库克隆、Release 资产的下载、原始文件内容的获取,这些走的不是 API 配额,而是普通的流量通道。如果你需要仓库的完整历史,clone 比调 API 拉 commit 列表高效得多。
另外,公开的事件流数据存在长期归档的离线数据集,需要做大规模分析的时候,直接下离线数据比自己调 API 一点点爬要现实得多。这个思路很重要:不是所有"我要 GitHub 数据"的需求都得通过 API 解决。
4. 被限流之后的正确做法
4.1 优先读 retry-after,别自己拍脑袋
撞上限流之后最常见的错误反应是"睡 60 秒再试"。这个数字是拍出来的,跟实际情况没关系。有的二次限流让你等 10 秒就够,你睡 60 秒白白浪费;有的主限流要等 40 分钟,你睡 60 秒重试 40 次,除了浪费 40 次请求什么也没得到,还可能因为反复重试加重限流。
正确的顺序是:
- 先看响应头有没有
retry-after,有就按它的秒数等,再加 1 到 2 秒余量。 - 没有
retry-after,再看x-ratelimit-remaining是不是 0,是的话按reset - now + 余量等。 - 两个都没有,才用指数退避兜底。
import time, random, requests def safe_get(session, url, max_wait=900, **kw): for attempt in range(5): resp = session.get(url, timeout=20, **kw) if resp.status_code in (200, 304): return resp if resp.status_code in (403, 429): retry_after = resp.headers.get("retry-after") remaining = resp.headers.get("x-ratelimit-remaining") if retry_after and retry_after.isdigit(): wait = int(retry_after) + 2 elif remaining == "0" and resp.headers.get("x-ratelimit-reset"): reset = int(resp.headers["x-ratelimit-reset"]) wait = max(reset - int(time.time()), 1) + 2 else: wait = min(2 ** attempt * 5, 120) if wait > max_wait: raise RuntimeError(f"等待时间 {wait}s 超出上限,转队列延后处理") time.sleep(wait + random.uniform(0, 2)) continue resp.raise_for_status() raise RuntimeError("重试次数耗尽")注意max_wait这个参数。它是一个保护阀:如果算出来要等 40 分钟,与其让线程挂在那里,不如抛出去让上层把这批任务放回队列,等下一个调度周期再处理。长驻服务里这么设计,避免一个慢任务把工作线程池占满。
还有个小细节:time.sleep里加了随机抖动。前面说过,多线程同时失败时如果退避时长一样,它们会永远同步,抖动就是打破同步的。
4.2 多进程、多机器场景下用全局令牌桶
单机单进程的时候,上面那套按响应头退避的方案足够。但如果你有多个 worker、甚至多台机器同时跑,每个进程各自算自己的额度就没意义了——它们的总和才是真实消耗。
这时候需要一个全局的协调器。最轻量的做法是用一个中心化的计数器做令牌桶,比如用带原子操作的缓存服务。下面是一个用脚本实现的令牌桶逻辑,思路是"惰性补充":不启定时器,每次取令牌的时候按经过的时间补算应该补充多少。
-- KEYS[1] 桶的键名 -- ARGV[1] 容量, ARGV[2] 每秒补充数, ARGV[3] 当前时间(毫秒), ARGV[4] 本次取几个 local capacity = tonumber(ARGV[1]) local rate = tonumber(ARGV[2]) local now = tonumber(ARGV[3]) local want = tonumber(ARGV[4]) local data = redis.call('HMGET', KEYS[1], 'tokens', 'ts') local tokens = tonumber(data[1]) or capacity local ts = tonumber(data[2]) or now local delta = math.max(0, now - ts) / 1000.0 tokens = math.min(capacity, tokens + delta * rate) if tokens < want then redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', now) return 0 end tokens = tokens - want redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', now) return 1容量设成多少、补充速率设成多少,要按你的真实额度来。有个经验公式:容量设成额度的一小时值除以 10 左右(比如 5000 就设 500),速率设成每小时额度除以 3600 再打个七折(5000/3600 ≈ 1.39,打七折约 0.97)。打七折的原因是要预留余量给那些不经过令牌桶的请求——比如流水线里的调用、人工触发的操作、上游其他服务的调用,它们也在花同一个池子的钱。
还有一个绕不开的问题:令牌桶的键必须和额度池对应。core 一个桶、search 一个桶、graphql 一个桶,千万别共用一个。前面说过它们是独立的水表,混着算会导致你的限速完全失真。
4.3 哪些错误该重试,哪些重试了更糟
盲目重试是最浪费额度的行为。我整理过一张分类表,贴在下面:
| 状态码 | 含义 | 是否重试 | 说明 |
|---|---|---|---|
| 200 / 304 | 成功 | 否 | 304 视为成功,用本地缓存 |
| 401 | 认证失败 | 否 | 令牌过期或错填,重试一万次也一样 |
| 403(remaining=0) | 主限流 | 是 | 等到 reset |
| 403(有 retry-after) | 二次限流 | 是 | 按 retry-after 等 |
| 404 | 资源不存在 | 否 | 重试纯粹是浪费额度 |
| 422 | 参数校验失败 | 否 | 请求体有问题,改参数 |
| 429 | 限流 | 是 | 同 403 处理 |
| 500 / 502 / 504 | 服务端异常 | 是 | 指数退避,上限 3 到 5 次 |
404 这条我想多说一句。做批量同步的时候,经常会遇到"仓库被删了但任务列表里还有"这种情况,每次都去请求、每次都 404,这不仅浪费时间,还照样扣额度。正确的做法是把 404 记为"该对象已失效"落到数据库里,下轮同步直接跳过。
401 也一样,见过有脚本把令牌写错了,然后带着指数退避重试了半小时,把日志刷满了,真正的错误信息淹没在一堆 403 里,排查了半天。
5. 认证方式选型与额度账
5.1 三种身份,三本账
同样是"认证后",不同的身份走的额度规则不一样:
| 身份类型 | 额度规则 | 适用场景 | 备注 |
|---|---|---|---|
| 个人访问令牌(细粒度/经典) | 每用户 5000 次/小时 | 本地开发、个人脚本 | 额度跟人走,多个项目共享 |
| OAuth 应用令牌 | 每用户 5000 次/小时 | 代表用户操作 | 用户撤销即失效 |
| 应用安装令牌 | 5000 起,按安装范围内的仓库数和用户数放大,有上限 | 服务端集成、团队级工具 | 额度按安装算,不占个人额度 |
这里最关键的一句是最后一行:应用安装令牌的额度是独立的,不消耗任何个人用户的 5000 次。所以如果你维护的是一个给团队用的同步服务,用应用身份是唯一合理的选择——它既能拿到更高的额度,又不会因为某个人离职、令牌被撤销而整体瘫痪,权限边界也更清晰。
代价是接入成本更高:要生成私钥、签发 JWT、换取安装令牌、处理令牌过期(安装令牌有效期一小时)。但这套流程写一次就够了,封装成一个小模块之后,剩下就是无感的。
5.2 多令牌轮换这条路,我不建议走
额度不够的时候,最直觉的想法是"我多准备几个账号的令牌,轮着用"。我不建议这么做,原因有三层。
第一层是规则层面。用多账号轮换来突破单账号额度,属于规避限制的行为,和平台的使用规范是冲突的,被识别到会有账号层面的后果。这不是"技术上能不能实现"的问题,是"该不该做"的问题。
第二层是工程层面。多令牌轮换会让你的调用链路变得难以追踪:出了问题你不知道是哪个令牌被限了,日志里全是匿名的请求;额度计算也变得复杂,你需要一个额外的调度器来分配令牌,这个调度器本身又成了单点。
第三层是收益层面。算一笔账:如果你的真实需求是每小时两万次,靠堆四个账号,你只是在把问题往后推——需求再涨一倍呢?八个账号?这条路没有尽头,而且每一步都在增加风险和复杂度。
正确的扩容路径是:先用应用身份把额度提到一万多,再用缓存、条件请求、GraphQL 合并把实际请求量压下来。这两步做完,绝大多数场景都够了。我在一个中等规模的同步项目里做过这个优化,请求量从每小时六千多次降到八百多次,降了将近九成,靠的就是条件请求加增量加对象缓存,没动任何额度规则。
5.3 权限越小,出事时的损失越小
细粒度令牌可以精确到"某个仓库的某几项只读权限"。这个特性在做集成的时候一定要用足。
原因不只是安全。权限范围直接决定了令牌泄露后的影响面,也决定了别人拿到之后能干什么。一个只有contents: read和metadata: read的令牌,就算泄露了也就是让人读读公开代码;一个带着写权限和组织管理权限的令牌泄露了,后果就是另一回事了。
我的习惯是给每个集成单独签发一个令牌,权限只勾它实际用到的,命名上写清楚用途和到期时间,到期必须轮换。这个习惯看起来很琐碎,但真出事的时候,你能在五分钟内定位到是哪个集成、影响哪些仓库,而不是在一堆混用的令牌里瞎猜。
6. 一次完整的限流排查复盘
前面讲的都是"应该怎么做",这一章我想把一次真实的排查过程完整走一遍,因为排查思路比结论更有价值。
6.1 第一步:先分清是哪种限流
拿到 403 的第一反应不应该是改代码,而是取证。我的标准动作是三步:
第一步,手动打一次GET /rate_limit。这个端点不消耗额度,返回所有池子的状态。看一眼 core 和 search 分别剩多少。
第二步,翻出错的那条请求的完整响应头,重点看x-ratelimit-remaining和x-ratelimit-resource。
第三步,看报错文本。文本里提到 secondary 就是二次限流,只是单纯说 rate limit exceeded 就是主限流。
这三步做完,你就能确定问题性质了。我遇到过一种情况特别有迷惑性:日志里 remaining 显示还有四千多,但请求一直失败。当时第一反应是"额度明明够啊",后来才反应过来,那个请求走的是 search 池,而 search 是每分钟 30 次,一分钟内早就超了。x-ratelimit-remaining显示的是 core 池的数字,因为我在日志里只打了这一个字段,没打x-ratelimit-resource。这个教训让我把所有x-ratelimit-*头都打进了日志。
6.2 第二步:定位到具体是哪一行代码在烧额度
确认是限流之后,下一个问题是"谁在烧"。规模稍大的项目,调用点可能有几十处,光看代码猜是猜不出来的。
我的做法是在 HTTP 客户端这一层加一个统一拦截,把每次请求记录下来:方法、路径(去掉 ID 只留模式,比如/repos/{owner}/{repo}/issues)、状态码、x-ratelimit-resource、x-ratelimit-remaining、耗时。然后按路径模式聚合,看 TOP 10。
实际跑出来的结果往往出人意料。我记得有一次排查,排第一的路径是"获取用户信息",占了总请求量的 62%。原因是代码里在渲染每条评论的时候都去查了一次作者信息,完全没做缓存。这个改动只花了二十行代码(加一个字典缓存),请求量直接掉到三分之一。
这个"按路径聚合"的动作,实际上是整个排查里性价比最高的一步。它把"感觉请求很多"变成了"具体是哪个端点在烧钱",后面的优化就有了靶子。
6.3 第三步:修复、验证、回归
定位到热点之后,修复方向按前面的优先级来:能缓存的加缓存,能合并的用 GraphQL 合并,能增量的换成 since 水位线,最后才是加限速器。
验证的方式我推荐看"下降斜率"而不是看"有没有报错"。具体做法是:跑一轮完整任务,每隔一分钟打一次/rate_limit,记录 remaining 的下降曲线。优化前如果是每分钟掉 200,优化后应该掉到 20 以内。这个数字比"这次没报错"可靠得多——没报错可能只是这次数据量小。
回归验证要覆盖两个维度:一是极限情况,比如把数据量放大三倍再跑一次,看会不会撞墙;二是并发情况,多个 worker 同时启动,看令牌桶是不是真的在全局生效。很多限速器只在单进程里测过,一上多进程就失效了,因为每个进程各自有一份计数。
7. 把额度当成一个可观测的指标
7.1 上报、告警、看趋势
限流问题最难受的地方是它的滞后性:你不会在代码写完的当下发现问题,而是在某次数据量变大、某个新功能上线、某个定时任务改了周期之后,突然在半夜炸掉。
解决思路是把剩余额度变成一个持续上报的指标。做法很简单:每次请求完,把x-ratelimit-remaining和x-ratelimit-resource打到指标体系里,按资源池分别记录。这样你能看到三条曲线,也能看到每天消耗的规律。
告警规则我一般设两条:
一条是绝对值告警,remaining < 500且当前时间距离 reset 还有超过 30 分钟,这时候说明消耗速度异常——正常情况不该在窗口没结束的时候就把额度用到只剩一成。
另一条是趋势告警,比如连续三天的峰值消耗比上周同期上涨 50% 以上。这条更早能发现问题,因为它在真正撞墙之前就会响。有一次就是靠这条告警发现某个新上的功能在无节制地拉数据,提前两周拦住了。
7.2 容量估算:先算清楚再动手
写代码之前先做一遍粗算,这个习惯能省掉很多返工。方法就是前面提过的"请求树":
- 要处理的对象有多少个(仓库数、PR 数、Issue 数)
- 每个对象需要多少次请求(列表一次、详情一次、关联展开几次)
- 全量跑一轮的总请求数
- 期望的刷新频率(每小时一轮、每天一轮)
- 用总请求数除以周期,得到每小时的稳态需求
举个具体的:800 个仓库,每个仓库平均 40 个 Issue、8 个 PR,每天同步一轮。全量请求大概是 800(列仓库)+ 800 次列 Issue(每个仓库分页一次)+ 800 次列 PR,再加上展开关联对象的开销,保守估个 4000 到 5000 次。放在一天 24 小时里摊,每小时不到 250 次,非常宽裕。
但如果改成每小时同步一轮,需求就变成每小时四五千次,刚好顶在额度线上,任何一点波动都会导致失败。这时候就必须上增量——只有变化的 Issue 才需要拉取,实际每小时变化的可能只有几十个,请求量立刻掉到几百。
这个估算过程还有个副产品:它能帮你判断"这个需求是不是本来就不该用轮询"。如果某个数据的实时性要求很高,而你算出来每小时需要两万次请求,那答案不是优化限流,而是换方案——用事件推送替代轮询,让平台在有变化的时候主动通知你,你只在收到通知时发一次请求。这条路能彻底摆脱"轮询频率和额度"的矛盾,代价是你需要维护一个能接收事件的入口。
7.3 一张随身带的检查清单
最后把上面这些压缩成一张清单,新项目接入或者出问题时对着过一遍:
| 检查项 | 具体要求 |
|---|---|
| 认证方式 | 团队级服务用应用身份,别用个人令牌 |
| 权限范围 | 只勾实际用到的,只读优先,设到期时间 |
| 日志字段 | 必须记录 resource、remaining、reset、状态码、路径 |
| 缓存层 | 单对象详情缓存 + ETag 条件请求 |
| 增量策略 | since 水位线 + 5 分钟重叠窗口 |
| 批量方式 | 关联查询用 GraphQL 合并,单次返回控制在 200 到 300 个对象 |
| 限速器 | 按资源池分别建令牌桶,容量约为小时额度的 1/10,速率打七折 |
| 重试策略 | 按 retry-after 优先,其次按 reset,最后指数退避,必加随机抖动 |
| 不可重试 | 401、404、422 一律不重试,404 落库标记失效 |
| 监控 | remaining 绝对值告警 + 消耗趋势告警 |
我个人在实际操作中的体会是,限流这件事九成的功夫都在"减量"上,剩下那一成才在"退避"。见过太多项目把精力花在写一个花哨的重试框架上,结果请求量本身没降,框架再优雅也只是把撞墙的过程变得更平滑而已。反而是那些看起来最朴素的改动——给用户信息加个字典缓存、把水位线往前挪五分钟、给列表请求带上 If-None-Match——往往一次就能把请求量砍掉一个数量级。所以我的建议是,在写第一行重试逻辑之前,先花半小时把请求树画出来,看看那个数字到底是多少,再决定往哪个方向使劲。