一聚教程网:一个值得你收藏的教程网站

最新下载

热门教程

AI 搜索缓存为何串了分类结果:缓存键粒度失误复盘

时间:2026-09-16 11:40:01 编辑:袖梨 来源:一聚教程网

缓存代码能正常读取、写入并按时过期,并不代表它一定正确。一次搜索功能上线后,相同关键词在不同商品分类下竟返回了同一批数据;沿着请求参数、数据库查询和缓存命中链路逐步排查,问题最终落在一个容易被忽略的细节上:缓存键没有覆盖所有会改变查询结果的条件。

摘要: 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 步写入的缓存,拿到了苹果手机的列表——这就是"搜'手机-苹果'和搜'手机-小米'出来一样"的直接原因。

第三步:确认数据源

查了一下数据库,确认"手机-苹果"和"手机-小米"确实应该返回不同的数据。数据没问题,问题在缓存。

整个排查链路可以用一张图来概括:

image.png

根因分析

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 的取舍:

场景推荐 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 写缓存相关的代码时扫一遍:

  1. 缓存键是否包含所有影响查询结果的参数?
  2. 可选参数是否也纳入了缓存键?(特别容易漏)
  3. 缓存键的粒度是否跟调用方的预期一致?
  4. 同一份数据有没有可能被不同的缓存键重复缓存?(浪费内存)
  5. 缓存键的变化会不会导致旧缓存无法自动失效?(需要手动清理的)

拿这个清单翻回去看 AI 写的代码,第一条就挂了——缓存键没包含 category 和 minPrice 这两个影响查询结果的参数。

这份清单怎么落地到团队流程?我的做法是:

  1. 把清单写进 PR 模板的 review 检查项,每次提交代码自动带上
  2. 代码评审时强制对照清单逐条打勾,缺一项不通过
  3. 沉淀为团队 Wiki 文档,新人入职必读

这样清单就不只是"看过就忘"的笔记,而是真正变成团队的习惯。

边界说明

缓存键加 category 不是万能方案。有些场景需要粗粒度缓存——比如热门搜索词,不加分类能让更多用户命中缓存,减少数据库压力。细粒度缓存虽然数据准确,但命中率低,可能反而增加数据库负载。

分场景的推荐:

场景推荐策略原因
热门搜索词(如"手机""电脑")粗粒度缓存,不加分类命中率高,数据库压力小
长尾搜索词(如"手工皮具""复古相机")细粒度缓存,加分类搜索量低,缓存命中率本来就低
价格敏感型搜索(如"1000元以下手机")细粒度缓存,加价格范围价格变动频繁,粗粒度缓存数据容易过时
用户个性化搜索(如"我的收藏""最近浏览")不缓存或极短 TTL数据因人而异,缓存价值低
后台管理查询不缓存数据实时性要求高,且访问量低

这个翻车说明了什么

回头来看,这个翻车跟主推那篇文章讲的是同一件事:review AI 代码,不能只看"代码对不对",要看"AI 知不知道你没告诉它的事"。

AI 的代码逻辑没问题,缓存读写、过期时间、SQL 查询都是对的。但 AI 不知道"category 虽然是可选参数,但一旦传了就影响查询结果"这个业务常识。

如果 review 时用了三步法——列出 AI 的假设(缓存键只包含 keyword 就够了)、验证假设(调用方会不会传 category?会的话 key 够不够?)、修复假设(补 category 到缓存键)——这个 bug 在 review 阶段就能发现,不会等到上线后被用户投诉。

代码写对了,不代表 AI 理解对了。

下次 review AI 代码时,不妨多问一句:它知道哪些你没告诉它的事?

热门栏目