《从零开发校园墙微信小程序》进阶篇之二。上一篇说项目里配置了 5 个来自不同应用的微博令牌。这篇讲它们如何轮流使用、被限流后如何退避。这是我踩坑最多、改了好几版的一块。
问题:微博的额度是「应用 × 接口」
微博开放平台对调用次数有限额,而且限额的单位是 【应用 × 接口】 :
- 来自不同应用 的令牌,额度互相独立;
- 同一个令牌 在不同接口上的额度,也是独立的。
这给了我们一个机会:多申请几个应用,把令牌放进一个池子里轮流用。某一个被限流了,就换下一个。想扩容,新增应用并往配置里追加令牌即可,不需要改代码 。
但「轮流用」只是起点,真正的难点是:被限流之后怎么办?
第一版:每个令牌一份冷却(有 bug)
最初的想法很朴素:某个令牌被限流了,就把它「冷却」一段时间,期间不再使用。
cooldown: Map<token, 冷却截止时间>上线后观察到两类互相矛盾的错误(2026 年 9 月 18 日的生产观察):
① 读限流连坐写。 读评论的接口被限流 → 令牌整个被冷却 → 发帖明明额度还在,也被一起跳过。我观察到过「读接口已经从限流名单里消失,5 个令牌却仍然全部在冷却」这种错配的状态。
② 写限流没人记录。 发帖接口撞限流时,代码没有登记这次失败,下一次照样拿同一个令牌去撞。
根因是同一个:冷却的粒度错了。 令牌的额度是按接口算的,冷却却是按令牌整体算的。
第二版:按「令牌 × 作用域」冷却
把接口分成两个作用域 :
/** 读类接口:comments/show、statuses/count、statuses/mentions */
public static final String SCOPE_READ = "read";
/** 写类接口:statuses/share、comments/create、comments/reply */
public static final String SCOPE_WRITE = "write";冷却的 key 变成 令牌|作用域:
private static final Map<String, Long> COOLDOWN_UNTIL = new ConcurrentHashMap<>();
private static String key(String token, String scope) {
return token + "|" + (scope == null ? SCOPE_READ : scope);
}于是:某令牌在发帖 上被限,只跳过它发帖这一条路,读评论仍然可以用它;反之亦然。两个问题同时解决。
取令牌:轮询 + 跳过冷却中的
public static String getAccessToken(String scope) {
int n = accessToken.size();
if (n == 0) {
throw new IllegalStateException("未配置微博 access_token(pzforest.access-token),微博相关功能不可用");
}
String fallback = null;
for (int i = 0; i < n; i++) {
String token = accessToken.get(Math.floorMod(COUNT.getAndIncrement(), n));
if (fallback == null) fallback = token;
if (!isTokenCoolingDown(token, scope)) return token;
}
return fallback; // 全部冷却:退回轮询位置上的令牌,由上层决定要不要整体退避
}这个小方法里藏着两个并发 bug 的修复:
1. 轮询下标要用AtomicInteger,不能用普通 int。
private static final AtomicInteger COUNT = new AtomicInteger(0);count++ 是「读—改—写」三步,多线程并发时会丢更新、算出越界的下标。
2. 取模要用Math.floorMod,不能用 %。AtomicInteger 一直自增,终有一天溢出变成负数 。Java 里负数 % n 的结果仍然是负数,用它当下标就会数组越界。floorMod 保证结果非负。
这两处都是「平时从不出问题、跑上几个月忽然出事」的典型,只有在并发和溢出的边缘才会触发。
还有一个设计决定:全部令牌都在冷却时,不返回null,而是退回轮询位置上的令牌。因为返回null会让调用方抛 NPE;保持与旧行为一致,由上层根据「整体是否可用」来判断是否退避。
冷却时长:递增退避,而不是固定
被限流后冷却多久?我最初用的是固定 10 分钟,后来改成了递增 :
private static final long TOKEN_COOLDOWN_BASE_MS = 10 * 60_000L; // 10 分钟
private static final long TOKEN_COOLDOWN_MAX_MS = 60 * 60_000L; // 上限 60 分钟
long cooldown = Math.min(TOKEN_COOLDOWN_BASE_MS << Math.min(streak - 1, 4), TOKEN_COOLDOWN_MAX_MS);
// streak=1 → 10min,2 → 20,3 → 40,4+ → 60(上限)为什么不用固定短冷却?代码里的注释把理由写得很清楚:
探测本身也消耗该应用的额度,固定 10 分钟等于每小时拿 6 次额度去试探,对小配额的高级接口来说占比很高。递增退避让「已被打空的应用」少被打扰。
换句话说:探测是有成本的。 一个已经被打空的应用,你越频繁地去试它能不能用,就越是在消耗它本来就不多的额度。
一个细心的自纠正
streak(连续被限流次数)怎么清零?理想是「调用成功就清零」,但有一个现实问题:写路径不登记成功 。所以代码用了一种自纠正的方式:
Long prev = COOLDOWN_UNTIL.get(k);
// 上一次冷却已经过期,说明这中间它被用过并且没报限流(否则会一直在冷却里)——
// 那就把退避次数当新一轮从 1 开始,避免偶发失败把冷却永久顶在 60 分钟。
int streak = (prev != null && prev > now) ? FAIL_STREAK.merge(k, 1, Integer::sum) : 1;逻辑是:如果上一次的冷却还没过期 就又失败了,说明确实是持续被限,streak 累加;如果冷却已经过期 才失败,说明中间用过且没出事,这是一次新的偶发失败,从 1 重新计。这样不依赖「成功」信号,也不会因为偶发失败把冷却永久顶满。
两层退避
只有「单个令牌」这一层还不够。当所有令牌 都被限流时,整条链路都应该停下来,而不是还在傻傻地一个个去试。所以有第二层,由 WeiboUpstreamHealth 负责:
| 层 | 问的问题 | 谁负责 |
|---|---|---|
| 单 (令牌, 作用域) 层 | 这个令牌在这类接口上能不能用? | WeiboConfig |
| 作用域整体层 | 这类接口还有没有任何 令牌可用? | WeiboUpstreamHealth |
整体层的判定依据很关键:
public boolean isCoolingDown(String scope) {
if (WeiboConfig.getTokens().isEmpty()) return true;
return !WeiboConfig.hasAvailableToken(scope);
}它看的是「还有没有可用的令牌 」,而不是 「最近有没有失败过」。只要还存在一个没被限流的令牌,这条路就仍然是通的,不应该整体退避。
整体不可用时的降级行为也是分链路的:
- 读链路不可用 :评论走本地镜像(第 11 篇)、
@转发暂停; - 写链路不可用 :直接告诉用户稍后再试。
记录失败,同时可能触发整体退避
public void markFailure(String token, String scope, String reason) {
...
WeiboConfig.markTokenFailure(token, scope); // 冷却这个 (令牌, 作用域)
if (!WeiboConfig.hasAvailableToken(scope)) {
long streak = consecutiveFailures.incrementAndGet();
if (streak == 1 || streak % 10 == 0) { // 日志降噪:第 1 次和每第 10 次才报警
log.warn("微博 {} 类接口的全部 {} 个应用均被限流,暂停该链路,连续第 {} 次: {}", ...);
}
} else {
consecutiveFailures.set(0);
}
}注意 streak == 1 || streak % 10 == 0:整体限流期间失败会持续发生,每次都打 WARN 会刷屏。只在第一次和每 10 次时报警 ,既不漏报也不淹没日志。
给运营看的仪表盘
状态同时暴露给管理后台,分读/写两栏展示「可用应用数、冷却中数量、下次重试倒计时」,并且令牌只显示前 12 位:
public static String shortToken(String token) {
return token != null && token.length() > 12 ? token.substring(0, 12) + "…" : token;
}日志和界面里永远不要打印完整令牌。
仪表盘文案里还有一处很讲究的措辞,代码注释特别强调:
「未被限流」指的是我们最近没从这个组合收到限流 ,不等于「确认额度充足」——重启后未被调用过的令牌也会显示为未被限流。
不要让界面显示的信息比你实际知道的更乐观。
小结
- 限额的单位是什么,冷却的粒度就应该是什么:令牌 × 作用域 ,而不是整个令牌;
- 多应用令牌池:想扩容只需追加令牌;
- 并发下的轮询:
AtomicInteger+Math.floorMod; - 探测有成本 ,所以用递增退避 (10→20→40→60 分钟),而不是固定短冷却;
- 用「上一次冷却是否已过期」来自纠正失败计数,不依赖成功信号;
- 两层退避:单令牌层 + 作用域整体层;整体层看「有没有可用令牌」,不看「最近有没有失败」;
- 日志降噪、令牌脱敏、仪表盘措辞不要夸大。
下一篇讲评论读取:当官方接口被限流时,用户为什么还能看到评论。