前言
在开发某调查系统时,我们遇到了严重的性能问题: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 {
@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 缓存设计原则
- 缓存键设计:使用有意义的前缀,避免冲突
- 过期时间设置:根据数据更新频率合理设置
- 空值处理:避免缓存null值,浪费存储空间
- 版本控制:支持缓存版本管理,便于升级
7.2 性能优化技巧
- 批量操作:使用Redis Pipeline减少网络开销
- 压缩存储:大数据使用压缩算法减少存储空间
- 预热缓存:系统启动时预加载热点数据
- 监控告警:设置缓存命中率告警,及时发现问题
7.3 常见问题解决
- 缓存穿透:使用布隆过滤器或缓存空值
- 缓存雪崩:设置随机过期时间,避免同时失效
- 缓存击穿:使用分布式锁,避免热点数据重建
- 内存溢出:设置合理的过期时间和清理策略
8. 总结
通过系统性的缓存优化,我们成功解决了土壤调查系统的性能问题:
- 响应时间:从5秒优化到毫秒级
- 用户体验:大幅提升,页面加载流畅
- 系统稳定性:减少数据库压力,提高并发能力
- 开发效率:提供完整的缓存管理工具
缓存优化是一个持续的过程,需要根据业务特点和数据变化规律不断调整策略。希望本文的经验能够帮助到有类似需求的开发者。