数据库存:产品技术团队实操指南:围绕表结构设计解决“缓存不同步”
数据库里的商品价格已经变成 89 元,接口却仍然返回 99 元;运营后台显示活动已下线,用户端还可以看到旧活动;权限记录已经撤销,某个服务节点却继续使用几分钟前的旧配置。很多团队遇到这类故障时,第一反应是“缓存删除失败”,但我在方案评审和故障排查中反复看到,真正让问题难以解决的,往往不是少写了一行删除缓存代码,而是表结构没有提供判断数据新旧、表达删除语义和追踪变更来源的能力。
这篇文章不从 Redis 命令开始,也不把“先更新数据库,再删除缓存”当成万能答案,而是从数据库表结构出发,说明一个产品技术团队如何为缓存失效、异步消息、重试补偿和版本校验打基础。核心判断是:缓存一致性不是缓存组件单独承担的职责,而是数据库状态、变更事件、缓存内容和故障恢复共同组成的一条链路。
一张业务表通常只关心当前状态。例如商品表保存商品名称、价格、库存和上下架状态。从业务查询角度看,这些字段已经够用;但从缓存同步角度看,系统还需要回答几个问题:数据库中的这条记录是第几次变更?缓存对应的是哪一个版本?某条异步消息是新事件还是旧事件?记录消失是业务删除,还是查询失败?
如果表里只有业务字段,系统只能看到最终结果,却无法判断变化过程。缓存消费者收到一条延迟消息时,不能确定消息中的价格是否已经过时;并发请求分别读取新旧数据时,也没有依据阻止旧请求把旧值重新写回缓存。
因此,表结构至少要为以下能力提供数据基础:
updated_at 很有价值,它可以帮助排查“数据最后什么时候改变”,也可以作为简单的新旧判断依据。但时间戳不天然等于严格版本号。数据库时间精度、多个写入节点的时钟差异、同一时间窗口内的并发更新,都可能让两个事件拥有相同或不稳定的时间。
如果业务需要在异步消费时明确判断新旧,建议增加数据库生成或事务内递增的 version 字段。缓存对象和变更事件都携带版本号,消费者就可以执行一个明确规则:只有更高版本的数据,才能覆盖当前缓存。
| 字段 | 主要作用 | 是否适合严格排序 | 常见限制 |
|---|---|---|---|
updated_at | 记录最后变更时间,辅助排查 | 有限 | 可能存在精度、时钟和并发问题 |
version | 判断数据版本、实现乐观锁 | 较适合 | 必须保证生成和递增机制可靠 |
event_id | 关联一次变更和下游事件 | 通常不适合 | 唯一不等于有序,不能代替版本号 |
deleted_at | 表达软删除语义 | 不适合 | 会增加查询条件和清理成本 |
我在设计这类方案时,会先问一句:“如果一条消息晚到 30 秒,系统能不能判断它是不是旧消息?”如果答案是否定的,就不能只讨论缓存删除接口,而应该先回到表结构和事件模型。

需要特别强调边界:增加版本号并不等于缓存马上同步,也不等于数据库和缓存之间获得了强一致。它只能让系统更容易判断数据是否过期。真正可靠的链路,还需要正确的事务边界、可靠的事件投递、幂等消费、失败重试、监控和补偿任务。
因此,文章标题中的“围绕表结构设计解决缓存不同步”,更准确的理解应该是:通过表结构设计补齐一致性链路所需的事实依据,再配合事件和缓存策略降低不一致概率、缩短不一致窗口,并让异常可以被发现和修复。
以商品详情为例,缓存键为 product:1001。缓存中保存价格、库存状态和商品名称。运营人员在后台将价格从 99 元改为 89 元,数据库事务正常提交,但用户端在一段时间内仍读到 99 元。
很多人只会检查“删除缓存是否成功”,但真实故障可能是下面这种更隐蔽的并发时序:
如果缓存对象不携带版本号,缓存写入逻辑只看到一份合法 JSON,就无法知道请求 A 的数据已经过时。此时即使数据库更新和缓存删除都没有明显报错,系统仍可能出现旧值回写。
排查缓存不同步时,我不会只看数据库更新时间和缓存过期时间,而会尽量还原四个时间点:业务请求开始时间、数据库事务提交时间、缓存读取时间、缓存写入或删除时间。只有把这几个时间点放在同一条时间线上,才能判断是旧读、旧写、消息延迟还是删除失败。
| 时间点 | 需要记录的内容 | 排查价值 |
|---|---|---|
| 请求开始 | 请求 ID、业务操作、节点信息 | 识别并发请求和慢请求 |
| 数据库提交 | 主键、旧版本、新版本、事务结果 | 确定数据库何时真正成为新状态 |
| 缓存读取 | 缓存版本、命中时间、数据摘要 | 确认请求拿到的是哪一版数据 |
| 缓存写入或删除 | 操作类型、版本、结果、耗时 | 判断旧值覆盖、删除失败和超时 |
这里有一个容易被忽视的细节:日志中不要只记录“删除成功”或“缓存命中”,最好记录版本号。没有版本号的日志只能告诉你系统做过什么动作,却不能告诉你这个动作针对的是哪一版数据。

缓存内容可以从简单的业务对象,改成带有版本元数据的对象:
{
"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 不是多余判断。消息到达时,数据库可能已经继续更新到更高版本。如果事件只携带主键,消费者重新查询数据库时虽然大多数情况下能拿到最新值,但在读副本延迟、事务尚未可见或跨库架构中,仍然需要明确处理版本关系。
TTL 只能限制旧数据最长保留时间,不能保证数据在 TTL 期间始终正确。假设缓存 TTL 为 60 秒,价格更新后没有删除缓存,那么旧价格可能继续展示 60 秒;如果旧请求在第 59 秒重新写入缓存,问题窗口还会被延长。
TTL 更像是一道兜底保险,而不是变更同步机制。它适合对短时间延迟不敏感的商品描述、统计摘要和推荐结果,不适合直接承担库存、支付状态、权限撤销等高风险数据的一致性责任。
先删除缓存、再更新数据库看起来很直接,但读请求可能在数据库更新完成前穿透到数据库,读到旧值并重新填充缓存。这样刚刚删除的缓存又被旧数据写回,数据库更新完成后缓存仍然是旧值。
通常更稳妥的基础顺序是:先完成数据库事务,确认提交成功,再执行缓存失效或刷新。它仍然不能保证强一致,但至少把缓存失效动作放在主数据已经生效之后,减少了旧数据回填的概率。
常见代码是更新数据库后直接发送一条消息。数据库更新成功而消息发送失败时,业务请求可能已经返回成功,但缓存永远不会被删除。重启应用、网络抖动和消息客户端超时,都会让这个窗口变得真实存在。
如果缓存失效事件很重要,可以在同一个数据库事务中写入业务表和 Outbox 表,再由后台任务负责投递。Outbox 不会自动解决所有问题,但它把“数据库更新成功、消息没有可靠记录”的风险降到了更可控的范围。
事件 ID 适合做链路追踪和幂等标识,却不一定具备业务顺序。UUID 可以保证相对唯一,但无法判断事件 A 是否晚于事件 B;即使使用数据库自增 ID,也要确认它是否和业务记录的提交顺序一致,是否跨分片、跨库仍然有效。
我的建议是:用 version 负责“新旧判断”,用 event_id 负责“事件追踪和幂等”。二者职责不同,不要为了少加一个字段而把两个概念混在一起。
商品描述短暂延迟几秒,通常不会造成严重损失;但权限撤销延迟几秒,可能形成安全风险。库存余额、支付结果和优惠价格,也不能简单套用普通详情缓存的处理方式。
| 业务数据 | 短暂旧值的风险 | 建议缓存策略 | 是否建议只依赖 TTL |
|---|---|---|---|
| 商品描述 | 低 | 更新后删除缓存,配合 TTL | 可以作为兜底 |
| 商品价格 | 中到高 | 版本校验、主动失效、关键接口回源 | 不建议 |
| 库存可用量 | 高 | 核心链路以数据库或专用库存服务为准 | 不建议 |
| 权限配置 | 高 | 版本广播、主动失效、短缓存和回源校验 | 不建议 |
| 报表统计 | 低到中 | 异步刷新、较长 TTL、明确更新时间 | 通常可以 |

一个只需要缓存商品详情的业务,可以先从最小可用结构开始。下面这张表没有引入完整事件日志,但补齐了版本、状态和时间字段:
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,缓存失效事件则要把删除动作作为一种明确事件发送,而不能依赖消费者再次查询数据库后“猜测记录去哪了”。
版本字段最怕出现“有的写路径递增,有的写路径不递增”。后台修改、批量导入、定时任务、数据修复脚本和管理工具,任何一个入口绕过版本更新,都会让消费者看到不可信的顺序。
典型更新语句可以采用乐观锁:
UPDATE product SET price = ?, version = version + 1, updated_at = CURRENT_TIMESTAMP(6) WHERE id = ? AND version = ?;
如果受影响行数为 0,通常意味着记录不存在,或者版本已经被其他请求修改。业务代码不能把这种情况当成普通成功,否则会产生“接口返回成功,但实际没有写入”的隐性问题。
对于不需要并发编辑控制、但需要顺序事件的表,也可以由数据库在更新时统一递增版本。关键不是 SQL 写法本身,而是确保每一次会改变缓存内容的数据库变更,都必然带来版本变化。
有些业务不是整条对象都需要同样的一致性。例如商品名称变化可以延迟,价格变化必须快速失效;用户资料中的头像变化可以异步,而账号状态变化需要立即生效。此时只保留一份全表 updated_at 可能不够精细。
可以根据业务风险增加字段级时间,例如:
price_updated_at TIMESTAMP(6) NULL,
status_updated_at TIMESTAMP(6) NULL,
inventory_updated_at TIMESTAMP(6) NULL
但我不会轻易建议给每个业务字段都加时间列。字段级时间会扩大表结构、更新逻辑和索引维护成本,只有当不同字段的同步策略明显不同,并且这种差异能影响业务决策时,才值得采用。
last_event_id 可以帮助团队把一次数据库变更和消息、日志、补偿记录串起来。例如排查某个缓存旧值时,可以从缓存日志找到版本 105,再通过事件 ID 找到 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 解决的是事件可靠记录和投递起点,不是缓存刷新结果本身。

对于大多数需要缓存详情的业务,我建议先采用下面这条基础链路:
version 和写入 updated_at。这里最关键的是事务顺序。缓存不是主数据,数据库事务提交之前不应该让下游把这次修改当作已经生效。否则会出现缓存短暂变成新值,但数据库事务回滚,最终缓存反而比数据库更新。
删除缓存的好处是逻辑简单,消费者不需要掌握完整对象,只要删除键,下一次读取时再从数据库加载。缺点是热点 Key 可能在删除后被大量请求同时回源,形成缓存击穿;如果回源读的是延迟副本,还可能重新加载旧值。
刷新缓存可以减少下一次读取的回源压力,但要求事件携带完整数据,或者消费者能可靠查询到最新主库。刷新逻辑还要处理序列化版本、字段裁剪、缓存结构变化和刷新失败。
| 判断条件 | 更适合删除缓存 | 更适合刷新缓存 |
|---|---|---|
| 对象大小 | 对象较大,回源成本可接受 | 对象较小,刷新成本可控 |
| 访问热度 | 访问量普通 | 热点 Key,回源可能造成瞬时压力 |
| 事件内容 | 只需知道失效主键 | 事件中已有可信完整对象 |
| 数据敏感度 | 读时可以再次确认数据库 | 需要尽快把新状态推送到缓存 |
我的实践判断是:普通详情数据先用“提交后删除缓存 + 重试 + TTL”,热点数据再考虑刷新或逻辑过期;价格、权限和库存等高风险数据,不应把缓存当作最终事实来源。
消息系统至少会带来两个现实问题:重复消费和乱序到达。重复消费并不一定是异常,消费者重启、确认超时和网络抖动都可能造成同一事件再次投递。因此,缓存失效操作应设计成重复执行不会破坏结果。
刷新缓存时则需要额外比较版本。例如缓存当前版本是 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 的数据。

很多团队只改写路径,却忽略了读路径中的缓存回填。一个常见的读流程是:缓存未命中,查询数据库,序列化对象,写入缓存并返回。如果查询数据库和缓存写入之间发生了更新,读到的对象就可能在回填时已经过期。
比较稳妥的方式有三种:
这几种方式不是必须全部使用。低流量详情接口可以接受短暂旧值;高并发热点 Key 则需要重点防止旧请求覆盖和集中回源。判断标准不是“哪种方案最先进”,而是旧值的业务代价和回源压力是否值得这部分复杂度。
假设产品团队维护商品详情接口,数据库表只有以下字段:
CREATE TABLE product (
id BIGINT PRIMARY KEY,
name VARCHAR(128) NOT NULL,
price DECIMAL(12, 2) NOT NULL,
status TINYINT NOT NULL
);
原始写入逻辑是:更新数据库,删除缓存;读取逻辑是:缓存命中直接返回,未命中则查询数据库并写回缓存。这个方案在低并发和低风险场景中可以工作,但它没有解决三个问题:
这三个问题都不是简单增加 TTL 可以解决的,因为 TTL 只知道时间,不知道这份数据对应哪一次业务变更。
改造后可以使用下面的结构:
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 更复杂,而在于把“数据已经变更”和“应该通知下游变更”放进了同一个事务边界。只要事务提交成功,系统至少有一条可以重试投递的事件记录。
下面的数据不是某个公开项目的生产统计,而是我在方案评审中常用的情景推演,用来帮助团队理解故障闭环。假设一个接口每天约 100 万次访问,写操作约 3000 次,缓存 TTL 为 60 秒。
| 观察项 | 仅更新后删缓存 | 增加版本号 | 版本号 + Outbox + 补偿 |
|---|---|---|---|
| 旧事件识别 | 无法可靠识别 | 可以识别 | 可以识别并追踪来源 |
| 删除失败处理 | 依赖临时重试 | 可按版本补偿 | 可通过事件状态和任务闭环 |
| 旧值回写防护 | 较弱 | 可设置版本门槛 | 可设置版本门槛并关联事件 |
| 故障定位耗时 | 通常较长 | 可通过缓存版本缩短 | 可关联请求、事务和消息链路 |
| 实施成本 | 低 | 中 | 中到高 |
这组推演说明一个重要问题:版本号的直接价值不一定体现在“缓存命中率提高多少”,而体现在旧事件不再盲目覆盖、排查可以从猜测变成比对、失败任务可以按版本重新执行。对于技术负责人来说,这些能力往往比单纯追求更高的缓存命中率更重要。

如果消费者查询的是延迟副本,事件已经是版本 105,但副本仍只返回版本 104,消费者不能把 104 写入缓存。此时应暂缓刷新、改查主库,或者直接删除缓存等待下一次回源。如何选择取决于主库压力、业务实时性和副本延迟水平。
如果业务允许绕过应用直接写数据库,版本和事件就可能没有同步更新。生产环境中常见的批量 SQL、临时修复脚本和数据导入任务,都需要纳入写入规范。否则系统表面上有版本字段,实际只有部分变化能被版本机制捕获。
我建议团队用三个问题给数据分级:旧值最多能接受多久?旧值会不会导致实际交易损失?数据是否涉及权限、安全或资金?这三个问题比“要不要上 CDC”更早,也更接近真实决策。
如果商品描述最多允许延迟 5 分钟,那么 TTL、更新后删除和定期刷新可能已经足够;如果是优惠价格,可能需要提交后主动失效、版本校验和失败补偿;如果是支付状态和库存扣减,缓存只能作为读优化,不能作为最终判断依据。
缓存不同步的风险和写入频率有关,但不是写得越多就一定越危险。低频写入的高价值数据,单次错误影响可能很大;高频写入的低价值统计数据,即使有延迟也可能可以接受。
热点程度则影响删除和刷新策略。一个每天更新几次、每分钟访问数百次的对象,删除后回源通常没有问题;一个每秒访问数万次的活动配置,删除后瞬间回源可能压垮数据库,更适合版本化刷新、逻辑过期或集中加载。
Outbox、CDC 和消息补偿都不是“接入后自动运行”的黑盒能力。团队需要有人负责消费积压、死信、表归档、数据对账和异常修复。如果团队没有稳定的监控和排障能力,先把表结构、失效重试和版本校验做好,往往比盲目引入复杂链路更稳妥。
| 团队现状 | 推荐起步方案 | 暂时不要做什么 |
|---|---|---|
| 单体应用、写入较少 | 数据库版本 + 提交后删除缓存 + TTL | 不要一开始就引入多套消息基础设施 |
| 多服务共享数据 | 版本 + event_id + 统一变更事件 | 不要允许各服务自行定义缓存键和失效规则 |
| 消息已经存在但经常乱序 | 缓存携带版本,消费者丢弃旧事件 | 不要只依赖消息到达时间排序 |
| 数据变更必须可靠广播 | 事务内 Outbox,再建设投递和补偿 | 不要让业务线程直接承担所有重试 |
| 多系统订阅数据库变化 | 评估 CDC,并配套幂等和对账 | 不要把 CDC 当成缓存一致性的完整答案 |

可以按以下顺序做判断:
这个顺序能避免一种常见浪费:团队先上复杂中间件,后来才发现真正的问题是某个批量脚本绕过了版本更新,或者缓存键在两个服务中根本不一致。
缓存一致性方案不能只验证正常链路。上线前应主动制造失败,让团队确认系统是否能发现并收敛。
测试时不要只检查接口最终返回值,还要检查数据库版本、缓存版本、事件状态、重试次数和告警是否一致。否则系统可能“最终看起来正常”,但中间已经发生了无法追踪的错误覆盖。
对账不需要每次请求都访问数据库。可以通过定时任务抽样读取高风险 Key,比较数据库主库版本和缓存版本。如果缓存版本低于数据库版本,记录差异并触发删除或刷新;如果缓存版本高于数据库版本,则重点检查是否存在数据库回滚、读写环境不一致或缓存写入错误。
对账指标至少包括:
缓存命中率高,不代表缓存一定新。一个系统可以拥有 98% 的命中率,同时其中一部分热点 Key 长时间保持旧状态。对于一致性敏感的数据,更应该增加“缓存落后时长”和“缓存版本差异”两个指标。
例如,缓存命中但版本落后 20 个变更,应该比一次缓存未命中更值得关注。前者可能直接把错误数据返回给大量用户,后者通常只是增加一次数据库查询。

一次缓存异常发生后,排障人员最想知道的不是“某个接口有没有报错”,而是“哪个版本的数据在什么时候被谁写进了缓存”。建议在关键日志中统一记录以下字段:
| 字段 | 示例 | 用途 |
|---|---|---|
| request_id | req-8f21 | 关联一次用户请求 |
| record_id | 1001 | 定位业务对象 |
| db_version | 105 | 确认主库当前版本 |
| cache_version | 104 | 确认缓存是否落后 |
| event_id | evt-20260916-000105 | 关联消息和补偿任务 |
| operation | refresh / delete / ignore | 还原消费者决策 |
如果数据是商品描述、帮助文档、非关键展示字段,且允许几十秒甚至几分钟延迟,可以采用轻量方案:
updated_at。这类场景不一定要立即建设 Outbox。过早引入复杂事件链路,可能让维护成本超过一致性收益。但即使采用轻量方案,也应统一缓存键和写入顺序,避免不同服务各自实现。
价格、活动规则和上下架状态经常被后台、定时任务和批量导入同时修改。建议增加 version,缓存对象携带版本,消费者拒绝低版本事件。
写入流程建议为:
对于价格展示和最终结算,还要区分“展示价格”和“成交价格”。缓存可以优化展示,但订单创建时应该重新读取可信主数据或调用专用价格服务,不能因为缓存版本设计完善,就把缓存当成结算依据。
权限撤销、账号禁用和安全策略变化不适合依赖长 TTL。可以使用短 TTL、主动广播、版本校验和关键请求回源校验的组合。
如果权限数据被多个服务缓存,建议为权限对象设置全局版本或变更序列。撤销事件必须具备更高优先级和明确的删除语义,消费者不能因为查询不到对象就把它当成临时数据库故障。
这类场景还要关注消息积压告警。缓存落后几秒可能已经超过业务容忍度,监控阈值不能沿用普通详情数据的分钟级标准。
库存、账户余额和支付状态属于高风险数据。即使缓存有版本号,也不能把缓存作为最终扣减、支付确认或资金计算依据。缓存可以用于展示、快速读取和降低非关键查询压力,但核心写入和最终判断应回到具备事务约束的主数据系统。
如果确实需要缓存库存,可以缓存可用量快照,但必须明确其语义:它是“最近一次同步的展示值”,还是“可以直接用于下单的可用库存”。两种语义对应完全不同的架构,不应使用同一个缓存键混淆。
当订单、营销、搜索、报表和通知服务都依赖同一张业务表时,直接在业务代码里逐个调用下游接口会形成紧耦合。此时应考虑统一变更事件,事件中至少包含主键、版本、事件类型和发生时间。
如果变更来源很多,CDC 可以减少业务代码侵入,但需要认真处理数据库日志保留、连接器故障、重复事件、字段脱敏、下游积压和历史重放。CDC 是数据变更捕获方式,不是完整的一致性策略。
不要一上来改表。先盘点哪些表被缓存、缓存键如何组成、谁负责写入、谁负责删除、是否存在读副本、是否存在绕过应用直接写库的脚本。
建议输出一张缓存依赖清单,至少包括:
如果团队连“某个缓存键由哪个服务写入”都无法确认,优先治理归属和命名,而不是讨论复杂的版本算法。
对高风险或高并发表增加 version 和 updated_at,统一所有写路径的更新规则。缓存对象中携带版本,日志中记录数据库版本和缓存版本。
这个阶段的目标不是实现绝对一致,而是让团队第一次能够回答:
如果版本校验已经暴露出消息丢失或投递不稳定,就在业务事务中增加 Outbox。之后建设投递任务、重试策略、死信处理和历史归档。
重试不应无限进行。建议设置最大重试次数和退避时间,超过阈值后进入死信,并通过告警通知负责人。对于可自动修复的数据,可以由补偿任务按主键和版本重新刷新;对于涉及业务规则的数据,则需要人工确认。
上线后仍要持续抽样比对数据库版本和缓存版本。每次发生重大数据库迁移、缓存结构调整或消息系统升级,都应重新做乱序、重复、延迟和失败演练。
我更看重团队是否能在 10 分钟内定位一个缓存旧值,而不是某次压测中缓存命中率提高了几个百分点。因为一致性系统的质量,最终体现为异常发生后能否快速识别、控制和恢复。

适合以下情况:
但要明确,更新时间只能作为辅助依据。如果事件会乱序,或者多个服务需要严格判断新旧,不能停留在 updated_at。
出现以下任意情况,就值得认真考虑版本号:
版本号的代价是所有写路径都必须遵守规则。对于历史数据迁移和批量修复,要提前设计版本初始化、批次版本和事件补发策略。
当“数据库已经更新,但事件可能丢失”会造成明显业务后果时,Outbox 的价值才足够清晰。例如价格更新后必须通知多个缓存节点,或者订单状态变化必须同步给多个下游服务。
如果缓存本身只是低风险加速层,偶尔失效后依靠 TTL 即可恢复,那么引入 Outbox 可能会产生不必要的表维护、投递任务和监控成本。
如果业务要求每次读取都必须是最新值,或者旧值会造成资金、安全和库存风险,那么继续增加 TTL、重试队列和缓存锁,可能只是把问题包装得更复杂。此时应重新定义缓存的职责,让核心判断走主库、事务服务或专用一致性组件。
缓存适合加速读取,不适合替代事实来源。这是表结构设计和缓存一致性方案中最容易被忽略、却最重要的边界。

缓存不同步并不是一个可以靠单条 Redis 命令彻底消灭的问题。只要数据库和缓存属于两个独立存储系统,就会存在事务提交、网络通信、消息投递、并发读写和故障恢复之间的时间窗口。
真正成熟的方案,不是宣称“强一致”“零延迟”或“彻底解决”,而是让团队能够清楚知道:数据库当前是什么版本,缓存当前是什么版本,哪条事件负责同步,事件是否已经消费,失败后由谁补偿。
从表结构开始设计,是因为版本、删除状态、更新时间和事件标识决定了系统有没有事实依据。没有这些字段,缓存消费者只能依赖时间、猜测和最后写入顺序;有了这些字段,系统才有机会拒绝旧事件、阻止旧值覆盖、定位异常并执行补偿。
下一步不建议直接改造所有业务。可以先选一张缓存访问量高、又经常发生旧值投诉的表,完成四件事:补充版本字段,统一写入路径,让缓存携带版本,增加数据库版本与缓存版本对账。验证这四件事能够稳定运行后,再根据消息丢失风险决定是否引入 Outbox、CDC 或更完整的补偿机制。
我的最终判断是:缓存一致性的起点不是“缓存怎么删”,而是“数据库能不能证明哪一次变化才是最新”。产品技术团队如果先把这个问题回答清楚,后面的缓存策略、中间件选型和运维建设,才不会变成一堆彼此割裂的补丁。
我以前总以为缓存不一致主要是删除缓存的代码没写对,直到排查商品详情接口时才发现,表里只有商品名称、价格和状态,根本无法判断缓存里的数据是哪一次更新产生的。想请教一下,表结构到底应该预留哪些字段,才能让缓存同步、排查和补偿真正有依据?
表结构设计不能直接消除缓存不一致,但它决定了系统有没有能力识别新旧数据、表达删除语义,并在异常后完成补偿。我的判断标准不是字段越多越好,而是每个字段都要对应一类实际故障。
对于商品、活动配置、权限规则这类会被缓存的数据,通常优先考虑以下字段: 字段主要作用解决的问题 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。
缓存对象也保存同一个版本号,排查时直接对比数据库版本和缓存版本,而不是只看缓存值是否相同。我不建议一开始给所有表都增加完整的事件字段。低风险、低频更新的内容,主键加更新时间和失效策略可能已经足够;价格、库存、权限等高风险数据,则至少需要版本号、明确的删除状态和可追踪的变更事件。
表结构的价值,在于把原本只能猜测的缓存问题,变成可以比较和定位的问题。
我在多实例服务中遇到过同一条记录几乎同时被两个请求更新的情况,应用日志里的更新时间看起来没有问题,但异步消息到达顺序一变,旧内容还是会覆盖新内容。我想知道,时间戳到底在哪些场景会失效,版本号又应该怎样生成才可靠?
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用于审计和故障定位。
两者不是替代关系,而是职责不同。
我曾经把缓存删除放在数据库更新之后,测试环境看起来完全正常,线上却偶发出现数据库已经是新价格、缓存又被旧价格写回去的情况。后来我才意识到,问题可能不在删除动作本身,而在并发读请求的时间顺序,应该怎样设计这条链路?
更新数据库后删除缓存是常见策略,但它只能降低不一致概率,不能自动保证强一致。最典型的竞态是:请求 A 先从数据库读到旧值;请求 B 更新数据库并删除缓存;请求 A 使用手里的旧值重新写入缓存。删除动作虽然成功,旧值却被后续请求重新放了回来。
可以把故障时间线拆开看: 时间请求 A请求 B结果 T1读取旧版本 103A 持有旧数据 T2数据库更新为版本 104主库已经是新值 T3删除缓存缓存暂时为空 T4写入版本 103旧请求重新污染缓存 更稳妥的做法是让缓存内容携带版本号,写入前比较版本。只有待写入版本大于当前缓存版本时才允许覆盖;
版本相同则做幂等处理,版本更低则直接丢弃。对于不适合在缓存中做版本比较的场景,可以采用延迟双删、互斥回源或短暂逻辑过期,但这些方式都只是缩小窗口,不能替代可靠的变更追踪。事务边界也非常关键。推荐顺序是:先在数据库事务中更新业务字段和版本号,事务提交成功后再发布缓存失效事件。
不要在事务尚未提交时删除缓存,否则其他请求可能在数据库回滚前回源读取旧值,随后把旧值重新写入缓存。上线前至少要验证四类故障:删除缓存失败、旧请求回写、消息重复消费和消息乱序。监控上不要只看缓存命中率,还要记录缓存版本、数据库版本、失效事件延迟和补偿任务结果。
真正可靠的目标不是宣称完全没有不一致,而是让不一致可检测、可重试、可恢复。
我见过一种系统,业务表更新成功后直接发送缓存失效消息,平时运行没有问题,但消息服务短暂故障时,数据库已经变更,缓存却一直没有清理。团队一开始想通过增加重试解决,可是重试记录本身也可能丢失,我该如何判断是否需要 Outbox 或 CDC?
判断是否引入 Outbox 或 CDC,关键不在于技术名词是否先进,而在于一次数据库变更是否必须可靠地通知多个下游。如果只是低风险详情数据,更新后删除缓存、失败重试和定时校验通常已经够用;如果涉及价格、权限、库存或多个订阅系统,直接在业务代码里发消息的风险会迅速上升。
直接发送消息存在一个无法靠普通重试彻底解决的窗口:数据库事务已经提交,但应用在发送消息前崩溃。此时业务数据已经是新值,却没有任何失效事件。若把消息发送放在数据库提交前,又会出现消息已发出、数据库最终回滚的问题。
Outbox 的做法是把业务表更新和变更事件写入同一个数据库事务: 业务表更新为版本 105,同时写入 Outbox 事件 105;事务提交后,后台投递程序再把事件发送给消费者。投递失败可以重试,消费重复则依靠事件 ID 和版本号幂等处理。它的优点是改造边界清晰,适合希望控制业务语义的团队;
代价是需要维护投递任务、失败状态、重试次数和过期事件。CDC 更适合已有多个下游订阅者、数据库变更频繁,或不希望在每个写入接口中重复编写事件逻辑的场景。它能从数据库变更日志中捕获记录变化,但通常会带来更高的运维复杂度,也需要处理字段变更、删除事件、消息重复、乱序、延迟和全量初始化问题。
场景建议理由 普通内容,允许短时间旧值更新后失效加重试成本最低,链路简单 高风险数据,写入入口较少Outbox 加版本号能保证事件与事务一起落库 多个系统订阅大量表变更CDC 加消费幂等减少业务代码侵入 库存扣减、支付状态等核心判断关键读路径回主库不能把普通缓存当作最终事实来源 我的建议是先做一次故障成本评估:统计过去一个月缓存失效失败次数、平均恢复时间、受影响业务和人工补偿成本。
如果问题主要是偶发删除失败,先补齐重试与监控;如果已经出现事件丢失、多个下游各自维护同步逻辑,再考虑 Outbox;只有在订阅规模和变更量确实达到一定程度时,才值得承担 CDC 的运维成本。


读者评论
文章把缓存不一致从“删缓存失败”扩展到版本、事件和补偿链路,视角比较全面。尤其是旧请求回写旧值的案例,对排查线上并发问题很有参考价值。
版本号和更新时间的区别讲得比较清楚,但实际落地时还需要说明版本递增由数据库触发、应用控制还是专门服务负责,否则并发场景下仍可能产生疑问。
Outbox方案能降低数据库提交成功但消息发送失败的风险,不过文章也提醒了它不是万能方案,这一点比较客观。后续还可以补充重复投递和补偿任务的实现细节。
文中强调日志记录版本号,而不只是记录缓存命中或删除成功,这个建议很实用。没有版本信息时,很多故障确实只能看到操作结果,难以还原具体时序。
文章适合产品技术团队做方案评审时参考,但库存、支付、权限等高风险场景不能只依赖缓存版本控制,仍应结合强一致查询或更严格的业务校验。