使用Redis做缓存后,业务普遍遇到一个问题:更新数据库后缓存没同步更新,导致读取到旧数据。缓存与数据库的双写一致性,一直是后端开发中常见又容易踩坑的场景。本文对比删除缓存、延迟双删、订阅Binlog三种方案,并给出各自的适用场景与踩坑点。
先认清:强一致不现实,目标是最终一致
把数据库和缓存放在同一个事务里更新,要么依赖分布式事务(代价高),要么牺牲可用性。业务上真正需要的是最终一致:读请求可能短暂读到旧值,但会在很短时间收敛到最新值。所以先给缓存一致性的目标定个标准:数据库提交成功后,缓存最多在几秒内(或由业务可接受的时间)完成刷新。
方案一:Cache Aside(旁路缓存)+ 写后删除
// 读:先查缓存,命中直接返回
// 写:先更新数据库,再删除缓存
void updateUser(User user) {
userMapper.update(user);
redis.delete("user:" + user.getId());
}
这是最常用的方案。删除缓存(而不是更新缓存)的原因:更新缓存把读写的成本也耦合进来,删除只承担下次读重建,更省。这个方案的时序漏洞在于”更新数据库->删除缓存”之间,有并发的读请求把旧值写回缓存:
时间线:
1. 线程A 更新DB为v2
2. 线程B 读缓存(miss),读DB(v2)
3. 线程A 删除缓存成功
4. 线程B 将v2写入缓存 <- 缓存最终还是对的
等等,这个顺序其实正确:B读到的DB是v2,写入也是v2,没问题。真正的问题版本是"先删缓存再更新DB",并发读会在中间把旧值放回缓存。所以原则是:先更新DB,后删缓存,顺序不要反过来。
方案二:延迟双删,处理"读旧写回"的竞争窗口
即使按"先DB后缓存"的顺序,仍存在极端窗口:线程A更新DB为v2后,线程B读缓存miss,读DB(此时可能拿到v1旧值,取决于事务隔离级别),在线程A删除缓存之前把v1写回缓存,之后A删除缓存。看起来没问题,但若A的删除动作在B的写回之后发生,缓存里是v1,DB是v2,出现不一致。解决手段是延迟双删:
// 更新DB后,删一次缓存,稍后(延迟200ms)再删一次
void update() {
userMapper.update(user);
cache.del(key);
// 异步延迟删除,覆盖写回窗口
Thread.sleep(200);
cache.del(key);
}
延迟时间的经验值取"一次完整读+写回的时间"(一般100-300ms),用延迟队列或定时任务执行第二次删除。这套方案代码简单,但延迟时间需要根据业务量调,量级较大的业务优先考虑方案三。
方案三:Binlog订阅 + 异步重建缓存(最终一致的最优解)
# 使用Canal订阅MySQL binlog
# 监听到UPDATE事件后,删除或刷新对应缓存
# 框架代码(伪代码)
binlogListener.onUpdate("t_user", (row) -> {
cache.del("user:" + row.getId());
// 或重建缓存:cache.set("user:" + id, buildValue(row));
});
这个方案把缓存更新从业务代码中完全剥离,不需要在业务层埋点。优点是天然解耦、可以控制"删除还是重建";缺点是需要额外部署Canal组件,且binlog同步也有延迟(毫秒级)。如果系统已有消息中间件和canal,这个方案是长期最稳的选择。
一致性监控:把不一致变成可观测的
不管用哪个方案,都要有手段发现不一致。推荐"对比读"方案:读缓存时,对部分流量加一个DB对照,缓存值与DB值不一致时记录日志并告警,同时触发缓存刷新。线上可以抽1%的流量做对照,成本低、能提前暴露问题。还有一个简单实用的检查:写库后生成版本号,缓存带版本号,读时版本号比一致才用,不一致就回源。版本号方案与延迟双删叠加,能解决绝大多数业务场景的不一致问题。
实际选型建议:读多写少、一致性要求不高的场景用Cache Aside;写频繁、严格不能读到旧值的用Binlog方案;两者叠加版本号兜底,成本可以接受。任何一种方案的前提都是:缓存失效时间要合理配置(比如5-10分钟兜底TTL),防止缓存永远不过期导致的数据永久不一致。
原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/redis-huan-cun-yu-shu-ju-ku-yi-zhi-xing-shi-jian-shuang-xie/