推荐方案是更新数据库后删除缓存并设过期时间兜底:先UPDATE MySQL,再DEL Redis,缓存必须EXPIRE(如EX 300),读miss时加updated_at校验,高一致场景直查库,优先用binlog监听(如canal)实现解耦、幂等的缓存清理。

缓存失效时数据不一致的典型表现
在 MySQL + Redis 架构中,并发写入常导致「缓存未更新但数据库已变」,比如用户 A 更新订单状态为 paid,写库成功,删缓存也成功;但用户 B 几乎同时读缓存,拿到旧的 unpaid 状态——这不是缓存没删,而是删完之后、新值还没写入缓存前的窗口期被读到了脏数据。
先删缓存再更新数据库?不行,有竞态
看似合理的顺序,在并发下反而更危险:两个请求都走「删缓存 → 更新 DB」,第二个请求的 DB 写入完成后,缓存仍是空的,后续读请求会回源查到第二个请求写入的新值,然后写入缓存——这本身没问题;但若此时第一个请求的缓存重建(比如它带了延迟双删逻辑)把旧值又刷进去了,就彻底错乱。
-
DEL cache:order:123(请求 A) -
UPDATE orders SET status='paid' WHERE id=123(请求 A) -
DEL cache:order:123(请求 B) -
UPDATE orders SET status='shipped' WHERE id=123(请求 B) -
SET cache:order:123 {status:'paid'}(请求 A 的延迟重建,覆盖了正确的 shipped)
推荐方案:更新数据库后删除缓存 + 设置过期时间兜底
核心是放弃「强一致」幻想,接受短暂不一致,用「最终一致 + 降低风险」组合拳。关键点不在删缓存时机多精巧,而在如何让错误窗口更小、更可测。
- 所有写操作统一走「先更新 MySQL,再
DEL缓存」,不搞延迟双删 - 缓存必须设
EXPIRE,比如SET cache:order:123 {...} EX 300(5 分钟),避免永久脏数据 - 读请求遇到缓存 miss,查库后写入缓存时,加一层「版本号 or 时间戳校验」:只当 DB 中的
updated_at比缓存里记录的更新,才允许写入(需业务表有该字段) - 对一致性要求极高的场景(如支付单状态),读请求直接查库,绕过缓存——用开关控制,不是所有读都缓存
UPDATE orders SET status = 'paid', updated_at = NOW() WHERE id = 123 AND version = 5;
配合应用层检查返回影响行数是否为 1,失败则重试或告警。
监听 binlog 做缓存更新比应用层更可靠
应用代码里删缓存容易漏(比如新增一个 DAO 方法但忘了配缓存清理),而 MySQL 的 binlog 是唯一真实写入源。用 canal 或 debezium 订阅变更,收到 UPDATE orders 事件后触发 DEL cache:order:${id},能消除应用层逻辑分散带来的不一致风险。
- binlog 方案不依赖业务代码,改 SQL 就生效
- 注意事务边界:一个事务含多条语句时,binlog 解析要按事务粒度投递,避免中间状态被消费
- 消费端需幂等:同一条 binlog 可能重复投递,
DEL本身是幂等的,但如果是SET缓存就要加判断
真正难的不是选哪种方案,而是意识到「缓存永远比数据库慢半拍」,然后在业务可接受范围内,把这半拍控制在毫秒级、可监控、可降级。










