数据库和缓存双写问题

缓存的目的是为了减少数据库的压力,但只要用了缓存,就肯定会有不一致,2个数据源之间是没有事务的,没法保证绝对的强一致。

从理论上来说,给缓存设置过期时间,是保证最终一致性的解决方案。

常见的四种方案:

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

微软和Facebook采用的更新策略是第四种:

cache-aside

Scaling Memcache at Facebook

先更新缓存,在更新数据库

问题:如果更新缓存后,数据库回滚了,那缓存如何处理?

解决思路:数据发生了回滚,即出现异常,这里做一个异常回调,删除对应的缓存。

总结:不推荐,代码的侵入性太大,首先你要记下来redis之前的值,回滚的时候再写回去,如果是insert,你得做一次delete,如果是update,你需要update回去,如果delete你得insert,并且如果回调中发生了异常怎么办,非常麻烦。

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

问题一(线程安全角度),同时有请求A和请求B进行更新操作,可能会出现:

  1. 线程A更新了数据库
  2. 线程B更新了数据库
  3. 线程B更新了缓存
  4. 线程A更新了缓存

请求A更新缓存应该比请求B更新缓存早才对,但是因为网络等原因,B却比A更早更新了缓存。这就导致了脏数据。

解决思路:加锁,比如修改id为1的学生姓名,更新数据库之前,先对id=1的资源加锁,更新完数据库和缓存后,再释放锁。

总结:不推荐,加锁降低了系统的并发度,也使得系统更复杂。

问题二(业务场景角度),主要有两点:

  1. 如果写数据库场景比较多,而读数据场景比较少的业务需求,采用这种方案就会导致,数据压根还没读到,缓存就被频繁的更新,浪费性能。
  2. 如果写入数据库的值,并不是直接写入缓存的,而是要经过一系列复杂的计算再写入缓存。那么,每次写入数据库后,都再次计算写入缓存的值,无疑是浪费性能的。显然,删除缓存更为适合。

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

这种方案同样会导致不一致,请求A进行更新操作,请求B进行查询操作,可能会出现:

  1. 请求A进行更新操作,先删除缓存
  2. 请求B查询发现缓存不存在
  3. 请求B去数据库查询得到旧值
  4. 请求B将旧值写入缓存
  5. 请求A将新值写入数据库

在删除缓存之后,更新数据库之前,如果有其他线程进行查询操作,就可能就会导致不一致的情形出现。如果不采用给缓存设置过期时间策略,该数据永远都是脏数据。

解决思路:延迟双删策略,伪代码如下

public void write(String key,Object data){
    redis.delKey(key);
    db.updateData(data);
    Thread.sleep(1000);
    redis.delKey(key);
}

转化为中文描述就是

  1. 先淘汰缓存
  2. 再写数据库(这两步和原来一样)
  3. 休眠1秒,再次淘汰缓存。这么做,可以将1秒内所造成的缓存脏数据,再次删除

那么,这个1秒怎么确定的,具体该休眠多久呢?

针对上面的情形,应该自行评估项目的读数据业务逻辑的耗时。然后写数据的休眠时间则在读数据业务逻辑的耗时基础上,加几百ms即可。这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。

采用这种同步淘汰策略,吞吐量降低怎么办?

那就将第二次删除作为异步的。自己起一个线程,异步删除。这样,写的请求就不用沉睡一段时间后了,再返回。这么做,加大吞吐量。

第二次删除,如果删除失败怎么办?

采用重试机制,详见方案4。

先更新数据库,再删缓存

这种方案同样会导致不一致,请求A进行查询操作,请求B进行更新操作,可能会出现:

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

在查询数据库之后,写入缓存之前,如果有其他线程进行更新操作,就可能就会导致不一致的情形出现。

仔细对比一下方案3和方案4发生数据不一致的场景:

方案3:在删除缓存之后,更新数据库之前,如果有其他线程进行查询操作

方案4:在查询数据库之后,写入缓存之前,如果有其他线程进行更新操作

但是方案4和方案3对比有一个先天优势,一般数据库都是写入速度比读写速度慢(当然也有写比读快的数据库,例如HBase),所以方案4发生不一致的可能性就更低。

如何解决上述的并发问题?

可以异步延迟双删+失败重试策略,保证读请求完成以后,再进行删除操作(即步骤4在步骤5之前发生)。

方案一,例如结合MQ,流程如下:

  1. 更新数据库数据
  2. 缓存因为种种问题删除失败
  3. 将需要删除的key发送至消息队列
  4. 自己消费消息,获得需要删除的key
  5. 继续重试删除操作,直到成功

该方案有一个缺点:对业务线代码造成大量的侵入

方案二,启动一个订阅程序去订阅数据库的binlog,获得需要操作的数据。在应用程序中,另起一段程序,获得这个订阅程序传来的信息,进行删除缓存操作。

流程如下图所示:

  1. 更新数据库数据
  2. 数据库会将操作信息写入binlog日志当中
  3. 订阅程序提取出所需要的数据以及key
  4. 另起一段非业务代码,获得该信息
  5. 尝试删除缓存操作,发现删除失败
  6. 将这些信息发送至消息队列重新从消息队列中获得该数据,重试操作
  7. 重新从消息队列中获得该数据,重试操作

备注:上述的订阅binlog程序在mysql中有现成的中间件叫canal,可以完成订阅binlog日志的功能。