缓存与数据库双写一致性问题

本文探讨了缓存架构的设计及其在提升系统读性能的作用,特别是在读多写少的场景下。深入分析了缓存更新策略,包括更新缓存与删除缓存的选择,以及先更新数据库再删除缓存或先删除缓存再更新数据库的利弊,提出了在分布式环境下保证数据一致性的解决方案。

1. 场景

缓存架构设计,可以提高系统的读性能,在缓存建立key-value的键值对,减少数据库的压力,通常用于读多写少的场景,流程一般如下:

  • 用户首先查询缓存(如Redis)是否有相关数据;
  • 如果有相关数据,缓存命中,返回查询结果;
  • 如果没有相关数据,则查询数据库,将结果放入缓存,同时返回结果;

2. 缓存更新策略

如果有数据更新,是先操作缓存,还是先操作数据库,这里涉及时序问题;而对于缓存的操作,是删除缓存,还是更新缓存?总结有以下几个方案:

  1. 先更新数据库,再更新缓存;
  2. 先更新缓存,再更新数据库;
  3. 先更新数据库,再删除缓存;
  4. 先删除缓存,再更新数据库;

其实通常缓存的数据都会设置一个过期时间,如果不要求强一致,那么在过期后,数据删除,后面的查询请求还是会把结果重新放入缓存,数据还是会达到最终的一致性。

2.1 更新缓存 or 删除缓存

这个和业务有关,但有以下情况需要考虑:

  • 如果要更新的数据是通过复杂计算得出的,比如商品价格,要查询商品原价,商品折扣,卖家和平台优惠信息,那还不如直接把缓存删除,否则更新缓存还需要去计算一次;而如果只是删除缓存,顶多增加一次查询命中失败;
  • 如果场景是写多读少的话,缓存被频繁的更新,影响系统性能;
  • 存在线程安全问题,无论先更新数据库,还是先更新缓存,并发情况下都可能导致数据不一致,比如线程1更新数据库,线程2更新数据库,线程2更新缓存,线程1更新缓存;
  • 原子性,更新数据库和更新缓存的操作不能保证在同一个事务内,不能保证原子性(当然删除缓存也是,所以需要补偿方法);

所以一般选择删除缓存。

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

问题流程如下:

  • 更新数据库成功;
  • 删除缓存失败;

之前提到由于不能保证原子性,所以有可能出现这种数据不一致的情况。

解决方案:
重试机制,将删除失败的key加入消息队列,重试直到删除成功,但这样增加了架构难度,所以并不推荐先更新数据库再删除缓存的方案,网上也有说facebook是这种策略,具体根据自己项目情况。

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

问题流程如下,线程1写操作,线程2读操作:

  • 线程1删除缓存;
  • 线程2查询发现缓存不存在,查询数据库得到旧数据,并把旧数据写入缓存;
  • 线程1更新数据库;

这样导致缓存的数据为脏数据,数据不一致;

解决方案:
串行化操作:更新数据的时候,根据数据的唯一标识,将操作路由之后,发送到一个 jvm 内部队列中。读取数据的时候,如果发现数据不在缓存中,那么将重新读取数据+更新缓存的操作,根据唯一标识路由之后,也发送同一个 jvm 内部队列中。
一个队列对应一个工作线程,每个工作线程串行拿到对应的操作,然后一条一条的执行。这样的话,一个数据变更的操作,先删除缓存,然后再去更新数据库,但是还没完成更新。此时如果一个读请求过来,读到了空的缓存,那么可以先将缓存更新的请求发送到队列中,此时会在队列中积压,然后同步等待缓存更新完成。
这里有一个优化点,一个队列中,其实多个更新缓存请求串在一起是没意义的,因此可以做过滤,如果发现队列中已经有一个更新缓存的请求了,那么就不用再放个更新请求操作进去了,直接等待前面的更新操作请求完成即可。
待那个队列对应的工作线程完成了上一个操作的数据库的修改之后,才会去执行下一个操作,也就是缓存更新的操作,此时会从数据库中读取最新的值,然后写入缓存中。
如果请求还在等待时间范围内,不断轮询发现可以取到值了,那么就直接返回;如果请求等待的时间超过一定时长,那么这一次直接从数据库中读取当前的旧值。

这种方案有明显的性能问题,串行化会导致系统的吞吐量降低,并且在分布式环境下,基于内存的队列无法解决这个问题,但是也有解决方案,重点是保证同一个数据的读写都落在同一个后端服务上,并且做到在数据库层面是串行的,详情参考:

https://mp.weixin.qq.com/s?__biz=MjM5ODYxMDA5OQ==&mid=404202261&idx=1&sn=1b8254ba5013952923bdc21e0579108e&scene=21#wechat_redirect


3. 总结

总得来说,业界推荐删除缓存的策略,但是无论先更新数据库,还是先删除缓存,都有弊端,需要做补偿,在不要求强一致的情况下,超时机制应该是最合适的。

缓存数据库场景中,保证数据一致性是一个常见但复杂的挑战。由于缓存数据库是两个独立的存储系统,操作无法通过单一事务保证原子性,因此需要采取一些策略来降低数据不一致的风险。以下是几种常见的解决方案: ### 1. 先更新数据库,再更新缓存(Cache Aside 模式) 这是最常见的一种做法。应用首先更新数据库中的数据,待数据库更新成功后,再更新缓存。这样可以保证缓存中的数据最终数据库保持一致。 ```python # 伪代码示例 def update_data(key, new_value): # 1. 更新数据库 db.update(key, new_value) # 2. 更新缓存 cache.set(key, new_value) ``` 但这种方式在极端情况下可能出现不一致问题。例如,在更新数据库后、更新缓存前发生异常,缓存中的旧数据仍存在,直到下一次读取触发更新。 ### 2. 先删除缓存,再更新数据库(Read Through + Delete) 在更新数据库之前,先删除缓存中的旧值。当后续读取请求到来时,发现缓存中没有数据,会从数据库中加载最新数据并重新缓存。 ```python def update_data(key, new_value): # 1. 删除缓存 cache.delete(key) # 2. 更新数据库 db.update(key, new_value) ``` 这种方式可以避免缓存中存在过期数据的问题,但可能在并发场景下导致缓存穿透,即多个请求同时访问缓存未命中,进而频繁访问数据库。 ### 3. 使用消息队列异步更新缓存(Eventual Consistency) 通过引入消息队列(如 Kafka、RabbitMQ),将数据库更新操作作为事件发布到队列中,由消费者异步更新缓存,实现最终一致性。 ```python def update_data(key, new_value): # 1. 更新数据库 db.update(key, new_value) # 2. 发送更新事件到消息队列 message_queue.publish("cache_update", {"key": key, "value": new_value}) ``` 消费者端: ```python def consume_message(message): key = message["key"] value = message["value"] cache.set(key, value) ``` 这种方式可以解耦数据库缓存更新操作,适用于对一致性要求不是实时的场景。 ### 4. 使用分布式事务或两阶段提交(2PC) 在某些对数据一致性要求极高的系统中,可以考虑使用分布式事务(如 Seata、XA 协议)来保证数据库缓存一致性。但这类方案通常性能开销较大,适用于金融类等对一致性要求非常高的系统。 ### 5. 使用缓存中间件的穿透机制(Write Through) 某些缓存系统(如 Redis + CacheLib)支持 Write Through 模式,即缓存层在入时自动同步数据库。这种方式将一致性逻辑封装在缓存层内部,简化了应用层的处理。 --- ### 总结 不同场景下可选择不同的策略: - **低一致性要求**:使用消息队列实现最终一致性。 - **中等一致性要求**:采用 Cache Aside 或 Read Through + Delete 模式。 - **高一致性要求**:使用分布式事务或专门的缓存穿透机制。 每种方案都有其适用场景和局限性,实际应用中应根据业务需求、性能约束和系统复杂度进行权衡选择。 ---
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值