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, RTK+Caveman 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 的文件头注释中,可以清楚看到它的设计目标与约束:
- 缓存对象:
temperature=0的 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.000Z | 'T'(0x54) |
datetime('now')(读取比较用) | 2026-08-26 14:00:00 | 空格(0x20) |
由于两列都是 TEXT,SQLite 会进行逐字符字典序比较。两条字符串从开头一路相等到第 9 个字符(2026-08-2),在第 10 个字符处分道扬镳:存储值此处是'T',SQLite 的datetime('now')此处是空格。而在 ASCII 字典序中,'T'(0x54)排在空格(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 中,共四组用例,每一组都对应一种真实风险:
今天早些时候过期的行必须 miss(L76-L87):测试辅助函数
startOfSqliteToday刻意构造"SQLite 视角今天的 UTC 零点"作为过期时刻,插入后清空内存缓存再读取——修复前该用例因'T'vs 空格的字典序侥幸而命中,修复后必须返回null。过期行不计入
dbEntries(L89-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_SIZE=50 # 内存 LRU 最大字节数(默认 2 MB) SEMANTIC_CACHE_MAX_BYTES=2097152 # 缓存条目 TTL 毫秒(默认 1800000 ms = 30 分钟) SEMANTIC_CACHE_TTL_MS=1800000这些变量在 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 = 3600000(1 小时),但只要设置了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 提升机制不再"复活"死条目。修复前,过期行会在命中路径被写回内存 LRU(promote),形成持久化的错误缓存;修复后该漏洞被根除。
如需在实盘观测效果,可以关注仪表盘 Cache 相关页面的dbEntries与命中率走势,也可以在运行环境查看缓存统计 API 的响应字段;关于语义缓存的用户侧定位与绕过开关,可参见 docs/guides/USER_GUIDE.md 中的 Semantic Cache 章节(自动缓存非流式、temperature=0响应,可用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, RTK+Caveman compression saves 15-95% tokens, MCP/A2A, Desktop/PWA. Built by 550+ contributors项目地址: https://gitcode.com/GitHub_Trending/om/OmniRoute
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考