Spring Boot + Redis 缓存性能优化实战:从5秒到毫秒级的性能提升

发布于:2025-09-13 ⋅ 阅读:(24) ⋅ 点赞:(0)

前言

在开发某调查系统时,我们遇到了严重的性能问题:GeoJSON数据接口响应时间长达5-6秒,数据量达到20MB+,用户体验极差。通过系统性的缓存优化,我们成功将响应时间从5秒优化到毫秒级,性能提升超过99%。

本文将详细介绍我们的优化过程,包括问题分析、解决方案、代码实现和性能测试结果。

1. 问题分析

1.1 性能瓶颈识别

通过浏览器开发者工具的网络面板,我们发现了以下性能问题:

user?_t=1757664118630     - 5.66s, 131KB
geojson?xzqdm=62&_t=...   - 2.15s, 20,602KB
left?xzqdm=62&_t=...      - 8ms, 11.3KB
right?xzqdm=62&_t=...     - 8ms, 7.6KB

关键发现:

  • _t 参数(时间戳)导致每次请求URL都不同
  • 相同数据重复查询,没有缓存机制
  • 大数据量接口(20MB+)每次都重新生成

1.2 根本原因分析

  1. 前端缓存破坏_t 参数使每个请求URL唯一,绕过浏览器缓存
  2. 后端缓存失效:Spring Cache的SpEL表达式处理_t参数时出现问题
  3. UserToken空值:未登录用户导致缓存键生成失败
  4. 缓存键冲突:多个接口使用相同的缓存名称

2. 解决方案设计

2.1 整体架构

我们采用了前后端双重缓存策略:

前端请求 → 前端缓存检查 → 后端Spring Cache → Redis → 数据库
    ↓           ↓              ↓
  5MB限制    无大小限制      持久化存储

2.2 缓存策略

缓存层级 存储位置 大小限制 过期时间 用途
前端缓存 localStorage 5MB 30分钟 小数据快速响应
后端缓存 Redis 无限制 1-24小时 大数据持久化
数据库 PostgreSQL/KingBase 无限制 永久 数据源

3. 后端缓存优化实现

3.1 修复SpEL表达式问题

问题代码:

@Cacheable(value = "geojson", key = "#xzqdm ?: #userToken.user.gsddm")
public Result getGeoJsonList(String xzqdm, UserToken userToken) {
    // 当userToken为null时会抛出NullPointerException
}

修复后:

@Cacheable(value = "geojson", key = "'dd_' + (#xzqdm ?: (#userToken != null ? #userToken.user.gsddm : '62'))")
public Result getGeoJsonList(String xzqdm, UserToken userToken) {
    if (xzqdm == null) {
        xzqdm = (userToken != null && userToken.getUser() != null) ? 
                userToken.getUser().getGsddm() : "62";
    }
    // 业务逻辑...
}

3.2 缓存键优化

为了避免不同接口的缓存冲突,我们为每个接口设计了独特的缓存键:

@Cacheable(value = "geojson", key = "'dd_' + (#xzqdm ?: (#userToken != null ? #userToken.user.gsddm : '62'))")
 
@Cacheable(value = "geojson", key = "'env_' + (#xzqdm ?: (#userToken != null ? #userToken.user.gsddm : '62'))")

@Cacheable(value = "geojson", key = "'prop_' + (#vo.xzqdm ?: (#userToken != null ? #userToken.user.gsddm : '62'))")

// 统计面板
@Cacheable(value = "left_panel", key = "'left_' + (#xzqdm ?: (#userToken != null ? #userToken.user.gsddm : '62'))")
@Cacheable(value = "right_panel", key = "'right_' + (#xzqdm ?: (#userToken != null ? #userToken.user.gsddm : '62'))")

3.3 Redis缓存配置

@Configuration
@EnableCaching
public class CacheConfig {
    
    @Bean
    public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
        // 配置序列化器
        RedisSerializationContext.SerializationPair<String> stringPair = 
            RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer());
        RedisSerializationContext.SerializationPair<Object> objectPair = 
            RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer());

        // GeoJSON缓存配置 - 10分钟(数据变化频繁)
        RedisCacheConfiguration geojsonConfig = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(10))
                .serializeKeysWith(stringPair)
                .serializeValuesWith(objectPair)
                .disableCachingNullValues();

        // 统计面板缓存配置 - 1小时
        RedisCacheConfiguration statsConfig = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofHours(1))
                .serializeKeysWith(stringPair)
                .serializeValuesWith(objectPair)
                .disableCachingNullValues();

        return RedisCacheManager.builder(redisConnectionFactory)
                .cacheDefaults(RedisCacheConfiguration.defaultCacheConfig()
                    .entryTtl(Duration.ofMinutes(30)))
                .withCacheConfiguration("geojson", geojsonConfig)
                .withCacheConfiguration("left_panel", statsConfig)
                .withCacheConfiguration("right_panel", statsConfig)
                .withCacheConfiguration("prop_panel", statsConfig)
                .withCacheConfiguration("env_panel", statsConfig)
                .withCacheConfiguration("user_auth", statsConfig)
                .build();
    }
}

3.4 缓存管理服务

@Service
public class CacheService {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    /**
     * 清理指定缓存
     */
    public boolean clearCache(String cacheName) {
        try {
            Set<String> keys = redisTemplate.keys(cacheName + ":*");
            if (keys != null && !keys.isEmpty()) {
                redisTemplate.delete(keys);
                return true;
            }
            return false;
        } catch (Exception e) {
            log.error("清理缓存失败: {}", e.getMessage());
            return false;
        }
    }
    
    /**
     * 清理区域相关缓存
     */
    public int clearRegionCaches(String xzqdm) {
        int clearedCount = 0;
        String[] cacheNames = {"geojson", "left_panel", "right_panel", "prop_panel", "env_panel"};
        
        for (String cacheName : cacheNames) {
            if (clearCacheKey(cacheName, "dd_" + xzqdm) ||
                clearCacheKey(cacheName, "env_" + xzqdm) ||
                clearCacheKey(cacheName, "prop_" + xzqdm) ||
                clearCacheKey(cacheName, "left_" + xzqdm) ||
                clearCacheKey(cacheName, "right_" + xzqdm)) {
                clearedCount++;
            }
        }
        return clearedCount;
    }
}

4. 前端缓存优化实现

4.1 缓存工具类

// geojson-cache.ts
interface CacheItem {
  data: any;
  timestamp: number;
  version: string;
}

class GeoJsonCache {
  private readonly CACHE_VERSION = 'v1.0';
  private readonly EXPIRE_TIME = 30 * 60 * 1000; // 30分钟
  private readonly MAX_SIZE_MB = 5; // 5MB限制
  private readonly MAX_FEATURES = 10000; // 最大要素数量

  /**
   * 获取缓存的GeoJSON数据
   */
  async getCachedDcydGeoJson(params: any, forceRefresh = false): Promise<any> {
    const cacheKey = this.generateCacheKey('dcdy', params);
    
    if (!forceRefresh) {
      const cached = this.getFromCache(cacheKey);
      if (cached) {
        console.log('使用缓存的 GeoJSON 数据:', cacheKey);
        return cached;
      }
    }

    console.log('从 API 获取 GeoJSON 数据:', params);
    const data = await this.fetchFromAPI('/api/geojson', params);
    
    // 检查数据大小
    if (this.isDataTooLarge(data)) {
      console.warn('数据过大, 跳过缓存存储');
      return data;
    }
    
    this.saveToCache(cacheKey, data);
    return data;
  }

  /**
   * 检查数据是否过大
   */
  private isDataTooLarge(data: any): boolean {
    const jsonString = JSON.stringify(data);
    const sizeMB = new Blob([jsonString]).size / (1024 * 1024);
    
    if (sizeMB > this.MAX_SIZE_MB) {
      console.warn(`数据过大 (${sizeMB.toFixed(2)}MB), 超过限制 (${this.MAX_SIZE_MB}MB)`);
      return true;
    }
    
    if (data.features && data.features.length > this.MAX_FEATURES) {
      console.warn(`要素数量过多 (${data.features.length}), 超过限制 (${this.MAX_FEATURES})`);
      return true;
    }
    
    return false;
  }

  /**
   * 生成缓存键
   */
  private generateCacheKey(type: string, params: any): string {
    const paramStr = params ? Object.keys(params)
      .sort()
      .map(key => `${key}=${params[key]}`)
      .join('&') : 'default';
    
    return `geojson_cache_${this.CACHE_VERSION}_${type}_${paramStr}`;
  }

  /**
   * 从缓存获取数据
   */
  private getFromCache(key: string): any {
    try {
      const cached = localStorage.getItem(key);
      if (!cached) return null;

      const item: CacheItem = JSON.parse(cached);
      
      // 检查版本
      if (item.version !== this.CACHE_VERSION) {
        localStorage.removeItem(key);
        return null;
      }

      // 检查过期时间
      if (Date.now() - item.timestamp > this.EXPIRE_TIME) {
        localStorage.removeItem(key);
        return null;
      }

      return item.data;
    } catch (error) {
      console.error('读取缓存失败:', error);
      return null;
    }
  }

  /**
   * 保存数据到缓存
   */
  private saveToCache(key: string, data: any): void {
    try {
      const item: CacheItem = {
        data,
        timestamp: Date.now(),
        version: this.CACHE_VERSION
      };

      localStorage.setItem(key, JSON.stringify(item));
      console.log('GeoJSON 数据已缓存:', key);
    } catch (error) {
      if (error.name === 'QuotaExceededError') {
        console.warn('存储空间不足,清理旧缓存');
        this.cleanOldCache();
        // 重试一次
        try {
          localStorage.setItem(key, JSON.stringify(item));
        } catch (retryError) {
          console.warn('重试后仍然失败,跳过缓存');
        }
      } else {
        console.error('保存缓存失败:', error);
      }
    }
  }

  /**
   * 清理旧缓存
   */
  private cleanOldCache(): void {
    const keys = Object.keys(localStorage)
      .filter(key => key.startsWith('geojson_cache_'))
      .map(key => ({
        key,
        timestamp: this.getCacheTimestamp(key)
      }))
      .sort((a, b) => a.timestamp - b.timestamp);

    // 删除最旧的30%
    const deleteCount = Math.ceil(keys.length * 0.3);
    for (let i = 0; i < deleteCount; i++) {
      localStorage.removeItem(keys[i].key);
    }
  }
}

export const geoJsonCache = new GeoJsonCache();

4.2 在组件中使用

<template>
  <div>
    <SamplePointsMap :geoJsonData="geoJsonData" />
    <a-button @click="refreshData" :loading="loading">
      刷新数据
    </a-button>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import { geoJsonCache } from '@/utils/cache/geojson-cache'

const geoJsonData = ref(null)
const loading = ref(false)

const fetchData = async (forceRefresh = false) => {
  loading.value = true
  try {
    const data = await geoJsonCache.getCachedDcydGeoJson(
      { xzqdm: '420000' }, 
      forceRefresh
    )
    geoJsonData.value = data
  } catch (error) {
    console.error('获取数据失败:', error)
  } finally {
    loading.value = false
  }
}

const refreshData = () => {
  fetchData(true) // 强制刷新
}

onMounted(() => {
  fetchData() // 首次加载
})
</script>

5. 性能测试结果

5.1 优化前后对比

接口 优化前 优化后 提升幅度
user接口 5.66s 15ms 99.7%
geojson接口 2.15s 8ms 99.6%
left面板 8ms 3ms 62.5%
right面板 8ms 2ms 75%

5.2 缓存命中率

首次请求: 0% 命中率(建立缓存)
第二次请求: 100% 命中率(从缓存获取)
第三次请求: 100% 命中率(从缓存获取)

5.3 内存使用情况

  • Redis内存使用: 约200MB(存储所有缓存数据)
  • 前端localStorage: 约3MB(小数据缓存)
  • 数据库查询减少: 90%以上

6. 监控和调试

6.1 缓存统计接口

@RestController
@RequestMapping("/cache")
public class CacheController {
    
    @Autowired
    private CacheService cacheService;
    
    @GetMapping("/stats")
    public Result getCacheStats() {
        Map<String, Object> stats = new HashMap<>();
        stats.put("totalKeys", cacheService.getTotalCacheKeys());
        stats.put("memoryUsage", cacheService.getMemoryUsage());
        stats.put("hitRate", cacheService.getHitRate());
        return Result.OK(stats);
    }
    
    @PostMapping("/clear")
    public Result clearCache(@RequestParam String cacheType) {
        boolean success = cacheService.clearCache(cacheType);
        return Result.OK(success ? "清理成功" : "清理失败");
    }
}

6.2 性能监控

@Component
public class CachePerformanceMonitor {
    
    private final MeterRegistry meterRegistry;
    
    public CachePerformanceMonitor(MeterRegistry meterRegistry) {
        this.meterRegistry = meterRegistry;
    }
    
    @EventListener
    public void handleCacheHit(CacheHitEvent event) {
        meterRegistry.counter("cache.hit", "name", event.getCacheName()).increment();
    }
    
    @EventListener
    public void handleCacheMiss(CacheMissEvent event) {
        meterRegistry.counter("cache.miss", "name", event.getCacheName()).increment();
    }
}

7. 最佳实践总结

7.1 缓存设计原则

  1. 缓存键设计:使用有意义的前缀,避免冲突
  2. 过期时间设置:根据数据更新频率合理设置
  3. 空值处理:避免缓存null值,浪费存储空间
  4. 版本控制:支持缓存版本管理,便于升级

7.2 性能优化技巧

  1. 批量操作:使用Redis Pipeline减少网络开销
  2. 压缩存储:大数据使用压缩算法减少存储空间
  3. 预热缓存:系统启动时预加载热点数据
  4. 监控告警:设置缓存命中率告警,及时发现问题

7.3 常见问题解决

  1. 缓存穿透:使用布隆过滤器或缓存空值
  2. 缓存雪崩:设置随机过期时间,避免同时失效
  3. 缓存击穿:使用分布式锁,避免热点数据重建
  4. 内存溢出:设置合理的过期时间和清理策略

8. 总结

通过系统性的缓存优化,我们成功解决了土壤调查系统的性能问题:

  • 响应时间:从5秒优化到毫秒级
  • 用户体验:大幅提升,页面加载流畅
  • 系统稳定性:减少数据库压力,提高并发能力
  • 开发效率:提供完整的缓存管理工具

缓存优化是一个持续的过程,需要根据业务特点和数据变化规律不断调整策略。希望本文的经验能够帮助到有类似需求的开发者。