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 ▼ 读数据库 │ ▼ 写缓存 │ ▼ 返回数据
写流程: 更新数据库 │ ▼ 删除缓存(不是更新缓存)
|
为什么写操作是删缓存而不是更新缓存?
避免并发写覆盖:
T1: 更新缓存为A=1 T2: 更新缓存为A=2 T1: 更新数据库为A=1(数据库和缓存不一致)
|
懒加载:删除后下次读取时才加载,避免写入未使用的缓存
#
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) { db.update(data); redis.del("data:" + data.getId()); }
public Data getData(Long id) { Data data = redis.get("data:" + id); if (data != null) { return data; } data = db.queryById(id); if (data != null) { redis.set("data:" + id, data, 3600); } return data; }
|
问题:极端情况下仍可能不一致(条件苛刻)
T1: 读取缓存(miss) T1: 读取数据库(旧值) T2: 更新数据库(新值) T2: 删除缓存 T1: 写入缓存(旧值)
|
发生条件:
- 缓存恰好失效
- 读请求在写请求更新数据库之后、删除缓存之前完成数据库读取
- 写请求删除缓存后,读请求才写入缓存
这个条件要求读操作非常慢,实际发生概率很低。
#
3.2 先删除缓存,再更新数据库
public void updateData(Data data) { redis.del("data:" + data.getId()); db.update(data); }
|
问题:更大的一致性问题
T1: 删除缓存 T2: 读取缓存(miss)→ 读取数据库(旧值)→ 写入缓存(旧值) T1: 更新数据库(新值)
|
这个场景发生的概率更高,不推荐。
#
3.3 延迟双删
public void updateData(Data data) { redis.del("data:" + data.getId()); db.update(data); asyncExecutor.execute(() -> { try { Thread.sleep(500); 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) { db.update(data); mq.send(new CacheDeleteMessage("data:" + data.getId())); }
@Component public class CacheDeleteConsumer { @RabbitListener(queues = "cache.delete") public void onMessage(CacheDeleteMessage msg) { redis.del(msg.getKey()); } }
|
方案三:Binlog监听(Canal)
MySQL ──> Binlog ──> Canal Server ──> MQ/直接删除Redis
|
@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) { 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; Data data = redis.get(cacheKey); if (data != null) { return data; } RLock lock = redisson.getLock(lockKey); try { lock.lock(10, TimeUnit.SECONDS); data = redis.get(cacheKey); if (data != null) { return data; } 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"; Data data = redis.get(cacheKey); if (data != null) { return data; } if (redis.hasKey(nullKey)) { return null; } 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 + 删除 |
最终一致 |
中 |
高 |
无侵入要求 |
| 分布式锁 |
强一致 |
高 |
低 |
强一致要求 |
缓存一致性设计原则:
- 优先使用Cache Aside:简单高效,适合绝大多数场景
- 写操作删除缓存,不是更新缓存:避免并发覆盖
- 处理删除失败:重试、MQ、Canal兜底
- 设置合理过期时间:最终一致的保障
- 热点数据永不过期:后台异步更新
核心认识:
- 缓存和数据库的强一致性很难保证
- 追求强一致性会大幅降低性能
- 大多数业务场景下,最终一致性已足够
- 设置合适的过期时间是最终一致性的”兜底”保障
核心要点
索引设计的原则:最左前缀原则、避免索引列参与计算、选择合适的索引类型
SQL 优化的方法:使用 EXPLAIN 分析执行计划、避免 SELECT *、优化 JOIN
事务隔离级别的选择:根据业务需求选择合适的级别
常见的锁问题:行锁、表锁、死锁的处理
总结
MySQL 优化是一个持续的过程,需要结合业务特点和数据量来调整。在实际项目中,定期分析慢查询日志、优化索引、调整配置参数,都能提升数据库性能。