缓存双写一致性

前言:

在开头有必要说明,如果对数据一致性要求比较高就不要存缓存,因为只要涉及到双写就一定存在一致性问题。

1. 缓存基本使用方式

  如果可以容忍数据不一致话,我们可以给缓存设置一个过期时间,所有写操作以数据库为基准,缓存过期后就会去数据库中取新值,保证了数据的最终一致性

在这里插入图片描述

  过期时间也就是我们能容忍数据最大不一致的时间。过期时间太短,数据不一致时间短,但是读数据库频繁;过期时间太长,数据不一致时间也就越长。

1.1 增大缓存过期时间,数据变更主动更新缓存

  增大缓存过期时间,能有效减少读DB频繁问题,但是会造成数据不一致时间过长,这时候我们得再数据变更后主动更新缓存,来减少不一致时间。

这里我们讨论三种更新策略:

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

2. 先更新数据库,再更新缓存

  假设我们采用【先更新数据库,再更新缓存】策略,并且这两步都可以成功执行前提下。如果存在并发,问题会咋样?

2.1 线程安全问题

  现有2个并发请求,请求A和请求B。

  1. 请求A更新数据库(x = 1)
  2. 请求B更新数据库(x = 2)
  3. 请求B更新缓存(x = 2)
  4. 请求A更新缓存(x = 1)

  最后我们发现,数据库中的 x 是2,而缓存中是1。显然是不一致的,缓存中的数据是“脏”的。

2.2 从业务场景角度考虑

  1. 如果业务是写多读少,每次对数据库写操作后,都去做一次缓存更新操作,显然产生了不必要的开销

该缓存策略暂不考虑

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

  假设我们采用【先删除缓存,再更新数据库】策略,并且这两步都可以成功执行前提下。如果存在并发,问题会咋样?

3.1 线程安全问题

  现有2个并发请求,请求A和请求B。

  1. 请求A删除缓存
  2. 请求B发现缓存不存在
  3. 请求B去数据库读取旧值(x = 1)
  4. 请求B将旧值写入缓存(x = 1)
  5. 请求A更新数据库(x = 2)

  上述情况就会导致数据库的值已经被更新,而缓存中的值仍然是旧值的情况。如果不给缓存设置过期时间的话,缓存中的数据永远是“脏”的数据。

3.2 延时双删策略

那么有什么解决办法吗?

  可以采用延时双删策略。其实就是请求A在更新完数据库后,延迟一会时间,然后进行删除缓存操作。具体延迟多久得看具体的业务耗时来定。同步的去淘汰缓存,只会导致吞吐量降低,因此可以将第二次删除以异步的方式来处理。

问题一

  即便是采取了双删策略,延时时间过短,也不能保证数据一定会一致。

  1. 请求A删除缓存
  2. 请求B发现缓存不存在
  3. 请求B去数据库读取旧值(x = 1)
  4. 请求A更新数据库(x = 2)
  5. 请求A删除缓存(此时请求B还没写入缓存)
  6. 请求B将旧值写入缓存(x = 1)

  发生这种情况主要原因:延时时间过短。

问题二

  数据库采用了读写分离架构,且同步方案是半同步机制 or 异步复制机制,那么这个延迟时间就要设置更长了

  1. 请求A删除缓存

  2. 请求A更新数据主库(x = 2)

  3. 请求B发现缓存不存在

  4. 请求B去数据从库读取旧值(x = 1)

  5. 数据库完成主从同步,数据从库数据更新(x = 2)

  6. 请求B将旧值写入缓存(x = 1)

  为了解决不一致的问题,可以简单粗暴的增长延时时间。或者数据库采用全同步机制,当然生产环境一般不这样做,mysql性能会受到严重影响。或者读写都在主库,也会影响数据库性能。

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

  这种策略也会存在并发问题吗?

  1. 缓存A刚好失效
  2. 请求A查询数据库,得到一个旧值(x = 1)
  3. 请求B将新值写入数据库
  4. 请求B删除缓存
  5. 请求A将旧值写入到缓存(x = 1)

  如果发生上述情况,确实是会有脏数据存在。不过发生的概率很低,比【先删除缓存,再更新数据库】策略要低。

4.1 主动更新缓存,为啥缓存还要过期时间?

这里插入一个问题?

  我都采用主动更新缓存策略了,为啥缓存要设置过期时间,我不设置可以吗?

  • 缓存是非常昂贵的,对于热点 key 转为冷 key,它们没理由还保存在内存中,如果不设置过期时间,它将永远保存在内存中。
  • 当然你的业务都是热点 key,就可以不设置缓存过期时间,也就不会出现上面的并发问题;甚至所有 key 都可以不设置过期时间,当变成冷 key 后,运维去主动监控、删除,请问你公司的运维会答应吗?

  再回到主题,我们知道上述并发情况一般很少出现,但是如果非要解决这个问题,怎么做?

  • 缓存设置过期时间:缓存过期时间后去取新值,保证最终一致性。(我们采用主动更新缓存策略,就是为了增大过期时间,减少对DB读压力,这里缓存过期虽然可以保证最终一致性,但是会导致不一致时间很长)
  • 延时双删策略:在删除缓存后,延迟一段时间,然后再次删除缓存
  • 保障的重试机制:延时双删会有一个问题,如果第二次删除失败,仍然会出现不一致

5. 最终方案

  我们可以采用 【先更新数据库,再删除缓存】+ 【延时双删策略】+ 【缓存过期】+ 【保障重试机制】来保证缓存最终一致性

在这里插入图片描述

  该方案有个缺点,对业务代码造成大量的侵入。每个更新、删除数据的业务代码都得接入。于是有了了另一种方案,启动一个订阅程序去订阅数据库的 binlog,获得需要操作的数据。在应用程序 中,获取该订阅程序传递来的消息,进行删除缓存操作。

在这里插入图片描述
  这里你会想为啥要第一次删除呢?既然第一次删除可能出行数据不一致。

  数据不一致是有个时间间隔,第一次删除的意义在于可以减少这个间隔,修改成功后,第一次删除保证缓存立马失效去读新的数据。

缺点

  • 不适合“秒杀”这种频繁修改数据和要求数据强一致的场景,多次删除会导致 nsq 消息多,且删除缓存频繁。(甚至可以理解为要求强一致、更新频繁就不应该用缓存)
### Redis 一致性旁路缓存实现方案 #### 1. 缓存读取逻辑 当应用程序尝试获取某个键的数据时,会首先访问缓存。如果缓存命中,则直接返回缓存中的数据[^4]。 ```python def get_data_from_cache(key): data = redis.get(key) if data is not None: return data # Cache miss, read from database and populate cache data = db.query(key) # Check deleteKey to avoid dirty reads during replication delay if check_delete_key(key): data = master_db.query(key) # Force read from master else: data = slave_db.query(key) # Normal read from slave redis.setex(key, ttl, data) # Populate the cache with fresh data return data ``` #### 2. 数据更新逻辑 对于数据更新操作,在修改数据库之前先清除对应的缓存条目,并设置一个`deleteKey`标志位用于处理主从延迟期间可能发生的脏读问题[^3]。 ```python def update_data_in_database(key, value): set_delete_key(key) # Set a flag indicating this key was deleted recently try: result = db.update(key, value) clear_delete_key_after_delay(key) # Clear after estimated replication lag time return result finally: redis.delete(key) # Invalidate stale cached entry immediately ``` #### 3. 处理主从复制延迟 为了避免因MySQL主从同步延迟而导致的脏读现象,可以在删除缓存的同时创建一条带有过期时间(`TTL`)等于预计最大主从延迟周期的特殊标记记录(deleteKey)[^3]。 ```python import threading def set_delete_key(key): """Set a temporary marker that indicates recent deletion.""" redis.set(f'delete:{key}', 'true', ex=replication_lag_time) def check_delete_key(key): """Check whether there's an active delete mark for given key.""" return bool(redis.exists(f'delete:{key}')) def clear_delete_key_after_delay(key): timer = threading.Timer(replication_lag_time, lambda: redis.delete(f'delete:{key}')) timer.start() ``` 通过上述措施可以有效解决Redis作为旁路缓存时可能出现的一致性问题,既保持了良好的性能又确保了数据准确性。
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值