缓存代码能正常读取、写入并按时过期,并不代表它一定正确。一次搜索功能上线后,相同关键词在不同商品分类下竟返回了同一批数据;沿着请求参数、数据库查询和缓存命中链路逐步排查,问题最终落在一个容易被忽略的细节上:缓存键没有覆盖所有会改变查询结果的条件。
摘要: AI 写的缓存代码逻辑完全正确,但缓存键只用了关键词没加分类,导致不同分类的搜索结果互相覆盖。根因不是 AI 写错了,而是它对「缓存键粒度」的认知不足——它知道 category 是可选参数,但没把它纳入缓存键。本文记录排查过程、根因分析、修复方案,以及沉淀的「缓存键设计检查清单」。
前阵子给一个搜索功能加缓存。搜索需求很简单——用户选分类、输入关键词、设置价格范围,搜出符合条件的商品列表。因为查询频率高,数据变动不频繁,加缓存能省不少数据库压力。
AI 写的缓存实现,我看了一遍逻辑,觉得没问题就 merge 上线了。
上线第二天,用户反馈说"搜'手机-苹果'和'搜'手机-小米',出来一样的商品列表"。
先看前端传了什么参数。
用户操作日志:
用户A: searchProducts('手机', '苹果', 0) → 返回商品列表
用户B: searchProducts('手机', '小米', 0) → 返回商品列表(跟用户A一样)
参数没传错——两个用户传了不同的 category,但返回了相同的结果。
AI 的缓存实现长这样:
// 为什么要看这段:缓存逻辑本身没问题,但缓存键的设计有隐患
function searchProducts(keyword: string, category?: string, minPrice?: number) {
const cacheKey = 'search:' + keyword
const cached = cache.get(cacheKey)
if (cached) return cached
const results = await db.query(
`SELECT * FROM products
WHERE name LIKE ?
AND (? IS NULL OR category = ?)
AND price >= ?`,
[`%${keyword}%`, category, category, minPrice ?? 0]
)
cache.set(cacheKey, results, { ttl: 300 })
return results
}
// 问题模拟
用户A: searchProducts('手机', '苹果', 0)
→ 缓存未命中,key = 'search:手机'
→ 查数据库:WHERE name LIKE '%手机%' AND category = '苹果' AND price >= 0
→ 写入缓存 key = 'search:手机',value = [iPhone15, iPhone14, iPhoneSE...]
→ 返回正确结果
用户B: searchProducts('手机', '小米', 0)
→ 缓存命中!key = 'search:手机' ← 问题在这里
→ 返回 [iPhone15, iPhone14, iPhoneSE...] ← 本应返回小米14、红米Note
缓存读写逻辑是对的——get、set、ttl、空值处理,每样都到位。但缓存键只用了 keyword,没把 category 和 minPrice 纳进去。
为了更直观地复现这个冲突,我们按时间顺序模拟一组真实请求,观察缓存键的变化和返回结果的差异:
| 步骤 | 请求序列 | 构造的缓存键 | 缓存是否命中 | 返回结果 | 是否正确 |
|---|---|---|---|---|---|
| 1 | 用户A:searchProducts('手机', '苹果', 0) | search:手机 | 未命中 | 查库返回 [iPhone15, iPhone14, iPhoneSE...],写入缓存 | ✅ |
| 2 | 用户B:searchProducts('手机', '小米', 0) | search:手机 | 命中 | 直接返回 [iPhone15, iPhone14, iPhoneSE...] | ❌ 应为小米手机 |
| 3 | 用户C:searchProducts('手机', '华为', 0) | search:手机 | 命中 | 直接返回 [iPhone15, iPhone14, iPhoneSE...] | ❌ 应为华为手机 |
| 4 | 用户D:searchProducts('手机', '苹果', 1000) | search:手机 | 命中 | 直接返回 [iPhone15, iPhone14, iPhoneSE...] | ❌ 应过滤价格≥1000 |
| 5 | 用户E:searchProducts('电脑', '联想', 0) | search:电脑 | 未命中 | 查库返回联想电脑列表,写入缓存 | ✅ |
从表格可以清楚看到:只要关键词相同,无论分类和价格范围怎么变,缓存键都是同一个。第 2、3、4 步的用户都命中了第 1 步写入的缓存,拿到了苹果手机的列表——这就是"搜'手机-苹果'和搜'手机-小米'出来一样"的直接原因。
查了一下数据库,确认"手机-苹果"和"手机-小米"确实应该返回不同的数据。数据没问题,问题在缓存。
整个排查链路可以用一张图来概括:

AI 的代码逻辑没错——缓存读写、过期时间、清理策略都是对的。
问题出在:AI 知道 category 是可选参数,但写缓存时"默认"了 category 为空的情况,没把 category 纳入缓存键。
注意看 AI 的 SQL 查询:
WHERE name LIKE ?
AND (? IS NULL OR category = ?)
AND price >= ?
SQL 里处理了 category 为空的情况——如果 category 没传,就不按分类过滤。这个逻辑是对的。但缓存键没跟上。
AI 的代码里,缓存键只用了 keyword,因为"从函数签名来看,keyword 是必选参数,category 是可选参数"。AI 的推理是:必选参数是"核心",可选参数是"可选的"——所以在缓存键里只用了"核心"参数。
但实际业务里,category 虽然是可选参数,但一旦传了,它直接影响查询结果。缓存键必须把它纳进去。
这个推理偏差不只在 AI 身上有——人写代码也可能犯同样的错。但区别在于,人写代码时心里清楚"这个分类参数会影响结果",而 AI 只是从函数签名推导了"keyword 重要,category 不重要"。
修复很简单:把 category 和 minPrice 纳入缓存键。
// 修复后:缓存键包含所有影响查询结果的参数
function searchProducts(keyword: string, category?: string, minPrice?: number) {
// 构建缓存键时,把所有影响查询结果的参数都包含进去
// 可选参数为空时用默认值兜底,避免缓存键歧义
const cacheKey = `search:${keyword}:${category ?? 'all'}:${minPrice ?? '0'}`
const cached = cache.get(cacheKey)
if (cached) return cached
const results = await db.query(
`SELECT * FROM products
WHERE name LIKE ?
AND (? IS NULL OR category = ?)
AND price >= ?`,
[`%${keyword}%`, category, category, minPrice ?? 0]
)
cache.set(cacheKey, results, { ttl: 300 })
return results
}
// 修复后运行结果
用户A: searchProducts('手机', '苹果', 0)
→ 缓存未命中,key = 'search:手机:苹果:0'
→ 查数据库,写入缓存
→ 返回 [iPhone15, iPhone14, iPhoneSE...]
用户B: searchProducts('手机', '小米', 0)
→ 缓存未命中(key不同),key = 'search:手机:小米:0'
→ 查数据库,写入缓存
→ 返回 [小米14, 红米Note13...]
两个结果不再互相覆盖 ✅
修复前后的缓存键差异,用一个简单的图来展示:
缓存键设计对比:
before: 'search:手机'
↓
用户A搜"手机-苹果" → 写入 key='search:手机' → 包含所有商品
用户B搜"手机-小米" → 命中 key='search:手机' → 返回相同结果 ❌
after: 'search:手机:苹果:0' vs 'search:手机:小米:0'
↓ ↓
用户A: 缓存未命中 → 写入 keyA → 只返回苹果手机
用户B: 缓存未命中 → 写入 keyB → 只返回小米手机 ✅
缓存键修好了,但还有一个问题没解决:商品数据更新后,旧缓存怎么办? 如果只加缓存键不处理失效,用户可能看到过期的商品列表——比如商品下架了、价格改了、库存变了,缓存里还是旧数据。
最直接的方式是:在商品数据发生变更的地方,主动删除受影响的缓存键。以 Redis 为例,用 DEL 命令删除:
// 商品更新/删除时,主动删除相关搜索缓存
async function invalidateProductCache(product: Product) {
// 方案一:删除该商品可能命中的所有搜索缓存键
// 注意:这里需要遍历所有可能的分类和价格组合,成本较高
const keys = await redis.keys(`search:${product.name}:*`)
if (keys.length > 0) {
await redis.del(keys)
}
// 方案二(推荐):维护「商品 → 缓存键」的映射关系
// 商品更新时,通过映射表精确找到需要删除的缓存键
const relatedKeys = await redis.smembers(`product:${product.id}:cache_keys`)
if (relatedKeys.length > 0) {
await redis.del(relatedKeys)
await redis.del(`product:${product.id}:cache_keys`)
}
}
方案一用 KEYS 通配符匹配,简单但性能差(会阻塞 Redis)。更稳妥的做法是维护一张映射表:写入缓存时,同时记录「这个商品影响了哪些缓存键」:
// 写入缓存时,同时登记商品与缓存键的映射关系
async function setSearchCache(cacheKey: string, results: Product[], productIds: string[]) {
await redis.set(cacheKey, JSON.stringify(results), 'EX', 300)
// 把缓存键登记到每个相关商品名下
for (const id of productIds) {
await redis.sadd(`product:${id}:cache_keys`, cacheKey)
}
}
// 商品更新时,通过映射表精确删除相关缓存
async function onProductUpdated(productId: string) {
const relatedKeys = await redis.smembers(`product:${productId}:cache_keys`)
if (relatedKeys.length > 0) {
await redis.del(relatedKeys)
await redis.del(`product:${productId}:cache_keys`)
}
}
主动删除能保证数据即时一致,但实现成本高。TTL(过期时间)是兜底方案——即使漏删了,缓存也会在过期后自动失效,重新查库。
TTL 的取舍:
| 场景 | 推荐 TTL | 原因 |
|---|---|---|
| 商品价格、库存 | 30~60 秒 | 价格变动频繁,过期太快会频繁查库,太慢用户看到旧价格 |
| 商品名称、分类 | 5~10 分钟 | 这类信息变动少,可以缓存久一点 |
| 热门搜索词 | 1~3 分钟 | 命中率高,但数据要相对新鲜 |
| 长尾搜索词 | 10~30 分钟 | 搜索量低,缓存久一点能减少数据库压力 |
// 根据业务场景动态设置 TTL
function getSearchTtl(keyword: string, category?: string): number {
// 热门搜索词:短 TTL,保证数据新鲜
if (isHotKeyword(keyword)) return 60
// 长尾搜索词:长 TTL,减少数据库压力
if (isLongTailKeyword(keyword)) return 1800
// 默认 5 分钟
return 300
}
// 写入缓存时使用动态 TTL
cache.set(cacheKey, results, { ttl: getSearchTtl(keyword, category) })
| 场景 | 推荐策略 | 原因 |
|---|---|---|
| 商品后台编辑(改价、下架) | 主动删除 + 短 TTL | 数据变更即时生效,TTL 兜底防漏删 |
| 批量导入/定时同步 | 主动删除 + 长 TTL | 批量操作后统一清缓存,长 TTL 减少日常查库 |
| 用户生成内容(评论、评分) | 只靠 TTL | 变更频繁且分散,主动删除成本太高 |
| 促销活动(秒杀、限时折扣) | 极短 TTL(10~30 秒) | 价格变化快,必须保证数据新鲜 |
核心原则:主动删除保证「即时一致」,TTL 保证「最终一致」。 两者配合使用,既能及时反映数据变更,又能在漏删时自动兜底,避免脏数据长期存在。
这次翻车之后,我整理了一个缓存键设计检查清单,每次让 AI 写缓存相关的代码时扫一遍:
拿这个清单翻回去看 AI 写的代码,第一条就挂了——缓存键没包含 category 和 minPrice 这两个影响查询结果的参数。
这份清单怎么落地到团队流程?我的做法是:
这样清单就不只是"看过就忘"的笔记,而是真正变成团队的习惯。
缓存键加 category 不是万能方案。有些场景需要粗粒度缓存——比如热门搜索词,不加分类能让更多用户命中缓存,减少数据库压力。细粒度缓存虽然数据准确,但命中率低,可能反而增加数据库负载。
分场景的推荐:
| 场景 | 推荐策略 | 原因 |
|---|---|---|
| 热门搜索词(如"手机""电脑") | 粗粒度缓存,不加分类 | 命中率高,数据库压力小 |
| 长尾搜索词(如"手工皮具""复古相机") | 细粒度缓存,加分类 | 搜索量低,缓存命中率本来就低 |
| 价格敏感型搜索(如"1000元以下手机") | 细粒度缓存,加价格范围 | 价格变动频繁,粗粒度缓存数据容易过时 |
| 用户个性化搜索(如"我的收藏""最近浏览") | 不缓存或极短 TTL | 数据因人而异,缓存价值低 |
| 后台管理查询 | 不缓存 | 数据实时性要求高,且访问量低 |
回头来看,这个翻车跟主推那篇文章讲的是同一件事:review AI 代码,不能只看"代码对不对",要看"AI 知不知道你没告诉它的事"。
AI 的代码逻辑没问题,缓存读写、过期时间、SQL 查询都是对的。但 AI 不知道"category 虽然是可选参数,但一旦传了就影响查询结果"这个业务常识。
如果 review 时用了三步法——列出 AI 的假设(缓存键只包含 keyword 就够了)、验证假设(调用方会不会传 category?会的话 key 够不够?)、修复假设(补 category 到缓存键)——这个 bug 在 review 阶段就能发现,不会等到上线后被用户投诉。
代码写对了,不代表 AI 理解对了。
下次 review AI 代码时,不妨多问一句:它知道哪些你没告诉它的事?