最近做项目时,被接口性能问题搞得头都大了。第三方压测结果显示,接口平均响应时间超过 500ms,高峰期甚至破了 1 秒,QPS 也远远达不到预期。领导说换数据库、升服务器成本太高,让我想想低成本的解决办法。琢磨来琢磨去,发现 Spring 缓存是个好东西,今天就把这段优化经历分享给大家。
为啥要用缓存?
其实道理很简单,接口慢大多是因为数据库查询太频繁,CPU 和内存都扛不住。如果把那些经常被访问的数据存在缓存里,就不用每次都去麻烦数据库了,这样响应速度自然就快了。我当时就想着,要是能把热门数据的响应时间降到 50ms 以内,QPS 再提升个几倍,那就完美了。
快速上手 Spring 缓存
说干就干,在 Spring Boot 项目里用缓存其实很简单。先加个依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
然后在启动类上加上 @EnableCaching 注解,缓存功能就启用了。
@SpringBootApplication
@EnableCaching
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
这几个缓存注解得好好说说,平时开发常用到:
- @Cacheable:查数据的时候先看缓存里有没有,有就直接拿,没有就查完了存进去。
- @CachePut:更新数据的时候用,每次都会执行方法,然后把结果更新到缓存里。
- @CacheEvict:删除数据后,把缓存里对应的也删掉,省得数据不一致。
- @Caching:可以组合上面这些操作,比如又更新又删除的时候用。
实战:给用户查询接口提速
之前做的用户查询接口,直接查数据库,慢得不行,响应时间动不动就 200-500ms。
@Service
public class UserServiceImpl implements UserService {
@Autowired
private UserRepository userRepository;
@Override
public User getUserById(Long id) {
return userRepository.findById(id).orElse(null);
}
后来加上缓存,效果立竿见影。查询的时候用 @Cacheable,更新用 @CachePut,删除用 @CacheEvict,一一对应上。
@Service
public class UserServiceImpl implements UserService {
@Autowired
private UserRepository userRepository;
@Cacheable(value = "users", key = "#id", unless = "#result == null")
@Override
public User getUserById(Long id) {
return userRepository.findById(id).orElse(null);
}
@CachePut(value = "users", key = "#user.id")
@Override
public User updateUser(User user) {
return userRepository.save(user);
}
@CacheEvict(value = "users", key = "#id")
@Override
public void deleteUser(Long id) {
userRepository.deleteById(id);
}
缓存配置也简单,单节点应用用 ConcurrentHashMap 就行。
@Configuration
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
return new ConcurrentMapCacheManager("users");
}
新问题:缓存导致权限判断失效
本以为加了缓存万事大吉,结果测试同学反馈了个棘手问题:用户权限修改后,接口的权限判断经常失灵。比如管理员明明已经把某个用户的 "删除权限" 收回了,但该用户还是能调用删除接口,直到过了很久或者重启服务才恢复正常。
排查了半天才发现症结所在:我们的权限判断逻辑依赖于 User 对象里的 role 字段,而这个 User 对象被缓存起来了。当管理员在后台修改用户角色时,虽然数据库里的 role 字段更新了,但缓存里的 User 对象还是旧的,导致权限校验时始终读取到过期的角色信息。
解决办法
针对这个问题,我们调整了缓存策略:
- 拆分缓存数据:把用户基本信息和权限信息分开存储,权限信息单独缓存并设置较短的过期时间(5 分钟)
// 基本信息缓存时间较长
@Cacheable(value = "userBaseInfo", key = "#id")
public UserBaseInfo getUserBaseInfo(Long id) {
...
}
// 权限信息缓存时间短
@Cacheable(value = "userPermissions", key = "#id", ttl = 300)
public List<String> getUserPermissions(Long id) {
...
}
- 权限变更时主动刷新:在角色修改接口中,主动删除对应用户的权限缓存
@CacheEvict(value = "userPermissions", key = "#userId")
public void updateUserRole(Long userId, List<String> newRoles) {
// 更新数据库角色信息
userRoleRepository.updateRoles(userId, newRoles);
}
- 关键接口增加实时校验:对于删除、修改等高危操作,在缓存校验后再做一次数据库实时校验
public void deleteResource(Long resourceId, Long userId) {
// 先查缓存做初步校验
List<String> permissions = getUserPermissions(userId);
if (!permissions.contains("DELETE_RESOURCE")) {
throw new AccessDeniedException("无权限");
}
// 关键操作再查一次数据库确认
if (!userPermissionRepository.hasPermission(userId, "DELETE_RESOURCE")) {
throw new AccessDeniedException("无权限");
}
// 执行删除操作
...
}
调整后,权限变更的生效延迟控制在了 5 分钟内,配合主动刷新机制,基本解决了权限判断失效的问题。
进阶玩法:Redis 缓存
要是分布式应用,内存缓存就不够用了,Redis 是个好选择。先加依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
然后配置一下,设置个过期时间,选个合适的序列化方式。
@Configuration
public class RedisCacheConfig {
@Bean
public CacheManager cacheManager(RedisConnectionFactory factory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30))
.serializeKeysWith(RedisSerializationContext.SerializationPair
.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer()));
return RedisCacheManager.builder(factory)
.cacheDefaults(config)
.build();
}
缓存参数还能玩出花样,比如只给 ID 大于 0 的缓存,结果为空就不缓存,还能开同步防止缓存击穿。
@Cacheable(value = "users",key = "#id",condition = "#id > 0",unless = "#result == null || #result.age < 18",sync = true)
public User getUser(Long id) { ... }
那些踩过的坑和解决办法
用缓存久了,难免遇到些问题。比如总有人查不存在的数据,数据库压力大,这就是缓存穿透,缓存个空值或者用布隆过滤器能解决。
热点 key 过期的时候,一堆请求冲去查数据库,缓存击穿了,加个 sync=true 或者分布式锁就行。
大量缓存同时过期,数据库直接扛不住,缓存雪崩了,设置随机过期时间或者搞个多级缓存能缓解。
还有缓存和数据对不上的问题,先更数据库再删缓存,或者用消息队列异步更新都靠谱。
监控也很重要
缓存命中率得盯着,理想情况得超过 90%,还有内存使用量、过期淘汰率这些指标。Redis Insight 可视化做得不错,Spring Boot Actuator 能暴露缓存指标,想搞复杂点就用 Prometheus 加 Grafana 整个监控大盘。
总结一下
不用换数据库、不用升服务器,加个 Spring 缓存就能让接口快不少。但缓存不是银弹,像权限这类动态变化的数据就需要特别处理。先从简单的内存缓存试试水,再过渡到 Redis,注意避开那些常见的坑,再整个监控体系,接口响应时间降个 80%-90%,QPS 提个 3-5 倍不是问题,数据库压力也能大减。亲测有效的低成本方案,赶紧试试吧!