凭证管理属于那种「不出事时没人看,出事时全线停摆」的模块。企业微信 API 鉴权本身不复杂 —— 拿一个 Token、放进请求头就完事 —— 难的是它会过期、要在多个进程之间共享、还可能漏出去。这篇只讲四件事:存在哪、什么时候刷、多实例并发刷新怎么不打架、泄露之后按什么顺序处置。下面按 wecomapi 的接入方式讲,换成别的上游,结论一样成立。
Token 存哪,取决于你有几个进程
先把一件事分清:长期密钥是配置,调用 Token 是运行时状态。前者跟着部署走,可以进密钥管理服务、进环境变量;后者会过期、会被刷新、会被作废,它的每一次变更都得让所有调用方立刻看到。把这两样东西放在同一个地方管,是凭证问题的起点。
调用 Token 常见的三种存法,适用范围差别很大:
- 进程内变量:最快、零依赖,但只在单实例、且能接受重启后重新获取的场景成立。
- 集中式缓存:多实例部署的默认答案。所有进程读同一份,刷新后全局立即生效,也才有条件做后面讲的并发控制。
- 每次调用现取:看着最「干净」,实际是把一次业务调用放大成两次网络往返,还额外顶着上游对换取频率的约束。除了临时排查,不要这么用。
判断标准只有一条:你的服务会不会同时跑超过一个进程。会,就上集中式存储 —— 包括那些「现在只有一台、以后再说」的服务。扩容那天没人会记得回来改这里,而它出问题的时间点恰好就是扩容之后。
别把长期密钥写进代码仓库,哪怕是私有仓库。提交历史是永久的,删掉当前文件不等于删掉记录。这一条和多实例无关,单机服务同样适用。
刷新策略:主动为主,被动兜底
刷新的触发方式有三种,实际项目里通常要用两种:
- 1被动刷新:调用返回鉴权失败,捕获之后换新再重试。实现最简单,代价是每次过期都要先付出一次失败调用 —— 而且高并发下这次失败是同时打在成百上千个请求上的。
- 2主动刷新:记下签发时间与有效期,剩余寿命低于阈值就提前换新。这条是主路径。
- 3定时刷新:后台任务按固定周期换新。适合调用稀疏的服务 —— 主动刷新要有请求进来才触发,长时间没流量的服务会把刷新延迟结结实实地压在第一个请求上。
推荐组合是主动为主、被动兜底,定时刷新按流量特征决定要不要加。主动刷新的阈值要挂在上游返回的剩余有效期上算,别在代码里写死一个常量 —— 上游调整了有效期,写死的那个数不会跟着变。凭证怎么换取、有效期怎么表达,wecomapi 这类上游各不相同,以线上接口文档为准。提前量给多少?给到「一次最慢的业务调用能在旧凭证失效前跑完」的量级,再乘二。不要卡着过期时间点刷:客户端和服务端的时钟不完全一致,网络还会抖。
被动刷新必须限制重试次数,只允许一次。见过最典型的事故是:鉴权失败触发刷新,换新之后仍然失败(真正原因是权限不覆盖,不是过期),于是继续刷新,几分钟内把上游打满。鉴权失败和权限不足在返回里是能区分的,别用同一个分支处理。
多实例并发刷新的三个坑
这一节是重点,因为它只在生产流量下暴露,本地怎么调都碰不到。三个坑按隐蔽程度递增。
坑一,惊群。凭证过期的那一瞬间,所有实例的所有在途请求同时发现需要刷新,于是同时去换新。上游看到的是一波瞬时并发,轻则触发频控,重则一部分实例拿到失败响应。解法是把「刷新」这个动作在全局收敛成一次:分布式锁加双重检查,拿到锁的实例负责换新,其余实例等待后重新读缓存,而不是排队各刷各的。
坑二,旧值覆盖新值。实例 A 在某一刻发起刷新,网络慢;实例 B 随后发起并成功写入缓存。A 的响应姗姗来迟,把自己那份(很可能已经被上游作废的)结果盖了上去,之后全局都在用一份坏凭证。这个坑的表现是「刷新明明成功了,但过一会儿又全线失败」,非常难查。解法是写回时带版本号做条件更新:只有缓存里还是自己读到的那一份时才允许覆盖。
坑三,刷新窗口内的在途请求。换新不是原子的,总要几十到几百毫秒。这段时间进来的请求拿什么用?让它们全部阻塞等待是最省事的做法,但等于把刷新延迟直接叠加到业务响应上。更稳的做法是允许新旧两份短暂并存:换新完成后旧凭证不立刻丢弃,留一个宽限期,在途请求用旧的跑完,新请求用新的。
// 示意逻辑,不是可直接上生产的实现
async function getToken() {
const cached = await store.get(KEY);
if (cached && cached.expiresAt - Date.now() > SKEW_MS) return cached.value;
// 全局只允许一个刷新者,拿不到锁的退避后重读
const lock = await store.tryLock(LOCK_KEY, { ttl: 10_000 });
if (!lock) {
await sleep(200);
return getToken();
}
try {
const again = await store.get(KEY); // 双检:可能已经被别人刷好了
if (again && again.version !== cached?.version) return again.value;
const fresh = await fetchNewToken(); // 向 manager.wecomapi.com 换取新凭证
await store.compareAndSet(KEY, cached, fresh); // 条件写回,防止旧值盖新值
return fresh.value;
} finally {
await lock.release();
}
}锁的 TTL、退避节奏、宽限期长度都要按你自己的调用量调,上面这段只表达结构。凭证的换取方式、有效期与刷新约束以 wecomapi 线上接口文档为准,不要照抄示意代码上生产。
泄露之后:先轮换,再查怎么漏的
顺序反了的代价很大。多数团队发现疑似泄露后的第一反应是「先确认一下是不是真漏了」,等查清楚已经过去几个小时。正确顺序是反过来的:立刻作废换新,然后再慢慢查。轮换的成本是一次可预期的短暂抖动,不轮换的成本不可控。
- 1作废当前凭证、签发新的,确认所有实例都已切到新凭证并恢复调用。
- 2拉调用日志,按时间窗看有没有非预期的来源和调用量突增,判断是否已经被用过。
- 3定位泄露路径并把它堵上。只换密钥不堵路径,换完还会再漏一次。
- 4复盘时把这条路径变成一条 CI 检查或代码评审清单项,而不是一句「以后注意」。
泄露路径的分布相当集中,按出现频率大致是:日志里打印了完整请求头;凭证被硬编码提交进仓库;调试时图省事让前端或移动端直连上游;截图和聊天记录里带出去;某个第三方组件把请求头一并上报给了它自己的监控服务。最后一条最容易漏掉,因为它不在你写的代码里。
- 日志脱敏在框架层做,不靠人记得。凭证类字段统一过一个打码函数,业务代码无法绕过。
- 分环境、分实例发凭证。测试环境永远不用生产凭证 —— 这一条能把绝大多数泄露的爆炸半径压到可接受。
- 前端和移动端一律不直连上游,走自己的后端中转,凭证不出服务端。
- 把轮换排进日程做常规演练。真出事时敢不敢立刻按下按钮,取决于你之前按过没有。
分环境分实例还有个附带好处:账号实例本身是隔离的,配合调用审计日志,出问题时能把排查范围直接圈到某个环境的某个实例,而不是全线翻。
上线前的六条自检
- 1长期密钥不在代码仓库、不在镜像、不在前端产物里。
- 2调用 Token 存在所有实例都读得到的地方,并且带过期时间。
- 3刷新走主动路径,被动刷新只作兜底,且只允许重试一次。
- 4刷新有全局互斥,写回是条件更新而不是无脑覆盖。
- 5随手抽一条线上日志,确认里面的凭证是打码的。
- 6有一份「十分钟内完成轮换」的操作手册,并且有人真的照着跑过一遍。
前四条决定线上稳不稳,后两条决定出事时亏多少。大多数团队做完前四条就收工了 —— 恰恰是后两条更省钱。自检时用到的有效期口径、返回码与作废接口,精确定义以 wecomapi 文档为准。
常见问题
- Token 可以下发到前端或移动端吗?
- 不要。前端产物和客户端包体都是可以被任意读取的,凭证一旦下发就等同于公开,而且你无法回收已经发出去的那一份。正确做法是客户端只调你自己的后端,由后端持有凭证并中转请求,顺便在这一层做业务鉴权与限流。
- 多实例部署,每个实例各自刷新可以吗?
- 能跑,但不建议。各刷各的会在过期瞬间形成一波并发换取,还容易出现慢响应把已经生效的新凭证覆盖掉的情况,表现为「刷新成功了却又全线失败」。把凭证放集中式缓存、刷新加全局互斥、写回用条件更新,改造成本很低。
- 怎么判断一次失败是不是凭证问题?
- 看返回里区分的是鉴权类还是权限类:前者刷新后重试一次通常就好,后者刷多少次都一样,要回去看能力范围与配置。所以这两类不能被同一个 catch 吞掉。具体的返回结构与区分方式以 wecomapi 线上接口文档为准。
准备好动手了?
精确字段、鉴权与端点以线上文档为准;可在控制台创建密钥后联调。
