Redis缓存与数据库一致性实践:双写不一致的三种处理方案

使用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/

(0)
小编小编
上一篇 2小时前
下一篇 2小时前

相关推荐