MySQL与Redis缓存一致性

MySQL与Redis缓存一致性

MySQL 是业务系统中最常见的数据库,但用好它并不容易。索引设计、SQL 优化、事务处理都是日常开发中需要关注的点。本文从实际场景出发,讲常见问题和解决思路。

一、为什么缓存会不一致

#

1.1 不一致的场景

场景一:并发读写
T1: 读取缓存(miss)→ 读取数据库 → 写入缓存
(此时T2修改数据库并删除缓存)
T1: 写入缓存(写入的是旧数据)

场景二:读写并发
T1: 读取缓存(miss)→ 读取数据库(旧数据)
T2: 更新数据库 → 删除缓存
T1: 写入缓存(旧数据)

场景三:主从延迟
T1: 更新主库 → 删除缓存
T2: 读取缓存(miss)→ 读取从库(可能还是旧数据)→ 写入缓存

#

1.2 不一致的根本原因

  • MySQL和Redis是两个独立的存储系统
  • 没有原生的事务机制保证两者同时更新
  • 网络延迟、并发操作导致时序问题

二、缓存更新策略

#

2.1 Cache Aside(旁路缓存)

最常用的策略,应用负责维护缓存:

读流程:
读缓存 ──hit──> 返回数据
│ miss

读数据库


写缓存


返回数据

写流程:
更新数据库


删除缓存(不是更新缓存)

为什么写操作是删缓存而不是更新缓存?

  1. 避免并发写覆盖

    T1: 更新缓存为A=1
    T2: 更新缓存为A=2
    T1: 更新数据库为A=1(数据库和缓存不一致)
  2. 懒加载:删除后下次读取时才加载,避免写入未使用的缓存

#

2.2 Read Through / Write Through

应用只和缓存交互,缓存层负责和数据库交互:

读流程:
应用 → 缓存 ──hit──> 返回
miss ──> 缓存自动从数据库加载

写流程:
应用 → 缓存更新 ──> 缓存同步更新数据库

实现:需要Cache Library支持(如Caffeine的CacheLoader)。

#

2.3 Write Behind(异步写)

先写缓存,异步批量写数据库:

应用 → 更新缓存 ──> 立即返回
└──> 异步队列 ──> 批量写入数据库

适用:写性能要求极高,可容忍短暂不一致(如计数器)。

#

2.4 策略对比

策略 一致性 复杂度 性能 适用场景
Cache Aside 最终一致 大多数场景
Read/Write Through 强一致 需要统一缓存层
Write Behind 弱一致 最高 高吞吐写入

三、Cache Aside一致性方案

#

3.1 先更新数据库,再删除缓存

public void updateData(Data data) {
// 1. 更新数据库
db.update(data);

// 2. 删除缓存
redis.del("data:" + data.getId());
}

public Data getData(Long id) {
// 1. 读缓存
Data data = redis.get("data:" + id);
if (data != null) {
return data;
}

// 2. 读数据库
data = db.queryById(id);

// 3. 写缓存
if (data != null) {
redis.set("data:" + id, data, 3600);
}

return data;
}

问题:极端情况下仍可能不一致(条件苛刻)

T1: 读取缓存(miss)
T1: 读取数据库(旧值)
T2: 更新数据库(新值)
T2: 删除缓存
T1: 写入缓存(旧值)

发生条件:

  • 缓存恰好失效
  • 读请求在写请求更新数据库之后、删除缓存之前完成数据库读取
  • 写请求删除缓存后,读请求才写入缓存

这个条件要求读操作非常慢,实际发生概率很低。

#

3.2 先删除缓存,再更新数据库

public void updateData(Data data) {
// 1. 删除缓存
redis.del("data:" + data.getId());

// 2. 更新数据库
db.update(data);
}

问题:更大的一致性问题

T1: 删除缓存
T2: 读取缓存(miss)→ 读取数据库(旧值)→ 写入缓存(旧值)
T1: 更新数据库(新值)

这个场景发生的概率更高,不推荐

#

3.3 延迟双删

public void updateData(Data data) {
// 1. 删除缓存
redis.del("data:" + data.getId());

// 2. 更新数据库
db.update(data);

// 3. 延迟再次删除缓存(异步)
asyncExecutor.execute(() -> {
try {
Thread.sleep(500); // 延迟500ms
redis.del("data:" + data.getId());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}

原理

  • 第一次删除:清除旧缓存
  • 更新数据库
  • 延迟删除:清除并发读操作可能写入的旧缓存

延迟时间确定

  • 主从复制延迟 + 读操作耗时
  • 通常200ms-1000ms

#

3.4 删除缓存失败处理

问题:更新数据库成功,但删除缓存失败

方案一:重试机制

public void deleteCacheWithRetry(String key) {
int retry = 3;
while (retry-- > 0) {
try {
redis.del(key);
return;
} catch (Exception e) {
if (retry == 0) {
// 记录日志,人工处理或加入延迟队列
log.error("删除缓存失败: {}", key);
break;
}
try {
Thread.sleep(100);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
}
}
}
}

方案二:消息队列保证

public void updateData(Data data) {
// 1. 更新数据库
db.update(data);

// 2. 发送删除缓存消息到MQ
mq.send(new CacheDeleteMessage("data:" + data.getId()));
}

// MQ消费者
@Component
public class CacheDeleteConsumer {
@RabbitListener(queues = "cache.delete")
public void onMessage(CacheDeleteMessage msg) {
// 删除缓存,失败则重试/NACK
redis.del(msg.getKey());
}
}

方案三:Binlog监听(Canal)

MySQL ──> Binlog ──> Canal Server ──> MQ/直接删除Redis
// Canal客户端监听binlog变更
@Component
public class CanalClient {

@PostConstruct
public void start() {
CanalConnector connector = CanalConnectors.newSingleConnector(
new InetSocketAddress("canal-server", 11111), "example", "", "");

connector.connect();
connector.subscribe("mydb\\..*");

while (running) {
Message message = connector.getWithoutAck(100);
for (Entry entry : message.getEntries()) {
if (entry.getEntryType() == EntryType.ROWDATA) {
handleRowChange(entry);
}
}
connector.ack(message.getId());
}
}

private void handleRowChange(Entry entry) {
RowChange rowChange = RowChange.parseFrom(entry.getStoreValue());
for (RowData rowData : rowChange.getRowDatasList()) {
if (rowChange.getEventType() == EventType.UPDATE ||
rowChange.getEventType() == EventType.DELETE) {
// 删除对应的Redis缓存
String tableName = entry.getHeader().getTableName();
Long id = getIdFromRow(rowData.getBeforeColumnsList());
redis.del(tableName + ":" + id);
}
}
}
}

Canal方案优势

  • 业务代码无侵入
  • 最终一致性保证
  • 天然支持数据库变更监听

四、强一致性方案

#

4.1 分布式锁

public Data getData(Long id) {
String lockKey = "lock:data:" + id;
String cacheKey = "data:" + id;

// 1. 读缓存
Data data = redis.get(cacheKey);
if (data != null) {
return data;
}

// 2. 获取分布式锁
RLock lock = redisson.getLock(lockKey);
try {
lock.lock(10, TimeUnit.SECONDS);

// 双重检查
data = redis.get(cacheKey);
if (data != null) {
return data;
}

// 3. 读数据库并写入缓存
data = db.queryById(id);
if (data != null) {
redis.set(cacheKey, data, 3600);
}
} finally {
lock.unlock();
}

return data;
}

#

4.2 读写锁

public class CacheService {
private ReadWriteLock rwLock = new ReentrantReadWriteLock();

public Data read(Long id) {
rwLock.readLock().lock();
try {
return redis.get("data:" + id);
} finally {
rwLock.readLock().unlock();
}
}

public void write(Data data) {
rwLock.writeLock().lock();
try {
db.update(data);
redis.del("data:" + data.getId());
} finally {
rwLock.writeLock().unlock();
}
}
}

五、特殊场景处理

#

5.1 主从延迟下的缓存

public Data getData(Long id) {
Data data = redis.get("data:" + id);
if (data != null) {
return data;
}

// 读主库(避免从库延迟)
data = masterDB.queryById(id);

if (data != null) {
// 设置较短的过期时间
redis.set("data:" + id, data, 30);
}

return data;
}

#

5.2 缓存穿透

问题:查询不存在的数据,每次都打到数据库

解决

public Data getData(Long id) {
String cacheKey = "data:" + id;
String nullKey = cacheKey + ":null";

// 1. 读缓存
Data data = redis.get(cacheKey);
if (data != null) {
return data;
}

// 2. 检查空值缓存(防止穿透)
if (redis.hasKey(nullKey)) {
return null;
}

// 3. 读数据库
data = db.queryById(id);

if (data != null) {
redis.set(cacheKey, data, 3600);
} else {
// 缓存空值,短时间过期
redis.set(nullKey, "", 60);
}

return data;
}

#

5.3 缓存击穿

问题:热点key过期瞬间,大量请求打到数据库

解决

public Data getData(Long id) {
String cacheKey = "data:" + id;

Data data = redis.get(cacheKey);
if (data != null) {
return data;
}

// 加分布式锁,只有一个线程去加载
RLock lock = redisson.getLock("lock:" + cacheKey);
try {
if (!lock.tryLock(3, 10, TimeUnit.SECONDS)) {
// 获取锁失败,短暂等待后重试
Thread.sleep(100);
return getData(id); // 递归重试
}

// 双重检查
data = redis.get(cacheKey);
if (data != null) {
return data;
}

data = db.queryById(id);
if (data != null) {
redis.set(cacheKey, data, 3600);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}

return data;
}

#

5.4 缓存雪崩

问题:大量key同时过期,数据库压力突增

解决

public void setData(String key, Data data) {
// 随机过期时间,避免同时过期
int expireTime = 3600 + ThreadLocalRandom.current().nextInt(300);
redis.set(key, data, expireTime);
}

六、总结

方案 一致性 复杂度 性能 推荐场景
Cache Aside 最终一致 大多数场景
延迟双删 最终一致 读多写少
MQ + 删除 最终一致 高可靠要求
Canal + 删除 最终一致 无侵入要求
分布式锁 强一致 强一致要求

缓存一致性设计原则:

  1. 优先使用Cache Aside:简单高效,适合绝大多数场景
  2. 写操作删除缓存,不是更新缓存:避免并发覆盖
  3. 处理删除失败:重试、MQ、Canal兜底
  4. 设置合理过期时间:最终一致的保障
  5. 热点数据永不过期:后台异步更新

核心认识:

  • 缓存和数据库的强一致性很难保证
  • 追求强一致性会大幅降低性能
  • 大多数业务场景下,最终一致性已足够
  • 设置合适的过期时间是最终一致性的”兜底”保障

核心要点

  1. 索引设计的原则:最左前缀原则、避免索引列参与计算、选择合适的索引类型

  2. SQL 优化的方法:使用 EXPLAIN 分析执行计划、避免 SELECT *、优化 JOIN

  3. 事务隔离级别的选择:根据业务需求选择合适的级别

  4. 常见的锁问题:行锁、表锁、死锁的处理

总结

MySQL 优化是一个持续的过程,需要结合业务特点和数据量来调整。在实际项目中,定期分析慢查询日志、优化索引、调整配置参数,都能提升数据库性能。


   转载规则


《MySQL与Redis缓存一致性》 小乐 采用 知识共享署名 4.0 国际许可协议 进行许可。
  目录