1. 项目背景与整体设计思路做过微信小程序后端对接的同学应该都有体会AccessToken 这玩意儿看着简单真到项目里落地的时候坑是一点都不少。我最近刚把一个 Spring Boot 服务和小程序做对接核心需求就是稳定地获取和管理 AccessToken中间踩了不少雷也总结了一些比较实用的方案。这篇文章就把整个思路和实现过程完整地梳理一遍从方案选型到代码落地再到线上排查尽量把每个环节讲透。先说清楚这个项目要解决什么问题。微信小程序的很多服务端接口比如获取用户手机号、发送订阅消息、生成小程序码、内容安全检测等都需要带上一个叫 AccessToken 的凭证。这个 Token 由微信服务端颁发有有效期默认是 7200 秒也就是两个小时。过期之后需要重新获取。听起来很简单对吧但问题在于这个 Token 是全局唯一的同一个 AppID 下多次调用获取接口会导致之前那个 Token 失效。这就意味着如果你在多台服务器上各自去获取或者每次请求都去拿一个新的就会出现互相顶掉的情况线上表现就是接口时不时报 Token 无效。所以这个项目的核心目标可以拆成三块第一用 Spring Boot 封装一个可靠的 AccessToken 获取逻辑第二做好缓存和过期管理避免频繁请求微信接口第三处理好并发和刷新问题保证多实例部署下也不会互相干扰。适合谁看呢如果你正在做小程序后端或者准备做对 Spring Boot 有一定了解但对微信这套凭证机制还不太熟那这篇内容应该能帮你少走不少弯路。整体设计上我采用的是“集中获取 本地缓存 定时刷新 分布式锁兜底”的思路。为什么这么设计因为 AccessToken 的获取接口本身有调用频率限制而且每次获取都会让旧 Token 失效所以绝对不能每次业务请求都去拿。缓存是必须的但缓存放在哪、怎么刷新、多实例怎么办这几个问题决定了方案的复杂度。我下面会一层一层拆开讲。2. 核心机制解析与关键细节2.1 AccessToken 的本质与获取接口AccessToken 本质上是微信服务端颁发的一个临时凭证你可以把它理解成一张两小时有效期的通行证。它的获取接口是https://api.weixin.qq.com/cgi-bin/token需要传三个参数grant_type固定为client_credentialappid是小程序的 AppIDsecret是小程序的 AppSecret。返回结果是一个 JSON包含access_token和expires_in两个字段。这里有个细节很多人会忽略expires_in返回的是 7200但实际有效时间并不是精确的 7200 秒。微信官方文档里也提到建议开发者提前刷新不要等到最后一刻。我一般会预留 300 秒的缓冲也就是实际缓存时间设为 6900 秒左右。这个缓冲很重要因为网络延迟、时钟偏差、微信服务端的状态同步都可能让 Token 提前失效。你如果卡着 7200 秒用线上大概率会遇到偶发的 Token 失效问题。还有一个关键点AppSecret 绝对不能放在前端或者客户端。它只能存在于服务端而且要做好保密。我见过有同学把 AppSecret 写在小程序代码里这是非常危险的一旦被反编译别人就能拿着你的 AppID 和 Secret 去获取 Token后果不用我多说。2.2 为什么不能每次请求都获取这个问题值得单独说一下。假设你的小程序日活一万每个用户操作都触发一次后端接口如果每次接口都去调微信的获取 Token 接口那一天的调用量就是几万次。微信对获取 Token 的接口是有频率限制的虽然官方没有明确公布具体数字但实测下来频繁调用很快就会被限流返回错误码 45009 或者 45011。更严重的是每次获取新 Token 都会让旧 Token 失效。如果你的服务是多实例部署实例 A 刚获取了一个 Token实例 B 又去获取了一个那实例 A 手里的 Token 就废了。用户请求打到实例 A就会报 Token 无效。这种问题在测试环境可能看不出来因为测试环境通常只有一个实例但一上生产就暴露了。所以正确的做法是全局维护一份 Token所有实例共享。获取操作要加锁保证同一时间只有一个实例去刷新。缓存要设置合理的过期时间并且有主动刷新的机制。这就是我前面说的“集中获取 本地缓存 定时刷新 分布式锁兜底”的由来。2.3 缓存方案选型本地缓存还是 Redis缓存放在哪这是个需要权衡的问题。本地缓存比如用 Caffeine 或者 Guava Cache优点是快没有网络开销实现简单。缺点是多个实例之间不共享每个实例都有一份自己的 Token如果各自刷新还是会互相顶掉。Redis 的优点是全局共享所有实例读同一份 Token天然解决了多实例问题。缺点是多了一次网络调用而且 Redis 本身也需要保证高可用。我的选择是Redis 作为主缓存本地缓存作为一级缓存做短时间的兜底。具体来说Token 存在 Redis 里设置过期时间。每个实例本地再用 Caffeine 缓存一份过期时间设短一点比如 60 秒。这样大部分请求直接读本地缓存只有本地缓存过期了才去读 Redis减少 Redis 的压力。刷新的时候用 Redis 的分布式锁保证只有一个实例去调微信接口刷新完成后更新 Redis其他实例等锁释放后重新读取。这个方案的好处是兼顾了性能和一致性。本地缓存 60 秒的过期时间意味着即使 Redis 挂了最多 60 秒内服务还是可用的。当然如果你的项目规模不大单实例部署那直接用本地缓存就够了不用上 Redis简单直接。方案选型一定要结合自己的实际情况不要为了技术而技术。3. 实操过程与核心代码实现3.1 项目依赖与基础配置先说一下项目环境。我用的是 Spring Boot 2.7 版本JDK 11Redis 用的是 Spring Data RedisHTTP 客户端用的是 OkHttp相比 RestTemplate 更轻量连接池管理也更方便。缓存用的是 Caffeine。下面是 Maven 的核心依赖我列了个表格方便对照。依赖用途备注spring-boot-starter-webWeb 框架基础依赖spring-boot-starter-data-redisRedis 操作用于分布式缓存和锁caffeine本地缓存一级缓存okhttpHTTP 客户端调用微信接口fastjson2JSON 解析解析返回结果lombok简化代码可选配置文件里需要配好 AppID、AppSecret 和 Redis 连接信息。我建议把这些敏感信息放在配置中心或者环境变量里不要直接写在 application.yml 里提交到代码仓库。下面是一个配置类的示例用ConfigurationProperties来绑定配置。Data Component ConfigurationProperties(prefix wechat.miniapp) public class WechatProperties { private String appId; private String appSecret; private String tokenUrl https://api.weixin.qq.com/cgi-bin/token; private int expireBuffer 300; }对应的 yml 配置大概是这样wechat: miniapp: app-id: ${WECHAT_APP_ID} app-secret: ${WECHAT_APP_SECRET} expire-buffer: 300这里用环境变量占位实际部署的时候通过环境变量注入这样代码仓库里就不会有敏感信息。expire-buffer就是前面说的提前刷新缓冲单位是秒。3.2 AccessToken 的获取与解析获取 Token 的核心逻辑就是发一个 GET 请求然后解析返回的 JSON。但这里有几个细节要注意。第一要处理网络异常和超时设置合理的连接超时和读取超时我一般设 3 秒和 5 秒。第二要处理微信返回的错误码比如 40001 表示 AppSecret 错误40013 表示 AppID 无效这些错误要能识别出来并记录日志。第三返回的 Token 要校验非空。下面是我封装的获取方法用 OkHttp 实现public String fetchAccessToken() { HttpUrl url HttpUrl.parse(properties.getTokenUrl()) .newBuilder() .addQueryParameter(grant_type, client_credential) .addQueryParameter(appid, properties.getAppId()) .addQueryParameter(secret, properties.getAppSecret()) .build(); Request request new Request.Builder().url(url).get().build(); try (Response response httpClient.newCall(request).execute()) { if (!response.isSuccessful() || response.body() null) { throw new RuntimeException(请求微信接口失败HTTP状态 response.code()); } String body response.body().string(); JSONObject json JSON.parseObject(body); Integer errCode json.getInteger(errcode); if (errCode ! null errCode ! 0) { throw new RuntimeException(获取AccessToken失败错误码 errCode 错误信息 json.getString(errmsg)); } String token json.getString(access_token); if (token null || token.isEmpty()) { throw new RuntimeException(获取AccessToken为空); } return token; } catch (IOException e) { throw new RuntimeException(获取AccessToken网络异常, e); } }这段代码里我把错误码和错误信息都带出来了方便排查。实际项目中你可能还需要根据不同的错误码做不同的处理比如 40001 说明 Secret 配错了这种是配置问题重试也没用应该直接告警。而网络超时这种是可以重试的。3.3 缓存与刷新逻辑的实现缓存这块我用 Redis 存 Tokenkey 设计成wechat:miniapp:access_token:{appId}这样如果以后有多个小程序可以区分开。value 就是 Token 字符串。过期时间设为expires_in - expireBuffer也就是 7200 减 300等于 6900 秒。本地缓存用 Caffeine过期时间设 60 秒最大容量设 10 就行因为一个 AppID 只有一个 Token。读取逻辑是先读本地缓存命中就返回没命中就读 RedisRedis 有就返回并写入本地缓存Redis 也没有就去获取新的 Token。刷新逻辑我做了两层保障。第一层是定时任务每隔一段时间检查 Redis 里的 Token 剩余有效期如果快过期了就主动刷新。第二层是懒加载当读取的时候发现 Redis 里没有 Token就触发一次获取。两层结合基本不会出现 Token 失效的情况。定时刷新的代码大概是这样Scheduled(fixedDelay 60_000) public void refreshTokenTask() { String key buildRedisKey(); Long ttl redisTemplate.getExpire(key, TimeUnit.SECONDS); if (ttl null || ttl properties.getExpireBuffer()) { refreshTokenWithLock(); } }这里用fixedDelay而不是fixedRate是因为fixedDelay是上一次执行完成后才计算下一次的间隔避免任务堆积。每分钟检查一次如果剩余有效期小于缓冲时间就触发刷新。3.4 分布式锁保证多实例安全多实例部署的时候必须保证同一时间只有一个实例去刷新 Token。我用 Redis 的setIfAbsent命令实现了一个简单的分布式锁。锁的 key 是wechat:miniapp:token:lock:{appId}value 是一个随机 UUID防止误删别人的锁。过期时间设 30 秒防止死锁。public void refreshTokenWithLock() { String lockKey buildLockKey(); String lockValue UUID.randomUUID().toString(); Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, lockValue, 30, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { try { String token fetchAccessToken(); long ttl 7200 - properties.getExpireBuffer(); redisTemplate.opsForValue().set(buildRedisKey(), token, ttl, TimeUnit.SECONDS); localCache.put(buildRedisKey(), token); } finally { // 释放锁校验value防止误删 String currentValue (String) redisTemplate.opsForValue().get(lockKey); if (lockValue.equals(currentValue)) { redisTemplate.delete(lockKey); } } } else { // 没抢到锁等一会儿重新读缓存 try { Thread.sleep(200); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }这里有个细节释放锁的时候要先 get 再判断 value 是否一致再 delete。虽然这不是原子操作但在实际场景下已经够用了。如果你追求更严谨可以用 Lua 脚本来保证原子性。不过对于 Token 刷新这种低频操作简单实现就够了。注意锁的过期时间要大于获取 Token 的最长耗时。如果获取 Token 的接口响应很慢超过了锁的过期时间锁自动释放了另一个实例就可能拿到锁去刷新导致并发。所以锁的过期时间要留足余量我设的 30 秒一般接口响应都在几百毫秒内足够了。4. 常见问题与排查技巧实录4.1 Token 失效的典型表现与原因线上最常见的问题就是接口突然报 Token 无效错误码通常是 40001 或者 42001。40001 是 Token 无效42001 是 Token 过期。遇到这个问题先别急着改代码按下面的顺序排查。第一检查是不是多实例各自刷新导致的。如果你没有用分布式锁或者锁没生效多个实例同时去获取 Token就会互相顶掉。排查方法是看日志里获取 Token 的时间戳如果短时间内有多次获取记录基本就是这个原因。第二检查缓存过期时间是不是设得太长。如果你把缓存时间设成了 7200 秒没有留缓冲那 Token 在微信服务端可能已经过期了但你的缓存还没过期就会用到失效的 Token。解决办法就是留 300 秒左右的缓冲。第三检查 AppSecret 是不是被重置过。如果在小程序后台重置了 Secret那旧的 Secret 获取的 Token 就会失效。这种情况需要更新配置并重启服务。第四检查是不是有多个环境共用同一个 AppID。比如测试环境和生产环境用了同一个 AppID测试环境频繁刷新 Token就会把生产环境的 Token 顶掉。这种情况一定要隔离环境用不同的 AppID。4.2 常见问题速查表我把实际遇到过的几个典型问题整理成了表格方便对照排查。问题现象可能原因排查方法解决方案接口报 40001 Token 无效多实例并发刷新查看获取 Token 的日志频率加分布式锁接口报 42001 Token 过期缓存时间设置过长检查缓存 TTL 配置预留 300 秒缓冲获取 Token 返回 40013AppID 配置错误核对配置文件修正 AppID获取 Token 返回 40001AppSecret 错误核对配置文件修正 Secret获取 Token 返回 45009调用频率超限查看调用次数减少调用加缓存服务启动后首次请求慢懒加载首次获取查看启动日志启动时预热 Token4.3 实操心得与避坑建议说几个我在实际项目中总结的经验。第一个服务启动的时候最好预热一下 Token也就是在ApplicationRunner里主动调一次获取逻辑把 Token 加载到缓存里。这样第一个用户请求就不会因为要等 Token 获取而变慢。预热的时候如果获取失败不要阻止服务启动记录日志就行等第一次业务请求再触发获取。第二个日志一定要打全。获取 Token 的成功和失败都要记录包括时间戳、耗时、错误码。我一般会在获取成功后打一条 INFO 日志记录 Token 的前几位和过期时间方便排查。失败的时候打 ERROR 日志把错误码和错误信息都带上。这样线上出问题的时候看日志就能快速定位。第三个监控和告警要跟上。Token 获取失败应该触发告警因为这意味着所有依赖 Token 的接口都会不可用。我一般会监控获取失败的次数连续失败超过 3 次就发告警。另外Token 的剩余有效期也可以监控如果长时间没有刷新可能定时任务挂了。第四个关于 HTTP 客户端的连接池。OkHttp 默认的连接池是 5 个空闲连接keep-alive 5 分钟。对于获取 Token 这种低频调用默认配置就够了。但如果你还用同一个客户端调其他微信接口可以适当调大连接池。另外记得设置超时我设的连接超时 3 秒读取超时 5 秒写入超时 5 秒。第五个关于 Token 的存储格式。我建议存字符串就行不要存 JSON。有些同学喜欢把 Token 和过期时间一起存成 JSON读取的时候再解析。这样虽然信息全但多了一次序列化和反序列化的开销。过期时间交给 Redis 的 TTL 来管理就够了不需要额外存。4.4 性能优化与扩展思路如果你的小程序规模比较大QPS 比较高可以考虑进一步优化。比如本地缓存的过期时间可以适当延长减少 Redis 的读压力。但要注意本地缓存越长Token 刷新后其他实例感知的延迟就越大。60 秒是一个比较平衡的值你可以根据自己的业务容忍度调整。另外如果你的项目有多个小程序可以把这套逻辑封装成一个通用的服务通过 AppID 来区分不同的 Token。缓存 key 和锁 key 都带上 AppID 前缀这样一套代码可以支持多个小程序。配置上可以用一个 Map 来存多个小程序的 AppID 和 Secret。还有一个扩展点是 Token 的降级策略。如果 Redis 挂了本地缓存还能撑 60 秒。如果本地缓存也过期了获取 Token 又失败这时候可以考虑返回一个明确的错误让上游知道是凭证问题而不是直接抛异常。这样上游可以做针对性的处理比如提示用户稍后重试。最后说一个我踩过的坑。有一次线上突然大量报 Token 无效排查了半天发现是运维同学在另一台机器上部署了一个旧版本的服务那个版本没有用分布式锁而且缓存时间设的是 7200 秒。它一启动就去获取了 Token把正常的 Token 顶掉了。所以版本管理和部署规范也很重要确保所有实例用的是同一套逻辑。这套方案我在两个项目里都用过一个是单实例的小项目直接用本地缓存简单稳定另一个是多实例的项目用了 Redis 加分布式锁跑了半年多没出过 Token 相关的问题。核心就是理解 Token 的全局唯一性和过期机制然后针对性地做缓存和并发控制。代码不复杂但细节决定成败。