每日Java面试场景题知识点之-数据库与缓存的一致性
一、为什么需要数据库与缓存的一致性?
在现代Java企业级应用中,为了提升系统性能和吞吐量,几乎都会引入缓存层(如Redis)。缓存的核心思路是将热点数据存储在内存中,减少对数据库的直接访问。然而,缓存和数据库是两个独立的数据存储,如何保证它们之间的数据一致性,成为了分布式系统中的经典难题。
面试中,考官往往会从以下几个维度来考察:
- 你是否理解缓存与数据库不一致的根本原因?
- 你能否设计出合理的缓存更新策略?
- 你对最终一致性有没有深刻的理解?
二、经典的缓存更新策略
2.1 Cache Aside Pattern(旁路缓存模式)
这是业界最常用的缓存更新策略,核心思想是:应用程序同时与缓存和数据库交互,缓存不主动与数据库通信。
读流程:
- 应用程序先读缓存;
- 如果缓存命中,直接返回数据;
- 如果缓存未命中,读取数据库,将数据写入缓存,再返回。
写流程:
- 先更新数据库;
- 再删除缓存。
为什么是删除缓存而不是更新缓存?因为更新缓存可能引发并发写导致的脏数据问题,而删除缓存是幂等操作,更加安全。
2.2 Read/Write Through Pattern(读写穿透模式)
应用程序只与缓存交互,由缓存层负责与数据库的读写同步。这种方式对业务代码侵入性低,但实现复杂度较高。
2.3 Write Behind Pattern(异步回写模式)
应用程序只更新缓存,由缓存层异步批量地将数据写入数据库。这种方式写入性能极高,但存在数据丢失的风险。
三、先更新数据库还是先删缓存?
这是面试中最高频的问题之一,我们需要分析两种方案的并发问题:
3.1 先删缓存,再更新数据库
并发问题场景:
- 线程A删除缓存;
- 线程B读缓存未命中,读数据库拿到旧值;
- 线程B将旧值写入缓存;
- 线程A更新数据库为新值。
结果: 缓存中是旧值,数据库中是新值,数据不一致!
解决方案:延迟双删
java
public void updateData(String key, Object value) {
// 第一步:先删除缓存
cache.delete(key);
// 第二步:更新数据库
db.update(key, value);
// 第三步:延迟一段时间后再次删除缓存
Thread.sleep(500); // 延迟时间需大于一次读操作的耗时
cache.delete(key);
}
延迟双删的核心思想是:在第二次删除缓存之前,等待足够长的时间,让其他线程将旧值写入缓存的操作完成,然后再将脏数据清除。
3.2 先更新数据库,再删缓存(推荐)
并发问题场景:
- 缓存刚好失效;
- 线程A读数据库拿到旧值;
- 线程B更新数据库为新值;
- 线程B删除缓存;
- 线程A将旧值写入缓存。
分析: 这种场景发生的概率极低,因为步骤3和4的时间消耗远大于步骤2和5,线程B通常会在线程A写缓存之前完成删除操作。所以这种策略在实际生产中被广泛推荐使用。
四、缓存删除失败怎么办?
不管采用哪种策略,如果最后一步操作(删除缓存或更新数据库)失败,就会导致数据不一致。以下是常见的补偿机制:
4.1 消息队列重试
将删除缓存的操作放入消息队列,如果删除失败,消费者会自动重试,直到成功为止。
java
public void updateData(String key, Object value) {
db.update(key, value);
// 发送消息到MQ,异步删除缓存
mq.send("cache-delete-topic", key);
}
4.2 订阅Binlog异步删除
通过Canal等中间件监听MySQL的Binlog日志,一旦检测到数据变更,异步删除对应的缓存。这种方式对业务代码零侵入,是目前大型互联网公司的主流方案。
架构示意:
MySQL -> Canal监听Binlog -> 发送至MQ -> 消费者删除Redis缓存
4.3 设置缓存过期时间
为缓存数据设置合理的TTL(Time To Live),即使出现不一致的情况,缓存过期后也会自动从数据库加载最新数据。这是一种兜底策略,保证最终一致性。
五、强一致性 vs 最终一致性
5.1 强一致性
如果要保证缓存和数据库的强一致性,通常需要引入分布式锁或分布式事务,例如:
- 使用Redisson的读写锁;
- 使用Seata等分布式事务框架。
但强一致性方案会显著降低系统性能,在绝大多数互联网场景中并不必要。
5.2 最终一致性(推荐)
对于绝大多数业务场景,最终一致性才是合理的选择。核心思路是:
- 采用先更新数据库、再删除缓存的策略;
- 通过消息队列或Binlog订阅保证删除操作的可靠性;
- 设置缓存过期时间作为兜底;
- 接受短暂的不一致窗口期。
六、面试高频追问
Q1:缓存雪崩、缓存穿透、缓存击穿分别是什么?如何解决?
- 缓存雪崩:大量缓存同时失效,请求全部落到数据库。解决方案:缓存过期时间加随机值、缓存预热、限流降级。
- 缓存穿透:查询不存在的数据,缓存永远不命中。解决方案:布隆过滤器、缓存空值。
- 缓存击穿:某个热点key过期瞬间,大量并发请求打到数据库。解决方案:互斥锁、热点数据永不过期。
Q2:如何保证缓存的高可用?
- Redis集群(主从+哨兵或Cluster模式);
- 本地缓存+分布式缓存多级架构;
- 缓存降级策略。
Q3:在Spring Boot中如何实现缓存与数据库的一致性?
- 使用Spring Cache注解(@Cacheable、@CacheEvict、@CachePut);
- 结合消息队列实现异步缓存失效;
- 整合Canal监听Binlog自动更新缓存。
七、总结
数据库与缓存的一致性是分布式系统设计的核心问题之一。在实际生产中,推荐的做法是:先更新数据库、再删除缓存 ,并结合消息队列重试 或Binlog异步订阅 来保证删除操作的可靠性,同时设置缓存过期时间作为兜底策略,实现最终一致性。对于特殊场景(如金融交易),才需要考虑强一致性方案。
掌握这些知识,不仅能帮助你通过面试,更能在实际项目设计中做出合理的技术决策。
感谢读者观看