代码 集群里 access_token 互相顶掉:40001/40014 的排查与中控刷新

2026-09-29 09:01:45

集群里 access_token 互相顶掉:40001/40014 的排查与中控刷新

从一条锯齿曲线说起

告警里 40001 的数量是锯齿形的:平静十几分钟,突然冒几十条,过一会儿自己好了。业务侧的描述更具体——用户点进页面白屏,刷新一下又能用;同一个接口,一部分请求成功,一部分返回 token 无效。

最反常的是时间对不上。日志里刚打印出来的 token,缓存 TTL 明明按 expires_in 设了 7200 秒,实际活不过三五分钟。翻调用日志能看到每个 Pod 都在调 /cgi-bin/token,来源 IP 一台一个样。八台机器各维护一份本地缓存、各自到点刷新,而平台那边只有一个 token 的位置。

平台侧只留了新老两个 token

微信 access_token 是公众号全局唯一接口调用凭据,有效期通过返回的 expires_in 传达,目前是 7200 秒之内的值。关键规则是:重复获取会让上一次获取的失效。官方建议用中控服务器统一获取和刷新,其他业务服务器只用中控给出来的 token,不各自刷新,否则容易冲突、token 覆盖影响业务。

内部的存储结构比“一个 token”再具体一点:只保留新老两个。短时间里连续调三次获取接口,最早那个立刻失效;老 token 的过期时间戳会被刷新成当前时间,但仍然保留大约 5 分钟的交替缓冲,让正在处理的请求平滑过渡。

集群里的现象到这里就能解释了。A 机器刷新拿到新 token,B 机器手里还攥着上一份,B 的请求开始报错;B 发现报错后自己也去刷,把 A 刚用上的顶掉,A 又开始报错。两边轮流刷,谁都活不长。

还有一个细节让问题看起来更“随机”:设备之间的时钟本来就有 1–2 分钟差异,所谓 5 分钟缓冲不是绝对值。所以同一批并发请求里,有的落在缓冲窗口内成功,有的失败——不是网络抖动,也不是代码里藏了个概率性 bug。

错误码挨个对一遍

微信

  • 40001,invalid credential, access_token is invalid or not latest。无效或不是最新的,通常就是账号 token 被别处的业务逻辑同时刷新掉了。官方给的解决方案是用 getAccessToken 或 getStableAccessToken 取新 token,并集中管理。
  • 42001,access_token expired,已经过期。

getStableAccessToken 有两种模式:普通模式下,有效期内重复调用不会更新 token,绝大多数场景用这个;强制刷新模式会让上次获取的 access_token 失效并返回新的,只在确实需要立刻换掉时用。该接口频率限制 1 万次/分钟,每天 50 万次。即便如此也建议照样搭中控服务器,减少对微信的访问流量,顺带突破频次限制。

企业微信

access_token 有效期 2 小时(7200s),获取接口有日调用频次限制。多节点集群同时刷新,旧 token 迅速失效,没来得及更新缓存的节点继续用旧值,就会打出大量 40014 invalid access_token。这些节点随后也发起刷新,很容易撞上企微的全局接口限频 45009——到这一步系统彻底丧失主动调用能力,问题已经不是 token,是连刷都刷不动。

支付宝

alipay.system.oauth.token(换取授权访问令牌接口)返回 access_token(40 位)、expires_in、refresh_token(40 位)、re_expires_in(单位秒)。刷新时用 refresh_token 调同一个接口,grant_type=refresh_token,refresh_token 参数必填。刷新后新 access_token 生效、原 access_token 立即失效,同时返回新的 refresh_token,原来的 refresh_token 作废。新 refresh_token 的截止时间不会重新计算,re_expires_in 会减少——到期时间从授权那一刻算起,不是从这次刷新算起。

对应错误码:

  • aop.invalid-auth-token:无效的访问令牌。
  • isv.refresh-token-invalid:刷新令牌错误或状态不对,可能已经被使用过。
  • isv.refreshed-token-invalid:刷新出来的令牌无效,需要用返回的刷新令牌再刷一次。
  • isv.refresh-token-time-out:刷新令牌过期。

存储上要求按 appId + uid + 单个 scope 建索引,否则 appid 之间令牌混用、不同 scope 的令牌互相覆盖,报错只会更难查。这套和下面要说的 access_token 中控不是一回事。

飞书

tenant_access_token 属于超级热点 key,缓存失效的瞬间多线程同时回源,很容易触发限流 429,需要互斥锁加双重检查。

中控刷新:一份缓存、一把锁、两次检查

把上面的症状收敛成一套结构。

调用链路:本地缓存 L1(进程内存,TTL 约 1 分钟)→ 分布式缓存 L2(Redis,唯一事实来源)→ 分布式锁 → 回源刷新 → 回写 Redis → 通知各节点更新 L1。L1 只是为了避免每个请求都打 Redis,TTL 要短,短到即使没收到通知也不会拿太久旧值。

锁用 Redis 的 SET NX EX 或 Redisson RLock,tryLock 等几秒,锁带自动释放(10s / 30s 量级),防止持锁进程挂掉后所有人卡死。

双重检查(DCL)是最容易被省掉、也最容易出问题的一步:抢到锁之后不要直接回源,先再读一次缓存。等锁的这几百毫秒里,前一个持锁者很可能已经把 token 写好并通知出去了,再读一次就能直接返回。没有这一步,N 个进程排队进锁就会刷新 N 次,等于把惊群从锁外面搬到了锁里面。

预过期缓冲:别把 expires_in 用满。微信 7200s 的例子,缓存 TTL 留 200–600s 余量。企微常见做法是 Redis 物理 TTL 设 7200s、业务逻辑有效期设 7000s(预留 200s),也有把 TTL 设成 115 分钟(预留 5 分钟)或 expires_in - 600s(预留 10 分钟)的。留这点时间是为了让刷新发生在 token 还有效的时候,而不是等它过期了才去补,避免“令牌真空期”。

定时主动刷新:起一个单实例任务(或者带锁的定时任务),间隔小于缓冲时长,主动把 token 换掉。缓存命中正常的情况下,绝大多数业务请求根本不会走到刷新路径。

被动兜底:业务请求收到 token 类错误码(微信 40001 / 42001、企微 40014)时,清掉缓存并触发一次抢锁刷新。这里要卡错误码范围,不能所有失败都清缓存——业务自身的参数错误、权限错误也会返回非零 code,照着清一遍只会让刷新次数暴涨。

抢锁失败的线程不要直接透传到平台,读一下旧缓存,或者短暂自旋等待,拿到新值再走。

监控看三样:token 刷新失败次数、缓存命中率、平台 API 响应耗时。再加一条阈值告警——1 小时内刷新超过 2 次就报,可能是并发碰撞,也可能是 token 泄露被别人在用。

PHP 版本

function getAccessToken(Redis $redis, string $appId, string $secret): string
{
    $cacheKey = "wx:token:{$appId}";
    if ($token = $redis->get($cacheKey)) {
        return $token;
    }

    $lockKey = "wx:token:lock:{$appId}";
    $lockVal = bin2hex(random_bytes(8));
    $locked  = $redis->set($lockKey, $lockVal, ['NX', 'EX' => 10]);

    if (!$locked) {
        // 没抢到锁:短自旋后读缓存,不要透传到微信
        usleep(200000);
        return $redis->get($cacheKey) ?: getAccessToken($redis, $appId, $secret);
    }

    try {
        // 双重检查:等锁期间前一个持锁者可能已经刷好了
        if ($token = $redis->get($cacheKey)) {
            return $token;
        }

        $resp = httpPostJson('https://api.weixin.qq.com/cgi-bin/stable_token', [
            'grant_type'    => 'client_credential',
            'appid'         => $appId,
            'secret'        => $secret,
            'force_refresh' => false, // 普通模式,不顶掉正在用的 token
        ]);

        if (empty($resp['access_token'])) {
            throw new RuntimeException('token refresh failed: ' . json_encode($resp));
        }

        // 7200s 里留 300s 缓冲,别用满
        $ttl = max(60, (int)$resp['expires_in'] - 300);
        $redis->setex($cacheKey, $ttl, $resp['access_token']);

        return $resp['access_token'];
    } finally {
        // 只删自己加的锁
        if ($redis->get($lockKey) === $lockVal) {
            $redis->del($lockKey);
        }
    }
}

Go 版本(核心部分)

func GetAccessToken(ctx context.Context, rdb *redis.Client, appID, secret string) (string, error) {
	key, lockKey := "wx:token:"+appID, "wx:token:lock:"+appID

	if v, err := rdb.Get(ctx, key).Result(); err == nil {
		return v, nil
	}

	ok, err := rdb.SetNX(ctx, lockKey, "1", 10*time.Second).Result()
	if err != nil {
		return "", err
	}
	if !ok {
		time.Sleep(200 * time.Millisecond)
		return rdb.Get(ctx, key).Result() // 抢锁失败读旧值
	}
	defer rdb.Del(ctx, lockKey)

	// 双重检查
	if v, err := rdb.Get(ctx, key).Result(); err == nil {
		return v, nil
	}

	resp, err := fetchStableToken(ctx, appID, secret, false)
	if err != nil || resp.AccessToken == "" {
		return "", fmt.Errorf("refresh failed: %w", err)
	}

	ttl := time.Duration(resp.ExpiresIn-300) * time.Second
	if ttl < time.Minute {
		ttl = time.Minute
	}
	rdb.Set(ctx, key, resp.AccessToken, ttl)
	return resp.AccessToken, nil
}

排查时敲的几条命令

APPID=wxxxxxxxxxxxxxxxx
SECRET=xxxxxxxxxxxxxxxx

# 普通获取接口:会顶掉正在使用的 token,线上排查慎用
curl -s "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=$APPID&secret=$SECRET"

# 稳定接口普通模式:有效期内不会更新 token,适合看当前状态
curl -s -X POST "https://api.weixin.qq.com/cgi-bin/stable_token" \
  -H 'Content-Type: application/json' \
  -d "{\"grant_type\":\"client_credential\",\"appid\":\"$APPID\",\"secret\":\"$SECRET\",\"force_refresh\":false}"

# 用拿到的 token 打一个只读接口,确认错误码落在哪一类
curl -s "https://api.weixin.qq.com/cgi-bin/getcallbackip?access_token=$TOKEN"

返回体里的 rid 带来源信息。40001 批量出现时,把 rid 和网关的出口 IP 对一遍,基本能定位到是哪个节点在刷。

日志侧先做错误码分布:

grep -o '"errcode":[0-9]*' app.log | sort | uniq -c | sort -rn | head

如果 40001 和 42001 同时出现,且时间戳呈锯齿状,八成就是并发刷新。

这套方案不覆盖的场景

中控只对“全局唯一、刷新即顶旧”的平台级凭证必要。用户级 OAuth2、支付宝用户授权是 refresh_token 轮换型:每次刷新都会换出新的 refresh_token,旧的立即作废,锁粒度要按 appId + uid + scope 拆细,refresh_token 必须持久化——丢了就只能让用户重新授权。这类凭证不能用“一个 appId 一把锁”的方式处理,粒度粗了就是互相顶。

微信支付 APIv3 不走 access_token,用的是商户私钥/证书签名与平台证书/公钥验签,本方案不要照搬过去。

留个待验证项

各平台的缓冲期长度和限频数值官方会调整,微信这边 5 分钟过渡、getStableAccessToken 的 1 万次/分钟与 50 万次/天,企微的 7200s 与日调用限制,都以最新文档为准,上线前拿实际响应里的 expires_in 和限频报错复核一遍。

参考文档:微信 access_token 使用说明。

推荐文章

程序员茄子在线接单