Spring Boot + Redis 缓存性能优化实战:从5秒到毫秒级的性能提升
前言
在开发某调查系统时,我们遇到了严重的性能问题: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 根本原因分析
- 前端缓存破坏:
_t
参数使每个请求URL唯一,绕过浏览器缓存 - 后端缓存失效:Spring Cache的SpEL表达式处理
_t
参数时出现问题 - UserToken空值:未登录用户导致缓存键生成失败
- 缓存键冲突:多个接口使用相同的缓存名称
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 {@Beanpublic 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 {@Autowiredprivate 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 = truetry {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 {@Autowiredprivate 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;}@EventListenerpublic void handleCacheHit(CacheHitEvent event) {meterRegistry.counter("cache.hit", "name", event.getCacheName()).increment();}@EventListenerpublic void handleCacheMiss(CacheMissEvent event) {meterRegistry.counter("cache.miss", "name", event.getCacheName()).increment();}
}
7. 最佳实践总结
7.1 缓存设计原则
- 缓存键设计:使用有意义的前缀,避免冲突
- 过期时间设置:根据数据更新频率合理设置
- 空值处理:避免缓存null值,浪费存储空间
- 版本控制:支持缓存版本管理,便于升级
7.2 性能优化技巧
- 批量操作:使用Redis Pipeline减少网络开销
- 压缩存储:大数据使用压缩算法减少存储空间
- 预热缓存:系统启动时预加载热点数据
- 监控告警:设置缓存命中率告警,及时发现问题
7.3 常见问题解决
- 缓存穿透:使用布隆过滤器或缓存空值
- 缓存雪崩:设置随机过期时间,避免同时失效
- 缓存击穿:使用分布式锁,避免热点数据重建
- 内存溢出:设置合理的过期时间和清理策略
8. 总结
通过系统性的缓存优化,我们成功解决了土壤调查系统的性能问题:
- 响应时间:从5秒优化到毫秒级
- 用户体验:大幅提升,页面加载流畅
- 系统稳定性:减少数据库压力,提高并发能力
- 开发效率:提供完整的缓存管理工具
缓存优化是一个持续的过程,需要根据业务特点和数据变化规律不断调整策略。希望本文的经验能够帮助到有类似需求的开发者。