缓存相关问题


数据本来应当存放在数据库,抽象来说的话,应当存放在数据源。但是我们在程序中不免有一些频繁使用的数据,这时候我们得考虑是否需要将数据存放在里服务器比较近的位置,就是缓存。但是使用缓存随之而来会有很多问题

以下说的缓存,都是服务端缓存,和客户端缓存无关

缓存属性

我们在系统中引入缓存一般需要考虑以下几个属性

吞吐量

吞吐量是指这个缓存在单位时间内读写的操作次数,反应了缓存进行并发读写的性能。如果不是并发,普通的 HashMap 的读取效率也是 O(1),但是在并发场景下,读取操作收到了诸多影响(需要记录缓存读取的访问时间与访问状态等等数据,为过期等功能服务),更别提写入操作了

既然吞吐量受限是有原因的,那么我们就可以优化了。缓存中最主要的数据竞争是读取操作的同时进行对数据状态的写入操作,这些写入操作主要做数据维护

此时有两种可以选择的处理思路。一种是以 guava cache 为代表的同步处理机制,在访问数据的时候一并完成缓存淘汰、统计等状态变更操作,通过分段加锁的方式来减少竞争

另外一种是以 caffeine 为代表的异步日志提交机制,参考了数据库的日志设计,将数据的读写看成日志的提交过程。并且提交后有专门的环形缓冲区来记录日志,我们已经在 MySQL 的 redolog 中用到了这个缓冲区。设计成环形的好处是可以用有限的空间来存放无限的数据,前提是这些数据应当过一段时间后失效

命中率与淘汰策略

缓存需要在消耗空间与节约时间中做取舍,我们应当尽可能让缓存淘汰掉一些低价值数据,根据空间局部性与时间局部性,刚刚使用过的数据肯定是需要留下来的,此时如何定义低价值成了提升命中率的重点

我们已经学习了 FIFO、LRU 等等基础的缓存淘汰策略,这些算法也在不断的升级改进,以 LFU(淘汰最不经常使用的数据)为例,已经有更好的 TinyLFU 版本了。该版本做了一定的优化,比如没过一段时间,就将计时器的数值减半,以此解决旧热点难以清除的问题(滑动时间窗口)

扩展功能

缓存往往提供以下的基础功能:

  • 淘汰策略:数据太多怎么处理
  • 失效策略:比如 redis 的过期字典,一段时间后缓存中的数据自动失效
  • 支持并发:为保证缓存数据的准确性,在并发访问的时候需要做同步控制
  • 统计信息:提供比如命中率、自动回收计数等等数据
  • 持久化:缓存挂了之后会从磁盘中读取数据,分布式缓存通常会考虑持久化功能

分布式缓存

分布式缓存是指使用缓存集群来保存数据,此时我们需要额外考虑数据在集群中的同步操作,对于分布式缓存来说,处理同步操作(数据在集群中的网络传递)比吞吐量更加重要

从访问的角度来说:

  • 频繁更新但是读取较少的数据,一般是不会被做成缓存的
  • 对于更新非常少,而读取又非常多的数据,更适合做成复制式缓存。复制式缓存的意思是缓存中的所有数据在分布式集群的每个节点中都又一份副本,因为这个特性,更新缓存的代价十分高昂
  • 对于更新与读取都十分频繁的数据,理论上应当做成集中式缓存。即缓存中的数据不会遍布集群中的所有机器,每个节点只缓存部分数据,当请求的数据在某个节点中没有的时候,机器的拓扑结构会让他找到存放该数据的机器。redis 的去中心化集群,就是集中式缓存的设计

同时,分布式缓存也不可避免的需要在 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 提供的布隆过滤器

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,定时任务刷数据

缓存污染

缓存污染是指操作系统将不常用的数据从内存移到缓存,降低了缓存效率的现象。缓存污染会随着数据的持续增加而逐渐显露,随着服务的不断运行,缓存中会存在大量的永远不会再次被访问的数据

缓存污染主要使用内存淘汰策略处理,根据淘汰策略去选择要淘汰的数据(比如 LRU,或者淘汰即将要过期的数据,或者淘汰最近最少使用的数据),然后进行删除操作

分布式锁

假如用 redis 实现分布式锁,也就是 setnx 同时设置超时时间,假设某业务耗时较长或网络原因,超过锁的超时时间了会导致两个线程都操作资源,这块如何解决?超时时间值如何设置?

一般来说往大了设置不会出现业务问题,但是从性能方面考虑可能会出现多个线程等待一个锁的问题。往小了设置可能会出现业务还没有执行完,其他的线程就加锁了,如何这个线程执行完了把其他线程加的锁删了

我们处理以上问题,我们有以下策略:

  • 在释放锁之前,抛异常了,或者业务执行完了,锁是一定要释放的。使用 finally,在 finally 中释放锁。如果担心误删别人的锁,我们可以在值里面再加一个字段,线程 ID,如果当前线程 ID 和锁里的线程 ID 不一致,不可删除
  • 如果到了时间任务还没有执行完,我们可以则调用 redis 的 expire 指令进行锁数据的延期操作

第一步中的判断线程 ID 不一致和删除锁是分两步执行的,我们可以使用 lua 脚本,通过 redis 的 eval/evalsha 命令来运行,通过这个命令,一条命令没执行完,其他客户端是看不到的。在 Redis 中,Lua 脚本能够保证原子性的主要原因还是 Redis 采用了单线程执行模型。也就是说,当 Redis 执行 Lua 脚本时,Redis 会把 Lua 脚本作为一个整体并把它当作一个任务加入到一个队列中,然后单线程按照队列的顺序依次执行这些任务,在执行过程中 Lua 脚本是不会被其他命令或请求打断,因此可以保证每个任务的执行都是原子性的

缓存常用的读写策略

旁路缓存模式

数据库作主,缓存为辅。这也是最正常的缓存使用方案。我们知道使用缓存时最需要注意的就是缓存数据库不一致问题,旁路缓存模式给了我们很好的解决方案。旁路缓存的特点在于缓存只做新增和删除,不做更新

  • 查询:先查缓存,查不到再查数据库,更新缓存然后返回结果,这里是由缓存组件负责从数据库中同步加载数据
  • 修改:修改数据库,然后删除缓存

落实到代码里,大概是这样的:

		// 更新 db
        crmUserInfoRouteService.updateStatus(userName, status, operator);
        // 删除 redis 数据
        deleteQueryByUserNameCache(userName);
        // 删除本地 ThreadLocal 数据
        RequestContextCache.clear();

旁路缓存模式有以下几个要点:

为什么不能先删除缓存,后修改数据库?

如果先删除缓存,后修改数据库

在进行写操作后如果有另外一个线程进行读操作,并且这个线程在缓存中没有找到,将未修改的数据放到 cache 中,脏数据只能自己过期或者下一次写操作时才可以去除,脏数据时间范围时间范围不确定性很大,比如

如果下一次对该数据的更新马上就到来,那么会失效缓存,脏数据的时间就很短

如果下一次对该数据的更新要很久才到来,那这期间缓存保存的一直是脏数据,时间范围很长

为什么不能修改数据库后直接更新缓存?

不更新缓存主要是为了防止并发

多并发写操作时,可能数据库中的数据和缓存中数据不是一个线程的

比如有如下情况:A线程写入数据库-》B线程写入数据库-》B线程修改缓存-》A线程修改缓存

直接删除缓存不会出现这个问题

上面两个问题,可以统称为缓存与数据库的一致性问题,即如何保证缓存和数据库的一致性,只需要遵从上面的流程就可以保证一致性了

更新失败应该怎么办?

如果更新数据库成功,而删除缓存这一步失败的情况的话应该怎么做:

1,缩短过期时间:如果删除失败它大概率也会自己过期
2,增加 cache 更新重试机制:自己定一个合适的重试次数,每隔一段时间后进行重试。如果多次重试失败,把当前更新失败的 key 存入队列中,等到时机合适后将队列中的 key 全部删除

如果更新数据库失败该怎么办:

先将更新失败的数据放到一个安全的地方,比如消息队列,然后再不停的去执行写入 DB 操作

读写穿透(Read/Write through)

以缓存为主要数据存储,数据库为辅。从 cache 中读取数据并将数据写入 DB。这里主要是写数据时有些区别。这么处理的好处是大大提升了读取效率,一般在流量高的时候会这么处理

查询:查询 catch,查不到查数据库,数据库返回 catch,然后 catch 返回结果

更新:直接更新 catch,然后 catch 同步更新数据库

异步回写

更新时直接更新 catch,并且异步批量让 catch 更新数据库

其他同上

主要用于读少写多的场景,Linux 系统的页缓存和 MySQL InnoDB 引擎的 Cache Pool 其实就是使用的 WriteBack 策略.

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
是的,WebRTC 缓存问题可能涉及到本地缓存。在 WebRTC 中,本地缓存通常指的是浏览器或应用程序在本地存储媒体数据的临时文件或缓冲区。 当进行音视频通话或媒体流传输时,WebRTC 可能会将接收到的数据暂时存储在本地缓存中,以确保较平滑的播放体验。这可以帮助处理网络延迟、丢包或其他网络不稳定情况。 然而,本地缓存也可能导致一些问题,例如延迟增加、占用过多的存储空间或数据不同步。这些问题可能是由于缓存设置不合理、缓存文件损坏或其他应用程序相关问题引起的。 如果你遇到 WebRTC 缓存问题并怀疑与本地缓存有关,你可以尝试以下方法: 1. 清除浏览器缓存:清除浏览器的缓存可能会清除一些本地缓存文件。尝试清除浏览器缓存后,重新加载页面并测试是否仍然存在问题。 2. 调整缓存设置:如果你有权限访问 WebRTC 应用程序的设置,可以尝试调整相关缓存设置。例如,你可以尝试更改本地缓存文件的存储位置或缓存大小。 3. 重启浏览器或应用程序:有时候,重启浏览器或应用程序可以清除一些临时文件或重置缓存设置,从而解决问题。 请注意,具体的解决方法可能会因应用程序、浏览器或操作系统的不同而有所差异。如果以上方法无法解决问题,建议查看相关的开发者文档或寻求社区支持以获取更详细的指导和解决方案。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值