
去年年底线上出过一次事故。商品详情页的 TP99 从 80ms 直接飙到 3s最初怀疑是数据库慢查询排查了半天才发现是缓存更新链路出了问题——更新策略用了“先删缓存再更新数据库”高并发下热点 key 被瞬间打穿回源流量把数据库压到了临界点。那次之后我把缓存更新的几种套路从头翻了一遍整理了这套在真实业务里能落地的方案。这篇聊的就是高性能网站设计里绕不开的缓存更新问题。内容覆盖三种经典更新模式、高并发场景下的细节处理、Ubuntu 环境里缓存更新报错的排查方法顺带结合 Hugging Face 官方 TEIText Embeddings Inference镜像部署聊一聊推理服务中的缓存更新策略。适合后端开发、架构师以及正在做接口性能优化的同学。1. 缓存更新的底层逻辑高性能网站的命门1.1 高性能网站的基本盘读多写少与命中率几乎所有高性能网站都建立在“读多写少”这个基本假设上。商品详情、用户资料、文章列表这些数据的读频率往往是写频率的几十倍甚至上百倍。缓存的引入就是把这个比例进一步拉大热点数据放在内存里读请求不再全部穿透到数据库。但缓存不是静态的数据一直在变。库存要扣减、价格要调整、用户的头像要换、文章的点赞数要加一。只要数据一变化缓存里的旧值就成了脏数据网站就必须有一套机制把缓存更新到最新状态。这块没做好读性能再高也没用用户看到的一直是过期内容早晚要出问题。说到命中率很多团队喜欢死盯这一个指标觉得命中率高就万事大吉。实际上缓存更新对整个系统的影响是隐性的命中率高但更新策略混乱在低峰期看不出问题一到高峰或大促旧数据、穿透、打崩数据库的风险全部暴露出来。换句话说缓存更新是决定缓存体系能不能长期稳定运行的底盘而不是某个可有可无的优化项。1.2 缓存更新的本质在一致性与性能之间找平衡缓存更新之所以复杂根本原因在于它涉及两个存储系统缓存层和数据层。这两个系统没有原生的分布式事务不可能做到强一致只能在不同策略下追求最终一致。最直接的例子更新数据库成功了但更新 Redis 失败或者先更新了 Redis数据库事务回滚了两边就出现了数据漂移。高性能场景下这种不一致还有放大效应。某个热点数据一旦出现不一致所有读到这个 key 的请求都会拿到错误值直到下一次更新把它覆盖掉。如果业务本身对新鲜度要求很高比如秒杀库存、价格展示这个“不一致窗口”就是事故窗口。所以缓存更新的本质不是“怎么把数据写进去”而是“如何用可控的延迟换取尽可能高的一致性同时保证系统不至于被打垮”。每个模式的选择背后都是这三个维度的取舍一致性要求、性能压力、实现复杂度。没有银弹只有匹配当前业务阶段的选择。1.3 更新套路之前先把缓存架构画清楚很多缓存更新问题根源不在更新本身而在架构没画清楚。缓存分几级哪些数据进缓存失效谁负责回源谁执行热点 key 怎么识别这些问题没答案讨论更新套路就是空中楼阁。我习惯在最开始先把缓存架构图简化为三个层次。最上层是客户端缓存浏览器、App 内嵌中间是分布式缓存层Redis、Memcached最下层是数据源MySQL、PostgreSQL 或第三方接口。缓存更新策略主要作用在中间这一层但不同层级之间会互相影响。比如在 CDN 未命中时请求落到 RedisRedis 未命中再查库这个链路里的“更新”事件可能是删除 CDN 缓存、删除 Redis 缓存、更新数据库三个动作如何编排和排序才是这套设计的核心。画图的过程其实是倒逼自己理清数据流向谁在写、谁在读、谁负责失效通知。理清之后选哪种更新套路就有了依据。2. 三种经典缓存更新模式我建议先从 Cache Aside 入手2.1 Cache Aside先读缓存Miss 再落库回填Cache Aside 是应用最广泛的缓存更新模式也常被称为旁路缓存。它的读写逻辑非常朴素读请求先查缓存命中就直接返回不命中去查数据库然后回填缓存。写请求则先更新数据库再删除或更新缓存。用伪代码表示就是这样def read(key): value cache.get(key) if value is not None: return value value db.query(key) if value is not None: cache.set(key, value, ttl3600) return value def write(key, new_value): db.update(key, new_value) cache.delete(key) # 或 cache.set(key, new_value)为什么写操作里推荐“先更新库再删缓存”原因有两个。第一更新数据库是源头数据库里是对的缓存最多只是暂时不一致第二删除缓存比更新缓存更安全因为并发环境下两个请求同时 set 可能出现旧值覆盖新值的问题而 delete 操作是幂等的下一次读自然会回填最新数据。Cache Aside 的优点是实现简单、对缓存故障容忍度高——缓存挂了不会影响数据库写入读请求只是多一次回源。缺点是存在一段不一致窗口在“更新数据库”和“删除缓存”之间如果有读请求命中旧缓存就会读到旧值。这个窗口通常只有几毫秒绝大多数业务可以接受。我做过的一个社区项目评论计数、用户信息、文章详情都用的 Cache Aside。上线两年极少出现和缓存相关的事故。对于中小团队来说这是一个起步友好、后期也容易演进的好模式。2.2 Read Through / Write Through把缓存升级为数据管家Read Through 和 Write Through 的核心思路是应用层不再直接操作数据库而是把缓存组件当成唯一的存储入口。缓存层内部自己负责从数据库加载数据和写回数据库应用层只需要读缓存、写缓存。实际落地时通常由缓存中间件或本地缓存封装完成。比如用 Redis 企业版模块、App 内的 ORM 缓存框架或者自研一个代理层。应用发一个写请求Write Through 会先写缓存缓存组件同步把数据写入数据库两边都成功后才算写完成读请求也是先读缓存缓存组件发现 Miss 后再从数据库加载并回填。这种模式最大的好处是应用层逻辑简单缓存一致性的控制权集中到了缓存组件里不容易出现“有人直接操作库、忘了删缓存”这种人为失误。坏处也很明显缓存组件的复杂度上去了缓存挂了整个读写链路就断了降级方案不好做。而且 Write Through 同步写库读多写少的场景还好写频繁的业务下性能提升有限。我给一个内部报表系统做过改造用 Write Through 把复杂的聚合查询结果缓存起来写入时同步触发缓存更新报表查询从秒级降到毫秒级。前提是缓存组件本身足够稳定并且我们对缓存故障做了全面的降级预案。2.3 Write Behind性能拉满但脏数据风险也拉满Write Behind 也叫 Write Back写请求只更新缓存立即返回成功然后由后台异步任务批量把缓存里的新值刷到数据库。这个模式下缓存几乎是唯一的写入入口数据库的压力被削峰填谷写入吞吐可以做到很高。举一个容易理解的例子文章浏览量。用户每点一次就写一次 MySQL 肯定扛不住用 Write Behind 把浏览量先累加在 Redis 里每 5 秒批量刷一次库数据库压力降低一个数量级用户侧体验也更好。但 Write Behind 的代价是数据安全性风险。缓存层一旦在“写缓存成功、刷库任务还没执行”时宕机这部分数据就丢了。即使不宕机如果异步任务逻辑有问题也可能出现部分数据没刷上、数据库和缓存长期不一致的情况。所以使用 Write Behind 的地方一定要接受“数据可以短暂丢失”的业务场景比如计数类、埋点类、非关键日志类数据。我在实践中使用 Write Behind 之前会先问三个问题丢几秒数据能不能接受刷库失败有没有补偿机制缓存重启后能不能从持久化恢复三个问题都能解决才敢上。2.4 选型对比和我的实践建议三个模式放到一起看本质区别在于“谁负责把数据写到最终存储”。模式写路径读路径一致性性能实现复杂度适用场景Cache Aside更新库后删缓存先读缓存Miss 查库回填最终一致窗口小中低大多数业务系统Read/Write Through写缓存同步写库通过缓存层读取较强一致中中需要统一数据入口的场景Write Behind更新缓存异步刷库通过缓存层读取弱一致可能丢数据高高计数、埋点、日志等高吞吐写场景我给团队定的规则很简单90% 的业务先用 Cache Aside别急着炫技。只有当写路径的并发压力明确成为瓶颈再评估 Write Through 或 Write Behind。缓存更新模式的选型应该让真实流量数据和业务容忍度来拍板而不是技术偏好。3. 高并发下的缓存更新细节延迟双删、版本号与多级缓存3.1 别让一次更新引发缓存击穿延迟双删的完整推导Cache Aside 看起来简单但在极端并发下有一个隐藏坑。假设一个热点 key 即将过期同时有大量请求涌进来某个请求发现缓存 Miss 后查库并准备回填此时另一个写请求完成了“更新数据库 删除缓存”操作。如果第一个请求的回填动作发生在删除动作之后那么一个旧值就被重新写进了缓存而且这个旧值可能长时间存活直到下一次更新。解决这个问题的常用手段是延迟双删。流程是更新数据库之后删除一次缓存等待一小段时间再删除一次缓存。等待时间通常要大于一次读请求完成“查库 回填”的最长耗时一般设置 500ms 到 1s。第二次删除的目的就是把回填旧值的那个瞬时数据清掉。def write_with_double_delete(key, new_value): db.update(key, new_value) cache.delete(key) time.sleep(0.5) # 依据业务耗时实测调整 cache.delete(key)这个方案不是强一致的但它把不一致窗口压缩得非常小。对于绝大多数互联网业务来说“短暂读到旧值、很快收敛到新值”是完全可接受的。要注意 sleep 是同步阻塞在高并发接口里会浪费线程资源实际项目里可以用延迟队列或异步任务替代效果一样。3.2 用版本号兜底解决并发写导致的旧缓存覆盖延迟双删能解决“删除时序”问题但解决不了“并发写顺序”问题。当两个写请求同时更新同一个 key数据库里的最终值可能是后发请求的结果但由于网络延迟、线程调度等原因前发请求的缓存回填动作可能晚于后发请求到达于是旧值覆盖了新值。版本号或者叫逻辑时钟是常见的兜底方案。给每个缓存 value 配一个递增的版本号每次写操作都让版本号加一。读取时不仅比较 key还要比较版本号只有版本号大的数据才允许写入缓存。def write_with_version(key, new_value): version cache.incr(f{key}:version) db.update(key, new_value, version) # 回填时带上版本号 cache.set(key, {value: new_value, version: version}) def read_with_version(key): data cache.get(key) if data is not None: # 缓存里的版本号如果落后说明数据可能旧 db_version db.get_version(key) if data[version] db_version: value db.query(key) cache.set(key, {value: value, version: db_version}) return value return data[value] value db.query(key) cache.set(key, {value: value, version: db.get_version(key)}) return value版本号方案让不一致窗口从“时间不可控”变成“逻辑可感知”即使缓存里短暂出现了旧值读取端也能识别并主动修正。代价是多一次版本查询或维护全局递增序列的成本适合一致性要求比较高的数据。比如订单状态、支付结果这类场景我宁愿多查一次也不愿让用户看到错误状态。3.3 多级缓存降低直接一致性压力单级缓存的一致性问题在多级缓存下会被放大但也提供了另一种解决问题的思路不是让某一层做到绝对一致而是通过层级配合把一致性压力分散到不同层。典型的做法是本地缓存 分布式缓存两级。读请求先查进程内本地缓存未命中再查 Redis最后落库。更新时先更新数据库再删除 Redis本地缓存则通过一个短 TTL比如 1 到 5 分钟自然过期。这样即使 Redis 删缓存出现短暂失败本地缓存也会在短时间内自动刷新不会长时间脏读。这里要注意本地缓存与 Redis 的 TTL 设置不能太接近。否则会产生“同时大面积失效”的问题回源请求集中打向数据库。我会把本地缓存 TTL 设置为固定值加随机偏移比如 2 分钟加上 0 到 30 秒的随机数让失效时间散开。多级缓存不是银弹但它可以把最核心数据的更新窗口明显缩短同时降低对单点缓存的依赖。4. Ubuntu乌邦图环境下缓存更新出错的排查实录4.1 现场还原一条“更新缓存时出错”的告警有段时间测试环境频繁报“乌邦图更新缓存时出错”实际上就是在 Ubuntu 服务器上执行缓存更新命令失败。先说明一下这里说的“乌邦图”就是 Ubuntu 的中文音译日常沟通里大家都这么叫。当时的具体现象代码里调用 Redis 的SET命令偶尔超时多试几次又恢复。从应用日志看报错集中于某一台 Ubuntu 服务器。我按「网络、资源、配置、权限」的顺序排查最后锁定在 Linux 系统文件句柄数上。Redis 客户端实例数量在高并发测试时暴涨单进程文件句柄数耗尽新的连接无法建立所有缓存更新操作全部排队或直接超时。这类问题在 Ubuntu 环境里其实很常见而且不一定只在 Redis。凡是“更新缓存报错、时好时坏、没有固定规律”的故障都值得先看一眼系统资源限制。4.2 更新缓存最容易被忽略的四类错误结合多年维护 Ubuntu 服务器的经验更新缓存出错基本可以归到四类第一类是权限问题。Redis 的持久化目录、日志目录权限不对BGSAVE或AOF写入失败Redis 进入只读或保护模式应用端执行写命令就会报错。这类问题很像 DNS 解析失败——名称对不上请求发不出去。解决办法是检查 Redis 配置里的dir和logfile权限确保运行 Redis 的用户对这些目录有写权限。第二类是磁盘空间不足。Ubuntu 系统的/var分区如果被打满Redis 的持久化写不进去内存淘汰策略又没配好会导致写入异常。常见表现是MISCONF Redis is configured to save RDB snapshots。排查时直接df -h看磁盘占用顺手清理旧日志和临时文件。第三类是配置参数踩坑。比如maxmemory-policy设置成noeviction内存写满后所有更新操作直接报OOM command not allowed when used memory maxmemory。这类问题在测试环境最坑——平时数据量不大没事一到压测就爆。我习惯在生产环境使用allkeys-lru对热key数据做 LRU 淘汰但因为这个策略会丢数据如果业务不能容忍缓存丢失就得设置合理的maxmemory并加上监控告警。第四类是网络层面的问题。Ubuntu 自带的 ufw 防火墙或者云平台安全组可能在更新部署后把 Redis 端口限制住导致应用服务器无法连接。排查方法很直接在应用服务器上telnet redis-ip 6379不通就逐层检查安全组、ufw 规则和 Redis 的bind配置。4.3 更新缓存不可靠时怎么优雅降级缓存更新出错时最忌讳的是一路重试到底把数据库也压垮。我在线上系统里会做两层降级。第一层是“更新失败先重试 N 次间隔递增”。这里的重试只针对缓存更新本身不连带重放数据库请求。第二层是重试仍然失败时打开“缓存旁路”开关读请求直接查数据库写请求也只写数据库不再尝试更新缓存。此时整个系统退化为无缓存模式性能下降但功能不受影响等缓存服务恢复后再自动切回。这个降级开关非常关键它保证了缓存组件故障时数据库仍然安全。实际操作中我还会给缓存更新打点监控失败率超过阈值就自动发告警到值班群。降级策略说起来简单但没有提前做预案的话故障现场基本只能靠人肉盯日志在下游流量洪峰时很难稳住。5. 从镜像部署视角看缓存更新Hugging Face TEI 的实践5.1 TEIText Embeddings Inference解决什么问题TEI 是 Hugging Face 官方推出的高性能文本嵌入Embedding推理服务全称 Text Embeddings Inference。它专门用来部署 embedding 类模型比如 BGE、GTE、E5 这些常用模型提供兼容 OpenAI 风格的接口开箱即用。很多团队做知识库、语义检索、RAG 应用时都会用 TEI 作为向量生成的底座服务。我为什么要在缓存更新的文章里提 TEI因为推理服务同样有缓存而且比普通 Web 缓存更“重”。TEI 会把模型的权重文件加载到显存或内存也会缓存 tokenizer 结果、embeddings 结果。一旦模型版本更新或者代码版本更新这些缓存如果不及时失效线上推理结果就会停留在“旧模型”的语义空间里检索效果会明显变差。5.2 部署 TEI 镜像时的缓存目录与更新策略TEI 官方提供了 Docker 镜像最常用的就是 Hugging Face 仓库里的ghcr.io/huggingface/text-embeddings-inference镜像。部署时通常会用-v参数把模型缓存目录挂载到宿主机避免每次重启容器都重新下载模型。TEI 默认的模型缓存目录是~/.cache/huggingface容器内对应/data目录。我部署时习惯把宿主机的一个持久化目录挂载进去docker run -d \ --name tei-service \ -p 8080:80 \ -v /data/tei/cache:/data \ -e HF_HOME/data \ ghcr.io/huggingface/text-embeddings-inference:latest \ --model-id BAAI/bge-large-zh-v1.5这个挂载解决了两个问题一是模型文件下载一次后可以复用不用每次重启都从远端拉取二是方便手动管理缓存模型更新出问题时可以直接查看宿主机目录里的模型文件。这里有一个容易踩坑的地方如果直接用latest拉取镜像而宿主机上已经缓存了旧镜像部署时可能没有真正更新到最新版本。更新 TEI 服务时需要先手动拉取新镜像再重建容器。否则会出现“镜像标签没变、底层内容更新了但拉不到新层”的问题这其实也是缓存更新里的一个经典案例——镜像缓存更新失败。5.3 模型迭代后如何让缓存安全过期模型不是一成不变的算法团队每隔一段时间就会发布新版本。TEI 在加载模型时如果本地缓存目录里已经存在同名模型文件会直接使用缓存不会重新下载。这时候如果不更新缓存线上用的就是旧模型评估指标再好也白搭。我总结了一套模型迭代时的缓存更新流程。先在宿主机上把旧模型目录重命名作为备份再启动一个临时容器手动拉取新模型mkdir -p /data/tei/cache docker run --rm \ -v /data/tei/cache:/data \ -e HF_HOME/data \ ghcr.io/huggingface/text-embeddings-inference:latest \ --model-id BAAI/bge-large-zh-v1.5 \ --revision v2确认新模型加载成功后重建正式服务容器。这样做的价值在于模型更新成为一个显式操作而不是依赖默认缓存的“碰巧更新”。我在之前的项目里吃过亏模型文件库里用了一个带日期的版本目录更新后忘了更新代码里的模型 ID跑了一周才发现线上 embedding 结果全是旧版本。从那以后我规定模型路径必须写清楚版本缓存清理和代码变更一起提交。6. 缓存更新问题排查速查表6.1 常见问题接线速查现象可能原因排查手段解决方向更新缓存偶发超时文件句柄数触顶 / 连接池耗尽ulimit -n、Redisclient list调大句柄数、优化连接池、增加超时重试Redis 报 OOM 错误maxmemory 写满 / 淘汰策略不匹配INFO memory查看内存配置maxmemory-policy、扩容内存缓存更新后马上读到旧值并发回填覆盖 / 副本延迟开启延迟双删 / 版本号校验用双删 版本号兜底Ubuntu 磁盘分区打满RDB 持久化失败df -h查看磁盘占用清理日志、迁移持久化目录容器重启后模型没更新Docker 镜像本地缓存未拉取新层docker pull手动拉取指定具体版本的镜像 tag缓存服务不可用后系统雪崩缺少降级开关看应用线程栈加缓存旁路开关直接查库这张表并不完整但覆盖了我在生产环境遇到的大部分问题。实际排查时先确认是“缓存失效”还是“缓存不可用”。“失效”通常由代码逻辑触发会自愈“不可用”则是组件级故障必须人工介入。6.2 我的几条避坑原则缓存更新这件事代码层面能做的事情其实不多大部分风险来自架构设计和使用习惯。我自己实践下来有几条原则一直没变过第一用删除代替更新。缓存更新优先考虑delete而不是把新值set进去。因为 set 可能因为回填顺序覆盖新值而 delete 能够让下一次读请求拉到最新数据。即使多一次 cache miss也比脏数据长期存活好得多。第二TTL 不是免责条款。很多团队把 TTL 当成兜底觉得缓存最终会过期不一致顶多持续几分钟。但热点 key 的 TTL 会被持续访问续期如果写操作一直不来刷新旧数据可能存活很久。所以核心数据一定要靠主动失效而不是等 TTL。第三监控缓存更新失败率而不是只监控命中率。命中率只能说明缓存有没有发挥作用更新失败率才能反映缓存链路是否健康。我见过太多系统命中率 95% 以上结果那 5% 的 miss 全部因为更新失败回源数据库最终把库打挂。第四任何缓存更新手段都要有“开关”。延迟双删、异步重试、降级旁路这些都要能在运行时动态开启和关闭。没有开关的方案不是一个完整的方案因为真实环境里你无法预判下一次故障会以什么姿势到来。缓存更新的套路并不复杂真正的复杂度来自线上环境的不可预测性。把每一种模式背后的取舍想清楚把每一种失败场景对应的兜底手段准备好这套设计才算真正落地。希望这篇内容对你有所帮助也欢迎你把自己的缓存更新踩坑经历分享出来一起交流。