SpringSecurity在处理每一次认证请求时,都会通过UserDetailsService去加载用户信息。默认的实现方式是每次都去数据库查询一遍,这在用户量小的时候问题不大,但一旦并发上来,数据库就会成为整个系统最明显的瓶颈。把UserDetails缓存起来是常见的优化手段,不过很多同学在做的过程中会遇到缓存不生效、权限更新不及时、反序列化异常等一堆坑。这篇文章就把整个缓存的思路、实现和注意事项一次性讲清楚。

一、先搞清楚UserDetails是什么时候被加载的
要缓存UserDetails,首先得知道它是在哪个环节被创建和使用的。在表单登录的场景下,用户提交用户名密码后,请求会交给UsernamePasswordAuthenticationFilter,它把参数封装成UsernamePasswordAuthenticationToken,再交给AuthenticationManager处理。AuthenticationManager的默认实现ProviderManager会遍历各个AuthenticationProvider,而用户名密码认证对应的正是DaoAuthenticationProvider。
DaoAuthenticationProvider内部会调用retrieveUser方法,这个方法里最核心的一步就是调用我们熟悉的UserDetailsService.loadUserByUsername(String username)。也就是说,只要发生了一次完整的认证流程,数据库就会被查询一次。下面是简化的源码逻辑:
protected final UserDetails retrieveUser(String username,
UsernamePasswordAuthenticationToken authentication) {
UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
if (loadedUser == null) {
throw new InternalAuthenticationServiceException(
"UserDetailsService returned null, which is an interface violation");
}
return loadedUser;
}需要特别注意的是,登录成功之后,后续请求并不会每次都调用loadUserByUsername。SecurityContext中保存的Authentication对象已经包含了权限信息,会话内的请求直接复用。真正频繁触发数据库查询的场景有两类:一是用户频繁重新登录,二是启用了RememberMe功能,因为RememberMe的自动登录流程会重新走一遍认证。另外,如果配置了SecurityContextPersistenceFilter每次从SecurityContextRepository读取失败,也可能触发重新认证。理解了这些触发点,才能判断缓存到底能带来多大收益。
二、使用Spring Cache实现UserDetails缓存
最优雅的做法是直接在UserDetailsService的实现上叠加缓存注解。Spring Cache抽象层可以对接Redis、Caffeine、Ehcache等各种实现,代码侵入性几乎为零。先确保配置类上开启了缓存:
@Configuration
@EnableWebSecurity
@EnableCaching
public class SecurityConfig {
@Bean
public UserDetailsService userDetailsService(UserRepository userRepository) {
return new CachedUserDetailsService(userRepository);
}
}然后在实现类的方法上加@Cacheable注解,注意key要指定为用户名参数:
@Service
public class CachedUserDetailsService implements UserDetailsService {
@Autowired
private UserRepository userRepository;
@Override
@Cacheable(value = "userDetails", key = "#username", unless = "#result == null")
public UserDetails loadUserByUsername(String username) {
System.out.println("从数据库加载用户: " + username);
return userRepository.findByUsername(username)
.map(this::toUserDetails)
.orElseThrow(() -> new UsernameNotFoundException("用户不存在"));
}
}这里有几个细节值得展开。第一,unless = "#result == null"可以防止把空结果缓存起来,避免恶意用户用不存在的用户名刷缓存。第二,缓存的value建议设置过期时间,比如配合Caffeine配置expireAfterWrite为30分钟,这样即使忘了做缓存清除,权限变更也能在一定时间后自然生效。第三,如果用Redis作为缓存介质,UserDetails的实现类必须实现Serializable接口,并且建议给实体类声明固定的serialVersionUID,否则反序列化时容易出问题。
三、缓存与权限更新的同步问题
缓存最大的坑就是数据不一致。假设管理员把某个用户禁用了,或者调整了角色,但缓存里还是旧数据,这个用户拿着旧权限继续访问系统,就是安全隐患。解决思路是在用户信息发生变更的地方,主动清除对应缓存。
@Service
public class UserManageService {
@Autowired
private UserRepository userRepository;
@CacheEvict(value = "userDetails", key = "#username")
public void updateUserRoles(String username, Set<String> newRoles) {
userRepository.updateRoles(username, newRoles);
}
@CacheEvict(value = "userDetails", key = "#username")
public void disableUser(String username) {
userRepository.updateEnabled(username, false);
}
}还有一个容易被忽略的点:清掉缓存只影响下一次认证,已经在会话中的用户,其Authentication对象里还是旧权限。如果需要立即生效,就得强制该用户下线。常见做法是在用户表中维护一个版本号或token签发时间字段,写一个Filter或用会话注册器把过期的会话失效掉。对于分布式系统,可以用Redis发布订阅广播踢人消息,各节点收到后调用SessionRegistry的expireNow方法。
另外提醒一句,对禁用用户来说,缓存过期时间不能设得太长。accountNonExpired、accountNonLocked、enabled这些布尔属性也在UserDetails里,缓存了它们意味着这段时间内前置检查用的都是旧状态。安全敏感的场景建议把过期时间控制在分钟级别,或者干脆对这类状态字段走实时查询。
四、常见报错与排查思路
第一个高频问题是缓存注解不生效,loadUserByUsername每次都打到数据库。绝大多数原因是方法内部调用导致AOP代理失效,或者UserDetailsService是被SpringSecurity通过new出来的对象而不是Spring容器里的Bean。解决办法是确认该Service由容器管理,并且调用方拿到的是代理对象。可以用断点检查当前对象是否带有$符号的代理类名。
第二个常见报错是使用Redis缓存时的序列化异常,典型堆栈类似SerializationException或者ClassNotFoundException。原因多是自定义的UserDetails实现类没有实现Serializable,或者类结构改了但Redis里还有旧数据。除了实现序列化接口,还可以考虑用GenericJackson2JsonRedisSerializer直接序列化成JSON,可读性更好,排查问题也方便。示例配置:
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30))
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer()));
return RedisCacheManager.builder(factory)
.cacheDefaults(config)
.build();
}第三个问题是循环依赖。有时SecurityConfig里注入了UserDetailsService,而UserDetailsService的实现又依赖PasswordEncoder等Bean,启动时可能报循环依赖错误。可以在配置方法参数上直接接收UserDetailsService,或者用@Lazy注解打断依赖环。如果用的是SpringSecurity 5.7之后的版本,推荐用Lambda风格的配置方式,声明SecurityFilterChain Bean代替旧的WebSecurityConfigurerAdapter,整体结构会清爽很多。
最后一个建议:不要过度缓存。如果系统登录频率本身不高,或者用户数据经常变动,缓存带来的收益可能还抵不上维护一致性的成本。先通过监控确认loadUserByUsername的调用频率和耗时,再决定是否引入缓存,用数据说话永远比拍脑袋可靠。
SpringSecurity缓存UserDetailsUserDetailsService修改时间:2026-09-07 23:54:36