在典型的互联网架构中,为了撑住高并发读流量,通常会把Redis作为缓存层,数据库采用一主多从的读写分离模式。写请求走主库并同步更新或删除缓存,读请求优先命中Redis,未命中时从数据库从库加载再写回缓存。这种结构在提升吞吐的同时,也带来了缓存与数据库之间、主库与从库之间的数据视图错位问题。如果处理不当,用户会看到已经下单成功但库存仍显示充足的矛盾画面。

读写分离带来的缓存一致性根因
缓存一致性的核心矛盾不在于Redis本身,而在于“写路径”和“读路径”使用了不同的数据源。当业务线程在主机上完成事务提交后,二进制日志还需要经过网络传输、从库重放才能对外可见。这个时间段内如果有读请求落到从库,取到的就是变更前的旧记录,若此时把旧记录写入Redis,就形成了脏缓存。即便采用先更新数据库再删缓存的策略,只要删除动作发生在从库同步之前,并发读仍然可能把过期数据重新塞回缓存。
另一个容易被忽略的点是客户端连接池的路由策略。很多ORM框架的读写分离插件是基于事务上下文或注解来选库的,但缓存回填逻辑往往写在通用DAO里,并不感知当前读的是主还是从。于是读从库未命中时查到的旧值,被无差别地写进Redis,主库的新值反而被覆盖。要理清这一点,必须画出完整的请求时序,而不是只在缓存组件层面加重试。
此外,Redis自身的淘汰策略和过期时间也会放大问题。如果缓存TTL设置过长,一次脏写之后可能持续数分钟误导用户;如果过短,又会导致穿透加剧、从库压力上升。因此一致性方案不能脱离过期时间、连接路由和复制延迟三者统一考虑,否则局部优化会引发全局抖动。
延迟双删与binlog订阅的落地对比
延迟双删是目前成本最低的做法:写主库前删一次缓存,事务提交后再起一个延时任务删第二次。第二次删除的目的就是清理掉在复制延迟窗口内被并发读填回的旧值。示例代码如下,利用线程池模拟延迟删除:
public void updateWithDelayDoubleDelete(String key, Runnable dbUpdate) {
redis.del(key);
dbUpdate.run();
// 提交后延迟500ms再次删除,覆盖从库同步窗口
scheduler.schedule(() -> {
redis.del(key);
}, 500, TimeUnit.MILLISECONDS);
}
这种方案的优点是侵入小、不需要额外中间件,缺点是延迟时间只能靠经验拍脑袋,设太短挡不住慢从库,设太长又增加删除失败风险。并且在极端情况下,若从库延迟超过设定值,仍会脏读。因此它适合容忍秒级不一致、且从库负载平稳的业务。
更严谨的做法是基于binlog的异步淘汰。通过监听主库binlog(如Canal组件),在确认从库位点追上之后再发缓存删除指令。这样删除动作与数据库真实提交严格对齐,不再依赖固定延时。下面是用伪代码描述的消费逻辑:
def on_binlog_event(event):
if event.type == 'UPDATE' and event.table == 'stock':
# 等待从库位点超过event.position
wait_slave_sync(event.position)
redis.delete('stock:' + event.pk)
binlog方案把一致性保障下沉到数据管道,业务代码最干净,但需要维护 Canal 集群和位点监控。一旦管道积压,删除也会滞后。实践中常把两者结合:业务内做基础的双删保底,管道做精准淘汰,形成双保险。
读路径的版本控制与强制读主策略
除了在写侧做删除,读侧也可以主动防御。给缓存值附带一个数据库版本号或更新时间戳,读从库时一并取出从库当前GTID或日志序号,若发现缓存版本落后于从库可见版本,则拒绝使用并触发回源。这样即使发生脏写,也会在下次读时被版本比对拦下。示例结构如下:
{
"data": { "name": "手机", "price": 2999 },
"db_version": 10245,
"cached_at": 1710000000
}
对于账户余额、券库存等绝对不能错的场景,可以规定写之后的一段时间内该用户的所有读都强制走主库。通过在请求上下文打标,读写分离中间件就会把流量导向主节点,从根本上避开从库延迟。代价是主库读压力上升,所以通常只对写后短时间(如1秒)或特定关键ID开启。
最后要强调监控的必要性。无论选哪种方案,都应采集主从延迟秒数、缓存脏命中率、双删失败次数等指标。当从库延迟突增时自动切换为读主或降级展示,才能把理论一致性变成可运维的系统能力。没有度量的一致性只是心理安慰,只有把复制偏移量和缓存命中日志串起来看,才知道自己的方案到底漏没漏。