数据库存:数据库管理员避坑指南:做性能优化时别忽略缓存不同步
数据库查询从 180 毫秒降到 35 毫秒,缓存命中率从 72% 提升到 94%,监控面板上的性能曲线几乎全绿,但用户仍然看到“待支付”的订单状态。类似问题在性能优化后并不少见:数据库已经写入新值,缓存却保留旧值;缓存刚被删除,又被并发请求用旧数据重新填充。性能指标变好,不等于数据访问链路变正确。
我在排查这类问题时,最常见的误区不是不会使用缓存,而是团队把缓存当成单纯的加速器,只关注命中率、响应时间和数据库负载,却没有把缓存失效、数据库事务、读写分离、消息重试和版本校验放在同一条链路里看。缓存一旦进入业务读路径,它就不再只是性能组件,而是数据正确性的一部分。
数据库通常是业务事实来源,但用户请求未必直接访问数据库。一次查询可能经过浏览器缓存、网关缓存、应用本地缓存、分布式缓存、读副本,最后才到达主库。只要其中一层仍然保存旧版本,用户就可能看到与数据库当前值不同的结果。
因此,排查“用户为什么看到旧数据”时,不能只执行一条 SQL 验证主库。必须同时确认用户命中的缓存层、缓存键、缓存值版本、数据库连接节点以及最近一次失效事件。只证明数据库是对的,无法证明整条读取路径是对的。
很多系统在初期直接查数据库,虽然响应较慢,但数据路径短,出现问题时容易定位。后来团队为了降低数据库压力,引入本地缓存、分布式缓存和读写分离,查询速度确实提升了,但数据传播路径也变长了。
新增的每一层都会带来新的时间窗口:数据库提交后,缓存何时删除;主库提交后,副本何时追上;消息发出后,消费者何时执行;本地缓存收到通知后,何时完成失效。优化后的系统不是只有一个“数据库写入成功”节点,而是多个“新版本传播完成”节点。
设置过期时间是必要的兜底手段,但它解决的是“旧值最多保留多久”,不是“数据库更新后缓存立即正确”。假设订单缓存 TTL 为 30 分钟,删除动作失败后,用户理论上仍可能在接下来 30 分钟内读到旧状态。
如果业务允许最多 5 分钟的旧数据,TTL 可以作为保护措施;如果数据涉及支付状态、库存扣减、权限变更或风控结果,仅靠 TTL 就不够。此时必须增加主动失效、版本校验、可靠消息或强制回源等机制。

数据库管理员在性能优化中经常负责索引、连接池、慢查询、主从复制和容量规划,但当应用引入缓存后,数据库与缓存之间的关系也应纳入性能评估。否则可能出现数据库负载下降、接口延迟下降,却把问题转移成订单状态错误、库存超卖或权限未及时收回。
我更倾向于把优化结果拆成三组指标:第一组是速度,包括 P95、P99 和数据库执行耗时;第二组是资源,包括 CPU、内存、连接数、磁盘读写和副本延迟;第三组是正确性,包括缓存失效失败率、版本差异、回源旧值比例和业务校验失败率。只有三组指标同时改善,才算完成一次合格的优化。
数据库 Buffer Pool 负责把数据页、索引页等内容保留在数据库进程内存中,目的是减少磁盘 I/O。它保存的是数据库内部页的缓存,不是应用可以直接读取的业务对象。
Redis、Memcached、应用本地 Map 或某个缓存组件保存的,则通常是序列化后的用户信息、商品信息、订单状态或权限结果。调整数据库 Buffer Pool 只能改善数据库自身的访问效率,不能删除应用缓存里的旧订单,也不能修复缓存键拼接错误。
如果监控显示数据库磁盘读取下降,但用户仍然看到旧数据,问题大概率不在数据库 Buffer Pool,而在业务缓存失效、读路由或数据回填逻辑。
本地缓存读取速度快,不需要网络跳转,但每个应用实例都有自己的副本。一个实例删除了缓存,不代表其他实例内存中的旧值也被清除。应用扩容后,实例数量增加,本地缓存的一致性成本会随之上升。
分布式缓存通常由多个应用实例共享,失效操作更集中,但它会引入网络超时、节点故障、集群切换、连接池耗尽和序列化失败等问题。不能因为分布式缓存只有一份,就认为它天然一致。
如果同时使用本地缓存和分布式缓存,还要明确失效顺序。例如,分布式缓存已经删除,本地缓存仍保留旧值,应用就不会回源到分布式缓存。此时只清理分布式缓存并不能解决用户问题。
本文所说的缓存不同步,是指同一个业务对象在数据库和一个或多个缓存层中存在不同版本,并且这种差异已经影响了用户读取、业务判断或后续写入。
它不只包括“数据库是新值、缓存是旧值”,也包括缓存之间版本倒退、主库是新值但副本是旧值、缓存对象字段不完整,以及旧消息覆盖新消息等情况。
| 对象 | 主要职责 | 典型内容 | 常见风险 |
|---|---|---|---|
| 数据库 Buffer Pool | 减少磁盘访问 | 数据页、索引页 | 内存不足、脏页刷盘压力 |
| 应用本地缓存 | 减少网络与远程访问 | 用户、配置、商品对象 | 实例之间失效不同步 |
| 分布式缓存 | 共享热点数据 | 序列化业务对象、列表结果 | 删除失败、网络超时、旧值回填 |
| 数据库只读副本 | 分担读请求 | 主库数据的复制版本 | 复制延迟导致回源读取旧值 |
这张区分表的实际价值在于:不同对象的修复动作完全不同。数据库 Buffer Pool 问题通常看内存与 I/O;业务缓存问题要看键、失效与回填;副本问题要看复制延迟和读路由。把它们都叫“缓存问题”,会让排查方向从一开始就偏离。

这是最容易理解、也最容易被低估的场景。应用先提交数据库事务,事务成功后执行缓存删除。如果删除操作遇到网络超时、连接池耗尽或缓存节点故障,数据库已经是新值,缓存却仍然保留旧值。
更危险的是,有些代码只记录一条错误日志,然后继续返回成功。业务方看到的是“更新成功”,运维看到的是“接口成功率 100%”,但缓存失效失败已经悄悄发生。
缓存删除失败至少应该进入可查询的失败队列或补偿机制。日志中需要包含业务主键、缓存键、数据库版本、操作类型、失败原因和重试次数,否则后续很难判断哪些缓存仍然可能是旧值。
假设系统采用以下流程:请求 A 先删除缓存,然后更新数据库;此时请求 B 读取缓存未命中,转而查询数据库。由于请求 A 的数据库事务还没有提交,请求 B 可能读到旧值,并把旧值写回缓存。
请求 A 随后提交新值,但没有再次删除缓存。之后所有请求都会命中 B 写入的旧值。这个问题并非每次都会发生,是否出现取决于并发时序、事务耗时、读请求耗时和回填策略,但高并发环境下不能把它当作极小概率。
这里的关键不是简单记住“先更新数据库还是先删缓存”,而是理解缓存回填发生在哪个时间点,以及旧值能否在新版本提交后再次进入缓存。
延迟双删通常是先删除缓存,更新数据库,等待一段时间后再次删除缓存,或者先更新数据库,再立即删除并延迟再次删除。它的价值在于增加一次清理机会,降低并发读取旧值并回填的概率。
但延迟双删不能覆盖所有异常:第二次删除仍可能失败;延迟时间可能不足;消费者、定时任务或其他服务可能在第二次删除之后再次写入旧对象;读副本延迟也可能让回源请求重新拿到旧值。
延迟时间不能凭“睡眠 500 毫秒”或“固定 1 秒”拍脑袋决定。更合理的估算是观察数据库事务提交耗时、读请求耗时、缓存回填耗时、网络延迟和副本同步延迟,再通过压测验证。在高峰流量下,平均耗时没有意义,应该重点看 P99 和异常长尾。
另一类问题是缓存操作发生在数据库事务提交之前。应用先修改缓存,然后数据库事务因为校验失败、锁等待超时或连接断开而回滚,结果是缓存保存了一个数据库中不存在的新值。
缓存通常不会自动参与数据库事务。即使应用代码把两者写在同一个方法里,也不代表它们具备原子性。事务回滚后,缓存不会自动回滚,除非应用明确设计补偿动作。
涉及订单、库存、账户余额和权限的数据,通常不建议在数据库事务提交前把新值直接写入缓存。更稳妥的做法是以数据库提交结果作为失效或发布事件的触发条件。
这是线上很容易漏查的一层。主库更新成功后,应用删除缓存。随后一个查询请求发现缓存未命中,按照读路由规则访问只读副本,但此时副本还没有完成复制,于是返回旧数据。应用把旧数据重新写进缓存,缓存又恢复成错误版本。
这种问题看起来像缓存删除失败,实际上缓存删除可能完全成功。真正的问题是回源读取了尚未追平的副本。排查时,如果只查看缓存操作日志,会误以为“缓存已经删掉了,为什么又变旧”,而忽略了副本延迟。
对于强一致要求较高的读请求,可以在写入后的一段时间内强制读主库,或者携带数据库日志位点、版本号等信息,确保读取节点至少已经追上目标版本。

不同业务对旧数据的容忍度差异很大。内容浏览、商品描述、推荐标签可能允许短时间旧值;支付状态、库存余额、账户额度和权限状态则可能因为一个旧值引发资金损失或越权。
我通常会先问三个问题:旧值最多可以存在多久;旧值被读取后会不会触发下一步写操作;出现一次错误时,能否自动修复,还是需要人工介入。答案比“系统是否使用缓存”更能决定架构。
| 业务类型 | 可接受旧值时间 | 建议策略 | 重点监控 |
|---|---|---|---|
| 文章标题、展示标签 | 数分钟至数小时 | 旁路缓存加 TTL,删除失败后异步补偿 | 失效失败率、缓存命中率 |
| 商品详情与营销信息 | 通常可接受数十秒至数分钟 | 更新数据库后失效,热点键增加主动刷新 | 旧值时长、回源峰值、热点键 |
| 订单状态 | 通常不应依赖长时间旧值 | 版本校验、可靠事件、关键状态回源主库 | 状态版本差异、重复消费、主从延迟 |
| 库存与账户余额 | 接近零容忍 | 数据库原子操作为准,缓存只做展示或加速 | 超卖、余额差异、并发写失败 |
| 权限与风控结果 | 撤销应尽快生效 | 短 TTL、主动广播失效、关键操作强制校验 | 权限撤销延迟、旧授权命中 |
旁路缓存的典型流程是:读取时先查缓存,未命中再查数据库并回填;写入时先更新数据库,再删除缓存。它的优点是实现成本低、组件依赖少、故障时容易降级回源。
它的短板也非常明确:缓存删除可能失败;高并发下可能发生旧值回填;缓存击穿时会产生大量回源请求;多服务共享数据时,任何一个服务都可能绕过统一规则写入缓存。
对于文章内容、商品详情、配置数据等读多写少业务,我通常会选择旁路缓存,但会补上三项能力:删除失败重试、缓存值版本号、热点键的回源保护。方案简单不等于治理可以简单。
如果业务可以接受几十毫秒到数秒的传播延迟,可以在数据库事务提交后发布缓存失效事件,由消费者删除或刷新缓存。它把同步调用改成了可追踪的异步事件,便于重试、积压监控和失败补偿。
消息方案的关键不是“用了消息就可靠”,而是要回答四个问题:数据库提交成功但消息没发出怎么办;消息重复消费怎么办;多条更新事件乱序怎么办;消费者长期失败怎么办。
通常需要结合可靠投递、幂等处理、重试队列、死信队列和定期校验任务。没有这些配套机制,消息只是把缓存删除失败从应用日志转移到了消息系统。
可以在数据库记录和缓存对象中加入单调递增的版本号。例如订单每次状态更新后版本号加一,缓存写入时携带该版本。任何低于当前版本的写入都不能覆盖高版本数据。
版本号不能解决所有问题,但它能阻止“旧数据覆盖新数据”。即使某条旧消息因为重试或乱序晚到,消费者也可以识别其版本过低并跳过处理。
版本号策略要求所有写入入口遵循同一规则。如果有一个定时任务仍然按照无版本方式直接覆盖缓存,版本保护就会被绕开。因此,版本控制不仅是字段设计,也是写入权限和代码规范问题。

强一致机制往往需要同步事务协调、主库读取、写后校验或更严格的锁控制,代价可能是更高的写延迟、更复杂的故障处理以及更低的可用性。
如果一个商品介绍更新延迟 10 秒不会造成损失,就没有必要让每一次展示请求都强制访问主库。相反,如果是支付结果,就不能为了几毫秒的响应优势接受长时间旧值。
一致性级别应该由错误成本决定,而不是由缓存组件的宣传能力决定。先定义业务风险,再决定缓存策略,通常比先选一个流行方案再强行套用更稳妥。
下面用一个常见的订单状态场景说明排查过程。订单服务将订单状态从“待支付”更新为“已支付”,数据库主库查询结果正确,但用户刷新页面后仍然看到“待支付”。查询接口的读取链路是:先查应用本地缓存,再查分布式缓存,缓存未命中后访问只读副本。
这个案例中的数字是为了展示排查方法的情景模拟,不代表某个具体企业的生产数据。真实系统应根据自身日志和链路追踪结果重新测量。
第一步不是直接清空所有缓存,而是选取一个具体订单号,分别记录主库值、只读副本值、本地缓存值和分布式缓存值。这样可以判断问题发生在哪一层,而不是用“清缓存后恢复”掩盖根因。
| 检查对象 | 观察结果 | 初步判断 |
|---|---|---|
| 主库订单状态 | 已支付,版本 18 | 数据库写入已经成功 |
| 只读副本订单状态 | 待支付,版本 17 | 存在复制延迟或读路由问题 |
| 分布式缓存 | 待支付,版本 17 | 缓存仍保存旧对象 |
| 本地缓存 | 待支付,版本 17 | 本地层未收到有效失效通知 |
| 缓存删除日志 | 分布式缓存删除超时 2 次 | 至少存在主动失效失败 |
如果此时只清理分布式缓存,应用仍可能先命中本地缓存中的旧状态。如果只清理本地缓存,回源又可能访问尚未追平的只读副本,并把“待支付”重新写回分布式缓存。
继续查看链路追踪后,可以还原出如下过程:支付服务提交主库成功;缓存删除第一次超时;查询请求命中本地缓存旧值;本地缓存过期后,查询请求访问只读副本;只读副本仍是旧版本;应用将旧结果回填到分布式缓存。
这说明该问题不是单一故障,而是三个风险叠加:缓存删除失败、本地缓存没有及时失效、读副本延迟导致旧值回源。任何一个环节单独存在,影响可能都有限,叠加后就会形成持续性错误。

临时处置可以包括清理受影响订单的所有缓存层、对关键状态强制回源主库、暂停旧副本读取,以及通过订单状态校验任务重新刷新异常缓存。但临时清缓存只能止血,不能替代长期修复。
长期修复需要至少覆盖四个方面:数据库提交成功后才触发失效事件;失效事件具备重试和死信能力;本地缓存能够接收广播或缩短关键数据 TTL;支付后的一段时间内,订单状态查询优先访问主库或具备版本门槛的副本。
验证时不能只看接口返回 200。应在高并发条件下连续执行状态更新和读取,并记录数据库版本、缓存版本、用户返回版本和消息消费版本,确认没有版本倒退和旧值回填。
订单状态问题表面上是“缓存没有更新”,实际是“缓存失效策略没有考虑多级缓存和副本延迟”。如果架构中存在本地缓存、分布式缓存和只读副本,任何只针对 Redis 的修复都可能不完整。
缓存一致性排查的最小单位不是缓存节点,而是一个业务对象的一次完整版本传播。只要能沿着订单号或用户编号还原版本从主库到用户的流转,就比泛泛查看缓存命中率更容易找到根因。
在调整索引、增加缓存或启用读写分离之前,我建议先画出一个具体业务对象的读写图。图中至少要包含写入服务、数据库事务、消息系统、本地缓存、分布式缓存、主库、只读副本和用户读取接口。
如果这些问题没有明确答案,就不应该直接把缓存命中率当作优化目标。因为团队可能正在优化一条自己尚未完全理解的读取路径。
缓存命中率是重要指标,但它只能说明请求是否命中了缓存,不能说明缓存内容是否是最新版本。一个错误缓存如果命中率很高,反而可能让错误更稳定、更难被发现。
建议把下面的指标一起纳入监控。指标名称可以根据业务系统实际情况调整,但必须能关联到具体业务对象和版本。
| 指标 | 它回答的问题 | 异常时的排查方向 |
|---|---|---|
| 缓存失效失败率 | 数据库更新后,删除动作是否成功 | 网络、连接池、权限、键名和节点状态 |
| 缓存版本差异数 | 缓存版本是否低于数据库版本 | 回填、消息乱序、删除失败和并发写入 |
| 旧值回源比例 | 缓存未命中后,数据库返回的是否仍是旧版本 | 只读副本延迟、事务提交时机和读路由 |
| 消息消费延迟 | 失效事件多久才能到达缓存消费者 | 积压、消费者容量和重试策略 |
| 本地缓存过期命中次数 | 应用实例是否仍大量返回本地旧值 | 广播失效、实例时钟和本地缓存配置 |
| 业务状态校验失败数 | 用户看到的状态是否影响后续业务 | 订单、库存、权限和风控链路 |
性能压测通常模拟大量相同查询,但缓存一致性验证必须同时模拟读写并发。只做“持续读”压测,很难暴露旧值回填;只做“单线程写后读”,也无法覆盖事务与副本延迟。
我建议至少设计以下测试场景:

缓存删除失败不能只打印一句“delete cache failed”。日志和指标需要携带足够的上下文,至少包括业务类型、业务主键、缓存键、数据库版本、操作时间、异常类型和重试次数。
下面是一段简化的伪代码,用来表达“数据库提交成功后再触发缓存失效”的思路。它不是可以直接复制到生产环境的完整实现,实际项目还需要补充幂等、超时、重试、死信和监控。
public void updateOrderStatus(Long orderId, String newStatus) {
long version;
transactionTemplate.execute(status -> {
Order order = orderRepository.lockById(orderId);
order.changeStatus(newStatus);
order.increaseVersion();
orderRepository.save(order);
return null;
});
version = orderRepository.findVersion(orderId);
try {
cacheInvalidationPublisher.publish(
new CacheInvalidationEvent("order", orderId, version)
);
} catch (Exception ex) {
retryQueue.enqueue(orderId, version, ex.getMessage());
metrics.increment("cache_invalidation_publish_failure");
}
}这段逻辑的核心不是“把删除放在事务后面”这么简单,而是建立数据库版本与失效事件之间的关联。消费者收到低版本事件时可以跳过,收到高版本事件时才执行删除或刷新。
任何涉及缓存的性能优化都应该准备降级方案。例如关闭某类缓存读取、强制关键接口读主库、暂停旧副本路由、缩短指定业务的 TTL、批量清理指定前缀,以及临时关闭缓存回填。
止血开关不等于“出问题就清空全部缓存”。全量清缓存可能造成数据库瞬时流量激增,引发缓存击穿和数据库雪崩。更合理的是按业务类型、租户、时间范围或主键集合进行精准处理。
不要一开始就观察全局平均指标。先选一个用户、订单、商品或配置项,记录它的数据库版本、缓存键、缓存值生成时间、请求实例和读节点。
如果没有具体主键,所有日志都只是统计现象,无法建立因果关系。一个明确的业务对象可以帮助团队沿着请求链路逐层确认:哪一层首先出现了旧版本。
先查看请求是否命中浏览器、网关、本地缓存或分布式缓存。很多团队看到分布式缓存中已经是新值,就认为系统没有问题,却忽略了应用实例仍返回本地缓存旧对象。
确认主库中的值只是第一步,还要确认写事务是否真正提交、读取请求连接的是哪个节点、只读副本的复制位点是否追上目标版本。
如果数据库使用读写分离,建议在日志中记录数据库节点标识。没有节点信息时,“查数据库得到旧值”这句话几乎没有排查价值,因为你不知道查的是主库还是副本。
把数据库提交时间、缓存删除时间、消息发送时间、消息消费时间和缓存回填时间放到同一条时间线上。重点寻找以下关系:数据库提交后是否有失效事件;失效事件是否失败或延迟;失效后是否存在旧值回填;旧值回填时读取的是主库还是副本。
如果消息事件没有业务版本号,建议尽快补充。只有事件时间而没有版本信息,很难判断一条晚到消息是应该执行还是应该丢弃。
单个缓存键异常,可能是数据脏写、键规则错误或特殊业务分支;一批键同时异常,可能是消息积压、批量任务或缓存集群故障;所有写请求都出现旧值,则更像是统一失效策略存在缺陷。
| 现象范围 | 优先怀疑对象 | 第一动作 |
|---|---|---|
| 单个业务对象 | 键错误、脏数据、特殊分支 | 核对键、版本和单次写入日志 |
| 同一批次对象 | 批处理、消息积压、任务失败 | 查看批次号、消费延迟和失败重试 |
| 固定时间段集中发生 | 发布、流量高峰、备份或切换 | 对齐发布记录、资源曲线和副本延迟 |
| 所有更新都可能发生 | 统一失效策略或缓存协议缺陷 | 审查写入顺序、事务边界和补偿链路 |
| 只在读副本读取时发生 | 复制延迟和读路由 | 强制主库验证并查看复制位点 |

例如文章内容、商品描述、运营标签和非关键配置,可以采用旁路缓存加 TTL。数据库更新成功后删除缓存,删除失败进入异步重试,定时任务负责抽样核对数据库与缓存版本。
这类业务不必为了极短的传播延迟引入复杂事务协调,但要把最大旧值时间写进业务约定。例如允许最多 60 秒旧值,就不能把 TTL 设置为 30 分钟后再宣称“最终会一致”。
取舍是:实现简单、读取速度快,但短时间内可能存在旧值。适合把资源投入到失效监控和故障补偿,而不是追求每次读取都访问主库。
商品价格、营销库存、活动资格等场景通常更新较频繁,单纯删除缓存容易受到并发回填影响。可以考虑版本号、写入序列号、延迟二次失效或消息驱动刷新。
如果对象更新频率很高,缓存整个对象可能比缓存稳定字段更危险。例如商品价格变化频繁,但商品描述变化很少,可以拆分缓存对象,分别设置失效规则,避免一个字段更新导致整个对象不断被覆盖。
取舍是:版本保护和事件处理会增加开发与运维成本,但能显著降低旧版本覆盖新版本的风险。不要只因为延迟双删代码少,就把它当成高并发写场景的完整答案。
这类数据不能把缓存当作唯一判断依据。缓存可以用于展示和加速,但最终扣减、支付确认、余额校验等关键动作应以数据库原子操作或专门的事实系统为准。
订单详情页面可以展示缓存状态,但用户点击“确认收货”或“申请退款”时,服务端仍应重新校验当前状态。库存展示可以使用缓存,但真正扣减要依赖数据库条件更新、库存服务或可靠的原子组件。
取舍是:关键动作增加一次校验会带来额外延迟,但换来业务正确性。这里不应该用“接口 P99 增加了 10 毫秒”作为取消校验的理由,因为一次错误扣减的成本远高于一次数据库查询。
权限撤销和风控规则更新的特点是:旧值可能导致安全风险。对于角色权限、黑名单、额度规则等数据,建议采用主动广播失效、较短 TTL,并在高风险操作前强制查询权威数据源。
多实例部署时,本地缓存必须有明确的失效通知机制。可以使用发布订阅、配置推送或版本轮询,但不能假设实例会自然感知其他实例的更新。
取舍是:更频繁的失效会降低缓存命中率和增加回源压力,但安全相关数据不应为了追求命中率而延长旧值寿命。
第一优先级是控制业务影响,而不是马上优化架构。根据数据类型选择强制回源、暂停缓存写入、临时读主库、精准清理受影响键或关闭相关功能。
第二优先级是保留现场。不要在没有记录缓存值、数据库值、版本和时间线之前全量清空缓存,否则可能失去定位根因所需的证据。
第三优先级是补偿数据。对于订单、库存和账户类数据,应建立异常对象列表,逐条核对数据库、缓存和业务流水,确认是否存在错误操作,而不是只让缓存重新变正确。

性能验收至少应同时包含延迟、资源和正确性三部分。不能只提交一张“平均响应时间下降”的图,就认定缓存改造完成。
| 验收维度 | 建议观察项 | 不合格表现 |
|---|---|---|
| 性能 | P95、P99、缓存命中率、回源量 | 平均耗时下降,但长尾和回源峰值明显上升 |
| 资源 | 数据库 CPU、连接数、缓存内存、网络流量 | 数据库负载下降,但缓存节点频繁淘汰或连接耗尽 |
| 正确性 | 版本差异、失效失败、旧值回填、业务校验失败 | 命中率很高,但关键业务仍出现旧状态 |
| 恢复性 | 重试成功率、补偿时长、故障恢复时间 | 只能人工清缓存,无法确认哪些对象受影响 |
缓存保存的是为了更快读取而生成的副本。只要它是副本,就会面临生成、更新、失效、过期和恢复问题。把缓存当成数据库的影子副本,容易产生“数据库更新了,缓存自然应该更新”的错觉。
事实上,数据库和缓存通常由不同的存储机制、网络连接、事务边界和失败模型维护。它们之间没有天然的原子提交。系统必须显式设计版本传播和异常补偿,才能让副本在可接受时间内追上事实来源。
命中率适合衡量缓存是否减少了回源,但它无法告诉我们命中的内容是否正确。对高风险业务,更值得关注的是版本差异、旧值暴露时间、失效失败率和业务校验失败次数。
如果暂时没有条件建立完整版本校验,至少可以从日志中记录缓存生成时间、数据库更新时间和请求读取时间,先估算旧值窗口。对一个无法量化的问题,团队很难做出合理的风险决策。
我对缓存优化的验收标准很简单:响应时间是否改善,数据库资源是否下降,错误数据是否可发现,失效失败是否可补偿,故障发生后是否能精准恢复。
如果只满足前两项,得到的可能是一套“跑得更快但错得更隐蔽”的系统。真正成熟的缓存架构,不是承诺永远不会不同步,而是能够明确允许多长时间不同步、哪些业务绝不能不同步,以及出现异常后如何发现、重试、校验和恢复。
下一步可以从一个最容易出问题的业务对象开始:选定一个订单、商品或权限记录,记录数据库版本、缓存版本、失效事件和最终读取结果。先把一次版本传播完整还原,再决定是否需要延迟双删、消息失效、版本校验或强制回源。这比盲目增加缓存容量、继续调高命中率,更接近数据库性能优化真正应该解决的问题。


读者评论
文章把缓存问题从单纯的性能指标扩展到数据正确性,尤其是“删除缓存后被旧值回填”的并发时序,解释得比较清楚。实际排查时,确实不能只看主库和命中率,还要结合副本延迟、回填日志和版本信息。
延迟双删并不是万能方案这一点很有参考价值。支付、库存、权限等场景对一致性要求更高,单靠TTL或固定延迟很难保证可靠,最好配合事务提交后的消息、失败重试和版本校验。
文章覆盖了本地缓存、分布式缓存和只读副本的差异,但落地时还需要根据业务容忍的旧值时间制定指标。建议补充一套可执行的监控示例,例如失效失败率、旧值回源比例和缓存版本不一致告警。