结果缓存机制:GeoAI-UP的执行结果缓存与失效策略
在空间分析系统中,缓存是提升性能的关键手段。GeoAI-UP实现了多层缓存架构,本文深入剖析其设计思想、实现细节和失效策略。
一、缓存架构全景
1.1 为什么需要多层缓存?
缓存的本质是一种空间换时间的策略,其核心目标是减少重复计算、降低响应延迟、优化资源利用。在空间分析系统中,数据处理涉及多个层次,不同层次的缓存有着不同的特点和适用场景。
缓存理论基础:
- 局部性原理:程序访问的数据在时间和空间上具有局部性,近期访问的数据很可能被再次访问(时间局部性),相邻位置的数据也可能被访问(空间局部性)
- 阿姆达尔定律:系统性能提升 = 1 / ((1 - 并行化比例) + 并行化比例 / 加速比)
- 缓存命中率公式:命中率 = 命中次数 / (命中次数 + 未命中次数) × 100%
在空间分析场景中,多层缓存尤为重要:
- 瓦片访问模式:用户浏览地图时,通常在一个区域内缩放和平移,热点瓦片会被频繁访问
- 分析结果复用:相同的分析参数可能被多次使用
- LLM交互成本高:Prompt模板加载和LLM调用都需要时间,缓存可以显著降低延迟
| 层次 | 延迟(ms) | 容量 | 持久性 | 适用场景 |
|---|---|---|---|---|
| HTTP客户端 | <1 | 小 | 会话级 | 静态资源、频繁访问的结果 |
| 服务端内存 | 1-10 | 中 | 进程级 | 热点数据、临时计算结果 |
| 文件系统 | 10-100 | 大 | 持久化 | 分析结果、瓦片存储 |
| 数据库 | 100-1000 | 超大 | 持久化 | 空间数据、索引 |
多层缓存的设计原理:
缓存层次设计遵循金字塔结构,从顶层到底层,延迟逐渐增加,容量逐渐增大:
-
L1缓存(HTTP客户端缓存):
- 位于用户浏览器或CDN边缘节点
- 延迟最低(<1ms),直接响应用户请求
- 存储静态资源和频繁访问的结果
- 设计目的:减少网络传输,降低服务器负载
-
L2缓存(服务端内存缓存):
- 位于应用进程内存中
- 延迟较低(1-10ms),O(1)读写复杂度
- 存储热点数据和临时计算结果
- 设计目的:避免重复计算,提升响应速度
-
L3缓存(文件系统缓存):
- 位于磁盘存储
- 延迟中等(10-100ms),持久化存储
- 存储分析结果和瓦片文件
- 设计目的:跨进程共享,支持服务重启恢复
-
L4缓存(数据库缓存):
- 位于PostGIS数据库
- 延迟较高(100-1000ms),容量最大
- 存储原始空间数据和空间索引
- 设计目的:支持复杂空间查询和事务
多层缓存协作原则:
- 向下查找:请求从顶层开始,逐层向下查找
- 向上写入:数据生成后,逐层向上缓存
- 智能失效:数据变更时,逐层清除相关缓存
1.2 多层缓存体系
┌─────────────────────────────────────────────────────────────────┐
│ GeoAI-UP缓存架构 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 第1层:HTTP客户端缓存 │ │
│ │ - Cache-Control响应头 (max-age控制过期时间) │ │
│ │ - 浏览器本地缓存 (减少重复请求) │ │
│ │ - CDN缓存 (全球加速) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ 未命中 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 第2层:服务端内存缓存 │ │
│ │ - Prompt模板缓存 (减少文件IO) │ │
│ │ - MVT瓦片缓存 (加速瓦片请求) │ │
│ │ - 数据访问层缓存 (减少数据库查询) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ 未命中 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 第3层:文件系统缓存 │ │
│ │ - MVT瓦片文件存储 (持久化) │ │
│ │ - GeoJSON/GeoTIFF结果文件 (分析结果持久化) │ │
│ │ - 元数据JSON文件 (配置信息) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ 未命中 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 第4层:数据库缓存 │ │
│ │ - PostGIS临时表 (中间结果) │ │
│ │ - 空间索引 (加速空间查询) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
1.3 缓存类型对比
| 缓存层 | 存储介质 | 生命周期 | 容量限制 | 失效策略 | 典型延迟 |
|---|---|---|---|---|---|
| HTTP客户端 | 浏览器/CDN | max-age控制 | 浏览器决定 | 时间过期 | <1ms |
| 内存缓存 | RAM | 进程存活期 | 配置上限(10000瓦片) | LRU淘汰 | 1-10ms |
| 文件系统 | 磁盘 | 持久化 | 磁盘容量 | 定时清理 | 10-100ms |
| 数据库 | PostGIS | 连接存活期 | 数据库限制 | 连接断开/DROP | 100-1000ms |
二、服务端内存缓存
2.1 Prompt模板缓存
原理说明:Prompt模板是LLM交互的核心,每次请求都需要加载模板。由于模板文件存储在磁盘上,频繁的文件IO会成为性能瓶颈。内存缓存将模板加载到内存中,避免重复读取。
性能分析:
- 磁盘IO延迟:约100ms/次
- 内存访问延迟:约1ms/次
- 性能提升:约100倍
缓存适用性判断:
- 数据大小:模板文件通常较小(KB级),适合内存存储
- 访问频率:高频访问,每次LLM调用都需要加载模板
- 更新频率:低频更新,通常在部署时更新
- 一致性要求:可接受短暂不一致,支持热更新机制
缓存设计模式选择:
- Singleton模式:全局唯一实例,避免重复创建
- Cache-Aside模式:先查缓存,未命中再加载,然后存入缓存
- Write-Through模式:更新时同时更新缓存(用于热更新)
// PromptManager.ts - 核心缓存实现
/**
* Prompt模板管理器
*
* 核心职责:
* 1. 从文件系统加载Prompt模板(Markdown格式)
* 2. 将模板解析后缓存到内存中,避免重复IO
* 3. 支持多语言模板的加载和切换(en-US, zh-CN等)
* 4. 提供缓存清理接口,支持热更新机制
*
* 缓存设计原理:
* - 使用TypeScript Map作为缓存容器,提供O(1)的查找和插入复杂度
* - 缓存键设计为 `${language}/${templateId}`,确保多语言模板隔离
* - 缓存失效策略:采用主动失效模式,模板文件修改后手动触发清理
* - 线程安全:Node.js单线程模型天然保证Map操作的原子性
*/
export class PromptManager {
private promptsDir: string; // 模板文件存放目录,如:workspace/llm/prompts/
private templateCache: Map<string, PromptTemplate> = new Map(); // 内存缓存容器
/**
* 构造函数
* @param promptsDir - Prompt模板文件所在目录的绝对路径
*/
constructor(promptsDir: string) {
this.promptsDir = promptsDir;
console.log(`[PromptManager] Initialized with prompts directory: ${promptsDir}`);
}
/**
* 加载Prompt模板(核心方法)
*
* @param templateId - 模板标识符,对应文件名(不含扩展名)
* @param language - 语言代码,如 'en-US', 'zh-CN'(默认英文)
* @returns Promise<PromptTemplate> - 加载并解析后的模板对象
*
* 执行流程:
* 1. 构建缓存键:language/templateId
* 2. 检查内存缓存(O(1))
* 3. 缓存命中:直接返回缓存的模板对象
* 4. 缓存未命中:从文件系统读取 → 解析 → 存入缓存 → 返回
*/
async loadTemplate(templateId: string, language: string = 'en-US'): Promise<PromptTemplate> {
// 构建缓存键:语言+模板ID,确保多语言隔离
// 例如:zh-CN/goal-splitting, en-US/task-planning
const cacheKey = `${language}/${templateId}`;
// 缓存命中检查:Map.has() 复杂度 O(1)
if (this.templateCache.has(cacheKey)) {
console.log(`[PromptManager] Cache hit for: ${cacheKey}`);
return this.templateCache.get(cacheKey)!; // 非空断言:已确认存在
}
// 缓存未命中:需要从文件系统加载
console.log(`[PromptManager] Cache miss for: ${cacheKey}, loading from file...`);
// 构建文件路径:{promptsDir}/{language}/{templateId}.md
const filePath = path.join(this.promptsDir, language, `${templateId}.md`);
// 加载并解析模板文件
// loadFromFile内部会处理文件读取、Markdown解析、变量提取等操作
const template = await this.loadFromFile(filePath, templateId, language);
// 存入缓存:后续相同请求直接从内存获取
this.templateCache.set(cacheKey, template);
return template;
}
/**
* 从文件系统加载模板(内部方法)
* @param filePath - 模板文件的绝对路径
* @param templateId - 模板标识符
* @param language - 语言代码
* @returns Promise<PromptTemplate> - 解析后的模板对象
*/
private async loadFromFile(filePath: string, templateId: string, language: string): Promise<PromptTemplate> {
// 实际实现会包含:
// 1. 文件存在性检查
// 2. 读取文件内容(utf-8编码)
// 3. 解析Markdown格式
// 4. 提取模板变量(如 {{input}}, {{context}})
// 5. 返回结构化的PromptTemplate对象
// 简化实现示例:
const content = await fs.promises.readFile(filePath, 'utf-8');
return {
id: templateId,
language,
content,
variables: this.extractVariables(content), // 提取模板变量
compiled: this.compileTemplate(content) // 预编译模板
};
}
/**
* 提取模板中的变量
* @param content - 模板内容
* @returns string[] - 变量名数组
*/
private extractVariables(content: string): string[] {
const matches = content.match(/\{\{(\w+)\}\}/g) || [];
return matches.map(m => m.slice(2, -2)); // 去除 {{ 和 }}
}
/**
* 编译模板为可执行函数
* @param content - 模板内容
* @returns (data: Record<string, string>) => string - 编译后的函数
*/
private compileTemplate(content: string): (data: Record<string, string>) => string {
// 使用正则替换实现简单的模板编译
return (data: Record<string, string>) => {
return content.replace(/\{\{(\w+)\}\}/g, (_, key) => data[key] || '');
};
}
/**
* 缓存清理方法(支持热更新)
*
* @param templateId - 可选,指定清理的模板ID,不指定则清理所有
*
* 设计意图:
* - 支持单个模板清理:用于热更新场景,模板文件修改后清理对应缓存
* - 支持全部清理:用于系统重启或大规模更新场景
* - 原子操作:遍历并删除,避免部分清理导致的不一致
*/
clearCache(templateId?: string): void {
if (templateId) {
// 清除特定模板的所有语言版本
console.log(`[PromptManager] Clearing cache for template: ${templateId}`);
// 收集需要删除的键,避免在遍历过程中修改Map
const keysToDelete: string[] = [];
for (const key of this.templateCache.keys()) {
if (key.endsWith(`/${templateId}`)) {
keysToDelete.push(key);
}
}
// 批量删除
for (const key of keysToDelete) {
this.templateCache.delete(key);
}
} else {
// 清除所有缓存
console.log(`[PromptManager] Clearing all cache (${this.templateCache.size} entries)`);
this.templateCache.clear();
}
}
/**
* 获取缓存统计信息
* @returns {size: number} - 当前缓存条目数量
*/
getCacheStats(): { size: number } {
return { size: this.templateCache.size };
}
}
设计要点深度解析:
-
缓存键设计原理
- 使用
${language}/${templateId}作为键,确保不同语言的同名模板不会冲突 - 例如:
en-US/goal-splitting和zh-CN/goal-splitting是两个独立的缓存条目
- 使用
-
缓存命中率计算
- 命中率 = 命中次数 / (命中次数 + 未命中次数)
- 对于Prompt模板,由于模板数量有限且访问频繁,命中率通常在95%以上
-
缓存失效时机
- 模板文件修改时(通过文件监听触发clearCache)
- 系统启动时(缓存为空,首次加载)
- 手动触发热更新
2.2 MVT瓦片缓存
原理说明:MVT(Mapbox Vector Tile)瓦片是地图渲染的核心数据格式,每个瓦片需要从PostGIS数据库查询空间数据并编码为Protocol Buffers格式。由于地图浏览具有强局部性特征(用户通常在一个区域内缩放和平移),热点瓦片会被频繁访问。内存缓存可以显著减少重复计算,提升地图加载性能。
MVT瓦片生成性能分析:
- 从PostGIS查询并生成一个瓦片:约500ms
- 内存缓存访问:约1ms
- 性能提升:约500倍
瓦片访问模式分析:
- 空间局部性:用户在地图上平移时,相邻瓦片会被连续访问
- 时间局部性:同一区域的瓦片在缩放操作中会被多次访问
- 金字塔访问:缩放时会访问不同级别的瓦片
缓存容量计算:
- 单个瓦片平均大小:~50KB
- 缓存容量:10000个瓦片
- 内存占用:~500MB
- 设计权衡:平衡内存占用和缓存命中率,500MB是大多数服务器可接受的内存开销
// MVTOnDemandPublisher.ts - 瓦片缓存实现
/**
* 瓦片缓存接口定义
*
* 设计原则:
* 1. **简洁性**:只暴露必要的操作(get/set/has/clear),保持接口最小化
* 2. **类型安全**:使用TypeScript接口约束实现,确保类型一致性
* 3. **可替换性**:支持不同缓存实现(内存、Redis、文件系统等)
* 4. **解耦性**:发布器依赖抽象接口而非具体实现,便于测试和替换
*/
export interface TileCache {
/**
* 获取缓存数据
* @param key - 缓存键
* @returns Buffer | null - 瓦片数据(Buffer格式)或null(未命中)
*/
get(key: string): Buffer | null;
/**
* 设置缓存数据
* @param key - 缓存键
* @param data - 瓦片数据(Buffer格式)
*/
set(key: string, data: Buffer): void;
/**
* 检查缓存是否存在
* @param key - 缓存键
* @returns boolean - 是否存在
*/
has(key: string): boolean;
/**
* 清空所有缓存
*/
clear(): void;
}
/**
* 内存瓦片缓存实现(FIFO版本)
*
* 核心特性:
* - 基于ES6 Map实现,提供O(1)的读写复杂度
* - 容量限制:防止内存溢出,保护系统稳定性
* - FIFO淘汰策略:优先删除最早加入的条目
*
* 设计权衡:
* - 使用FIFO而非真正的LRU:实现简单,性能更好,无需维护访问顺序
* - 容量设为10000:平衡内存占用(~500MB)和缓存命中率
* - 适用于瓦片访问模式相对均匀的场景
*
* 数据结构选择:
* - Map vs Object:Map保持插入顺序(ES6规范),适合FIFO实现
* - Buffer存储:瓦片数据为二进制格式,Buffer是Node.js最佳选择
*/
class InMemoryTileCache implements TileCache {
private cache: Map<string, Buffer> = new Map(); // 缓存容器,键为瓦片标识,值为二进制数据
private maxSize: number; // 缓存容量上限,防止内存无限增长
/**
* 构造函数
* @param maxSize - 缓存最大容量(默认10000个瓦片)
*
* 容量选择原理:
* - 单个MVT瓦片平均大小:~50KB(根据数据复杂度有所差异)
* - 10000个瓦片 × 50KB = ~500MB内存占用
* - 500MB是合理的平衡点:既保证较高命中率,又不会占用过多内存
* - 可根据服务器配置调整此参数
*/
constructor(maxSize: number = 10000) {
this.maxSize = maxSize;
console.log(`[InMemoryTileCache] Initialized with max size: ${maxSize} tiles`);
console.log(`[InMemoryTileCache] Estimated memory usage: ${(maxSize * 50 / 1024).toFixed(1)} MB`);
}
/**
* 获取缓存数据
* @param key - 缓存键,格式:${tilesetId}:${z}:${x}:${y}
* @returns Buffer | null - 瓦片数据或null(未命中)
*
* 执行流程:
* 1. 使用Map.get()查找(O(1)复杂度)
* 2. 记录调试日志(生产环境可关闭)
* 3. 返回数据或null
*/
get(key: string): Buffer | null {
const data = this.cache.get(key);
if (data) {
console.debug(`[InMemoryTileCache] Hit: ${key}`);
} else {
console.debug(`[InMemoryTileCache] Miss: ${key}`);
}
return data || null;
}
/**
* 设置缓存数据
* @param key - 缓存键,格式:${tilesetId}:${z}:${x}:${y}
* @param data - 瓦片数据(Buffer格式)
*
* 执行流程:
* 1. 检查缓存容量是否达到上限
* 2. 若达到上限,执行FIFO淘汰(删除最早插入的条目)
* 3. 将新数据插入缓存(Map会自动将其放在最后)
*
* FIFO淘汰策略原理:
* - ES6 Map保持插入顺序,keys()返回的第一个元素即为最早插入的
* - 使用Iterator API获取第一个键:this.cache.keys().next().value
* - 时间复杂度:O(1)(获取第一个元素) + O(1)(删除) + O(1)(插入)
*/
set(key: string, data: Buffer): void {
// 容量检查:超过上限时执行FIFO淘汰
if (this.cache.size >= this.maxSize) {
// 获取第一个键(最早插入的)
const firstKey = this.cache.keys().next().value;
if (firstKey) {
console.debug(`[InMemoryTileCache] Evicting (FIFO): ${firstKey}`);
this.cache.delete(firstKey);
}
}
// 插入新条目(Map会自动将其放在最后,成为最新的)
this.cache.set(key, data);
console.debug(`[InMemoryTileCache] Added: ${key}, size: ${data.length} bytes`);
}
/**
* 检查缓存是否存在
* @param key - 缓存键
* @returns boolean - 是否存在
*
* 注意:此方法不记录访问时间,仅用于存在性检查
*/
has(key: string): boolean {
return this.cache.has(key);
}
/**
* 清空所有缓存
*
* 使用场景:
* - 系统维护期间
* - 大规模数据更新后
* - 内存占用过高时
*/
clear(): void {
console.log(`[InMemoryTileCache] Cleared ${this.cache.size} entries`);
this.cache.clear();
}
/**
* 获取缓存统计信息
* @returns {size: number, maxSize: number} - 当前缓存大小和容量上限
*/
getStats(): { size: number; maxSize: number } {
return {
size: this.cache.size,
maxSize: this.maxSize
};
}
}
瓦片缓存键设计原理:
// 缓存键 = tilesetId:zoom:x:y
const cacheKey = `${tilesetId}:${z}:${x}:${y}`;
/**
* 键设计原理:
*
* 1. tilesetId:瓦片集唯一标识,由UUID生成或数据库主键
* - 作用:区分不同的数据源和分析结果
* - 示例:mvt_abc123, mvt_buffer_result_xyz
*
* 2. zoom(z):缩放级别,范围0-18(Web Mercator标准)
* - 作用:标识地图缩放层级
* - 级别0:全球视图,级别18:最大缩放(约1米精度)
*
* 3. x/y:瓦片坐标(TMS/OSM坐标系)
* - TMS(Tile Map Service)坐标系
* - 原点在左下角,从0,0开始
* - 每个缩放级别下,瓦片数量为 2^z × 2^z
*
* 示例:mvt_abc123:10:345:678
* - 瓦片集:mvt_abc123
* - 缩放级别:10(约150米精度)
* - X坐标:345
* - Y坐标:678
*
* 唯一性保证:
* - tilesetId全局唯一(UUID或业务主键)
* - (z,x,y)在同一瓦片集内唯一标识一个瓦片
* - 四部分组合后确保全局唯一
*
* 命名规范:
* - 使用冒号(:)作为分隔符(URI-safe,无需编码)
* - 避免使用特殊字符,确保跨平台兼容性
* - 格式简洁,便于日志阅读和调试
*/
缓存键设计的工程考量:
| 设计要素 | 考虑内容 | 决策理由 |
|---|---|---|
| 唯一性 | 使用UUID作为tilesetId | 避免冲突,支持分布式部署 |
| 可读性 | 使用冒号分隔,结构清晰 | 便于调试和日志分析 |
| 可扩展性 | 预留版本号位置 | 支持未来版本化缓存 |
| 性能 | 字符串拼接开销小 | Map查找为O(1),键长度影响可忽略 |
| 兼容性 | 不使用特殊字符 | 支持文件系统存储(部分系统对文件名有限制) |
缓存键演变路径:
// 第一版:简单但不够唯一
const v1Key = `${z}-${x}-${y}`; // ❌ 不同瓦片集冲突
// 第二版:增加瓦片集标识
const v2Key = `${tilesetId}:${z}:${x}:${y}`; // ✅ 当前实现
// 第三版:支持版本管理(未来扩展)
const v3Key = `${tilesetId}:v1.0:${z}:${x}:${y}`; // 支持数据版本
// 第四版:支持数据校验(高级)
const v4Key = `${tilesetId}:${dataHash}:${z}:${x}:${y}`; // 数据变化自动失效
缓存使用流程详解:
┌─────────────────────────────────────────────────────────────────┐
│ 瓦片请求缓存流程 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. Client Request: /api/mvt/{tilesetId}/{z}/{x}/{y}.pbf │
│ │ │
│ ▼ │
│ 2. 构建缓存键: `${tilesetId}:${z}:${x}:${y}` │
│ │ │
│ ▼ │
│ 3. 内存缓存命中? │
│ │ │
│ Yes │ No │
│ │ │ │ │
│ ▼ │ ▼ │
│ 返回缓存 │ 检查文件系统缓存 │
│ 数据 │ │ │
│ │ ▼ │
│ │ 文件缓存命中? │
│ │ │ │
│ │ Yes │ No │
│ │ │ │ │ │
│ │ ▼ │ ▼ │
│ │ 返回 │ 查询PostGIS生成瓦片 │
│ │ 文件 │ │ │
│ │ │ ▼ │
│ │ │ 存入内存缓存 + 文件缓存 │
│ │ │ │ │
│ └───────┴─────┘ │
│ ▼ │
│ 返回瓦片数据给客户端 │
│ │
└─────────────────────────────────────────────────────────────────┘
三、HTTP客户端缓存
3.1 Cache-Control策略原理
原理说明:HTTP缓存通过响应头控制浏览器和CDN的缓存行为。合理的缓存策略可以显著减少重复请求,提升用户体验。
// ResultController.ts - 不同资源的缓存策略
/**
* 服务GeoJSON分析结果
*
* 缓存策略设计:
* - max-age=3600(1小时):分析结果相对稳定,但可能被更新
* - public:允许CDN和代理缓存
* - Access-Control-Allow-Origin:支持跨域访问
*/
async serveGeoJSON(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const filePath = path.join(this.workspaceBase, 'results', 'geojson', `${id}.geojson`);
// 文件存在检查
if (!fs.existsSync(filePath)) {
res.status(404).json({
success: false,
error: 'Result not found'
});
return;
}
// 读取文件内容
const content = fs.readFileSync(filePath, 'utf-8');
// 设置HTTP缓存头
// public: 允许任何缓存(浏览器、CDN、代理)存储此响应
// max-age=3600: 缓存有效时间为1小时(3600秒)
res.setHeader('Cache-Control', 'public, max-age=3600');
// 允许跨域访问
res.setHeader('Access-Control-Allow-Origin', '*');
// 设置正确的Content-Type
res.setHeader('Content-Type', 'application/json');
res.send(content);
} catch (error) {
console.error('[Result Controller] Error serving GeoJSON:', error);
res.status(500).json({ success: false, error: 'Internal server error' });
}
}
/**
* 服务GeoTIFF栅格数据
*
* 缓存策略设计:
* - max-age=3600(1小时):栅格数据通常较大,缓存可减少带宽消耗
* - 使用流式传输:避免一次性加载大文件到内存
*/
async serveGeoTIFF(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const filePath = path.join(this.workspaceBase, 'results', 'geotiff', `${id}.tif`);
if (!fs.existsSync(filePath)) {
res.status(404).json({ success: false, error: 'GeoTIFF not found' });
return;
}
// 设置缓存头
res.setHeader('Cache-Control', 'public, max-age=3600');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Content-Type', 'image/tiff');
res.setHeader('Content-Disposition', `inline; filename="${id}.tif"`);
// 流式传输:适合大文件,减少内存占用
const stream = fs.createReadStream(filePath);
stream.pipe(res);
stream.on('error', (error) => {
console.error('[Result Controller] Stream error:', error);
if (!res.headersSent) {
res.status(500).json({ success: false, error: 'File read error' });
}
});
} catch (error) {
console.error('[Result Controller] Error serving GeoTIFF:', error);
res.status(500).json({ success: false, error: 'Internal server error' });
}
}
不同资源的缓存策略对比:
| 资源类型 | Cache-Control | 时长 | 设计原理 |
|---|---|---|---|
| GeoJSON结果 | public, max-age=3600 |
1小时 | 分析结果相对稳定,1小时内重复请求可直接返回 |
| GeoTIFF结果 | public, max-age=3600 |
1小时 | 栅格数据较大,缓存减少带宽消耗 |
| Heatmap结果 | public, max-age=3600 |
1小时 | 热力图计算成本高,缓存避免重复计算 |
| HTML报告 | public, max-age=3600 |
1小时 | 报告生成后通常不会变更 |
| WMS服务 | public, max-age=86400 |
24小时 | 影像数据更新频率低 |
| MVT瓦片 | public, max-age=86400 |
24小时 | 空间数据相对稳定,瓦片复用率高 |
| Chat API | no-cache |
实时 | 对话消息需要最新,禁止缓存 |
Cache-Control指令详解:
| 指令 | 含义 | 适用场景 |
|---|---|---|
public |
允许任何缓存存储 | 公共资源、静态文件 |
private |
仅允许浏览器缓存 | 敏感数据、用户特定内容 |
no-cache |
必须向服务器验证 | 实时数据、动态内容 |
no-store |
禁止任何缓存 | 敏感信息、一次性数据 |
max-age=N |
缓存有效时间(秒) | 所有需要缓存的资源 |
must-revalidate |
过期后必须验证 | 需要 freshness 保证的资源 |
3.2 缓存策略设计原则
┌─────────────────────────────────────────────────────────────────┐
│ 缓存策略决策矩阵 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 资源类型 变化频率 缓存时长 设计原理 │
│ ───────────────────────────────────────────────────────────── │
│ │
│ 分析结果 中等 1小时 分析任务完成后稳定 │
│ (可能被更新) 但需要支持更新通知 │
│ │
│ 地图瓦片 低 24小时 空间数据相对稳定 │
│ (极少更新) 瓦片复用率高 │
│ │
│ WMS服务 低 24小时 影像数据更新频率低 │
│ (每日/每周) 减少服务器压力 │
│ │
│ Chat消息 高 no-cache 实时对话需最新 │
│ (每条都新) 禁止缓存 │
│ │
│ Prompt模板 低 进程周期 部署时更新,运行时稳定 │
│ (部署更新) 内存缓存即可 │
│ │
└─────────────────────────────────────────────────────────────────┘
四、文件系统缓存
4.1 结果文件组织原理
原理说明:文件系统缓存是持久化存储层,用于保存分析结果和瓦片数据。合理的目录结构设计可以提高文件管理效率和访问性能。
// BaseMVTPublisher.ts - 文件系统组织
/**
* MVT发布器基类
*
* 核心职责:
* 1. 管理输出目录结构
* 2. 提供元数据持久化能力
* 3. 统一瓦片存储和读取接口
*
* 目录结构设计原理:
* - 将不同类型的结果分开存储
* - 使用唯一ID作为文件名,避免冲突
* - 支持跨平台路径处理
*/
export class BaseMVTPublisher {
protected workspaceBase: string; // 工作空间根目录
protected mvtOutputDir: string; // MVT瓦片输出目录
/**
* 构造函数
* @param workspaceBase - 工作空间根目录
* @param outputSubdir - 输出子目录名(默认'mvt')
*/
constructor(workspaceBase: string, outputSubdir: string = 'mvt') {
this.workspaceBase = workspaceBase;
this.mvtOutputDir = this.ensureOutputDir(outputSubdir);
console.log(`[BaseMVTPublisher] Initialized with output dir: ${this.mvtOutputDir}`);
}
/**
* 确保输出目录存在
* @param subdir - 子目录名
* @returns string - 完整目录路径
*
* 设计原理:
* - 使用path.join处理跨平台路径
* - recursive: true 确保创建所有父目录
* - 幂等操作:多次调用不会出错
*/
protected ensureOutputDir(subdir: string): string {
// 构建完整路径:{workspace}/results/{subdir}
const dir = path.join(this.workspaceBase, 'results', subdir);
// 如果目录不存在则创建
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
console.log(`[BaseMVTPublisher] Created directory: ${dir}`);
}
return dir;
}
}
文件系统目录结构设计:
workspace/ # 工作空间根目录
├── results/ # 所有结果文件根目录
│ ├── geojson/ # GeoJSON分析结果
│ │ ├── {resultId}.geojson # 分析结果文件
│ │ └── ...
│ ├── geotiff/ # GeoTIFF栅格结果
│ │ ├── {resultId}.tif # 栅格数据文件
│ │ └── ...
│ ├── heatmaps/ # 热力图结果
│ │ ├── {resultId}.geojson # 热力图GeoJSON
│ │ └── ...
│ ├── reports/ # HTML报告
│ │ ├── {resultId}.html # 报告文件
│ │ └── ...
│ └── mvt/ # MVT瓦片
│ ├── {tilesetId}/ # 瓦片集目录
│ │ ├── metadata.json # 瓦片集元数据
│ │ ├── tilejson.json # TileJSON配置(可选)
│ │ └── {z}/ # 缩放级别目录
│ │ └── {x}/ # X坐标目录
│ │ └── {y}.pbf # 瓦片文件
│ └── ...
目录结构设计原理:
- 按类型分离:不同类型的结果存储在不同目录,便于管理和清理
- 唯一ID命名:使用UUID或时间戳生成唯一ID,避免文件名冲突
- 层级化瓦片存储:
{z}/{x}/{y}.pbf结构符合OGC标准,便于CDN缓存 - 元数据独立:metadata.json存储瓦片集信息,便于快速检索
4.2 元数据持久化原理
// BaseMVTPublisher.ts - 元数据管理
/**
* 保存瓦片集元数据到磁盘
* @param tilesetId - 瓦片集唯一标识
* @param metadata - 元数据对象
*
* 设计原理:
* - 将元数据序列化为JSON格式
* - 存储在瓦片集目录下,与瓦片文件同目录
* - 便于加载和管理
*/
protected saveMetadata(tilesetId: string, metadata: any): void {
// 确保瓦片集目录存在
const tilesetDir = path.join(this.mvtOutputDir, tilesetId);
if (!fs.existsSync(tilesetDir)) {
fs.mkdirSync(tilesetDir, { recursive: true });
}
// 构建元数据文件路径
const metadataPath = path.join(tilesetDir, 'metadata.json');
// 序列化并写入文件(格式化输出,便于阅读)
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
console.log(`[BaseMVTPublisher] Saved metadata: ${metadataPath}`);
}
/**
* 从磁盘加载瓦片集元数据
* @param tilesetId - 瓦片集唯一标识
* @returns any | null - 元数据对象或null(不存在时)
*
* 设计原理:
* - 读取JSON文件并解析
* - 处理文件不存在和解析错误的情况
* - 返回null表示元数据不存在
*/
protected loadMetadata(tilesetId: string): any | null {
const metadataPath = path.join(this.mvtOutputDir, tilesetId, 'metadata.json');
// 检查文件是否存在
if (fs.existsSync(metadataPath)) {
try {
// 读取并解析JSON
const content = fs.readFileSync(metadataPath, 'utf-8');
const metadata = JSON.parse(content);
console.log(`[BaseMVTPublisher] Loaded metadata: ${tilesetId}`);
return metadata;
} catch (error) {
// 解析错误处理
console.error(`[BaseMVTPublisher] Failed to load metadata for ${tilesetId}:`, error);
return null;
}
}
// 文件不存在
return null;
}
元数据结构详解:
{
"tilesetId": "mvt_1234567890_abc",
"name": "缓冲区分析结果", // 显示名称
"description": "500米缓冲区分析", // 描述信息
"dataSource": "postgis://localhost:5432/db/table", // 数据源
"createdAt": "2024-01-15T10:30:00Z", // 创建时间
"updatedAt": "2024-01-15T10:35:00Z", // 更新时间
"tileInfo": {
"minZoom": 0, // 最小缩放级别
"maxZoom": 18, // 最大缩放级别
"bounds": [-180, -90, 180, 90] // 地理范围
},
"properties": {
"sourceType": "postgis", // 数据源类型
"featureCount": 1000, // 要素数量
"geometryType": "Polygon" // 几何类型
}
}
元数据字段设计原理:
| 字段 | 用途 | 设计意图 |
|---|---|---|
| tilesetId | 唯一标识 | 用于URL和文件路径 |
| name/description | 显示信息 | 前端展示用 |
| dataSource | 数据源信息 | 追踪数据来源 |
| createdAt/updatedAt | 时间戳 | 缓存失效判断 |
| tileInfo | 瓦片配置 | 前端渲染配置 |
| properties | 扩展属性 | 灵活存储额外信息 |
五、缓存失效策略
5.1 内存缓存失效原理
原理说明:缓存失效(Cache Invalidation)是保证数据一致性的关键机制。当底层数据源发生变化时,必须清除相关的缓存条目,否则用户会获取到过期(stale)数据。
缓存一致性模型:
- 强一致性:缓存与数据源实时同步,任何修改立即反映到缓存
- 最终一致性:缓存与数据源在一段时间后达到一致
- GeoAI-UP选择:采用主动失效+时间过期的混合策略,平衡一致性和性能
缓存失效策略对比:
| 策略 | 实现方式 | 一致性 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| 主动失效 | 数据变更时显式清除缓存 | 强一致性 | 中 | 写少读多的场景 |
| 时间过期 | 设置TTL自动过期 | 最终一致性 | 低 | 数据变化不频繁 |
| 版本管理 | 缓存键包含版本号 | 强一致性 | 高 | 需要精确控制 |
| 事件驱动 | 监听数据变更事件 | 强一致性 | 高 | 分布式系统 |
失效时机分析:
- 立即失效:数据修改后立即清除缓存,适用于一致性要求高的场景
- 延迟失效:数据修改后延迟一段时间再清除,适用于写频繁场景
- 异步失效:通过消息队列异步清除,适用于分布式系统
GeoAI-UP的失效策略设计:
- 主动失效为主:删除瓦片集时同步清除相关缓存
- 时间过期为辅:文件系统缓存设置24小时过期
- 容量淘汰兜底:内存缓存满时自动淘汰
// MVTOnDemandPublisher.ts - 缓存失效机制
/**
* MVT按需发布器(单例模式)
*
* 核心职责:
* 1. 管理瓦片缓存生命周期(创建、读取、更新、删除)
* 2. 处理缓存失效和清理,保证数据一致性
* 3. 协调内存缓存和文件缓存的协作
* 4. 提供单例访问,确保全局缓存一致性
*
* 设计模式:
* - **Singleton模式**:全局唯一实例,避免重复创建缓存容器
* - **Facade模式**:提供统一接口管理多种缓存类型
* - **Observer模式**:可扩展为监听数据变更事件
*/
export class MVTOnDemandPublisher extends BaseMVTPublisher {
private tileCache: TileCache; // 瓦片内存缓存(核心)
private geojsonTileIndexes: Map<string, any>; // GeoJSON瓦片索引缓存
private postgisConfigs: Map<string, PostGISDataSource>; // PostGIS配置缓存
private tilesetMetadata: Map<string, MVTPublishMetadata>; // 元数据内存缓存
// 单例实例(私有静态)
private static instance: MVTOnDemandPublisher | null = null;
/**
* 获取单例实例
* @returns MVTOnDemandPublisher - 单例实例
*/
public static getInstance(): MVTOnDemandPublisher {
if (!MVTOnDemandPublisher.instance) {
MVTOnDemandPublisher.instance = new MVTOnDemandPublisher();
}
return MVTOnDemandPublisher.instance;
}
/**
* 删除瓦片集(完整流程)
*
* @param tilesetId - 瓦片集ID
* @returns boolean - 是否删除成功
*
* 设计原理:
* - **两步删除策略**:先删除持久化存储,再清除内存缓存
* - **顺序保证**:文件系统删除成功后才清除内存,避免数据丢失
* - **原子性保障**:要么全部删除,要么全部保留
*
* 执行流程:
* 1. 删除文件系统中的瓦片集目录
* 2. 清除内存中该瓦片集的所有瓦片缓存
* 3. 清除相关的索引、配置和元数据
* 4. 记录日志
*/
deleteTileset(tilesetId: string): boolean {
console.log(`[MVT On-Demand Publisher] Deleting tileset: ${tilesetId}`);
// 第一步:调用父类方法删除文件系统中的瓦片集
// 父类会递归删除瓦片集目录及其所有文件
const deleted = super.deleteTileset(tilesetId);
if (deleted) {
// 第二步:清除内存中该瓦片集的所有瓦片缓存
this.clearTilesetCache(tilesetId);
// 第三步:清除相关的辅助缓存
this.geojsonTileIndexes.delete(tilesetId);
this.postgisConfigs.delete(tilesetId);
this.tilesetMetadata.delete(tilesetId);
console.log(`[MVT On-Demand Publisher] Successfully deleted tileset: ${tilesetId}`);
} else {
console.warn(`[MVT On-Demand Publisher] Tileset not found or failed to delete: ${tilesetId}`);
}
return deleted;
}
/**
* 清除特定瓦片集的内存缓存(核心方法)
*
* @param tilesetId - 瓦片集ID
*
* 设计原理:
* - **前缀匹配删除**:缓存键格式为 `${tilesetId}:${z}:${x}:${y}`
* - **分两步操作**:先收集再删除,避免遍历过程中修改Map
* - **高效删除**:批量删除比逐个删除更高效
*
* 时间复杂度分析:
* - 遍历缓存:O(n),n为缓存条目数
* - 批量删除:O(k),k为匹配的条目数
* - 总体:O(n + k)
*/
private clearTilesetCache(tilesetId: string): void {
// 收集需要删除的缓存键(避免在遍历过程中修改Map)
const keysToDelete: string[] = [];
const prefix = `${tilesetId}:`;
// 遍历所有缓存键,匹配前缀
for (const key of this.tileCache.keys()) {
if (key.startsWith(prefix)) {
keysToDelete.push(key);
}
}
// 批量删除匹配的缓存条目
const deletedCount = keysToDelete.length;
for (const key of keysToDelete) {
this.tileCache.delete(key);
}
console.log(`[MVT On-Demand Publisher] Cleared ${deletedCount} tiles from cache for tileset: ${tilesetId}`);
}
/**
* 清空所有内存缓存
*
* 使用场景:
* - 系统维护期间
* - 大规模数据更新后
* - 内存占用过高时
* - 测试环境重置
*
* 设计原理:
* - 重置所有缓存容器为初始状态
* - 不影响文件系统缓存(持久化数据保留)
* - 用于快速恢复系统状态
*/
clearAllCache(): void {
const tileCount = this.tileCache.getStats ? this.tileCache.getStats().size : 0;
const indexCount = this.geojsonTileIndexes.size;
const configCount = this.postgisConfigs.size;
const metadataCount = this.tilesetMetadata.size;
// 清除所有缓存容器
this.tileCache.clear();
this.geojsonTileIndexes.clear();
this.postgisConfigs.clear();
this.tilesetMetadata.clear();
console.log(`[MVT On-Demand Publisher] All cache cleared:`);
console.log(` - Tiles: ${tileCount}`);
console.log(` - GeoJSON Indexes: ${indexCount}`);
console.log(` - PostGIS Configs: ${configCount}`);
console.log(` - Metadata: ${metadataCount}`);
}
/**
* 清除单个瓦片缓存
* @param tilesetId - 瓦片集ID
* @param z - 缩放级别
* @param x - X坐标
* @param y - Y坐标
*/
clearTileCache(tilesetId: string, z: number, x: number, y: number): void {
const cacheKey = `${tilesetId}:${z}:${x}:${y}`;
if (this.tileCache.has(cacheKey)) {
this.tileCache.delete(cacheKey);
console.debug(`[MVT On-Demand Publisher] Cleared tile: ${cacheKey}`);
}
}
}
5.2 文件系统清理原理
原理说明:文件系统缓存是持久化的,不会随进程结束而消失。由于分析结果和瓦片文件可能占用大量磁盘空间,需要定期清理过期文件以释放存储资源。
文件系统缓存特点:
- 持久性:数据存储在磁盘上,进程重启后仍然存在
- 大容量:可利用磁盘的大容量特性存储大量数据
- 慢速访问:磁盘IO延迟远高于内存
- 需要管理:不会自动过期,需要人工干预或定时任务清理
清理策略设计:
- 基于时间的清理:删除超过一定时间未访问的文件
- 基于大小的清理:当磁盘占用超过阈值时清理
- 基于类型的清理:不同类型文件设置不同的过期时间
- 混合策略:结合时间和大小进行清理
过期时间选择:
- 分析结果(GeoJSON/GeoTIFF):24小时,分析任务完成后结果相对稳定,但用户通常在短时间内查看
- 瓦片文件:7天,瓦片复用率高,但也会随着数据更新而失效
- 临时文件:1小时,临时数据生命周期短
清理时机:
- 定时任务:每天凌晨3点(低峰期)执行
- 按需触发:磁盘空间不足时触发
- 事件驱动:分析任务完成后触发清理
/**
* 定时清理过期结果文件
*
* @param maxAgeHours - 文件最大存活时间(小时),默认24小时
* @returns Promise<void>
*
* 设计原理:
* - **时间判断**:使用文件修改时间(mtime)判断是否过期
* - **分层遍历**:遍历所有结果类型目录
* - **递归删除**:支持删除目录及其所有子文件
* - **幂等操作**:重复执行不会产生错误
*
* 清理策略:
* - **基于修改时间**:mtime反映文件最后修改时间
* - **递归删除目录**:使用 fs.rmSync 的 recursive 选项
* - **保留最近使用**:仅删除超过maxAgeHours的文件
* - **跳过不存在**:目录不存在时跳过,避免错误
*
* 执行流程:
* 1. 计算过期时间戳(当前时间 - maxAgeHours)
* 2. 遍历所有结果类型目录
* 3. 检查每个文件/目录的修改时间
* 4. 删除过期的文件或目录
* 5. 记录清理日志
*/
async cleanupExpiredResults(maxAgeHours: number = 24): Promise<void> {
// 构建结果目录路径
const resultsDir = path.join(this.workspaceBase, 'results');
// 目录不存在则直接返回(幂等性保障)
if (!fs.existsSync(resultsDir)) {
console.log(`[Cleanup] Results directory not found: ${resultsDir}`);
return;
}
// 计算过期时间戳
// 当前时间减去最大存活时间,得到过期阈值
const expireTime = Date.now() - maxAgeHours * 3600 * 1000;
console.log(`[Cleanup] Starting cleanup - expiring files older than: ${new Date(expireTime).toISOString()}`);
// 需要清理的目录列表(按类型分类)
// 每种类型可以设置不同的过期策略(当前统一使用maxAgeHours)
const dirsToClean = [
'geojson', // GeoJSON分析结果
'geotiff', // GeoTIFF栅格结果
'heatmaps', // 热力图结果
'reports', // HTML报告
'mvt' // MVT瓦片
];
let totalFilesRemoved = 0;
let totalDirsRemoved = 0;
// 遍历每个类型目录
for (const dirName of dirsToClean) {
const fullPath = path.join(resultsDir, dirName);
// 子目录不存在则跳过
if (!fs.existsSync(fullPath)) {
console.debug(`[Cleanup] Skipping non-existent directory: ${fullPath}`);
continue;
}
// 获取目录下所有条目(使用withFileTypes避免二次stat调用)
const entries = fs.readdirSync(fullPath, { withFileTypes: true });
// 遍历每个条目
for (const entry of entries) {
const entryPath = path.join(fullPath, entry.name);
try {
// 获取文件/目录的stat信息
const stats = fs.statSync(entryPath);
// 判断是否过期:修改时间早于过期时间戳
if (stats.mtime.getTime() < expireTime) {
if (entry.isDirectory()) {
// 递归删除目录(包含所有子文件和子目录)
// force: true 忽略不存在的文件错误
fs.rmSync(entryPath, { recursive: true, force: true });
console.log(`[Cleanup] Removed expired directory: ${entryPath}`);
totalDirsRemoved++;
} else {
// 删除单个文件
fs.unlinkSync(entryPath);
console.log(`[Cleanup] Removed expired file: ${entryPath}`);
totalFilesRemoved++;
}
}
} catch (error) {
// 处理可能的错误(权限问题、文件已被删除等)
console.error(`[Cleanup] Error processing ${entryPath}:`, error);
}
}
}
console.log(`[Cleanup] Completed. Removed ${totalFilesRemoved} files and ${totalDirsRemoved} directories.`);
}
/**
* 启动定时清理任务
* @param intervalHours - 清理间隔(小时),默认24小时
* @param maxAgeHours - 文件最大存活时间(小时),默认24小时
* @returns NodeJS.Timeout - 定时器ID,用于取消定时任务
*
* 使用示例:
* ```typescript
* const cleanupTimer = startCleanupScheduler(24, 24);
* // 取消定时任务
* // clearTimeout(cleanupTimer);
* ```
*/
function startCleanupScheduler(intervalHours: number = 24, maxAgeHours: number = 24): NodeJS.Timeout {
console.log(`[Cleanup Scheduler] Starting cleanup scheduler. Interval: ${intervalHours}h, Max age: ${maxAgeHours}h`);
// 立即执行一次清理(启动时)
cleanupExpiredResults(maxAgeHours);
// 设置定时任务
const intervalMs = intervalHours * 3600 * 1000;
return setInterval(() => {
console.log(`[Cleanup Scheduler] Running scheduled cleanup...`);
cleanupExpiredResults(maxAgeHours);
}, intervalMs);
}
5.3 缓存失效策略总结
┌─────────────────────────────────────────────────────────────────┐
│ 缓存失效策略矩阵 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 缓存类型 失效触发条件 失效方式 │
│ ───────────────────────────────────────────────────────────── │
│ │
│ HTTP客户端 max-age过期 浏览器自动清理 │
│ 用户手动刷新 CDN自动清理 │
│ │
│ Prompt缓存 手动调用clearCache() 内存删除 │
│ 模板文件修改触发 内存删除 │
│ │
│ 瓦片缓存 LRU淘汰(容量满时) 内存删除 │
│ deleteTileset()调用 内存删除 │
│ clearAllCache()调用 内存删除 │
│ │
│ 文件系统 定时任务清理(默认24小时) 文件删除 │
│ deleteTileset()调用 文件删除 │
│ │
│ 数据库临时表 连接断开 自动清理 │
│ 手动DROP TABLE 数据库删除 │
│ 会话结束 自动清理 │
│ │
└─────────────────────────────────────────────────────────────────┘
缓存一致性保证:
| 场景 | 数据变更 | 缓存处理 | 一致性保证 |
|---|---|---|---|
| 分析结果更新 | 重新运行分析任务 | 新结果生成新文件,旧文件保留 | 文件名唯一,新结果新ID |
| 瓦片集删除 | 用户删除瓦片集 | 删除文件 + 清除内存缓存 | 立即生效 |
| 模板更新 | 修改模板文件 | 触发clearCache() | 下次请求重新加载 |
| 数据库更新 | 修改原始数据 | 临时表重新创建 | 连接级隔离 |
六、性能优化与最佳实践
6.1 缓存命中率优化
原理说明:缓存命中率(Cache Hit Rate)是衡量缓存效果的关键指标,计算公式为:
命中率 = 命中次数 / (命中次数 + 未命中次数) × 100%
命中率直接影响系统性能:
- 命中率90%:10次请求中9次直接从缓存获取
- 命中率99%:100次请求中99次直接从缓存获取
缓存命中率提升策略:
- 缓存预热:系统启动时预先加载热门数据
- 智能缓存:基于访问模式动态调整缓存策略
- 多级缓存:利用多层缓存体系提升整体命中率
- 缓存合并:合并相似请求,减少重复计算
缓存预热原理:
- 在系统空闲时预先生成热门数据
- 减少用户首次访问时的等待时间
- 针对用户经常访问的区域和缩放级别进行预热
预热策略设计:
- 启动时预热:系统启动后立即执行
- 定时预热:周期性预热热门数据
- 预测预热:基于访问日志预测热门数据
// 优化:缓存预热
export class MVTOnDemandPublisher extends BaseMVTPublisher {
/**
* 缓存预热:在系统启动时加载常用瓦片
*
* @param tilesetIds - 需要预热的瓦片集ID列表
* @param zoomLevels - 需要预热的缩放级别列表
* @param bbox - 可选,限定预热的地理范围
* @returns Promise<void>
*
* 设计原理:
* - **预加载热门数据**:在系统空闲时预先生成热门瓦片
* - **减少首屏等待**:用户首次访问时直接从缓存获取
* - **针对性预热**:针对用户经常访问的区域和缩放级别
* - **后台执行**:不影响正常请求处理
*
* 预热策略:
* 1. **关键缩放级别**:通常预热中间级别(如4-12级)
* 2. **热门区域**:基于访问日志确定热门区域
* 3. **增量预热**:只预热新增或更新的瓦片集
*
* 性能影响:
* - 启动时间增加:需要预生成瓦片
* - 内存占用增加:预热的瓦片会占用内存
* - 用户体验提升:首次访问响应更快
*/
async prewarmCache(
tilesetIds: string[],
zoomLevels: number[],
bbox?: [number, number, number, number] // [minLon, minLat, maxLon, maxLat]
): Promise<void> {
console.log(`[MVT On-Demand Publisher] Starting cache prewarming...`);
console.log(`[MVT On-Demand Publisher] Tilesets: ${tilesetIds.join(', ')}`);
console.log(`[MVT On-Demand Publisher] Zoom levels: ${zoomLevels.join(', ')}`);
if (bbox) {
console.log(`[MVT On-Demand Publisher] Bounding box: ${bbox.join(', ')}`);
}
let totalCached = 0;
let totalSkipped = 0;
// 遍历每个瓦片集
for (const tilesetId of tilesetIds) {
// 获取瓦片集元数据(包含边界范围)
const metadata = this.tilesetMetadata.get(tilesetId);
if (!metadata) {
console.warn(`[MVT On-Demand Publisher] Skipping prewarm for tileset ${tilesetId}: metadata not found`);
totalSkipped++;
continue;
}
// 遍历每个缩放级别
for (const z of zoomLevels) {
try {
// 生成指定级别的瓦片
const tiles = await this.generateTilesForZoom(tilesetId, z, bbox);
// 存入内存缓存
for (const tile of tiles) {
const cacheKey = `${tilesetId}:${tile.z}:${tile.x}:${tile.y}`;
this.tileCache.set(cacheKey, tile.data);
totalCached++;
}
console.debug(`[MVT On-Demand Publisher] Prewarmed ${tiles.length} tiles for ${tilesetId} at zoom ${z}`);
} catch (error) {
console.error(`[MVT On-Demand Publisher] Error prewarming tileset ${tilesetId} at zoom ${z}:`, error);
totalSkipped++;
}
}
}
console.log(`[MVT On-Demand Publisher] Cache prewarming completed.`);
console.log(`[MVT On-Demand Publisher] - Cached: ${totalCached} tiles`);
console.log(`[MVT On-Demand Publisher] - Skipped: ${totalSkipped} tilesets/levels`);
}
/**
* 生成指定缩放级别的瓦片
* @param tilesetId - 瓦片集ID
* @param z - 缩放级别
* @param bbox - 可选,限定地理范围
* @returns Promise<Array<{z: number, x: number, y: number, data: Buffer}>>
*/
private async generateTilesForZoom(
tilesetId: string,
z: number,
bbox?: [number, number, number, number]
): Promise<Array<{z: number; x: number; y: number; data: Buffer}>> {
const tiles: Array<{z: number; x: number; y: number; data: Buffer}> = [];
// 根据瓦片集范围和缩放级别计算需要生成的瓦片坐标
// 简化实现:实际需要根据空间索引和范围查询
const metadata = this.tilesetMetadata.get(tilesetId);
if (!metadata) {
return tiles;
}
// 计算瓦片范围(简化实现)
const { minX, maxX, minY, maxY } = this.calculateTileRange(metadata.tileInfo.bounds, z);
// 遍历所有需要生成的瓦片
for (let x = minX; x <= maxX; x++) {
for (let y = minY; y <= maxY; y++) {
// 如果指定了bbox,检查瓦片是否在范围内
if (bbox && !this.tileIntersectsBbox(z, x, y, bbox)) {
continue;
}
// 生成瓦片数据
const tileData = await this.generateTile(tilesetId, z, x, y);
if (tileData) {
tiles.push({ z, x, y, data: tileData });
}
}
}
return tiles;
}
/**
* 计算瓦片范围
* @param bounds - 地理边界 [minLon, minLat, maxLon, maxLat]
* @param z - 缩放级别
* @returns {minX: number, maxX: number, minY: number, maxY: number}
*/
private calculateTileRange(bounds: [number, number, number, number], z: number) {
const [minLon, minLat, maxLon, maxLat] = bounds;
return {
minX: this.lonToTileX(minLon, z),
maxX: this.lonToTileX(maxLon, z),
minY: this.latToTileY(maxLat, z),
maxY: this.latToTileY(minLat, z)
};
}
/**
* 经度转瓦片X坐标
*/
private lonToTileX(lon: number, z: number): number {
return Math.floor((lon + 180) / 360 * Math.pow(2, z));
}
/**
* 纬度转瓦片Y坐标
*/
private latToTileY(lat: number, z: number): number {
const rad = lat * Math.PI / 180;
return Math.floor((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2 * Math.pow(2, z));
}
/**
* 检查瓦片是否与bbox相交
*/
private tileIntersectsBbox(z: number, x: number, y: number, bbox: [number, number, number, number]): boolean {
// 简化实现:实际需要计算瓦片边界并检查交集
return true;
}
}
缓存预热策略:
| 策略 | 适用场景 | 实现方式 |
|---|---|---|
| 启动时预热 | 热门瓦片集 | 系统启动后立即执行 |
| 定时预热 | 周期性热门数据 | 定时任务触发 |
| 预测预热 | 基于访问模式 | 分析访问日志预测 |
6.2 缓存键设计最佳实践
// 缓存键设计原则
// ❌ 不好:过于简单,容易冲突
const badKey = `${z}-${x}-${y}`;
// 问题:不同瓦片集的相同坐标会冲突
// ✅ 好:包含唯一标识符
const goodKey = `${tilesetId}:${z}:${x}:${y}`;
// 优势:tilesetId保证唯一性
// ✅ 更好:包含版本信息
const betterKey = `${tilesetId}:v1:${z}:${x}:${y}`;
// 优势:支持版本管理,发布新版本时自动失效旧缓存
// ✅ 最佳:包含校验和
const bestKey = `${tilesetId}:${dataHash}:${z}:${x}:${y}`;
// 优势:数据变化时自动生成新键,无需手动清理缓存
缓存键设计原则:
- 唯一性:必须包含足够的唯一标识信息
- 可读性:使用分隔符(如
:)提高可读性 - 版本化:支持数据版本管理
- 可扩展性:预留扩展空间
6.3 多级缓存协作流程
┌─────────────────────────────────────────────────────────────────┐
│ 多级缓存协作流程 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Client Request │
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ HTTP缓存 │ ← 检查浏览器缓存(最快,<1ms) │
│ └─────┬───────┘ │
│ │ Miss │
│ ▼ │
│ ┌─────────────┐ │
│ │ 内存缓存 │ ← 检查服务端内存(快,1-10ms) │
│ └─────┬───────┘ │
│ │ Miss │
│ ▼ │
│ ┌─────────────┐ │
│ │ 文件系统 │ ← 检查磁盘文件(中等,10-100ms) │
│ └─────┬───────┘ │
│ │ Miss │
│ ▼ │
│ ┌─────────────┐ │
│ │ 数据库 │ ← 查询PostGIS(慢,100-1000ms) │
│ └─────┬───────┘ │
│ │ │
│ └───────► 生成结果 │
│ │ │
│ ┌────────┼────────┐ │
│ ▼ ▼ ▼ │
│ 数据库 文件系统 内存缓存 HTTP响应 │
│ 更新 更新 更新 │
│ │
└─────────────────────────────────────────────────────────────────┘
多级缓存性能对比:
| 层级 | 延迟 | 命中率目标 | 容量 |
|---|---|---|---|
| HTTP缓存 | <1ms | 99% | 浏览器决定 |
| 内存缓存 | 1-10ms | 95% | 10000瓦片 |
| 文件系统 | 10-100ms | 90% | 磁盘容量 |
| 数据库 | 100-1000ms | - | 数据库限制 |
七、代码优化建议
7.1 增强LRU缓存实现
原理说明:当前实现使用简单的FIFO(先进先出)淘汰策略,但FIFO无法区分数据的访问频率。真正的LRU(Least Recently Used,最近最少使用)策略能够更好地保留热门数据,提高缓存命中率。
FIFO vs LRU对比:
| 策略 | 淘汰对象 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| FIFO | 最早插入的 | 实现简单,性能好 | 不考虑访问频率 | 数据访问均匀 |
| LRU | 最久未使用的 | 保留热门数据,命中率高 | 实现稍复杂 | 数据访问不均匀 |
LRU原理:
- 核心思想:优先保留最近访问的数据
- 当缓存满时,淘汰最久未被访问的条目
- 基于"最近使用的数据未来被访问的概率更高"的假设
实现方式:
- 方式一:使用Map保持插入顺序(ES6特性)
- 方式二:使用双向链表+哈希表(经典实现)
- 方式三:使用OrderedDict(Python)
本实现选择:使用ES6 Map的插入顺序特性,实现简洁且性能良好。
// 当前实现:简单FIFO淘汰(存在局限性)
// 优化:真正的LRU实现(推荐)
/**
* LRU缓存实现
*
* LRU = Least Recently Used(最近最少使用)
*
* 设计原理:
* - **核心思想**:当缓存满时,删除最久未使用的条目
* - **数据结构**:利用ES6 Map的有序特性(保持插入顺序)
* - **访问标记**:访问时将条目删除并重新插入,移到末尾
* - **淘汰策略**:删除Map的第一个元素(最久未使用)
*
* 复杂度分析:
* - 插入:O(1)
* - 查询:O(1)
* - 淘汰:O(1)
*
* 适用场景:
* - 数据访问具有时间局部性
* - 热门数据需要优先保留
* - 缓存容量有限,需要高效利用
*/
class LRUCache implements TileCache {
private cache: Map<string, Buffer> = new Map(); // 缓存容器,保持插入顺序
private maxSize: number; // 缓存容量上限
/**
* 构造函数
* @param maxSize - 缓存最大容量(默认10000个瓦片)
*/
constructor(maxSize: number = 10000) {
this.maxSize = maxSize;
console.log(`[LRUCache] Initialized with max size: ${maxSize}`);
}
/**
* 获取缓存数据
*
* @param key - 缓存键
* @returns Buffer | null
*
* LRU核心逻辑:
* 1. 查找数据
* 2. 如果命中,删除并重新插入(移到末尾,标记为最近使用)
* 3. 返回数据或null
*
* 为什么要删除再插入?
* - ES6 Map保持插入顺序
* - 删除再插入会将条目移到Map的末尾
* - 末尾的条目是最近使用的
* - 开头的条目是最久未使用的
*/
get(key: string): Buffer | null {
const value = this.cache.get(key);
if (value) {
// LRU关键操作:删除并重新插入,标记为最近使用
this.cache.delete(key);
this.cache.set(key, value);
console.debug(`[LRUCache] Hit: ${key}`);
} else {
console.debug(`[LRUCache] Miss: ${key}`);
}
return value || null;
}
/**
* 设置缓存数据
*
* @param key - 缓存键
* @param data - 瓦片数据(Buffer格式)
*
* 执行流程:
* 1. 如果已存在,先删除(保证重新插入后在末尾)
* 2. 检查容量,超过上限则淘汰最久未使用的
* 3. 插入新数据(自动放在末尾)
*/
set(key: string, data: Buffer): void {
// 如果已存在,先删除(保证重新插入后位置正确)
if (this.cache.has(key)) {
this.cache.delete(key);
}
// 容量检查:超过上限时执行LRU淘汰
if (this.cache.size >= this.maxSize) {
// 获取第一个键(最久未使用的)
const firstKey = this.cache.keys().next().value;
if (firstKey) {
console.debug(`[LRUCache] Evicting LRU: ${firstKey}`);
this.cache.delete(firstKey);
}
}
// 插入到末尾(标记为最近使用)
this.cache.set(key, data);
console.debug(`[LRUCache] Added: ${key}, size: ${data.length} bytes`);
}
/**
* 检查缓存是否存在
* @param key - 缓存键
* @returns boolean - 是否存在
*
* 注意:此方法不更新访问时间,仅用于存在性检查
*/
has(key: string): boolean {
return this.cache.has(key);
}
/**
* 清空所有缓存
*/
clear(): void {
console.log(`[LRUCache] Cleared ${this.cache.size} entries`);
this.cache.clear();
}
/**
* 获取缓存统计信息
* @returns {size: number, maxSize: number}
*/
getStats(): { size: number; maxSize: number } {
return {
size: this.cache.size,
maxSize: this.maxSize
};
}
}
7.2 添加缓存监控
原理说明:监控缓存命中率是评估缓存效果的关键手段。通过实时监控命中率、命中次数、未命中次数等指标,可以:
- 评估缓存策略的有效性
- 及时发现缓存问题(如命中率过低)
- 指导缓存容量调整
- 支持性能优化决策
监控指标设计:
- 命中率(Hit Rate):命中次数 / 总请求次数
- 命中次数(Hits):缓存命中的总次数
- 未命中次数(Misses):缓存未命中的总次数
- 缓存大小(Size):当前缓存条目数
- 淘汰次数(Evictions):缓存淘汰的总次数
监控用途:
- 告警触发:命中率低于阈值时告警
- 容量调整:根据缓存使用情况调整容量
- 策略优化:根据访问模式优化缓存策略
- 容量规划:预测内存需求
设计模式:使用装饰器模式(Decorator Pattern)包装原始缓存,在不修改原有实现的情况下添加监控功能。
// 缓存监控增强(装饰器模式)
/**
* 带监控的瓦片缓存包装器
*
* 设计原理:
* - 使用装饰器模式包装原始缓存
* - 统计命中/未命中次数
* - 计算命中率
*/
class MonitoredTileCache implements TileCache {
private cache: TileCache; // 内部缓存实例
private hits: number = 0; // 命中次数
private misses: number = 0; // 未命中次数
constructor(innerCache: TileCache) {
this.cache = innerCache;
console.log('[MonitoredTileCache] Initialized');
}
/**
* 获取缓存数据
* @param key - 缓存键
* @returns Buffer | null
*/
get(key: string): Buffer | null {
const result = this.cache.get(key);
// 统计命中/未命中
if (result) {
this.hits++;
} else {
this.misses++;
}
return result;
}
set(key: string, data: Buffer): void {
this.cache.set(key, data);
}
has(key: string): boolean {
return this.cache.has(key);
}
clear(): void {
this.cache.clear();
// 重置统计
this.hits = 0;
this.misses = 0;
console.log('[MonitoredTileCache] Cache cleared, stats reset');
}
/**
* 获取命中率
* @returns number - 命中率(0-1)
*/
getHitRate(): number {
const total = this.hits + this.misses;
return total > 0 ? this.hits / total : 0;
}
/**
* 获取统计信息
* @returns {hits: number, misses: number, hitRate: number}
*/
getStats(): { hits: number; misses: number; hitRate: number } {
return {
hits: this.hits,
misses: this.misses,
hitRate: this.getHitRate()
};
}
/**
* 重置统计
*/
resetStats(): void {
this.hits = 0;
this.misses = 0;
console.log('[MonitoredTileCache] Stats reset');
}
}
// 使用示例
const innerCache = new LRUCache(10000);
const monitoredCache = new MonitoredTileCache(innerCache);
// 定期输出统计
setInterval(() => {
const stats = monitoredCache.getStats();
console.log(`[Cache Stats] Hits: ${stats.hits}, Misses: ${stats.misses}, Hit Rate: ${(stats.hitRate * 100).toFixed(2)}%`);
}, 60000); // 每分钟输出一次
7.3 分布式缓存支持
原理说明:在单实例部署场景下,内存缓存可以满足需求。但在多实例部署(如负载均衡、容器集群)场景下,每个实例都有独立的内存缓存,无法共享数据,导致:
- 缓存命中率降低(每个实例都需要独立预热)
- 内存资源浪费(相同数据存储在多个实例中)
- 数据不一致(不同实例可能返回不同版本的数据)
分布式缓存解决方案:使用Redis等分布式缓存系统,实现多实例共享缓存。
分布式缓存优势:
- 缓存共享:所有实例共享同一个缓存
- 内存节省:避免重复存储
- 数据一致:所有实例访问相同的数据
- 水平扩展:缓存容量可独立扩展
架构设计:
┌─────────────────────────────────────────────────────────────┐
│ 分布式缓存架构 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ 实例1 │ │ 实例2 │ │ 实例N │ │
│ │ (Node.js)│ │ (Node.js)│ │ (Node.js)│ │
│ └────┬────┘ └────┬────┘ └────┬────┘ │
│ │ │ │ │
│ └──────────────┼──────────────┘ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Redis集群 │ ← 分布式缓存后端 │
│ │ (共享缓存) │ │
│ └─────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
数据序列化方案:
- Base64编码:将Buffer转换为字符串存储
- MessagePack:更高效的二进制序列化(推荐)
- Redis Binary:直接存储二进制数据(Redis 6+支持)
// Redis缓存适配器
/**
* Redis瓦片缓存实现
*
* 设计原理:
* - **分布式存储**:使用Redis作为分布式缓存后端
* - **多实例共享**:所有应用实例共享同一个缓存
* - **自动过期**:设置TTL自动清理过期数据
* - **键前缀隔离**:使用前缀避免不同应用的键冲突
*
* 适用场景:
* - 多实例部署(负载均衡)
* - 容器化部署(Kubernetes)
* - 需要缓存共享的场景
*
* 性能考虑:
* - 网络延迟:Redis访问存在网络开销(需部署在同一局域网)
* - 序列化开销:Buffer与字符串之间的转换
* - 集群模式:支持Redis Cluster实现高可用和水平扩展
*/
class RedisTileCache implements TileCache {
private client: RedisClientType; // Redis客户端
private prefix: string; // 键前缀,避免冲突
/**
* 构造函数
* @param client - Redis客户端实例
* @param prefix - 键前缀(默认'mvt:')
*/
constructor(client: RedisClientType, prefix: string = 'mvt:') {
this.client = client;
this.prefix = prefix;
console.log(`[RedisTileCache] Initialized with prefix: ${prefix}`);
}
/**
* 获取缓存数据
* @param key - 缓存键
* @returns Promise<Buffer | null>
*/
async get(key: string): Promise<Buffer | null> {
const fullKey = `${this.prefix}${key}`;
const value = await this.client.get(fullKey);
if (value) {
// Redis存储的是Base64编码,需要解码
return Buffer.from(value, 'base64');
}
return null;
}
/**
* 设置缓存数据
* @param key - 缓存键
* @param data - 瓦片数据(Buffer)
*/
async set(key: string, data: Buffer): Promise<void> {
const fullKey = `${this.prefix}${key}`;
// 将Buffer转换为Base64字符串存储
// 设置过期时间为24小时(86400秒)
await this.client.setEx(fullKey, 86400, data.toString('base64'));
}
/**
* 检查缓存是否存在
* @param key - 缓存键
* @returns Promise<boolean>
*/
async has(key: string): Promise<boolean> {
const fullKey = `${this.prefix}${key}`;
const exists = await this.client.exists(fullKey);
return exists === 1;
}
/**
* 清空所有缓存
*/
async clear(): Promise<void> {
// 获取所有匹配前缀的键
const keys = await this.client.keys(`${this.prefix}*`);
if (keys.length > 0) {
await this.client.del(keys);
console.log(`[RedisTileCache] Cleared ${keys.length} keys`);
}
}
}
八、缓存策略总结
8.1 核心设计原则
┌─────────────────────────────────────────────────────────────────┐
│ 缓存设计核心原则 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. 多级缓存:HTTP → 内存 → 文件 → 数据库 │
│ - 每一层都有明确的职责和生命周期 │
│ - 从快到慢,从易失到持久 │
│ │
│ 2. 缓存键设计:包含唯一标识符 │
│ - 避免冲突,支持版本管理 │
│ - 格式:${唯一标识}:${其他信息} │
│ │
│ 3. 主动失效:删除资源时同步清除缓存 │
│ - 保证数据一致性 │
│ - 避免脏数据返回 │
│ │
│ 4. 容量限制:内存缓存设置上限 │
│ - 防止内存溢出 │
│ - 使用LRU/FIFO策略淘汰 │
│ │
│ 5. 持久化备份:内存缓存同步到文件系统 │
│ - 保证服务重启后缓存不丢失 │
│ - 支持跨进程共享 │
│ │
└─────────────────────────────────────────────────────────────────┘
8.2 性能提升效果
| 指标 | 无缓存 | 有缓存 | 提升倍数 |
|---|---|---|---|
| Prompt加载 | 100ms/file | 1ms/hit | 100x |
| 瓦片请求 | 500ms/tile | 1ms/hit | 500x |
| 结果下载 | 100ms/file | 1ms/hit | 100x |
8.3 未来优化方向
- 分布式缓存:引入Redis支持多实例共享缓存
- 智能预热:基于访问模式预测预缓存热门瓦片
- 缓存分层:根据访问频率动态调整缓存层级
- 监控告警:添加缓存命中率监控和告警
- 自适应缓存大小:根据内存使用动态调整缓存容量
结语
GeoAI-UP的缓存体系体现了以下核心思想:
- 分层设计:从HTTP到数据库,每层缓存各司其职,形成完整的缓存体系
- 主动失效:数据变更时立即清除相关缓存,保证数据一致性
- 持久化备份:内存缓存同步到文件系统,支持服务重启恢复
- 容量管理:通过LRU淘汰策略控制内存占用,避免溢出
这套缓存机制在保证系统性能的同时,也兼顾了数据一致性和资源管理,是空间分析系统的典型缓存设计模式。通过合理的缓存策略,GeoAI-UP能够高效处理大量空间数据查询,为用户提供快速响应的分析体验。
参考代码文件:
代码在gitee geo-ai-universal-platform
核心文件:

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)