他人调用刷新 token?服务号缓存 token 提前失效应对方案

微信Token缓存失效应对方案

微信服务号的 access_token 存在多实例 / 多调用方共享、被其他调用方刷新后导致本地缓存提前失效的核心问题(官方明确:新 access_token 生成后,旧的会立即失效,而非等 2 小时过期)。处理的核心思路是:放弃 “固定 2 小时缓存”,改为 “主动校验有效性 + 原子化刷新 + 容错降级”,确保缓存的 access_token 始终可用。

一、核心问题拆解

  1. 共享性:access_token 是服务号全局唯一的,多个后台服务、第三方工具(如微信开发者工具、其他开发人员调试)都可能调用 /cgi-bin/token 接口刷新;
  2. 即时失效:新 access_token 生成后,旧的立即失效(哪怕距离生成不足 2 小时);
  3. 缓存风险:本地缓存的 access_token 可能因他人刷新而 “静默失效”,直接使用会返回 40001(invalid credential) 错误。

二、解决方案:四步保障机制

1. 第一步:放弃 “固定过期时间”,改为 “提前失效缓存”(减少失效概率)

官方规定 access_token 有效期 “最长 2 小时”,但实际可能被他人刷新,因此本地缓存时不按 2 小时设置过期,而是缩短至 1.5 小时(5400 秒)

  • 理由:即使他人在 1.5 小时后刷新,本地缓存也已过期,会主动重新获取新 token,避免使用 “已失效的旧 token”;
  • 实现:用 Redis(推荐)或本地缓存(单实例可用)存储,设置 expire = 5400秒,同时记录 token 的 “生成时间”(备用)。

示例(Redis 存储结构):

key: wx_service_access_token:{appid}  # 按appid区分(多服务号场景)
value: "17_O9xXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXx"
expire: 5400秒  # 1.5小时
2. 第二步:原子化刷新 token(避免多实例并发重复刷新)

如果多个服务实例同时发现缓存过期,会并发调用微信接口刷新 token,导致 “重复刷新”(浪费接口调用次数,且可能生成多个无效 token)。需通过分布式锁保证 “同一时间只有一个实例能刷新 token”:

实现流程:
  1. 所有实例获取 token 时,先查 Redis 缓存:
    • 缓存存在且未过期:直接返回;
    • 缓存不存在 / 已过期:尝试获取分布式锁(锁的 key 建议为 wx_access_token_lock:{appid},过期时间设为 30 秒,避免死锁);
  2. 成功获取锁的实例:
    • 调用微信接口 /cgi-bin/token 刷新 token;
    • 刷新成功后,将新 token 存入 Redis(设置 1.5 小时过期);
    • 释放锁;
  3. 未获取到锁的实例:
    • 等待 100-300ms 后,重新查询 Redis 缓存(此时锁持有者已存入新 token);
    • 若等待后仍无缓存,可重试 2-3 次(避免极端情况)。
分布式锁选型:
  • 推荐:Redis Redlock(高可用)、ZooKeeper 锁;
  • 简化方案(单 Redis 实例):用 SET NX EX 命令实现(原子操作):
    # 尝试获取锁(key=锁名,value=随机字符串,EX=30秒过期,NX=不存在才设置)
    SET wx_access_token_lock:wx12345678 NX EX 30
    
3. 第三步:主动校验 token 有效性(兜底处理 “提前失效”)

即使设置了 1.5 小时缓存,仍可能出现 “缓存未过期,但 token 已被他人刷新” 的情况(比如他人在 1 小时后刷新)。此时需在使用 token 前 / 使用失败后校验有效性:

方案 A:使用前预校验(推荐,减少接口报错)

调用微信「校验接口调用凭证」接口,快速判断 token 是否有效(该接口无调用次数限制):

  • 接口地址:https://api.weixin.qq.com/cgi-bin/get_api_domain_ip?access_token=ACCESS_TOKEN
  • 响应逻辑:
    • 返回 {"ip_list":["xxx.xxx.xxx.xxx"]}:token 有效,直接使用;
    • 返回 {"errcode":40001,"errmsg":"invalid credential"}:token 失效,触发强制刷新。
方案 B:使用失败后重试(兜底)

当调用其他接口(如发送模板消息、获取用户信息)返回 40001(invalid credential) 或 42001(access_token expired) 时:

  1. 标记当前缓存的 token 为 “无效”(立即删除 Redis 缓存);
  2. 触发一次 “强制刷新 token”(走第二步的原子化刷新流程);
  3. 重试原接口(最多重试 1 次,避免循环)。

注意:仅针对 40001/42001 错误重试,其他错误(如 40003 无效 openid)无需重试。

4. 第四步:限制刷新频率(避免滥用接口)

微信 access_token 接口有调用频率限制(默认每日上限 1000 次,足够使用),但需避免因异常情况(如缓存穿透)导致高频刷新:

  • 给刷新操作加 “限流”:同一 appid 每分钟最多刷新 1 次(用 Redis 记录刷新次数,超过则拒绝,返回缓存的 token 或报错);
  • 刷新失败时的退避策略:若调用微信接口失败(如网络超时、微信服务器报错),不要立即重试,采用 “指数退避”(1 秒、3 秒、5 秒后重试,最多重试 3 次),避免无效请求。

三、完整流程梳理(闭环)

四、关键注意事项

  1. 多服务号场景:按 appid 区分缓存 key 和锁 key(避免不同服务号的 token 互相覆盖);
  2. 单实例 vs 多实例:
    • 单实例:可简化分布式锁为 “本地锁”(如 Java 的 ReentrantLock);
    • 多实例(如微服务集群):必须用分布式锁(Redis/ZooKeeper),否则会出现并发刷新问题;
  3. 缓存选型:优先用 Redis(支持过期时间、分布式锁),避免用本地内存缓存(多实例无法共享,易导致重复刷新);
  4. 权限控制:严格保管 appid 和 appsecret,避免泄露给第三方(第三方可能恶意刷新 token)。

五、代码示例(Java 伪代码)

public class WxAccessTokenManager {
    private static final String CACHE_KEY = "wx_service_access_token:%s"; // %s 替换为 appid
    private static final String LOCK_KEY = "wx_access_token_lock:%s";
    private static final int CACHE_EXPIRE = 5400; // 1.5小时
    private static final int LOCK_EXPIRE = 30; // 锁过期时间30秒
    private RedisTemplate redisTemplate;
    private WxConfig wxConfig; // 存储 appid、appsecret

    // 获取有效 access_token
    public String getValidAccessToken() {
        String appid = wxConfig.getAppid();
        String cacheKey = String.format(CACHE_KEY, appid);

        // 1. 先查缓存
        String token = (String) redisTemplate.opsForValue().get(cacheKey);
        if (token != null && checkTokenValid(token)) {
            return token;
        }

        // 2. 缓存无效,获取分布式锁刷新
        String lockKey = String.format(LOCK_KEY, appid);
        String lockValue = UUID.randomUUID().toString();
        boolean lockAcquired = false;
        try {
            // 尝试获取锁(NX=不存在才设置,EX=30秒过期)
            lockAcquired = redisTemplate.opsForValue().setIfAbsent(lockKey, lockValue, LOCK_EXPIRE, TimeUnit.SECONDS);
            if (lockAcquired) {
                // 3. 再次查缓存(避免锁等待期间已被其他实例刷新)
                token = (String) redisTemplate.opsForValue().get(cacheKey);
                if (token == null || !checkTokenValid(token)) {
                    // 4. 调用微信接口刷新 token
                    token = refreshTokenFromWx();
                    // 5. 存入缓存
                    redisTemplate.opsForValue().set(cacheKey, token, CACHE_EXPIRE, TimeUnit.SECONDS);
                }
                return token;
            } else {
                // 未获取到锁,等待后重试
                Thread.sleep(200);
                return getValidAccessToken(); // 递归重试(或循环重试2-3次)
            }
        } finally {
            // 释放锁(仅释放自己持有锁)
            if (lockAcquired) {
                String currentLockValue = (String) redisTemplate.opsForValue().get(lockKey);
                if (lockValue.equals(currentLockValue)) {
                    redisTemplate.delete(lockKey);
                }
            }
        }
    }

    // 校验 token 有效性(调用微信接口)
    private boolean checkTokenValid(String token) {
        String url = String.format("https://api.weixin.qq.com/cgi-bin/get_api_domain_ip?access_token=%s", token);
        try {
            String response = HttpUtil.get(url); // 简化的 HTTP 工具
            JSONObject json = JSONObject.parseObject(response);
            return json.getJSONArray("ip_list") != null; // 有 ip_list 则有效
        } catch (Exception e) {
            return false; // 网络异常视为无效
        }
    }

    // 调用微信接口刷新 token
    private String refreshTokenFromWx() {
        String url = String.format(
            "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s",
            wxConfig.getAppid(), wxConfig.getAppsecret()
        );
        String response = HttpUtil.get(url);
        JSONObject json = JSONObject.parseObject(response);
        if (json.containsKey("access_token")) {
            return json.getString("access_token");
        } else {
            throw new RuntimeException("刷新 access_token 失败:" + response);
        }
    }

    // 业务接口调用示例(带失败重试)
    public String callWxApi(String apiUrl) {
        String token = getValidAccessToken();
        String fullUrl = apiUrl + "?access_token=" + token;
        try {
            String response = HttpUtil.get(fullUrl);
            JSONObject json = JSONObject.parseObject(response);
            if (json.containsKey("errcode") && (json.getIntValue("errcode") == 40001 || json.getIntValue("errcode") == 42001)) {
                // token 失效,强制刷新后重试
                redisTemplate.delete(String.format(CACHE_KEY, wxConfig.getAppid()));
                token = getValidAccessToken();
                fullUrl = apiUrl + "?access_token=" + token;
                return HttpUtil.get(fullUrl);
            }
            return response;
        } catch (Exception e) {
            throw new RuntimeException("调用微信接口失败", e);
        }
    }
}

总结

处理 access_token 提前失效的核心是 “不依赖固定过期时间,而是通过 “提前缓存过期 + 原子化刷新 + 有效性校验 + 失败重试” 形成闭环”,既保证了 token 的可用性,又避免了重复刷新和接口滥用。实际落地时,优先使用 Redis 实现缓存和分布式锁,再结合微信的校验接口兜底,可彻底解决 “他人刷新导致本地缓存失效” 的问题。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

canjun_wen

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值