前言

在开发某调查系统时,我们遇到了严重的性能问题: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 缓存策略

缓存层级存储位置大小限制过期时间用途
前端缓存localStorage5MB30分钟小数据快速响应
后端缓存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 在组件中使用

刷新数据
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() // 首次加载
})

5. 性能测试结果

5.1 优化前后对比

接口优化前优化后提升幅度
user接口5.66s15ms99.7%
geojson接口2.15s8ms99.6%
left面板8ms3ms62.5%
right面板8ms2ms75%

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秒优化到毫秒级
  • 用户体验:大幅提升,页面加载流畅
  • 系统稳定性:减少数据库压力,提高并发能力
  • 开发效率:提供完整的缓存管理工具

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

posted on 2025-09-14 10:01  ycfenxi  阅读(9)  评论(0)    收藏  举报