Redis 与数据库缓存一致性
Redis 与数据库一致性讨论的是:MySQL 里的事实数据发生变化后,Redis 里的缓存副本如何尽快、可靠地变成正确状态。
商业系统里最常见的定位是:
MySQL 是事实源,Redis 是可重建的加速层。普通缓存不追求每一毫秒都强一致,而是控制不一致窗口,并通过重试、补偿、TTL、监控和降级保证最终一致。
学习目标
| 目标 | 学完要能说明什么 |
|---|---|
| 知道定位 | 为什么数据库是事实源,Redis 只是缓存副本 |
| 知道风险 | 为什么双写缓存和数据库会出现并发、失败、旧值覆盖 |
| 知道方案 | Cache Aside、延迟双删、消息补偿、Binlog CDC、TTL 兜底怎么选 |
| 知道原理 | 为什么推荐“先更新数据库,再删除缓存”,而不是直接更新缓存 |
| 会写 Demo | 能写读缓存、回源、事务提交后删缓存、删除失败重试 |
| 会做商业设计 | 商品详情、资产详情、字典配置、权限、库存分别怎么缓存 |
| 会排查 | 用户看到旧数据、删除失败、缓存击穿、数据库被打满怎么查 |
先明确 Redis 和数据库的分工
| 组件 | 负责什么 | 是否是最终事实 |
|---|---|---|
| MySQL | 事务、约束、状态机、可靠持久化 | 是 |
| Redis | 低延迟读取、热点数据、临时状态、可重建副本 | 通常不是 |
| 本地缓存 | 进程内热点数据、配置、字典 | 不是 |
| MQ 或 Binlog | 变更通知、异步补偿 | 不是 |
如果是账户余额、支付状态、库存最终扣减这类强约束数据,最终判断必须回到数据库事务或专门业务服务。Redis 可以做加速、预扣、限流、互斥,但不能单独承担最终正确性。
为什么会不一致
只要数据库和 Redis 是两个存储,就一定存在时间差、失败和并发。
flowchart TD
A["写请求修改 MySQL"] --> B["MySQL 已经是新值"]
B --> C["Redis 仍可能是旧值"]
C --> D["读请求命中 Redis"]
D --> E["用户看到旧数据"]常见不一致来源:
| 来源 | 具体表现 | 后果 |
|---|---|---|
| 更新顺序 | 先删缓存还是先写库 | 并发读可能把旧值写回缓存 |
| 删除失败 | 数据库成功,删 Redis 失败 | 旧缓存一直存在 |
| 并发读写 | 读请求回源和写请求交叉 | 旧数据覆盖新缓存 |
| 事务时机 | 数据库事务未提交就删缓存 | 其他请求可能读到旧库并重建旧缓存 |
| 主从延迟 | 回源读到数据库从库旧数据 | Redis 被写入旧值 |
| 本地缓存 | JVM 本地缓存没有失效 | Redis 对了,本地还旧 |
| 缓存击穿 | 热点 key 失效后大量回源 | 数据库压力暴涨 |
所以缓存一致性不是一个命令能解决的,而是一套读写顺序、失败重试、并发控制和兜底机制。
一致性边界
普通缓存系统通常不承诺:
- 写数据库成功后,所有读请求立刻读到新值。
- Redis 和 MySQL 每一刻都完全相同。
- 删除缓存永远成功。
- 消息或 Binlog 同步永远不延迟。
- 本地缓存、Redis、数据库可以零延迟同步。
通常要承诺:
| 承诺 | 含义 |
|---|---|
| 不长期旧 | 旧缓存不能无限期存在 |
| 能修复 | 删除失败、消息失败后能重试 |
| 可观测 | 能看到失败、延迟、命中率、回源量 |
| 可兜底 | TTL、补偿、人工重放能恢复 |
| 关键读正确 | 下单、支付、权限判断等关键路径回事实源 |
一句话:缓存一致性重点不是消灭所有短暂不一致,而是避免短暂不一致变成长期错误。
最常用模式:Cache Aside
Cache Aside 也叫旁路缓存,是商业项目最常用的 Redis 缓存模式。应用代码自己负责读缓存、查数据库、写缓存、删缓存。
读流程
flowchart TD
A["读请求"] --> B["查 Redis"]
B --> C{"是否命中"}
C -- "命中" --> D["返回缓存"]
C -- "未命中" --> E["查 MySQL"]
E --> F{"是否存在"}
F -- "存在" --> G["写 Redis 并设置 TTL"]
F -- "不存在" --> H["写短 TTL 空值或直接返回"]
G --> I["返回数据"]
H --> I写流程
推荐写流程是:先更新数据库,再删除缓存。
flowchart TD
A["写请求"] --> B["开启数据库事务"]
B --> C["更新 MySQL"]
C --> D{"事务是否提交"}
D -- "失败" --> E["返回失败,不动缓存"]
D -- "成功" --> F["删除 Redis 缓存"]
F --> G{"删除是否成功"}
G -- "成功" --> H["完成"]
G -- "失败" --> I["记录重试和告警"]为什么是删除缓存,而不是更新缓存:下一次读请求会基于数据库最新值重建缓存,能减少并发更新时旧值覆盖新值的风险。
为什么不推荐直接更新缓存
很多初学者会想:数据库更新后,把 Redis 也更新成新值,不就一致了吗?
问题在于真实业务里缓存值经常不是数据库一行。
| 场景 | 缓存值可能来自哪里 |
|---|---|
| 商品详情 | 商品表、价格表、库存服务、类目表、标签表 |
| 用户权限 | 用户表、角色表、菜单表、组织表、数据权限 |
| 资产详情 | 资产表、字段表、机构表、标签表、采集任务表 |
| 首页统计 | 多张表聚合、离线统计、实时计数 |
直接更新缓存的风险:
- 缓存组装逻辑复杂,写接口不一定能拿到完整字段。
- 多个写请求并发时,后完成的旧请求可能覆盖新缓存。
- 很多数据改完后短时间不会再读,更新缓存浪费资源。
- 部分字段变化会影响多个缓存 key,容易漏更新。
- 更新缓存成功但数据库事务回滚,会产生错误缓存。
因此通用方案是:写数据库后删除缓存,让读请求按最新数据库状态重建缓存。
为什么不推荐先删缓存再写数据库
先删缓存再写数据库看起来也合理,但并发下会出问题。
flowchart TD
A["写请求删除 Redis"] --> B["缓存已空"]
B --> C["读请求未命中 Redis"]
C --> D["读 MySQL 旧值"]
D --> E["把旧值写回 Redis"]
E --> F["写请求更新 MySQL 新值"]
F --> G["Redis 仍是旧值"]这个问题的本质是:删除缓存和更新数据库之间有窗口。读请求可能在这个窗口里把旧数据库值重新写入缓存。
所以不建议把“先删缓存再写库”作为默认方案。除非配合延迟双删、强互斥、读写串行化等额外机制,否则容易留下旧缓存。
先写数据库再删缓存还有问题吗
有。它不是绝对强一致,只是综合风险最低。
flowchart TD
A["更新 MySQL 成功"] --> B["删除 Redis"]
B --> C{"删除是否成功"}
C -- "成功" --> D["下一次读回源重建"]
C -- "失败" --> E["Redis 保留旧值"]
E --> F["重试、TTL、Binlog 补偿"]主要风险:
| 风险 | 说明 | 解决办法 |
|---|---|---|
| 删除缓存失败 | Redis 网络异常、超时、权限问题 | 重试任务、MQ、告警 |
| 事务未提交就删除 | 其他请求读旧库并重建旧缓存 | 事务提交后再删除 |
| 主从延迟 | 回源读从库旧数据 | 关键回源读主库,或延迟删除 |
| 热点并发回源 | 删除后大量请求查库 | 互斥重建、逻辑过期 |
| 多级缓存 | Redis 删除了,本地缓存没删 | 广播失效、本地 TTL |
所以正确说法不是“先写库再删缓存就万无一失”,而是:它是最常见基础策略,还要配套失败重试、TTL、补偿和并发保护。
四种写法的并发全过程
缓存一致性最容易学成结论:“先写库再删缓存”。但面试和生产排查真正考的是:每种写法在并发下为什么会错。
写法一:先更新缓存,再更新数据库
flowchart TD
A["写请求"] --> B["先更新 Redis 为新值"]
B --> C["更新 MySQL"]
C --> D{"MySQL 是否成功"}
D -- "成功" --> E["看起来一致"]
D -- "失败" --> F["Redis 是新值<br/>MySQL 是旧值"]
F --> G["读请求读到不存在的事实"]问题:
- 数据库事务可能失败或回滚。
- Redis 已经暴露了新值。
- 用户读到的是数据库里并不存在的事实。
- 如果是价格、权限、库存,就可能造成业务错误。
这个方案不适合把 MySQL 作为事实源的普通业务缓存。
写法二:先更新数据库,再更新缓存
看起来比第一种好,但并发下仍然可能旧值覆盖新值。
flowchart TD
A["请求 A 更新 MySQL 为 A1"] --> B["请求 B 更新 MySQL 为 B1"]
B --> C["请求 B 先更新 Redis 为 B1"]
A --> D["请求 A 后更新 Redis 为 A1"]
D --> E["MySQL 最终是 B1<br/>Redis 却是 A1"]这类问题通常发生在:
- 两个写请求并发更新同一个业务对象。
- 先提交数据库的新请求先写了缓存。
- 后完成的旧请求又把旧计算结果写回缓存。
所以直接更新缓存并不总是更一致,尤其是缓存值来自多表聚合、接口组装、权限计算、价格计算时,更容易出错。
写法三:先删除缓存,再更新数据库
这个方案的问题在“删除后、数据库更新前”的窗口。
flowchart TD
A["写请求删除 Redis"] --> B["Redis 为空"]
B --> C["读请求进来"]
C --> D["读 MySQL 旧值"]
D --> E["旧值写回 Redis"]
E --> F["写请求更新 MySQL 为新值"]
F --> G["Redis 长期保留旧值"]为什么旧值会长期存在?因为读请求把旧值重新写回了缓存,而写请求后面只更新数据库,没有再次删除缓存。
这也是“先删缓存再写库”最经典的并发问题。
写法四:先更新数据库,再删除缓存
这是最常用的基础方案。
flowchart TD
A["写请求更新 MySQL"] --> B["事务提交成功"]
B --> C["删除 Redis"]
C --> D["下一次读 Redis 未命中"]
D --> E["读取 MySQL 新值"]
E --> F["新值写回 Redis"]它为什么相对更好:
- 数据库先成为事实源。
- 删除缓存后,下一次读会基于数据库新值重建。
- 不需要在写接口里重新组装复杂缓存。
- 旧值覆盖新值的概率更低。
但它仍然有两个风险:
| 风险 | 说明 | 兜底 |
|---|---|---|
| 删除缓存失败 | Redis 超时、网络失败,旧值还在 | 重试、MQ、CDC、TTL |
| 极端并发读写 | 读请求在写事务提交前查旧库,写缓存晚于删除 | 延迟双删、版本号、短 TTL |
先写库再删缓存的极端并发窗口
很多文章会说“先写库再删缓存基本没问题”,但为了真正理解,要看极端情况。
flowchart TD
A["读请求 R 缓存未命中"] --> B["R 查询 MySQL 旧值"]
C["写请求 W 更新 MySQL 新值"] --> D["W 删除 Redis"]
B --> E["R 查询很慢,晚于 W"]
D --> F["Redis 已被删除"]
E --> G["R 把旧值写入 Redis"]
G --> H["Redis 又变成旧值"]这个窗口要求几个条件同时成立:
- 读请求先发生缓存未命中。
- 读请求查数据库很慢,拿到旧值后迟迟没写缓存。
- 写请求在中间完成数据库更新并删除缓存。
- 读请求最后把旧值写回 Redis。
概率不高,但热点 key、慢 SQL、数据库抖动时可能出现。
解决思路:
| 方案 | 作用 | 代价 |
|---|---|---|
| 延迟双删 | 写库后立即删一次,延迟后再删一次 | 延迟时间不好估,增加异步任务 |
| 缓存版本号 | 旧版本写缓存时被识别 | 需要业务版本字段 |
| 短 TTL | 旧值最多保留一段时间 | 增加回源 |
| 互斥重建 | 防止多个读请求同时回源写缓存 | 增加等待 |
| 关键读回源 | 关键场景不读缓存 | 牺牲性能 |
延迟双删全过程
延迟双删不是银弹,它解决的是“读请求慢查询后把旧值写回缓存”的窗口。
flowchart TD
A["写请求更新 MySQL"] --> B["事务提交成功"]
B --> C["第一次删除 Redis"]
C --> D["等待一段时间"]
D --> E["第二次删除 Redis"]
E --> F["下一次读回源新值"]为什么要延迟?因为要等并发读请求完成旧值回写,然后第二次删除把旧值清掉。
延迟时间怎么定
延迟时间不能拍脑袋。它应该参考:
| 参考项 | 说明 |
|---|---|
| 读数据库 P99 耗时 | 并发读查库多久能结束 |
| 缓存重建耗时 | 查库后组装 DTO、序列化、写 Redis 的时间 |
| 主从延迟 | 如果读从库,要考虑从库同步时间 |
| 业务可接受旧值时间 | 用户能接受旧数据多久 |
例如读库 P99 是 200ms,缓存组装 P99 是 100ms,主从延迟偶尔 300ms,可以考虑延迟 500ms 到 1s 再删一次。但这不是固定答案,必须看监控。
延迟双删的问题
| 问题 | 说明 |
|---|---|
| 仍然不是强一致 | 第二次删除前仍可能短暂读旧 |
| 延迟不好定 | 太短删不掉旧回写,太长旧值窗口变长 |
| 删除仍可能失败 | 第二次删除也要重试 |
| 高并发下任务多 | 大量延迟删除会带来任务系统压力 |
所以延迟双删适合对短暂旧值敏感但又不要求强一致的热点缓存。强一致业务仍应回数据库或业务服务。
Binlog CDC 兜底全过程
Binlog CDC 的思路是:既然 MySQL 是事实源,那就订阅 MySQL 的变更日志,用异步消费者修正缓存。
flowchart TD
A["业务更新 MySQL"] --> B["MySQL 写 binlog"]
B --> C["Canal / Debezium 订阅 binlog"]
C --> D["解析表名、主键、变更字段"]
D --> E["计算受影响缓存 key"]
E --> F["删除或刷新 Redis"]
F --> G["记录消费位点"]
F --> H["失败进入重试"]它适合解决:
- 业务代码漏删缓存。
- 删除缓存失败后异步补偿。
- 多个服务都会改同一张表,难以统一删缓存。
- 想用数据库变更做最终兜底。
但 CDC 也不是强一致:
| 风险 | 说明 |
|---|---|
| 消费延迟 | Binlog 到缓存删除有时间差 |
| 解析失败 | 表结构变化、字段变更可能导致消费者失败 |
| key 映射复杂 | 一条表变更可能影响多个列表、详情、聚合 key |
| 顺序问题 | 多表聚合缓存要处理事件顺序 |
| 消费失败 | 仍要重试、告警、死信和人工补偿 |
CDC 和业务删除缓存怎么配合
商业项目里更稳的模式通常是:
- 写接口事务提交后主动删除缓存,缩短不一致窗口。
- 删除失败写重试表或 MQ。
- Binlog CDC 作为兜底,再删一次相关 key。
- 所有缓存设置 TTL,防止补偿链路全失败时旧值永久存在。
flowchart TD
A["写接口提交事务"] --> B["主动删除 Redis"]
B --> C{"是否成功"}
C -- "失败" --> D["重试表或 MQ"]
C -- "成功" --> E["完成主链路"]
A --> F["MySQL binlog"]
F --> G["CDC 再次删除相关 key"]
D --> H["后台重试删除"]
G --> I["TTL 最终兜底"]
H --> I事务提交后再删缓存
在 Spring 里,不能在数据库事务还没提交时就删除缓存。更稳妥的方式是在事务提交后执行删除。
@Service
public class ProductService {
private final ProductRepository productRepository;
private final StringRedisTemplate redisTemplate;
private final CacheDeleteRetryRepository retryRepository;
public ProductService(ProductRepository productRepository,
StringRedisTemplate redisTemplate,
CacheDeleteRetryRepository retryRepository) {
this.productRepository = productRepository;
this.redisTemplate = redisTemplate;
this.retryRepository = retryRepository;
}
@Transactional
public void updateProductName(Long productId, String productName) {
Product product = productRepository.findById(productId);
if (product == null) {
throw new BizException("商品不存在");
}
product.setProductName(productName);
productRepository.update(product);
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() {
@Override
public void afterCommit() {
deleteCacheWithRetry("product:detail:" + productId);
}
});
}
private void deleteCacheWithRetry(String key) {
try {
redisTemplate.delete(key);
} catch (Exception ex) {
CacheDeleteRetryTask task = new CacheDeleteRetryTask();
task.setCacheKey(key);
task.setErrorMessage(ex.getMessage());
task.setStatus("WAIT_RETRY");
task.setRetryCount(0);
task.setNextRetryTime(new Date());
retryRepository.insert(task);
}
}
}注意点:
- 数据库事务失败时不要删缓存。
- 删除缓存失败不能只打日志。
- 删除缓存最好写成独立方法,方便重试和监控。
- 如果同一个业务更新影响多个 key,要统一管理 key 列表。
删除缓存失败怎么办
删除失败是最常见的追问。正确答案不是“再删一次”,而是要形成闭环。
flowchart TD
A["删除 Redis 失败"] --> B["记录重试任务"]
B --> C["后台任务扫描"]
C --> D["再次删除缓存"]
D --> E{"是否成功"}
E -- "成功" --> F["标记成功"]
E -- "失败但未超限" --> G["更新下次重试时间"]
G --> C
E -- "超过阈值" --> H["告警和人工处理"]重试表 Demo:
create table cache_delete_retry (
id bigint primary key auto_increment,
cache_key varchar(300) not null,
biz_type varchar(64) not null,
biz_id varchar(64) not null,
retry_count int not null default 0,
status varchar(32) not null,
error_message varchar(500),
next_retry_time datetime not null,
created_at datetime not null,
updated_at datetime not null,
key idx_status_retry(status, next_retry_time),
key idx_biz(biz_type, biz_id)
) engine = InnoDB default charset = utf8mb4;后台重试 Demo:
public class CacheDeleteRetryJob {
private final StringRedisTemplate redisTemplate;
private final CacheDeleteRetryRepository retryRepository;
public CacheDeleteRetryJob(StringRedisTemplate redisTemplate,
CacheDeleteRetryRepository retryRepository) {
this.redisTemplate = redisTemplate;
this.retryRepository = retryRepository;
}
public void execute() {
List<CacheDeleteRetryTask> tasks = retryRepository.findWaitRetry(100);
for (CacheDeleteRetryTask task : tasks) {
try {
redisTemplate.delete(task.getCacheKey());
retryRepository.markSuccess(task.getId());
} catch (Exception ex) {
int nextRetryCount = task.getRetryCount() + 1;
if (nextRetryCount >= 5) {
retryRepository.markFailed(task.getId(), ex.getMessage());
alarm(task, ex);
} else {
retryRepository.markRetry(
task.getId(),
nextRetryCount,
nextRetryTime(nextRetryCount),
ex.getMessage()
);
}
}
}
}
private Date nextRetryTime(int retryCount) {
int[] seconds = new int[] {10, 30, 60, 300, 900};
int index = Math.min(retryCount - 1, seconds.length - 1);
return new Date(System.currentTimeMillis() + seconds[index] * 1000L);
}
private void alarm(CacheDeleteRetryTask task, Exception ex) {
// 接入企业微信、钉钉、Prometheus Alertmanager 等告警渠道。
}
}这个方案解决的是:Redis 删除失败后,旧缓存不会永久存在而没人知道。
TTL 是最终兜底
所有缓存都建议设置过期时间。TTL 不是为了替代删除缓存,而是为了在删除失败、消息失败、代码漏删时给系统一个最终恢复机会。
| 数据类型 | 建议 TTL |
|---|---|
| 商品详情 | 几分钟到几十分钟,配合随机值 |
| 字典配置 | 几分钟到数小时,变更时主动删除 |
| 用户权限 | 较短 TTL,关键操作回权限服务 |
| 首页统计 | 可按业务接受程度设置 |
| 空值缓存 | 几十秒到几分钟 |
| 强一致状态 | 不建议依赖长 TTL 缓存 |
TTL 也不能完全随机乱设。太短会导致频繁回源数据库,太长会放大旧数据窗口。热点 key 的 TTL 还要加随机抖动,避免大量 key 同时过期。
private Duration randomTtlMinutes(int baseMinutes, int randomMinutes) {
int extra = ThreadLocalRandom.current().nextInt(randomMinutes + 1);
return Duration.ofMinutes(baseMinutes + extra);
}延迟双删
延迟双删的思路是:写库前删一次,写库后再删一次,隔一段时间再删一次。
flowchart TD
A["删除缓存"] --> B["更新 MySQL"]
B --> C["提交成功"]
C --> D["再次删除缓存"]
D --> E["延迟一段时间"]
E --> F["第三次删除缓存"]它想解决的问题是:并发读请求可能在写库期间把旧值写回缓存,延迟再删一次可以清理这个旧值。
但延迟双删有明显缺点:
| 问题 | 说明 |
|---|---|
| 延迟时间难确定 | 太短删不掉旧值,太长旧值窗口变大 |
| 增加复杂度 | 需要延迟任务、线程池或 MQ |
| 不适合所有场景 | 高频写入会产生大量延迟删除任务 |
| 仍不是强一致 | 延迟期间仍可能读到旧值 |
适用场景:
- 高并发读写同一个 key。
- 回源可能读到旧库或从库旧数据。
- 业务能接受短暂旧值。
- 有可靠延迟任务或 MQ。
不建议把延迟双删当成万能答案。多数场景先用“写库后删缓存 + 重试 + TTL”,只有并发窗口明显时再加延迟双删。
Binlog CDC 修正缓存
对一致性要求更高、业务写入口很多的系统,可以订阅 MySQL Binlog,发现数据变更后异步删除或刷新缓存。
flowchart TD
A["业务服务写 MySQL"] --> B["MySQL binlog"]
B --> C["Canal 或 Debezium"]
C --> D["缓存失效事件"]
D --> E["删除 Redis key"]
E --> F["失败重试和告警"]优点:
| 优点 | 说明 |
|---|---|
| 侵入少 | 不需要每个写接口都手动删缓存 |
| 能兜住漏删 | 人工 SQL 或旧代码更新也能捕获 |
| 统一治理 | 可以集中处理缓存 key 删除和重试 |
难点:
| 难点 | 说明 |
|---|---|
| 表到 key 的映射 | 一张表变更可能影响多个缓存 key |
| 跨表缓存 | 商品详情可能来自多张表 |
| 延迟 | Binlog 消费有延迟,不是强实时 |
| 位点可靠性 | CDC 消费位点要能恢复 |
| 重复事件 | 删除缓存必须幂等 |
Binlog CDC 更适合做补偿和统一修正,不一定替代业务代码里的主动删缓存。成熟系统常常组合使用:业务写后主动删除,CDC 兜底修正。
版本号和缓存内容设计
如果缓存对象里带版本号或更新时间,排查会容易很多。
{
"id": 10001,
"productName": "无线蓝牙耳机",
"price": 29900,
"version": 18,
"updatedAt": "2026-07-04T10:00:00"
}好处:
- 用户反馈旧数据时,可以直接比较 Redis 和 MySQL 的
version。 - 补偿任务可以按版本判断是否落后。
- 多级缓存可以判断本地缓存是否过期。
- 排查时不用只靠感觉判断“是不是旧缓存”。
但版本号不能代替删除缓存。版本号是诊断和防旧覆盖工具,不是自动同步机制。
版本号防旧值回写
前面讲过一种极端并发窗口:读请求先查到数据库旧值,但写缓存动作发生得很晚;写请求中间已经把数据库改成新值并删除缓存;最后慢读请求又把旧值写回 Redis。
flowchart TD
A["读请求 R 未命中 Redis"] --> B["R 查询 MySQL 得到 version=18"]
C["写请求 W 更新 MySQL 为 version=19"] --> D["W 删除 Redis"]
B --> E["R 因网络或序列化很慢"]
D --> F["Redis 已空"]
E --> G["R 尝试写入旧缓存 version=18"]
G --> H["如果不校验版本,旧值覆盖缓存"]版本号防旧值回写的思路是:缓存里保存数据版本,写缓存前先判断“当前要写入的版本是否比缓存中已有版本新”。如果要写入的是旧版本,就拒绝写入。
这类方案适合:
| 场景 | 原因 |
|---|---|
| 热点详情缓存 | 并发读写高,慢读回写旧值概率更高 |
| 多表聚合缓存 | 组装缓存耗时更长,更容易晚写 |
| 本地缓存 + Redis | 多级缓存里旧值更难排查 |
| 用户反馈旧数据敏感 | 版本号可以直接定位旧值来源 |
不适合把它当成唯一方案。它解决的是“旧值不要覆盖新值”,仍然要配合写库后删缓存、删除失败重试、TTL 和关键读回源。
版本号写缓存 Demo
先让缓存对象携带版本:
public class CacheValue<T> {
private T data;
private long version;
private LocalDateTime cachedAt;
public CacheValue(T data, long version) {
this.data = data;
this.version = version;
this.cachedAt = LocalDateTime.now();
}
public T getData() {
return data;
}
public long getVersion() {
return version;
}
}数据库表也要有可比较版本字段,常见选择:
| 字段 | 说明 |
|---|---|
version | 乐观锁版本号,每次更新递增 |
updated_at | 更新时间,精度和时钟一致性要注意 |
data_version | 业务数据版本,适合多表聚合缓存 |
写 Redis 前用 Lua 做“只允许新版本覆盖旧版本”。这样比较版本和写入缓存是 Redis 端原子执行,不会被其他客户端插入打断。
local key = KEYS[1]
local newVersion = tonumber(ARGV[1])
local newValue = ARGV[2]
local ttlSeconds = tonumber(ARGV[3])
local oldVersion = redis.call('HGET', key, 'version')
if oldVersion ~= false and tonumber(oldVersion) > newVersion then
return 0
end
redis.call('HSET', key, 'version', newVersion, 'value', newValue)
redis.call('EXPIRE', key, ttlSeconds)
return 1Java 侧伪代码:
public AssetDTO getAsset(Long assetId) {
String key = "asset:detail:" + assetId;
AssetDTO cached = readCache(key);
if (cached != null) {
return cached;
}
AssetPO po = assetRepository.findById(assetId);
if (po == null) {
writeNullCache(key);
return null;
}
AssetDTO dto = convert(po);
writeCacheIfNewer(key, po.getVersion(), dto, Duration.ofMinutes(30));
return dto;
}这个 Demo 的关键点不是语法,而是顺序:
flowchart TD
A["读请求回源 MySQL"] --> B["拿到数据和 version"]
B --> C["准备写 Redis"]
C --> D["Lua 读取 Redis 当前 version"]
D --> E{"当前 version 是否更新?"}
E -->|"是"| F["拒绝旧值写入"]
E -->|"否"| G["写入新值和 TTL"]为什么只比较版本不够
版本号能防“旧值覆盖新值”,但不能解决所有一致性问题:
| 问题 | 版本号能否解决 | 还需要 |
|---|---|---|
| 删除 Redis 失败 | 不能 | 重试、MQ、CDC、TTL |
| 本地缓存旧值 | 不能完全 | 本地短 TTL、广播失效 |
| 回源读从库旧值 | 部分能识别 | 关键回源读主库、主从延迟治理 |
| 多个缓存 key 漏删 | 不能 | key 影响范围管理 |
| 强一致读 | 不能 | 直接读事实源或业务服务 |
如果发现要写入的版本比 Redis 旧,通常说明发生了慢读回写、主从延迟或乱序事件。此时应该记录指标:
cache_stale_write_rejected_total{cache="asset:detail"} +1这个指标对线上排查很有价值:它能告诉你“旧缓存回写不是理论风险,线上真的发生过”。
热点 key 重建和一致性
删除热点 key 后,可能大量请求同时未命中 Redis,全部打到数据库,这就是缓存击穿。
flowchart TD
A["热点 key 被删除或过期"] --> B["大量请求未命中"]
B --> C["同时查 MySQL"]
C --> D["数据库压力暴涨"]
D --> E["接口超时"]处理方式:
| 方案 | 思路 | 一致性影响 |
|---|---|---|
| 互斥锁重建 | 只有一个线程查库写缓存 | 其他请求等待或返回空 |
| 逻辑过期 | 先返回旧值,后台异步重建 | 会短暂返回旧数据 |
| 热点预热 | 提前加载热点缓存 | 变更后仍要删除或刷新 |
| 本地缓存 | 降低 Redis 压力 | 增加本地缓存一致性问题 |
逻辑过期适合商品详情、文章详情、配置展示等允许短暂旧值的读多写少场景。不适合支付状态、权限最终判断、库存最终确认。
本地缓存的一致性
很多高并发系统不只用 Redis,还会用 Caffeine、Guava Cache 或应用内 Map 做本地缓存。此时一致性链路变成:
flowchart TD
A["MySQL"] --> B["Redis"]
B --> C["应用本地缓存"]
C --> D["接口返回"]本地缓存的问题是:Redis 删除了,本地缓存可能还在。解决思路:
| 方案 | 说明 |
|---|---|
| 本地短 TTL | 最简单,接受短暂旧值 |
| Redis Pub/Sub 广播 | 数据变更后通知各实例删除本地缓存 |
| MQ 广播 | 更可靠,适合多实例 |
| 配置中心推送 | 适合配置和字典 |
| 关键读绕过本地缓存 | 权限、交易类关键判断 |
本地缓存只适合读多写少、允许短暂旧值的数据。不要把强一致权限或账户余额长期放在本地缓存里直接做最终判断。
其他缓存模式对比
| 模式 | 读写方式 | 优点 | 风险 | 适合场景 |
|---|---|---|---|---|
| Cache Aside | 应用读写缓存和数据库 | 简单、最常用 | 需要处理删除失败 | 大多数业务缓存 |
| Read Through | 应用只读缓存,缓存层负责回源 | 对应用透明 | 缓存组件复杂 | 统一缓存平台 |
| Write Through | 写缓存时同步写数据库 | 写入路径统一 | 写延迟高,失败复杂 | 少量强管控写入 |
| Write Behind | 先写缓存,异步写数据库 | 写入快 | 丢数据和一致性风险大 | 日志、统计等可补偿场景 |
| Refresh Ahead | 快过期时提前刷新 | 降低击穿 | 需要预测热点 | 热点数据 |
普通业务最常用还是 Cache Aside。Write Behind 不能用于资金、订单最终状态、库存最终扣减这类核心事实数据。
读己之写怎么处理
有些场景用户刚修改完资料,下一秒进入详情页,希望马上看到新值。普通最终一致缓存可能短暂读到旧值。
处理方式:
| 方案 | 说明 |
|---|---|
| 写后短时间读数据库 | 修改成功后的详情页直接查 MySQL |
| 删除缓存后再返回 | 确保缓存删除完成再返回,失败则提示或降级 |
| 返回写入后的新值 | 写接口直接返回最新 DTO |
| 前端本地更新展示 | 适合非关键展示字段 |
| 使用版本号判断 | 读到旧版本时回源数据库 |
关键点是按业务体验选择,不要为了所有读请求都强一致而把缓存价值打没。
商业场景怎么选
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 商品详情 | Cache Aside + 写库后删缓存 + TTL + 热点互斥重建 | 读多写少,允许短暂旧值 |
| 商品价格展示 | 短 TTL + 删除缓存 + 下单回源校验 | 展示可短暂旧,下单必须准 |
| 库存最终扣减 | 数据库或库存服务强约束,Redis 只做预热或预扣 | 不能只信缓存 |
| 用户权限 | 短 TTL + 变更主动删除 + 关键操作回权限服务 | 权限错误风险高 |
| 字典配置 | Redis + 本地缓存 + 变更广播 | 读多写少 |
| 资产详情 | Cache Aside + 标签/字段变更删除多个 key + 补偿 | 文档可能来自多表 |
| 首页统计 | 定时刷新或异步统计 | 通常允许延迟 |
资产平台示例
医疗数据采集与资产平台里,资产详情可能包含资产表、字段表、机构表、标签表、采集任务状态等信息。
缓存 key:
asset:detail:{assetId}
asset:fields:{assetId}
asset:tags:{assetId}
org:asset:list:{orgId}:{pageNo}一致性设计:
- 资产编辑先更新 MySQL。
- 事务提交后删除
asset:detail:{assetId}。 - 字段变更删除
asset:fields:{assetId}和asset:detail:{assetId}。 - 标签变更删除
asset:tags:{assetId}和asset:detail:{assetId}。 - 上下线变更还要删除机构资产列表缓存。
- 删除失败写入重试任务。
- 缓存设置随机 TTL。
- 关键审批、权限判断回 MySQL 或权限服务。
这个场景里最大的坑是:只删除了详情 key,没有删除列表 key 或标签 key,导致详情新、列表旧、搜索筛选旧。
线上排查流程
用户看到旧数据
flowchart TD
A["用户看到旧数据"] --> B["查 MySQL 当前值"]
B --> C["查 Redis 当前值"]
C --> D{"Redis 是否旧值"}
D -- "否" --> E["查本地缓存或前端缓存"]
D -- "是" --> F["查写接口是否删缓存"]
F --> G["查删除失败重试记录"]
G --> H["查 TTL 和版本号"]排查清单:
| 检查项 | 说明 |
|---|---|
| MySQL 当前值 | 事实源是否已经更新 |
| Redis 值 | 是否旧版本、TTL 还剩多久 |
| 本地缓存 | JVM 进程内是否还有旧值 |
| 删除日志 | 写接口是否执行删除 |
| 重试任务 | 删除失败是否进入重试 |
| 事务时机 | 是否事务未提交就删除 |
| 主从延迟 | 回源是否读了从库旧值 |
| key 范围 | 是否漏删列表、聚合、标签 key |
删除缓存失败
重点看:
- Redis 是否超时或连接池耗尽。
- 删除命令是否被权限或网络拦截。
- key 是否拼错。
- 删除失败是否进入重试表或 MQ。
- 重试任务是否正常运行。
- 失败次数是否告警。
数据库突然被打满
可能不是数据库本身问题,而是缓存层把流量放下来了。
| 现象 | 可能原因 |
|---|---|
| Redis 命中率下降 | 大量 key 过期、淘汰策略触发、缓存删除过猛 |
| 某个接口 DB QPS 暴涨 | 热点 key 击穿 |
| 大量查询不存在 ID | 缓存穿透 |
| Redis timeout 同时 DB 慢 | 回源变慢拖住应用线程 |
处理方向:
- 查 Redis 命中率和 key 过期情况。
- 查是否有热点 key 被删除或过期。
- 加互斥重建或逻辑过期。
- 增加限流和降级保护数据库。
- 对不存在数据做空值缓存或布隆过滤器。
监控指标
| 指标 | 为什么重要 |
|---|---|
| Redis 命中率 | 判断缓存是否有效保护数据库 |
| 回源数据库 QPS | 判断缓存失效对数据库的冲击 |
| 缓存删除失败数 | 判断一致性风险 |
| 重试任务积压 | 判断失败是否长期未修复 |
| key TTL 分布 | 判断是否大量同时过期 |
| 热点 key 访问量 | 判断是否存在击穿风险 |
| 本地缓存命中率 | 判断多级缓存是否生效 |
| 旧版本命中次数 | 判断用户是否读到旧数据 |
| Redis 连接池等待 | 判断应用是否卡在 Redis 客户端 |
没有监控的缓存一致性方案,本质上是靠运气。
常见错误
| 错误 | 后果 | 正确做法 |
|---|---|---|
| 把 Redis 当事实源 | 缓存丢失或淘汰后数据不可恢复 | MySQL 保存事实 |
| 先删缓存再写库 | 并发读可能写回旧值 | 默认先写库再删缓存 |
| 事务未提交就删缓存 | 其他请求可能读旧库重建旧缓存 | afterCommit 后删除 |
| 删除失败只打日志 | 旧缓存可能长期存在 | 重试、告警、TTL |
| 直接更新复杂缓存 | 并发下旧值覆盖新值 | 删除缓存后回源重建 |
| 缓存不设 TTL | 旧数据和无效数据长期存在 | 所有缓存设置合理 TTL |
| 强一致业务依赖缓存 | 下单、支付、权限可能错 | 关键判断回事实源 |
| 只删详情不删列表 | 列表旧、详情新 | 维护 key 影响范围 |
| Bulk 删除无失败记录 | 漏删后无法定位 | 逐个 key 记录结果 |
关联知识点
| 知识点 | 继续学习什么 |
|---|---|
| Redis 缓存问题 | 穿透、击穿、雪崩 |
| Redis 高并发缓存治理 | 大 Key、热 Key、限流降级、生产排查 |
| 布隆过滤器原理 | 缓存穿透治理 |
| 数据一致性设计 | 强一致、最终一致、补偿和对账 |
| 可靠消息与本地消息表 | 删除失败异步补偿 |
| CDC与Outbox数据同步 | MySQL 到 Redis、ES、MQ 的可靠传播、位点、重放和对账 |
| Redis 面试题 | 按题目复习缓存一致性相关问法 |
本章小结
Redis 与数据库一致性的核心是:数据库保存事实,Redis 保存可重建副本。最常用方案是 Cache Aside:读缓存,未命中查库并写缓存;写操作先更新数据库,事务提交后删除缓存。删除失败要进入重试、告警和补偿,缓存本身要设置 TTL 作为兜底。对热点 key 要防击穿,对多级缓存要处理本地失效,对关键交易和权限判断要回事实源。成熟系统不是追求缓存和数据库每一刻强一致,而是让短暂不一致可控、可观测、可修复。
