OAuth2 资源服务器 JWT 验签 CPU 飙高?Nimbus JWKSet 缓存与并发读取优化
大家好!今天我们来聊聊 OAuth2 资源服务器中 JWT 验签环节 CPU 飙高的问题,以及如何利用 Nimbus JOSE+JWT 库提供的 JWKSet 缓存机制进行优化,特别是在高并发场景下的处理。
问题背景:JWT 验签的性能瓶颈
在 OAuth2 协议中,资源服务器负责验证客户端提供的 JWT(JSON Web Token)的合法性,以确保客户端拥有访问受保护资源的权限。这个验证过程的核心步骤就是使用与 JWT 签名算法对应的公钥进行验签。
通常,资源服务器会从授权服务器(Authorization Server)获取用于验签的公钥。这些公钥以 JWKSet(JSON Web Key Set)的形式存在,它是一个包含多个 JWK(JSON Web Key)的 JSON 文档。资源服务器需要根据 JWT 的 kid (Key ID) 字段,在 JWKSet 中找到对应的公钥,然后进行验签。
在高并发的场景下,每次收到 JWT 都去授权服务器获取 JWKSet,会给授权服务器带来巨大的压力,并且显著增加资源服务器的延迟。因此,缓存 JWKSet 是一个非常常见的优化手段。然而,不当的缓存策略和并发处理方式,反而会导致 CPU 飙高,适得其反。
CPU 飙高的常见原因
-
频繁的 JWKSet 获取: 即使进行了缓存,如果缓存失效时间设置过短,或者缓存失效策略不合理,仍然会导致资源服务器频繁地向授权服务器请求 JWKSet。频繁的网络请求和 JSON 解析会消耗大量的 CPU 资源。
-
同步的 JWKSet 获取: 如果 JWKSet 的获取是同步的(例如,使用
synchronized关键字或者阻塞式的锁),在高并发场景下,大量的请求会阻塞在 JWKSet 获取的环节,导致 CPU 上下文切换频繁,CPU 利用率升高。 -
不合理的缓存数据结构: 使用不适合高并发场景的数据结构(例如,普通的
HashMap)作为缓存,会导致在并发读写时出现竞争,增加 CPU 开销。 -
不必要的异常处理: 在 JWKSet 获取失败或者 JWT 验签失败时,如果抛出大量的异常,会导致 JVM 花费大量的时间进行栈追踪和异常处理,也会增加 CPU 消耗。
Nimbus JOSE+JWT 库的 JWKSet 缓存机制
Nimbus JOSE+JWT 库提供了一套相对完善的 JWKSet 缓存机制,可以有效地缓解上述问题。它主要涉及 JWKSource 接口和 RemoteJWKSet 类。
-
JWKSource 接口:
JWKSource是一个接口,定义了获取 JWK 的方法。资源服务器通过实现JWKSource接口,可以自定义 JWK 的获取方式,例如从本地文件读取、从数据库读取或者从远程服务器获取。 -
RemoteJWKSet 类:
RemoteJWKSet是JWKSource接口的一个实现,它负责从远程 URL 获取 JWKSet,并进行缓存。RemoteJWKSet内部使用了ConcurrentHashMap来存储 JWK,可以支持高并发的读取。
代码示例:使用 RemoteJWKSet 进行 JWT 验签
下面是一个简单的代码示例,展示了如何使用 RemoteJWKSet 进行 JWT 验签:
import com.nimbusds.jose.*;
import com.nimbusds.jose.crypto.RSASSAVerifier;
import com.nimbusds.jose.jwk.*;
import com.nimbusds.jose.jwk.source.*;
import com.nimbusds.jose.proc.*;
import com.nimbusds.jwt.*;
import com.nimbusds.jwt.proc.*;
import java.net.URL;
import java.security.interfaces.RSAPublicKey;
public class JWTVerifier {
private final JWKSource<SecurityContext> jwkSource;
public JWTVerifier(String jwkSetURL) throws Exception {
this.jwkSource = new RemoteJWKSet<>(new URL(jwkSetURL));
}
public boolean verifyToken(String jwtString) {
try {
JWT jwt = JWTParser.parse(jwtString);
JWSKeySelector<SecurityContext> keySelector =
new JWSVerificationKeySelector<>(JWSAlgorithm.RS256, jwkSource); // Or any other JWSAlgorithm
ConfigurableJWTProcessor<SecurityContext> jwtProcessor =
new DefaultJWTProcessor<>();
jwtProcessor.setJWSKeySelector(keySelector);
// Optional: Set required claims
// jwtProcessor.setJWTClaimsSetVerifier(new DefaultJWTClaimsSetVerifier<>(null, null));
SecurityContext ctx = null; // optional context parameter, not required here
JWTClaimsSet claimsSet = jwtProcessor.process(jwt, ctx);
// Token is valid
return true;
} catch (Exception e) {
// Token verification failed
e.printStackTrace(); // In production, log the exception instead of printing
return false;
}
}
public static void main(String[] args) throws Exception {
// Replace with your JWK Set URL
String jwkSetURL = "https://your-auth-server.com/oauth2/jwks";
JWTVerifier verifier = new JWTVerifier(jwkSetURL);
// Replace with your JWT token
String jwtToken = "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0xMjMiLCJ0eXAiOiJKV1QifQ.eyJzdWIiOiJ1c2VyMTIzIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNjc4ODg4ODg4fQ.signature";
boolean isValid = verifier.verifyToken(jwtToken);
if (isValid) {
System.out.println("JWT is valid!");
} else {
System.out.println("JWT is invalid!");
}
}
}
在这个示例中,RemoteJWKSet 会自动从指定的 URL 获取 JWKSet,并将其缓存在内存中。当需要验证 JWT 时,RemoteJWKSet 会首先从缓存中查找对应的公钥。如果缓存中没有找到,才会向远程服务器发起请求。
进一步优化:缓存配置和并发读取
虽然 RemoteJWKSet 提供了基本的缓存机制,但在高并发场景下,仍然需要进行一些优化,以避免 CPU 飙高。
-
合理的缓存失效时间: 缓存失效时间需要根据实际情况进行调整。如果 JWKSet 的更新频率较低,可以适当增加缓存失效时间,减少对授权服务器的请求。反之,如果 JWKSet 的更新频率较高,则需要缩短缓存失效时间,以避免使用过期的公钥进行验签。
RemoteJWKSet允许你设置缓存失效时间和刷新频率。 -
异步的 JWKSet 获取:
RemoteJWKSet默认是同步获取 JWKSet 的。在高并发场景下,可以考虑使用异步的方式获取 JWKSet,避免阻塞大量的请求。这可以通过自定义JWKSource实现,并使用ExecutorService等线程池来异步地获取 JWKSet。 -
避免不必要的异常: 在 JWT 验签过程中,尽量避免抛出不必要的异常。例如,如果 JWT 的
kid字段不存在于 JWKSet 中,可以返回一个特定的错误码,而不是抛出一个异常。 -
使用本地缓存: 如果JWKSet非常稳定,可以考虑将JWKSet缓存在本地文件或者数据库中。这样可以完全避免对授权服务器的依赖,进一步提高性能。
代码示例:自定义 JWKSource 实现异步获取
下面是一个代码示例,展示了如何自定义 JWKSource 实现异步获取 JWKSet:
import com.nimbusds.jose.*;
import com.nimbusds.jose.jwk.*;
import com.nimbusds.jose.jwk.source.*;
import com.nimbusds.jose.proc.*;
import java.net.URL;
import java.util.List;
import java.util.concurrent.*;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class AsyncRemoteJWKSet implements JWKSource<SecurityContext> {
private final URL jwkSetURL;
private final ExecutorService executorService;
private final long refreshInterval; // in milliseconds
private volatile List<JWK> jwkList;
private volatile long lastRefreshTime;
private final ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
public AsyncRemoteJWKSet(URL jwkSetURL, ExecutorService executorService, long refreshInterval) {
this.jwkSetURL = jwkSetURL;
this.executorService = executorService;
this.refreshInterval = refreshInterval;
this.lastRefreshTime = 0;
refreshJWKSet(); // Initial refresh
}
@Override
public List<JWK> get(JWKSelector jwkSelector, SecurityContext securityContext) throws JOSEException {
// Check if refresh is needed
if (System.currentTimeMillis() - lastRefreshTime > refreshInterval) {
executorService.submit(this::refreshJWKSet); // Asynchronous refresh
}
// Read from cache
readWriteLock.readLock().lock();
try {
if (jwkList == null || jwkList.isEmpty()) {
return null; // Or throw an exception, depending on your requirements
}
return jwkSelector.select(jwkList);
} finally {
readWriteLock.readLock().unlock();
}
}
private void refreshJWKSet() {
// Use write lock to ensure exclusive access during refresh
readWriteLock.writeLock().lock();
try {
// Only refresh if it hasn't been refreshed recently by another thread
if (System.currentTimeMillis() - lastRefreshTime <= refreshInterval) {
return;
}
// Fetch JWK Set from remote URL
JWKSet jwkSet;
try {
jwkSet = JWKSet.load(jwkSetURL.toURI().toURL());
jwkList = jwkSet.getKeys();
lastRefreshTime = System.currentTimeMillis();
System.out.println("JWK Set refreshed successfully.");
} catch (Exception e) {
System.err.println("Failed to refresh JWK Set: " + e.getMessage());
// Consider logging the exception instead of printing it to the console
// Handle the error appropriately, e.g., retry, use a fallback, etc.
}
} finally {
readWriteLock.writeLock().unlock();
}
}
public static void main(String[] args) throws Exception {
// Configuration
URL jwkSetURL = new URL("https://your-auth-server.com/oauth2/jwks");
ExecutorService executorService = Executors.newFixedThreadPool(5); // Adjust thread pool size as needed
long refreshInterval = 60000; // 60 seconds
// Create AsyncRemoteJWKSet instance
AsyncRemoteJWKSet asyncJWKSet = new AsyncRemoteJWKSet(jwkSetURL, executorService, refreshInterval);
// Simulate JWT verification
for (int i = 0; i < 10; i++) {
// Create a dummy JWKSelector (replace with your actual logic)
JWKSelector jwkSelector = new JWKSelector() {
@Override
public List<JWK> select(List<JWK> jwks) {
// Return all JWKs for testing
return jwks;
}
};
try {
List<JWK> selectedKeys = asyncJWKSet.get(jwkSelector, null);
if (selectedKeys != null && !selectedKeys.isEmpty()) {
System.out.println("Successfully retrieved JWKs. Count: " + selectedKeys.size());
} else {
System.out.println("No JWKs found.");
}
} catch (JOSEException e) {
System.err.println("Error retrieving JWKs: " + e.getMessage());
}
Thread.sleep(5000); // Simulate some delay between requests
}
// Shutdown executor service
executorService.shutdown();
executorService.awaitTermination(10, TimeUnit.SECONDS);
}
}
在这个示例中,我们使用 ExecutorService 异步地刷新 JWKSet。get 方法首先检查是否需要刷新 JWKSet,如果需要,则提交一个刷新任务到线程池。然后,它从缓存中读取 JWKSet。使用了 ReadWriteLock 确保在刷新 JWKSet 时,不会有其他线程读取过期的 JWKSet。
表格:同步 vs. 异步 JWKSet 获取
| 特性 | 同步 JWKSet 获取 (RemoteJWKSet 默认) | 异步 JWKSet 获取 (AsyncRemoteJWKSet) |
|---|---|---|
| 并发性能 | 较低,易阻塞 | 较高,并发性好 |
| 资源利用率 | 较低,CPU 切换频繁 | 较高,CPU 利用率更高 |
| 实现复杂度 | 简单 | 较复杂 |
| 适用场景 | 低并发场景 | 高并发场景 |
选择合适的缓存数据结构
RemoteJWKSet 内部使用了 ConcurrentHashMap 来存储 JWK,这已经是一个相对不错的选择。ConcurrentHashMap 允许多个线程并发地读取数据,只有在写入数据时才会进行加锁。
但是,在某些极端情况下,ConcurrentHashMap 仍然可能成为瓶颈。例如,如果 JWKSet 非常大,或者 JWK 的更新非常频繁,ConcurrentHashMap 的性能可能会下降。
在这种情况下,可以考虑使用更高级的缓存数据结构,例如 Caffeine 或者 Guava Cache。这些缓存库提供了更多的配置选项,例如缓存大小、过期策略、驱逐策略等等,可以更好地满足不同的需求。
使用监控工具进行性能分析
在进行 JWKSet 缓存优化时,一定要使用监控工具进行性能分析。监控工具可以帮助你了解 CPU 使用率、内存使用率、网络延迟等等指标,从而找到性能瓶颈所在。
常用的监控工具包括:
-
JConsole: JDK 自带的监控工具,可以用来监控 JVM 的各种指标。
-
VisualVM: 一个功能强大的 JVM 监控工具,可以用来监控 CPU 使用率、内存使用率、线程状态等等。
-
Prometheus: 一个流行的开源监控系统,可以用来监控各种应用程序的指标。
一些需要注意的点
-
安全性: 确保从安全可靠的渠道获取 JWKSet。使用 HTTPS 协议,并验证授权服务器的证书。
-
回退机制: 在 JWKSet 获取失败时,提供回退机制,例如使用本地缓存或者默认的公钥。
-
错误处理: 在 JWT 验签失败时,记录详细的错误日志,方便排查问题。
-
可观测性: 添加监控指标,例如 JWKSet 的刷新次数、缓存命中率、验签失败次数等等,方便进行性能分析和故障排查。
快速概括几点
- 缓存是关键: 在资源服务器缓存 JWKSet,减少对授权服务器的请求。
- 异步刷新: 在高并发场景下,使用异步的方式刷新 JWKSet,避免阻塞请求。
- 监控分析: 使用监控工具进行性能分析,找到性能瓶颈。