在后端系统里,MySQL + Redis 几乎是最常见的一套组合。

MySQL 负责持久化数据,Redis 负责缓存热点数据,提高接口性能。
但只要同时使用 Redis 和 MySQL,就绕不开一个经典问题:
Redis 和 MySQL 的数据不一致怎么办?
比如数据库里的商品价格已经从 100 修改成 120,Redis 里却还是 100。
用户访问接口时,如果读取 Redis,就可能继续看到旧价格。
这篇文章就从实际开发角度,把 Redis 和 MySQL 数据一致性问题完整讲清楚,并给出大量代码案例。
一、为什么会出现数据不一致?
假设我们有一个商品详情接口:
text
用户请求
↓
Redis
↓ 未命中
MySQL
通常读数据的逻辑是:
java
public Product getProduct(Long id) {
Product product = redis.get("product:" + id);
if (product != null) {
return product;
}
product = mysql.selectById(id);
redis.set("product:" + id, product);
return product;
}
这种模式叫:
text
Cache Aside
也叫:
text
旁路缓存模式
正常读取流程:
text
1. 查询 Redis
2. Redis 有数据
↓
直接返回
3. Redis 没数据
↓
查询 MySQL
4. MySQL 查询成功
↓
写入 Redis
5. 返回结果
看起来非常简单。
真正麻烦的是:
数据修改的时候怎么办?
二、最容易想到的方案:先更新 MySQL,再更新 Redis
很多刚接触缓存的开发者会这样写:
java
@Transactional
public void updateProduct(Product product) {
productMapper.updateById(product);
redisTemplate.opsForValue().set(
"product:" + product.getId(),
product
);
}
流程:
text
更新 MySQL
↓
更新 Redis
看起来非常合理。
但它其实存在并发问题。
三、为什么"更新数据库 + 更新缓存"会出问题?
假设数据库最开始:
text
price = 100
现在有两个线程同时更新:
text
线程 A:
price = 120
线程 B:
price = 150
正常情况下:
text
A 更新 MySQL:120
B 更新 MySQL:150
A 更新 Redis:120
B 更新 Redis:150
最终:
text
MySQL = 150
Redis = 150
没有问题。
但是并发情况下执行顺序可能变成:
text
A 更新 MySQL:120
B 更新 MySQL:150
B 更新 Redis:150
A 更新 Redis:120
最终变成:
text
MySQL = 150
Redis = 120
数据不一致。
而且 Redis 中这个错误数据可能长期存在。
所以:
修改数据时,一般不推荐"更新数据库 + 更新缓存"。
更常见的方式是:
text
更新数据库
+
删除缓存
四、为什么通常是删除缓存,而不是更新缓存?
例如商品:
json
{
"id": 1001,
"name": "MacBook Pro",
"price": 12999,
"sales": 1000,
"stock": 20
}
缓存中的商品数据,可能不是简单从一张表查出来的。
有可能需要:
text
product
+
product_category
+
product_brand
+
product_stock
+
promotion
最后拼装:
java
ProductDetail detail = new ProductDetail();
detail.setProduct(product);
detail.setCategory(category);
detail.setBrand(brand);
detail.setStock(stock);
detail.setPromotion(promotion);
如果每次数据库更新,都重新计算缓存,会非常麻烦。
所以更合理的思想是:
数据库更新时,只负责删除缓存。
下一次查询时,再重新从数据库加载。
也就是:
text
修改:
MySQL
↓
删除 Redis
读取:
Redis
↓ miss
MySQL
↓
重建 Redis
这就是经典的:
text
Cache Aside Pattern
五、方案一:先删除 Redis,再更新 MySQL
代码:
java
@Transactional
public void updateProduct(Product product) {
String key = "product:" + product.getId();
redisTemplate.delete(key);
productMapper.updateById(product);
}
流程:
text
删除 Redis
↓
更新 MySQL
这个方案有没有问题?
有。
而且是一个很经典的并发问题。
六、先删缓存为什么会出现脏数据?
假设当前数据:
text
MySQL:
price = 100
Redis:
price = 100
现在线程 A 修改价格:
text
100 → 120
线程 A:
text
删除 Redis
但是此时:
text
MySQL 还没有更新
与此同时线程 B 查询商品。
执行顺序可能变成:
text
线程 A:删除 Redis
线程 B:查询 Redis
↓
Redis miss
线程 B:查询 MySQL
↓
price = 100
线程 B:把 100 写入 Redis
线程 A:更新 MySQL
↓
price = 120
最终:
text
MySQL = 120
Redis = 100
数据不一致。
整个过程:
text
时间 →
线程A:
删除缓存
更新数据库120
线程B:
查询缓存miss
查询数据库100
写缓存100
最后缓存中的 100 可能一直存在。
所以一般不推荐:
text
先删除缓存
再更新数据库
七、方案二:先更新 MySQL,再删除 Redis
这是目前实际项目里最常见的方案。
代码:
java
@Transactional
public void updateProduct(Product product) {
productMapper.updateById(product);
redisTemplate.delete(
"product:" + product.getId()
);
}
执行顺序:
text
更新 MySQL
↓
删除 Redis
为什么这个方案更好?
因为即使发生并发,出现永久脏数据的概率会小很多。
八、为什么"先更新数据库,再删缓存"更安全?
假设:
text
MySQL = 100
Redis = 100
线程 A 查询商品。
线程 B 修改商品。
可能出现:
text
线程 A 查询 Redis
↓
得到100
线程 B 更新 MySQL
↓
120
线程 B 删除 Redis
线程 A 这次请求仍然可能拿到旧值:
text
100
但是之后:
text
Redis 已经被删除
下一次请求:
text
Redis miss
↓
查询 MySQL
↓
得到 120
↓
重新写 Redis
最终:
text
MySQL = 120
Redis = 120
所以这里产生的是:
短暂不一致。
而不是:
长时间缓存脏数据。
这也是实际工程中通常能够接受的。
九、标准的 Cache Aside 实现
查询:
java
public Product getProduct(Long id) {
String key = "product:" + id;
Product cache = redisCache.get(key);
if (cache != null) {
return cache;
}
Product product = productMapper.selectById(id);
if (product == null) {
return null;
}
redisCache.set(
key,
product,
30,
TimeUnit.MINUTES
);
return product;
}
更新:
java
@Transactional
public void updateProduct(Product product) {
productMapper.updateById(product);
redisCache.delete(
"product:" + product.getId()
);
}
删除:
java
@Transactional
public void deleteProduct(Long id) {
productMapper.deleteById(id);
redisCache.delete(
"product:" + id
);
}
这是最基本的版本。
十、问题又来了:Redis 删除失败怎么办?
假设流程:
text
更新 MySQL
↓
删除 Redis
但是 Redis 突然网络异常:
text
MySQL 更新成功
Redis 删除失败
最终:
text
MySQL:
price = 120
Redis:
price = 100
又不一致了。
例如:
java
@Transactional
public void updateProduct(Product product) {
productMapper.updateById(product);
redisTemplate.delete(
"product:" + product.getId()
);
}
如果:
java
redisTemplate.delete()
抛异常。
数据库事务应该怎么办?
很多人会想到:
text
Redis 删除失败
↓
MySQL 回滚
但问题是:
Redis 和 MySQL 本身不属于同一个事务。
十一、为什么 @Transactional 管不了 Redis?
例如:
java
@Transactional
public void updateProduct(Product product) {
productMapper.updateById(product);
redisTemplate.delete("product:" + product.getId());
}
@Transactional 通常只能管理:
text
MySQL transaction
比如:
sql
BEGIN;
UPDATE product
SET price = 120
WHERE id = 1001;
COMMIT;
但是 Redis:
text
DEL product:1001
是另一个独立系统。
MySQL 并不知道 Redis 有没有执行成功。
Redis 也不知道 MySQL 有没有提交成功。
所以本质上这是一个:
text
分布式数据一致性问题
十二、更隐藏的问题:事务还没有提交,就删除缓存了
这也是实际开发中特别容易踩坑的问题。
代码:
java
@Transactional
public void updateProduct(Product product) {
productMapper.updateById(product);
redisTemplate.delete(
"product:" + product.getId()
);
}
你以为执行顺序是:
text
更新数据库
↓
提交事务
↓
删除缓存
其实可能是:
text
UPDATE MySQL
↓
DELETE Redis
↓
COMMIT MySQL
因为:
java
@Transactional
真正的 COMMIT 往往发生在方法执行结束之后。
于是可能出现:
text
线程 A:
UPDATE MySQL = 120
DELETE Redis
此时事务还没有 commit
线程 B 恰好查询:
text
Redis miss
于是查数据库。
但是因为事务隔离:
text
线程 B 读取到旧数据100
然后:
text
Redis SET 100
最后:
text
线程 A COMMIT
最终:
text
MySQL = 120
Redis = 100
这其实是一个非常重要的问题。
十三、解决方案:事务提交后再删除 Redis
Spring 提供了事务同步机制。
可以在事务真正提交成功以后,再删除 Redis。
例如:
java
@Transactional
public void updateProduct(Product product) {
productMapper.updateById(product);
String key = "product:" + product.getId();
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
redisTemplate.delete(key);
}
}
);
}
执行流程变成:
text
UPDATE MySQL
↓
COMMIT
↓
DELETE Redis
相比:
text
UPDATE
↓
DELETE Redis
↓
COMMIT
更加安全。
十四、封装一个 AfterCommit 工具
实际项目里不可能每次都写一大坨:
java
TransactionSynchronizationManager
可以封装:
java
public class TransactionUtils {
public static void afterCommit(Runnable runnable) {
if (
TransactionSynchronizationManager
.isSynchronizationActive()
) {
TransactionSynchronizationManager
.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
runnable.run();
}
}
);
} else {
runnable.run();
}
}
}
业务代码:
java
@Transactional
public void updateProduct(Product product) {
productMapper.updateById(product);
String key = "product:" + product.getId();
TransactionUtils.afterCommit(() -> {
redisTemplate.delete(key);
});
}
代码会干净很多。
十五、方案三:延迟双删
这是 Redis 数据一致性里经常会听到的一个词:
text
延迟双删
它的基本思想是:
text
第一次删除缓存
↓
更新数据库
↓
等待一段时间
↓
第二次删除缓存
流程:
text
DEL Redis
↓
UPDATE MySQL
↓
sleep
↓
DEL Redis
代码:
java
public void updateProduct(Product product) {
String key = "product:" + product.getId();
redisTemplate.delete(key);
productMapper.updateById(product);
try {
Thread.sleep(500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
redisTemplate.delete(key);
}
为什么要删两次?
第一次:
text
删除旧缓存
第二次:
text
删除并发期间可能重新写进去的旧缓存
十六、延迟双删的完整并发过程
假设:
text
Redis = 100
MySQL = 100
线程 A 修改:
text
100 → 120
线程 B 查询。
执行:
text
A:删除 Redis
B:查询 Redis miss
B:查询 MySQL = 100
A:更新 MySQL = 120
B:Redis SET = 100
此时:
text
Redis = 100
MySQL = 120
但线程 A 稍微等待:
text
500ms
然后再次:
text
DEL Redis
最终:
text
Redis = empty
MySQL = 120
下次查询:
text
Redis miss
↓
MySQL = 120
↓
SET Redis = 120
数据恢复一致。
十七、不要真的 Thread.sleep
虽然:
java
Thread.sleep(500);
非常容易理解。
但线上项目通常不建议这么做。
因为 Web 请求线程是非常宝贵的。
假设:
text
Tomcat 线程 = 200
每个请求都睡:
text
500ms
并发一高:
text
大量线程全部 sleep
吞吐量会明显下降。
所以更合理的是:
text
异步延迟删除
例如:
java
executor.schedule(
() -> redisTemplate.delete(key),
500,
TimeUnit.MILLISECONDS
);
十八、ScheduledExecutorService 实现延迟双删
定义:
java
@Configuration
public class CacheConfig {
@Bean
public ScheduledExecutorService cacheExecutor() {
return Executors.newScheduledThreadPool(4);
}
}
业务代码:
java
@Service
public class ProductService {
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Resource
private ProductMapper productMapper;
@Resource
private ScheduledExecutorService cacheExecutor;
@Transactional
public void updateProduct(Product product) {
String key = "product:" + product.getId();
redisTemplate.delete(key);
productMapper.updateById(product);
cacheExecutor.schedule(
() -> redisTemplate.delete(key),
500,
TimeUnit.MILLISECONDS
);
}
}
但是这里仍然存在一个问题:
text
应用重启怎么办?
如果:
text
任务刚创建
↓
Java 服务宕机
第二次删除任务就丢了。
所以对于重要业务,我们还需要:
text
消息队列
十九、方案四:通过消息队列保证最终一致性
例如:
text
MySQL
+
RocketMQ
+
Redis
流程:
text
更新 MySQL
↓
发送 MQ
↓
消费者删除 Redis
结构:
text
┌──────────┐
│ MySQL │
└────┬─────┘
│
│
▼
┌──────────┐
│ MQ │
└────┬─────┘
│
▼
┌──────────┐
│ Redis │
└──────────┘
例如定义消息:
java
@Data
public class CacheDeleteMessage {
private String key;
}
更新商品:
java
@Transactional
public void updateProduct(Product product) {
productMapper.updateById(product);
CacheDeleteMessage message =
new CacheDeleteMessage();
message.setKey(
"product:" + product.getId()
);
rocketMQTemplate.convertAndSend(
"cache-delete-topic",
message
);
}
消费者:
java
@Component
@RocketMQMessageListener(
topic = "cache-delete-topic",
consumerGroup = "cache-delete-consumer"
)
public class CacheDeleteConsumer
implements RocketMQListener<CacheDeleteMessage> {
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Override
public void onMessage(CacheDeleteMessage message) {
redisTemplate.delete(
message.getKey()
);
}
}
二十、MQ 失败怎么办?
这时又出现一个经典问题。
假设:
text
MySQL 更新成功
↓
发送 MQ 失败
那么 Redis 仍然没有删除。
所以简单写:
java
updateMysql();
sendMq();
依然不能严格保证一致性。
我们需要解决:
text
数据库事务
+
MQ 消息
的一致性。
一种非常实用的方式就是:
本地消息表
二十一、本地消息表方案
我们在 MySQL 里增加一张表:
sql
CREATE TABLE cache_event (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
event_type VARCHAR(50) NOT NULL,
cache_key VARCHAR(255) NOT NULL,
status TINYINT NOT NULL DEFAULT 0,
retry_count INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP
);
其中:
text
status = 0
表示未处理
status = 1
表示已处理
更新业务数据时,同时插入事件:
java
@Transactional
public void updateProduct(Product product) {
productMapper.updateById(product);
CacheEvent event = new CacheEvent();
event.setEventType("DELETE");
event.setCacheKey(
"product:" + product.getId()
);
event.setStatus(0);
cacheEventMapper.insert(event);
}
因为:
text
product
和
cache_event
都属于同一个 MySQL。
所以可以进入同一个事务:
text
BEGIN
↓
UPDATE product
↓
INSERT cache_event
↓
COMMIT
要么:
text
全部成功
要么:
text
全部失败
二十二、后台任务处理缓存删除事件
定时扫描:
java
@Scheduled(fixedDelay = 1000)
public void processCacheEvent() {
List<CacheEvent> events =
cacheEventMapper.selectPendingEvents(100);
for (CacheEvent event : events) {
try {
redisTemplate.delete(
event.getCacheKey()
);
cacheEventMapper.markSuccess(
event.getId()
);
} catch (Exception e) {
cacheEventMapper.incrementRetry(
event.getId()
);
}
}
}
SQL:
sql
SELECT *
FROM cache_event
WHERE status = 0
ORDER BY id
LIMIT 100;
执行成功:
sql
UPDATE cache_event
SET status = 1
WHERE id = ?;
失败:
sql
UPDATE cache_event
SET retry_count = retry_count + 1
WHERE id = ?;
这样即使:
text
Redis 临时宕机
也没关系。
任务之后还会继续重试。
最终:
text
Redis 缓存一定会被删除
这就是:
text
最终一致性
二十三、更成熟的方案:Outbox Pattern
本地消息表其实就是一个非常经典的架构模式:
text
Transactional Outbox Pattern
简单理解:
把"需要通知其他系统的事件",先和业务数据一起写进数据库。
例如:
text
业务事务
↓
┌──────────────────────┐
│ MySQL │
│ │
│ product │
│ │
│ outbox_event │
└──────────────────────┘
↓
Outbox Worker
↓
MQ
↓
Consumer
↓
Redis
例如表:
sql
CREATE TABLE outbox_event (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
aggregate_type VARCHAR(100),
aggregate_id VARCHAR(100),
event_type VARCHAR(100),
payload JSON,
status VARCHAR(20),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
写业务:
java
@Transactional
public void updateProduct(Product product) {
productMapper.updateById(product);
OutboxEvent event = new OutboxEvent();
event.setAggregateType("PRODUCT");
event.setAggregateId(
product.getId().toString()
);
event.setEventType(
"PRODUCT_UPDATED"
);
event.setPayload(
JSON.toJSONString(product)
);
event.setStatus("PENDING");
outboxMapper.insert(event);
}
后台 Worker:
java
@Scheduled(fixedDelay = 500)
public void sendOutboxEvent() {
List<OutboxEvent> events =
outboxMapper.selectPending();
for (OutboxEvent event : events) {
try {
rocketMQTemplate.syncSend(
"product-events",
event
);
outboxMapper.markSent(
event.getId()
);
} catch (Exception e) {
log.error(
"send event failed {}",
event.getId(),
e
);
}
}
}
消费者:
java
@Override
public void onMessage(OutboxEvent event) {
if (
"PRODUCT_UPDATED".equals(
event.getEventType()
)
) {
String key =
"product:" +
event.getAggregateId();
redisTemplate.delete(key);
}
}
二十四、方案五:订阅 MySQL Binlog
还有一种非常经典的方案:
text
Canal
或者:
text
Debezium
核心思想是:
应用程序只修改 MySQL,不主动操作 Redis。
然后监听 MySQL Binlog。
架构:
text
Application
↓
MySQL
↓
Binlog
↓
Canal
↓
MQ
↓
Cache Consumer
↓
Redis
业务代码变得非常简单:
java
@Transactional
public void updateProduct(Product product) {
productMapper.updateById(product);
}
完全不操作 Redis。
数据库修改后:
text
MySQL Binlog
会记录:
text
UPDATE product
SET price = 120
WHERE id = 1001
Canal 捕获这个事件:
json
{
"database": "shop",
"table": "product",
"type": "UPDATE",
"data": [
{
"id": "1001",
"price": "120"
}
]
}
消费者:
java
public void handleBinlog(BinlogEvent event) {
if (
!"product".equals(event.getTable())
) {
return;
}
for (
Map<String, String> row :
event.getData()
) {
String id = row.get("id");
redisTemplate.delete(
"product:" + id
);
}
}
这样 Redis 就变成:
text
MySQL 数据变化的消费者
二十五、Canal 方案为什么很适合大型系统?
因为业务代码里不用到处写:
java
redisTemplate.delete()
假设现在:
text
product-service
order-service
admin-service
promotion-service
都可能修改商品。
如果每一个服务都要记得:
text
更新数据库
+
删除 Redis
非常容易漏。
但如果统一监听:
text
MySQL Binlog
那么只要 MySQL 发生变化:
text
Binlog
↓
Canal
↓
Redis
就能统一处理。
二十六、Canal 和 MQ 组合
生产环境比较常见:
text
MySQL
↓
Binlog
↓
Canal
↓
Kafka
↓
Cache Consumer
↓
Redis
为什么中间要加 Kafka?
因为可以做到:
text
削峰
解耦
重试
消息持久化
多消费者
例如除了 Redis 之外,还可以有:
text
Elasticsearch Consumer
Data Warehouse Consumer
Search Consumer
Analytics Consumer
架构:
text
┌──────── Redis
│
MySQL → Canal → Kafka ───── Elasticsearch
│
├──────── Data Warehouse
│
└──────── Analytics
这已经属于 CDC:
text
Change Data Capture
架构。
二十七、缓存一致性到底应该追求强一致还是最终一致?
这里需要理解一个非常重要的工程思想。
很多人会问:
怎么让 Redis 和 MySQL 100% 强一致?
实际上,如果业务真的要求:
text
100% 强一致
那么:
最好不要依赖缓存作为正确性判断依据。
例如:
text
银行余额
库存扣减
支付状态
订单状态
这些数据不能因为 Redis 的值就直接认为是真的。
比如余额:
java
BigDecimal balance =
redisTemplate.opsForValue()
.get("balance:" + userId);
然后直接:
java
if (
balance.compareTo(amount) >= 0
) {
transfer();
}
这种设计风险非常大。
真正关键的数据应该以:
text
MySQL
或者其他权威存储
作为最终判断依据。
Redis 更适合:
text
缓存
加速查询
热点数据
会话
限流
排行榜
分布式锁
二十八、Redis 缓存最好设置过期时间
无论你采用什么方案,我都建议缓存设置 TTL。
不要:
java
redisTemplate.opsForValue()
.set(key, value);
更推荐:
java
redisTemplate.opsForValue()
.set(
key,
value,
30,
TimeUnit.MINUTES
);
原因很简单。
假设所有机制都失效:
text
MQ 消息丢失
删除缓存失败
程序 Bug
网络异常
TTL 是最后一道保险。
例如:
text
MySQL = 120
Redis = 100
即使没有成功删除。
30 分钟以后:
text
Redis 自动过期
下次请求:
text
Redis miss
↓
MySQL = 120
↓
Redis = 120
数据自动恢复。
二十九、TTL 最好加入随机值
如果大量缓存统一设置:
text
30分钟
可能在某一个时间点一起过期。
造成:
text
缓存雪崩
所以可以随机化:
java
int ttl =
30 * 60
+
ThreadLocalRandom
.current()
.nextInt(300);
redisTemplate.opsForValue()
.set(
key,
product,
ttl,
TimeUnit.SECONDS
);
最终:
text
1800 ~ 2100 秒
随机过期。
这样可以避免大量缓存同时失效。
三十、缓存重建也可能导致并发问题
假设一个热门商品:
text
product:1001
刚好过期。
此时:
text
10000 个请求
同时进来。
全部:
text
Redis miss
于是:
text
10000 个请求查询 MySQL
数据库瞬间压力巨大。
这就是:
text
缓存击穿
三十一、使用分布式锁解决缓存击穿
例如:
java
public Product getProduct(Long id) {
String key =
"product:" + id;
Product product =
redisCache.get(key);
if (product != null) {
return product;
}
String lockKey =
"lock:product:" + id;
boolean locked =
redisLock.tryLock(
lockKey,
10,
TimeUnit.SECONDS
);
if (!locked) {
try {
Thread.sleep(50);
} catch (
InterruptedException e
) {
Thread.currentThread()
.interrupt();
}
return getProduct(id);
}
try {
// double check
product =
redisCache.get(key);
if (product != null) {
return product;
}
product =
productMapper
.selectById(id);
if (product != null) {
redisCache.set(
key,
product,
30,
TimeUnit.MINUTES
);
}
return product;
} finally {
redisLock.unlock(lockKey);
}
}
这里特别关键的一点是:
java
product = redisCache.get(key);
if (product != null) {
return product;
}
锁里面需要再次查询 Redis。
也就是:
text
Double Check
因为可能其他线程已经完成了缓存重建。
三十二、使用 Redisson 实现
实际项目中一般不会自己手写 Redis 分布式锁。
可以使用:
text
Redisson
例如:
java
@Resource
private RedissonClient redissonClient;
完整代码:
java
public Product getProduct(Long id) {
String key =
"product:" + id;
Product product =
redisCache.get(key);
if (product != null) {
return product;
}
String lockKey =
"lock:product:" + id;
RLock lock =
redissonClient
.getLock(lockKey);
try {
boolean success =
lock.tryLock(
3,
10,
TimeUnit.SECONDS
);
if (!success) {
throw new RuntimeException(
"系统繁忙,请稍后重试"
);
}
product =
redisCache.get(key);
if (product != null) {
return product;
}
product =
productMapper
.selectById(id);
if (product == null) {
return null;
}
redisCache.set(
key,
product,
30,
TimeUnit.MINUTES
);
return product;
} catch (
InterruptedException e
) {
Thread.currentThread()
.interrupt();
throw new RuntimeException(e);
} finally {
if (
lock.isHeldByCurrentThread()
) {
lock.unlock();
}
}
}
三十三、缓存空对象解决缓存穿透
例如有人不断请求:
text
/product/999999999
数据库里根本不存在。
流程一直:
text
Redis miss
↓
MySQL miss
攻击者大量请求就会一直打数据库。
解决办法:
text
缓存空值
例如:
java
Product product =
productMapper.selectById(id);
if (product == null) {
redisTemplate.opsForValue()
.set(
key,
"NULL",
5,
TimeUnit.MINUTES
);
return null;
}
读取:
java
Object value =
redisTemplate.opsForValue()
.get(key);
if ("NULL".equals(value)) {
return null;
}
这里空值 TTL 一般不要太长:
text
1分钟
5分钟
10分钟
根据业务调整。
三十四、商品查询完整案例
我们把前面的方案整合一下。
java
@Service
public class ProductService {
@Resource
private ProductMapper productMapper;
@Resource
private RedisTemplate<String, Object>
redisTemplate;
@Resource
private RedissonClient redissonClient;
private static final String
PRODUCT_PREFIX = "product:";
public Product getProduct(Long id) {
String key =
PRODUCT_PREFIX + id;
Object cache =
redisTemplate
.opsForValue()
.get(key);
if (cache != null) {
if (
"NULL".equals(cache)
) {
return null;
}
return (Product) cache;
}
return rebuildCache(id);
}
private Product rebuildCache(
Long id
) {
String key =
PRODUCT_PREFIX + id;
String lockKey =
"lock:" + key;
RLock lock =
redissonClient
.getLock(lockKey);
try {
boolean locked =
lock.tryLock(
3,
10,
TimeUnit.SECONDS
);
if (!locked) {
throw new RuntimeException(
"系统繁忙"
);
}
Object cache =
redisTemplate
.opsForValue()
.get(key);
if (cache != null) {
if (
"NULL".equals(cache)
) {
return null;
}
return (Product) cache;
}
Product product =
productMapper
.selectById(id);
if (product == null) {
redisTemplate
.opsForValue()
.set(
key,
"NULL",
5,
TimeUnit.MINUTES
);
return null;
}
long ttl =
1800 +
ThreadLocalRandom
.current()
.nextLong(300);
redisTemplate
.opsForValue()
.set(
key,
product,
ttl,
TimeUnit.SECONDS
);
return product;
} catch (
InterruptedException e
) {
Thread.currentThread()
.interrupt();
throw new RuntimeException(e);
} finally {
if (
lock.isHeldByCurrentThread()
) {
lock.unlock();
}
}
}
}
三十五、商品更新完整案例
数据库更新完成后删除缓存:
java
@Transactional
public void updateProduct(
Product product
) {
productMapper.updateById(product);
String key =
PRODUCT_PREFIX +
product.getId();
TransactionUtils.afterCommit(() -> {
deleteCacheWithRetry(key);
});
}
缓存删除:
java
private void deleteCacheWithRetry(
String key
) {
try {
redisTemplate.delete(key);
} catch (Exception e) {
log.error(
"delete cache failed, key={}",
key,
e
);
saveRetryTask(key);
}
}
保存重试任务:
java
private void saveRetryTask(
String key
) {
CacheEvent event =
new CacheEvent();
event.setCacheKey(key);
event.setStatus(0);
event.setRetryCount(0);
cacheEventMapper.insert(event);
}
三十六、进一步优化:缓存版本号
有些高并发场景,可以给数据增加:
text
version
例如数据库:
sql
CREATE TABLE product (
id BIGINT PRIMARY KEY,
name VARCHAR(255),
price DECIMAL(10,2),
version BIGINT NOT NULL DEFAULT 1
);
更新:
sql
UPDATE product
SET
price = 120,
version = version + 1
WHERE id = 1001;
缓存:
json
{
"id": 1001,
"price": 120,
"version": 8
}
写缓存时比较版本:
text
newVersion > cacheVersion
才允许覆盖。
伪代码:
java
if (
newProduct.getVersion()
>
cacheProduct.getVersion()
) {
redis.set(
key,
newProduct
);
}
不过这里要注意:
text
GET
比较
SET
本身也不是原子操作。
所以真正实现时通常需要:
text
Lua Script
三十七、Lua 实现版本控制写缓存
例如 Redis 中保存:
text
Hash
字段:
text
version
data
Lua:
lua
local key = KEYS[1]
local newVersion =
tonumber(ARGV[1])
local newData =
ARGV[2]
local oldVersion =
redis.call(
'HGET',
key,
'version'
)
if not oldVersion then
redis.call(
'HSET',
key,
'version',
newVersion,
'data',
newData
)
return 1
end
oldVersion =
tonumber(oldVersion)
if newVersion > oldVersion then
redis.call(
'HSET',
key,
'version',
newVersion,
'data',
newData
)
return 1
end
return 0
Java:
java
DefaultRedisScript<Long> script =
new DefaultRedisScript<>();
script.setScriptText(luaScript);
script.setResultType(Long.class);
Long result =
redisTemplate.execute(
script,
Collections.singletonList(key),
version.toString(),
JSON.toJSONString(product)
);
这样就能避免:
text
旧版本数据
覆盖
新版本数据
三十八、实际项目应该怎么选?
如果是普通项目,比如:
text
博客
商城商品
后台管理系统
内容平台
推荐:
text
Cache Aside
+
先更新 MySQL
+
事务提交后删除 Redis
+
TTL
已经足够。
整体:
text
读取:
Redis
↓ miss
MySQL
↓
Redis
更新:
MySQL
↓ commit
删除 Redis
如果项目并发比较高,可以加:
text
延迟双删
+
Redis 分布式锁
+
缓存随机 TTL
如果是中大型系统:
text
MySQL
+
MQ
+
Redis
通过消息实现:
text
最终一致性
如果业务服务很多:
text
微服务
几十个服务
多数据消费者
推荐:
text
MySQL Binlog
+
Canal / Debezium
+
Kafka
+
Redis
把缓存同步从业务代码中解耦出来。
三十九、几个方案对比
| 方案 | 实现难度 | 一致性 | 适合场景 |
|---|---|---|---|
| 更新 DB + 更新缓存 | 低 | 较差 | 不推荐 |
| 先删缓存再更新 DB | 低 | 较差 | 一般不推荐 |
| 更新 DB 再删缓存 | 低 | 较好 | 大多数项目 |
| 事务提交后删缓存 | 中 | 更好 | 推荐 |
| 延迟双删 | 中 | 较好 | 高并发 |
| MQ 异步删缓存 | 中高 | 最终一致 | 中大型系统 |
| 本地消息表 | 高 | 高可靠最终一致 | 核心业务 |
| Canal + Binlog | 高 | 高可靠最终一致 | 大型微服务 |
| Redis + 版本号 | 高 | 较强 | 特殊高并发场景 |
四十、我更推荐的生产方案
如果让我设计一个普通互联网业务,我通常会优先选择:
text
Cache Aside
读取:
text
Redis
↓
没有
↓
MySQL
↓
写 Redis
更新:
text
BEGIN
↓
UPDATE MySQL
↓
COMMIT
↓
DELETE Redis
Redis:
text
设置 TTL
+
随机过期时间
如果 Redis 删除失败:
text
记录失败任务
↓
异步重试
整体架构:
text
┌───────────────┐
│ Client │
└───────┬───────┘
│
▼
┌───────────────┐
│ Application │
└───────┬───────┘
│
┌────────┴─────────┐
│ │
▼ ▼
Redis MySQL
▲ │
│ │
│ COMMIT
│ │
└──── DELETE ──────┘
对于大多数业务来说:
没有必要追求 Redis 和 MySQL 在每一个毫秒都绝对一致。
更现实的目标应该是:
text
避免长期不一致
+
允许短暂不一致
+
异常情况下能够自动恢复
+
最终保证一致
这才是缓存一致性设计真正的工程思想。
四十一、最后总结
Redis 和 MySQL 数据一致性问题,本质上并不是一道简单的:
text
先更新谁?
的问题。
而是涉及:
text
并发
事务
缓存失效
网络异常
消息可靠性
分布式系统
最终一致性
等一系列问题。
实际开发中可以记住一句最重要的话:
缓存不是数据源,MySQL 才是数据源。
对于绝大多数业务,我建议采用:
text
Cache Aside
+
更新数据库
+
事务提交后删除缓存
+
缓存 TTL
+
删除失败异步重试
如果系统规模继续扩大,再逐步升级到:
text
本地消息表
↓
MQ
↓
Canal / Debezium
↓
CDC
而不是一开始就为了"绝对一致"引入一整套复杂架构。
缓存设计真正要解决的,不是"永远不出现旧数据",而是:
即使发生异常,系统也能够在一个可接受的时间窗口内自动恢复到正确状态。