数据库和缓存双写问题
缓存的目的是为了减少数据库的压力,但只要用了缓存,就肯定会有不一致,2个数据源之间是没有事务的,没法保证绝对的强一致。
从理论上来说,给缓存设置过期时间,是保证最终一致性的解决方案。
常见的四种方案:
- 先更新缓存,在更新数据库
- 先更新数据库,再更新缓存
- 先删除缓存,再更新数据库
- 先更新数据库,再删除缓存
微软和Facebook采用的更新策略是第四种:
先更新缓存,在更新数据库
问题:如果更新缓存后,数据库回滚了,那缓存如何处理?
解决思路:数据发生了回滚,即出现异常,这里做一个异常回调,删除对应的缓存。
总结:不推荐,代码的侵入性太大,首先你要记下来redis之前的值,回滚的时候再写回去,如果是insert,你得做一次delete,如果是update,你需要update回去,如果delete你得insert,并且如果回调中发生了异常怎么办,非常麻烦。
先更新数据库,再更新缓存
问题一(线程安全角度),同时有请求A和请求B进行更新操作,可能会出现:
- 线程A更新了数据库
- 线程B更新了数据库
- 线程B更新了缓存
- 线程A更新了缓存
请求A更新缓存应该比请求B更新缓存早才对,但是因为网络等原因,B却比A更早更新了缓存。这就导致了脏数据。
解决思路:加锁,比如修改id为1的学生姓名,更新数据库之前,先对id=1的资源加锁,更新完数据库和缓存后,再释放锁。
总结:不推荐,加锁降低了系统的并发度,也使得系统更复杂。
问题二(业务场景角度),主要有两点:
- 如果写数据库场景比较多,而读数据场景比较少的业务需求,采用这种方案就会导致,数据压根还没读到,缓存就被频繁的更新,浪费性能。
- 如果写入数据库的值,并不是直接写入缓存的,而是要经过一系列复杂的计算再写入缓存。那么,每次写入数据库后,都再次计算写入缓存的值,无疑是浪费性能的。显然,删除缓存更为适合。
先删除缓存,再更新数据库
这种方案同样会导致不一致,请求A进行更新操作,请求B进行查询操作,可能会出现:
- 请求A进行更新操作,先删除缓存
- 请求B查询发现缓存不存在
- 请求B去数据库查询得到旧值
- 请求B将旧值写入缓存
- 请求A将新值写入数据库
在删除缓存之后,更新数据库之前,如果有其他线程进行查询操作,就可能就会导致不一致的情形出现。如果不采用给缓存设置过期时间策略,该数据永远都是脏数据。
解决思路:延迟双删策略,伪代码如下
public void write(String key,Object data){
redis.delKey(key);
db.updateData(data);
Thread.sleep(1000);
redis.delKey(key);
}
转化为中文描述就是
- 先淘汰缓存
- 再写数据库(这两步和原来一样)
- 休眠1秒,再次淘汰缓存。这么做,可以将1秒内所造成的缓存脏数据,再次删除
那么,这个1秒怎么确定的,具体该休眠多久呢?
针对上面的情形,应该自行评估项目的读数据业务逻辑的耗时。然后写数据的休眠时间则在读数据业务逻辑的耗时基础上,加几百ms即可。这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。
采用这种同步淘汰策略,吞吐量降低怎么办?
那就将第二次删除作为异步的。自己起一个线程,异步删除。这样,写的请求就不用沉睡一段时间后了,再返回。这么做,加大吞吐量。
第二次删除,如果删除失败怎么办?
采用重试机制,详见方案4。
先更新数据库,再删缓存
这种方案同样会导致不一致,请求A进行查询操作,请求B进行更新操作,可能会出现:
- 缓存不存在或刚好失效
- 请求A查询数据库,得一个旧值
- 请求B将新值写入数据库
- 请求B删除缓存
- 请求A将查到的旧值写入缓存
在查询数据库之后,写入缓存之前,如果有其他线程进行更新操作,就可能就会导致不一致的情形出现。
仔细对比一下方案3和方案4发生数据不一致的场景:
方案3:在删除缓存之后,更新数据库之前,如果有其他线程进行查询操作
方案4:在查询数据库之后,写入缓存之前,如果有其他线程进行更新操作
但是方案4和方案3对比有一个先天优势,一般数据库都是写入速度比读写速度慢(当然也有写比读快的数据库,例如HBase),所以方案4发生不一致的可能性就更低。
如何解决上述的并发问题?
可以异步延迟双删+失败重试策略,保证读请求完成以后,再进行删除操作(即步骤4在步骤5之前发生)。
方案一,例如结合MQ,流程如下:
- 更新数据库数据
- 缓存因为种种问题删除失败
- 将需要删除的key发送至消息队列
- 自己消费消息,获得需要删除的key
- 继续重试删除操作,直到成功
该方案有一个缺点:对业务线代码造成大量的侵入。
方案二,启动一个订阅程序去订阅数据库的binlog,获得需要操作的数据。在应用程序中,另起一段程序,获得这个订阅程序传来的信息,进行删除缓存操作。
流程如下图所示:
- 更新数据库数据
- 数据库会将操作信息写入binlog日志当中
- 订阅程序提取出所需要的数据以及key
- 另起一段非业务代码,获得该信息
- 尝试删除缓存操作,发现删除失败
- 将这些信息发送至消息队列重新从消息队列中获得该数据,重试操作
- 重新从消息队列中获得该数据,重试操作
备注:上述的订阅binlog程序在mysql中有现成的中间件叫canal,可以完成订阅binlog日志的功能。