缓存一致性问题本质上是副本一致性问题。数据库保存权威数据,Redis保存低延迟副本,两者之间的同步总会有时间窗口。强一致性要求写入数据库成功后,缓存副本必须立即反映最新值,否则所有读请求要么等待,要么失败。这个要求在分布式环境下成本极高,因为数据库事务和Redis操作无法天然组成同一个ACID事务。即使通过分布式事务协调器强行保证,吞吐量也会大幅下降,还容易引入锁冲突、补偿失败和运维复杂度。

可以从两个角度理解放弃强一致性:一是业务上允许短时间读到旧值,二是不一致数据可以在后续被自动纠正。只要满足这两点,缓存设计就可以从强一致转向最终一致,把资源投入到更重要的读写性能上。下面从一致性模型、典型场景、工程方案和决策边界四个层面展开。
一、为什么缓存强一致性成本如此之高
缓存更新最常用的模式是Cache Aside,也就是读请求先查缓存,缓存未命中再读数据库,写请求先更新数据库,再删除缓存。这个模式本身只能保证最终一致,因为在删除缓存之前,其他读请求仍然可能拿到旧缓存。想要缩小这个窗口,开发者通常会引入延迟双删、消息队列异步删除、数据库binlog订阅等机制,但这些机制都只是降低不一致概率,无法彻底消除。
强一致性的实现思路通常是把数据库写操作和缓存删除操作放进同一个分布式事务。这样做的代价包括:需要引入分布式事务协调器,例如Seata;需要给缓存操作增加事务日志和补偿任务;需要在读写路径上增加锁或版本校验。以秒杀扣减库存为例,如果每次扣减都要等待数据库事务和缓存事务同时提交,单接口响应时间会从毫秒级上升到几十毫秒甚至更高,整体吞吐量可能下降数倍。
另一个被忽视的代价是运维复杂度。强一致方案一旦出现消息丢失、补偿任务积压、分布式锁超时等问题,定位难度远高于普通缓存穿透或缓存雪崩。很多系统原本只需要一个TTL兜底就能解决的问题,最后演变成一套庞大的对账系统。因此,放弃强一致性并不是偷懒,而是把一致性成本放到业务可接受的范围内。
public class CacheAsideService {
private ProductMapper productMapper;
private StringRedisTemplate redis;
private RetryQueue retryQueue;
public void updateProduct(Product p) {
// 第一步:先更新数据库
productMapper.update(p);
// 第二步:再删除缓存,最大限度缩小不一致窗口
Boolean ok = redis.delete("product:" + p.getId());
// 第三步:删除失败时放入重试队列,避免缓存长期脏读
if (!Boolean.TRUE.equals(ok)) {
retryQueue.offer("product:" + p.getId());
}
}
}
二、适合放弃强一致性的典型业务场景
第一类是计数和浏览量场景。文章阅读数、视频播放量、点赞数、用户积分展示等数据,天然允许少量误差。通常做法是先在Redis中做INCR或INCRBY,再异步批量落库。即使Redis短暂丢失部分计数,业务上也可以接受,因为用户不会因为阅读量差了几个而认为系统出现严重问题。这类场景放弃强一致性几乎没有任何业务风险,却能极大降低数据库写压力。
第二类是库存展示和商品详情。商品详情页、价格、标题、库存剩余量等信息变化频率不高,但读取量极大。很多电商系统会把这些数据缓存较长时间,并用TTL主动失效。秒杀场景下,缓存中的库存数字可能比数据库延迟几百毫秒,但真正扣减库存时以数据库中的实际值为准。只要下单链路对库存做二次校验,缓存展示层的不一致完全可以接受。
第三类是排行榜、推荐位和用户会话信息。排行榜通常以分钟级甚至更长时间为周期重新计算,用户看到的排名落后几十秒不会影响核心体验。推荐位内容本来就是算法异步生成的,不需要与数据库实时同步。用户会话信息即使缓存丢失,也可以通过重新登录或重新授权恢复。这些场景的共同点是数据可重建、可延迟、可丢弃,非常适合采用最终一致缓存。
需要特别区分的是订单状态、支付结果、账户余额等关键数据。这些数据一旦读错,可能直接造成资损或用户投诉。实践中常见的做法是:关键状态不走缓存,或者缓存只用于展示,所有关键判断都回源数据库。比如订单支付状态在缓存中显示为待支付,但用户点击支付时必须由数据库事务重新确认,而不是仅依赖缓存值。
三、最终一致性的工程实现方案
方案一是先更新数据库,再删除缓存,并配合失败重试和TTL兜底。这是最轻量的方案。删除缓存失败时,可以把删除任务写入消息队列或本地重试表,由后台任务持续重试。同时给所有缓存设置合理的过期时间,即使重试全部失败,缓存也会在过期后自然失效。这个方案实现简单,适合大部分对一致性要求不高的业务。
方案二是延迟双删。在更新数据库后先删除缓存,等待业务线程可能存在的并发读完成,再延迟删除一次缓存。延迟时间通常根据读请求耗时设置,例如200毫秒到1秒。延迟双删可以降低并发读写场景下旧值被重新写回缓存的概率,但它并不能完全消除不一致,仍然需要与TTL和重试机制配合使用。
public class DelayDoubleDelete {
private ProductMapper db;
private StringRedisTemplate redis;
private ScheduledExecutorService scheduler;
public void update(Product p) {
db.update(p);
redis.delete("product:" + p.getId());
// 延迟再删一次,防止并发读把旧值重新写回缓存
scheduler.schedule(() -> {
redis.delete("product:" + p.getId());
}, 200, TimeUnit.MILLISECONDS);
}
}
方案三是基于数据库binlog的异步更新。通过Canal等工具订阅数据库变更日志,当数据发生变化时,由消费端负责删除或更新Redis缓存。这种方案的好处是缓存更新逻辑与业务代码解耦,业务系统只需要正常操作数据库,不需要在每个写操作后手动维护缓存。缺点是链路更长,需要额外维护订阅组件,并且binlog消费延迟会直接影响不一致窗口。
方案四是引入版本号或时间戳控制写入顺序。在缓存值中附带数据版本号,更新缓存时只允许高版本覆盖低版本,避免旧请求把新数据覆盖成旧数据。这种方案适用于并发更新较多、无法完全避免旧写覆盖新写的场景。实现时可以借助Redis的Lua脚本完成版本比较和写入的原子操作。
public void writeWithVersion(Product p) {
long version = System.currentTimeMillis();
db.update(p);
// 只允许高版本覆盖低版本,防止旧值回写
String lua = "if redis.call('get', KEYS[1]) then "
+ "if tonumber(redis.call('get', KEYS[1])) <= tonumber(ARGV[1]) then "
+ "redis.call('set', KEYS[1], ARGV[1]) end "
+ "else redis.call('set', KEYS[1], ARGV[1]) end";
redis.execute(new DefaultRedisScript<>(lua, Long.class),
List.of("product_version:" + p.getId()),
String.valueOf(version));
}
四、如何判断当前场景能否放弃强一致性
判断一个业务场景是否可以放弃缓存强一致性,可以从四个维度出发。第一是数据重要性,涉及资金、权限、安全的数据通常不能放弃;第二是业务容忍度,用户能否接受短时间内看到旧值;第三是可恢复性,不一致数据是否能够通过重试、过期或对账自动修复;第四是读写比例,读多写少且写入频率低的数据更适合用缓存加速,也更容易接受最终一致。
可以用一组问题辅助判断:如果用户看到的是10秒前的数据,会不会产生错误操作?如果缓存丢了,数据是否能够从数据库或其他来源重建?如果缓存和数据库不一致,是否会造成资损或法律风险?如果答案都是否定的,那么大概率可以放弃强一致性。相反,只要有一个答案是肯定的,就需要在关键路径上增加强一致校验,而不是简单依赖缓存。
最终一致并不意味着完全不管。工程上需要为缓存一致性建立监控和兜底机制,例如监控缓存删除失败率、binlog消费延迟、缓存命中率、缓存空值率等指标。对于重要程度中等的场景,可以提供手动刷新缓存的运营接口,必要时人工介入。对于可自动修复的场景,应尽量让修复链路短、幂等、可重试,避免补偿任务自身成为新的故障点。
缓存一致性的设计从来没有绝对标准。放弃强一致性不是降低工程质量,而是把一致性要求与业务价值对齐。通过识别场景、选择合适方案、建立兜底机制,团队可以把原本花在分布式事务上的大量精力,转移到缓存命中率、热点防护和读写性能优化上,最终获得更稳定、更简单的系统架构。