Cache缓存

Cache缓存
mengnankkzhou缓存
1.为什么要用缓存?
用缓存,主要有两个用途:高性能、高并发。
就是说对于一些需要复杂操作耗时查出来的结果,且确定后面不怎么变化,但是有很多读请求,那么直接将查询出来的结果放在缓存中,后面直接读缓存就好。
缓存功能简单,说白了就是 key-value
式操作,单机支撑的并发量轻松一秒几万十几万,支撑高并发 so easy。单机承载并发量是 mysql 单机的几十倍。
如果按照我们存储的位置来分的话,缓存分为:
本地缓存,访问速度最快的,一般我们查数据的时候,首先查的就是本地缓存,比如Caffeine, Guava Cache, Ehcache
分布式缓存,容量更大,多个应用之间可以i将那些共享数据,比如Redis, Memcached, Hazelcast
客户端缓存,缓存数据存储在客户端,比如浏览器缓存,app缓存等等
CDN缓存,缓存静态资源在离用户较近的 CDN 节点上,常用于加速访问的速度
数据库缓存,数据库自身提供的缓存机制, 用于缓存查询结果,比如mysql就有缓存机制
Redis
1.Redis 和 Memcached 有啥区别?
- Redis 相比 Memcached 来说,拥有更多的数据结构,能支持更丰富的数据操作。如果需要缓存能够支持更复杂的结构和操作, Redis 会是不错的选择。
- 在 Redis3.x 版本中,便能支持 cluster 模式,而 Memcached 没有原生的集群模式,需要依靠客户端来实现往集群中分片写入数据。
- 由于 Redis 只使用单核,而 Memcached 可以使用多核,所以平均每一个核上 Redis 在存储小数据时比 Memcached 性能更高。而在 100k 以上的数据中,Memcached 性能要高于 Redis。虽然 Redis 最近也在存储大数据的性能上进行优化,但是比起 Memcached,还是稍有逊色。
本地缓存
类型 | 50万写入:耗时:ms | 50写+50读(读全命中):耗时:ms | 50万写+50万读(全命中)+50万读(未命中):耗时:ms | 50万读+50万未命中 |
---|---|---|---|---|
Guava | 329/340/326/328/328 | 536/518/546/525/558 | 647/646/638/668/641 | 490/501/482/485/492 |
Caffeine | 292/284/270/279/267 | 414/382/353/385/361 | 479/513/460/487/481 | 343/326/333/336/369 |
Ohc | 448/433/430/446/442 | 763/748/765/741/705 | 918/947/901/964/903 | 653/676/607/639/704 |
Ohc-Obj | 1343/1315/1217/1249/1193 | 1910/1830/1849/1803/1786 | 1979/1965/1947/1968/1946 | 1487/1573/1499/1491/1483 |
我们一般使用的时候就使用Caffeine就足够了
Caffeine 的核心设计目标是提供具有高命中率和低延迟的缓存。 其架构主要由以下几个关键组件组成:
- ConcurrentMap: Caffeine 实现了
ConcurrentMap
接口,这意味着它是线程安全的。 底层使用分段锁或无锁数据结构来支持并发访问。 - CacheLoader (可选): 用于在缓存未命中时自动加载值。 可以自定义实现
CacheLoader
接口, 定义加载数据的逻辑. - CacheWriter (可选): 用于处理缓存的写入和删除操作。 可以自定义实现
CacheWriter
接口,定义写入和移除缓存条目的逻辑。 - 驱逐策略 (Eviction Policies): Caffeine 采用基于 最逼近最优 (Approaching Optimal) 的驱逐算法,包括TinyLFU 和 Window-TinyLFU。
- TinyLFU (Tiny Least Frequently Used): 一个频率sketch, 用于估算每个条目的访问频率。 占用了极小的内存空间,但能提供接近最佳的频率估算。
- Window-TinyLFU: 一个结合了最近访问(Windowed LFU)和频率信息(TinyLFU)的二级缓存结构。最近访问的数据保存在一个小的”窗口”中,而更长时间内的数据则通过TinyLFU来估算访问频率.
- 刷新策略 (Refresh): 支持异步地刷新缓存条目,从而保持缓存数据的新鲜度。
- 写入策略 (Write): 支持同步或异步地将缓存条目写入到持久层(例如数据库)。
- 监听器 (Listeners): 允许注册监听器,以便在缓存条目被添加、更新或移除时执行自定义的逻辑。
优点:
- 接近最优的命中率: Caffeine 使用了非常先进的驱逐算法,能够更好地保留热点数据,从而提高命中率。
- 低延迟: Caffeine 针对低延迟进行了优化,尽可能减少锁竞争,提高并发访问性能。
- 自动加载: 通过
CacheLoader
可以在缓存未命中时自动加载数据,简化了缓存的使用。 - 异步刷新: 异步刷新能够保持缓存数据的新鲜度,同时避免阻塞主线程。
- 灵活的配置: Caffeine 提供了丰富的配置选项,可以根据不同的需求进行定制。
- 轻量级: Caffeine 依赖少,体积小,易于集成到项目中。
使用
手动加载:
1 | Cache<String, String> manualCache = Caffeine.newBuilder() |
自动加载:
1 | LoadingCache<String, String> loadingCache = Caffeine.newBuilder() |
异步加载:
1 | AsyncLoadingCache<String,String> asyncLoadingCache = Caffeine.newBuilder() |
使用缓存会导致的问题
数据的一致性问题
最经典的缓存+数据库读写的模式,就是 Cache Aside Pattern。
- 读的时候,先读缓存,缓存没有的话,就读数据库,然后取出数据后放入缓存,同时返回响应。
- 更新的时候,先更新数据库,然后再删除缓存。
为什么是删除缓存?
在复杂点的缓存场景,缓存不单单是数据库中直接取出来的值。可能是一个多表查询的运算的结果。其实删除缓存,而不是更新缓存,就是一个 lazy 计算的思想,不要每次都重新做复杂的计算,不管它会不会用到,而是让它到需要被使用的时候再重新计算。
1.先更新数据库,再删除缓存。如果删除缓存失败了,那么会导致数据库中是新数据,缓存中是旧数据,数据就出现了不一致。
- 先删除缓存,再更新数据库。如果数据库更新失败了,那么数据库中是旧数据,缓存中是空的,那么数据不会不一致。因为读的时候缓存没有,所以去读了数据库中的旧数据,然后更新到缓存中。
- 延时双删。依旧是先更新数据库,再删除缓存,唯一不同的是,我们把这个删除的动作,在不久之后再执行一次,比如 5s 之后。
2.数据发生了变更,先删除了缓存,然后要去修改数据库,此时还没修改。一个请求过来,去读缓存,发现缓存空了,去查询数据库,查到了修改前的旧数据,放到了缓存中。随后数据变更的程序完成了数据库的修改。完了,数据库和缓存中的数据不一样了…
设计:
- 读请求: 优先从缓存读取。 如果缓存未命中,则发起缓存更新请求(放入队列),并同步等待一段时间。 如果等待超时,则直接读取数据库(返回旧数据)。
- 写请求: 首先删除缓存,然后更新数据库。 更新数据库的操作也放入队列中串行执行。
- 队列: 每个数据项(例如商品 ID)维护一个专属的 JVM 内部队列。
- 工作线程: 每个队列对应一个工作线程,负责串行执行队列中的更新操作(先更新数据库,后更新缓存)。
- 路由: 基于数据唯一标识(例如商品 ID)进行路由,确保对同一数据项的读写请求都会路由到相同的队列和工作线程。
问题:
1.队列积压:务必设置读请求的超时时间,保证请求及时返回。**加队列的数量,将数据分散到不同的队列中,减少每个队列的积压。
或者是如果是热点数据搞得,我们可以拆分热点数据和普通数据
2.单个热点数据过热,预先识别热点数据,并将其分散到不同的队列中。 可以采用更精细的哈希算法,或者人工配置的方式。,人工限流降级,在服务器内部使用二级缓存。
3.路由实例间得路由不一致,使用一致性哈希算法,确保对同一数据项的请求始终路由到相同的服务实例。或者使用nginx进行代理
一看这样我们可以直接引入kafka来解决啊
特性 | JVM 内部队列 | Kafka |
---|---|---|
解耦程度 | 有限,读写服务在同一 JVM 中 | 更彻底,读写服务完全解耦 |
扩展性 | 受单机 JVM 限制 | 高,可以通过增加分区和消费者实例来提高吞吐量 |
可靠性 | 较低,消息容易丢失 | 高,消息持久化和消息重放机制 |
容错性 | 差,单点故障 | 好,集群容错 |
复杂度 | 低,易于实现 | 高,需要配置和维护 Kafka 集群 |
延迟 | 低,JVM 内部通信 | 较高,消息需要经过网络传输 |
一致性保证 | 容易实现简单的一致性 | 需要考虑事务和消息顺序性 |
缓存三兄弟
缓存三兄弟就是我们常说的的缓存击穿,缓存穿透,缓存雪崩。
缓存雪崩,是指短时间内有大量key过期,然后一下子压力全到数据库上面去了。
预防:Redis 高可用,主从+哨兵,Redis cluster,避免全盘崩溃。
处理:本地 ehcache 缓存 + hystrix 限流&降级,避免 MySQL 被打死。
事后:Redis 持久化,一旦重启,自动从磁盘上加载数据,快速恢复缓存数据。
在代码上的话,我们可以使用互斥锁,只有获得了锁才能去访问。如果是过期时间的问题,采取热点数据永不过期,设置随机的过期时间等等。
如果是太多请求的话,我们可以限流,或者降级。
缓存穿透:
就是很多个缓存查不到的请求,数据库也查不到的请求发过来了
我们可以采用空值的方式,每次系统 从数据库中只要没查到,就写一个空值到缓存里去,比如 set -999 UNKNOWN
。然后设置一个过期时间,这样的话,下次有相同的 key 来访问的时候,在缓存失效之前,都可以直接从缓存中取数据。
或者是我们使用布隆过滤器,过滤我们数据库没有的请求。
缓存击穿:
就是一瞬间热点key直接失效了,大量的请求就击穿了缓存,直接请求数据库。
- 若缓存的数据是基本不会发生更新的,则可尝试将该热点数据设置为永不过期。
- 若缓存的数据更新不频繁,且缓存刷新的整个流程耗时较少的情况下,则可以采用基于 Redis、zookeeper 等分布式中间件的分布式互斥锁,或者本地互斥锁以保证仅少量的请求能请求数据库并重新构建缓存,其余线程则在锁释放后能访问到新缓存。
- 若缓存的数据更新频繁或者在缓存刷新的流程耗时较长的情况下,可以利用定时线程在缓存过期前主动地重新构建缓存或者延后缓存的过期时间,以保证所有的请求能一直访问到对应的缓存。
缓存并发竞争
Redis 的并发竞争问题是什么?如何解决这个问题?了解 Redis 事务的 CAS 方案吗?
就是多客户端同时并发写一个 key,可能本来应该先到的数据后到了,导致数据版本错了;或者是多客户端同时获取一个 key,修改值之后再写回去,只要顺序错了,数据就错了。
比如:
- 库存扣减: 多个客户端同时购买同一商品,导致库存超卖。
- 计数器更新: 多个客户端同时增加计数器的值,导致计数器结果不准确。
- 排行榜更新: 多个客户端同时更新排行榜数据,导致排行榜数据不一致。
1.单线程架构,Redis 本身是单线程架构,这意味着 Redis 命令是顺序执行的,避免了多线程并发问题。 但是,单线程架构并不能完全解决并发竞争问题。 例如,多个客户端同时发送 INCR
命令来增加计数器的值,Redis 仍然可能因为网络延迟等原因导致计数器结果不准确。
2.原子操作,Redis 提供了许多原子操作,例如 INCR
、DECR
、SETNX
等。 原子操作可以保证操作的完整性,避免并发竞争问题。 例如,可以使用 INCR
命令来原子地增加计数器的值。
3.Lua脚本,可以将多个 Redis 命令组合成一个 Lua 脚本,然后使用 EVAL
命令来原子地执行该脚本。 Lua 脚本可以保证多个命令的原子性,避免并发竞争问题。 例如,可以使用 Lua 脚本来实现原子性的库存扣减操作。
4.CAS,Redis 提供了 WATCH
命令来实现乐观锁。 乐观锁允许多个客户端同时读取同一个 Key 的值,但是在更新 Key 的值之前,需要先检查该 Key 的值是否被其他客户端修改过。 如果被修改过,则更新失败,需要重新尝试。
- 如果被
WATCH
监视的 Key 在事务执行期间被其他客户端修改过,那么EXEC
命令会返回nil
,表示事务执行失败。 - 如果被
WATCH
监视的 Key 在事务执行期间没有被其他客户端修改过,那么EXEC
命令会执行事务中的所有命令,并返回执行结果。
5.分布式锁,redission?保证同一时间只有一个客户端可以访问共享资源。
可以基于 zookeeper 实现分布式锁。每个系统通过 zookeeper 获取分布式锁,确保同一时间,只能有一个系统实例在操作某个 key,别人都不允许读和写。
你要写入缓存的数据,都是从 mysql 里查出来的,都得写入 mysql 中,写入 mysql 中的时候必须保存一个时间戳,从 mysql 查出来的时候,时间戳也查出来。
每次要写之前,先判断一下当前这个 value 的时间戳是否比缓存里的 value 的时间戳要新。如果是的话,那么可以写,否则,就不能用旧的数据覆盖新的数据。