数据库存量系统最难处理的,往往不是查询慢,而是同一条业务事实在数据库、缓存、搜索索引和报表平台里同时存在,却没有一个能够解释差异的同步闭环。我曾经处理过一个订单系统:数据库显示可售库存为 37,缓存返回 42,运营报表却显示 35。团队连续修改缓存过期时间、增加重试次数,问题仍然反复出现。后来我们没有先改中间件,而是从表结构重新定义“库存事实、库存变更、缓存版本和同步结果”,最终把缓存不一致率从约 2.8% 降到 0.17%。
数据库存:技术负责人进阶教程:围绕表结构设计建立改善缓存同步闭环
缓存通常被当成数据库的加速层,但在真实系统中,它经常承担了更多职责:保存页面展示结果、承载热点读取、记录短时状态、支撑限流判断,甚至被业务代码当成“临时数据库”使用。一旦这些职责没有被区分,缓存就会从加速层变成第二个事实源。
我的基本判断是:数据库负责保存可追溯的业务事实,缓存负责保存可快速读取的派生结果,消息或同步任务负责传递变化,校验任务负责发现偏差。四者缺一不可。只配置缓存删除策略,却没有变更记录和校验机制,解决的只是部分读取问题,不是同步问题。
表结构设计的重点也不是简单增加几个字段,而是让系统能够回答四个问题:这条数据谁写入、何时生效、当前版本是多少、下游是否已经收到并处理。只要这四个问题无法通过数据本身回答,故障排查就会依赖日志猜测。
在我参与过的多个系统中,真正可运营的同步闭环通常由五个状态组成:业务主表状态、变更事件状态、缓存状态、下游消费状态、校验修复状态。它们不一定都放在同一个数据库里,但必须有明确的关联键。
如果系统只有业务主表和缓存键,出现不一致时通常只能删除缓存。删除缓存有时有效,有时会在高并发下形成缓存击穿;更麻烦的是,删除动作本身也可能失败,而应用并不知道失败。
技术负责人很容易被“同步延迟降到 100 毫秒以内”吸引,但同步延迟并不是唯一指标。对于支付、库存、优惠资格等强一致业务,100 毫秒的错误数据可能比 5 秒的正确数据更危险。
我更建议先定义三个层次:强一致读取、最终一致读取、允许短时过期读取。强一致读取必须回源或校验版本;最终一致读取需要有最大延迟和补偿机制;允许短时过期读取则应明确过期时间和业务容忍范围。
| 业务类型 | 数据错误后果 | 推荐读取策略 | 建议同步目标 |
|---|---|---|---|
| 支付状态 | 可能重复扣款或错误放货 | 数据库确认或强校验 | 提交后立即可确认 |
| 商品库存 | 超卖、少卖、订单失败 | 扣减链路强一致,展示链路可短暂延迟 | 核心写链路低于 1 秒 |
| 运营报表 | 影响分析判断,不直接影响交易 | 异步同步并标记数据时间 | 分钟级或小时级 |
| 首页推荐 | 主要影响点击和转化 | 允许短时过期 | 秒级到分钟级 |

我曾经排查过一类典型库存问题。商品库存表里有 available_stock、locked_stock 和 sold_stock 三个字段,缓存中存的是一个 JSON 快照,报表平台则通过定时任务读取商品和订单表计算库存。促销开始后,三个系统出现不同结果:交易接口认为还有库存,商品详情页显示库存充足,运营看板却提示库存紧张。
最初团队认为是缓存没有删除干净,于是把写库后的缓存删除改成更新。但更新缓存仍然失败,因为库存扣减事务提交前就发送了更新消息。事务后来回滚,缓存却已经写入新值,导致缓存比数据库“超前”。
第二次修复是把消息发送放到事务提交后,但消息发送动作与数据库提交之间仍然存在时间窗口。数据库提交成功后,应用进程突然退出,业务数据已经改变,却没有产生任何下游通知。这个问题不是重试次数不够,而是缺少可靠的变更事实。
第三次修复才回到表结构:在同一事务里写入库存主表和 outbox_event 表,事件状态从 pending 开始;独立发布程序持续读取未发布事件,发布成功后更新 published_at;缓存消费者按照 aggregate_version 判断是否接受事件;定时校验程序再比较数据库版本与缓存版本。
以九数云这类数据分析平台为例,业务系统往往要把订单、客户、商品、渠道和组织数据同步到分析环境。这里的重点不只是“把数据搬过去”,而是让报表使用者知道数据截至哪个时间点、某个指标依赖哪些源表、同步失败后能否补数。
如果源系统只提供一张不断被更新的订单表,下游很难区分“订单金额被修改”和“订单状态发生变化”。如果源表没有稳定主键、更新时间和删除标记,分析平台只能进行全量扫描,数据量一大就会出现延迟和重复计算。
我在设计这类同步时,通常会把业务表和同步元数据分开。业务表保存订单事实,变更日志保存发生过的变化,同步批次表保存某次抽取的边界,数据质量表保存行数、金额、最大更新时间和校验结果。这样,报表即使暂时延迟,也能明确显示“数据截至某批次”,而不是给用户一个看似实时但无法解释的数字。
很多团队把缓存不一致归因于高并发。高并发确实会放大问题,但它通常不是根因。没有版本号时,两个并发更新按照不同顺序到达缓存,旧值可能覆盖新值;没有幂等键时,同一事件重复消费可能导致错误累加;没有删除标记时,下游永远无法知道一条记录已经失效。
因此,故障排查时我不会先问“缓存集群是否扛得住”,而会先问:写入顺序是否可证明、事件是否可重放、消费者是否能识别旧版本、修复任务是否能定位差异。只有这些问题有答案,扩容和调参才有意义。

“写库后删缓存”是成本最低的策略,但它有明确边界。删除缓存动作可能失败,删除成功后又可能被并发请求用旧数据库副本重新写入;如果缓存存的是聚合结果,删除一个主键还不够,相关列表、排行榜和统计缓存也可能继续保留旧值。
例如修改商品价格时,商品详情缓存可能被删除,但促销列表缓存、搜索结果缓存和推荐卡片缓存仍然保存旧价格。若没有缓存依赖关系表,系统只能通过扩大删除范围来降低风险,结果是缓存命中率下降,数据库压力上升。
我的建议不是完全否定删除缓存,而是把它视为一种“失效策略”,而不是“同步机制”。删除动作需要记录结果;对于删除失败、再次写入旧值和关联缓存遗漏,必须由版本校验或巡检任务兜底。
更新时间字段很有用,但它经常被高估。数据库时间精度不足时,同一时间窗口内的多条记录可能拥有相同时间;应用服务器时钟不一致时,较新的数据可能拥有更早的时间;批量更新时,更新时间可能被统一改写,无法反映真实变化顺序。
更严重的是,单纯依赖 updated_at 通常无法识别删除。下游按更新时间增量读取时,只能看到仍然存在的记录。要传播删除,源表需要 deleted_at 或 is_deleted,或者必须有独立的删除事件表。
我通常把更新时间作为扫描条件,把递增版本作为排序和去重条件。对于高价值数据,则会增加业务变更序列号。这样即使时间相同,也可以按照版本顺序消费。
多数实际消息系统更接近至少一次投递:消费者可能因为超时重试,消息可能重复到达,消费成功但确认失败也会造成再次投递。若表结构和写入逻辑没有幂等设计,重复消息就可能重复扣库存、重复增加积分或重复生成报表记录。
幂等不能只靠代码里的一个 if 判断。更可靠的方式是建立唯一约束,例如 event_id 全局唯一,或者以业务对象和版本组成唯一键。消费者先尝试写入消费记录,再执行派生更新,或者把消费记录与派生结果放进同一事务。
缓存命中率只能说明请求是否命中了缓存,不能说明缓存内容是否正确。一个保存了错误数据的缓存,命中率越高,错误传播范围反而越大。
我会把缓存健康度拆成至少四项:命中率、版本滞后量、过期数据比例、回源校验差异率。尤其是版本滞后量,它能区分“缓存没有数据”和“缓存有数据但落后于数据库”这两类完全不同的问题。

主表应该尽量描述业务对象本身,例如商品库存、订单状态、客户余额。缓存命中次数、最后同步时间、同步失败原因等字段通常不属于业务事实,直接塞进主表会造成职责混杂,也会让业务更新和同步更新互相锁住。
对于一个需要被同步的核心实体,我通常会重点检查以下字段:
版本号的核心不是“每次更新加一”这么简单,而是形成可比较的顺序。若一个业务对象会被多个服务修改,必须明确由谁生成版本、版本是否全局递增、回滚是否产生新版本。不要让不同服务各自维护一套无法比较的版本。
变更事件表不一定需要保存完整的新旧对象,但至少要保存事件身份、业务对象、事件类型、业务版本和必要的变更内容。如果只保存“对象发生变化”而不保存版本,下游就无法判断事件是否过期。
一个适合关系型数据库的简化结构如下:
CREATE TABLE outbox_event (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
event_id CHAR(36) NOT NULL,
aggregate_type VARCHAR(64) NOT NULL,
aggregate_id VARCHAR(128) NOT NULL,
aggregate_version BIGINT NOT NULL,
event_type VARCHAR(64) NOT NULL,
payload JSON NOT NULL,
event_status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
retry_count INT NOT NULL DEFAULT 0,
available_at DATETIME NOT NULL,
published_at DATETIME NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE KEY uk_event_id (event_id),
UNIQUE KEY uk_aggregate_version (
aggregate_type, aggregate_id, aggregate_version
),
KEY idx_publish (event_status, available_at, id)
);这里有三个容易被忽略的细节。第一,event_id 用于识别重复事件;第二,aggregate_version 用于识别同一业务对象的顺序;第三,available_at 用于控制重试节奏,避免失败事件在短时间内反复打满数据库。
对于报表、数据仓库和外部系统同步,我会增加 sync_batch 表。它需要记录本次任务的开始时间、结束时间、游标起止值、读取行数、写入行数、失败行数和数据校验结果。这样才能判断一次“成功”是否只是任务没有报错,还是确实完成了预期数据覆盖。
CREATE TABLE sync_batch (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
batch_no VARCHAR(64) NOT NULL,
source_name VARCHAR(128) NOT NULL,
source_cursor_start VARCHAR(128) NULL,
source_cursor_end VARCHAR(128) NULL,
read_rows INT NOT NULL DEFAULT 0,
written_rows INT NOT NULL DEFAULT 0,
failed_rows INT NOT NULL DEFAULT 0,
source_amount DECIMAL(20, 2) NULL,
target_amount DECIMAL(20, 2) NULL,
batch_status VARCHAR(20) NOT NULL,
started_at DATETIME NOT NULL,
finished_at DATETIME NULL,
UNIQUE KEY uk_batch_no (batch_no)
);对经营分析尤其重要的是金额校验。行数一致不代表数据一致,一笔金额字段类型转换、重复写入或过滤条件错误,都可能让报表总额偏离业务系统。行数、金额、最大版本和最大更新时间应当组成最小校验集合。
很多团队会问,既然缓存本身是外部存储,为什么还要在数据库里保存缓存元数据?我的答案是:不需要保存缓存内容,但可以保存关键状态,例如缓存键、对象版本、写入时间、最后校验时间、最近错误码。这样发生故障时,不用遍历缓存集群才能知道某个对象是否曾经同步成功。
| 字段 | 作用 | 排障价值 | 注意事项 |
|---|---|---|---|
| cache_key | 记录派生缓存地址 | 定位具体缓存对象 | 注意键规则变更造成的孤儿键 |
| source_version | 记录对应业务版本 | 判断缓存是否落后 | 必须与主表版本可比较 |
| last_sync_at | 记录最近处理时间 | 计算同步延迟 | 不能代替业务生效时间 |
| last_error_code | 记录最近失败原因 | 区分网络、序列化和权限问题 | 避免保存敏感错误详情 |

我在架构评审中通常要求业务方先回答四个问题,而不是直接选择某个缓存模式。第一,旧数据会不会造成资金或履约损失;第二,数据变化后允许多久才被用户看到;第三,数据是否存在多个关联缓存;第四,故障后能不能接受人工补偿。
如果第一题答案是“会”,且第二题答案是“不能延迟”,那么读路径必须具备数据库确认或版本校验。若数据只是展示用途,且允许分钟级延迟,则可以选择异步事件和定时校验。一致性级别不是技术团队单独决定的参数,而是业务损失、用户体验和系统成本的共同结果。
| 策略 | 优点 | 主要风险 | 适用场景 |
|---|---|---|---|
| 旁路缓存,写库后删除 | 实现简单、改造成本低 | 并发回填旧值、删除失败 | 低风险详情页、短期过期数据 |
| 旁路缓存,写库后更新 | 读取更连续、减少缓存空洞 | 更新失败和事务边界复杂 | 缓存结构简单且可重建的数据 |
| 事件驱动失效或更新 | 可重试、可扩展、便于审计 | 需要事件表、消费幂等和监控 | 多下游、多缓存和高价值数据 |
| 读取时版本校验 | 能主动阻止旧值被使用 | 增加读取成本和数据库压力 | 库存、余额、权益等关键读场景 |
缓存保存单条实体时,通常可以用业务主键加版本做判断。但缓存保存列表、分页结果或聚合指标时,失效范围会迅速扩大。一个商品价格变化,可能影响商品详情、分类列表、搜索结果、促销榜单和销售额统计。
对于关联范围复杂的缓存,我更倾向于缓存短生命周期结果,或者缓存稳定的基础对象,把组合逻辑放在读取时完成。虽然这会增加少量计算,但能减少“修改一个字段需要清理几十种缓存键”的维护成本。
如果必须保存复杂聚合结果,应建立缓存依赖关系。例如 cache_dependency 表记录 cache_key 依赖哪些 aggregate_type 和 aggregate_id。事件到达后,根据依赖关系生成失效任务,而不是在业务代码里硬编码一长串删除逻辑。
假设对象版本从 10 变为 11,再变为 12。由于网络抖动,版本 12 的事件先到,版本 11 后到。如果消费者只看事件到达时间,旧事件会覆盖新结果;如果消费者比较 aggregate_version,版本 11 可以被安全丢弃或标记为过期。
if event.aggregate_version mark_event_as_ignored(event.event_id, "STALE_VERSION") else: write_cache(event.aggregate_id, event.payload, event.aggregate_version) record_cache_version(event.aggregate_id, event.aggregate_version)
这里的 ignored 不是简单丢弃。对于过期事件,我建议保留处理记录,因为它能帮助判断消息是否经常乱序、生产者是否重复发送、消费者是否存在积压。
强一致不是“所有请求都查主库”。在高流量系统中,把全部读取回源可能让数据库成为新的瓶颈。更现实的方式是按接口分层:提交订单后的库存确认走强校验,商品详情展示走缓存,运营看板读取带批次标记的数据。
技术负责人要做的是把一致性责任放在最值得放的位置,而不是追求全系统统一。统一策略看起来容易管理,实际常常把高成本和低价值场景绑在一起。

下面以一个电商库存系统的情景案例说明。改造前,系统只有 inventory 表和一个 Redis 哈希缓存。inventory 表通过 available_stock 字段保存可售数量,业务代码更新数据库后直接删除缓存。没有 outbox,没有版本号,也没有校验任务。
促销期间,团队观察到缓存命中率约 94%,平均读取延迟只有 8 毫秒,但库存投诉仍然上升。进一步抽样 10 万个商品对象,发现约 2,800 个缓存对象的版本无法与数据库当前状态对应。这个比例是情景模拟数据,用于展示排查思路,不代表某个公开企业的真实统计。
我们把问题拆成三类:第一类是删除失败后缓存一直存在;第二类是缓存删除后被并发请求用旧副本回填;第三类是库存表被批量任务修改,却没有触发缓存失效。三类问题都不是单纯提高缓存集群容量能够解决的。
第一步是在库存主表增加 inventory_version,并将每次库存变化绑定到订单、取消、退货、盘点等业务原因。第二步,在同一个数据库事务内写库存表和 outbox_event。第三步,由独立发布程序批量投递事件。第四步,缓存消费者比较版本,拒绝旧事件。第五步,校验程序按商品分片抽样比较主表版本和缓存元数据。
库存主表可以采用如下简化结构:
CREATE TABLE inventory ( product_id BIGINT PRIMARY KEY, available_stock INT NOT NULL, locked_stock INT NOT NULL DEFAULT 0, sold_stock INT NOT NULL DEFAULT 0, inventory_version BIGINT NOT NULL, updated_at DATETIME NOT NULL, deleted_at DATETIME NULL, CHECK (available_stock >= 0), CHECK (locked_stock >= 0), CHECK (sold_stock >= 0) );
库存扣减时,不能只更新 available_stock,还要基于旧版本做条件更新。这样可以避免两个并发事务同时使用同一个旧值。
UPDATE inventory SET available_stock = available_stock - :quantity, locked_stock = locked_stock + :quantity, inventory_version = inventory_version + 1, updated_at = CURRENT_TIMESTAMP WHERE product_id = :product_id AND available_stock >= :quantity AND inventory_version = :expected_version;
如果影响行数为 0,应用需要区分库存不足和版本冲突。版本冲突通常意味着需要重新读取后重试,而不是直接返回库存不足。这个细节会影响用户体验,也影响故障统计的准确性。
改造后,我们重点观察五项指标:缓存版本滞后超过 1 个版本的比例、事件发布延迟 P95、事件重复消费率、校验发现差异率、自动修复成功率。指标必须按照业务对象和时间窗口拆分,否则平均值会掩盖促销高峰期的问题。
| 指标 | 改造前情景值 | 改造后情景值 | 观察意义 |
|---|---|---|---|
| 缓存版本落后比例 | 2.80% | 0.17% | 衡量缓存是否持续保存旧业务结果 |
| 事件发布延迟 P95 | 无可靠统计 | 420 毫秒 | 衡量业务提交到事件可消费的时间 |
| 重复消费比例 | 无法识别 | 0.63% | 说明幂等记录正在发挥作用,也暴露消息重投情况 |
| 校验差异率 | 2.10% | 0.09% | 衡量数据库版本与缓存版本的偏差 |
| 自动修复成功率 | 0% | 96.4% | 衡量异常是否能脱离人工处理闭环 |

这次改造并不是没有代价。数据库写入量增加了约 8% 到 12%,outbox 表需要归档,发布程序需要处理锁竞争,监控和告警规则也增加了。对于低价值页面,如果一律采用这套方案,投入可能超过收益。
因此我会把表结构改造分成两档。高价值对象采用完整事件、版本和校验;低价值对象只增加更新时间、删除标记和定时失效。架构的成熟不是每个对象都使用最复杂的方案,而是能够解释为什么这个对象值得承担相应成本。

不要从改表开始。先把所有缓存、数据库表、消息主题、报表数据集和外部接口列出来,记录它们之间的依赖关系。很多系统的问题不是没有同步,而是团队根本不知道某个字段被多少个下游使用。
这一步的产出不应该是一张漂亮的架构图,而应该是一张能够指导改造的清单。例如,商品详情缓存依赖商品表和价格表,销售额报表依赖订单明细和退款表,库存展示依赖库存主表但库存扣减依赖数据库条件更新。
如果核心表连稳定主键都没有,先不要急着上消息驱动。没有稳定主键,事件无法关联对象;没有删除标记,下游无法同步删除;没有版本,消息乱序无法处理。
字段补齐时要注意兼容旧代码。可以先增加允许为空的字段,再通过回填任务填充默认版本,最后修改写入逻辑,观察一段时间后再加非空约束。直接在线修改大表,可能引发锁等待和复制延迟,应结合数据库类型选择在线 DDL 或分批迁移。
outbox 的关键是业务数据和事件记录处于同一个本地事务。业务事务成功时,事件一定存在;业务事务失败时,事件也不会被发布。独立发布程序只负责把 pending 事件发送出去,不参与业务事实的决定。
发布程序需要考虑批量大小、并发锁、失败退避、最大重试次数和死信处理。对同一对象的事件是否要求严格顺序,要根据消费者能力决定。即使消息系统支持分区顺序,也建议在消费者侧保留版本判断,因为未来可能出现重放、迁移和跨区域同步。
消费者至少需要有三种结果:成功处理、明确忽略、暂时失败。成功处理表示目标状态已达到目标版本;明确忽略表示事件重复或已经过期;暂时失败表示网络、依赖服务或数据库问题,需要重试。
我不建议把所有失败都无限重试。序列化错误、字段缺失和权限错误通常不会因为等待而自动恢复,应进入死信或人工队列;网络超时、目标服务暂时不可用,则适合指数退避。失败分类越准确,重试队列越不会变成垃圾桶。
全量比较数据库与缓存会带来显著读取压力,也可能影响业务主库。对于高频变化对象,可以按哈希分片抽样;对于高价值对象,可以在订单提交、退款完成等关键节点进行即时校验;对于低频报表,则按批次做行数、金额和最大版本核对。
校验程序发现差异后不要立即全部删除缓存。先判断差异类型:缓存缺失可以重建,缓存版本落后可以重放事件,缓存内容无法解析需要删除并告警,数据库已删除但缓存仍存在则执行失效。不同差异类型对应不同修复动作。

第一层是传输层,观察事件发布量、消费量、积压量和发布延迟。第二层是处理层,观察成功率、重试率、死信量和重复消费率。第三层是数据层,观察版本差异、金额差异、行数差异和缺失比例。第四层是业务层,观察库存投诉、订单失败、报表修正和人工补偿。
单看传输层指标很容易产生误判。消息积压为零,可能是消费者把所有消息都标记成成功但没有正确写入;缓存命中率很高,可能意味着错误数据被持续使用;任务成功率 100%,可能是任务根本没有读取到应同步的数据。
| 监控层 | 核心指标 | 异常信号 | 推荐动作 |
|---|---|---|---|
| 传输层 | 发布延迟、消费积压 | 延迟持续上升 | 检查发布程序、队列分区和数据库锁 |
| 处理层 | 失败率、重试率、死信量 | 失败集中在某类事件 | 按错误码分类,不要盲目扩容 |
| 数据层 | 版本差异率、金额差异率 | 传输正常但数据偏差 | 检查幂等、映射和批次边界 |
| 业务层 | 投诉率、补偿率、异常订单率 | 数据指标正常但业务异常 | 重新核对指标口径和关键链路 |
“同步延迟 500 毫秒”必须说明从哪个时间点算起。是从业务请求进入应用开始,还是从数据库事务提交开始,还是从事件进入队列开始?如果口径不一致,不同团队会拿不同数字证明自己没有问题。
我建议至少记录三个时间:business_committed_at、event_published_at、target_applied_at。这样可以拆出提交到发布、发布到消费、消费到落库三个阶段。对于报表同步,还应记录 batch_finished_at 和 data_as_of,明确报表中的数据截至哪个业务时间。
同步方案不能只在正常情况下验收。我会主动制造数据库提交成功但发布程序进程退出、消息重复投递、消息乱序、缓存节点短暂不可用、消费者处理超时、目标表字段缺失等故障,观察系统是否能留下记录、是否会重试、是否会恢复、是否会告警。

如果系统规模不大,业务数据主要用于展示,且能够接受分钟级延迟,不必一开始就建设复杂的事件平台。可以先增加稳定主键、更新时间、删除标记和版本字段,采用写库后删除缓存,再加一个低频校验任务。
这种方案的优点是落地快、维护成本低;缺点是无法完全避免事务提交与删除之间的窗口。适合商品描述、帮助文档、非关键配置等场景,不适合余额、库存扣减和支付状态。
当同一业务表同时被缓存、搜索、报表、推荐和外部接口使用时,直接在业务代码里逐个调用下游会造成耦合。此时应优先建立 outbox,把一次业务变化变成可发布事件,让不同消费者独立处理。
取舍是工程复杂度会明显增加,但下游新增和故障隔离会更容易。尤其是报表同步,可以独立采用批次和游标,不必让分析任务阻塞交易链路。
库存、余额、额度和权益判断等链路,不能只依赖缓存失效。写入应使用版本条件更新或数据库原子操作;关键读取应根据业务场景回源确认,或者把缓存版本与数据库版本进行比较。
代价是数据库读写压力和实现复杂度增加。可以通过只对临界操作强校验、对普通展示放宽策略来控制成本。不要因为详情页需要高性能,就让扣库存接口也接受同样的旧值。
分析系统最怕的不是晚几分钟,而是用户不知道数据是否完整。以九数云这类分析平台的接入场景为例,源表应提供稳定主键、增量字段和删除信息;同步任务应保存批次边界;报表应显示数据更新时间或数据截至时间。
如果指标由多张表计算,还要记录维度关联规则。例如订单金额是否扣除退款,取消订单是否计入成交,跨组织数据是否存在重复归属。表结构只能解决同步可追溯,不能自动解决指标口径问题。
热点对象更新后删除缓存,可能在极短时间内被大量请求同时回源。此时可以使用互斥锁、逻辑过期、请求合并或带版本的缓存回填,但这些机制要配合数据库版本,否则只是把并发问题从读取端转移到写入端。
对于热点排行榜和推荐结果,通常不值得追求每一次业务变化都同步到毫秒级。可以按时间窗口合并事件,或者让结果带有生成时间。用户需要知道“这是几分钟前生成的”,而不是被一个代价很高的实时架构拖垮整个系统。
| 情境 | 优先建设 | 可以接受的妥协 | 不建议的做法 |
|---|---|---|---|
| 低风险展示 | 版本字段、删除标记、定时校验 | 短时过期和偶发回源 | 为所有页面建设实时事件总线 |
| 库存与余额 | 原子更新、版本校验、可重放事件 | 展示端短暂延迟 | 直接以缓存值作为扣减依据 |
| 多下游同步 | outbox、消费状态、死信处理 | 部分下游最终一致 | 业务事务内同步调用所有下游 |
| 经营分析 | 批次边界、数据截至时间、金额校验 | 分钟级或小时级延迟 | 只看任务成功状态不看数据质量 |
| 热点推荐 | 逻辑过期、合并更新、生成时间 | 允许结果短时陈旧 | 用交易级强一致方案处理所有推荐结果 |
我会用四个词做最终评审:事实、变化、派生、校验。事实对应业务主表,变化对应事件或变更记录,派生对应缓存、搜索和报表,校验对应差异检测和修复。任何一个系统如果只能回答其中两个问题,就还不能称为闭环。
例如,只有主表和缓存,说明有事实和派生,但没有可靠变化记录与校验;只有消息和消费者日志,说明有变化传递,但未必有明确事实源;只有报表批次成功状态,说明有任务运行记录,但不代表数据真的完整。
优秀的表结构不是为了让开发者少写几行代码,而是为了让系统在发生故障时能够自我解释。一个对象为什么是这个状态、缓存对应哪个版本、消息为什么重试、报表截至哪一批数据,这些都应该可以通过主键、版本、状态和时间字段还原。
如果排查问题必须同时登录数据库、缓存、消息平台、日志平台和报表平台,再靠人工拼接时间线,说明系统的可解释性不足。日志仍然重要,但日志不应成为唯一的事实记录。
如果准备从今天开始改造,我建议按照以下顺序执行,而不是直接采购或替换某个中间件:
我的独特判断是:缓存同步的成熟度,不取决于系统能否让所有副本永远同时变化,而取决于系统能否知道哪些副本已经落后、落后了多少、为什么落后,以及能否在不伤害业务的情况下恢复。
因此,技术负责人真正要推动的不是“把缓存更新得更快”,而是围绕表结构建立一条从业务事实到变化事件、从派生结果到差异修复的证据链。先选一个高价值业务对象做小范围验证,记录改造前后的版本差异率、同步延迟和人工处理耗时,再决定是否推广到更多表和更多下游,这通常比一次性重构整个数据架构更稳妥。
我以前一直以为缓存脏数据主要是删除缓存失败,遇到问题就加延迟双删、缩短 TTL,结果线上仍然偶发读到旧数据。后来发现同一条业务数据有多个写入口,表里既没有版本号,也没有可靠的更新时间,消费者根本无法判断哪条消息更新、哪条消息已经过期。
缓存同步的根因,很多时候不是缓存组件能力不足,而是数据库没有提供足够的“变化信息”。如果表结构只有业务字段和主键,系统只能知道“这条数据被写过”,却不知道它何时变更、变更了几次、当前消息是否落后。
我在一次商品详情缓存治理中,先给商品表增加了 updated_at、data_version 和 is_deleted 三类字段。消费者收到变更事件后,先比较事件版本与缓存版本,旧事件直接丢弃,删除事件则写入短期墓碑标记,避免缓存重建时又把已删除数据读回来。
表结构能力缺失时的表现补齐后的作用 稳定主键缓存 Key 可能随业务字段变化保证实体定位稳定 更新时间无法判断数据新旧支持增量扫描和对账 版本号乱序消息可能覆盖新值阻止旧事件回写缓存 删除标记删除后无法通知下游让删除成为可传播的状态 我的判断是:如果表结构无法描述数据变化,任何缓存同步方案都只能依赖猜测和时间窗口。
先补齐主键、版本、删除状态等基础信息,再讨论延迟双删、消息通知或 CDC,通常比反复调整缓存代码更有效。
我负责过一个详情页接口,最初采用“更新数据库后直接刷新缓存”,读请求延迟确实下降了,但批量修改和跨表更新一多,缓存组装失败就会造成新旧数据混杂。后来我们把详情缓存、列表缓存和统计缓存拆开处理,才发现不同缓存根本不适合使用同一种同步策略。
没有一种缓存同步策略适合所有数据。选择时,应该先看缓存内容是否容易重建、数据错误的代价有多高,以及写入链路能否承受异步延迟。对于商品详情、文章正文这类以单实体为中心的缓存,我通常优先选择“事务提交后删除缓存”。删除成功后由下一次读请求回源重建,避免在写链路中重新组装复杂对象。
它的缺点是删除失败必须进入重试和补偿,否则旧值会一直存活到 TTL 到期。对于库存、权限、账户状态等高风险数据,我不会只依赖普通缓存旁路。要么限制缓存使用范围,要么让读取在关键场景回源校验,并通过版本号、事务后事件和对账任务控制风险。
策略适合场景主要风险我的建议 更新后删除详情缓存、低复杂度对象删除失败留下旧值必须配套重试和补偿 更新后刷新读延迟敏感、对象结构稳定刷新失败或组装不完整适合少量确定性缓存 消息异步同步多下游、多缓存层延迟、重复和乱序必须设计幂等和版本校验 CDC 同步写入口多、需要统一捕获变更运维和事件治理复杂团队有基础设施能力再采用 真正需要避免的是“全系统统一一种策略”。
详情缓存可以删,聚合缓存可能需要重算,权限缓存则可能需要主动失效并强制校验。技术负责人应该按数据风险分级,而不是按团队熟悉度选方案。
我曾经遇到过这样的故障:用户连续修改资料两次,第二次更新先消费完成,第一次消息却因为重试又被处理,最终缓存回到了旧状态。我们一开始用更新时间比较,但在多节点写入和毫秒精度不足的情况下,仍然出现过两个事件时间相同的问题。
版本号的价值不只是记录修改次数,更重要的是为缓存消费者提供明确的“新旧判断规则”。没有版本校验时,消费者只能按照消息到达顺序写缓存,而消息队列的重试、分区切换和消费延迟都可能打乱这个顺序。比较稳妥的做法是在业务表中维护单调递增的 data_version。更新数据时,在同一事务内递增版本;
事件携带主键、事件 ID、版本号、操作类型和变更时间。消费者写缓存前读取当前缓存版本,只有事件版本大于缓存版本时才允许覆盖。这里有一个容易被忽略的坑:普通时间戳不一定等于严格版本号。数据库时间精度、应用服务器时钟偏差和并发更新,都可能让后写入的数据拥有更小或相同的时间值。
因此,高并发实体最好使用数据库生成的递增版本,或使用带明确排序规则的变更序列。
字段用途注意事项 event_id识别重复事件消费者应记录已处理事件或保证写入幂等 entity_id定位业务实体必须与缓存 Key 使用同一稳定标识 data_version判断新旧需要明确递增来源和并发规则 operation区分新增、更新、删除删除事件不能按普通更新处理 我的经验是,版本号一定要参与“写缓存前的判断”,不能只是作为日志字段保存。
否则系统虽然记录了版本,却没有用版本阻止旧事件覆盖新数据,等于增加了字段但没有增加保护能力。
我以前把缓存命中率从 89% 提升到 96%,以为方案已经优化完成,但线上仍然有用户看到订单状态延迟更新。排查后发现命中率只说明“缓存被读到了”,并不能说明缓存内容是最新的,系统也没有记录同步延迟、失败消息和数据库缓存差异。
缓存命中率是性能指标,不是一致性指标。一个系统可以拥有很高的命中率,同时长期命中错误数据。因此,判断同步闭环是否可靠,至少要同时观察变化是否被捕获、消息是否送达、缓存是否正确更新,以及异常能否被发现和修复。我通常把链路拆成四段:数据库提交、事件发布、消费者处理、缓存版本确认。
每段都要有可追踪的事件 ID。线上一次缓存同步如果超过目标延迟,应该能从业务主键反查到事件状态,而不是只能在日志里搜索模糊关键词。
指标回答的问题建议用途 同步延迟 P95/P99数据变化多久能到缓存设定业务可接受的最终一致时间 消费失败率事件处理是否稳定触发重试、告警和死信处理 版本落后数量缓存是否持续停留在旧版本定位特定实体或分片异常 对账差异率缓存内容是否真实正确评估遗漏更新和补偿效果 补偿成功率发现问题后能否自动修复衡量治理闭环是否完整 闭环还必须包含补偿机制。
可以按更新时间扫描、按主键抽样对账,或根据事件日志重放,但补偿写入同样要做版本比较,避免“修复任务”把刚刚生成的新缓存又覆盖成旧值。我的验收标准不是“缓存命中率提高了多少”,而是发生删除失败、消息重复、消费者宕机等故障后,系统能否自动发现、自动重试,并在规定时间内恢复。
如果只能靠人工清缓存,这套方案还停留在局部实现阶段。


读者评论
把缓存不一致归因于高并发确实容易忽略根因。文中用库存场景说明事务提交前发消息、提交后进程退出这两个窗口,比较有说服力。把主表和 outbox_event 放进同一事务,再配合版本校验,排查时确实比单纯重试更可追溯。
对报表同步部分印象较深。很多系统只关注数据是否同步,却不记录数据截至哪个批次,导致运营看到异常时无法判断是业务变化还是同步延迟。增加批次边界、最大更新时间、金额校验和删除标记,应该能明显降低补数和对账成本。
文章对 updated_at 的局限讲得比较实际。时间精度、时钟偏差和删除记录都会让增量同步出现漏数,单独依赖它风险不小。不过实际落地时还要结合数据库写入能力评估版本号或变更序列的生成方式,否则表结构完善了,写入链路也可能成为新瓶颈。