
在高并发系统中,Redis 经常被放在数据库前面,用来降低查询延迟和数据库压力。
一个典型的查询流程如下:
客户端请求
↓
查询 Redis
↓
缓存命中:直接返回
缓存未命中:查询 MySQL
↓
将结果写入 Redis
↓
返回数据
这个流程看起来并不复杂,但只要业务数据会发生修改,就绕不开一个问题:
MySQL 中的数据已经更新,Redis 中保存的还是旧值,应该怎么办?
例如用户在后台修改昵称:
数据库:新昵称
缓存:旧昵称
接口继续读取缓存后,页面上看到的仍然是旧数据。
类似问题还可能出现在:
商品价格修改
订单状态变化
用户权限更新
套餐额度扣减
配置参数调整
会议任务状态更新
缓存一致性并不意味着 Redis 和 MySQL 在任何时刻都绝对相同,而是要根据业务要求,在性能、复杂度和一致性之间做取舍。
一、最常见的缓存模式:Cache Aside
大部分业务系统使用的是 Cache Aside,也叫旁路缓存模式。
读取流程:
1. 先查询缓存
2. 缓存存在则直接返回
3. 缓存不存在则查询数据库
4. 将数据库结果写入缓存
伪代码如下:
public User queryUser(Long userId) {
String cacheKey = "user:" + userId;
User cachedUser = redisTemplate
.opsForValue()
.get(cacheKey);
if (cachedUser != null) {
return cachedUser;
}
User user = userMapper.selectById(userId);
if (user != null) {
redisTemplate
.opsForValue()
.set(
cacheKey,
user,
Duration.ofMinutes(30)
);
}
return user;
}
真正需要讨论的是写入流程。
通常有几种选择:
先更新数据库,再更新缓存
先更新缓存,再更新数据库
先删除缓存,再更新数据库
先更新数据库,再删除缓存
这几种方案看起来差别不大,但在并发环境中结果完全不同。
二、为什么不推荐"先更新缓存,再更新数据库"?
假设当前商品价格是 100 元。
业务执行:
1. 将缓存改为 80 元
2. 将数据库改为 80 元
如果第一步成功,第二步失败,就会出现:
Redis:80
MySQL:100
后续请求读到的是缓存中的 80 元,但数据库真实数据仍然是 100 元。
更麻烦的是,缓存可能持续存在较长时间。
即使系统发现数据库更新失败,也不一定能可靠地把缓存恢复到原值。
因此,通常不建议把"更新缓存"放在"更新数据库"前面。
三、为什么"先更新数据库,再更新缓存"也有问题?
另一种直觉方案是:
1. 更新数据库
2. 更新缓存
单个请求执行时没有明显问题。
但并发写入时可能发生覆盖。
假设两个请求同时修改同一条数据:
请求 A:把价格改为 80
请求 B:把价格改为 60
实际执行顺序可能是:
请求 A 更新数据库为 80
请求 B 更新数据库为 60
请求 B 更新缓存为 60
请求 A 更新缓存为 80
最终结果:
MySQL:60
Redis:80
数据库中的最新结果被旧请求写回了缓存。
此外,更新缓存还会增加不必要的操作。
某条数据更新后可能很久都不会再被读取,但系统仍然立即计算并写入了缓存。
所以,在 Cache Aside 模式中,更常见的做法不是更新缓存,而是删除缓存。
四、推荐方案:先更新数据库,再删除缓存
常见写入流程是:
1. 更新数据库
2. 删除对应缓存
代码示例:
@Transactional
public void updateUser(
Long userId,
UpdateUserRequest request
) {
userMapper.updateUser(
userId,
request.getNickname()
);
redisTemplate.delete(
"user:" + userId
);
}
删除缓存后,下一次查询会重新读取数据库,并把最新结果写入缓存。
这种方案有几个优点:
数据库仍然是主要数据源
不需要主动计算新缓存
修改后缓存自然失效
实现相对简单
但它仍然不是绝对一致的。
五、"更新数据库,再删除缓存"什么时候会失败?
假设:
1. 数据库更新成功
2. Redis 删除失败
此时缓存中仍然保留旧值。
常见原因包括:
Redis 网络超时
Redis 实例故障
应用进程在删除前退出
连接池耗尽
代码执行异常
因此,删除缓存不能被当成一个永远成功的操作。
至少要考虑:
失败重试
缓存过期时间
删除失败监控
异步补偿
一个最基础的保护措施,是所有业务缓存都设置过期时间。
即使删除失败,旧数据也不会永久存在。
redisTemplate
.opsForValue()
.set(
cacheKey,
value,
Duration.ofMinutes(30)
);
过期时间不能完全解决一致性问题,但可以限制旧数据的最长存活时间。
六、为什么一般不推荐"先删除缓存,再更新数据库"?
流程如下:
1. 删除缓存
2. 更新数据库
在并发读取时,可能出现下面的情况:
请求 A 删除缓存
请求 B 查询缓存,发现不存在
请求 B 查询数据库,读到旧值
请求 A 更新数据库
请求 B 将旧值写回缓存
最终:
MySQL:新值
Redis:旧值
而且这个旧缓存可能一直存在到过期。
因此,相比"先删缓存再更新数据库",通常更推荐:
先更新数据库
再删除缓存
七、什么是延迟双删?
为了处理部分并发场景,有些系统会使用延迟双删。
流程如下:
1. 删除缓存
2. 更新数据库
3. 等待一段时间
4. 再次删除缓存
伪代码:
public void updateProduct(
Long productId,
ProductUpdateRequest request
) {
String key = "product:" + productId;
redisTemplate.delete(key);
productMapper.updateProduct(
productId,
request
);
delayedExecutor.execute(
() -> redisTemplate.delete(key),
500,
TimeUnit.MILLISECONDS
);
}
第二次删除的作用是清理并发读取过程中可能重新写入的旧缓存。
但延迟双删并不是通用答案。
它存在几个问题:
延迟时间很难准确设置
线程休眠会占用资源
应用重启后延迟任务可能丢失
第二次删除仍然可能失败
代码流程更加复杂
如果确实要使用,建议通过延迟消息或任务队列实现,而不是在业务线程中直接 sleep。
八、读写并发下的缓存回填问题
即使采用"更新数据库,再删除缓存",仍然存在一个概率较低的并发问题。
假设初始数据库值为 A。
执行顺序:
请求 1 查询缓存,未命中
请求 1 开始查询数据库,读到 A
请求 2 将数据库更新为 B
请求 2 删除缓存
请求 1 将刚才读到的 A 写入缓存
最终:
MySQL:B
Redis:A
这个问题出现需要满足一个条件:
旧查询在数据库更新前开始,
但在缓存删除后才完成回填。
如果查询速度很快、写操作较少,这种情况概率通常较低。
但在高一致性业务中,仍然需要额外处理。
九、方案一:给缓存写入增加版本号
可以在数据库记录中增加版本字段:
CREATE TABLE product (
id BIGINT PRIMARY KEY,
price DECIMAL(10, 2),
version BIGINT NOT NULL
);
更新时版本递增:
UPDATE product
SET price = 80,
version = version + 1
WHERE id = 10001;
缓存中同时保存:
{
"id": 10001,
"price": 80,
"version": 12
}
回填缓存前,对比当前版本。
只有新版本数据才能覆盖旧版本。
这种方案思路清晰,但实现成本较高,通常适合:
状态变更频繁
数据不能回退
同一 Key 并发读写较多
的业务。
十、方案二:使用分布式锁控制缓存重建
缓存失效后,大量请求可能同时查询数据库。
可以在缓存重建时使用分布式锁:
public Product queryProduct(Long productId) {
String cacheKey =
"product:" + productId;
Product product =
getFromCache(cacheKey);
if (product != null) {
return product;
}
String lockKey =
"lock:product:" + productId;
boolean locked =
tryLock(lockKey, Duration.ofSeconds(5));
if (!locked) {
sleepShortTime();
return queryProduct(productId);
}
try {
product = getFromCache(cacheKey);
if (product != null) {
return product;
}
product =
productMapper.selectById(productId);
setCache(cacheKey, product);
return product;
} finally {
unlock(lockKey);
}
}
锁内需要再次检查缓存,因为等待锁期间,其他线程可能已经完成缓存重建。
这就是常见的双重检查。
分布式锁主要解决:
缓存击穿
并发回填
数据库瞬时压力
但不要给所有缓存查询无差别加锁,否则 Redis 锁本身可能成为性能瓶颈。
十一、方案三:通过消息队列异步删除缓存
为了降低删除失败的影响,可以在数据库更新成功后发送消息。
数据库更新成功
↓
发送缓存失效消息
↓
消费者删除 Redis Key
↓
失败后自动重试
消息内容可以非常简单:
{
"bizType": "PRODUCT",
"bizId": 10001,
"cacheKey": "product:10001"
}
消费者:
public void consume(
CacheInvalidationMessage message
) {
redisTemplate.delete(
message.getCacheKey()
);
}
优势:
删除失败可以重试
业务更新与缓存处理解耦
可以统一监控缓存失效任务
但也会引入新的问题:
消息是否发送成功
消息是否重复消费
删除缓存是否具备幂等性
队列积压时旧缓存存在多久
删除操作天然具有较好的幂等性。
同一个 Key 删除多次,最终结果仍然是不存在。
十二、数据库事务和消息发送怎么保证一致?
下面这种写法存在风险:
@Transactional
public void updateProduct(Product product) {
productMapper.update(product);
messageProducer.send(
new CacheInvalidationMessage(...)
);
}
可能出现:
数据库事务回滚,但消息已经发出
数据库提交成功,但消息发送失败
更可靠的方式包括:
事务消息
本地消息表
Outbox Pattern
订阅数据库 Binlog
十三、使用本地消息表
可以在同一个数据库事务中:
更新业务数据
写入待发送消息
示例:
@Transactional
public void updateProduct(
ProductUpdateRequest request
) {
productMapper.update(request);
OutboxEvent event =
new OutboxEvent();
event.setEventType(
"CACHE_INVALIDATE"
);
event.setPayload(
JSON.toJSONString(
Map.of(
"cacheKey",
"product:" + request.getId()
)
)
);
outboxEventMapper.insert(event);
}
后台任务持续扫描未发送消息:
查询待发送事件
发送到消息队列
更新事件状态
因为业务数据和消息记录处于同一个数据库事务中,所以不会出现只成功一半的情况。
这种方案可靠性较高,但需要额外维护消息表、扫描任务和清理机制。
十四、订阅 Binlog 删除缓存
另一种思路是不让业务代码主动发送缓存失效消息。
系统订阅 MySQL Binlog:
MySQL 数据发生变化
↓
Binlog 事件
↓
同步服务解析变更
↓
删除相关 Redis 缓存
这种方式可以减少业务代码侵入。
优点:
数据库变更是最终依据
业务服务不需要显式处理缓存
多个写入入口可以统一覆盖
局限:
需要维护 Binlog 消费系统
表结构变化会影响解析
数据库字段与缓存 Key 需要映射
缓存失效存在一定延迟
适合数据量较大、写入入口较多、缓存体系较复杂的系统。
十五、缓存过期时间应该怎么设置?
缓存时间不是越长越好,也不是越短越安全。
过期时间太长:
旧数据保留时间更长
删除失败影响更明显
过期时间太短:
缓存命中率下降
数据库查询压力增加
大量 Key 可能同时失效
通常要考虑:
数据更新频率
业务可接受的旧数据时间
查询压力
缓存重建成本
例如:
| 数据类型 | 参考策略 |
|---|---|
| 国家地区列表 | 数小时或数天 |
| 用户基本资料 | 数十分钟 |
| 商品详情 | 数分钟到数十分钟 |
| 库存 | 短缓存或不直接缓存最终结果 |
| 权限信息 | 较短时间,并在修改后主动删除 |
| 系统配置 | 主动通知刷新,同时保留过期兜底 |
为了避免大量 Key 同时失效,可以增加随机时间:
long baseSeconds = 1800;
long randomSeconds =
ThreadLocalRandom.current()
.nextLong(0, 300);
Duration ttl = Duration.ofSeconds(
baseSeconds + randomSeconds
);
这样可以降低缓存雪崩风险。
十六、如何处理缓存空值?
查询一个不存在的用户时,如果不缓存空结果,每次请求都会访问数据库。
攻击者可以不断使用随机 ID 请求:
user:100000001
user:100000002
user:100000003
这类问题称为缓存穿透。
一种简单方式是缓存空值:
if (user == null) {
redisTemplate
.opsForValue()
.set(
cacheKey,
NULL_VALUE,
Duration.ofMinutes(2)
);
return null;
}
空值缓存时间通常应短于正常数据。
否则数据刚创建后,用户可能因为旧的空值缓存而暂时查询不到。
十七、实时业务中的缓存一致性
实时语音、在线会议和 AI 翻译系统同样会使用缓存。
常见缓存内容包括:
用户套餐和剩余时长
当前会议状态
模型配置
语言设置
术语关键词
语音播报选项
短期会话信息
例如**同言翻译(Transync AI)**这类实时翻译产品,在会议过程中可能频繁读取用户设置和会话配置。
这类数据需要区分两种情况。
第一类:允许短时间不一致
例如:
界面偏好
最近使用的声音
悬浮字幕窗口位置
短时间读取旧值通常不会造成严重后果。
第二类:要求较强一致性
例如:
剩余可用时长
会员状态
企业席位状态
会议任务是否已结束
这类数据如果只依赖缓存,可能导致:
额度重复扣减
过期用户继续使用
任务状态回退
多个设备同时修改产生覆盖
因此,缓存只能作为查询加速层。
涉及额度、支付和权益的最终判断,通常仍应以数据库事务或专门的计费服务为准。
十八、是否所有业务都需要强一致?
不是。
越强的一致性通常意味着:
更高延迟
更复杂实现
更多外部依赖
更低系统可用性
可以按业务分类。
强一致场景
支付
余额
库存最终扣减
会员权益
计费额度
这些数据不应该只依赖缓存结果。
最终一致场景
用户资料
商品详情
配置项
会议状态展示
统计数据
短时间不一致通常可以接受。
可容忍旧数据场景
热门排行
推荐结果
历史报表
非核心页面信息
这类数据甚至可以在数据库异常时直接返回旧缓存。
在设计缓存方案前,先明确业务到底能接受多长时间的不一致。
十九、缓存一致性需要监控什么?
建议监控:
缓存命中率
缓存删除失败次数
缓存重建耗时
数据库回源请求量
热点 Key 请求量
消息队列积压数量
缓存失效消息重试次数
Redis 响应延迟
空值缓存数量
还可以对关键数据进行定期抽样比对:
随机读取一批数据库记录
读取对应缓存
对比关键字段是否一致
发现不一致后记录:
{
"cacheKey": "product:10001",
"dbVersion": 12,
"cacheVersion": 10,
"detectedAt": "2026-06-16T10:30:00"
}
没有监控时,缓存问题通常只能等用户反馈后才被发现。
二十、缓存一致性检查清单
开发缓存功能时,可以逐项检查:
1. 数据库是否是最终数据源?
2. 写入后是更新缓存还是删除缓存?
3. 是否采用先更新数据库、再删除缓存?
4. 缓存删除失败后如何重试?
5. 所有缓存是否设置过期时间?
6. 是否避免大量 Key 同时过期?
7. 缓存回填是否可能覆盖新数据?
8. 热点 Key 是否需要分布式锁?
9. 是否缓存不存在的数据?
10. 消息消费是否具备幂等性?
11. 数据库事务与消息发送如何保持一致?
12. 是否需要本地消息表或 Binlog 订阅?
13. 哪些业务要求强一致?
14. 哪些业务允许最终一致?
15. 是否监控删除失败和回源流量?
总结
Redis 和 MySQL 的缓存一致性,没有一种适用于所有业务的标准答案。
在大多数旁路缓存场景中,可以优先采用:
读取:
先查缓存
未命中再查数据库并回填
写入:
先更新数据库
再删除缓存
在此基础上,再根据业务风险增加:
合理的缓存过期时间
删除失败重试
消息队列异步补偿
分布式锁控制缓存重建
版本号防止旧值覆盖
本地消息表或 Binlog 订阅
真正重要的不是追求任何时刻都绝对一致,而是先明确:
业务允许旧数据存在多久?
数据错误会造成什么后果?
系统愿意为一致性付出多少成本?
只有回答这三个问题,才能选择合适的缓存更新策略。
