核心结论先摆出来:先更新数据库、再删除缓存(Cache Aside) 是通用读缓存场景下唯一可取的顺序。但它给你的不是强一致,而是"把不一致窗口压到工程可接受"。真正兜住底线的是三样东西:TTL 过期、删除失败的重试、以及不要在事务提交前删缓存。如果业务真的要求缓存与数据库强一致,那么正确的动作往往不是把方案做复杂,而是重新审视这个数据该不该走缓存。
一、先把问题定义清楚
1.1 缓存解决了什么,又引入了什么
引入缓存的动机很直接:关系型数据库的单机并发能力有限,读多写少的热点数据放到 Redis 这类内存存储里,可以把绝大部分读请求从数据库上卸下来。
代价也很明确:同一份数据出现了两个副本,而它们分属两个独立的存储系统。 数据库有事务,Redis 有自己的持久化和复制,两者之间没有任何原子性保证。一旦要更新数据,就必然面临"先动哪个、后动哪个、中间挂了怎么办"的问题。
这不是实现细节,而是架构层面的取舍:你用一致性换了吞吐量。
1.2 "一致性"在这里到底指什么
讨论前必须把词定义清楚,否则很容易得出错误结论。这里有三个完全不同的目标:
| 目标 | 含义 | 能否实现 |
|---|---|---|
| 强一致 | 写完成后,任何读都立刻看到新值 | 不引入分布式事务的前提下做不到 |
| 最终一致 | 允许短暂读到旧值,但保证有限时间内收敛 | 可以做到,且是主流选择 |
| 不一致窗口不可控 | 脏数据可能永久驻留 | 必须避免------这是真正的 bug |
"缓存 + 数据库"这个架构本身就不提供强一致。 任何声称能做到的方案,要么隐藏了分布式锁/串行化带来的吞吐代价,要么只是把窗口缩小到不易被观测到。所以后文的评价标准是第三条:窗口有多大、是否可控、脏数据能否自愈。
二、四种更新顺序:逐个拆掉
先给这四种写法一个标准术语锚点,方便后续沟通:方案一、二属于 Write Through (写穿透,写请求同时维护两份数据)的变体,方案四是 Cache Aside(旁路缓存),这是业界的主流命名。
2.1 方案一:先更新缓存,再更新数据库(不可取)
ini
1. SET cache = v2
2. UPDATE db SET value = v2
失败模式:
- 第 2 步失败 → 缓存是新值 v2,数据库是旧值 v1。此时缓存里的数据在数据库中从未存在过。
- 缓存宕机后重建 → 缓存从数据库恢复,拿到的是 v1,v2 这次更新彻底丢失,且没有任何地方能感知到丢失。
第二点是这个方案最致命的地方:它把缓存当成了数据的权威来源(source of truth),但 Redis 的持久化保证弱于数据库(AOF 默认 everysec 刷盘,主从复制是异步的)。把弱持久化的存储放在写链路前面,等于把数据可靠性降级到了缓存的水平。
但要注意这个结论的边界。 有一类场景恰恰是"先写缓存"的:点赞数、浏览量、非精确库存这类高频计数器,Redis
INCR后异步批量落库(Write Behind 模式)。这时业务已经明确接受"可能丢少量计数",用的是另一套权衡。所以准确的说法是:在"数据库是权威来源"的通用读缓存场景下,方案一不可取------而不是"先写缓存永远错"。
2.2 方案二:先更新数据库,再更新缓存(不可取)
ini
1. UPDATE db SET value = v2
2. SET cache = v2
数据库的权威地位保住了,但仍有三个问题。
问题 1:并发写导致缓存被旧值覆盖。
两个写线程 A(写 v1)、B(写 v2)交错执行:
ini
时刻 1 线程 A: UPDATE db = v1
时刻 2 线程 B: UPDATE db = v2 ← 数据库最终是 v2,正确
时刻 3 线程 B: SET cache = v2
时刻 4 线程 A: SET cache = v1 ← 缓存被旧值覆盖,且不会自愈
数据库正确、缓存错误,而且没有 TTL 的话这个脏值会一直存在。原因在于:数据库的更新顺序由行锁保证,但"更新数据库"和"更新缓存"是两个独立步骤,它们之间的相对顺序在跨线程时是不确定的。这类问题的排查特征很明显------数据库查出来是对的,业务界面显示的是错的,清一次缓存就好了。
问题 2:写多读少时做了无用功。
每次写都刷缓存,但这份缓存可能到过期都没被读过一次。写 100 次读 1 次的数据,99% 的缓存更新都是纯开销。
问题 3:缓存值需要计算时开销被放大。
缓存里往往不是单表的一行,而是聚合结果------比如商品详情页要 JOIN 三张表再拼装 DTO。每次写数据库都重算一遍这个聚合,写链路的 RT 会被显著拖长。
原文这一段有个笔误("并不是直接写入换粗"),应为"缓存",重写时已修正。
2.3 方案三:先删除缓存,再更新数据库(不可取)
ini
1. DEL cache
2. UPDATE db SET value = v2
这个方案的问题最严重,因为它主动制造了一个读请求必然穿透的窗口:
less
时刻 1 线程 A(写): DEL cache
时刻 2 线程 B(读): GET cache → miss
时刻 3 线程 B(读): SELECT db → 得到旧值 v1
时刻 4 线程 A(写): UPDATE db = v2
时刻 5 线程 B(读): SET cache = v1 ← 旧值被回填,脏数据驻留
关键在于:第 1 步和第 2 步之间的窗口是必然存在的(数据库写操作的耗时通常在毫秒级,而缓存 miss 后的读请求随时可能进来),而回填发生在数据库更新之后。这不是低概率的竞态,在有一定 QPS 的热点 key 上几乎必然发生。
原文提到的补救手段是延时双删,这个思路是对的,但它的定位需要澄清------放到第五章单独讲。
2.4 方案四:先更新数据库,再删除缓存(可取)
ini
1. UPDATE db SET value = v2
2. DEL cache
这就是 Cache Aside。它的读写路径是这样的:
java
@Service
public class ProductService {
private static final String KEY_PREFIX = "product:detail:";
private static final Duration TTL = Duration.ofMinutes(10);
private final StringRedisTemplate redis;
private final ProductMapper mapper;
private final ObjectMapper json;
/** 读:缓存优先,miss 时回填 */
public ProductDetail get(long id) throws Exception {
String key = KEY_PREFIX + id;
String cached = redis.opsForValue().get(key);
if (cached != null) {
return json.readValue(cached, ProductDetail.class);
}
ProductDetail detail = mapper.selectDetail(id);
if (detail == null) {
// 空值也要缓存,且 TTL 更短,避免缓存穿透
redis.opsForValue().set(key, "", Duration.ofSeconds(30));
return null;
}
redis.opsForValue().set(key, json.writeValueAsString(detail), TTL);
return detail;
}
/** 写:先落库,再删缓存 */
@Transactional
public void update(ProductDetail detail) {
mapper.updateById(detail);
// 注意:这里直接删是有坑的,见 4.1
redis.delete(KEY_PREFIX + detail.getId());
}
}
它优于前三个方案的地方:
- 数据库始终是权威来源,缓存只是派生数据,宕机不丢数据。
- 删除是幂等的,多次执行结果一致,重试成本极低------这一点在做失败补偿时非常关键。
- 规避了 2.2 的并发写覆盖 :两个写线程都执行
DEL,无论顺序如何,结果都是"缓存为空",下次读会从数据库拿到最终值。这是"删除"相对"更新"最本质的优势。 - 规避了 2.3 的必然穿透窗口:缓存在数据库更新完成后才被删除。
2.5 为什么是"删除"而不是"更新"
| 维度 | 更新缓存 | 删除缓存 |
|---|---|---|
| 并发写乱序 | 会被旧值覆盖,脏数据驻留 | 结果都是"空",天然收敛 |
| 幂等性 | 不幂等(值不同) | 幂等,可安全重试 |
| 写链路开销 | 需要重新计算聚合值 | 一次 DEL |
| 冷数据 | 白算白写 | 按需加载(lazy loading) |
| 缓存击穿风险 | 低(缓存一直有值) | 高(删除后热点 key 会有一波并发穿透) |
最后一行是删除方案唯一的代价:热点 key 被删除后,瞬间会有一批请求同时穿透到数据库。 这需要单独处理(互斥重建 / 逻辑过期),属于缓存击穿的范畴,与一致性问题正交。
三、方案四的窗口有多大:不要把它当成正确性方案
3.1 理论上的不一致场景
less
时刻 1 线程 B(读): GET cache → miss(可能是刚过期,或刚被删)
时刻 2 线程 B(读): SELECT db → 得到旧值 v1
时刻 3 线程 A(写): UPDATE db = v2
时刻 4 线程 A(写): DEL cache ← 此时缓存本来就是空的,删了个空
时刻 5 线程 B(读): SET cache = v1 ← 旧值回填,脏数据驻留
这个场景和 2.3 的结构完全一样。所以严格来说,Cache Aside 并没有消灭"旧值回填"这个问题,只是让它变得难以触发。
3.2 为什么工程上可以接受
触发这个场景需要同时满足三个条件:
- 读请求刚好遇到缓存 miss;
- 写请求在"读已查库、但还未回填"这个极窄的时间片内完成了数据库写入 + 缓存删除;
- 读请求的回填动作晚于写请求的删除动作。
第 2 条是关键:它要求一次数据库写事务(含加锁、刷 redo log、提交)比一次数据库读查询还快。在正常的系统里,写通常比读慢一个量级以上,所以这个窗口极难命中。
这是一个概率上的工程妥协,而不是逻辑上的正确性证明。写代码时可以接受它,但做设计评审、排查线上疑难问题时必须知道它存在。
3.3 TTL 是最后一道防线
上面所有方案的所有失败路径,最终都靠同一件事兜底:缓存有过期时间。
TTL 的作用不是提升命中率,而是给系统一个"脏数据必然自愈"的时间上界。它把"可能永久不一致"降级成"最多不一致 N 秒"。
因此:任何缓存 key 都必须设置 TTL,没有例外。 一个永不过期的 key 意味着一旦出现任何一次删除丢失,这份脏数据就会永久存在,只能靠人工介入。
TTL 的选择是个权衡:
- 太长 → 脏数据存活久,且缓存内存占用高;
- 太短 → 命中率下降,数据库压力回升;
- 热点 key 集中过期 → 缓存雪崩,需要加随机抖动:
java
private Duration ttlWithJitter() {
// 基础 10 分钟,叠加 0~120 秒随机抖动,避免同时过期
long base = Duration.ofMinutes(10).getSeconds();
long jitter = ThreadLocalRandom.current().nextLong(120);
return Duration.ofSeconds(base + jitter);
}
四、落地时最容易踩的四个坑
方案选对了,实现写错一行照样出事故。这四个坑我按线上出现频率排序。
4.1 在事务提交前删缓存
2.4 节那段示例代码有个隐藏 bug。@Transactional 方法内的 redis.delete() 执行时,数据库事务还没有提交:
less
时刻 1 线程 A(写): UPDATE db(事务未提交,其他会话看不到 v2)
时刻 2 线程 A(写): DEL cache
时刻 3 线程 B(读): miss → SELECT db → 读到 v1(A 未提交)
时刻 4 线程 B(读): SET cache = v1
时刻 5 线程 A(写): COMMIT ← 数据库是 v2,缓存是 v1
注意这个窗口远比 3.1 宽 :它的宽度等于"从删缓存到事务提交"的时间,包含了事务内后续的所有逻辑。事务越大,窗口越宽。这是一个必然会发生的问题,而不是低概率竞态。
正确做法是把删除动作挂到事务提交之后:
java
@Transactional
public void update(ProductDetail detail) {
mapper.updateById(detail);
evictAfterCommit(KEY_PREFIX + detail.getId());
}
private void evictAfterCommit(String key) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
redis.delete(key); // 无事务上下文,直接删
return;
}
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
redis.delete(key);
}
});
}
Spring 里也可以用事件的方式表达,可读性更好:
java
// 写入侧:发布事件
applicationEventPublisher.publishEvent(new ProductUpdatedEvent(detail.getId()));
// 监听侧:只在事务提交后触发
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onProductUpdated(ProductUpdatedEvent event) {
redis.delete(KEY_PREFIX + event.getId());
}
排查特征:数据库对、缓存错,且集中出现在事务耗时长的写接口上(比如一个更新方法里还串了 RPC 调用)。
4.2 读写分离下的主从延迟
如果数据库做了读写分离,缓存回填走的是从库,那么方案四会多一个不一致来源:
less
时刻 1 线程 A(写): UPDATE 主库 = v2, COMMIT
时刻 2 线程 A(写): DEL cache
时刻 3 线程 B(读): miss → SELECT 从库 → 主从还没同步过来,读到 v1
时刻 4 线程 B(读): SET cache = v1
这个窗口的宽度等于主从复制延迟,通常是毫秒级,但在大事务、DDL、从库负载高的时候可以到秒级甚至分钟级。这比 3.1 的窗口宽几个数量级,是被严重低估的一个坑。
处理方式(按代价从低到高):
-
缓存回填强制走主库 。最直接,代价是主库读压力上升。如果用了动态数据源,回填路径上强制切主:
javapublic ProductDetail get(long id) { // ... miss 之后 ProductDetail detail = DataSourceContext.forceMaster( () -> mapper.selectDetail(id)); // ... 回填 }具体 API 取决于你用的框架(dynamic-datasource 的
@DS("master")、ShardingSphere 的 hint 等),思路一致:回填是"写后读",必须读权威源。 -
删除动作延后到主从同步之后(本质是第五章的延时双删)。
-
接受它,并把 TTL 设短于业务容忍度。
排查特征 :不一致集中出现在主从延迟高的时间段(大批量任务、备份窗口),且清缓存后立刻恢复。排查时把"缓存写入时间戳"和"从库 Seconds_Behind_Master"的监控叠在一起看,很容易对上。
4.3 旧值覆盖新值
3.1 和 4.2 的根因是同一个:回填时没有校验自己手上的值是不是最新的 。可以在缓存 value 里带上版本号(数据库的 update_time 或乐观锁 version 字段),回填时用 Lua 做 CAS,只允许更新版本更高的值:
java
private static final String CAS_SET = """
local cur = redis.call('GET', KEYS[1])
if cur and cur ~= '' then
local curVer = tonumber(cjson.decode(cur)['version'])
if curVer and curVer >= tonumber(ARGV[2]) then
return 0
end
end
redis.call('SET', KEYS[1], ARGV[1], 'EX', ARGV[3])
return 1
""";
/** 只有当缓存中不存在更高版本时才回填 */
private void fillIfNewer(String key, String value, long version, long ttlSeconds) {
redis.execute(RedisScript.of(CAS_SET, Long.class),
List.of(key),
value, String.valueOf(version), String.valueOf(ttlSeconds));
}
这个手段把"旧值覆盖新值"从可能发生 变成不可能发生(在版本号单调递增的前提下),是把 Cache Aside 从"概率可接受"推向"逻辑正确"的关键一步。
代价是缓存结构和读写路径都变复杂了:value 必须是结构化的、每次回填多一次 Lua 执行、版本号的单调性依赖数据库字段。只在核心数据上做,不要全站铺开。
4.4 删除失败没有兜底
redis.delete() 会因为网络抖动、Redis 主从切换、连接池耗尽而失败。如果失败了什么都不做,这个 key 的脏数据就要等到 TTL 才自愈。
最低成本的兜底:把删除失败的 key 记录下来并告警,先保证可观测。
java
try {
redis.delete(key);
} catch (Exception e) {
// 不能因为删缓存失败而让业务写入回滚
log.error("evict cache failed, key={}", key, e);
cacheEvictFailCounter.increment(); // Micrometer 计数器,配告警
retryQueue.submit(key); // 见第六章
}
这里有个容易写错的判断:删缓存失败不应该导致业务写入失败 。数据库已经提交了,抛异常给上层只会让调用方误以为写入没成功而重试,反而制造更多问题。正确的做法是:吞掉异常、记录、异步重试。
五、延时双删:它解决了什么,没解决什么
markdown
1. DEL cache
2. UPDATE db
3. 等待 N 毫秒后,再 DEL cache
5.1 它解决的问题
第 3 步的作用是清掉在窗口期内被回填的旧值。无论这个旧值来自 2.3 的穿透窗口、3.1 的竞态,还是 4.2 的主从延迟,只要它在 N 毫秒内被写进缓存,第二次删除就能把它清掉。
5.2 它没解决的问题
N 取多少无法证明。 它必须大于"读请求查库 + 回填"的耗时,在读写分离场景下还必须大于主从复制延迟。但这两个值都是分布而不是常量:P99 可能是 20ms,但偶发的慢查询、GC 停顿、主从延迟毛刺可以到几秒。
也就是说:延时双删把不一致的概率降低了一个量级,但没有把它变成零。 它是一个概率优化手段,不是正确性方案。这个定位必须清楚,否则会在设计评审时给出过强的承诺。
5.3 实现上的两个错误写法
错误一:在业务线程里 sleep。
java
// 反面示例
redis.delete(key);
mapper.updateById(detail);
Thread.sleep(1000); // 白占一个 Tomcat 工作线程 1 秒
redis.delete(key);
如果这是个 QPS 100 的写接口,等于常驻 100 个线程在睡觉。线程池被打满后,整个服务的其他接口一起不可用。
错误二:用 new Thread() 或无界线程池异步 sleep。 下游一慢就会无限制创建线程或堆积任务。
正确写法:用一个受控的定时线程池,且必须在事务提交后调度:
java
@Component
public class DelayedCacheEvictor {
private final ScheduledExecutorService scheduler = new ScheduledThreadPoolExecutor(
2,
new CustomizableThreadFactory("cache-evict-"),
new ThreadPoolExecutor.AbortPolicy()); // 快速失败 + 告警,不要静默堆积
private final StringRedisTemplate redis;
/** 立即删一次,delay 后再删一次 */
public void evictTwice(String key, long delayMillis) {
safeDelete(key);
try {
scheduler.schedule(() -> safeDelete(key), delayMillis, TimeUnit.MILLISECONDS);
} catch (RejectedExecutionException e) {
log.error("delayed evict rejected, key={}", key, e);
delayedEvictRejectCounter.increment();
}
}
private void safeDelete(String key) {
try {
redis.delete(key);
} catch (Exception e) {
log.error("evict cache failed, key={}", key, e);
}
}
}
注意这里用的是"进程内定时任务",进程重启会丢掉待执行的第二次删除。如果这次删除对业务很关键,应该换成消息队列的延时消息(RocketMQ 延时消息、RabbitMQ TTL + 死信队列),让它跨进程可靠。
5.4 什么时候需要它,什么时候不需要
- 不需要:走的是方案四、单库无读写分离、TTL 已经短于业务容忍度。此时双删的收益小于它带来的复杂度和额外 Redis 请求。
- 需要:有读写分离(4.2 的窗口是宽的)、或者存在必须先删缓存的历史包袱(方案三)。
- 延时时长 :不要拍一个 1 秒的整数。用主从延迟的 P99 加一个安全余量来定,并且把这个值做成配置项------因为它会随数据库负载变化。
六、把删除做成"最终一定成功"
TTL 是被动兜底(要等),删除重试是主动收敛(更快)。生产系统通常需要后者。
6.1 方案 A:消息队列重试
删除失败时,把 key 投到 MQ,由消费者重试;重试若干次仍失败则进死信队列并告警。
java
private void evict(String key) {
try {
redis.delete(key);
} catch (Exception e) {
log.warn("evict failed, fallback to mq, key={}", key, e);
mqProducer.send(new CacheEvictMessage(key)); // 消费者带退避重试
}
}
优点是改造成本低。缺点是它只能覆盖"删除动作抛异常"这一种失败------如果进程在"数据库提交成功"和"发起删除"之间宕机,这次删除就彻底丢了,只能等 TTL。
6.2 方案 B:订阅 binlog 异步删除
把"删缓存"从业务代码里彻底拿掉,改由 Canal / Debezium 订阅 MySQL binlog,解析出变更的表和主键,投递到 MQ,由独立的消费者删除缓存。
业务服务 ──写──> MySQL ──binlog──> Canal ──> MQ ──> 缓存清理消费者 ──> Redis
它解决了方案 A 的根本缺陷:binlog 是数据库事务提交的产物,只要事务提交了,binlog 就一定存在。所以"提交成功但删除丢失"在结构上就不可能发生。附带的好处:
- 业务代码不用再关心缓存清理,写链路更干净,也不会再犯 4.1 的事务边界错误;
- 天然按事务提交顺序有序(同一 key 的变更在 MQ 中按主键 hash 分区可保序);
- 失败可无限重试,消费位点可回溯。
代价也很实在:
- 多了 Canal + MQ 两个组件,可用性和运维成本都要算进来;
- 清理链路变成异步的,不一致窗口从"毫秒级"变成"binlog 订阅延迟"(正常毫秒到百毫秒,积压时可到秒级);
- 缓存 key 的构造规则需要在业务侧和消费侧维护两份,容易不同步------这是最容易出问题的地方,建议把 key 生成逻辑抽成独立的公共模块。
6.3 怎么选
| 场景 | 建议 |
|---|---|
| 缓存 key 数量少、清理逻辑简单 | 业务内删除 + afterCommit + 失败告警 |
| 核心业务、不一致代价高 | 业务内删除(快路径)+ binlog 订阅(兜底慢路径),双保险 |
| 缓存维护逻辑分散在多个服务 | binlog 订阅,统一收口 |
| 团队没有 Canal 运维能力 | 老老实实用方案 A + 短 TTL,不要为了架构完整性引入运维负担 |
七、真的需要强一致怎么办
7.1 串行队列
把同一个缓存场景的读写请求都放进一个串行队列,逐个处理。没有并发,就没有竞态。但必须按 key 分片,否则全局串行的吞吐会退化到不可用。
java
public class KeyedSerialExecutor {
private final ExecutorService[] shards;
public KeyedSerialExecutor(int shardCount, int queueCapacity) {
this.shards = new ExecutorService[shardCount];
for (int i = 0; i < shardCount; i++) {
// 每个分片单线程 → 同一分片内天然串行
shards[i] = new ThreadPoolExecutor(
1, 1, 0L, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(queueCapacity),
new CustomizableThreadFactory("cache-serial-" + i + "-"),
new ThreadPoolExecutor.AbortPolicy());
}
}
/** 同一个 key 的任务恒定落到同一个单线程池,因此严格串行 */
public <T> Future<T> submit(String key, Callable<T> task) {
int idx = Math.abs(key.hashCode() % shards.length);
return shards[idx].submit(task);
}
}
调用方需要带超时,避免排队把请求线程全部拖住:
java
Future<ProductDetail> f = serialExecutor.submit(key, () -> loadFromDb(id));
try {
return f.get(200, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
f.cancel(true);
queueTimeoutCounter.increment();
return loadFromDbDirectly(id); // 降级:绕过队列直读数据库
}
7.2 双串行队列
拆成读队列和写队列,写队列优先执行,读队列等待;写队列执行完主动唤醒读队列,或读队列等待超时(如 50ms)后暂停写队列、批量并发执行一批读(如 1000 个)。
这个设计的核心洞察是:读之间不冲突,可以批量并发;读和写之间冲突,需要互斥;同时要防止写请求持续到来导致读请求饿死。
7.3 它的本质是"按 key 的读写锁"
读读共享、读写互斥、并且带防饥饿的公平性调度 ------正是一个公平读写锁。自己实现两个队列加唤醒/超时逻辑,需要处理的边界情况很多(唤醒丢失、超时与唤醒的竞态、队列积压、优先级反转),而这些在成熟的读写锁实现里已经解决了。
单机场景直接用 JDK:
java
// 构造参数 true 表示公平模式,避免写请求连续到来导致读饥饿
private final ReadWriteLock lock = new ReentrantReadWriteLock(true);
分布式场景(多实例部署)用 Redisson 的分布式读写锁:
java
public ProductDetail getConsistently(long id) throws InterruptedException {
RReadWriteLock rwLock = redisson.getReadWriteLock("lock:product:" + id);
RLock readLock = rwLock.readLock();
// waitTime=200ms 拿不到就降级,leaseTime=3s 防止持有者宕机导致死锁
if (!readLock.tryLock(200, 3000, TimeUnit.MILLISECONDS)) {
lockTimeoutCounter.increment();
return loadFromMaster(id); // 降级:直读主库,不走缓存
}
try {
return get(id);
} finally {
readLock.unlock();
}
}
@Transactional
public void updateConsistently(ProductDetail detail) throws InterruptedException {
RReadWriteLock rwLock = redisson.getReadWriteLock("lock:product:" + detail.getId());
RLock writeLock = rwLock.writeLock();
writeLock.lock(3, TimeUnit.SECONDS);
try {
mapper.updateById(detail);
evictAfterCommit(KEY_PREFIX + detail.getId());
} finally {
writeLock.unlock();
}
}
这个写法比自建双队列更可维护,但它也不是免费的:
- 多实例下队列方案必须做请求路由(同一 key 的请求必须打到同一实例),一旦扩缩容或实例宕机,路由变化会让串行保证短暂失效。分布式锁没有这个问题,代价是每次读写多两次 Redis 往返。
- 锁本身成了新的可用性依赖 :Redis 抖动会直接导致业务不可用,所以
tryLock的超时和降级路径必须写,不能只写 happy path。 - P99 延迟必然上升。读请求要排队、要拿锁,这是强一致的直接成本。
leaseTime是个陷阱:设太短,业务没执行完锁就释放了,互斥失效;设太长,持有者宕机后其他请求要等很久。必须大于业务逻辑的 P99 耗时。
7.4 更该问的问题:这个数据该不该走缓存
在引入分布式锁或串行队列之前,值得先质疑需求本身。愿意为强一致付出"每次读多两次 Redis 往返 + 排队延迟 + 多一个可用性依赖"的代价,那么:
- 如果 QPS 不高:直接读数据库(配好索引、必要时读主库)通常比"缓存 + 分布式锁"更快、更简单、更可靠。缓存本来就是为了扛高并发读,为了强一致把读路径搞得比数据库还慢,就本末倒置了。
- 如果 QPS 很高但要求强一致 :这类需求往往是被过度表述的。真正需要强一致的通常是写路径上的判断 (扣库存、校验余额、防重复下单),这些应该在数据库里用行锁、乐观锁或唯一索引解决,根本不该经过缓存;而读路径上展示给用户的数据,绝大多数能接受秒级延迟。
- 如果确实是"高并发 + 强一致读":那么该考虑的不是给 Cache Aside 打补丁,而是换架构------比如让缓存成为唯一的读写入口(写也走缓存,异步落库),或者用支持强一致读的分布式数据库。
先区分"业务真的要求"和"需求描述得太强",这一步能省掉大量无谓的复杂度。 我的建议是:把"允许多少秒的不一致"当作一个需要产品明确签字的指标,而不是默认取零。
八、可观测性与线上排查
一致性问题的特点是"出现时没有异常、只有数据不对",所以必须靠指标和日志主动发现,不能等用户反馈。
8.1 至少要有的四个指标
| 指标 | 怎么用 |
|---|---|
| 缓存命中率 | 突然下跌 → 有人在批量删 key,或 TTL 配置被改,或发生了雪崩 |
| 缓存删除失败次数 | 直接对应 4.4,必须配告警 |
| 延时删除/重试队列积压 | 积压意味着兜底链路已经不可靠 |
| binlog 订阅延迟(若有) | 直接等于不一致窗口宽度 |
埋点用 Micrometer 就够:
java
private final Counter hitCounter = Metrics.counter("cache.access", "result", "hit");
private final Counter missCounter = Metrics.counter("cache.access", "result", "miss");
private final Counter evictFail = Metrics.counter("cache.evict", "result", "fail");
8.2 一致性巡检
指标只能发现链路故障,发现不了"数据静默地不一致"。补一个低频巡检任务:定时抽样一批 key,比对缓存值与数据库值,不一致就上报并修复。
java
@Scheduled(cron = "0 */10 * * * ?")
public void inspect() {
List<Long> sample = mapper.sampleRecentUpdatedIds(200); // 抽最近更新的
for (Long id : sample) {
String cached = redis.opsForValue().get(KEY_PREFIX + id);
if (cached == null || cached.isEmpty()) {
continue; // 未缓存,不算不一致
}
ProductDetail fromDb = mapper.selectDetail(id); // 走主库
if (!isSame(cached, fromDb)) {
inconsistentCounter.increment();
log.warn("cache inconsistent, id={}", id);
redis.delete(KEY_PREFIX + id); // 顺手修复
}
}
}
抽样对象要选"最近更新过的数据"------不一致只可能发生在写之后,全表随机抽样命中率极低。这个任务的价值不在修数据(TTL 也能修),而在把不一致率变成一个可观测、可追踪的数字。有了这个数字,才能判断改造是否真的有效。
8.3 排查对照表
| 现象 | 优先看什么 | 常见根因 |
|---|---|---|
| 数据库对、缓存错,清缓存后恢复 | 该 key 的 TTL、删除日志 | 删除失败无兜底(4.4);方案二的并发写覆盖(2.2) |
| 不一致集中在耗时长的写接口 | 事务边界,删除是否在 afterCommit |
在事务提交前删缓存(4.1) |
| 不一致集中在特定时间段 | 主从延迟 Seconds_Behind_Master |
回填读了从库(4.2) |
| 缓存里有数据库中不存在的值 | 写链路顺序 | 用了方案一,缓存被当成权威源(2.1) |
| 改完数据要等很久才生效 | binlog 订阅延迟、MQ 积压 | 异步清理链路积压(6.2) |
| 脏数据永不自愈 | key 是否设了 TTL | 缺 TTL(3.3) |
| 更新后数据库压力突增 | 是否热点 key 被删 | 缓存击穿,需要互斥重建 |
8.4 一个实用技巧
在缓存 value 里存写入时间戳和来源。 排查时能立刻回答"这个脏值是什么时候、由哪条路径写进去的":
json
{ "data": { ... }, "version": 1725436800, "writeAt": 1725436801234, "by": "read-fill" }
by 区分是读回填(read-fill)还是写更新(write-through),配合 4.3 的版本号一起用。这几个字节的开销,在排查疑难一致性问题时能省掉几个小时。
九、总结
- 顺序选择 :四种写法只有"先更新数据库、再删除缓存"(Cache Aside)在通用读缓存场景下站得住。理由不只是"数据库不会丢数据",更关键的是删除是幂等的,因此并发写天然收敛、失败可安全重试。
- 不要过度承诺:Cache Aside 同样有理论上的不一致窗口(3.1)。它是概率上的工程妥协,不是正确性证明。
- 真正的兜底是三件事:所有 key 都设 TTL(脏数据必然自愈)、删除失败有重试(收敛更快)、删除动作在事务提交后执行(避免制造一个必然发生的宽窗口)。
- 延时双删的定位要准:它降低概率,不消除问题;延时时长无法证明,必须做成可配置;实现上不能在业务线程 sleep。
- 强一致的代价要算清:串行队列/双队列的本质是按 key 的读写锁,用成熟实现比自建更可维护。但在引入之前,先确认这个数据是否真的需要缓存------很多"强一致"需求,正确答案是直接读数据库。