在前面的blog中提到过,分布式缓存系统是大型分布式系统的基础设施之一。
常用的分布式缓存主要有Memcache和Redis。尤其是Redis,是一些大厂的主流分布式缓存选择。主要原因有两个,一是Redis支持多种数据存储类型,二是Redis支持数据持久化。
缓存主要在读多写少的情况下使用。在使用缓存的时候需要注意一些问题,如数据不一致,缓存击穿等。下面将对这些问题进行介绍。
缓存击穿,既然能击穿,说明是有缓存的,只是在某个时间点没有从缓存中获取到数据,例如缓存过期,此时需要到数据库中查询。如果并发量很小,那没什么问题。而如果并发量很大的情况下,例如对“热点”数据的查询,大量的请求同时涌入到数据库,会给数据库带来巨大的压力,甚至引起性能问题。
缓存穿透,就要查询的数据是不存在的,这样每次查询均需要走到数据库中,并且返回空。如果被人利用,很可能会对数据库造成影响。
缓存雪崩,就是批量的缓存穿透。缓存穿透是因为个别key缓存失效导致的,而当大量的key设置了相同的过期时间时,会导致缓存在同一时刻全部失效,从而瞬时DB请求量巨大、压力骤增,引起雪崩。
缓存穿透
缓存穿透有两种解决办法,一种是布隆过滤器,一种是对不存在的值也设置一个特殊的缓存如null,并将过期时间设短一点。
缓存击穿
对于缓存击穿问题,也有多种解决办法。
一种是通过定时任务定期地刷新缓存,即每次判断key当前的过期时间,发现快要过期了就重新设置过期时间。但这种方法只针对key较少的情况下可以使用,如果有较多的缓存key就不太合适了。
还有一种方法是通过互斥锁来实现。当有多个线程准备进入数据库获取数据时,让第一个线程加上互斥锁,只允许第一个线程进入数据库查询,获得结果后将数据设置到缓存中。其他的线程在互斥锁处等待几十或者几百毫秒,直到缓存中有了数据为止。代码示例如下:
public static String getData(String key) throws InterruptedException {
// 从缓存中读取数据
String result = getDataFormRedis(key);
if (result == null) {
// 加锁,从数据库中取数据
if (distributionLock.tryLock(lockKey, uniqueId, expireTime)) {
result = getDataFromDB(key);
if (result != null) {
setDataToCache(key, result);
}
distributionLock.releaseLock(lockKey, uniqueId);
} else {
// 获取锁失败, 暂停100ms之后重新获取数据
Thread.sleep(100);
result = getData(key);
}
}
return result;
}
缓存雪崩
对于缓存雪崩问题,设置缓存数据的过期时间时可以在基础值上加上随机数,防止同一时间大量数据过期现象发生。
缓存是为了缓解高并发下的数据库压力,使用缓存必然会带来数据的不一致问题。
正常使用缓存的过程如下:(1)查询缓存数据是否存在;(2)不存在则查询数据库;(3)将从数据库中查询到的数据设置到缓存中并返回查询结果;(4)下一次访问时直接从缓存中取数据,没有则重复以上步骤。
那么更新数据库时,该如何更新缓存呢?
缓存更新有两种方式,一种是set,一种是直接delete。
直接淘汰缓存比较方便,但是如果是热点数据的缓存,可能会带来缓存击穿问题。
更新缓存则会相对复杂一些,如果是简单的String类型还好,如果缓存的是一个复杂对象或文本,现在需要修改其中的部分属性,修改缓存需要先获取到完整对象,然后修改属性值,将对象序列化并set到缓存中。整个过程中修改缓存值的成本较高,不如直接做delete操作。
修改缓存和淘汰缓存的主要区别就是一次cache miss的区别,对于非热点数据的缓存更新,建议使用缓存淘汰即可。
先写数据库再淘汰缓存的问题是,数据库更新成功而缓存淘汰失败时,缓存中的数据就一直是脏数据了。
先淘汰缓存再写数据库的问题是,如果在淘汰缓存和写数据库中间有其他的读请求,则会读取到数据库更新之前的数据并设置到缓存中,随后数据库被更新,而缓存中的数据仍然是旧数据。
此时可以使用双淘汰法,即在写数据库之前和之后都淘汰缓存。
进一步思考,如果数据库是主从架构,主库写入之后同步到从库中需要一定的时间,此时双淘汰法的后一次淘汰则需要考虑这个延迟时间。如果直接在写入数据库成功之后淘汰缓存,而此时主从同步还没有完成,读请求读到的数据仍然是旧数据,这个旧数据被设置到缓存中就达不到双淘汰的目的。
可以在后一次淘汰缓存时考虑上主从同步的延迟时间,如休眠500毫秒之后再淘汰。
双淘汰法可以解决数据库与缓存不一致的问题,但由于主从同步的延迟,在这个延迟时间内,如果有读请求进入数据库,读到的数据仍然不是最新数据。当然,在大多数业务场景下,这个微小的延迟是可以接受的。
如果一定要求强一致性,可以考虑选择性读主库。
具体来说,就是当发生写操作时,对操作的记录设置一个缓存,用于标识对这个记录进行了操作(可以用db:table:PK这样的key来标识),过期时间设为主从同步的延时时间,如500ms。当读请求进来时,先在cache中查询标识key,如果存在,说明短时间内有写操作,而此时主从同步还没有完成,从库数据不是最新的,应该去主库查询。不存在key就去从库查询。