资讯中心

Spring Security OAuth2性能优化实战:从JWT到Redis的调优指南

📅 2026/9/28 12:41:15
Spring Security OAuth2性能优化实战:从JWT到Redis的调优指南
1. 为什么OAuth2性能问题总是上线前最后一刻才暴露先说一个我真实踩过的坑有次给一个内部账号体系做集中认证服务用的Spring Security OAuth2搭的授权服务器。开发阶段联调顺畅功能测试全绿结果压测一上直接把服务器打到内存溢出。问题的根子不在JVM参数也不在业务代码而是我压根没做过任何OAuth2层面的性能设计。Token怎么存、JWT怎么验、密钥怎么加载、并发刷新怎么处理这些全用默认配置。默认配置能跑通功能但扛不住流量。这个经历让我把Spring Security OAuth2从能用到扛得住整个过程重新捋了一遍。这篇文章不打算重复文档里有的内容我想拆解的是高性能OAuth2认证/授权服务到底哪些环节最容易被忽略、哪些优化性价比最高、以及监控应该盯什么。适合谁看如果你正在做基于Spring Security OAuth2的认证服务或者公司已经有OAuth2机制但压测不达标又或者你想在Spring Boot 3Spring Security 6这套新组合下做性能调优这篇文章应该是能直接抄作业的。2. 先分清你的OAuth2实现JWT模式与TokenStore模式的性能分水岭很多人拿到OAuth2就开码但没有先想清楚一个核心问题你的access_token是JWT自包含格式还是走传统的不透明Token加TokenStore持久化这两种模式的性能特征完全不一样优化方向和监控指标也完全不一样。2.1 JWT模式的性能瓶颈不是Token生成而是每次验签JWT模式最大的性能优势是资源服务器不需要查库收到Token本地验签就行。但本地验签这四个字是最大的认知偏差。如果授权服务器和资源服务器是分离部署的资源服务器为了校验JWT签名需要先去授权服务器拉取JWKSJSON Web Key Set也就是公钥列表。第一次请求没缓存按需去拉这个链路放在高并发下就是灾难。# 典型的表现是资源服务器日志里频繁出现 # Remote JWKS fetch failed / unable to fetch keyset我见过一个项目QPS压到300多资源服务器因为反复拉JWKS把授权服务器的/oauth2/jwks接口打到接近超时。往后延时从30ms直接飙到2秒以上。正确的做法是把JWKS缓存做在本地并且配上合理的刷新策略。Spring Security默认对RemoteJWKSet有5分钟缓存但这个默认值在弱网或高并发场景下不够稳尤其是多实例部署时Configuration public class JwkConfig { Bean public NimbusJwtDecoder jwtDecoder(OAuth2ResourceServerProperties properties) { // 基于配置的JWK Set URI构建解码器 NimbusJwtDecoder decoder NimbusJwtDecoder .withJwkSetUri(properties.getJwt().getJwkSetUri()) .build(); // 调小超时避免认证服务悬挂时资源服务器跟着卡住 ((NimbusJwtDecoder) decoder).setJwtValidator(new DelegatingOAuth2TokenValidator( new JwtTimestampValidator(), new JwtIssuerValidator(properties.getJwt().getIssuerUri()) )); return decoder; } Bean public JwtDecoder jwsDecoderWithCache() { return NimbusJwtDecoder .withJwkSetUri(http://auth-service/oauth2/jwks) .jwtProcessorCustomizer(customizer - { // 显式配置缓存刷新周期600秒最少保留300秒 customizer.setJWKSource(new RemoteJWKSet( URI.create(http://auth-service/oauth2/jwks), // 连接超时200ms Duration.ofMillis(200), // 读取超时200ms Duration.ofMillis(200), // 单个key的缓存时间 Duration.ofSeconds(600), // key集的最小刷新间隔 Duration.ofSeconds(300) )); }) .build(); } }这里有几个参数值得专门说。连接超时和读取超时不能照搬默认配置默认值偏大在链路抖动时会把资源服务器的线程池拖垮。我一般把连接超时压到200ms以内读取超时300ms以内。一旦授权服务器久无响应资源服务器要快速失败而不是让线程吊在那里干等。JWKS的缓存刷新周期也要看发布的频率。如果你平时不怎么轮换密钥5分钟和15分钟差别不大但如果你有定期轮换密钥的需求刷新周期就要和密钥轮换周期匹配否则会出现旧key缓存过期、新key还没拉下来的间隙。我建议密钥轮换周期不要低于缓存周期的两倍。2.2 不透明Token模式TokenStore选型决定数据库压力不透明Token模式的逻辑是授权服务器生成一个随机Token把Token和用户信息存到存储端资源服务器每次都要拿着Token回来查库/查缓存。写Token、读Token、刷新Token、吊销Token全部打在存储上。这里最大的性能陷阱是JdbcTokenStore的单表热点。默认的JdbcTokenStore把所有Token塞进oauth_access_token和oauth_refresh_token两张表。单表在低流量下没问题但Token量上去了表膨胀以后查询性能和清理性能都会出问题-- 默认查询按token_id检索本身有索引问题不大 SELECT user_name, client_id, authentication FROM oauth_access_token WHERE token_id ?; -- 但批量清理是按过期时间扫的token量大了以后扫表就是灾难 DELETE FROM oauth_access_token WHERE expires_at NOW() LIMIT 1000;如果你决定用JdbcTokenStore几个优化建议对expires_at字段必须建索引否则清理任务每跑一次就是一次全表扫描定期清理任务别在业务高峰期跑凌晨跑否则锁表能把认证接口全拖死如果Token量增速很快尽早换Redis方案。Redis方案在Spring Boot 3/Spring Security 6里已经非常简单了直接用RedisIndexedSessionRepository的思路来管理Token也不是不行但我更推荐直接上RedisTokenStore的思路自己在TokenStore接口下写一个Redis实现Spring本身就预留了TokenStore的扩展点。2.3 混合方案短期JWT加长期Refresh Token我最终在线上比较推荐的是混合方案access_token用JWTrefresh_token用不透明Token并存Redis。理由是JWT虽然免查库但一旦产生吊销需求比如用户改密码、被踢下线JWT在过期之前无法立即生效。纯不透明Token能吊销但每次验Token都查库扛不住。混合方案刚好折中——高频验证走JWT验签低频换发走Redis查refresh_token两者各管一段。这个方案在Spring Security 6的授权服务器配置里也比较好落地需要自己覆盖OAuth2TokenEndpointConfigurer的逻辑在生成Token时分别处理access token和refresh token的存储策略。代码量不大但性能收益非常直观。压测数据对比纯JdbcTokenStore在500 QPS时数据库连接池已经打满混合方案把刷新换发也放到Redis之后1200 QPS时数据库基本无感。3. 配置层调优从Boot 2迁移到Boot 3之后最容易被忽略的性能点Spring Boot 3和Spring Security 6不是简单的版本升级整个OAuth2的实现方式从EnableAuthorizationServer那种老写法变成了全新的AuthorizationServerConfiguration体系。很多人迁移之后发现功能是通了但性能上埋了一些雷。3.1 Spring Boot 3中Spring Security配置迁移的几个隐性变化老项目中WebSecurityConfigurerAdapter的写法在Spring Security 6里彻底移除了。现在统一用SecurityFilterChain组件方式声明式配置。这个迁移表面上只是写法变化但有一些行为差异直接影响性能。最典型的是session创建策略。老写法里如果不注意sessionCreationPolicy默认可能是IF_REQUIRED意味着请求过程中如果需要session容器会创建session。对接口型OAuth2认证服务来说这几乎是无意义的开销。Bean public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { http .csrf(csrf - csrf.disable()) .cors(cors - cors.configurationSource(corsConfigurationSource())) .sessionManagement(session - session .sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) .authorizeHttpRequests(auth - auth .requestMatchers(/oauth2/**, /.well-known/**, /actuator/**).permitAll() .anyRequest().authenticated() ) .oauth2ResourceServer(oauth2 - oauth2 .jwt(jwt - jwt.decoder(jwtDecoder())) ) .exceptionHandling(ex - ex .authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint(/oauth2/authorize)) ); return http.build(); }加上.sessionCreationPolicy(SessionCreationPolicy.STATELESS)之后Spring容器不会再为每次请求创建HttpSession这能省掉不少内存和序列化开销。如果你是纯接口型认证服务这句话一定要加。另一个隐性开销是csrf。内部服务间调用如果是无状态接口CSRF保护是纯多余的开销。虽然校验逻辑本身不算重但每个请求都要多走一段过滤器链放大到几十台资源服务器后也不容忽视。3.2 过滤链上那些看不见的耗时Spring Security 6的过滤器链默认包含了一长串过滤器从DisableEncodeUrlFilter到AuthorizationFilter十几个过滤器每个都走一遍。有些过滤器在你的场景下根本没启用但默认的HttpSecurity构建过程中还是会被装配。做过性能剖析后你会发现最耗时的往往不在Spring Security自己的过滤器逻辑里而在于每个过滤器触发的下游调用。比如BearerTokenAuthenticationFilter解析出JWT后JwtAuthenticationProvider要做的校验包括签名校验、过期时间校验、Issuer校验、Audience校验如果还配置了RemoteJWKSet刷新策略那么第一次验签时还要触发一次远程JWKS拉取。我建议这么优化确认不用到的过滤器直接关闭比如csrf、logout、rememberMe、sessionManagement相关的内置配置把oauth2ResourceServer放在尽可能靠后的位置因为认证过滤器本身是最耗时的环节最好让前面的静态资源、健康检查先短路多实例部署时给每个实例本地配置同样的JWKS缓存不要让多个实例同时拉授权服务器的JWKS接口。这些都是配置层面的小改动积少成多压测时能看出差别。3.3 线程池与并发参数Tomcat和Security之间的配合认证服务是高并发入口Tomcat线程池参数直接影响整体吞吐。我之前习惯性只调server.tomcat.threads.max以为把线程数调大就能扛住流量但忽略了accept-count和max-connections这些排队参数的作用。比较合理的调法# Tomcat配置 server.tomcat.threads.max200 server.tomcat.threads.min-spare20 server.tomcat.accept-count1000 server.tomcat.max-connections10000 server.tomcat.connection-timeout5000ms线程数和连接数不能盲目放大。线程太多上下文切换开销反而拖垮CPUaccept-count太小突发流量来了请求直接在TCP层被丢弃accept-count太大又会让你在监控里误以为系统很健康其实后面线程池早就满了请求全在排队。Spring Security侧的并发参数主要藏在AuthenticationManager的provider里。如果你自定义了UserDetailsService记得去看用户的加载逻辑是否每次请求都去查库。OAuth2的client认证走的是DaoAuthenticationProvider默认会走InMemoryUserDetailsManager或JdbcUserDetailsManager这里很容易漏掉缓存。我倾向于把client信息缓存一层用Caffeine做个5分钟的本地缓存缓存的key就是clientId。毕竟client信息变化频率极低不值得每次认证都去查一次库。4. 监控体系建设不只是看TPS要看每个环节的真实耗时性能优化做没做对得靠监控数据说话。OAuth2服务的监控不能只看整体TPS必须把授权码模式、密码模式、刷新Token这几个核心路径拆开来看。4.1 Micrometer指标埋点盯住这几个关键度量Spring Boot 3对Micrometer的集成已经非常完善。在Spring Security OAuth2授权服务器里我建议至少埋这几类指标第一类是接口级耗时。通过/actuator/metrics暴露的http.server.requests按URI维度拆分看/oauth2/token、/oauth2/authorize、/oauth2/jwks、/oauth2/introspect这4个接口是核心。第二类是Spring Security内部的认证事件。org.springframework.security.authentication.event相关的指标能看认证成功和失败次数失败次数突增往往是暴力破解或者密钥轮换导致大量验签失败的信号。第三类是OAuth2自身的规模指标当前有效Token数量、刷新Token数量、过期Token数量。这些指标在Redis方案里可以很轻松地通过SCARD或者ZCARD拿到。比如我常用Redis的Sorted Set存Token的过期时间用ZCOUNT查某个时间窗口内会过期多少Token这比翻日志方便得多。Configuration public class OAuth2MetricsConfig { Bean public MeterBinder tokenStoreMetrics(RedisConnectionFactory connectionFactory) { return registry - { Gauge.builder(oauth2.active.access.tokens, () - countKeys(connectionFactory, access_token)) .description(当前存活access token数) .register(registry); Gauge.builder(oauth2.active.refresh.tokens, () - countKeys(connectionFactory, refresh_token)) .description(当前存活refresh token数) .register(registry); }; } private long countKeys(RedisConnectionFactory factory, String prefix) { try (RedisConnection conn factory.getConnection()) { return Optional.ofNullable(conn.execute( () - new DefaultRedisScript(return #redis.call(keys, ARGV[1]), Long.class), Collections.singletonList(prefix :*) )).map(Long.class::cast).orElse(0L); } } }这里插一句KEYS命令在Redis生产环境是禁忌数据量大的时候会阻塞单线程。如果只是测试环境用量不大可以用生产环境一定要换SCAN。实际上Spring官方不适应这个场景你也可以在业务层面直接统计Token颁发接口增加一个累加器吊销接口减少一个累加器用计数器代替全量扫描。我真正每次排障都用的指标组合是http.server.requests的平均耗时P50和P99、线程池活跃数、数据库连接池活跃数、GC耗时占比。这四个指标能定位90%的性能事故。4.2 GC与内存监控OAuth2认证服务器最容易出现的GC黑洞OAuth2授权服务器和普通CRUD接口服务的GC画像不一样。正常情况下认证服务器的对象生命周期很短进来一个请求创建认证对象校验一堆Token返回响应请求对象立刻变成垃圾。这类服务应该是典型的Minor GC频繁但占用量小的模式。但如果你的TokenStore是内存型的比如默认的InMemoryTokenStore已经颁发的Token会一直留在堆内存里直到过期。Token量一大老年代持续增长出现晋升失败导致频繁Full GC整体吞吐就断崖下跌。解决思路很直白一方面生产环境不要用内存TokenStore把Token挪到Redis另一方面在JVM参数上做调整。认证服务常用ParNewCMS或G1。用G1的话关注这几个参数-Xms4g -Xmx4g -XX:MaxGCPauseMillis200 -XX:InitiatingHeapOccupancyPercent45InitiatingHeapOccupancyPercent默认45如果你发现GC日志里Mixed GC太少、而Full GC很多可以把它调低一点比如35到40让G1更早开始并发回收。这个参数没有标准答案要靠压测中观察GC日志一点一点调。监控工具上不管是JDK自带的jstat还是Arthas都能看GC情况。我更推荐在Prometheus里直接接JMX Exporter或者用Micrometer的JVM指标。jvm.gc.pause这个指标要必配秒级的P99能达到多少毫秒直接反映认证服务的健康度。一次线上事故复盘某次发布后授权服务CPU飙到100%查下来发现是JWT编码器每次请求都生成新的RSAKey实例。RSA密钥对的生成是出了名的费CPU每次100-200ms的高CPU操作并发一上来线程池全部卡在密钥生成上。这个问题的本质是对象使用周期错了密钥应该只生成一次请求级复用流程里用的是同一个Key实例结果代码里在encode方法里每次KeyPairGenerator.generateKeyPair()。压测场景一上问题立刻暴露。4.3 上下文切换与锁竞争不透明Token在Redis上的并发消耗不透明Token模式的每个Token校验都是一次Redis读取。如果你用较老的方式直接为每个请求创建新的Redis连接性能会非常差。Spring Boot 2.0以后默认用Lettuce连接池比Jedis好在连接管理上但要注意连接池的上限。spring.data.redis.lettuce.pool.max-active50 spring.data.redis.lettuce.pool.max-idle20 spring.data.redis.lettuce.pool.min-idle5 spring.data.redis.lettuce.pool.time-between-eviction-runs10000ms连接池里的连接是复用的但如果max-active设置得过小高并发下会出现获取连接等待。我压测遇到过一种情况数据库连接池没满、Redis延迟也正常但TPS上不去最后排查发现Redis连接池在max-active边缘疯狂排队Wait时间已经几百毫秒。一个必经环节是确认Token读取操作走的是同步还是异步。Spring Security对Token的打读通常是同步的一个请求一个操作同步读Redis本身延迟不高1ms以内逻辑简单直接反而是优势。不需要为了异步化而异步化省下的延迟还不够补偿代码复杂度。5. 实战压测复盘一次QPS上不去引发的完整排查链路纸上谈兵没意思我说一次真实的排查过程。这套操作思路是可复现的遇到类似问题可以直接照此排查。5.1 压测环境和核心指标测试配置4核8G单机授权服务器Redis在另外一台机器数据库共用一个MySQL实例。压测工具用wrk2400条TCP连接跑了3分钟。压测结果目标QPS 2000实际只能到1100左右再往上推P99延迟直接从80ms跳到800ms线程池拒绝策略开始生效大量502网关报错。5.2 第一轮排查从响应时间拆分找谁拖垮了谁先看监控大屏四个核心指标都有P99高达700ms活跃线程数直接打满Tomcat的200个线程GC平均停顿50ms但频率不高Redis耗时正常MySQL CPU在90%附近。到这里第N感已经猜到数据库是瓶颈。进了MySQL排查慢日志里发现了恐怖的SQLSELECT id, client_id, user_name, authentication, token FROM oauth_access_token WHERE token_id ? ORDER BY token_id DESC;这个查询本身走了主键单次执行几毫秒但请求量太大把数据库CPU打到接近100%。让我意外的是这里居然有大量ORDER BY token_id DESC的排序操作——默认的JdbcTokenStore查询模板里带了排序逻辑数据量大之后排序费用远高于查询本身。5.3 第二轮排查锁竞争和热点Key升数据库排查之后同时把oauth_access_token表里的数据量打印出来7天累计3000万行。查询单次快但索引深度已经上去了且高频的DELETE清理任务和业务INSERT之间产生了行锁竞争。这里引出另一个点Token表本质上是个写多读多删多的表。数据库对这类访问模式非常不友好尤其是过期Token的批量删除。优化手段表按月分区过期Token直接DROP分区代替逐行DELETE清理窗口改到凌晨且分批执行避免和高峰期重叠最终换了Redis方案把读操作从MySQL移除数据库压力瞬间下降90%。5.4 最终定位刷新Token的竞态条件数据库压力降下来之后QPS推到1600左右就卡住了。Thread dump一打发现大量线程阻塞在同一个Redis锁上刷新Token的逻辑。刷新Token在Spring Security的DefaultOAuth2TokenService里默认是无论是否并发每次都生成新Token并撤销旧Token。如果同一个clientId带同一个refresh_token并发打过来会出现多个请求同时换上不同的新Token的场景这就是竞态条件。分布式锁解决public OAuth2AccessToken refreshToken(String refreshTokenValue) { String lockKey lock:refresh: DigestUtils.sha256Hex(refreshTokenValue); // 用Redis实现分布式锁300ms拿不到锁直接返回失败 if (!redisLock.tryLock(lockKey, Duration.ofMillis(200))) { throw new InvalidGrantException(Concurrent refresh request detected); } try { // 原有刷新逻辑 return super.refreshToken(refreshTokenValue); } finally { redisLock.unlock(lockKey); } }这个问题在网上问得很频繁。Spring Security OAuth2原本的设计里没有内置并发刷新保护因为老版OAuth2规范倾向于刷新Token之后旧Token立即失效这天然要求串行。但实际很多客户端会重试、会并发导致刷新接口变成性能黑洞。加了锁之后QPS稳定在1800。虽然牺牲了一点极端并发下的刷新成功率但换来的是整体稳定。5.5 事后沉淀的压测清单经历这次问题之后我把OAuth2服务的压测检查项固定下来JWKS缓存有没有拍平远程拉取是否会触发热点TokenStore是不是内存型Token并发量上来会不会撑爆堆数据库连接池和Redis连接池的上限是否匹配并发数refresh_token并发刷新是否有保护机制过期Token清理任务是否和业务高峰期错开JWT密钥生成是否只做了单次初始化GC参数是否根据认证服务短生命周期对象为主的特点调过。这张清单可以当成上线前的性能检查卡每一条都对应一个真实坑。6. 进阶调优密钥轮换、Caffeine缓存和Token清理策略基础调优做完了想再往深走一层或者想把成本再压一压这部分内容适合你。6.1 密钥轮换别让性能优化毁于正规操作JWT的密钥轮换是安全运维的常规动作但一轮换就会让所有资源服务器的JWKS缓存瞬间失效。如果资源服务器数量多且配置的缓存刷新逻辑是发现签名失败就去远程拉取轮换后那几秒钟会出现验签风暴。我的做法轮换时保留新旧两个密钥同时在线新版JWT用新密钥签发旧版JWT在过渡期内仍能被旧密钥验证提前清理资源服务器的cache不要等签名失败再触发拉取密钥轮换放在流量低谷比如凌晨。具体到Spring SecurityNimbusJwtDecoder内部使用RemoteJWKSet除非兼顾了两个密钥的JWKS列表否则旧token直接验证失败。如果不支持多key就要用CompositeJwtDecoder把新旧两个解码器都配进去验签时逐个尝试。我之前在线上就因为没考虑旧token还能不能验就直接轮换导致大批存量用户被踢下线。这个坑和性能无关但比性能问题更致命。6.2 Caffeine本地缓存低频读、不常变的数据不要每次都打远程很多OAuth2服务会反复读取client详情、权限配置。这类数据有三个特征量不大、变化不频繁、读频率不低。用Cacheable配合Caffeine做本地缓存性价比极高。Configuration EnableCaching public class CacheConfig { Bean public CacheManager cacheManager() { CaffeineCacheManager manager new CaffeineCacheManager(clientDetails, userAuth); manager.setCaffeine(Caffeine.newBuilder() .expireAfterWrite(Duration.ofMinutes(5)) .maximumSize(10_000) .recordStats()); return manager; } }有几个细节要注意本地缓存第一个请求打穿之后并发场景下会有缓存穿透要配合Caffeine的同步加载最好用CacheLoader多实例部署中本地缓存更新时机不一致的问题短期容忍没问题但配置变更时要有能力手动清除权限模型特别复杂的时候不要把所有权限都塞进缓存只缓存稳定的、变化慢的数据权限这种东西如果变化频繁就不进缓存。6.3 过期Token清理别让数据无限膨胀不管用JdbcTokenStore还是RedisTokenStore过期Token的清理都必须有。Redis方案里直接给Token设置TTL是最省心的redisTemplate.opsForValue().set(tokenKey, tokenValue, Duration.ofSeconds(expiresIn));但有一个场景要注意刷新Token的TTL如果等于access_token的TTL用户可能还在活跃刷新Token已经没了。JWT模式下的access_token默认TTL是5分钟refresh_token往往设到7天。这两个过期时间要分开处理refresh_token的过期策略建议做成滑动过期每次刷新成功重新设置一个7天TTL。redisTemplate.expire(refreshTokenKey, Duration.ofDays(7));这个滑动过期逻辑在Spring Security的DefaultOAuth2TokenService里没有默认实现需要自己覆盖。它的意义在于长期活跃用户的refresh_token不会因为7天没操作就过期而不活跃用户倒是能及时回收资源一举两得。批量清理方面用Redis的SCAN配合ZSET做有序过期索引可以高效地处理哪些token即将过期的问题。每次颁发token时把过期时间戳当成score存进一个ZSET后台定时任务用ZRANGEBYSCORE取出已过期的key再逐个删除Token本身。这个方案不阻塞Redis也不会因为KEYS命令把实例卡死。7. 我在这套实践里的两点真实体会写到最后分享两个纯个人经验。第一点OAuth2性能优化最忌讳先优化再测。我见过太多人把JWKS缓存、Redis连接池、Caffeine缓存全配好了再压测结果完全说不清到底哪个优化起了作用。正确做法是先把默认配置拉个基线压测然后一次只动一个变量每个变量都重新压一轮记录下P50、P99、线程活跃数、DB/Redis耗时。这样你做完之后能拿数据告诉团队到底是谁的改动把服务救回来的。第二点监控比优化重要。没有监控的优化像蒙眼开车。至少把jvm.gc.pause、http.server.requests、spring.security.authentication这几个指标标配出来出了问题能第一时间缩小排查范围。等到你被没有监控的线上环境坑过一次你就会理解为什么我把监控这块放在这么靠前的位置。这套体系跑了大半年授权服务和资源服务器都稳定压测从最初的1100 QPS优化到了1800 QPS数据库压力降了一个量级。OAuth2本身并不重重的是把它放进真实的高并发环境里不打折扣地运行。每个跳过的配置都会在流量峰值时以事故的形式结算。

看完文章,想为自己的企业也做一次专业网站诊断?

尧图顾问免费为您评估现有网站,并给出建站/改版建议与报价方案。

免费获取方案