数据库存:产品技术团队实操指南:围绕表结构设计解决“缓存不同步
目录

数据库存:产品技术团队实操指南:围绕表结构设计解决“缓存不同步 | 九数云-E数通

eshutong 发表于2026年9月16日

数据库存:产品技术团队实操指南:围绕表结构设计解决“缓存不同步”

数据库里的商品价格已经变成 89 元,接口却仍然返回 99 元;运营后台显示活动已下线,用户端还可以看到旧活动;权限记录已经撤销,某个服务节点却继续使用几分钟前的旧配置。很多团队遇到这类故障时,第一反应是“缓存删除失败”,但我在方案评审和故障排查中反复看到,真正让问题难以解决的,往往不是少写了一行删除缓存代码,而是表结构没有提供判断数据新旧、表达删除语义和追踪变更来源的能力。

这篇文章不从 Redis 命令开始,也不把“先更新数据库,再删除缓存”当成万能答案,而是从数据库表结构出发,说明一个产品技术团队如何为缓存失效、异步消息、重试补偿和版本校验打基础。核心判断是:缓存一致性不是缓存组件单独承担的职责,而是数据库状态、变更事件、缓存内容和故障恢复共同组成的一条链路。

一、先讲核心结论:缓存不同步,首先要解决“谁是最新的”

1. 表结构保存的不能只有“当前值”

一张业务表通常只关心当前状态。例如商品表保存商品名称、价格、库存和上下架状态。从业务查询角度看,这些字段已经够用;但从缓存同步角度看,系统还需要回答几个问题:数据库中的这条记录是第几次变更?缓存对应的是哪一个版本?某条异步消息是新事件还是旧事件?记录消失是业务删除,还是查询失败?

如果表里只有业务字段,系统只能看到最终结果,却无法判断变化过程。缓存消费者收到一条延迟消息时,不能确定消息中的价格是否已经过时;并发请求分别读取新旧数据时,也没有依据阻止旧请求把旧值重新写回缓存。

因此,表结构至少要为以下能力提供数据基础:

  • 顺序判断:能够判断第 105 次变更是否晚于第 104 次变更。
  • 删除表达:能够区分“业务上被删除”和“读取时暂时失败”。
  • 变更追踪:能够将数据库更新、消息事件和缓存操作关联起来。
  • 并发控制:能够发现两个请求是否同时修改了同一条数据。
  • 故障补偿:能够在消息丢失、缓存删除失败后重新判断应执行什么动作。

2. 版本号通常比更新时间更适合做顺序控制

updated_at 很有价值,它可以帮助排查“数据最后什么时候改变”,也可以作为简单的新旧判断依据。但时间戳不天然等于严格版本号。数据库时间精度、多个写入节点的时钟差异、同一时间窗口内的并发更新,都可能让两个事件拥有相同或不稳定的时间。

如果业务需要在异步消费时明确判断新旧,建议增加数据库生成或事务内递增的 version 字段。缓存对象和变更事件都携带版本号,消费者就可以执行一个明确规则:只有更高版本的数据,才能覆盖当前缓存。

字段主要作用是否适合严格排序常见限制
updated_at记录最后变更时间,辅助排查有限可能存在精度、时钟和并发问题
version判断数据版本、实现乐观锁较适合必须保证生成和递增机制可靠
event_id关联一次变更和下游事件通常不适合唯一不等于有序,不能代替版本号
deleted_at表达软删除语义不适合会增加查询条件和清理成本

我在设计这类方案时,会先问一句:“如果一条消息晚到 30 秒,系统能不能判断它是不是旧消息?”如果答案是否定的,就不能只讨论缓存删除接口,而应该先回到表结构和事件模型。

数据库存:产品技术团队实操指南:围绕表结构设计解决“缓存不同步

3. 表结构不能单独保证一致性

需要特别强调边界:增加版本号并不等于缓存马上同步,也不等于数据库和缓存之间获得了强一致。它只能让系统更容易判断数据是否过期。真正可靠的链路,还需要正确的事务边界、可靠的事件投递、幂等消费、失败重试、监控和补偿任务。

因此,文章标题中的“围绕表结构设计解决缓存不同步”,更准确的理解应该是:通过表结构设计补齐一致性链路所需的事实依据,再配合事件和缓存策略降低不一致概率、缩短不一致窗口,并让异常可以被发现和修复。

二、一个典型故障:数据库更新成功,缓存却被旧请求重新写回

1. 商品价格场景中的故障时间线

以商品详情为例,缓存键为 product:1001。缓存中保存价格、库存状态和商品名称。运营人员在后台将价格从 99 元改为 89 元,数据库事务正常提交,但用户端在一段时间内仍读到 99 元。

很多人只会检查“删除缓存是否成功”,但真实故障可能是下面这种更隐蔽的并发时序:

  1. 请求 A 在数据库更新前读取到价格 99 元。
  2. 运营操作更新数据库,价格变为 89 元,版本从 104 变为 105。
  3. 请求 B 读取到新价格 89 元,并将版本 105 写入缓存。
  4. 请求 A 的处理速度较慢,随后把早先读取到的 99 元写回缓存。
  5. 缓存最终保存旧价格,直到 TTL 到期或下一次主动失效。

如果缓存对象不携带版本号,缓存写入逻辑只看到一份合法 JSON,就无法知道请求 A 的数据已经过时。此时即使数据库更新和缓存删除都没有明显报错,系统仍可能出现旧值回写。

2. 真正需要记录的是四个时间点

排查缓存不同步时,我不会只看数据库更新时间和缓存过期时间,而会尽量还原四个时间点:业务请求开始时间、数据库事务提交时间、缓存读取时间、缓存写入或删除时间。只有把这几个时间点放在同一条时间线上,才能判断是旧读、旧写、消息延迟还是删除失败。

时间点需要记录的内容排查价值
请求开始请求 ID、业务操作、节点信息识别并发请求和慢请求
数据库提交主键、旧版本、新版本、事务结果确定数据库何时真正成为新状态
缓存读取缓存版本、命中时间、数据摘要确认请求拿到的是哪一版数据
缓存写入或删除操作类型、版本、结果、耗时判断旧值覆盖、删除失败和超时

这里有一个容易被忽视的细节:日志中不要只记录“删除成功”或“缓存命中”,最好记录版本号。没有版本号的日志只能告诉你系统做过什么动作,却不能告诉你这个动作针对的是哪一版数据。

数据库存:产品技术团队实操指南:围绕表结构设计解决“缓存不同步

3. 用版本号改写缓存写入规则

缓存内容可以从简单的业务对象,改成带有版本元数据的对象:

{
"id": 1001,

"name": "示例商品",

"price": "89.00",

"status": 1,

"version": 105,

"updated_at": "2026-09-16 10:20:30.123456"

}

写缓存时不再使用“谁最后写入谁生效”的规则,而是改成“谁的版本更高谁生效”。伪代码如下:

current = cache.get("product:1001")
if current is not null and event.version <= current.version:

ignore(event)

else:

latest = load_from_database(event.id)

if latest.version >= event.version:

cache.set("product:1001", latest)

else:

retry(event)

这里的 latest.version >= event.version 不是多余判断。消息到达时,数据库可能已经继续更新到更高版本。如果事件只携带主键,消费者重新查询数据库时虽然大多数情况下能拿到最新值,但在读副本延迟、事务尚未可见或跨库架构中,仍然需要明确处理版本关系。

三、常见误区:为什么“加一行删缓存”经常不够

1. 误区一:把 TTL 当作一致性方案

TTL 只能限制旧数据最长保留时间,不能保证数据在 TTL 期间始终正确。假设缓存 TTL 为 60 秒,价格更新后没有删除缓存,那么旧价格可能继续展示 60 秒;如果旧请求在第 59 秒重新写入缓存,问题窗口还会被延长。

TTL 更像是一道兜底保险,而不是变更同步机制。它适合对短时间延迟不敏感的商品描述、统计摘要和推荐结果,不适合直接承担库存、支付状态、权限撤销等高风险数据的一致性责任。

2. 误区二:先删缓存,再更新数据库

先删除缓存、再更新数据库看起来很直接,但读请求可能在数据库更新完成前穿透到数据库,读到旧值并重新填充缓存。这样刚刚删除的缓存又被旧数据写回,数据库更新完成后缓存仍然是旧值。

通常更稳妥的基础顺序是:先完成数据库事务,确认提交成功,再执行缓存失效或刷新。它仍然不能保证强一致,但至少把缓存失效动作放在主数据已经生效之后,减少了旧数据回填的概率。

3. 误区三:数据库提交和消息发送不在同一个可靠边界

常见代码是更新数据库后直接发送一条消息。数据库更新成功而消息发送失败时,业务请求可能已经返回成功,但缓存永远不会被删除。重启应用、网络抖动和消息客户端超时,都会让这个窗口变得真实存在。

如果缓存失效事件很重要,可以在同一个数据库事务中写入业务表和 Outbox 表,再由后台任务负责投递。Outbox 不会自动解决所有问题,但它把“数据库更新成功、消息没有可靠记录”的风险降到了更可控的范围。

4. 误区四:用 event_id 代替 version

事件 ID 适合做链路追踪和幂等标识,却不一定具备业务顺序。UUID 可以保证相对唯一,但无法判断事件 A 是否晚于事件 B;即使使用数据库自增 ID,也要确认它是否和业务记录的提交顺序一致,是否跨分片、跨库仍然有效。

我的建议是:用 version 负责“新旧判断”,用 event_id 负责“事件追踪和幂等”。二者职责不同,不要为了少加一个字段而把两个概念混在一起。

5. 误区五:所有数据都采用同样的一致性强度

商品描述短暂延迟几秒,通常不会造成严重损失;但权限撤销延迟几秒,可能形成安全风险。库存余额、支付结果和优惠价格,也不能简单套用普通详情缓存的处理方式。

业务数据短暂旧值的风险建议缓存策略是否建议只依赖 TTL
商品描述更新后删除缓存,配合 TTL可以作为兜底
商品价格中到高版本校验、主动失效、关键接口回源不建议
库存可用量核心链路以数据库或专用库存服务为准不建议
权限配置版本广播、主动失效、短缓存和回源校验不建议
报表统计低到中异步刷新、较长 TTL、明确更新时间通常可以

数据库存:产品技术团队实操指南:围绕表结构设计解决“缓存不同步

四、表结构应该怎样设计:从能存数据到能支撑同步

1. 基础表结构:适合低复杂度详情数据

一个只需要缓存商品详情的业务,可以先从最小可用结构开始。下面这张表没有引入完整事件日志,但补齐了版本、状态和时间字段:

CREATE TABLE product (
id BIGINT NOT NULL PRIMARY KEY,

name VARCHAR(128) NOT NULL,

price DECIMAL(12, 2) NOT NULL,

status TINYINT NOT NULL DEFAULT 1,

version BIGINT NOT NULL DEFAULT 1,

updated_at TIMESTAMP(6) NOT NULL,

created_at TIMESTAMP(6) NOT NULL,

deleted_at TIMESTAMP(6) NULL

);

这几个字段不是越多越好,而是分别承担不同责任。version 用于判断数据顺序,updated_at 用于审计和排查,status 表示业务状态,deleted_at 表达删除时间,created_at 记录对象生命周期。

如果团队采用软删除,还要统一所有查询条件。例如详情查询需要明确过滤 deleted_at IS NULL,缓存失效事件则要把删除动作作为一种明确事件发送,而不能依赖消费者再次查询数据库后“猜测记录去哪了”。

2. 版本字段的更新规则必须统一

版本字段最怕出现“有的写路径递增,有的写路径不递增”。后台修改、批量导入、定时任务、数据修复脚本和管理工具,任何一个入口绕过版本更新,都会让消费者看到不可信的顺序。

典型更新语句可以采用乐观锁:

UPDATE product
SET price = ?,

version = version + 1,

updated_at = CURRENT_TIMESTAMP(6)

WHERE id = ?

AND version = ?;

如果受影响行数为 0,通常意味着记录不存在,或者版本已经被其他请求修改。业务代码不能把这种情况当成普通成功,否则会产生“接口返回成功,但实际没有写入”的隐性问题。

对于不需要并发编辑控制、但需要顺序事件的表,也可以由数据库在更新时统一递增版本。关键不是 SQL 写法本身,而是确保每一次会改变缓存内容的数据库变更,都必然带来版本变化。

3. 是否增加字段级更新时间

有些业务不是整条对象都需要同样的一致性。例如商品名称变化可以延迟,价格变化必须快速失效;用户资料中的头像变化可以异步,而账号状态变化需要立即生效。此时只保留一份全表 updated_at 可能不够精细。

可以根据业务风险增加字段级时间,例如:

price_updated_at TIMESTAMP(6) NULL,
status_updated_at TIMESTAMP(6) NULL,

inventory_updated_at TIMESTAMP(6) NULL

但我不会轻易建议给每个业务字段都加时间列。字段级时间会扩大表结构、更新逻辑和索引维护成本,只有当不同字段的同步策略明显不同,并且这种差异能影响业务决策时,才值得采用。

4. 事件标识如何设计

last_event_id 可以帮助团队把一次数据库变更和消息、日志、补偿记录串起来。例如排查某个缓存旧值时,可以从缓存日志找到版本 105,再通过事件 ID 找到 Outbox 记录和消费者处理结果。

事件标识最好具备以下特征:

  • 在一次业务变更中稳定且唯一。
  • 能关联业务主键和版本号。
  • 可以在重试时保持不变,便于幂等。
  • 能被日志、消息和补偿任务共同记录。
  • 不能把唯一性误认为顺序性。

如果一个事件同时修改多张表,团队还要明确事件粒度:是每行一个事件、每个聚合对象一个事件,还是一次事务一个事件。粒度越细,补偿越精确,但消息量、存储量和处理复杂度也越高。

5. Outbox 表不应变成“第二张业务表”

Outbox 表的职责是可靠记录待投递的变更事件,不是保存所有业务历史。一个简化结构可以是:

CREATE TABLE product_change_outbox (
id BIGINT NOT NULL PRIMARY KEY,
event_id VARCHAR(64) NOT NULL,
product_id BIGINT NOT NULL,
version BIGINT NOT NULL,
event_type VARCHAR(32) NOT NULL,
payload JSON NOT NULL,
status TINYINT NOT NULL DEFAULT 0,
retry_count INT NOT NULL DEFAULT 0,
next_retry_at TIMESTAMP(6) NULL,
created_at TIMESTAMP(6) NOT NULL,
published_at TIMESTAMP(6) NULL,
UNIQUE KEY uk_event_id (event_id),
KEY idx_pending (status, next_retry_at)
);

业务表和 Outbox 表在同一事务中提交,后台投递程序负责读取待发送记录。消费成功后更新投递状态,失败则增加重试次数并设置下一次尝试时间。

实际落地时,还要处理 Outbox 表膨胀、历史归档、重复投递、投递超时但实际上已成功等问题。Outbox 解决的是事件可靠记录和投递起点,不是缓存刷新结果本身。

数据库存:产品技术团队实操指南:围绕表结构设计解决“缓存不同步

五、把数据库、事件和缓存连成一条可执行链路

1. 推荐的写入流程

对于大多数需要缓存详情的业务,我建议先采用下面这条基础链路:

  1. 业务请求进入应用服务,校验参数和权限。
  2. 开启数据库事务。
  3. 更新业务字段,同时递增 version 和写入 updated_at
  4. 在同一事务内写入 Outbox 事件,记录主键、版本和事件类型。
  5. 提交数据库事务。
  6. 由投递程序发布缓存失效或刷新事件。
  7. 消费者执行幂等处理,并记录处理结果。
  8. 失败进入重试、死信或补偿任务。

这里最关键的是事务顺序。缓存不是主数据,数据库事务提交之前不应该让下游把这次修改当作已经生效。否则会出现缓存短暂变成新值,但数据库事务回滚,最终缓存反而比数据库更新。

2. 删除缓存和刷新缓存如何取舍

删除缓存的好处是逻辑简单,消费者不需要掌握完整对象,只要删除键,下一次读取时再从数据库加载。缺点是热点 Key 可能在删除后被大量请求同时回源,形成缓存击穿;如果回源读的是延迟副本,还可能重新加载旧值。

刷新缓存可以减少下一次读取的回源压力,但要求事件携带完整数据,或者消费者能可靠查询到最新主库。刷新逻辑还要处理序列化版本、字段裁剪、缓存结构变化和刷新失败。

判断条件更适合删除缓存更适合刷新缓存
对象大小对象较大,回源成本可接受对象较小,刷新成本可控
访问热度访问量普通热点 Key,回源可能造成瞬时压力
事件内容只需知道失效主键事件中已有可信完整对象
数据敏感度读时可以再次确认数据库需要尽快把新状态推送到缓存

我的实践判断是:普通详情数据先用“提交后删除缓存 + 重试 + TTL”,热点数据再考虑刷新或逻辑过期;价格、权限和库存等高风险数据,不应把缓存当作最终事实来源。

3. 消费者必须幂等,还要能处理乱序

消息系统至少会带来两个现实问题:重复消费和乱序到达。重复消费并不一定是异常,消费者重启、确认超时和网络抖动都可能造成同一事件再次投递。因此,缓存失效操作应设计成重复执行不会破坏结果。

刷新缓存时则需要额外比较版本。例如缓存当前版本是 105,收到版本 104 的事件,应直接忽略;缓存当前版本是 103,收到版本 105 的事件,才允许刷新或删除。

function handle(event):
cached = cache.get(event.cache_key)

if cached != null and cached.version >= event.version:

record("stale_event_ignored", event.event_id)

return

if event.type == "DELETE":

cache.delete(event.cache_key)

record("cache_invalidated", event.event_id)

return

latest = query_primary_database(event.record_id)

if latest.version < event.version:

retry_later(event)

return

cache.set(event.cache_key, latest, ttl)

record("cache_refreshed", event.event_id)

需要注意,事件处理中的“查询数据库”最好明确读主库还是读副本。如果业务已经依赖读副本,就要把副本延迟纳入设计,否则消费者可能收到版本 105 的事件,却只能查询到版本 104 的数据。

数据库存:产品技术团队实操指南:围绕表结构设计解决“缓存不同步

4. 读路径也要防止旧值回写

很多团队只改写路径,却忽略了读路径中的缓存回填。一个常见的读流程是:缓存未命中,查询数据库,序列化对象,写入缓存并返回。如果查询数据库和缓存写入之间发生了更新,读到的对象就可能在回填时已经过期。

比较稳妥的方式有三种:

  • 回填前再次确认版本,只有版本未变化时才写入。
  • 使用带版本条件的缓存写入脚本,低版本不能覆盖高版本。
  • 热点数据采用互斥锁、逻辑过期或单飞机制,减少多个请求同时回源。

这几种方式不是必须全部使用。低流量详情接口可以接受短暂旧值;高并发热点 Key 则需要重点防止旧请求覆盖和集中回源。判断标准不是“哪种方案最先进”,而是旧值的业务代价和回源压力是否值得这部分复杂度。

六、一个可复现的商品缓存案例:从错误方案到版本化方案

1. 初始表结构和初始流程

假设产品团队维护商品详情接口,数据库表只有以下字段:

CREATE TABLE product (
id BIGINT PRIMARY KEY,

name VARCHAR(128) NOT NULL,

price DECIMAL(12, 2) NOT NULL,

status TINYINT NOT NULL

);

原始写入逻辑是:更新数据库,删除缓存;读取逻辑是:缓存命中直接返回,未命中则查询数据库并写回缓存。这个方案在低并发和低风险场景中可以工作,但它没有解决三个问题:

  • 删除缓存失败后,谁负责再次删除?
  • 旧请求回写缓存时,谁判断它是旧数据?
  • 消息重复或乱序时,消费者如何避免错误覆盖?

这三个问题都不是简单增加 TTL 可以解决的,因为 TTL 只知道时间,不知道这份数据对应哪一次业务变更。

2. 改造后的表结构

改造后可以使用下面的结构:

CREATE TABLE product (
id BIGINT NOT NULL PRIMARY KEY,
name VARCHAR(128) NOT NULL,
price DECIMAL(12, 2) NOT NULL,
status TINYINT NOT NULL DEFAULT 1,
version BIGINT NOT NULL DEFAULT 1,
updated_at TIMESTAMP(6) NOT NULL,
deleted_at TIMESTAMP(6) NULL,
last_event_id VARCHAR(64) NULL,
created_at TIMESTAMP(6) NOT NULL
);

后台修改价格时,在同一事务中完成业务数据更新和事件记录:

BEGIN;
UPDATE product

SET price = 89.00,

version = version + 1,

updated_at = CURRENT_TIMESTAMP(6),

last_event_id = 'evt-20260916-000105'

WHERE id = 1001
AND deleted_at IS NULL;
INSERT INTO product_change_outbox (

id,

event_id,

product_id,

version,

event_type,

payload,

status,

retry_count,

created_at

) VALUES (

900001,

'evt-20260916-000105',

1001,

105,

'PRODUCT_UPDATED',

'{"product_id":1001,"version":105}',

0,

0,

CURRENT_TIMESTAMP(6)

);

COMMIT;

这个流程的价值不在于 SQL 更复杂,而在于把“数据已经变更”和“应该通知下游变更”放进了同一个事务边界。只要事务提交成功,系统至少有一条可以重试投递的事件记录。

3. 用一组示意数据观察改造价值

下面的数据不是某个公开项目的生产统计,而是我在方案评审中常用的情景推演,用来帮助团队理解故障闭环。假设一个接口每天约 100 万次访问,写操作约 3000 次,缓存 TTL 为 60 秒。

观察项仅更新后删缓存增加版本号版本号 + Outbox + 补偿
旧事件识别无法可靠识别可以识别可以识别并追踪来源
删除失败处理依赖临时重试可按版本补偿可通过事件状态和任务闭环
旧值回写防护较弱可设置版本门槛可设置版本门槛并关联事件
故障定位耗时通常较长可通过缓存版本缩短可关联请求、事务和消息链路
实施成本中到高

这组推演说明一个重要问题:版本号的直接价值不一定体现在“缓存命中率提高多少”,而体现在旧事件不再盲目覆盖、排查可以从猜测变成比对、失败任务可以按版本重新执行。对于技术负责人来说,这些能力往往比单纯追求更高的缓存命中率更重要。

数据库存:产品技术团队实操指南:围绕表结构设计解决“缓存不同步

4. 这个方案仍然有哪些边界

如果消费者查询的是延迟副本,事件已经是版本 105,但副本仍只返回版本 104,消费者不能把 104 写入缓存。此时应暂缓刷新、改查主库,或者直接删除缓存等待下一次回源。如何选择取决于主库压力、业务实时性和副本延迟水平。

如果业务允许绕过应用直接写数据库,版本和事件就可能没有同步更新。生产环境中常见的批量 SQL、临时修复脚本和数据导入任务,都需要纳入写入规范。否则系统表面上有版本字段,实际只有部分变化能被版本机制捕获。

七、如何选择方案:不要从中间件名称开始

1. 先评估数据错误的业务成本

我建议团队用三个问题给数据分级:旧值最多能接受多久?旧值会不会导致实际交易损失?数据是否涉及权限、安全或资金?这三个问题比“要不要上 CDC”更早,也更接近真实决策。

如果商品描述最多允许延迟 5 分钟,那么 TTL、更新后删除和定期刷新可能已经足够;如果是优惠价格,可能需要提交后主动失效、版本校验和失败补偿;如果是支付状态和库存扣减,缓存只能作为读优化,不能作为最终判断依据。

2. 再评估写入频率和热点程度

缓存不同步的风险和写入频率有关,但不是写得越多就一定越危险。低频写入的高价值数据,单次错误影响可能很大;高频写入的低价值统计数据,即使有延迟也可能可以接受。

热点程度则影响删除和刷新策略。一个每天更新几次、每分钟访问数百次的对象,删除后回源通常没有问题;一个每秒访问数万次的活动配置,删除后瞬间回源可能压垮数据库,更适合版本化刷新、逻辑过期或集中加载。

3. 最后评估团队的运维能力

Outbox、CDC 和消息补偿都不是“接入后自动运行”的黑盒能力。团队需要有人负责消费积压、死信、表归档、数据对账和异常修复。如果团队没有稳定的监控和排障能力,先把表结构、失效重试和版本校验做好,往往比盲目引入复杂链路更稳妥。

团队现状推荐起步方案暂时不要做什么
单体应用、写入较少数据库版本 + 提交后删除缓存 + TTL不要一开始就引入多套消息基础设施
多服务共享数据版本 + event_id + 统一变更事件不要允许各服务自行定义缓存键和失效规则
消息已经存在但经常乱序缓存携带版本,消费者丢弃旧事件不要只依赖消息到达时间排序
数据变更必须可靠广播事务内 Outbox,再建设投递和补偿不要让业务线程直接承担所有重试
多系统订阅数据库变化评估 CDC,并配套幂等和对账不要把 CDC 当成缓存一致性的完整答案

数据库存:产品技术团队实操指南:围绕表结构设计解决“缓存不同步

4. 用决策树替代“技术信仰”

可以按以下顺序做判断:

  1. 如果缓存旧值不会造成交易、安全或合规风险,先采用提交后删除缓存和 TTL。
  2. 如果存在并发更新、消息乱序或旧请求回写,增加版本号并让缓存携带版本。
  3. 如果不能接受数据库更新后事件丢失,在同一事务内写入 Outbox。
  4. 如果多个系统都需要订阅数据库变更,再评估 CDC 或统一事件平台。
  5. 如果数据本身要求强一致,不要继续叠加缓存技巧,而应让核心判断回到主库或专用一致性服务。

这个顺序能避免一种常见浪费:团队先上复杂中间件,后来才发现真正的问题是某个批量脚本绕过了版本更新,或者缓存键在两个服务中根本不一致。

八、上线前如何验证:不要只做“更新后立刻查询”

1. 至少覆盖五类故障注入

缓存一致性方案不能只验证正常链路。上线前应主动制造失败,让团队确认系统是否能发现并收敛。

  • 数据库提交成功后,模拟缓存删除超时。
  • 消息发布成功后,模拟消费者重复消费。
  • 先投递版本 105,再投递版本 104,验证旧事件不会覆盖新值。
  • 让一个慢请求持有旧数据,确认它不能回写低版本缓存。
  • 让读副本延迟,确认消费者不会把低版本数据刷新进缓存。

测试时不要只检查接口最终返回值,还要检查数据库版本、缓存版本、事件状态、重试次数和告警是否一致。否则系统可能“最终看起来正常”,但中间已经发生了无法追踪的错误覆盖。

2. 建立数据库版本与缓存版本对账

对账不需要每次请求都访问数据库。可以通过定时任务抽样读取高风险 Key,比较数据库主库版本和缓存版本。如果缓存版本低于数据库版本,记录差异并触发删除或刷新;如果缓存版本高于数据库版本,则重点检查是否存在数据库回滚、读写环境不一致或缓存写入错误。

对账指标至少包括:

  • 数据库版本高于缓存版本的 Key 数量。
  • 缓存版本高于数据库版本的 Key 数量。
  • 版本差异超过一个版本的 Key 数量。
  • 缓存失效失败次数和重试成功率。
  • 事件从数据库提交到缓存处理完成的延迟。
  • 死信事件数量和最大滞留时长。

3. 观察“落后时间”,不要只观察命中率

缓存命中率高,不代表缓存一定新。一个系统可以拥有 98% 的命中率,同时其中一部分热点 Key 长时间保持旧状态。对于一致性敏感的数据,更应该增加“缓存落后时长”和“缓存版本差异”两个指标。

例如,缓存命中但版本落后 20 个变更,应该比一次缓存未命中更值得关注。前者可能直接把错误数据返回给大量用户,后者通常只是增加一次数据库查询。

数据库存:产品技术团队实操指南:围绕表结构设计解决“缓存不同步

4. 让日志能够回答故障问题

一次缓存异常发生后,排障人员最想知道的不是“某个接口有没有报错”,而是“哪个版本的数据在什么时候被谁写进了缓存”。建议在关键日志中统一记录以下字段:

字段示例用途
request_idreq-8f21关联一次用户请求
record_id1001定位业务对象
db_version105确认主库当前版本
cache_version104确认缓存是否落后
event_idevt-20260916-000105关联消息和补偿任务
operationrefresh / delete / ignore还原消费者决策

九、不同场景下的行动建议

1. 低风险、低并发的内容详情

如果数据是商品描述、帮助文档、非关键展示字段,且允许几十秒甚至几分钟延迟,可以采用轻量方案:

  • 增加 updated_at
  • 数据库事务提交后删除缓存。
  • 删除失败进行有限次数重试。
  • 设置合理 TTL 作为最终兜底。
  • 记录删除失败和回源耗时。

这类场景不一定要立即建设 Outbox。过早引入复杂事件链路,可能让维护成本超过一致性收益。但即使采用轻量方案,也应统一缓存键和写入顺序,避免不同服务各自实现。

2. 有并发修改的商品价格和活动配置

价格、活动规则和上下架状态经常被后台、定时任务和批量导入同时修改。建议增加 version,缓存对象携带版本,消费者拒绝低版本事件。

写入流程建议为:

  1. 使用版本条件更新数据库。
  2. 数据库提交成功后写入或投递失效事件。
  3. 缓存刷新时比较事件版本和当前缓存版本。
  4. 失败事件进入重试队列。
  5. 高风险 Key 通过定时任务做版本抽样对账。

对于价格展示和最终结算,还要区分“展示价格”和“成交价格”。缓存可以优化展示,但订单创建时应该重新读取可信主数据或调用专用价格服务,不能因为缓存版本设计完善,就把缓存当成结算依据。

3. 权限、账号状态和安全配置

权限撤销、账号禁用和安全策略变化不适合依赖长 TTL。可以使用短 TTL、主动广播、版本校验和关键请求回源校验的组合。

如果权限数据被多个服务缓存,建议为权限对象设置全局版本或变更序列。撤销事件必须具备更高优先级和明确的删除语义,消费者不能因为查询不到对象就把它当成临时数据库故障。

这类场景还要关注消息积压告警。缓存落后几秒可能已经超过业务容忍度,监控阈值不能沿用普通详情数据的分钟级标准。

4. 库存、余额和支付状态

库存、账户余额和支付状态属于高风险数据。即使缓存有版本号,也不能把缓存作为最终扣减、支付确认或资金计算依据。缓存可以用于展示、快速读取和降低非关键查询压力,但核心写入和最终判断应回到具备事务约束的主数据系统。

如果确实需要缓存库存,可以缓存可用量快照,但必须明确其语义:它是“最近一次同步的展示值”,还是“可以直接用于下单的可用库存”。两种语义对应完全不同的架构,不应使用同一个缓存键混淆。

5. 多系统订阅同一张业务表

当订单、营销、搜索、报表和通知服务都依赖同一张业务表时,直接在业务代码里逐个调用下游接口会形成紧耦合。此时应考虑统一变更事件,事件中至少包含主键、版本、事件类型和发生时间。

如果变更来源很多,CDC 可以减少业务代码侵入,但需要认真处理数据库日志保留、连接器故障、重复事件、字段脱敏、下游积压和历史重放。CDC 是数据变更捕获方式,不是完整的一致性策略。

十、团队落地时的分阶段实施计划

1. 第一阶段:先把现状看清楚

不要一上来改表。先盘点哪些表被缓存、缓存键如何组成、谁负责写入、谁负责删除、是否存在读副本、是否存在绕过应用直接写库的脚本。

建议输出一张缓存依赖清单,至少包括:

  • 业务表和主键。
  • 缓存键规则。
  • 缓存内容和 TTL。
  • 写入入口和批处理入口。
  • 缓存失效触发方式。
  • 允许的最大不一致时间。
  • 当前故障发现和修复方式。

如果团队连“某个缓存键由哪个服务写入”都无法确认,优先治理归属和命名,而不是讨论复杂的版本算法。

2. 第二阶段:补齐版本和可观测性

对高风险或高并发表增加 versionupdated_at,统一所有写路径的更新规则。缓存对象中携带版本,日志中记录数据库版本和缓存版本。

这个阶段的目标不是实现绝对一致,而是让团队第一次能够回答:

  • 数据库现在是哪个版本?
  • 缓存现在是哪个版本?
  • 哪个事件负责同步这次变更?
  • 这条事件是否发布、消费和重试过?

3. 第三阶段:建立可靠事件和补偿闭环

如果版本校验已经暴露出消息丢失或投递不稳定,就在业务事务中增加 Outbox。之后建设投递任务、重试策略、死信处理和历史归档。

重试不应无限进行。建议设置最大重试次数和退避时间,超过阈值后进入死信,并通过告警通知负责人。对于可自动修复的数据,可以由补偿任务按主键和版本重新刷新;对于涉及业务规则的数据,则需要人工确认。

4. 第四阶段:用对账和演练验证长期效果

上线后仍要持续抽样比对数据库版本和缓存版本。每次发生重大数据库迁移、缓存结构调整或消息系统升级,都应重新做乱序、重复、延迟和失败演练。

我更看重团队是否能在 10 分钟内定位一个缓存旧值,而不是某次压测中缓存命中率提高了几个百分点。因为一致性系统的质量,最终体现为异常发生后能否快速识别、控制和恢复。

数据库存:产品技术团队实操指南:围绕表结构设计解决“缓存不同步

十一、数据库设计和缓存策略的取舍清单

1. 什么时候只增加 updated_at

适合以下情况:

  • 数据更新频率低。
  • 业务允许短时间展示旧值。
  • 没有复杂异步消息链路。
  • 主要需求是排查和审计,而不是严格排序。
  • 团队希望以较低成本改善可观测性。

但要明确,更新时间只能作为辅助依据。如果事件会乱序,或者多个服务需要严格判断新旧,不能停留在 updated_at

2. 什么时候必须增加 version

出现以下任意情况,就值得认真考虑版本号:

  • 同一条记录存在并发更新。
  • 消息可能重复或乱序。
  • 缓存写入存在慢请求回写。
  • 需要实现乐观锁。
  • 需要通过对账判断缓存落后多少次变更。
  • 数据更新后要被多个下游系统消费。

版本号的代价是所有写路径都必须遵守规则。对于历史数据迁移和批量修复,要提前设计版本初始化、批次版本和事件补发策略。

3. 什么时候引入 Outbox

当“数据库已经更新,但事件可能丢失”会造成明显业务后果时,Outbox 的价值才足够清晰。例如价格更新后必须通知多个缓存节点,或者订单状态变化必须同步给多个下游服务。

如果缓存本身只是低风险加速层,偶尔失效后依靠 TTL 即可恢复,那么引入 Outbox 可能会产生不必要的表维护、投递任务和监控成本。

4. 什么时候不应继续堆叠缓存方案

如果业务要求每次读取都必须是最新值,或者旧值会造成资金、安全和库存风险,那么继续增加 TTL、重试队列和缓存锁,可能只是把问题包装得更复杂。此时应重新定义缓存的职责,让核心判断走主库、事务服务或专用一致性组件。

缓存适合加速读取,不适合替代事实来源。这是表结构设计和缓存一致性方案中最容易被忽略、却最重要的边界。

十二、上线前检查清单

1. 表结构检查

  • 是否有稳定且不可变的业务主键。
  • 是否有版本号或至少有可靠的更新时间。
  • 所有会改变缓存内容的写路径是否都会更新版本。
  • 是否能表达上架、下架、禁用和删除。
  • 物理删除后是否仍能产生明确的失效事件。
  • 是否记录了变更事件标识。
  • 批量导入、脚本修复和数据迁移是否遵守同样的规则。

2. 事务和事件检查

  • 数据库事务提交前是否不会刷新缓存。
  • 业务表和 Outbox 表是否处于同一事务。
  • 事件是否包含主键、版本和事件类型。
  • 消费者是否支持重复消费。
  • 消费者是否会拒绝低版本事件。
  • 事件发布失败是否可重试。
  • 超过重试次数的事件是否进入死信。

3. 缓存读写检查

  • 缓存键是否由统一组件生成。
  • 缓存对象是否携带版本号。
  • 旧请求回写时是否有版本保护。
  • 缓存未命中时是否可能读取延迟副本。
  • 热点 Key 删除后是否可能发生缓存击穿。
  • 缓存结构升级时是否兼容旧版本对象。
  • TTL 是否只是兜底,而不是唯一一致性措施。

4. 监控和排障检查

  • 是否能查看数据库版本与缓存版本。
  • 是否监控缓存失效失败次数。
  • 是否监控事件投递延迟和消费积压。
  • 是否能找到某个事件对应的请求和数据库事务。
  • 是否有版本差异抽样对账。
  • 是否有失败事件的自动补偿和人工处理入口。
  • 是否做过重复、乱序、延迟和读副本落后的故障演练。

数据库存:产品技术团队实操指南:围绕表结构设计解决“缓存不同步

十三、结语:把不可控的不一致,变成可识别、可补偿的问题

缓存不同步并不是一个可以靠单条 Redis 命令彻底消灭的问题。只要数据库和缓存属于两个独立存储系统,就会存在事务提交、网络通信、消息投递、并发读写和故障恢复之间的时间窗口。

真正成熟的方案,不是宣称“强一致”“零延迟”或“彻底解决”,而是让团队能够清楚知道:数据库当前是什么版本,缓存当前是什么版本,哪条事件负责同步,事件是否已经消费,失败后由谁补偿。

从表结构开始设计,是因为版本、删除状态、更新时间和事件标识决定了系统有没有事实依据。没有这些字段,缓存消费者只能依赖时间、猜测和最后写入顺序;有了这些字段,系统才有机会拒绝旧事件、阻止旧值覆盖、定位异常并执行补偿。

下一步不建议直接改造所有业务。可以先选一张缓存访问量高、又经常发生旧值投诉的表,完成四件事:补充版本字段,统一写入路径,让缓存携带版本,增加数据库版本与缓存版本对账。验证这四件事能够稳定运行后,再根据消息丢失风险决定是否引入 Outbox、CDC 或更完整的补偿机制。

我的最终判断是:缓存一致性的起点不是“缓存怎么删”,而是“数据库能不能证明哪一次变化才是最新”。产品技术团队如果先把这个问题回答清楚,后面的缓存策略、中间件选型和运维建设,才不会变成一堆彼此割裂的补丁。

常见问题解答(FAQ)

1. 缓存不同步时,数据库表结构需要增加哪些字段?

我以前总以为缓存不一致主要是删除缓存的代码没写对,直到排查商品详情接口时才发现,表里只有商品名称、价格和状态,根本无法判断缓存里的数据是哪一次更新产生的。想请教一下,表结构到底应该预留哪些字段,才能让缓存同步、排查和补偿真正有依据?

表结构设计不能直接消除缓存不一致,但它决定了系统有没有能力识别新旧数据、表达删除语义,并在异常后完成补偿。我的判断标准不是字段越多越好,而是每个字段都要对应一类实际故障。

对于商品、活动配置、权限规则这类会被缓存的数据,通常优先考虑以下字段: 字段主要作用解决的问题 version记录数据变更顺序避免旧消息或旧请求覆盖新缓存 updated_at记录最后修改时间辅助排查缓存落后时间 status表达上架、下架、禁用等状态避免把业务下线误判成查询异常 deleted_at表达删除发生的时间让下游明确识别删除事件 last_event_id关联一次具体变更串联数据库、消息和缓存日志 一个可落地的示例是:version BIGINT NOT NULL DEFAULT 1、updated_at TIMESTAMP(6) NOT NULL、deleted_at TIMESTAMP(6) NULL。

缓存对象也保存同一个版本号,排查时直接对比数据库版本和缓存版本,而不是只看缓存值是否相同。我不建议一开始给所有表都增加完整的事件字段。低风险、低频更新的内容,主键加更新时间和失效策略可能已经足够;价格、库存、权限等高风险数据,则至少需要版本号、明确的删除状态和可追踪的变更事件。

表结构的价值,在于把原本只能猜测的缓存问题,变成可以比较和定位的问题。

2. version 和 updated_at 都能判断新旧数据,为什么更推荐版本号?

我在多实例服务中遇到过同一条记录几乎同时被两个请求更新的情况,应用日志里的更新时间看起来没有问题,但异步消息到达顺序一变,旧内容还是会覆盖新内容。我想知道,时间戳到底在哪些场景会失效,版本号又应该怎样生成才可靠?

updated_at适合记录时间和辅助排查,但它不天然代表严格的变更顺序。问题通常出在三个地方:数据库时间精度不足、多个写入请求落在同一时间刻度,以及应用服务器自行生成时间时存在时钟偏差。例如,两次更新实际顺序是 A、B,但由于时间精度只有毫秒,最终可能都记录为同一个时间。

又或者 A 事件先写入数据库,B 事件后写入数据库,但消息系统先投递 B、后投递 A。如果消费者只比较时间,可能无法可靠拒绝 A。版本号更适合承担顺序判断职责。典型更新语句可以写成: UPDATE product SET price = ?

version = version + 1 updated_at = CURRENT_TIMESTAMP(6) WHERE id = ?AND version = ?;更新成功后,数据库版本从 104 变为 105。缓存或消息消费者看到版本 104 时,应拒绝它覆盖已经存在的版本 105。

这个规则比比较字符串时间更直接,也更容易写成自动化测试。但版本号并不是随便加一个自增字段就够了。所有写入路径都必须递增版本,包括后台脚本、批处理、管理后台和数据修复任务;否则某条路径绕过版本更新,仍然会制造旧数据。

若一条业务记录会被多个系统直接修改,最好把写入权限收敛到统一服务,或让数据库触发器、变更日志机制承担兜底。我的选型建议是:只需要展示最后修改时间时使用updated_at;存在并发更新、异步消息、缓存回写或多消费者时,使用单调递增的version,同时保留updated_at用于审计和故障定位。

两者不是替代关系,而是职责不同。

3. 更新数据库后删除缓存,为什么仍然会出现旧数据?

我曾经把缓存删除放在数据库更新之后,测试环境看起来完全正常,线上却偶发出现数据库已经是新价格、缓存又被旧价格写回去的情况。后来我才意识到,问题可能不在删除动作本身,而在并发读请求的时间顺序,应该怎样设计这条链路?

更新数据库后删除缓存是常见策略,但它只能降低不一致概率,不能自动保证强一致。最典型的竞态是:请求 A 先从数据库读到旧值;请求 B 更新数据库并删除缓存;请求 A 使用手里的旧值重新写入缓存。删除动作虽然成功,旧值却被后续请求重新放了回来。

可以把故障时间线拆开看: 时间请求 A请求 B结果 T1读取旧版本 103A 持有旧数据 T2数据库更新为版本 104主库已经是新值 T3删除缓存缓存暂时为空 T4写入版本 103旧请求重新污染缓存 更稳妥的做法是让缓存内容携带版本号,写入前比较版本。只有待写入版本大于当前缓存版本时才允许覆盖;

版本相同则做幂等处理,版本更低则直接丢弃。对于不适合在缓存中做版本比较的场景,可以采用延迟双删、互斥回源或短暂逻辑过期,但这些方式都只是缩小窗口,不能替代可靠的变更追踪。事务边界也非常关键。推荐顺序是:先在数据库事务中更新业务字段和版本号,事务提交成功后再发布缓存失效事件。

不要在事务尚未提交时删除缓存,否则其他请求可能在数据库回滚前回源读取旧值,随后把旧值重新写入缓存。上线前至少要验证四类故障:删除缓存失败、旧请求回写、消息重复消费和消息乱序。监控上不要只看缓存命中率,还要记录缓存版本、数据库版本、失效事件延迟和补偿任务结果。

真正可靠的目标不是宣称完全没有不一致,而是让不一致可检测、可重试、可恢复。

4. 什么时候应该引入 Outbox 或 CDC,而不是继续增加删缓存代码?

我见过一种系统,业务表更新成功后直接发送缓存失效消息,平时运行没有问题,但消息服务短暂故障时,数据库已经变更,缓存却一直没有清理。团队一开始想通过增加重试解决,可是重试记录本身也可能丢失,我该如何判断是否需要 Outbox 或 CDC?

判断是否引入 Outbox 或 CDC,关键不在于技术名词是否先进,而在于一次数据库变更是否必须可靠地通知多个下游。如果只是低风险详情数据,更新后删除缓存、失败重试和定时校验通常已经够用;如果涉及价格、权限、库存或多个订阅系统,直接在业务代码里发消息的风险会迅速上升。

直接发送消息存在一个无法靠普通重试彻底解决的窗口:数据库事务已经提交,但应用在发送消息前崩溃。此时业务数据已经是新值,却没有任何失效事件。若把消息发送放在数据库提交前,又会出现消息已发出、数据库最终回滚的问题。

Outbox 的做法是把业务表更新和变更事件写入同一个数据库事务: 业务表更新为版本 105,同时写入 Outbox 事件 105;事务提交后,后台投递程序再把事件发送给消费者。投递失败可以重试,消费重复则依靠事件 ID 和版本号幂等处理。它的优点是改造边界清晰,适合希望控制业务语义的团队;

代价是需要维护投递任务、失败状态、重试次数和过期事件。CDC 更适合已有多个下游订阅者、数据库变更频繁,或不希望在每个写入接口中重复编写事件逻辑的场景。它能从数据库变更日志中捕获记录变化,但通常会带来更高的运维复杂度,也需要处理字段变更、删除事件、消息重复、乱序、延迟和全量初始化问题。

场景建议理由 普通内容,允许短时间旧值更新后失效加重试成本最低,链路简单 高风险数据,写入入口较少Outbox 加版本号能保证事件与事务一起落库 多个系统订阅大量表变更CDC 加消费幂等减少业务代码侵入 库存扣减、支付状态等核心判断关键读路径回主库不能把普通缓存当作最终事实来源 我的建议是先做一次故障成本评估:统计过去一个月缓存失效失败次数、平均恢复时间、受影响业务和人工补偿成本。

如果问题主要是偶发删除失败,先补齐重试与监控;如果已经出现事件丢失、多个下游各自维护同步逻辑,再考虑 Outbox;只有在订阅规模和变更量确实达到一定程度时,才值得承担 CDC 的运维成本。

核心关键词

读者评论

欧阳亦辰

文章把缓存不一致从“删缓存失败”扩展到版本、事件和补偿链路,视角比较全面。尤其是旧请求回写旧值的案例,对排查线上并发问题很有参考价值。

段佳宁

版本号和更新时间的区别讲得比较清楚,但实际落地时还需要说明版本递增由数据库触发、应用控制还是专门服务负责,否则并发场景下仍可能产生疑问。

莫一凡

Outbox方案能降低数据库提交成功但消息发送失败的风险,不过文章也提醒了它不是万能方案,这一点比较客观。后续还可以补充重复投递和补偿任务的实现细节。

黎静怡

文中强调日志记录版本号,而不只是记录缓存命中或删除成功,这个建议很实用。没有版本信息时,很多故障确实只能看到操作结果,难以还原具体时序。

董梓萱

文章适合产品技术团队做方案评审时参考,但库存、支付、权限等高风险场景不能只依赖缓存版本控制,仍应结合强一致查询或更严格的业务校验。

免责申明:本文内容通过AI工具匹配关键字智能整合而成,仅供参考,帆软及九数云不对内容的真实、准确或完整作任何形式的承诺。如有任何问题或意见,您可以通过联系jiushuyun@fanruan.com进行反馈,九数云收到您的反馈后将及时处理并反馈。
咨询方案
咨询方案二维码

扫码咨询方案

热门产品推荐

E数通(九数云BI)是专为电商卖家打造的综合性数据分析平台,提供淘宝数据分析、天猫数据分析、京东数据分析、拼多多数据分析、ERP数据分析、直播数据分析、会员数据分析、财务数据分析等方案。自动化计算销售数据、财务数据、绩效数据、库存数据,帮助卖家全局了解整体情况,决策效率高。

相关内容

查看更多
电商管理实践指南:多平台经营的进阶玩法怎样更有效

电商管理实践指南:多平台经营的进阶玩法怎样更有效

《电商管理实践指南:多平台经营的进阶玩法怎样更有效》真正要解决的,不是“还要不要开一个新店”,而是一个更容易被 […]
电商管理管理模板:围绕订单履约开展进阶玩法

电商管理管理模板:围绕订单履约开展进阶玩法

《电商管理管理模板:围绕订单履约开展进阶玩法》真正要解决的,不是“如何把订单填进一张表”,而是如何让团队在订单 […]
电商管理使用技巧:商品管理对应的进阶玩法方法

电商管理使用技巧:商品管理对应的进阶玩法方法

很多店铺把“商品管理”理解成上架、改价、改库存,真正进入多平台、多规格和多人协作阶段后,才发现最耗时间的并不是 […]
电商管理改造重点:从多平台经营推进进阶玩法

电商管理改造重点:从多平台经营推进进阶玩法

多平台经营最容易出现的误判,是把“店铺数量增加”当成“经营能力升级”。我在做电商经营诊断时见过一种很典型的情况 […]
电商管理优化清单:客服售后与进阶玩法的关键动作

电商管理优化清单:客服售后与进阶玩法的关键动作

《电商管理优化清单:客服售后与进阶玩法的关键动作》真正要解决的,不是“客服回复够不够快”,而是用户为什么要反复 […]

让电商企业精细化运营更简单

整合电商全链路数据,用可视化报表辅助自动化运营

让决策更精准