《从零开发校园墙微信小程序》进阶篇之三。评论是墙上读取最频繁的数据,却也最依赖微博接口。这篇讲怎么在官方接口被限流、甚至完全不可用时,仍然让用户看到评论。

为什么评论是难点

  • 墙上每个帖子都有评论区,每次打开详情都要读 ,请求量远大于发帖;
  • 读评论的接口(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 不会遇到。

小结

  1. 读多写少的数据,做多层降级 :缓存 → 官方(多令牌)→ 网页兜底 → 本地镜像;
  2. 缓存要区分「不存在」和「存在但为空」;空结果用更短的 TTL;缓存故障当未命中;
  3. 写操作成功后主动失效缓存 ;
  4. 令牌失败就换下一个,用 attemptsLeft 限制递归;整体不可用就跳层,别白试;
  5. 非官方接口只做兜底,要有开关;
  6. 不要把「成功但为空」当成「失败」去降级 ;
  7. 协议要能区分「空」和「失败」(200 vs 502);
  8. 镜像写入要幂等,并且小心不同来源的 ID 互相覆盖;
  9. 落库是「加热」,失败不能连累已经拿到的数据;collectList() 后用 switchIfEmpty 要先 filter。

接下来是另一条完全不同的线:课表 。

最后修改:2026 年 11 月 12 日
如果觉得我的文章对你有用,请随意赞赏