首页 / 资讯中心 / 文章详情

AI助手缓存预热实战:从高并发事故到保命工程

AI助手缓存预热实战:从高并发事故到保命工程 ★ FEATURED ARTICLE
做 HR AI 助手的这大半年我踩过最深的坑就是缓存。准确地说是缓存没预热导致的线上事故。当时我们上线了一个基于大模型的智能简历解析功能用户量一大同一批企业客户的简历批量导入直接把 Redis 打挂了。那次之后我才明白缓存预热不是锦上添花的性能优化而是 AI 助手这种高并发、高计算成本场景下的保命工程。这篇文章就把我们团队从零到一设计缓存预热方案的完整过程写出来包括为什么预热、预热什么、怎么预热、踩过哪些坑。如果你是做 AI 应用、智能助手、或者任何涉及模型推理前有大量数据准备的系统这篇文章应该能帮你少走不少弯路。1. 项目背景与设计思路拆解1.1 HR AI 助手场景下的缓存需求分析市面上大多数 HR 智能助手做的事情都差不多解析简历、筛选候选人、生成岗位描述、辅助面试评估、回答员工 HR 政策问题。听起来不复杂但落到技术上有一个共同的特点——每次 AI 调用都不是一个单纯的模型请求前面拖着一条长长的数据准备链路。以我们的候选人画像生成功能为例。用户上传一份简历系统要做以下事情解析 PDF / Word 原始文件提取文本用 NER 模型抽取姓名、学历、工作经历、技能标签调用大模型对经历描述做语义摘要匹配内部岗位库计算匹配度生成结构化的候选人画像卡片这条链路里第 1 步平均耗时 300ms第 2 步 200ms第 3 步大模型调用 1 到 3 秒第 4 步取决于岗位库的匹配算法和数据库查询。有一个很尴尬的事实大模型推理时间难以大幅压缩但前序步骤的耗时可以通过缓存砍掉一大半。更有意思的是大量的数据准备过程是高度重复的。同一个企业客户可能同一批候选人信息会被不同 HR 反复查看同一个岗位描述模板会被不同的部门重复生成同一份员工手册问答被几百个员工轮番询问。这些场景天然适合缓存。问题来了缓存命中率上不去系统压力全都压在下游缓存命中率上去了AI 助手的响应速度能快得让 HR 感觉机器真的懂我。而决定命中率高低的就是预热做得够不够好。1.2 为什么必须做缓存预热很多人觉得缓存是用户第一次请求时慢慢写入就够了。我们一开始也是这么想的后来发现三个致命问题第一AI 场景下第一次请求的成本极高。普通 Web 应用缓存未命中大不了多查一次数据库几十毫秒的事。AI 场景下缓存未命中意味着要走完整条 NER 大模型 向量检索链路一次可能好几秒。如果一批新数据同时涌入——比如 HR 一次性导入 200 份简历——200 个并发未命中请求同时触底数据库和大模型系统直接雪崩。第二缓存过期时的集中回源问题。HR 系统的数据不像电商商品那样天然分散它有非常强的组织聚集性。一个岗位发布后所有候选人匹配都会访问同一个岗位向量一份员工手册更新后大量员工同时查询同一批 FAQ 答案。这些 key 在同一时间点过期就像超市一开门大家都涌进去收银台全堵住。第三AI 缓存的价格和容量约束。大模型生成的内容动辄几千 token如果缓存存原始文本内存消耗巨大。我们早期试过把完整的 AI 回复直接塞进 Redis一个小型客户的全量数据就能吃掉几个 GB。这种情况下缓存无法覆盖全量数据必须有选择地预热——把高价值数据在流量到达之前提前加载到缓存而不是被动等请求打上来。所以缓存预热的本质是把流量来了再准备改成流量没来先准备。从项目第一天就把这个机制设计进去比后期做应急扩容要省心一万倍。1.3 方案选型什么数据值得预热设计预热方案前我们最纠结的一句话是到底什么数据算值得预热团队最初的想法很朴素——把所有数据全量预热简单粗暴。结果算了一下账我们的候选人数百万岗位描述数十万FAQ 知识库上万条全量预热意味着要把所有数据跑一遍 NER、大模型摘要、向量化耗时可能超过 10 个小时服务器成本高到离谱而且里面大量数据可能半年都没人访问纯属浪费。后来我们整理出一套筛选逻辑核心就三条高频访问数据岗位描述模板、组织架构、常用 FAQ、企业员工的常见查询意图。这些数据在业务上是高基数、低变动的。强时效数据刚导入的候选人简历、刚发布的职位、刚更新的制度文档。这类数据在短期内访问集中过了窗口期价值迅速下降。高计算成本数据即使访问频率不高但生成一次代价特别大的数据例如跨部门的人才盘点报告、多轮对话的上下文摘要这类数据值得预热来换取时间。最终我们的策略是高频 强时效 高成本三者交集优先预热并在此基础上做两级缓存分层。一级缓存是热点数据的短 TTL 缓存二级缓存是基础数据的长期缓存。预热的范围严格按照这套优先级执行。2. 缓存预热完整方案设计2.1 确定预热数据范围MVP 优先还是全量覆盖在方案组会上我们曾经认真讨论过要不要一次到位做全量预热。后来综合评估选了 MVP 优先的策略。理由有两方面一是预热的规模和计算成本强相关。全量预热意味着要把所有数据跑完整个 AI 链路模型推理费用和时间都受不了。我们测过一个粗略数字1 万条数据的全量摘要生成用当时的中型模型配额要跑约 40 分钟消耗几千次模型调用。全量几百万条数据根本不可行。二是预热必须围绕业务目标做取舍。我们当时业务上最重要的场景是招聘旺季的候选人快速筛选。所以第一版 MVP 选择预热三类数据企业客户下所有已激活岗位的向量表示最近 7 天内导入的候选人简历解析结果员工手册中 Top 500 高频问答对的缓存这里有个很关键的经验预热数据范围要跟业务节奏对齐。比如招聘淡季你可以把预热范围缩小到当日活跃岗位 近 3 天候选人降低资源占用招聘旺季前再扩大预热范围。我们用一个配置文件来管理预热策略而不是写死在代码里后面调整成本低很多。2.2 预热触发时机与执行策略预热时机设计得不好要么缓存白做要么又变成新的性能瓶颈。我们综合考虑下来把触发时机分成四个维度服务启动时应用启动过程中加载静态基础数据比如知识库向量、岗位模板。这是最经典的预热时机保证服务一上线就有可用缓存。定时任务采用 Cron 表达式在业务低谷期执行一般是凌晨两点到六点对当天的高频数据和新增数据做批量预热。事件驱动核心机制当业务操作触发关键事件时立刻对该数据的关联缓存做预热。例如 HR 导入新候选人系统不仅解析这一份简历还会把该候选人的标准问题答案、相似岗位匹配结果一并预生成。过期异步补偿当缓存被访问但恰好过期时我们不是让请求直接打到下游而是先返回旧值同时在后台异步任务里重建新的缓存。这样用户无感知系统又不会因为集中过期而雪崩。在这四种策略里我认为事件驱动是最容易被忽略、但实际效果最好的一种。因为它的实时性高能把预热动作精准绑定在业务动作上避免定时任务覆盖不到的突发场景。比如 HR 在晚上十点临时发布了一个紧急岗位第二天的定时预热显然来不及但事件驱动机制可以在这个动作发生的当下就把缓存升温了。2.3 架构设计预热服务在整体系统中的位置预热模块没有做成一个独立的巨型服务而是作为 HR AI 助手中间层的一部分。整体架构分三层业务接入层Web / 移动端 / 企业微信入口 AI 服务层意图识别、简历解析、问答生成、匹配引擎 数据缓存层Redis 本地 Caffeine 双层缓存预热模块挂在 AI 服务层和数据缓存层之间。它的职责很清晰从业务数据库获取原始数据经过 AI 链路处理后写入缓存。不直接暴露给上层调用方也不干扰正常的业务请求链路。设计时我们特别强调预热任务之间的隔离性。如果预热任务跑挂了不能影响正常服务的稳定性。所以预热任务统一走独立的线程池配置独立的 Redis 实例或逻辑分库同时支持熔断——如果预热任务连续失败超过阈值就自动暂停等下一轮触发再试。一个重要的取舍是预热任务的数据装配逻辑和线上请求处理逻辑必须共用一套代码。我们最开始踩过坑预热单独写了一套数据拼装逻辑结果线上请求和预热数据格式不一致缓存命中但解析报错排查了半天。后来强制要求同一套数据准备服务预热只是多了一个主动调用的入口。3. 核心实现细节3.1 数据加载与组装逻辑我们要预热的数据不是简单的数据库行而是一个完整的业务对象。以岗位匹配度预计算为例单个岗位的缓存内容至少包括岗位基础信息名称、部门、职级、薪资范围岗位 JD 的大模型摘要向量该岗位 Top 优先级的能力标签与该岗位匹配度超过阈值的历史候选人 ID 列表这些数据分散在三张业务表和两个向量索引里。预热前必须先把它们聚合组装。我们的做法是定义了一个统一的数据装配接口所有可预热数据都实现同一个接口public interface PreheatingSourceT { ListCacheKey collectKeys(); T assemble(String key); String serialize(T obj); }collectKeys负责找出需要预热的所有 keyassemble负责把分散的数据组装成一个完整对象serialize负责序列化格式。这样无论底层是 MySQL、Elasticsearch 还是向量库预热模块都能统一处理新增一种数据源只需要实现这个接口不需要改预热框架。有一个细节必须提组装逻辑一定要做数据版本校验。因为数据来源不同组装过程中可能出现某一字段为空比如候选人的技能标签没抽取出来。如果直接写入缓存后续的 AI 生成就会基于残缺数据输出结果非常离谱。我们在组装里加入了字段完整性检查不满足条件的数据丢弃并记录日志宁可不预热也不缓存错误内容。3.2 预热任务执行器的实现执行器是预热模块的核心它负责从数据源拉取 key 列表并发执行组装和写入。我们基于 Java 的 CompletableFuture 实现了一个版本核心逻辑是并发控制 批量写入public void preheat(PreheatingSource? source) { ListString keys source.collectKeys(); // 控制并发度避免预热流量过大压垮数据库 ExecutorService executor new FixedThreadPool(16); ListCompletableFutureVoid futures keys.stream() .map(key - CompletableFuture.runAsync(() - { try { Object data source.assemble(key); if (data ! null) { cacheService.set(key, source.serialize(data), ttl); } } catch (Exception e) { log.error(preheat failed: {}, key, e); failCounter.increment(); } }, executor)) .collect(Collectors.toList()); CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); }并发度设多少很有讲究。我们最初设为 64结果一次预热任务把车间数据库的连接池打满了线上正常业务查询变慢。后来根据数据库连接池大小、下游 AI 服务的 QPS 上限来算数据库连接池如果有 100 个连接线上业务要占 60 个预热的并发度就控制在 30 以内。同时给 AI 服务调用单独加了限流预热请求的”权重”比线上请求低保证预热不会挤占正常流量。实际运行中我们还给执行器加了分批提交的机制。一次性提交一万个任务到线程池会导致任务队列疯狂积压而且如果某一批数据挂掉整批重试的成本太高。分成每批 500 个一批跑完再提下一批出错时只需要重试当前批次。3.3 任务编排、并发控制与失败重试预热任务之间有依赖关系。比如你想预热候选人完整画像前提是该候选人的简历解析结果已经缓存好了。如果没有编排多个任务可能重复计算、互相覆盖甚至出现把半成品写成成品的时序问题。我们设计了一个简单的 DAG 编排器节点是预热任务边是依赖关系。比如解析简历 - 生成标签 - 计算向量 - 匹配岗位 - 组装画像执行时从入度为零的节点开始执行一组无依赖任务等前置任务完成后自动触发后续任务。基于这个思路我们用队列 状态标记的方式实现了一个轻量级的任务编排。每个任务有PENDING / RUNNING / SUCCEED / FAILED四种状态任务执行完成后检查后置任务的前置状态如果全部满足就把后置任务放入执行队列。失败重试策略上我们分了三层单条数据失败不重试直接记录等待下一个定期预热周期自动重建。单批任务失败延迟 30 秒重试一次如果还是失败放弃该批次。整个预热周期失败告警通知运维并触发熔断防止无休止的重试拖垮系统。这里有个教训不要对 AI 链路里的模型调用做无限重试。模型接口偶尔超时重试一两次还可以接受如果连续重试大模型费用会迅速上升而且还会拖慢其他正常请求。我们设置的上限是三次重试并且重试之间必须有递增的退避时间。3.4 代码实现一个完整的预热流程示例下面是一个简化但完整可跑通的预热流程以岗位模板语义向量为例用伪代码展示从数据扫描到缓存放热的全过程import redis import time from concurrent.futures import ThreadPoolExecutor, as_completed r redis.Redis(hostlocalhost, port6379, db0) PREHEAT_KEY_PREFIX hr_ai:job_embedding: BATCH_SIZE 500 TTL_SECONDS 3600 * 24 def scan_job_ids(): # 模拟从数据库查询活跃岗位ID return [fjob_{i} for i in range(1, 10001)] def generate_embedding(job_id): # 真实场景这里调用大模型接口把JD向量化 time.sleep(0.05) return {job_id: job_id, embedding: [0.1] * 128, generated_at: int(time.time())} def preheat_jobs(): job_ids scan_job_ids() total len(job_ids) for start in range(0, total, BATCH_SIZE): batch_ids job_ids[start:start BATCH_SIZE] with ThreadPoolExecutor(max_workers16) as executor: futures {executor.submit(generate_embedding, jid): jid for jid in batch_ids} for future in as_completed(futures): job_id futures[future] try: data future.result() cache_key PREHEAT_KEY_PREFIX job_id # 使用JSON序列化并设置过期时间 r.setex(cache_key, TTL_SECONDS, json.dumps(data)) except Exception as e: print(f预热失败 ${job_id}: {e}) if __name__ __main__: preheat_jobs()这段代码里有两个点值得展开说。第一是setex必须带 TTL防止预热后的数据永远不过期导致业务数据更新后缓存还是旧值。第二是ThreadPoolExecutor(max_workers16)的线程数这个值建议通过压测得出不要随手写。我们最终在生成环境用的线程数是 16因为模型接口的并发上限就是 20留 4 个余量给正常请求。4. 实操过程与性能验证4.1 环境准备与配置实践这个方案前需要准备一个最小化的环境。建议本地用 Docker 跑一套 Redis 和 MySQLAI 服务用 Mock 接口模拟不需要真的接大模型。这样一方面节省成本另一方面便于重现缓存的全部问题。我们当时的验证环境配置如下组件配置说明Redis单机 8GB 内存存储所有预热缓存数据MySQL单实例 4 核 8G存储岗位、候选人等业务数据AI 解析服务Mock 接口单次请求 50ms模拟模型推理耗时预热服务2 核 4GJVM 堆 2GB运行预热任务调度器配置上有两个细节需要注意。一是 Redis 的maxmemory-policy建议设为allkeys-lru缓存预热的本质是保留高价值数据当内存不足时淘汰最久未使用的数据是合理的。二是连接池大小要分开配置Redis 连接池、数据库连接池、AI 调用线程池三者独立避免互相拖累。4.2 预热执行流程实录以我们验证过的10000 个岗位向量预热为例完整的执行流程如下触发定时任务每天凌晨 2 点调度器扫描job_status ACTIVE的岗位记录。收集 key 列表预热模块从 MySQL 查出所有符合条件的岗位 ID约 10000 个。分批组装数据每 500 个 ID 为一批分批提交到线程池。调用 AI 接口生成向量每条岗位记录组装后调用向量生成接口平均耗时 50ms。写入 Redis生成的向量对象序列化为 JSON通过SETEX写入TTL 设置为 24 小时。记录预热日志每批任务完成后输出耗时、成功数、失败数到日志中心。实测下来10000 条数据分布在 16 并发线程下总体耗时约 35 秒。这个效率在我们的接受范围内。但有一个性能瓶颈是明显的Redis 批量写入不够快。后来我们把逐条写改成pipeline批量写单批次 500 条用 100ms 写完整体预热耗时压到了 20 秒以内。如果你用的是 Python 的 redis-pypipeline的用法很简单pipe r.pipeline(transactionFalse) for data in batch_data: pipe.setex(cache_key, TTL, json.dumps(data)) pipe.execute()注意transactionFalse预热任务不需要事务保护关掉它能减少网络往返提升写入吞吐实测可以减少约 40% 的耗时。4.3 冷启动 vs 预热后的对比数据性能验证阶段我们做了两组对比测试分别模拟没有预热和有预热两种状态下的系统表现。测试场景是 50 个 HR 用户同时查看候选人画像。无预热状态下的表现平均响应时间 4.8 秒最慢响应 12.3 秒数据库连接池一度达到 90% 使用率大模型接口调用总量250 次大量重复计算有预热状态下的表现平均响应时间 0.6 秒最慢响应 1.2 秒数据库连接池使用率稳定在 30% 以下大模型接口调用总量8 次仅针对未覆盖的少量新增数据这两组数据的差异已经远远超过了“优化”的范畴基本可以看作两种不同的系统形态。无预热时50 个并发请求就已经把下游打得很吃力有预热时即便把这个数字放大 10 倍到 500 并发系统也能稳定支撑。但我也要强调一个反直觉的现象预热不是越快越好预热本身也会造成资源空转。我们观察到凌晨全量预热期间数据库负载会阶段性升高如果同一时间有其他跑批任务就会互相争抢资源。后来我们给预热任务设置了最低优先级同时把预热时间窗口尽量和业务跑批错开情况才缓解。5. 常见问题与排查技巧实录5.1 缓存命中率不升反降怎么排查我们上线预热后的第一周发现整体缓存命中率只提升了 5%远低于预期。排查过程值得记录一下。第一步我们检查了预热日志发现预热任务本身没有问题数据确实写入缓存了。第二步看了 Redis 的 hit 和 miss 统计发现很多 key 在预热后根本没被访问过。第三步查看线上请求的 key 分布发现业务代码里拼接缓存 key 的规则和预热规则不一致——两边用了不同的字段分隔符有的用冒号有的用下划线导致预热的 key 和请求的 key 对不上。解决办法很笨但也最有效把缓存 key 的生成逻辑抽取到一个公共类所有写入和读取都走同一个方法杜绝手写拼接。这个坑花了两天才定位出来教训是缓存 key 命名规范必须在项目第一天就定下来并且用工具强制约束。5.2 预热数据与线上数据不一致怎么办这个问题的典型场景是 HR 早上更新了某个岗位的 JD但预热任务在凌晨已经执行过了缓存里还是旧的 JD。AI 助手基于旧数据给候选人生成了完全过时的匹配结果。我们的方案是双写失效 延迟重建。具体做法是业务更新岗位 JD 时不直接删除缓存而是先更新数据库然后向延迟队列发一条失效并重建缓存的消息。延迟 5 秒后执行这样可以避免更新数据库的事务还没提交完毕就去重建缓存从而读到脏数据。5 秒的延迟窗口内旧缓存继续服务用户无感知。这个机制配合我们前面说的事件驱动预热几乎解决了所有实时一致性问题。唯一要小心的是延迟队列的可靠性我们用 Redis Stream 实现如果消费失败会进入死信队列人工介入。5.3 预热任务压垮数据库如何限流前面提到过我们最初并发度设 64 把数据库压垮。后来总结了一套相对稳妥的动态限流方案启动前从数据库连接池拿到当前空闲连接数动态计算预热的并发上限。预热任务运行中每 10 秒检查一次下游 AI 服务的健康状态如果延迟上升超过阈值自动降低并发。为预热任务单独设置 QPS 上限不能让预热对正常业务的响应时间产生超过 5% 的影响。这套动态限流方案在实际运行中基本稳定偶尔有波动但不会再出现压垮数据库的事故了。5.4 你不能踩的坑预热数据过期时间不敢设太长最后一个特别想提醒的坑。很多人在设计缓存时为了让预热效果持久把 TTL 设得很长比如 7 天。但是对于 HR AI 助手这种强业务时效场景这是有隐患的。举个例子员工手册里的年假政策被预热成缓存后如果今年公司调整了休假规则而 TTL 还有 3 天才过期这 3 天内所有问答都是基于旧政策回答的。轻则误导员工重则引发劳动纠纷。我们的经验是按数据的业务时效性分级设置 TTL。数据类别典型数据TTL 建议基础静态数据组织架构、岗位模板24-48 小时高频问答员工手册 FAQ12 小时实时性强的数据候选人简历解析、招聘进度2-4 小时高度敏感数据薪酬、绩效不缓存直接查库在 AI 助手场景里缓存不只是查库背后还有模型推理错误的缓存比没有缓存的伤害更大。6. 后续扩展思路与个人体会这套预热机制上线之后我们还规划了几个扩展方向有的已经落地有的还在验证中。第一个是基于访问日志的智能预热。目前我们的预热范围主要依赖业务规则比如最近 7 天导入的候选人。但业务规则永远滞后于真实访问模式。我们正在尝试对访问日志做统计找出 Top 20% 的高频 key针对这些 key 做动态加长 TTL 和更激进的预热目标是让缓存命中率再上一个台阶。第二个是多级缓存联动预热。现在预热主要面向 Redis 这一层但本地 Caffeine 缓存在进程重启后就完全失效了重启瞬间会有大量请求穿透到 Redis。我们的想法是应用启动时先读取一个预热的 key 列表主动加载到本地缓存避免重启后的缓存风暴。第三个是预热结果质量监控。AI 链路的数据准备成本高所以保证预热进缓存的数据本身就是高质量、符合预期的非常重要。我们后续计划给每条预热数据加上源数据版本号如果源数据版本变了但缓存未刷新可以在读取时快速识别并主动失效从根上杜绝脏数据。回到我开头说的那次事故。当时如果我们的系统里有预热机制200 份简历导入的事件触发式预热会在流量到达前完成大部分计算数据库和大模型根本不会被打到极限。那次事故之后我们团队达成了一个共识在 AI 应用的系统设计中缓存预热应该和数据库设计、模型选型放在同等重要的位置来考虑它不是性能优化而是架构的一部分。如果你正在做 AI 应用不管是 HR、客服、教育还是内容生成的场景我强烈建议你把缓存预热作为第一版架构的一部分而不是上线后遇到性能问题再补。预热看似只消耗了额外的资源实际上它节省的是每一次重复计算的巨额成本以及系统雪崩时你凌晨三点爬起来救火的头发。
阅读完成 · 觉得有帮助?
咨询建站