尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

AI 写的搜索缓存,同一个关键词不同分类返回了相同的结果——一个缓存键粒度的翻车

AI 写的搜索缓存,同一个关键词不同分类返回了相同的结果——一个缓存键粒度的翻车 摘要AI 写的缓存代码逻辑完全正确但缓存键只用了关键词没加分类导致不同分类的搜索结果互相覆盖。根因不是 AI 写错了而是它对「缓存键粒度」的认知不足——它知道 category 是可选参数但没把它纳入缓存键。本文记录排查过程、根因分析、修复方案以及沉淀的「缓存键设计检查清单」。文章目录上线第二天用户投诉了排查过程第一步确认调用方的参数第二步看缓存代码第三步确认数据源根因分析修复方案缓存失效策略主动失效数据变更时删除相关缓存键设置合理的 TTL用过期时间兜底不同场景的选择缓存键设计检查清单边界说明这个翻车说明了什么上线第二天用户投诉了前阵子给一个搜索功能加缓存。搜索需求很简单——用户选分类、输入关键词、设置价格范围搜出符合条件的商品列表。因为查询频率高数据变动不频繁加缓存能省不少数据库压力。AI 写的缓存实现我看了一遍逻辑觉得没问题就 merge 上线了。上线第二天用户反馈说搜’手机-苹果’和’搜’手机-小米’出来一样的商品列表。排查过程第一步确认调用方的参数先看前端传了什么参数。用户操作日志 用户A: searchProducts(手机, 苹果, 0) → 返回商品列表 用户B: searchProducts(手机, 小米, 0) → 返回商品列表跟用户A一样参数没传错——两个用户传了不同的 category但返回了相同的结果。第二步看缓存代码AI 的缓存实现长这样// 为什么要看这段缓存逻辑本身没问题但缓存键的设计有隐患functionsearchProducts(keyword:string,category?:string,minPrice?:number){constcacheKeysearch:keywordconstcachedcache.get(cacheKey)if(cached)returncachedconstresultsawaitdb.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})returnresults}// 问题模拟 用户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用户AsearchProducts(手机, 苹果, 0)search:手机未命中查库返回[iPhone15, iPhone14, iPhoneSE...]写入缓存✅2用户BsearchProducts(手机, 小米, 0)search:手机命中直接返回[iPhone15, iPhone14, iPhoneSE...]❌ 应为小米手机3用户CsearchProducts(手机, 华为, 0)search:手机命中直接返回[iPhone15, iPhone14, iPhoneSE...]❌ 应为华为手机4用户DsearchProducts(手机, 苹果, 1000)search:手机命中直接返回[iPhone15, iPhone14, iPhoneSE...]❌ 应过滤价格≥10005用户EsearchProducts(电脑, 联想, 0)search:电脑未命中查库返回联想电脑列表写入缓存✅从表格可以清楚看到只要关键词相同无论分类和价格范围怎么变缓存键都是同一个。第 2、3、4 步的用户都命中了第 1 步写入的缓存拿到了苹果手机的列表——这就是搜’手机-苹果’和搜’手机-小米’出来一样的直接原因。第三步确认数据源查了一下数据库确认手机-苹果和手机-小米确实应该返回不同的数据。数据没问题问题在缓存。整个排查链路可以用一张图来概括命中未命中问题点key 只含 keyword用户发起搜索请求构造缓存键缓存是否命中直接返回缓存结果查询数据库写入缓存返回商品列表不同分类互相覆盖根因分析AI 的代码逻辑没错——缓存读写、过期时间、清理策略都是对的。问题出在AI 知道 category 是可选参数但写缓存时默认了 category 为空的情况没把 category 纳入缓存键。注意看 AI 的 SQL 查询WHEREnameLIKE?AND(?ISNULLORcategory?)ANDprice?SQL 里处理了 category 为空的情况——如果 category 没传就不按分类过滤。这个逻辑是对的。但缓存键没跟上。AI 的代码里缓存键只用了 keyword因为从函数签名来看keyword 是必选参数category 是可选参数。AI 的推理是必选参数是核心可选参数是可选的——所以在缓存键里只用了核心参数。但实际业务里category 虽然是可选参数但一旦传了它直接影响查询结果。缓存键必须把它纳进去。这个推理偏差不只在 AI 身上有——人写代码也可能犯同样的错。但区别在于人写代码时心里清楚这个分类参数会影响结果而 AI 只是从函数签名推导了keyword 重要category 不重要。修复方案修复很简单把 category 和 minPrice 纳入缓存键。// 修复后缓存键包含所有影响查询结果的参数functionsearchProducts(keyword:string,category?:string,minPrice?:number){// 构建缓存键时把所有影响查询结果的参数都包含进去// 可选参数为空时用默认值兜底避免缓存键歧义constcacheKeysearch:${keyword}:${category??all}:${minPrice??0}constcachedcache.get(cacheKey)if(cached)returncachedconstresultsawaitdb.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})returnresults}// 修复后运行结果 用户A: searchProducts(手机, 苹果, 0) → 缓存未命中key search:手机:苹果:0 → 查数据库写入缓存 → 返回 [iPhone15, iPhone14, iPhoneSE...] 用户B: searchProducts(手机, 小米, 0) → 缓存未命中key不同key search:手机:小米:0 → 查数据库写入缓存 → 返回 [小米14, 红米Note13...] 两个结果不再互相覆盖 ✅修复前后的缓存键差异用一个简单的图来展示缓存键设计对比 before: search:手机 ↓ 用户A搜手机-苹果 → 写入 keysearch:手机 → 包含所有商品 用户B搜手机-小米 → 命中 keysearch:手机 → 返回相同结果 ❌ after: search:手机:苹果:0 vs search:手机:小米:0 ↓ ↓ 用户A: 缓存未命中 → 写入 keyA → 只返回苹果手机 用户B: 缓存未命中 → 写入 keyB → 只返回小米手机 ✅缓存失效策略缓存键修好了但还有一个问题没解决商品数据更新后旧缓存怎么办如果只加缓存键不处理失效用户可能看到过期的商品列表——比如商品下架了、价格改了、库存变了缓存里还是旧数据。主动失效数据变更时删除相关缓存键最直接的方式是在商品数据发生变更的地方主动删除受影响的缓存键。以 Redis 为例用DEL命令删除// 商品更新/删除时主动删除相关搜索缓存asyncfunctioninvalidateProductCache(product:Product){// 方案一删除该商品可能命中的所有搜索缓存键// 注意这里需要遍历所有可能的分类和价格组合成本较高constkeysawaitredis.keys(search:${product.name}:*)if(keys.length0){awaitredis.del(keys)}// 方案二推荐维护「商品 → 缓存键」的映射关系// 商品更新时通过映射表精确找到需要删除的缓存键constrelatedKeysawaitredis.smembers(product:${product.id}:cache_keys)if(relatedKeys.length0){awaitredis.del(relatedKeys)awaitredis.del(product:${product.id}:cache_keys)}}方案一用KEYS通配符匹配简单但性能差会阻塞 Redis。更稳妥的做法是维护一张映射表写入缓存时同时记录「这个商品影响了哪些缓存键」// 写入缓存时同时登记商品与缓存键的映射关系asyncfunctionsetSearchCache(cacheKey:string,results:Product[],productIds:string[]){awaitredis.set(cacheKey,JSON.stringify(results),EX,300)// 把缓存键登记到每个相关商品名下for(constidofproductIds){awaitredis.sadd(product:${id}:cache_keys,cacheKey)}}// 商品更新时通过映射表精确删除相关缓存asyncfunctiononProductUpdated(productId:string){constrelatedKeysawaitredis.smembers(product:${productId}:cache_keys)if(relatedKeys.length0){awaitredis.del(relatedKeys)awaitredis.del(product:${productId}:cache_keys)}}设置合理的 TTL用过期时间兜底主动删除能保证数据即时一致但实现成本高。TTL过期时间是兜底方案——即使漏删了缓存也会在过期后自动失效重新查库。TTL 的取舍场景推荐 TTL原因商品价格、库存30~60 秒价格变动频繁过期太快会频繁查库太慢用户看到旧价格商品名称、分类5~10 分钟这类信息变动少可以缓存久一点热门搜索词1~3 分钟命中率高但数据要相对新鲜长尾搜索词10~30 分钟搜索量低缓存久一点能减少数据库压力// 根据业务场景动态设置 TTLfunctiongetSearchTtl(keyword:string,category?:string):number{// 热门搜索词短 TTL保证数据新鲜if(isHotKeyword(keyword))return60// 长尾搜索词长 TTL减少数据库压力if(isLongTailKeyword(keyword))return1800// 默认 5 分钟return300}// 写入缓存时使用动态 TTLcache.set(cacheKey,results,{ttl:getSearchTtl(keyword,category)})不同场景的选择场景推荐策略原因商品后台编辑改价、下架主动删除 短 TTL数据变更即时生效TTL 兜底防漏删批量导入/定时同步主动删除 长 TTL批量操作后统一清缓存长 TTL 减少日常查库用户生成内容评论、评分只靠 TTL变更频繁且分散主动删除成本太高促销活动秒杀、限时折扣极短 TTL10~30 秒价格变化快必须保证数据新鲜核心原则主动删除保证「即时一致」TTL 保证「最终一致」。两者配合使用既能及时反映数据变更又能在漏删时自动兜底避免脏数据长期存在。缓存键设计检查清单这次翻车之后我整理了一个缓存键设计检查清单每次让 AI 写缓存相关的代码时扫一遍缓存键是否包含所有影响查询结果的参数可选参数是否也纳入了缓存键特别容易漏缓存键的粒度是否跟调用方的预期一致同一份数据有没有可能被不同的缓存键重复缓存浪费内存缓存键的变化会不会导致旧缓存无法自动失效需要手动清理的拿这个清单翻回去看 AI 写的代码第一条就挂了——缓存键没包含 category 和 minPrice 这两个影响查询结果的参数。这份清单怎么落地到团队流程我的做法是把清单写进 PR 模板的 review 检查项每次提交代码自动带上代码评审时强制对照清单逐条打勾缺一项不通过沉淀为团队 Wiki 文档新人入职必读这样清单就不只是看过就忘的笔记而是真正变成团队的习惯。边界说明缓存键加 category 不是万能方案。有些场景需要粗粒度缓存——比如热门搜索词不加分类能让更多用户命中缓存减少数据库压力。细粒度缓存虽然数据准确但命中率低可能反而增加数据库负载。分场景的推荐场景推荐策略原因热门搜索词如手机“电脑”粗粒度缓存不加分类命中率高数据库压力小长尾搜索词如手工皮具“复古相机”细粒度缓存加分类搜索量低缓存命中率本来就低价格敏感型搜索如1000元以下手机细粒度缓存加价格范围价格变动频繁粗粒度缓存数据容易过时用户个性化搜索如我的收藏“最近浏览”不缓存或极短 TTL数据因人而异缓存价值低后台管理查询不缓存数据实时性要求高且访问量低这个翻车说明了什么回头来看这个翻车跟主推那篇文章讲的是同一件事review AI 代码不能只看代码对不对要看AI 知不知道你没告诉它的事。AI 的代码逻辑没问题缓存读写、过期时间、SQL 查询都是对的。但 AI 不知道category 虽然是可选参数但一旦传了就影响查询结果这个业务常识。如果 review 时用了三步法——列出 AI 的假设缓存键只包含 keyword 就够了、验证假设调用方会不会传 category会的话 key 够不够、修复假设补 category 到缓存键——这个 bug 在 review 阶段就能发现不会等到上线后被用户投诉。代码写对了不代表 AI 理解对了。下次 review AI 代码时不妨多问一句它知道哪些你没告诉它的事
返回列表