尧图精选

OmniRoute 语义缓存 TTL 过期修复深度解析:从“存活到次日零点“到“按时逐出“的 SQLite 时间比较陷阱

🕒 发布时间:2026/9/8 22:38:52 📁 来源:尧图网络
OmniRoute 语义缓存 TTL 过期修复深度解析从存活到次日零点到按时逐出的 SQLite 时间比较陷阱【免费下载链接】OmniRouteNever stop coding. Free MIT AI gateway: one endpoint, 352 providers (150 free), 1200 models Kimi, Claude, GPT, Gemini, GLM, DeepSeek, MiniMax. Works with Claude Code, Codex, Cursor, OpenCode, Cline Copilot. Quota-aware auto-fallback, RTKCaveman compression saves 15-95% tokens, MCP/A2A, Desktop/PWA. Built by 550 contributors项目地址: https://gitcode.com/GitHub_Trending/om/OmniRoute导读本篇文章聚焦 OmniRoute 语义缓存Semantic Cache模块的一次关键缺陷修复对应changelog.d/fixes/11573-semantic-cache-ttl.md。它揭示了一个非常隐蔽的 SQLite 日期比较陷阱由于缓存写入的时间格式与读取时使用的比较函数格式不一致本应到点失效的缓存条目会被当作仍未过期最长多存活约 24 小时。读完本文你将理解这条 bug 的完整成因、两层缓存的过期机制、dbEntries统计口径的修正方式以及如何通过环境变量精确控制 TTL。一、背景OmniRoute 的双层语义缓存OmniRoute 是一个面向 Claude Code、Codex、Cursor、OpenCode、Cline 等客户端的统一 LLM 网关一个端点汇聚数百家 provider 与上千模型。作为 AI 代理网关它最关心两个指标成本与延迟。语义缓存正是为此引入的降本增效机制。在 src/lib/semanticCache.ts 的文件头注释中可以清楚看到它的设计目标与约束缓存对象temperature0的 LLM 响应。确定性采样条件下相同输入重复请求会得到近似相同结果命中缓存即可跳过上游计费。双层存储内存 LRU快路径 SQLite跨重启持久化。查询时先查内存未命中再查 SQLite命中后会把 SQLite 行提升回内存 LRU。缓存键SHA-256(model normalized messages temperature top_p)通过 generateSignature 生成确定性签名。绕过开关请求携带X-OmniRoute-No-Cache: true头时跳过缓存读写。可缓存判定请求必须显式携带数值temperature: 0缺省 temperature 不缓存见 isCacheableForRead / isCacheableForWrite。SQLite 层的表结构在 tests/unit/11559-semantic-cache-ttl-expiry.test.ts 中可见完整 DDL如下CREATE TABLE semantic_cache ( id TEXT PRIMARY KEY, signature TEXT NOT NULL UNIQUE, -- 缓存签名SHA-256 摘要 model TEXT NOT NULL, -- 模型名 prompt_hash TEXT NOT NULL, -- 签名前 16 字符用于展示/检索 response TEXT NOT NULL, -- 序列化后的响应 JSON tokens_saved INTEGER DEFAULT 0, -- 预估节省的 token 数 hit_count INTEGER DEFAULT 0, -- 历史命中次数 created_at TEXT NOT NULL, -- 创建时间ISO-8601 expires_at TEXT NOT NULL -- 过期时间ISO-8601 );其中expires_at、created_at均为TEXT 列存储的是 ISO-8601 字符串——这正是本次 bug 的温床。二、缺陷根因T与空格在第 10 个字符处的字典序对决修复条目changelog.d/fixes/11573-semantic-cache-ttl.md用一句话概括了表象语义缓存条目不再存活到下一个 UTC 零点而是精确按其 TTL 过期同时dbEntries统计不再计入已过期行。要理解为什么会出现存活到次日零点这种诡异行为需要还原修复前的读取 SQL。旧代码在查询缓存时使用的判定条件是SELECT response, tokens_saved FROM semantic_cache WHERE signature ? AND expires_at datetime(now)而写入时setCachedResponse 计算过期时间用的是 JavaScript 侧的标准时间序列化const now new Date().toISOString(); // 2026-08-26T14:00:00.000Z const expiresAt new Date(Date.now() ttl).toISOString();问题就出在这两种时间格式的差异上这一分析在 src/lib/semanticCache.ts 的注释中被精确定位并由回归测试 tests/unit/11559-semantic-cache-ttl-expiry.test.ts 完整复现时间来源示例字符串第 10 个字符new Date().toISOString()写入用2026-08-26T14:00:00.000ZT0x54datetime(now)读取比较用2026-08-26 14:00:00空格0x20由于两列都是 TEXTSQLite 会进行逐字符字典序比较。两条字符串从开头一路相等到第 9 个字符2026-08-2在第 10 个字符处分道扬镳存储值此处是TSQLite 的datetime(now)此处是空格。而在 ASCII 字典序中T0x54排在空格0x20之后因此只要某行的日期部分等于今天无论它的时刻 TTL 是否已经过期比较结果恒为未过期。换言之一个设置 1 小时 TTL 的条目如果在 UTC 当天早些时候写入并过期它的expires_at仍属今天与datetime(now)今天比较时由于T 会被错误地判定为有效。这类幽灵条目会一直存活到下一个 UTC 零点最长带来近 24 小时的过期数据出库更麻烦的是SQLite 命中的过期行还会被重新提升进内存 LRU见 getCachedResponse 的 promote 逻辑使错误进一步扩散并跨重启存活。三、修复实现让读写两侧使用同一时间坐标系修复方案的核心思路非常朴素让比较用的当前时间与写入用的expires_at采用完全一致的 ISO-8601 字符串格式消除格式分歧让字典序比较退化为真正的时间先后比较。3.1 统一的isoNow()时钟semanticCache.ts 新增了内部辅助函数function isoNow(): string { return new Date().toISOString(); }它产出的正是2026-08-26T14:00:00.000Z形式与setCachedResponse写入expires_at时使用的格式逐字节一致。自此读取谓词expires_at isoNow()就是纯粹的字符串字典序比较——相同格式下即等价于时间大小比较。3.2 命中路径改为 ISO 谓词getCachedResponse 的 SQLite 查询随之改为const row db .prepare( SELECT response, tokens_saved FROM semantic_cache WHERE signature ? AND expires_at ? ) .get(signature, isoNow());一条 TTL 已到期的行哪怕它过期于今天不再被返回读路径第一时间过滤掉过期条目命中率统计与hit_count也不再被污染的过期行抬高。3.3dbEntries统计口径修正getCacheStats 用于向/api/cache/stats及健康/用量仪表盘提供指标。修复前它的计数 SQL 同样存在问题——现在改为只统计仍有效的行const row db .prepare(SELECT COUNT(*) as count FROM semantic_cache WHERE expires_at ?) .get(isoNow()); dbSize toNumber(asRecord(row).count, 0);由此返回结构中的dbEntries严格等于尚未过期、可以命中的 SQLite 条目数不再把过期行计入总容量仪表盘上展示的缓存占用与真实可服务条目保持一致。完整返回结构包含return { memoryEntries: memStats.size, // 内存 LRU 当前条目数 dbEntries: dbSize, // SQLite 中仍有效未过期条目数 hits, misses, hitRate, tokensSaved, };四、回归测试如何验证这次修复该缺陷的复现与验证被固化在 tests/unit/11559-semantic-cache-ttl-expiry.test.ts 中共四组用例每一组都对应一种真实风险今天早些时候过期的行必须 missL76-L87测试辅助函数startOfSqliteToday刻意构造SQLite 视角今天的 UTC 零点作为过期时刻插入后清空内存缓存再读取——修复前该用例因Tvs 空格的字典序侥幸而命中修复后必须返回null。过期行不计入dbEntriesL89-L94同一条今天零点过期的行写入后断言getCacheStats().dbEntries 0。未过期行仍正常服务L96-L103写入一个 TTL 尚余 1 小时的条目读取必须命中且dbEntries 1防止修复矫枉过正把有效缓存也杀掉。正常写入条目可承受内存淘汰L105-L115通过公开的setCachedResponse写入再清空内存确认新读取谓词依然能匹配写入格式保护了双层缓存之间的一致性契约。五、TTL 配置语义内存与 SQLite 两套默认值的差异理解修复后再来梳理 TTL 的完整语义。语义缓存受环境变量控制涉及三个维度默认值以 src/lib/semanticCache.ts 源码为准# 内存 LRU 最大条目数默认 50取整解析 SEMANTIC_CACHE_MAX_SIZE50 # 内存 LRU 最大字节数默认 2 MB SEMANTIC_CACHE_MAX_BYTES2097152 # 缓存条目 TTL 毫秒默认 1800000 ms 30 分钟 SEMANTIC_CACHE_TTL_MS1800000这些变量在 getMemoryCache 构造 LRU 时生效memoryCache new LRUCache({ maxSize: parseInt(process.env.SEMANTIC_CACHE_MAX_SIZE || 50, 10), maxBytes: parseInt(process.env.SEMANTIC_CACHE_MAX_BYTES || String(2 * 1024 * 1024), 10), defaultTTL: parseInt(process.env.SEMANTIC_CACHE_TTL_MS || 1800000, 10), });而在写入路径 setCachedResponse 中TTL 的解析顺序是const ttl parseInt(process.env.SEMANTIC_CACHE_TTL_MS || String(ttlMs), 10);这里有一个值得注意的细节函数签名默认ttlMs 36000001 小时但只要设置了SEMANTIC_CACHE_TTL_MS环境变量它就同时覆盖内存 LRU 的默认 TTL 与每次写入的 TTL。换句话说运维希望统一 TTL 节奏时只需设置这一个变量内存层与 SQLite 层会同步收敛到同一过期窗口不设置时内存写默认 1 小时、LRU 构造默认 30 分钟的微妙差异会被写入时显式传入的 ttl 覆盖写入调用统一使用同一 ttl 值写内存与 SQLite。本次修复的意义在于无论 TTL 配置为 30 分钟、1 小时还是任意毫秒值SQLite 层的条目都会真正按该 TTL 过期而非被 UTC 日界整体续命到零点。六、修复的运维意义与观测建议从网关运营者的角度看这次修复带来三类直接收益过期响应不再被投喂给客户端。此前一个已过 TTL 的缓存行会在当天剩余时间内持续命中把旧内容当作新结果返回给 AI 客户端修复后读路径在出库瞬间即按真实 TTL 过滤数据新鲜度有了硬保证。缓存容量与命中统计回归真实。dbEntries、命中/未命中计数、hitRate、tokensSaved等由getCacheStats汇总并经 getCacheStats 暴露给仪表盘不再被过期行污染缓存的真实效能token 节省可以被准确量化。LRU 提升机制不再复活死条目。修复前过期行会在命中路径被写回内存 LRUpromote形成持久化的错误缓存修复后该漏洞被根除。如需在实盘观测效果可以关注仪表盘 Cache 相关页面的dbEntries与命中率走势也可以在运行环境查看缓存统计 API 的响应字段关于语义缓存的用户侧定位与绕过开关可参见 docs/guides/USER_GUIDE.md 中的 Semantic Cache 章节自动缓存非流式、temperature0响应可用X-OmniRoute-No-Cache: true逐请求绕过。七、小结#11573是一次教科书级别的时间表示不一致缺陷修复写库用 ISO-8601含T读库却拿 SQLitedatetime(now)空格分隔做字典序比较T 的排序让今天的过期条目被误判为有效直到次日零点才被自然淘汰。修复以isoNow()统一读写时钟、修正expires_at ?谓词并重校dbEntries统计口径最终由四组回归测试锁定行为。它也提醒所有把时间存成 TEXT 的网关/缓存类系统比较时间字符串之前先确认两侧格式完全同构——字符级的一致才是时间级正确的前提。【免费下载链接】OmniRouteNever stop coding. Free MIT AI gateway: one endpoint, 352 providers (150 free), 1200 models Kimi, Claude, GPT, Gemini, GLM, DeepSeek, MiniMax. Works with Claude Code, Codex, Cursor, OpenCode, Cline Copilot. Quota-aware auto-fallback, RTKCaveman compression saves 15-95% tokens, MCP/A2A, Desktop/PWA. Built by 550 contributors项目地址: https://gitcode.com/GitHub_Trending/om/OmniRoute创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
上一篇/下一篇内容由系统自动关联 返回资讯列表 →