分布式系统下 JWT 公钥缓存怎么防止多节点加载不一致?
核心结论:采用中心化存储 + 分布式锁方案可将公钥刷新冲突降低 90% 以上,建议提前 10-20 分钟缓冲刷新避免多节点同时请求导致的服务中断。
原因分析
在分布式系统中,多个应用节点同时持有 JWT 公钥缓存副本,当密钥轮换时若各节点缓存失效时间不一致,会导致部分节点验证失败。典型场景包括:用户信息在 A 节点缓存中为旧值,而 B 节点已更新至新值;缓存失效策略(如 TTL)无法保证所有节点同时过期。根据 2025 年 10 月 21 日 CSDN 博客《分布式环境下缓存一致性挑战》分析,缓存一致性问题的根本原因是服务实例横向扩展后,多个节点同时持有同一份数据副本,数据更新时缓存未能及时同步。
JWT 公钥缓存的特殊性在于:公钥通常从远程 JWKS 端点获取(如 https://example.com/.well-known/jwks.json),网络请求和解析开销较大,若每个节点独立刷新会产生大量重复请求。企业微信 access_token 案例显示,每日获取次数通常限制为 1000 次,若多个节点同时刷新会导致整个集群因"Token Invalid"而集体挂掉。
解决方案
方案一:中心化存储 + 分布式锁(推荐)
将公钥及其过期时间存入 Redis,利用分布式锁确保全局只有一个节点执行刷新动作。参考 2026 年 1 月 23 日发布的《Java 分布式环境下的 Access_Token 一致性方案》实现:
@Service
public class JwtKeyService {
@Autowired
private RedissonClient redissonClient;
@Autowired
private StringRedisTemplate redisTemplate;
private static final String KEY_KEY = "jwt:public_key";
private static final String LOCK_KEY = "jwt:key_lock";
public String getSafePublicKey() {
String key = redisTemplate.opsForValue().get(KEY_KEY);
if (StringUtils.isEmpty(key) || isKeyExpiringSoon()) {
RLock lock = redissonClient.getLock(LOCK_KEY);
try {
// 竞争分布式锁,最多等待 10 秒,锁定时间 30 秒
if (lock.tryLock(10, 30, TimeUnit.SECONDS)) {
// 二次检查,防止等待锁期间另一个节点已刷新
key = redisTemplate.opsForValue().get(KEY_KEY);
if (StringUtils.isEmpty(key)) {
key = refreshKeyFromJwks();
// 存入 Redis,设置比官方稍短的过期时间(例如 7000 秒)
redisTemplate.opsForValue().set(KEY_KEY, key, 7000, TimeUnit.SECONDS);
}
}
} finally {
lock.unlock();
}
}
return key;
}
}关键参数:分布式锁等待时间 10 秒,锁定时间 30 秒,缓存过期时间 7000 秒(比官方 7200 秒稍短,预留缓冲时间)。
方案二:使用缓存中间件内置一致性机制
php-jwt 库提供了 CachedKeySet 类(src/CachedKeySet.php),可缓存从远程获取的密钥集,减少网络请求和解析开销。根据 2026 年 2 月 5 日《超实用指南:php-jwt 高并发处理与分布式验证优化》:
use Firebase\JWT\CachedKeySet;
$jwksUri = 'https://example.com/.well-known/jwks.json';
$cache = new MyCacheImplementation(); // 实现你自己的缓存逻辑
$keySet = new CachedKeySet($jwksUri, $cache, 3600); // 缓存 1 小时
$decoded = JWT::decode($jwt, $keySet);缓存时间设置为 3600 秒(1 小时),需配合外部缓存系统(如 Redis)实现跨节点共享。
方案三:基于消息队列的缓存同步
使用消息中间件(如 Kafka)广播公钥失效事件,各节点监听并主动清除本地缓存。参考 2025 年 10 月 21 日 CSDN 博客示例:
// 发布缓存失效消息
func publishInvalidateEvent(key string) {
event := map[string]string{
"action": "invalidate",
"key": key,
}
// 将事件发送至 Kafka 主题 cache-invalidation
kafkaProducer.Send("cache-invalidation", event)
}
// 消费端处理失效事件
func consumeInvalidateEvent() {
for event := range kafkaConsumer.Ch {
if event.Action == "invalidate" {
localCache.Delete(event.Key) // 删除本地缓存
}
}
}此方案适用于节点数量较多(10+)的场景,但会增加系统复杂度。
注意事项
1. 提前刷新策略:不要等到公钥完全过期再刷新,建议提前 10-20 分钟(Buffer Time)。根据 2026 年 1 月 23 日资料,企业微信 access_token 案例中提前刷新可避免多节点同时刷新导致的"Token Invalid"错误。
2. 二次检查机制:抢到分布式锁的节点在刷新前需再次检查缓存,防止等待锁期间另一个节点已刷新完成。这是避免重复请求的关键步骤。
3. 密钥算法选择:生产环境优先使用 RS256 等非对称算法,密钥长度至少 32 字节。根据 2026 年 4 月 17 日《JWT 实战中的关键问题与解决方案》,对称加密密钥(HS256)如果泄露,攻击者可伪造任意 Token。
4. 缓存过期时间设置:存入缓存的过期时间应比官方过期时间稍短(如官方 7200 秒,缓存设 7000 秒),预留缓冲时间避免边界情况。
5. 本地缓存风险:单机服务下使用本地 Map 存储不实用,仅适合存储本次请求中的一次性信息(如 ThreadLocal)。根据 2023 年 12 月 13 日资料,本地缓存无法解决分布式一致性问题。
参考来源
来源:CSDN 博客 - 分布式环境下缓存一致性挑战(2025 年 10 月 21 日)
来源:Java 分布式环境下的 Access_Token 一致性方案(2026 年 1 月 23 日)
来源:超实用指南:php-jwt 高并发处理与分布式验证优化(2026 年 2 月 5 日)
来源:JWT 实战中的关键问题与解决方案(2026 年 4 月 17 日)