以下说的缓存,都是服务端缓存,和客户端缓存无关
我们在系统中引入缓存一般需要考虑以下几个属性
吞吐量是指这个缓存在单位时间内读写的操作次数,反应了缓存进行并发读写的性能。如果不是并发,普通的 HashMap 的读取效率也是 O(1),但是在并发场景下,读取操作收到了诸多影响(需要记录缓存读取的访问时间与访问状态等等数据,为过期等功能服务),更别提写入操作了
既然吞吐量受限是有原因的,那么我们就可以优化了。缓存中最主要的数据竞争是读取操作的同时进行对数据状态的写入操作,这些写入操作主要做数据维护
此时有两种可以选择的处理思路。一种是以 guava cache 为代表的同步处理机制,在访问数据的时候一并完成缓存淘汰、统计等状态变更操作,通过分段加锁的方式来减少竞争
另外一种是以 caffeine 为代表的异步日志提交机制,参考了数据库的日志设计,将数据的读写看成日志的提交过程。并且提交后有专门的环形缓冲区来记录日志,我们已经在 MySQL 的 redolog 中用到了这个缓冲区。设计成环形的好处是可以用有限的空间来存放无限的数据,前提是这些数据应当过一段时间后失效
缓存需要在消耗空间与节约时间中做取舍,我们应当尽可能让缓存淘汰掉一些低价值数据,根据空间局部性与时间局部性,刚刚使用过的数据肯定是需要留下来的,此时如何定义低价值成了提升命中率的重点
我们已经学习了 FIFO、LRU 等等基础的缓存淘汰策略,这些算法也在不断的升级改进,以 LFU(淘汰最不经常使用的数据) 为例,已经有更好的 TinyLFU 版本了。该版本做了一定的优化,比如没过一段时间,就将计时器的数值减半,以此解决旧热点难以清除的问题(滑动时间窗口)
缓存往往提供以下的基础功能:
分布式缓存是指使用缓存集群来保存数据,此时我们需要额外考虑数据在集群中的同步操作,对于分布式缓存来说,处理同步操作(数据在集群中的网络传递)比吞吐量更加重要
从访问的角度来说:
同时,分布式缓存也不可避免的需要在 AP 与 CP 中做取舍,redis 就是典型的 AP 式,高性能高可用,但是不保证强一致性,有可能这个节点写入数据了,过一会再另外一个节点还是访问不到该数据,以此该设计更适合做缓存。zk 集群就是 CP 型,强一致性的实现,让他更适合当分布式锁与注册中心
使用缓存大大提高了系统性能,但是不可避免的也提高了系统的复杂度,引发的问题也随之而来
指执行了大量不存在在 redis 里的查询(高并发情况下缓存命中率降低),或者出现了大量恶意查询(黑客攻击使数据库压力增大),或者缓存中数据已经过期了,一群人访问一些奇奇怪怪的请求,等等情况
这些情况会导致数据库的压力激增,并且通常出现缓存穿透的时候,数据库中一般也查不出数据,导致没有意义的 db 查询
解决方法:
1,缓存无效数据,让无效访问命中缓存,但是一般而言,无效请求无穷无尽,用个字典存放所有的恶意请求,一般来说不现实
2,使用 bitmaps 存放 url 白名单,只有登录的用户才可以访问该接口,如果一个用户执行大量的恶意请求就把他踢出白名单,但是如果有人想用爬虫搞你,一般来说攻击者会有很多的 IP 地址,在某些情况下这种方法不适用
3,布隆过滤器(一个判断 key 是否合法的数据结构),比较优秀的解法
4,业务层限流或者熔断
总结一下,防止穿透的主要思路就是请求在打到 db 之前对其进行过滤,减少数据库压力。我们推荐使用布隆过滤器这些方法进行算法过滤
维持布隆过滤器的是一个位数组(byte[]),和一群 hash 函数组成的
在一个元素加入布隆过滤器中的时,会使用这些 hash 函数映射出多个地址,将位数组的对应地址标记为1
判断一个元素是否存在于布隆过滤器的时,会使用这些 hash 函数映射出多个地址,如果映射出的地址中有0,说明这个元素不在过滤器中
除了寻找数据是否有效以外,过滤器还可以对两组大量数据进行模糊比较以寻找相同的数据,这种查询虽然消耗了一些准确性,但时间复杂度和空间复杂度都大大优化了,之后可以对选出的数进行二次操作
从原理中我们可以推断出,过滤器的时间复杂度与空间复杂度都为 O(1),非常的节省资源,并且该过滤器是保证准确性的。他在企业生产中可以使用的原因就是,他判定在过滤器里的数据不一定真的存在,但是他判定不在过滤器里的数据一定不存在
布隆过滤器的准确性主要依靠于 hash 碰撞的次数,只要减少了 hash 碰撞的次数就能增加准确性,因此我们有以下几个思路
1,将位数组增大
2,增加多个 hash 函数(过多的 hash 函数会填满位数组,导致准确性降低)
3,优化 hash 函数减少 hash 碰撞
guava 为我们提供了一个不稳定的过滤器,这个 API 还挺好用的,我们可以模仿这个思路实现一个布隆过滤器。这个 API 在业务中也可以使用
// 创建布隆过滤器,设置存储的数据类型,预期数据量,误判率 (必须大于0,小于1)
int insertions = 10000000;
double fpp = 0.0001;
BloomFilter<String> bloomFilter = BloomFilter.create(Funnels.stringFunnel(Charset.defaultCharset()), insertions, fpp);
// 随机生成数据,并添加到布隆过滤器中(将预期数据量全部塞满)
// 同时也创建一个 List 集合,将布隆过滤器中预期数据的十分之一存储到该 List 中
List<String> lists_1 = new ArrayList<String>();
for (int i = 0; i < insertions; i++) {
String uid = UUID.randomUUID().toString();
bloomFilter.put(uid);
if (i < insertions / 10) {
lists_1.add(uid);
}
}
// 再创建一个 List 集合,用来存储另外五分之一不存在布隆过滤器中的数据
List<String> lists_2 = new ArrayList<String>();
for (int i = 0; i < insertions / 5; i++) {
String uid = UUID.randomUUID().toString();
lists_2.add(uid);
}
// 对已存在布隆过滤器中的 lists_1 中的数据进行判断,看是否在布隆过滤器中
int result_1 = 0;
for (String s : lists_1) {
if (bloomFilter.mightContain(s)) result_1++;
}
System.out.println("在 <已存在> 布隆过滤器中的" + lists_1.size() + "条数据中,布隆过滤器认为存在的数量为:" + result_1);
// 对不存在布隆过滤器中的 lists_2 中的数据进行判断,看是否在布隆过滤器中
int result_2 = 0;
for (String s : lists_2) {
if (bloomFilter.mightContain(s)) result_2++;
}
System.out.println("在 <不存在> 布隆过滤器中的" + lists_2.size() + "条数据中,布隆过滤器认为存在的数量为:" + result_2);
// 对数据进行整除,求出百分率
NumberFormat percentFormat = NumberFormat.getPercentInstance();
percentFormat.setMaximumFractionDigits(2);
float percent = (float) result_1 / lists_1.size();
float bingo = (float) result_2 / lists_2.size();
System.out.println("命中率为:" + percentFormat.format(percent) + ",误判率为:" + percentFormat.format(bingo));
redis 中某个热点数据过期导致数据库访问压力增大,和缓存雪崩有些类似,本质上都是数据过期导致数据库压力增大
解决方法:
1,延长热点数据过期时间
2,实时监控
3,业务层限流或者熔断
4,定时任务刷数据
多个缓存在同一时间大面积过期,导致数据库接受大量请求,常见的出现原因有两种,第一种可能是 Redis 宕机,第二种可能是大量数据采用了相同的过期时间
解决方法:
1,限流(尽量避免使用),避免同时处理大量请求
2,使用集群
3,错开缓存过期时间,设置随机时间戳
4,定时任务刷数据
指缓存中的数据与数据源的真实数据不一致,缓存污染大概率是开发者更新缓存不规范导致的,具体的解决方法就是下面要介绍的设计模式了
数据库作主,缓存为辅。这也是最正常的缓存使用方案。我们知道使用缓存时最需要注意的就是缓存数据库不一致问题,旁路缓存模式给了我们很好的解决方案。旁路缓存的特点在于缓存只做新增和删除,不做更新
落实到代码里,大概是这样的:
// 更新 db
crmUserInfoRouteService.updateStatus(userName, status, operator);
// 删除 redis 数据
deleteQueryByUserNameCache(userName);
// 删除本地 ThreadLocal 数据
RequestContextCache.clear();
旁路缓存模式有以下几个要点:
如果先删除缓存,后修改数据库
在进行写操作后如果有另外一个线程进行读操作,并且这个线程在缓存中没有找到,将未修改的数据放到 cache 中,脏数据只能自己过期或者下一次写操作时才可以去除,脏数据时间范围时间范围不确定性很大,比如
如果下一次对该数据的更新马上就到来,那么会失效缓存,脏数据的时间就很短
如果下一次对该数据的更新要很久才到来,那这期间缓存保存的一直是脏数据,时间范围很长
不更新缓存主要是为了防止并发
多并发写操作时,可能数据库中的数据和缓存中数据不是一个线程的
比如有如下情况:A线程写入数据库-》B线程写入数据库-》B线程修改缓存-》A线程修改缓存
直接删除缓存不会出现这个问题
如果更新数据库成功,而删除缓存这一步失败的情况的话应该怎么做:
1,缩短过期时间:如果删除失败它大概率也会自己过期
2,增加 cache 更新重试机制:自己定一个合适的重试次数,每隔一段时间后进行重试。如果多次重试失败,把当前更新失败的 key 存入队列中,等到时机合适后将队列中的 key 全部删除
如果更新数据库失败该怎么办:
先将更新失败的数据放到一个安全的地方,比如消息队列,然后再不停的去执行写入 DB 操作
以缓存为主要数据存储,数据库为辅。从 cache 中读取数据并将数据写入 DB。这里主要是写数据时有些区别
查询:查询 catch,查不到查数据库,数据库返回 catch,然后 catch 返回结果
更新:直接更新 catch,然后 catch 同步更新数据库
更新时直接更新 catch,并且异步批量让 catch 更新数据库
其他同上
主要用于读少写多的场景,Linux 系统的页缓存和 MySQL InnoDB 引擎的 Cache Pool 其实就是使用的 WriteBack 策略.