《从零开发校园墙微信小程序》进阶篇之三。评论是墙上读取最频繁的数据,却也最依赖微博接口。这篇讲怎么在官方接口被限流、甚至完全不可用时,仍然让用户看到评论。
为什么评论是难点
- 墙上每个帖子都有评论区,每次打开详情都要读 ,请求量远大于发帖;
- 读评论的接口(
comments/show)额度有限,容易被打满; - 微博上的评论可能是别人直接在微博里评的,不经过我们的系统 ,所以不能只依赖自己写进去的数据。
如果只依赖官方接口:一旦被限流,所有帖子的评论区同时变空。这显然不行,所以我设计了多层读取 。
整体策略:一层不行就换下一层
┌───────────────────────────────────────────┐
用户 ─▶ │ ① Redis 缓存(命中就直接返回) │
└──────────────┬────────────────────────────┘
│ 未命中
┌──────────────▼────────────────────────────┐
│ ② 官方接口,多令牌轮换(某个被限就换下一个) │
└──────────────┬────────────────────────────┘
│ 全部令牌都失败
┌──────────────▼────────────────────────────┐
│ ③ 网页接口兜底(不消耗开放平台额度) │
└──────────────┬────────────────────────────┘
│ 也失败
┌──────────────▼────────────────────────────┐
│ ④ 本地镜像(自己库里之前存的评论) │
└───────────────────────────────────────────┘每往下一层,数据可能更旧或更不完整,但可用性更高 。
① Redis 缓存
private Mono<List<Comments>> readCommentCache(String id) {
return Mono.fromCallable(() -> {
String json = cacheUtils.get(COMMENT_CACHE_PREFIX + id);
if (StringUtils.isEmpty(json)) return null;
// 注意:空列表也是有效缓存(表示"这帖确实没评论"),不能当未命中,
// 否则没评论的帖子每个访客都要去打一次上游。
return JSONUtil.toList(JSONUtil.parseArray(json), Comments.class);
}).subscribeOn(Schedulers.boundedElastic()); // 阻塞式 Redis 客户端,放弹性线程池
}这里有三个细节:
1. 「空列表」也是有效缓存。 很多人写缓存时用「查不到 / 为空 → 未命中」,结果没有评论的帖子永远穿透缓存,每个访客都要去问一次上游。要区分「没有这个 key」和「key 存在但内容是空」。
2. 空结果用更短的 TTL。
long ttl = list.isEmpty() ? Math.min(commentCacheSeconds, 60) : commentCacheSeconds;不缓存空结果,会让没评论的帖子反复打上游;按完整 TTL 缓存空结果,又会让刚出现的第一条评论 迟迟看不到。折中是:空结果最多缓存 60 秒。
3. 缓存的「纯缓存语义」。 Redis 不可用、或者缓存内容损坏,都当作未命中 处理,不能影响主流程。缓存永远只是加速手段,不应该成为故障点。
发布 / 回复后要主动失效缓存
public void evictCommentCache(String id) {
Mono.fromRunnable(() -> cacheUtils.del(COMMENT_CACHE_PREFIX + id))
.subscribeOn(Schedulers.boundedElastic())
.subscribe(v -> { }, e -> log.warn("清理评论缓存失败: id={}, err={}", id, e.getMessage()));
}用户刚发了一条评论,如果缓存没清,他自己刷新后看不到自己的评论 ,体验很糟。所以发布成功后要主动删缓存。注意这里清理失败只打 warn,不影响发布结果。
② 官方接口 + 多令牌轮换
这一层把上一篇的令牌池用起来:
private Mono<List<Comments>> fetchCommentsWithTokenFailover(String id, String firstToken, int attemptsLeft) {
String token = (firstToken != null && !WeiboConfig.isTokenCoolingDown(firstToken, SCOPE_READ))
? firstToken : WeiboConfig.getAccessToken();
if (attemptsLeft <= 0 || token == null) return mirrorOrFail(id);
final String used = token;
return fetchCommentsFromWeibo(used, id)
.doOnSuccess(list -> upstreamHealth.markSuccess(used, SCOPE_READ))
.onErrorResume(e -> {
upstreamHealth.markFailure(used, SCOPE_READ, "getComments: " + brief(e.getMessage()));
if (attemptsLeft <= 1) return fetchCommentsFromWebThenMirror(id); // 都试过了,进下一层
return fetchCommentsWithTokenFailover(id, null, attemptsLeft - 1); // 换一个令牌再试
});
}设计要点:
attemptsLeft初值 = 令牌总数,防止递归失控。每换一次令牌减 1,减到 0 就进入下一层;- 某个令牌失败 → 登记失败(进入冷却)→ 传
null让它自己挑下一个可用的 ,避免重复试同一个; - 进入这一层之前,先判断「读作用域是否整体不可用」:如果全部令牌都在冷却,试了也是白试,直接跳到第 ③ 层 ,省掉一次必败的请求。
③ 网页接口兜底
当所有官方令牌都被限流,退一步:使用微博网页版自己前端调用的接口 。它的好处是不消耗开放平台的额度 ,所以官方接口被限流时仍然可用。
但它有明确的局限,代码注释写得很坦诚:
它返回的是「热门评论」(默认约 20 条),而且字段属于未公开的契约 ,所以只做兜底,不替代官方接口 。
使用这类非官方接口要有心理准备:
- 它随时可能变更或失效,不能当成稳定依赖;
- 需要模拟移动端的请求头,否则可能被重定向到访客验证,或者返回空响应;
- 返回的文本是纯文本(官方接口返回的是带 HTML 的富文本),处理方式也不同。
务必把它放在降级链路的后面 ,并且保留一个开关(commentScrapeEnabled),出问题时可以直接关掉。
一个关于「空」的重要判断
// 网页接口只要能调通就以它为准(哪怕返回空列表 = 这帖确实没评论),
// 只有它真出错时才退到本地镜像。不能拿"结果为空"当失败去查镜像:
// 那会把"没有评论"误判成故障,让前端显示"加载失败"而不是"快来抢沙发"。这段注释说的是一个容易犯的逻辑错误:把「成功但为空」当成「失败」去触发降级。 一个帖子本来就没评论,这是正常状态,应该显示「快来抢沙发」,而不是「加载失败」。降级只应该由真正的错误 触发。
④ 本地镜像
最后一层是自己库里的 weibos_comments。它里面的评论,是每次成功读到评论时顺手镜像进去的 (官方接口和网页接口共用同一个落库步骤,见后文)。它可能不是最新的,但一定能读出来 。
顺便说一个容易被忽略的事实:小程序用户发的评论,其实也是以墙号的身份发到微博上 的——所以在微博那一侧,它们的评论人名字恒为墙号。本项目靠本地镜像里的 user_id(是 openid 还是纯数字的微博 uid)来区分「这条评论是小程序用户发的」,并在前端显示成「昵称(小程序)」。读镜像时的规则是:本地昵称为空,或者只是纯数字的占位,就回退显示成「同学」。
private Mono<List<Comments>> mirrorOrFail(String id) { ... }函数名里的 OrFail 很有意思:镜像里也读不出来时,必须明确失败,而不是返回一个空列表 ,这样前端才能提示「加载失败,点击重试」,而不是误报「还没有评论」。
这里还有一个响应式编程里很典型的坑,代码注释特意标了出来:
// 注意:collectList() 一定会 emit 一个(可能为空的)List,不是"空 Mono",
// 所以必须用 filter 判断内容,直接用 switchIfEmpty 永远不会触发。
return loadCommentsFromMirror(id)
.filter(list -> !list.isEmpty())
.switchIfEmpty(Mono.error(new WeiboApiException("评论加载失败,请稍后重试")));Flux.collectList() 即使上游没有任何元素,也会发出一个空列表 ,而不是「什么都不发」。所以想用 switchIfEmpty 处理「没数据」,必须先 filter 掉空列表,否则 switchIfEmpty 永远不会触发。这类 bug 在测试里很难发现——因为它只在「真的没数据」时才暴露。
失败和「空」必须能区分
前端和后端有一个约定(第 4 篇也提过):
- 拿到评论:
code = 200; - 上游不可用(限流 / 配额耗尽):
code = 502,且data = null。
前端收到 502 时必须抛错并提示用户 ,而不是显示成「还没有评论」:
// 必须在这里抛出,否则会被当成「加载成功但没有评论」,
// 评论区静默显示「还没有评论」,用户完全看不出是故障。
if (body.code !== 200) { throw new Error(body.message || '评论加载失败,请稍后重试'); }「读到了但是空」和「没读到」,对用户意味着完全不同的事,接口协议里必须能表达出这个区别 。
评论写入镜像:幂等
无论评论来自官方接口还是网页接口,读到之后都会镜像写入本地库 ,供下一次降级时使用。写入用了 ON DUPLICATE KEY UPDATE(有则更新、无则插入),这样同一条评论被重复拉取多次也不会产生重复数据:
... ON DUPLICATE KEY UPDATE
user_id = IF(user_id REGEXP '^[0-9]+$', VALUES(user_id), user_id),
...其中有一个小技巧:user_id 的更新规则是「只有当前值是纯数字(微博 uid)时才覆盖 」。因为本库里的 user_id 既可能是微博 uid(纯数字),也可能是小程序用户的 openid(字母数字混合)。小程序用户发的评论带的是 openid,不能被后来从微博拉到的数据冲掉 。用正则区分两类 ID,是个朴素但有效的办法。
落库只是「加热」,失败不能连累读取
官方接口和网页接口读到评论后,都走同一个后处理方法(排序 + 识别小程序评论 + 镜像落库),保证两条来源产出的数据形态完全一致。其中落库这一步有一个重要的容错决定:
// 落库只是给本地镜像"加热",属于优化而非必需:写失败也必须把已经拿到的
// 评论返回给前端。历史问题:网页兜底抓取成功但落库报错,被当成整条链路失败,
// 用户侧表现为"评论加载失败",而数据其实就在手里。
.onErrorResume(e -> {
log.warn("评论镜像落库失败(不影响本次读取,评论照常返回): id={}, err={}", id, brief(e.getMessage()));
return Mono.just(commentsList);
})手里已经有的数据,不能因为一个「顺带做的」副作用失败而丢掉。 把「主流程」和「附带优化」分开,附带的失败只记日志。
另外,因为用的是 Spring Data R2DBC + r2dbc-mariadb,镜像写入不能直接用 repository.save():
- 对带非空
@Id的实体,save会走UPDATE,行不存在就报错,而我们要的是「有则更新、无则插入」,所以只能手写 upsert; - 这个驱动对
null参数编码会报No encoder for Object,所以绑定参数前要把null清洗成类型默认值(字符串变空串,数字变 0)。
这两点都是迁移到 R2DBC 之后才踩到的,MyBatis-Plus 时代的 saveOrUpdate 不会遇到。
小结
- 读多写少的数据,做多层降级 :缓存 → 官方(多令牌)→ 网页兜底 → 本地镜像;
- 缓存要区分「不存在」和「存在但为空」;空结果用更短的 TTL;缓存故障当未命中;
- 写操作成功后主动失效缓存 ;
- 令牌失败就换下一个,用
attemptsLeft限制递归;整体不可用就跳层,别白试; - 非官方接口只做兜底,要有开关;
- 不要把「成功但为空」当成「失败」去降级 ;
- 协议要能区分「空」和「失败」(200 vs 502);
- 镜像写入要幂等,并且小心不同来源的 ID 互相覆盖;
- 落库是「加热」,失败不能连累已经拿到的数据;
collectList()后用switchIfEmpty要先filter。
接下来是另一条完全不同的线:课表 。