数据库存:产品技术团队避坑指南:做缓存同步时别忽略设计难扩展
缓存同步最危险的地方,不是“偶尔读到旧数据”,而是团队在系统规模还小时,以为一次数据库写入、一次缓存删除就能解决问题,等到服务实例、数据分片、消息消费者和地域数量增加后,才发现没有人能回答三个问题:哪份数据是最终事实、哪次变更更新、同步失败后谁负责修复。缓存同步真正考验的不是组件使用熟练度,而是方案能否在扩容、并发、故障和业务变化之后继续成立。
很多技术讨论一开始就进入“先写数据库还是先写缓存”“更新缓存还是删除缓存”“要不要上消息队列”。这些问题并非不重要,但它们都排在业务一致性目标之后。假设商品详情页允许用户在 2 秒内看到旧描述,那么异步失效可以接受;如果是账户余额、库存可用量或支付状态,就不能仅凭“最终会一致”来解释风险。
我在做缓存方案评审时,通常先要求团队把“旧数据还能被读多久”写成一个可验证的时间窗口,而不是使用“实时”“强一致”“最终一致”这类容易被不同角色理解成不同意思的词。这个窗口还要区分正常路径和异常路径:正常消息消费可能是 100 毫秒,消息堆积或缓存节点故障时,是否允许扩大到 5 秒、30 秒,必须提前说清楚。
| 业务数据类型 | 典型旧值风险 | 可接受策略 | 必须具备的兜底 |
|---|---|---|---|
| 商品标题、图片、营销文案 | 用户短时间看到旧内容 | 数据库更新后删除缓存,必要时异步重试 | 删除失败补偿、定时抽样对账 |
| 库存、优惠券剩余量 | 超卖、错误展示可用量 | 以数据库原子扣减或专用库存服务为准,缓存只做读取加速 | 版本校验、异常降级、库存对账 |
| 账户余额、支付状态 | 用户误判资金状态,产生投诉或资损 | 关键查询绕过普通缓存,或使用严格版本与状态确认 | 审计日志、人工核验、状态机保护 |
| 报表、排行榜、推荐结果 | 统计结果短时延迟 | 异步刷新、定时构建、过期重算 | 刷新状态展示、失败重算、历史结果保留 |
上表不是固定标准,而是一种评审顺序:先判断旧值的业务后果,再判断缓存到底应该承担什么角色。越重要的数据,越不应该把缓存当成事实来源。缓存更适合承担读取加速、热点保护和短期结果复用,而不是替代数据库或业务状态机。

一个可扩展的缓存同步方案,至少要拆出四条责任链:正常同步、失败重试、长期补偿、结果对账。正常同步解决“事情通常如何发生”;重试解决网络抖动或短暂不可用;补偿解决消息丢失、消费者长时间宕机和历史脏数据;对账解决系统根本没有感知到的遗漏。
实践中最常见的误区,是把所有可靠性都压在一次删除动作上。代码执行到删除缓存这一步,不代表删除一定成功;消息发送成功,也不代表消费者已经处理;消费者返回成功,也不代表缓存中的对象就是最新版本。同步动作是过程,对账和修复才是闭环。
单体应用里,写库、删缓存、记录日志可能都在一个进程内完成,团队容易产生“这几步天然是一体的错觉”。当系统变成多个服务后,商品服务、搜索服务、报表服务和推荐服务可能分别维护自己的缓存。此时再把清缓存逻辑隐藏在某个写接口中,就会出现一处更新、多个缓存无人通知的问题。
我更倾向于在设计文档中明确写出责任边界:数据库是哪个领域的权威来源,哪个服务有权修改数据,哪个事件代表什么业务事实,哪些消费者只允许删除缓存,哪些消费者可以重建缓存,以及出现冲突时按版本、时间还是数据库结果裁决。没有责任边界的“同步方案”,规模越大,越容易变成互相等待和互相甩锅。
假设商品价格从 99 元改成 89 元,应用先更新数据库,再删除缓存。数据库提交成功后,缓存服务发生短暂网络超时,删除请求没有得到确认。此时数据库已经是 89 元,缓存仍然是 99 元。若系统没有重试或过期机制,旧值可能在 TTL 到期前持续返回。
这个场景通常容易补救,因为团队至少知道删除动作失败了。真正麻烦的是请求超时不等于服务端一定没有执行:删除请求可能已经在缓存节点执行,只是响应在网络中丢失。客户端如果盲目重试,通常没有问题,因为删除具备幂等性;但如果动作是“把对象更新为某个完整快照”,就必须考虑重试是否会用旧快照覆盖新快照。
这是我在并发时序分析中最常提醒团队的一类问题。线程 A 正在查询数据库,读到旧价格 99 元;线程 B 更新数据库为 89 元,并删除缓存;线程 A 随后把自己早先查到的 99 元写回缓存。最终缓存重新出现旧值,虽然数据库一直是正确的。
可以用下面的简化时序表示:
时间顺序:
T1 读请求 A:缓存未命中,开始查询数据库
T2 写请求 B:数据库更新为新值 89
T3 写请求 B:删除缓存成功
T4 读请求 A:拿到此前查询到的旧值 99
T5 读请求 A:把旧值 99 写入缓存
结果:
数据库 = 89
缓存 = 99
这个问题不是“先写库后删缓存”这八个字能够自动解决的,因为漏洞出现在删除之后的回写阶段。降低风险的办法包括给缓存值带版本、给查询结果设置短暂有效期、在高风险对象上使用版本比较,或者让缓存重建过程再次确认数据版本。具体采用哪一种,取决于读写并发和数据重要性。
假设某商品在 10:00:01 被改成 89 元,10:00:02 又被改成 79 元。两个事件分别进入消息队列,由于分区、重试或消费者负载不同,后产生的 79 元事件可能先到达,先产生的 89 元事件反而后到达。如果消费者只执行“收到什么就写什么”,最终缓存可能回到 89 元。
这类问题在测试环境很难稳定复现,因为测试通常只有一个实例、很少的并发和几乎没有消息延迟。上线后只要出现多个消费者、重试或跨地域网络延迟,顺序就不再是默认成立的假设。凡是允许事件乱序的链路,都应该在事件中携带可比较的版本号或单调递增序列。

从一个应用实例扩展到十个实例后,本地缓存会出现各实例内容不同步;从单个缓存节点扩展到集群后,Key 的路由、迁移和节点故障会成为新的变量;从单个数据库扩展到分库分表后,缓存 Key 与数据分片的关系也可能发生变化。
因此,“扩展性”不能只理解为吞吐量能不能提高。更重要的是,系统增加组件以后,原有的同步关系是否仍然可证明。一个方案如果只能在“一个写入服务、一个缓存集群、一个消费者、一个地域”的前提下成立,那么它不是通用方案,而是阶段性方案。
更新缓存看上去更直接:数据库修改成功后,应用拿着新数据写入缓存,下一次读取就能直接命中新值。但这种方案增加了第二次写入,而且缓存中的对象往往不是简单字段,而是经过权限过滤、关联查询、排序和格式化后的结果。只要缓存结构与数据库结构不完全相同,更新缓存就意味着应用需要维护一套额外的组装逻辑。
删除缓存的优点是简单、幂等、对数据结构变化更不敏感。缺点是下一次读取可能触发回源,热点 Key 同时失效时会把压力集中到数据库。更新缓存的优点是减少回源,缺点是双写失败和乱序覆盖更复杂。不能脱离对象特征讨论哪一种“更好”。
| 策略 | 主要优点 | 主要风险 | 更适合的场景 |
|---|---|---|---|
| 写库后删缓存 | 实现简单,删除天然幂等 | 删除失败、缓存重建回写旧值、热点回源 | 详情页、配置读取、普通对象缓存 |
| 写库后更新缓存 | 减少缓存重建,读取更及时 | 双写失败、对象组装复杂、乱序覆盖 | 结构稳定、访问极热且可控制版本的对象 |
| 异步事件失效 | 解耦写入和多个缓存消费者 | 消息延迟、重复、丢失、补偿复杂 | 跨服务同步、报表和搜索索引等派生数据 |
| 读取时版本校验 | 能识别旧值,降低乱序覆盖 | 增加读取成本和版本管理复杂度 | 写入频繁、旧值风险较高的核心对象 |
数据库和缓存通常不在同一个事务中。即使应用按顺序完成两次调用,也只能得到“数据库调用成功”和“缓存调用成功”两个局部结果,不能得到跨系统的原子提交。数据库成功、缓存失败,或缓存成功、数据库失败,都可能产生中间状态。
有人会尝试用分布式事务把两者绑在一起,但这并不一定是好答案。缓存本身通常是可重建的非权威数据,为了保证一个可丢失副本与数据库严格同步而引入复杂事务,可能增加锁等待、恢复成本和系统耦合。更实际的判断是:数据库承担事实写入,缓存承担可恢复副本,通过事件、重试、补偿和过期机制降低不一致窗口。
消息队列只能提供某种投递和消费能力,不能自动保证业务处理正确。一个事件可能重复消费,可能延迟消费,可能因为消费者异常进入重试,也可能在业务处理成功后响应超时,导致消息再次投递。若消费者没有幂等设计,重试本身可能制造新的问题。
我会要求每个缓存同步消费者明确写出以下内容:事件唯一键是什么;重复事件如何判断;旧版本如何丢弃;失败重试几次;超过重试次数进入哪里;历史事件能否重放;缓存无法重建时是否允许保留旧值。回答不完整时,消息队列只是把同步问题从接口调用转移到了后台。
设置 TTL 确实能让旧缓存最终消失,但它无法保证旧值在业务可接受窗口内消失。对于高频更新对象,TTL 过长会放大旧值风险,TTL 过短又会降低命中率并增加数据库压力。更重要的是,TTL 只能解决“过一段时间自动失效”,不能解决旧事件覆盖新事件,也不能解释缓存删除失败发生了多少次。
TTL 应该被视为安全网,而不是一致性方案。一般来说,越重要的数据,TTL 越不应该成为唯一修复路径;越复杂的派生缓存,越需要版本、事件和重建机制共同作用。

每个缓存对象都应该有唯一的权威来源。商品详情通常以商品库为准,订单状态以订单服务或交易库为准,报表结果则可能由计算任务生成。若一个对象同时允许多个服务直接修改,缓存同步会先遇到事实冲突,再遇到缓存冲突。
权威来源的定义还应包含字段粒度。一个商品对象中,名称可能来自商品服务,库存来自库存服务,营销标签来自运营系统。把它们整体序列化到一个缓存 Key 中,看似读取方便,实际上会让任何一处字段变化都触发整体更新,增加覆盖和失效范围。随着系统扩展,按领域拆分对象或为不同字段维护版本,通常比无限扩大一个大对象更稳妥。
如果缓存丢失,系统可以从数据库重建,说明它更接近加速层;如果缓存丢失会导致业务状态无法恢复,说明团队实际上把缓存当成了状态层。这两种定位对应完全不同的可靠性要求。
加速层可以接受短期失效、回源和异步重建,但要做好数据库保护;状态层则必须有持久化、备份、主从切换和明确的数据恢复机制,不能只依赖普通 Key-Value 缓存。很多事故的根源,是架构文档说缓存“可重建”,业务代码却把缓存里的状态当成唯一判断依据。
缓存策略不是只由一致性要求决定,还受访问分布影响。一个每分钟更新一次、每天访问几十万次的商品详情,适合把缓存命中率和热点保护放在重要位置;一个每秒更新几百次、读取量并不高的状态对象,频繁更新缓存可能比直接读取数据库更复杂。
评估时至少要看四类数据:读写比例、Key 的访问集中度、单次回源成本、更新事件峰值。平均 QPS 往往会掩盖问题,真正决定系统是否被打垮的,可能是前 1% 热点 Key 在失效瞬间产生的并发回源。
只要一个对象可能在短时间内连续更新,就不能默认消息顺序和线程完成顺序一致。版本号可以是数据库行版本、业务递增序列、事件生成时间,或者由领域服务维护的变更序列。关键不在于字段名称,而在于消费者能够比较两个事件的新旧。
但时间戳并非总是可靠的版本号。多台机器的系统时间可能存在偏差,同一毫秒内也可能生成多个事件。对于强顺序要求的对象,优先使用数据库递增版本或业务序列;如果只能使用时间戳,至少应定义相同时间戳下的二级排序规则,并验证时钟同步和精度。
“失败后重试”不是完整设计。团队需要明确重试间隔、最大次数、是否指数退避、哪些异常值得重试、哪些异常应立即进入死信,以及补偿任务从哪里读取待处理记录。
如果同步事件只存在于内存或一次接口请求中,应用进程崩溃后就无法知道哪些缓存没有删除。更稳妥的做法是让变更事件具备可追踪记录,或者使用能够可靠承接变更的日志、事件表和消息系统。对于不同业务,不必强行采用同一种机制,但必须确保失败后有证据、有入口、有责任人。

以一个中型交易系统的商品详情页为例:数据库保存商品名称、价格、库存展示值和营销标签,缓存保存经过组装的详情对象。读请求优先访问缓存,未命中时查询数据库并回填缓存;运营人员修改价格时,应用更新数据库后删除商品详情缓存。
这个方案在低并发阶段通常运行良好。商品详情更新频率不高,缓存命中率较高,数据库回源压力也可控。问题出现在三个条件同时出现时:大促期间某个商品成为热点,运营频繁调整价格,缓存集群又发生短暂网络抖动。
价格更新接口返回成功,说明数据库写入完成,但缓存删除请求超时。由于应用把超时当成普通日志,没有进入重试队列,旧价格继续留在缓存中。此时如果 TTL 是 30 分钟,理论上旧值最长可能保留 30 分钟;如果 TTL 被设置成 10 分钟,问题也不会自动变成“10 分钟内一定修复”,因为缓存可能在过期前被再次续期。
更合理的处理不是简单缩短 TTL,而是为删除失败建立可追踪记录。记录至少包括对象 Key、数据库版本、失败原因、重试次数、最近重试时间和最终处理结果。这样运维看到的不是一条“删除缓存超时”的孤立日志,而是一条可以追踪到闭环的同步任务。
在热点商品场景下,多个读请求可能同时回源。请求 A 在数据库事务提交前读到旧价格,请求 B 完成价格修改并删缓存,请求 A 随后把旧详情回填。即使删除动作本身成功,缓存也可能重新出现旧值。
如果详情对象带有数据库版本号,回填时就可以比较当前缓存版本和待写入版本。版本较旧时拒绝写入;如果无法在缓存层完成比较,也可以缩短重建结果的 TTL,并对价格、库存等关键字段在返回前做额外确认。
伪代码示意: function rebuildCache(key): data = queryDatabase(key) if data is null: setCache(key, NULL_MARKER, shortTtl) return current = getCache(key) if current exists and current.version > data.version: return setCache( key, value = data, version = data.version, ttl = normalTtl )
这段代码不是适用于所有系统的标准实现。它只表达一个关键原则:缓存回填不能只比较“有没有值”,还要考虑“这个值是不是比现有值更新”。如果业务只允许删除而不允许更新,版本判断可以放在事件消费者或重建任务中,不必把所有逻辑塞进读请求。
运营人员先把价格改成 89 元,再改成 79 元。两个事件分别进入异步链路,79 元事件因为消费者所在分区负载较低而先执行,89 元事件因重试后延迟到达,最终把缓存改回 89 元。如果消费者只看事件到达顺序,系统没有任何能力判断后到的事件其实更旧。
解决方式可以是让每条事件携带商品版本:
{
"event_id": "evt-20260916-000128",
"object_id": "product-1024",
"object_version": 58,
"changed_fields": ["price"],
"occurred_at": "2026-09-16T14:30:02+08:00"
}
消费者处理时,只接受不小于当前缓存版本的事件。若事件版本低于缓存版本,则记录为“过期事件并跳过”,而不是当成消费失败反复重试。这里要特别区分两类问题:过期事件是业务上可预期的乱序结果,应该被安全忽略;无法连接缓存、数据格式错误等问题,才应该进入重试或死信。

如果使用本地进程缓存,每个实例都可能保存同一个对象的不同版本。实例 A 收到更新通知并清除了本地缓存,实例 B 没有收到通知,仍然返回旧值。此时数据库和分布式缓存即使同步正常,用户依然可能从某个应用实例读到旧数据。
多实例阶段有三种常见方向:减少本地缓存层级,统一使用分布式缓存;通过广播或订阅机制通知所有实例;为本地缓存设置较短 TTL,并接受短暂不一致。判断标准不是“哪种架构更先进”,而是本地缓存带来的收益是否足以覆盖同步复杂度。
缓存 Key 如果直接拼接数据库表名、分片编号或物理库信息,数据迁移后可能出现同一业务对象对应多个 Key。旧 Key 没有被清除,新 Key 又被写入,用户会根据路由结果读到不同版本。
更稳定的 Key 设计应优先绑定业务对象身份,而不是物理存储位置。例如使用业务域、对象类型和业务 ID 组成逻辑 Key,分片路由隐藏在数据访问层。这样做不能自动解决迁移问题,但至少不会让缓存命名与数据库物理布局强耦合。
商品变更事件可能同时被详情缓存、搜索索引、推荐结果和报表服务消费。若每个消费者都自行推断哪些字段发生变化,事件结构一变就可能出现某个下游没有同步。更稳妥的做法是区分“领域事实事件”和“缓存操作命令”:前者描述业务发生了什么,后者描述某个下游需要做什么。
例如,“商品价格已变更”是领域事实,详情服务可以删除详情缓存,搜索服务可以重建价格索引,报表服务可以记录指标变化。不要把“删除某个缓存 Key”硬编码成所有系统都必须执行的唯一动作,否则未来增加下游消费者时,事件会被旧有实现绑死。
多地域环境最容易让“最终一致”变成没有边界的口号。跨地域复制会引入网络延迟,读请求可能访问本地缓存,写请求却落在另一地域;故障切换时,新的主库、旧的缓存和延迟到达的事件可能同时存在。
如果业务允许地域间短时延迟,应明确写出读写策略、版本裁决和故障恢复时间。对于支付、余额、订单状态等关键数据,不能仅因为缓存同步链路已经异步化,就默认用户可以接受跨地域旧状态。某些场景下,宁可牺牲部分读取速度,也应在关键操作前回源确认。

我建议团队不要只在性能评审中讨论扩容,而要重新画一遍写入和读取时序。至少模拟以下问题:写入在哪个地域完成;事件在哪个节点产生;哪个消费者先收到;缓存重建从哪个数据库读取;故障切换后旧事件是否仍会到达;旧事件到达时如何判断它已经过期。
如果这张时序图只能靠口头解释,或者同一个问题不同成员给出不同答案,就说明方案还没有真正具备扩展性。设计难扩展往往不是代码量大,而是边界条件没有被显式化。
删除缓存通常天然幂等,但更新缓存不一定。将对象写入版本 10 的结果重复执行两次,通常没有问题;先写版本 10,再写版本 9,就可能发生旧值覆盖。真正的幂等设计需要同时考虑重复和乱序。
建议为同步事件设置事件 ID和对象版本。事件 ID用于识别同一事件是否被重复处理,对象版本用于判断事件是否过期。两个字段的职责不同,不能只用其中一个替代另一个。
| 问题类型 | 判断依据 | 处理动作 | 是否需要重试 |
|---|---|---|---|
| 同一事件重复到达 | 事件 ID 已处理 | 直接返回成功,记录重复消费 | 不需要 |
| 旧版本事件晚到 | 事件版本小于缓存版本 | 跳过写入,记录过期事件 | 不需要 |
| 缓存节点暂时不可用 | 连接超时、服务不可用 | 指数退避后重试 | 需要 |
| 事件格式不合法 | 字段缺失或无法解析 | 进入死信,通知生产方修复 | 不应盲目重试 |
| 数据库暂时不可读 | 连接池耗尽或主库切换 | 延迟重建,必要时保留旧缓存 | 视错误类型决定 |
最简单的重试是失败后立即再试几次,但在缓存集群或数据库已经过载时,这会形成重试风暴。更好的方式是指数退避,并设置随机抖动,让大量失败任务不要在同一时间再次冲击下游。
重试次数也不能脱离业务窗口。商品文案更新失败,可以在几十秒内多次尝试;支付状态同步不能无限重试后继续返回模糊结果;报表刷新失败则可以进入下一次调度周期。重试策略的终点必须明确:成功、跳过、死信、人工处理或重新构建。
补偿任务至少需要有扫描范围、执行频率、并发上限、幂等规则和停止条件。若每分钟扫描全量商品并重新删除缓存,短期看似简单,数据量增大后会造成无效操作和缓存压力。
更可控的方式是维护待处理记录或变更水位,只扫描在某个时间窗口内失败的对象。补偿结果还应该写回状态,包括最后一次成功时间、失败原因和已重试次数。否则一条长期失败记录会在日志中反复出现,却没有人知道它是否已经影响用户。
缓存与数据库全量实时比较通常成本过高,但完全不对账又无法发现静默不一致。可以采用分层策略:普通对象按比例抽样,高价值对象或近期发生变更的对象重点核验,热点 Key 在更新后短时间内提高检查频率。
对账不是把数据库内容全部搬到缓存再比较,而是比较版本、更新时间、哈希或关键字段。对于大对象,优先比较版本和摘要,发现异常后再拉取详细内容。对账任务本身也要限速,避免为了检查一致性而影响正常业务。

很多团队的问题不是不会写同步代码,而是不知道系统里到底有哪些缓存。建议为每类缓存建立登记表,记录对象名称、业务负责人、权威来源、Key 规则、TTL、读写方式、允许延迟、失效入口和修复方式。
| 登记字段 | 示例内容 | 为什么重要 |
|---|---|---|
| 缓存对象 | 商品详情、门店配置、日报汇总 | 避免把不同一致性等级的数据混用一套策略 |
| 权威来源 | 商品库、配置服务、统计任务 | 出现冲突时明确最终裁决者 |
| 失效方式 | 同步删除、事件删除、定时刷新 | 确定正常路径和异常路径的责任 |
| 版本字段 | 行版本、业务序列、任务批次号 | 防止乱序事件和旧值回填 |
| 修复入口 | 重放事件、手动重建、批量对账 | 故障发生后能够快速恢复,而不是临时改代码 |
对于普通业务对象,不需要一开始就堆叠复杂组件。可以先实现以下最小闭环:
这套链路的重点不是组件名称,而是每一步都能留下状态。团队可以先用关系型数据库记录变更,再逐步接入消息系统;也可以直接使用事件日志,但要确保事件可追踪、可重放、可幂等。
一条缓存同步事件不应只有对象 ID 和操作类型。最低限度,它应能回答:谁产生了这次变更、变更的是哪个对象、对象处于什么版本、事件何时产生、消费者如何识别重复事件。
{
"event_id": "唯一事件标识",
"event_type": "ProductChanged",
"aggregate_id": "业务对象标识",
"aggregate_version": 58,
"source": "权威业务服务",
"occurred_at": "事件产生时间",
"changed_fields": ["price", "updated_at"],
"trace_id": "链路追踪标识"
}
是否携带完整对象快照,要根据下游需求决定。只携带对象 ID 可以降低事件体积,并让消费者从权威来源读取最新数据;携带快照可以减少回源,但要承担快照过期和乱序覆盖风险。对于经常变化、体积较大的对象,我通常更倾向于事件只描述变更,缓存重建从权威来源读取最新版本。
缓存命中率高,不代表缓存同步正确。一个旧值长期存在的缓存,仍然可能被大量请求命中,甚至让命中率看起来更漂亮。监控至少要覆盖同步延迟、失败比例、重试堆积、过期事件、回源峰值和抽样不一致率。

商品详情、帮助文档、门店介绍和活动配置等数据,通常读多写少,且允许短时延迟。建议以数据库为权威来源,更新成功后删除缓存,并通过重试、TTL和定时抽样对账兜底。
如果某个对象突然成为热点,不要立刻把所有缓存改成强一致更新。先观察回源峰值、数据库连接池和热点 Key 分布,再决定是否需要请求合并、逻辑过期、提前预热或单独扩容。热点治理与一致性治理要同时设计,但不是同一个问题。
库存、订单状态、配送状态和任务状态会频繁变化,且不同状态的业务后果不同。建议先区分“展示缓存”和“决策依据”。展示页面可以读缓存并显示更新时间,但真正扣减库存、确认支付或判断订单是否可取消时,应回到权威服务确认。
如果必须缓存用于高频判断,应使用版本、原子操作或状态机约束,防止旧状态覆盖新状态。不要用一个短 TTL 掩盖状态竞争,因为 TTL 只能让结果过期,不能阻止错误决策已经发生。
搜索索引、推荐结果、报表汇总和权限快照,通常不是原始事实,而是从多个数据源计算出来的派生结果。此时适合使用领域变更事件驱动重建或失效,但要明确每个下游的刷新延迟和失败处理。
如果下游重建成本高,可以采用“旧结果继续服务、后台异步刷新”的模式,同时向用户展示生成时间。对于权限、价格和支付等不能长时间使用旧值的数据,则不应照搬报表缓存的策略。
配置类数据经常由后台人员实时修改,问题不只在缓存同步,还在于误操作和灰度发布。建议为配置增加版本、发布批次和回滚标识,并将“草稿保存”和“正式发布”区分开。
缓存同步应绑定正式发布事件,而不是绑定每一次草稿修改。这样可以减少无意义的失效,也避免用户读取到半完成配置。发布后可以对重点实例或地域进行版本探测,确认新配置已经生效。
小团队不必为了理论上的高并发提前建设复杂的事件平台。普通业务可以采用写库后删缓存、失败记录、定时补偿和基础监控,先把可靠闭环做出来。
真正不建议省略的是三个东西:缓存对象登记、失败任务记录、单对象重建入口。它们的实现成本不高,却能显著降低故障时的排查和恢复成本。等到服务数量和数据规模增加,再将变更记录迁移到更适合的消息或日志系统。

写库后删缓存的最大价值,是责任链短、操作幂等、容易理解。它适合大多数可重建对象,也是我更愿意推荐给初期系统的起点。它的边界在于:删除失败必须可重试,读回填必须考虑并发旧值,热点失效必须有数据库保护。
如果团队没有监控、补偿和对账能力,直接采用更复杂的异步架构,往往不会自动变得可靠。复杂组件增加了新的故障面,而团队还没有建立处理这些故障的能力。
直接更新缓存适合对象结构稳定、访问极热、回源成本高,并且团队可以严格控制版本的场景。它可以减少缓存失效后的回源峰值,但会把数据库更新和缓存组装绑定在一起。
如果缓存对象包含多个来源的数据,或者下游服务也会独立修改其中字段,直接更新很容易发生覆盖。此时更适合删除后由拥有完整读取权限的服务重建,而不是让每个写入方都维护一份缓存组装逻辑。
事件驱动适合多个服务订阅同一领域变更的场景,可以减少服务之间的同步调用,让写入方不必逐个通知所有下游。它的代价是系统从同步调用变成了异步流程,延迟、重复、乱序和补偿都必须进入日常运维范围。
如果团队无法监控消息堆积、无法查询单条事件、无法重放历史事件,那么事件驱动可能只是把问题隐藏得更深。上消息系统前,应先确认团队是否能承担事件生命周期管理,而不是只看吞吐量和技术名词。
分布式锁可以减少多个请求同时重建同一 Key 的浪费,但它不能保证数据库与缓存跨系统一致,也不能修复消息乱序和旧事件覆盖。锁的价值在于控制并发重建,不在于替代版本和补偿。
使用锁时还要考虑锁超时、持有者宕机、续租失败和锁粒度。锁粒度过大,会把不同对象的重建串行化;粒度过小,管理和存储开销又会上升。热点 Key 治理通常需要锁、请求合并或单飞机制,但具体选择应基于回源压力观察。

产品团队需要明确哪些字段可以延迟、哪些字段必须实时确认,以及用户在同步失败时应该看到什么。比如报表页面可以显示“数据生成于 10 分钟前”,但支付页面不能让用户依据缓存旧状态判断付款是否成功。
还要确认更新是否需要立即对用户可见、是否允许按地域或用户群体灰度、是否需要回滚。没有这些业务约束,技术团队很难判断应该选择简单失效、异步刷新还是强校验。
开发设计时应逐项确认:谁写权威数据库,谁产生事件,谁执行缓存操作,谁处理失败,谁负责最终补偿,谁有权限触发全量重建。责任人最好落到服务或团队,而不是笼统写“系统自动处理”。
接口和事件还要明确成功语义。缓存删除超时后,写入接口是否返回失败;消息消费完成但缓存暂时不可用时,是否允许确认消费;补偿任务处理成功后,如何通知业务方。成功语义不清,监控数字也会失去解释能力。
缓存同步测试不能只验证“修改后再次查询能读到新值”。至少要注入数据库成功、缓存失败,缓存删除后旧查询回填,事件重复、事件乱序、消费者宕机、消息堆积、缓存节点迁移和热点 Key 同时失效等场景。
同步任务数量本身不一定能说明问题。队列中有少量刚产生的任务很正常,但一条任务等待了 20 分钟,可能比几千条等待 1 秒的任务更危险。建议同时监控待处理数量、最老任务年龄、处理延迟分位数和失败原因分布。
告警也要分级。短暂延迟可以提示,持续失败和高价值对象不一致应升级,涉及支付、库存和权限的异常则需要明确值班响应。只有把告警与业务影响关联起来,团队才不会在大量低价值日志中错过真正严重的问题。


缓存同步设计经常陷入组件崇拜:引入消息队列、分布式锁、事件总线、变更捕获和多级缓存,架构图看起来越来越完整,但一旦发生异常,团队仍然不知道哪条数据是新的、哪条任务需要重试、哪个服务负责恢复。
我更看重方案的可证明性。团队能否用一张时序图解释正常写入、并发回填和乱序事件;能否用监控发现旧值风险;能否通过单对象重建恢复;能否在扩容后重新验证边界。如果答案是肯定的,方案未必复杂,却可能比堆满组件的架构更可靠。
“考虑扩展性”不等于一开始就建设多地域、多活、全量事件平台。它更像是给未来留出正确的接口:Key 不绑定物理分片,事件带有版本和唯一标识,失败任务有持久记录,缓存可以单对象重建,核心对象与普通对象有不同策略。
这些设计约束本身并不一定昂贵,却能避免未来迁移时推翻整个同步链路。系统可以先使用简单实现,再逐步升级消费者、补偿任务和对账能力,但不能把未来必需的身份、版本和责任边界完全省略。
如果团队已经有缓存同步问题,不建议立即重写。可以先选择访问量最高、更新最频繁或投诉最多的三个缓存对象,完成一次小范围体检。
完成这六步后,团队通常就能判断问题究竟出在同步顺序、事件可靠性、缓存层级、热点压力,还是业务本身没有定义一致性目标。先找到真正的故障边界,再决定是否需要更换组件,往往比直接重构更节省时间。
缓存同步真正要解决的,不是让所有数据永远在同一时刻变化,而是让团队知道数据什么时候可能不一致、如何发现不一致,以及出了问题之后如何恢复。产品技术团队如果只为今天的低并发写一个“能跑”的方案,未来每增加一个实例、一个消费者或一个地域,都可能重新支付一次架构成本;如果从第一天就把权威来源、版本、幂等、补偿和对账边界设计清楚,系统才有机会随着业务增长继续扩展。
我现在的服务采用“写数据库、删缓存”的方式,低并发时一直没有明显问题。但我担心在并发读写、删除失败,或者旧查询结果晚于写请求返回时,缓存会不会又被旧数据覆盖?这种方案到底适合哪些场景?
“先更新数据库,再删除缓存”是一个实用的起点,但不能被当成一致性保证。它解决的是大多数正常链路中的旧缓存问题,却没有覆盖删除失败、并发回写和请求乱序这三类异常。我在一次商品详情缓存的并发测试中,使用两个读线程和一个写线程反复操作同一个 Key。
测试结果很典型:正常情况下缓存能及时失效,但当读线程在数据库查询阶段被延迟,写线程完成更新并删除缓存后,读线程仍可能拿着旧查询结果重新写入缓存。
场景执行顺序最终结果风险 正常读写写库→删缓存→读请求回源缓存写入新值较低 读请求延迟回写读旧值→写库→删缓存→旧值回填缓存短暂变旧中等 删除失败写库成功→删缓存失败持续命中旧缓存较高 多次更新乱序新值先到、旧值后到旧事件覆盖新值高 因此,我更倾向于把这个方案定义为“数据库为权威源、缓存最终收敛”的策略,而不是强一致方案。
对于商品描述、内容详情、非关键配置等允许短时间延迟的数据,它通常足够简单可靠;对于余额、库存、支付状态,则不能只依赖缓存删除。落地时至少要补上三层保护:删除失败可重试,重试过程必须幂等;对高价值数据增加版本号或更新时间校验,避免旧请求覆盖新值;通过抽样对账发现长时间未收敛的 Key。
真正需要评审的不是“采用哪种顺序”,而是“删除失败后多久能发现、多久能修复”。
我的系统目前只有一个应用实例和一个缓存集群,团队觉得先用本地缓存加定时刷新就够了。可是后面可能会扩容到多个实例,甚至拆分服务和部署多个地域,我想知道原来的同步设计会在哪些地方突然失效?
缓存同步最容易被低估的地方,是它往往只在当前部署形态下成立。一旦从单实例扩展到多实例,本地缓存之间就不再共享失效状态;从单服务扩展到多服务后,任何一个服务写库都可能影响其他服务维护的缓存。我通常会把架构扩展拆成四个检查阶段,而不是一开始就堆叠消息队列、分布式锁等组件。
每增加一个阶段,团队都要重新确认缓存失效通知的覆盖范围、事件顺序和故障恢复方式。
架构阶段新增问题必须补充的设计 单实例进程重启后缓存重建TTL、空值策略、预热机制 多实例某实例删除缓存,其他实例仍保留本地副本统一失效通知或减少本地缓存职责 分库分表业务 Key 与物理分片绑定,迁移后可能失效稳定的业务 Key、版本前缀、批量失效方案 多地域事件跨地域延迟、网络分区、读写切换明确地域一致性边界和故障降级策略 我特别不建议把数据库分片编号直接写进对外缓存 Key。
这样做初期看起来方便,但数据迁移或分片调整后,旧 Key 很难完整清理,最终会出现同一个业务对象同时存在多个物理版本。更稳妥的做法是让缓存 Key 依赖稳定的业务标识,并通过版本前缀、命名空间或批量失效记录支持演进。对于本地缓存,要明确它只是短时加速层;
如果业务无法接受实例之间存在秒级差异,就不应让本地缓存承担关键状态。判断方案是否可扩展,可以问一句很具体的问题:新增一个服务实例或一个地域后,谁能通知所有相关缓存失效?如果答案仍是“靠定时任务最终会刷新”,那通常说明系统只是把一致性问题推迟了。
我准备在数据库更新后发送一条变更消息,由消费者负责删除或重建缓存。团队认为消息队列已经能解决服务解耦和异步同步问题,但我不确定消息重复、延迟、丢失或乱序时,缓存是否会被错误地恢复成旧值?
消息队列解决的是“变更通知如何异步传递”,并不自动解决“缓存最终是否正确”。在实际设计中,消息可能重复投递、延迟到达、消费超时后再次投递,也可能因为消费者故障而长期堆积。我在测试异步缓存更新时,故意让同一业务对象连续产生两个版本:版本 12 和版本 13。
只要消费端没有版本判断,即使版本 13 先处理成功,晚到的版本 12 仍可能把缓存覆盖成旧值。这个问题在单线程演示里很难暴露,却经常出现在扩容后的并行消费场景。
异常类型可能后果建议机制 重复消费重复删除、重复重建或重复写入事件 ID、幂等记录、可重复执行逻辑 消息乱序旧值覆盖新值对象版本号、更新时间校验、按 Key 保序 消费延迟用户持续读到旧缓存延迟监控、超时告警、必要时主动回源 消息未完成数据库已变更但缓存未收敛重试、死信、定时补偿和对账 事件内容也不要只放一个“删除缓存”的动作。
至少应包含业务对象标识、事件唯一 ID、对象版本、变更时间和必要的来源信息。消费者拿到事件后,应先判断该版本是否早于当前已处理版本,再决定是否执行更新。我更推荐把同步链路拆成四个责任:消息负责正常通知,重试负责处理短暂故障,补偿负责找回长期未完成事件,对账负责发现消息链路本身没有感知的不一致。
四者缺一不可,尤其不能把“消息发送成功”误认为“缓存已经同步成功”。如果业务只是删除缓存,消费逻辑可以尽量保持幂等;如果业务需要根据事件内容直接更新缓存,则必须增加版本校验。简单地说,删除是让缓存重新读取事实,更新则是在缓存中复制事实,后者对顺序和数据完整性的要求更高。
我们以前把缓存同步当成后端实现细节,产品只确认页面能不能展示,测试主要验证正常流程,运维则在出问题后看日志。现在我想建立一份上线前检查清单,避免大家都默认“最终一致”却没有人说清楚具体能接受多长时间的不一致。
缓存同步不是单纯的后端编码问题,因为“旧数据是否可接受”最终取决于业务。技术团队如果没有先拿到一致性目标,很容易在不重要的数据上过度设计,又在库存、权限等关键数据上低估风险。我参与这类方案评审时,通常先让产品写出最坏情况,而不是先讨论使用哪种缓存组件。
例如商品标题延迟 10 秒可能只是体验问题,但余额展示错误 10 秒可能直接引发投诉、退款或合规风险。这个区分比“统一采用强一致”更有决策价值。角色上线前必须回答的问题常见遗漏 产品哪些字段允许旧值?最长容忍延迟是多少?只说“实时”,没有明确时间边界 开发谁写权威数据?失败后谁重试和补偿?
默认消息成功等于缓存成功 测试乱序、重复、超时、节点故障如何验证?只覆盖正常读写流程 运维如何发现延迟、堆积和数据不一致?
只有应用错误日志,没有链路指标 我建议把一致性目标写成可验证的句子,例如“商品价格更新后,99% 的缓存应在 3 秒内失效,超过 30 秒必须告警并进入补偿”,而不是写“保证最终一致”。前者可以测试、监控和复盘,后者只是一个无法验收的口号。
上线前至少应准备四类故障演练:数据库成功但缓存删除失败,消息重复或乱序,缓存集中失效导致数据库回源,以及消费者长时间不可用。每类演练都要记录发现方式、用户表现、自动修复时间和人工介入步骤。最后要保留一项数据抽样对账。缓存命中率高并不代表数据正确,很多脏数据恰恰因为一直被命中而持续存在。
对于关键对象,可以按业务 ID 定期比较缓存版本与数据库版本;对普通数据,则可采用低比例抽样,控制成本的同时保留发现问题的能力。


读者评论
文章把缓存同步从“写库后删缓存”的操作问题,提升到了扩展性和责任边界层面,这个视角比较实用。尤其是明确旧数据可接受时间窗口,能减少团队沟通中的模糊表述。
删除缓存后旧查询结果回写”这个并发场景很容易被忽略,时序拆解比较清楚。实际落地时,版本号、短TTL和回源校验需要结合业务成本选择,不能简单照搬。
文中区分了正常同步、失败重试、长期补偿和对账,说明缓存一致性不是接入消息队列就结束了。不过补偿任务本身也需要监控、告警和权限控制,否则闭环仍可能停留在设计层面。
关于更新缓存和删除缓存的比较比较客观,没有把某一种策略说成通用答案。对于热点数据,还应进一步评估缓存失效时的并发回源、限流和击穿保护。
文章强调数据库应作为权威来源,这对库存、余额和支付状态尤其重要。缓存只能加速读取,不能承担最终事实判断;但不同业务的版本校验和降级规则还需要结合实际案例细化。