// ResourceCache - MCP 资源 TTL + LRU 缓存. // // 背景:MCP 服务器可以暴露「资源」(resources),Agent 通过 resources/read 读取. // 资源内容通常变化较慢(如配置文件,知识库文档,静态数据集), // 每次工具调用前都发起 RPC 既浪费 latency 又消耗服务器资源. // // 设计要点: // - TTL-based 缓存:过期后下次 Get 触发 miss,调用方负责回源 // - LRU 驱逐:容量满时驱逐最久未访问的条目(Get 和 Set 均更新访问顺序) // - 缓存键格式:serverName + ":" + resourceURI(命名空间隔离,避免不同服务器同名 URI 碰撞) // - InvalidateServer:ToolListChanged 通知时批量清除某服务器的资源缓存-- // 工具列表变化通常意味着服务器重启或更新,资源也应视为失效 // - 读多写少:RWMutex 分离读/写路径,高并发 Get 无锁争用 // // 升华改进(ELEVATED): 早期实现 无 MCP 资源缓存,每次 resources/read 都是 RPC-- // 在高频工具调用场景(如检索增强生成)下,同一资源可能被读取数十次, // TTL 缓存将 RPC 次数降低到 1(TTL 内),latency 从 ~100ms 降到 ~0.1μs. // 替代方案:<不缓存,每次 RPC> // - 否决:资源读取在工具链中可高度重复,无缓存等于把 MCP 服务器当慢速函数调用. // // 替代方案2:<基于 TTLCache[string, []ResourceContent] 泛型缓存> // - 否决:此处不需要 stale-while-revalidate(资源过期直接 miss,让调用方决定回源时机), // 专用结构更直观,测试也更清晰. package mcp import ( "container/list" "strings" "sync" "time" ) // ResourceCache 是 MCP 资源内容的 TTL + LRU 缓存. // // 线程安全:所有方法可从任意 goroutine 并发调用. // // 精妙之处(CLEVER): 用 container/list 双向链表维护访问顺序-- // list.Front() 是最近访问的条目,list.Back() 是最久未访问的驱逐候选. // Get 命中时把链表节点移到 Front,Set 新 key 满容量时驱逐 Back. // map[key]*list.Element 提供 O(1) 查找,list 提供 O(1) 移动/删除. // 经典 LRU 实现:空间 O(n),时间 O(1) per operation. type ResourceCache struct { // mu 读写分离:Get 用 Lock(需要维护链表顺序),Set/Invalidate/Clear 用 Lock // 操蛋之处(LEGACY): LRU 的 Get 也要写链表(移动节点到 Front), // 因此无法保持 RLock--这是 LRU 与 RWMutex 不兼容的本质矛盾. // 如需高并发读优化,可用 shard 分段锁或 "访问顺序延迟更新" 策略, // 当前规模(≤1000 条目)全局 Mutex 已足够. // 原方案: ,已替换为 Mutex + LRU. mu sync.Mutex entries map[string]*list.Element // key → 链表节点 order *list.List // Front = 最近访问;Back = 最久未访问(驱逐候选) ttl time.Duration // 可替换时间源(测试注入假时间,无需 sleep) // 精妙之处(CLEVER): now 函数注入--与 cache.TTLCache 同一技巧,测试零等待. now func() time.Time } // resourceEntry 是缓存中的单个资源条目. // 存储在 list.Element.Value 中,同时持有 key 以便驱逐时从 map 删除. type resourceEntry struct { key string // 冗余存储 key,驱逐时无需反查 content []ResourceContent fetchedAt time.Time } // NewResourceCache 创建一个新的资源缓存. // // ttl 是每个条目的生存时间.ttl <= 0 表示永不过期(适用于只读静态资源). // // 升华改进(ELEVATED): ttl=0 永不过期,而非 ttl=0 立即过期-- // 永不过期更符合"资源不变"的业务语义(对比 TTLCache 语义不同). // 调用方可通过 Invalidate/InvalidateServer 手动触发失效. func NewResourceCache(ttl time.Duration) *ResourceCache { return &ResourceCache{ entries: make(map[string]*list.Element), order: list.New(), ttl: ttl, now: time.Now, } } // cacheKey 构建缓存键. // // 精妙之处(CLEVER): serverName + ":" + uri-- // 冒号是 URI scheme 分隔符,几乎不会出现在 serverName 中(serverName 通常是短标识符如 "github"). // 两段之间不用 "/" 是因为 URI 本身包含 "/",用冒号分隔可以直接 HasPrefix 筛选服务器条目. func cacheKey(serverName, uri string) string { return serverName + ":" + uri } // Get 查询缓存,命中时将条目移到 LRU 链表头部(最近访问). // // 返回 (content, true):命中且未过期. // 返回 (nil, false):未命中(key 不存在或已过期). // // 过期条目在 Get 时不自动删除-- // 懒清理策略:避免每次 Get 都做删除(需要更多锁操作), // 过期条目占一个链表位置,下次 Set 时被驱逐或被覆盖写替换. func (c *ResourceCache) Get(serverName, uri string) ([]ResourceContent, bool) { key := cacheKey(serverName, uri) c.mu.Lock() elem, ok := c.entries[key] if !ok { c.mu.Unlock() return nil, false } entry := elem.Value.(*resourceEntry) // ttl <= 0 永不过期 if c.ttl > 0 && c.now().After(entry.fetchedAt.Add(c.ttl)) { c.mu.Unlock() return nil, false // 过期视为 miss;不删除(懒清理) } // 精妙之处(CLEVER): 命中时移到 Front-- // 这是 LRU 的核心操作:访问一次等于"刷新热度",推迟驱逐. c.order.MoveToFront(elem) content := entry.content c.mu.Unlock() return content, true } // maxResourceCacheEntries 是资源缓存的硬性上限,防止恶意 MCP 服务器通过大量 // resources/read 响应撑爆内存(OOM). // // 升华改进(ELEVATED): 原实现在超限时静默丢弃新条目(LEGACY 注释保留于 git 历史). // 现在用 LRU 驱逐 Back 节点-- // 驱逐最久未访问的条目比拒绝写入更合理: // 1. 热门资源(频繁 Get)不会被驱逐; // 2. 冷门资源占用容量时会被新资源替换,符合缓存语义. // // 原方案: <超限静默丢弃新条目,已有条目可更新>,已替换为 LRU 驱逐. const maxResourceCacheEntries = 1000 // Set 写入或更新缓存条目,刷新过期时间,并将条目移到 LRU 链表头部. // // 如果缓存已满(>= maxResourceCacheEntries)且目标 key 不存在, // 驱逐 LRU 链表尾部(最久未访问)的条目后再写入. func (c *ResourceCache) Set(serverName, uri string, content []ResourceContent) { key := cacheKey(serverName, uri) c.mu.Lock() defer c.mu.Unlock() // 已存在的 key:更新内容,刷新 fetchedAt,并移到链表 Front. if elem, exists := c.entries[key]; exists { entry := elem.Value.(*resourceEntry) entry.content = content entry.fetchedAt = c.now() c.order.MoveToFront(elem) return } // 新 key:容量满时驱逐最久未访问的条目(Back). // 精妙之处(CLEVER): 驱逐 Back 而非拒绝写入-- // 冷数据给热数据让路,缓存始终保持 maxResourceCacheEntries 容量的有效利用. if len(c.entries) >= maxResourceCacheEntries { oldest := c.order.Back() if oldest != nil { evicted := oldest.Value.(*resourceEntry) delete(c.entries, evicted.key) c.order.Remove(oldest) } } // 插入新条目到链表 Front(最近访问). entry := &resourceEntry{ key: key, content: content, fetchedAt: c.now(), } elem := c.order.PushFront(entry) c.entries[key] = elem } // Invalidate 使单个资源条目立即失效(删除),同步维护 LRU 链表. // // 下次 Get 返回 miss,调用方触发重新拉取. func (c *ResourceCache) Invalidate(serverName, uri string) { key := cacheKey(serverName, uri) c.mu.Lock() defer c.mu.Unlock() if elem, ok := c.entries[key]; ok { c.order.Remove(elem) delete(c.entries, key) } } // InvalidateServer 清除指定服务器的所有资源缓存,同步维护 LRU 链表. // // 典型触发场景: // - 收到 notifications/tools/list_changed 通知(服务器可能已更新/重启) // - 用户手动重连服务器 // - 服务器断线重连后 // // 升华改进(ELEVATED): 批量失效而非逐条失效-- // ToolListChanged 意味着服务器整体状态变化,资源列表也可能变化, // 批量清除比逐条 Invalidate 更安全(不会遗漏未知 URI 的条目). func (c *ResourceCache) InvalidateServer(serverName string) { prefix := serverName + ":" c.mu.Lock() defer c.mu.Unlock() // 精妙之处(CLEVER): 遍历 map 删除 + 同步移除链表节点-- // 实践中单个服务器的资源条目通常 <100 个,线性扫描快于 GC 压力. // 替代方案:<重建 map,拷贝其他服务器的条目> // - 否决:多服务器场景下,拷贝其他服务器的全量条目才是真正的 O(n),而 n 更大. for key, elem := range c.entries { if strings.HasPrefix(key, prefix) { c.order.Remove(elem) delete(c.entries, key) } } } // Clear 清空所有缓存条目,重置 LRU 链表. func (c *ResourceCache) Clear() { c.mu.Lock() defer c.mu.Unlock() c.entries = make(map[string]*list.Element) c.order.Init() // 重置链表,复用对象,不触发 GC } // Len 返回缓存中的条目数量(包括已过期未清理的). // 主要用于测试和监控. func (c *ResourceCache) Len() int { c.mu.Lock() defer c.mu.Unlock() return len(c.entries) }