昨晚在复盘企微底层高并发架构时,有个做代账 SaaS 系统的技术合伙人给我发来一段堪称"毁灭级"的生产事故日志,隔着屏幕都能感觉到他的绝望。
他们公司上周刚把企微社群管家升级成了多租户(Multi-tenant)架构,接入了 300 多家代账公司的企微实例。这兄弟的开发团队图省事,把企微 API 的 access_token 管理和请求客户端写成了一个简单的 ConcurrentHashMap 缓存。 结果大促群发活动一开始,服务器线程池被打满。发生了极其惨烈的"上下文串灾":A 公司的财务报表,被机器人在 B 公司的老板群里发了出去;C 公司的客户退群事件,触发了 D 公司的流失预警。几个代账公司的老板直接把电话打到了他们 CEO 那里要起诉。
很多兄弟在做企微二次开发时,单体应用写得很溜,一旦切入到多租户、多实例的 SaaS 场景,立刻就会被"Token 缓存击穿"和 "线程上下文污染"这两座大山压垮。在真实的工业级架构中,几百个 corpid 和 agentid 并发运转,绝不是靠几个全局变量就能搞定的。今天咱们直接手撕一套"TTL 强隔离 + 分布式锁刷新 + 动态代理注入"的高阶多实例管线,彻底斩断串灾的噩梦。
第一关:斩断线程污染------构建绝对隔离的请求上下文(Context)
当你同时服务 300 个企微企业时,网关每秒钟会收到成百上千个来自不同 corpid 的 Webhook 回调,或者来自不同租户前端发起的发消息请求。 新手最容易踩的坑,就是把 corpid 一层一层地当做方法参数往下传,导致业务代码里全是冗余参数;或者更作死地用普通的 ThreadLocal,结果在配合 MQ 和异步线程池使用时,发生了上下文串透。
工业级解法:拥抱 TTL(TransmittableThreadLocal)与强制清理边界。
我们必须在网关层,将租户身份打上"线程级的钢印"。为了防止异步线程池复用导致的污染,必须引入阿里开源的 TransmittableThreadLocal。
Java
public class TenantContextHolder {
// 工业级防线:使用 TTL,确保在线程池切换、MQ 异步投递时上下文不丢失、不串位
private static final TransmittableThreadLocal<String> TENANT_CONTEXT = new TransmittableThreadLocal<>();
public static void setCorpId(String corpId) {
TENANT_CONTEXT.set(corpId);
}
public static String getCorpId() {
return TENANT_CONTEXT.get();
}
public static void clear() {
TENANT_CONTEXT.remove();
}
}
致命细节: 在任何 Webhook 接收网关、MQ 消费者的入口处,第一行代码是 setCorpId,那么在 finally 块里,必须强制执行 clear()。如果你漏了清理,下一个被线程池复用的任务就会带着上一个租户的身份去执行数据库写操作,这就是数据大串灾的根本原因。
第二关:Token 刷新风暴------防击穿的分布式锁引擎
身份隔离做好了,接下来是企微开发中最让人头疼的 access_token。
如果你去深扒底层的 开放文档,你会发现企微对于 Token 有一个极其苛刻的限制:有效期 2 小时,且高频调用刷新接口会触发限流报错。
假设你服务了 500 个租户,如果采用"用到时发现过期再去刷新"的惰性策略(Lazy Refresh),一旦某个大租户的 Token 刚好在双十一秒杀的瞬间过期,瞬间会有几千个并发线程同时发现 Token 失效,然后同时去调企微的 gettoken 接口。 企微网关会立刻甩你一脸 45009,并且由于多次重复刷新,旧 Token 会被瞬间挤下线,你的系统直接陷入死锁瘫痪。
实战打法:双重检查锁(DCL)+ Redis 分布式引擎。
我们必须把 Token 的生命周期托管到 Redis,并且在刷新时加上极其严格的分布式互斥锁。
Java
public String getAccessToken() {
String corpId = TenantContextHolder.getCorpId(); // 从 TTL 极速获取当前租户
String tokenKey = "WeCom:Token:" + corpId;
// 1. 尝试从 Redis 获取有效 Token
String token = redisTemplate.opsForValue().get(tokenKey);
if (StringUtils.isNotBlank(token)) {
return token;
}
// 2. 发生缓存击穿,准备去企微刷新,先上分布式锁!
String lockKey = "WeCom:TokenLock:" + corpId;
RLock lock = redissonClient.getLock(lockKey);
try {
// 等待 3 秒,拿到锁后锁住 10 秒
if (lock.tryLock(3, 10, TimeUnit.SECONDS)) {
// 3. 拿到锁后的"双重检查(Double-Check)",防止其他线程刚才已经刷好了
token = redisTemplate.opsForValue().get(tokenKey);
if (StringUtils.isNotBlank(token)) {
return token;
}
// 4. 真正发起 HTTP 请求,调企微接口获取新 Token
WeComTokenResponse response = fetchTokenFromWeComAPI(corpId);
// 5. 写入 Redis,有效期设置为 7000 秒(提前 200 秒过期留出冗余)
redisTemplate.opsForValue().set(tokenKey, response.getAccessToken(), 7000, TimeUnit.SECONDS);
return response.getAccessToken();
} else {
// 没拿到锁的线程,直接睡 50 毫秒后重试,绝对不允许去调企微接口!
Thread.sleep(50);
return getAccessToken();
}
} catch (Exception e) {
throw new RuntimeException("获取企微 Token 异常", e);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
第三关:动态客户端代理------让业务层"无感"切流
上下文有了,安全的 Token 引擎也有了,最后一步就是整合。 我们在写业务逻辑(比如"发送群消息"、"打标签")时,如果每次都要手动写 String token = tokenManager.getToken(); 然后拼 HTTP 请求,代码依然会腐化。
工业级架构:动态代理与拦截器注入。
我们利用 Feign 或者自定义的 HTTP 拦截器,在网络请求发出的最后一刻,将 access_token 强行塞进 URL 里。业务开发人员根本不需要知道当前是哪个租户,也不需要关心 Token 是否过期,只管调业务方法即可。
Java
// 拦截器在 HTTP 请求发出前,动态拼装鉴权信息
public class WeComAuthInterceptor implements RequestInterceptor {
@Autowired
private TokenManager tokenManager;
@Override
public void apply(RequestTemplate template) {
// 从拦截器中自动获取当前上下文的 Token
String accessToken = tokenManager.getAccessToken();
// 动态注入到 URL 参数中
template.query("access_token", accessToken);
log.debug("【动态路由】API 调用已动态注入租户 {} 的 Token", TenantContextHolder.getCorpId());
}
}
有了这层动态代理,你的大模型聊天模块、发券模块、打标模块,写出来的代码就跟单机版一模一样。底层中台会自动根据 TTL 上下文,把这几千个并发请求安全地路由到对应的企微通道中。
做企微的多实例 SaaS 架构,其实就是在和多线程、并发边界作斗争。用 TTL 锁死租户上下文防止串灾,用分布式锁掐死 Token 刷新风暴,用底层拦截器抹平业务感知。把这三道防线夯实,哪怕你接入上万个企微实例,中台依然能跑得像钟表一样精准。
你们在实际交付多租户 SaaS 时,对于 access_token 的刷新策略,是喜欢用上述这种"业务请求触发时的惰性刷新(加分布式锁)",还是倾向于在后台起一个 XXL-JOB 定时任务,在 Token 过期前 10 分钟主动去做"定时全量轮询刷新"?
