缓存
// TODO: 与 Redis 章节中的生产问题重复,考虑此篇文章的处理方式。
1. 出现的背景
进程内缓存 -> 单实例的进程间缓存(本地缓存) -> 分布式缓存
关于 本地缓存 : 请参考笔者的另外一篇文章: Java 中的本地缓存。获取更多内容可以关注笔者微信公众号:天晴小猪(WeChatID: zeanzai-me),也可以扫描文章底部的二维码进行关注。
2. 分布式缓存特性
- 高性能
- 高可扩展
- 高可用
- 高并发
- 故障自动转移
- 负载均衡
- [optional]支持事务和持久化
3. 技术选型
关于 如何进行技术选型
请参考笔者的另外一篇文章: 技术选型。获取更多内容可以关注笔者微信公众号:天晴小猪(WeChatID: zeanzai-me
),也可以扫描文章底部的二维码进行关注。
3.1. 中间件选型
redis memecached
3.2. 组件选型
jedis RedisTemplate lettuce Redisson
3.3. redis 基本原理
请参考笔者的另外一篇文章: Redis 基本原理-redis.md。获取更多内容可以关注笔者微信公众号:天晴小猪(WeChatID: zeanzai-me
),也可以扫描文章底部的二维码进行关注。
4. 面试题
4.1. 分布式系统中的多级缓存
4.2. redis 如何调优?
这个问题的另一个问法: 在不增加机器和不淘汰 key 的情况下,如何优化 redis 的存储空间?
4.3. 七大经典缓存问题
缓存失效
- 解释: 系统由于预热,把一批数据加载到缓存中,但是由于对缓存时间考虑不周,导致后期缓存在某一个时间节点突然集体失效,系统大量请求全部打在数据库上,造成数据库压力过大甚至宕机;
- 解决方案: 既然缓存实效是因为 key 的失效时间设置的不合理,那么解决这个问题也要从 key 的失效时间下手,我们可以让 key 的失效时间=原定的失效时间+随机时间,这样的话,key 就不会集中失效了。
缓存雪崩
- 解释: 就是由于突然缓存所在机器出现问题(可能是大流量打死其中的 n 个节点、大流量导致网卡异常),导致所有的请求直接打到了 mysql 库上去,造成 mysql 也被瞬间打死。(所谓雪崩就是服务与服务之间造成了级联故障,换句话就是一个服务死机导致了另外一个服务扛不住压力也死机了。)
- 处理方案: 中心思想就是防止 redis 被打死+应用程序限流熔断+应用程序提高响应能力,可分为两部分。在缓存部分,redis 需要配置高可用+持久化+告警机制,高可用可以采用主从、哨兵、集群、异地多活等方式,持久化机制可以开启 AOF、RDB、混合模式以便于在故障恢复后快速加载数据,告警机制可以人工提前介入进行动态扩容等;在应用程序部分,可以采用限流+熔断+本地缓存的方案;
缓存穿透
- 解释: 就是多次请求缓存中没有的数据,导致直接查询数据库,导致数据库被打死;
- 解决方案:
- 方案一: 如果从数据库中查询的结果为空,就在缓存中 set 一个值,再加上一个过期时间,这样就可以把请求拦截到缓存上,从而避免数据库被打死;但这种方式也有另外一个问题就是 key 如果很多,就会造成缓存的命中率下降,这时我们要定期清理 key,或者将这些非法的 key 存入一个独立的公共缓存中,每次查询时先查询主缓存,如果不存在就查询独立缓存,再不存在就查询数据库,如果 mysql 返回结果为空,就把 kv 设置到独立缓存中,否则就放到主缓存中;
- 方案二: 使用 Bloomfilter 来缓存全量的 key,利用了 Bloomfilter 的位图数据结构特性,如果为 ture,就一定存在,如果为 false,不一定存在的特性;不过 key 的数量级要控制在 10 亿以内,大概占用 1.2g 缓存,又因为 key 越多误判率越高,因此还要定期清理 key;
缓存击穿
- 解释: 击穿的意思有点像在一道屏障上穿了一个孔。就是某一个 key 的访问非常频繁,但是某一个时刻,这个 key 突然失效,导致获取这个 key 的请求直接穿过缓存请求到数据库。
- 处理方案: 中心思想是对热点 key 的处理。 针对基本不会发生更新的场景,可以把 key 设置为永不过期,让 key 常驻缓存;针对偶尔需要更新的场景,可以对请求代码使用分布式互斥锁,是的少部分直接请求请求数据库后更新缓存,而剩余的其他请求直接使用新缓存即可,或者采用本地互斥锁保证仅有少量请求能够更新缓存,其余请求访问新缓存; 针对需要频繁更新的场景,可以使用额外的补偿程序来定时刷新缓存或者延长 key 的实效时间;
如何保证缓存与数据库双写一致性?
- 解释: 在使用缓存时,往往是数据库中保存着一份数据,而缓存中也保存着一份数据,这就涉及到数据库与缓存中数据的一致性问题
- 处理方案:
- 方案一: 使用 Cache Aside 模式,就是在读缓存的时候,先读缓存,如果缓存中没有读到,那就读数据库,然后把读到的数据再放入缓存中,最后返回响应;更新操作时,就先更新数据库,然后再删除缓存;【更新操作是有问题的,下面会讲到】;
- 优缺点: 这种方案使用了懒加载的思想,适用于数据一致性要求较高的业务场景,或者缓存更新较为复杂的业务场景; 但是这种方案需要同时关注 cache 和 db 的数据变更,有些繁琐;
- 更新操作为什么是删除缓存而不是更新缓存?
- 这里涉及到懒加载的思想,事实上,更新缓存的性能损耗要大于删除缓存的性能损耗,如果读操作不多,那每次都要更新缓存所带来的性能损耗一定大于删除缓存的性能损耗,让第一次读操作从数据库中获取数据后更新缓存,之后所有的读操作直接请求缓存,性能损耗就会大幅度下降;
- 如果更新操作时,先更新数据库,然后删除缓存,如果缓存删除失败呢?
- 这同样会造成缓存与数据库不一致。解决办法就是先删除缓存,然后更新数据库。这样读操作时,如果缓存为空,就去读数据库,然后更新缓存,虽然读到的数据是旧数据,但是缓存更新后也是旧数据,就保证缓存与数据库一致了。
- 如果更新操作时,瞬间有大量请求发送过来,会造成什么情况?仍然会造成缓存与数据库不一致问题。
- 因为一个更新请求过来,我们先执行删除缓存,然后更新数据库,但是在删除缓存之后还没有来得及更新数据库,另一个读请求也过来了,然后它发现缓存中没有数据,它就先去数据库中读取数据然后再更新到缓存,这时之前的更新请求再更新数据库,此时缓存和数据库不一致了【这个问题的本质原因是高并发请求和更新单个 key】。解决方案是: 可以根据 key 的唯一性标识把相同参数的请求路由到同一台机器上,然后创建 JVM 内部队列,使更新操作放入一个队列,读操作也放入一个队列。目的是 hang 住读操作一些时间,等更新操作完成之后再进行读操作。但这种方式有可能会造成读操作的时间过长并且还有可能会造成某个单台机器负载过高的情况,这个时候要严格执行性能测试,一方面要看一下这种方式下读操作请求时长是否是可以忍受的,如果不可忍受,那就只能加机器;但单台机器负载过高的情况不可避免;
- 方案二: 使用 Read/Write Through 模式,就是提供一个存储服务,查询数据和修改数据都通过这个服务来完成,这样可以屏蔽对数据的访问细节,在存储服务内部,针对查询数据的操作,可以直接去 cache 中查询,如果不存在就去 db 中查询,然后回种到 cache 中后返回,针对写数据的操作,先去查询 cache,如果 key 存在,就更新缓存再更新 db,如果缓存中 key 不存在就只更新 db;
- 优缺点: 这种方案使用起来更加方便,因为它提供了一套操作 cache 和 db 的 API,等同于封装了 cache 和 db 的操作细节,使业务系统不必关注 cache 和 db 的读写操作实现;此外,由于同样适用了懒加载方式,使得这种方式也适用于数据有冷热区分的业务场景;
- 更新操作是怎么实现的?
- 是通过 cas 算法实现的,这样利用了算法锁的方式避免了高并发带来的问题;
- 方案三: 使用 Write Behind Caching 模式,这种模式跟 方案二 模式差不多,都是提供了一个存储服务,封装对 cache 和 db 的操作细节,让外部业务系统无感知的访问缓存。在其内部,读操作的实现原理与 方案二 是一样的,同样是先去 cache 中读,如果 cache 中不存在,就去 db 中读取,然后回种到 cache 后返回;与方案二不同的是更新操作的实现原理, 这种方案的更新操作是只更新 cache,并提供异步批量的方案来根据 cache 来更新 db;
- 优缺点: 这种方案的写性能是最大的,但是数据不一致性发生的几率最大,极端场景下可能会丢失数据,因此这种方案适合写合并的场景,比如微博的点赞数量,如果采用方案二,那势必是点赞一次就需要写 db 一次,这对 db 是很大压力的,在方案三中,可以点赞到 1w 后再写 db,这样 db 压力就大大减小了;
- 方案一: 使用 Cache Aside 模式,就是在读缓存的时候,先读缓存,如果缓存中没有读到,那就读数据库,然后把读到的数据再放入缓存中,最后返回响应;更新操作时,就先更新数据库,然后再删除缓存;【更新操作是有问题的,下面会讲到】;
- 如何选择?
- 高性能与强一致性本来就不可兼得,不同模式的选择就是针对高并发与强一致性的取舍;
- 不存在最佳方案,只有最符合业务场景的方案;
热点key
- 解释: 某些业务在某一瞬间或某一时间段内可能会成为热点业务,热点业务的数据可能会产生热点 key,比如微博上热榜数据;
- 方案: 先找出哪些 key 是热点 key,可以通过 spark 的流计算或 Hadoop 的批处理来得出热点 key,然后中心思想就是把这些热点 key 打散到不同的节点中以应付高并发请求;总的实现方案有加入二级缓存和加冗余节点,这个问题的关键在于如何发现热点 key,4.0 之后,可以使用 redis-cli --hotkeys 命令获取;业内著名处理方案有有赞透明多级缓存解决方案(TMC)
大key
- 解释: 缓存中某些 key 的 value 的值过大,导致写操作超时、加载速度缓慢等问题;
- 方案: 主要的处理思路是先找出哪些 key 是大 key,然后再对大 key 进行操作;
- 如何找到大 key?
- 使用报警机制可以查看带宽与 qps 的关系,来判断是否有大 key 产生;
- 利用 redis-cli --bigkeys 命令可以异步获取大 key
- 使用 redis-rdb-tools 离线分析工具来扫描 RDB 持久化文件
- 找到大 key 后如何处理大 key?
- 可删除:
- 小于 4.0, 使用 scan 命令扫描出 key 后进行删除
- 大于 4.0, 使用 UNLINK 命令异步删除
- 不可删除:
- value 是 string,比较难拆分,则使用序列化、压缩算法将 key 的大小控制在合理范围内,但是序列化和反序列化都会带来更多时间上的消耗; 如果压缩之后仍然是大 key,则需要进行拆分,一个大 key 分为不同的部分,记录每个部分的 key,使用 multiget 等操作实现事务读取;
- value 是 list/set 等集合类型时,根据预估的数据规模来进行分片,不同的元素计算后分到不同的片;
- 可删除:
- 如何找到大 key?
4.3.1. 参考链接
5. 分布式缓存与数据库一致性问题: 应用场景
企业网银中,在客户管理等公共模块中,客户等热点信息需要被多个微服务模块所使用,如果直接从 MySQL 中读取,有点浪费 MySQL 服务器性能。于是放入缓存,但是客户信息也会被客户修改,这就造成了其他微服务读取到的数据与实际的数据不一致。
其他微服务使用时,通过调用客户管理模块中的接口进行获取,先读缓存,缓存中不存在就读 db。
6. 问题
为什么会出现双写不一致问题?本质是在并发读写过程中,操作 DB 的动作与操作 Cache 的动作不具有原子性。换句话来说,就是在高并发场景下,无法保证操作 DB 的动作与操作 Cache 的动作同时成功或同时失败,也就是说可能会有中间状态。因此,只要使用缓存,就会涉及到缓存中数据与数据库中实际数据的一致性问题。 实际业务中,写操作和读操作是两套不相关的代码逻辑。因此我们分别讨论读操作和写操作的代码逻辑。
读操作,分为两种,延迟加载和预加载。
- 延迟加载,即用到数据时,才从 DB 中加载到缓存中。这种方式是我们常用的方式。
- 预加载,即应用程序启动时,就把数据加载到缓存中。这种场景适用于变更极少的数据,如配置项、数据字典等。
写操作,主要是更新数据和删除数据。更新数据时,为保证 DB 和 Cache 的数据一致性,既需要更新 DB,又需要更新 Cache;删除数据时,则需要删除 DB 和删除 Cache。因此我们可以知道,写操作无论如何都需要同时操作 DB 和 Cache,但是代码实现时,操作 DB 和操作 Cache 并不具备原子性,即无法同时保证 DB 的操作和 Cache 的操作同时成功或同时失败。不具备原子性,就意味着会造成 DB 和 Cache 数据不一致性问题。因此为了解决数据不一致性问题,我们首先想到的是让两个操作具备原子性,那么此时我们可以采用分布式锁的方式,让操作 DB 的动作和操作 Cache 的动作组合成一个原子操作,可是采用分布式锁,势必会降低缓存的性能,而我们之所以使用 Cache 的原因就是想利用 Cache 的高性能特性。因此我们首先排除分布式锁的方案。 根据操作 DB 和操作 Cache 的时机不同,我们把这个更新数据类型的写操作划分为这么几种:
- 先更新 Cache,后更新 DB;
- 先删除 Cache,后更新 DB;
- 先更新 DB,后更新 Cache;
- 先更新 DB,后删除 Cache;
我们把删除数据类型的写操作划分为这么几种:
- 先删除 Cache,再删除 DB;
- 先删除 DB,再删除 Cache;
我们把写操作中的操作 DB 部分进行合并,则写操作可以分为以下几种:
- 先更新 Cache(不包括删除),后更新 DB(包括删除)。
- 先删除 Cache,后更新 DB(包括删除)。
- 先更新 DB(包括删除),后更新 Cache(不包括删除)。
- 先更新 DB(包括删除),后删除 Cache。
再从性能的角度分析,如果更新 Cache,那么写多读少的场景下,就会出现一个缓存写了好多次才被读到一次,因此更新 Cache 的方式不可取,所以写操作可以分为两种:
- (第一种写操作方式)先删除 Cache,后更新 DB(包括删除)。
- (第二种写操作方式)先更新 DB(包括删除),后删除 Cache。
我们再来研究一下并发读写过程中可能会出现的问题,并发读写过程可以分为这么几种:
- 读-读。即多个线程同时读取。在预加载模式下,读-读操作是不会出现问题的。但是在延迟加载过程中,可能会出现线程 A 没有在 Cache 中读到数据,此时,线程 B 也去 Cache 中读取数据,也发现没有命中,所以直接去 DB 中查询,之后放入 Cache 中。在这个过程中,有可能是线程 A 写了 Cache,紧接着线程 B 又写一遍 Cache,也有可能是线程 B 先写,紧接着线程 A 写。但是不管怎样,读取到的数据都是以 DB 为主的。因此不管是预加载还是延迟加载,数据都是准确的。所以在读-读这种场景下,是不会出现问题的。
- 读-写。即线程 A 进行读操作,而线程 B 进行写操作,且线程 a 先于线程 b 发生,这种场景下也不会出现问题。因为线程 A 读 Cache 时,Cache 中的数据与 DB 中的数据一致,线程 B 进行写操作晚于线程 A 读操作发生,之后的数据变更与线程 A 读操作已经无关了,因此这种场景下也不会出现问题。
- 写-读。即写操作线程早于读线程发生。这种场景下,结合上面写操作的四种流程进行区分:
- 先删除 Cache,后更新 DB(包括删除),这种方式在写-读并发操作中会出现 DB 与 Cache 不一致问题,写线程早于读线程发生,就意味着先删除 Cache 后,读线程发现 Cache 没有命中,就会从 DB 中获取并放入 Cache,之后写线程再执行更新 DB(包括删除)操作,此时 DB 和 Cache 中的数据就不一致了,如果后续再也没有写操作(包括删除),则有可能不一致性持续到 Cache 失效,直到再次读缓存才能变得一致;并且写多读少的场景下,可能会涉及到多次删除同一个缓存的情况,这也造成了 Cache 性能浪费的问题;
- 先更新 DB(包括删除),后删除 Cache,这种场景下,加入读线程发生在更新 DB(包括删除)后删除 Cache 前,由于读取的是 Cache,而 DB 中数据已经更新,如果此时有多个读线程同时发生,那么这些读操作读取的都是老旧数据,因此也会发生短暂不一致问题;这种场景出现的问题与 c 中出现的问题一致;
- 写-写。即同时操作同一个数据的多个写操作线程同时发生。这种场景下,也结合上面写操作的四种流程进行区分:
- 先删除 Cache,后更新 DB(包括删除),也会出现线程 A 先删除 Cache,之后线程 B 再删除 Cache 并更新 DB(包括删除),最后线程 A 才更新 DB(包括删除),假设线程 A 是要删除数据,线程 B 是要修改数据,那么就会造成线程 B 的更新丢失问题;
- 先更新 DB(包括删除),后删除 Cache,如果线程 A 先更新 DB(包括删除),之后线程 B 更新 DB(包括删除)并删除 Cache,最后线程 A 删除 Cache,那么会同样会导致线程 A 丢失更新问题;
所以总结一下在并发读写过程中出现的问题:
- 写-读并发场景下,两种写操作方式都会存在数据不一致现象,极端情况下,第一种写操作方式不一致现象持续的时间可能会比第二种写操作方式持续的时间长;
- 写-写并发场景下,两种方式都会出现丢失更新的问题;并且第二种写操作方式还会出现数据不一致现象;
在实际开发过程中,如果我们能够忍受较短时间的不一致现象,可以直接采用第二种写操作方式(先更新 DB,再删除 Cache)。
7. 设计实现方案
根据上面的分析,我们可以知道读操作在并发场景下并不会出现问题。因此我们的主要专注点转为写操作。
7.1. 方案一-更新 DB 时,连带更新 Cache
会产生问题:
- **并发写数据时,会出现丢失更新。**在并发场景下,a 更新数据后,此时 b 也要更新同一个值,就会出现 a 更新 db 后,b 紧接着也要更新 db,之后 a 在更新缓存,在之后 b 更新缓存。缓存中的数据就不是我们期望的值了。
- **写多读少的情况下,会浪费缓存的性能。**会出现很多缓存刚被更新完还没有被读到一次,就又被更新了。这严重浪费了缓存的性能。
7.2. 方案二-先删除 Cache,再更新 DB
会产生问题:
- 会出现数据不一致问题,甚至会出现较长时间的数据不一致问题。a 删除 Cache 后,b 需要读取缓存,此时 b 发现 Cache 中没有数据,就会从数据库中读取数据,放入缓存,之后才执行 a 的更新 db 的操作,此时缓存中数据就与 db 中数据不一致了。如果 a 更新 db 后,很长一段时间内没有更新操作,不一致性可能会持续到缓存失效。
7.3. 方案三-更新 DB 后,删除 Cache【优选】
会产生问题:
- 会出现短暂不一致问题。在 a 更新 db 后,删除 Cache 前,在此期间的所有读操作的数据都是不一致的。
- 也可能会出现很长时间的不一致问题。如 a 更新完 db 后,删除 Cache 操作失败了,那么可能需要等到缓存过期,DB 和 Cache 才能保持一致。
7.4. 方案四-延迟双删
先删除 Cache,再更新 DB,之后延迟一段时间后再删除一遍 Cache。
解决了方案二中可能会导致的长时间数据不一致性问题。
会产生问题:
- 读少写多的场景下,会造成性能的浪费。因为每一次写操作,都会操作两次 Cache。
- 也可能会出现双写不一致问题。极端场景下,第二遍删除 Cache 时失败,操作效果就退化成方案二了。可以通过多次重试的方式解决这个问题。
7.5. 方案五-基于消息队列删除 Cache
先更新 DB,再基于队列方式,更新 db 时构造一个消息,由额外的监听任务更新缓存。但这种方式在更新 DB 之后和消费消息之前同样会产生短暂不一致现象。
分为三种方式:
- 基于内存队列
- 基于消息队列
- 基于 binlog+消息队列
基于内存队列的方式,就是把删除操作转化成消息,加入任务队列,之后由异步线程去消费任务,这种方式可以在删除失败时进行多次重试,确保删除成功。这种方式多是采用基于阻塞队列的方式。同时,这种方式也有问题:
- 读少写多的场景下,阻塞队列中的任务较多,可能会产生积压,此时可以通过引入多线程机制,加快消费;
- 应用的复杂性增高,可用性降低;
- 阻塞队列的不可靠造成数据不一致。基于 JVM 的阻塞队列会随着 JVM 崩溃而不可用,造成删除操作失败,也会产生数据不一致性;
基于消息队列的方式,把删除操作转化成消息队列里面的消息,引入高可靠的消息组件,如 RocketMQ,这种方式解决了基于内存队列中的 JVM 崩溃造成的问题。但是:
- 要求使用高可靠的消息组件,并开启投递确认机制和消费确认机制。目的是确保消息不会丢失和消息被正确消费;
- 这种方式增加了写操作的代码复杂度。虽然让写操作程序不用直接删除 Cache,但是需要在更新 DB 后,需要增加投递删除消息的逻辑,并且还需要额外的消费消息的逻辑;
基于 Binlog+消息队列的方式,这种方式相当于把删除 Cache 的操作委托给监听 Binlog 变更程序、消息组件及消费者监听程序了。原理是在写入 DB 后,根据 Binlog 变更日志,生产消息,再由专门的消费者消费消息。这种方式:
- 需要引入额外的逻辑,如解析 Binlog 变更日志的逻辑,当然可以使用 Canal 中间件等;
- 同样需要引入高可靠的消息组件,也同样需要开启投递确认机制和消费确认机制;
优缺点分析:
- 这种方式降低了写操作的代码复杂度。让写操作只需要关注更新 DB 的逻辑;
- 引入额外的组件,增加了整个系统的复杂度,降低了整个系统的可用性;
7.6. 方案六-强一致性方案
中心思想是把写 DB 和删除 Cache 这两个原子操作合并成一个原子操作。此过程可以采用具有 CP 性能的 ZK 作为分布式锁。也可以使用 RedLock 作为分布式锁的实现。
8. 方案七-Read/Write Through 模式
这种方式的使用原理是,提供一个专门用来操作缓存的服务,使用对外提供 API 接口的方式屏蔽对缓存的操作细节。
- 读操作时,先去 Cache 中查询一下,如果命中就直接返回;如果没有命中,就去 DB 中查询,之后回种到 Cache 后返回;
- 写操作时,先去 Cache 中查询一下,如果命中,就先更新 Cache,之后再通过 CAS 并发锁更新 DB;如果没有命中,就只通过 CAS 并发锁更新 DB;
这种方案,屏蔽了 Cache 和 DB 读写操作的实现细节。但这种方案依赖一个专门的服务,如果节点发生故障,就会导致读写失败,因此需要集群方式部署才能保证高可用。
8.1. 方案八-Write Behind 模式
这种方式的实现原理是,同样提供一个专门用来操作缓存的服务,使用对外提供 API 接口的方式屏蔽对缓存的操作细节。
- 读操作时,先去 Cache 中查询一下,如果命中就直接返回;如果没有命中,就去 DB 中查询,之后回种到 Cache 后返回;
- 写操作时,也需要先去 Cache 中查询一下:
- 方式一:如果命中,就直接更新;如果没有命中就先去 DB 中查询,然后根据业务逻辑组装数据并回种到 Cache。然后,再利用一个异步周期任务,把 Cache 数据同步到 DB 中。这种方式写操作性能最高,相当于数据以 Cache 为准,适合写合并的业务场景,如点赞数放入 Cache,后续定期写 DB。
- 方式二:如果命中,就直接更新 Cache,之后发送更新 DB 的消息;如果没有命中就先去 DB 中查询,然后根据业务逻辑组装数据并回种到 Cache,最后发送更新 DB 的消息。然后再利用消息队列的方式,把更新 DB 的消息解析出来之后变更到 DB 中。这种方式写性能也还行,但是需要保证消息组件高可用(高可用部署+持久化),还需要开启投递消息的确认机制和消费消息的确认机制。
9. 总结
不管使用上面的哪一种方案,其实都会出现数据不一致性问题,只不过是数据不一致性问题持续的时间长短问题。结合性能需求和业务场景需要,我们总结出以下生产最佳实践:
- 如果数据不一致性问题在可忍受范围内,优先考虑【方案三-更新 DB 后,删除 Cache】;
- 考虑其他方案时,要考虑高性能与高可用之间的平衡,因为引入程序的复杂度越高,可用性就越低;
10. 实现过程
13 的面试突击课美团 2 面:如何保障 MySQL 和 Redis 数据一致性?这样答,让面试官爱到 死去活来Canal 解决 MySQL 和 Redis 数据同步问题不知道这四种缓存模式,敢说懂缓存吗?缓存模式(Cache Aside、Read Through、Write Through、Write Behind)
扩展: 实现多级缓存的架构设计方案
应用场景
首页排名信息查询,当日信息查询,当月信息查询,
实现过程
一般情况下,本地缓存称为一级缓存,分布式缓存称为二级缓存。
ehcache 一级缓存
Spring Cache 就是一个这个框架。它利用了 AOP,实现了基于注解的缓存功能,并且进行了合理的抽象,业务代码不用关心底层是使用了什么缓存框架,只需要简单地加一个注解,就能实现缓存功能了。 每次调用需要缓存功能的方法时,Spring 会检查检查指定参数的指定的目标方法是否已经被调用过;如果有就直接从缓存中获取方法调用后的结果,如果没有就调用方法并缓存结果后返回给用户,下次调用直接从缓存中获取。 常用的注解有@CachePut 、@CacheEvict 、@Cacheable、@CacheConfig、@EnableCaching。
- 引入相关依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<!--添加 ehcache依赖-->
<dependency>
<groupId>net.sf.ehcache</groupId>
<artifactId>ehcache</artifactId>
</dependency>
- 新建配置文件
<?xml version="1.0" encoding="UTF-8"?>
<ehcache updateCheck="false">
<!--
磁盘存储:将缓存中暂时不使用的对象,转移到硬盘,类似于Windows系统的虚拟内存
path:指定在硬盘上存储对象的路径
path可以配置的目录有:
user.home(用户的家目录)
user.dir(用户当前的工作目录)
java.io.tmpdir(默认的临时目录)
ehcache.disk.store.dir(ehcache的配置目录)
绝对路径(如:d:\\ehcache)
查看路径方法:String tmpDir = System.getProperty("java.io.tmpdir");
-->
<diskStore path="java.io.tmpdir"/>
<!--
defaultCache:默认的缓存配置信息,如果不加特殊说明,则所有对象按照此配置项处理
maxElementsInMemory:设置了缓存的上限,最多存储多少个记录对象
eternal:代表对象是否永不过期 (指定true则下面两项配置需为0无限期)
timeToIdleSeconds:最大的发呆时间 /秒
timeToLiveSeconds:最大的存活时间 /秒
overflowToDisk:是否允许对象被写入到磁盘
说明:下列配置自缓存建立起600秒(10分钟)有效 。
在有效的600秒(10分钟)内,如果连续120秒(2分钟)未访问缓存,则缓存失效。
就算有访问,也只会存活600秒。
-->
<defaultCache maxElementsInMemory="10000" eternal="false"
timeToIdleSeconds="600" timeToLiveSeconds="600" overflowToDisk="true" />
<!--
name:缓存名称。
maxElementsInMemory:缓存最大个数。
eternal:对象是否永久有效,一但设置了,timeout将不起作用。
timeToIdleSeconds:设置对象在失效前的允许闲置时间(单位:秒)。仅当eternal=false对象不是永久有效时使用,可选属性,默认值是0,也就是可闲置时间无穷大。
timeToLiveSeconds:设置对象在失效前允许存活时间(单位:秒)。最大时间介于创建时间和失效时间之间。仅当eternal=false对象不是永久有效时使用,默认是0.,也就是对象存活时间无穷大。
overflowToDisk:当内存中对象数量达到maxElementsInMemory时,Ehcache将会对象写到磁盘中。
diskSpoolBufferSizeMB:这个参数设置DiskStore(磁盘缓存)的缓存区大小。默认是30MB。每个Cache都应该有自己的一个缓冲区。
maxElementsOnDisk:硬盘最大缓存个数。
diskPersistent:是否缓存虚拟机重启期数据 Whether the disk store persists between restarts of the Virtual Machine. The default value is false.
diskExpiryThreadIntervalSeconds:磁盘失效线程运行时间间隔,默认是120秒。
memoryStoreEvictionPolicy:当达到maxElementsInMemory限制时,Ehcache将会根据指定的策略去清理内存。默认策略是LRU(最近最少使用)。你可以设置为FIFO(先进先出)或是LFU(较少使用)。
clearOnFlush:内存数量最大时是否清除。
-->
<cache name="orderInfo"
maxElementsInMemory="10000"
eternal="false"
timeToIdleSeconds="3600"
timeToLiveSeconds="3600"
overflowToDisk="true"
maxElementsOnDisk="10000000"
diskPersistent="false"
diskExpiryThreadIntervalSeconds="120"
memoryStoreEvictionPolicy="LRU"/>
</ehcache>
现在假设有如下配置:
timeToIdleSeconds=600
timeToLiveSeconds=1800
缓存有效时间为1800秒(自缓存建立起半小时有效),在有效的半小时内,如果连续600s钟未访问缓存,则缓存失效,特别说明的是,就算缓存访问从未间断,到半小时后,缓存也会失效。当然,timeToLiveSeconds必须大于timeToIdleSeconds才有意义并且只有在eternal为false时,这2个属性才有效。
- 添加配置
spring:
cache:
type: ehcache
ehcache:
config: classpath:ehcache.xml
打开缓存开关
使用
测试效果展示
从 200ms,优化到 1ms 以内。
redis 二级缓存
一级缓存与二级缓存配合使用
具体实现过程,可以在 redis 作为二级缓存的基础上加上 @Cacheable 即可。
一级缓存 VS 二级缓存
访问速度:理论上,访问速度上一级缓存要比二级缓存要快一些,因为一级缓存是直接从本地内存中获取数据,而二级缓存则会产生网络 IO; 应用场景:一级缓存只适合变更频率低、实时性要求低的数据,主要是用来提升访问速度,二级缓存的适用场景更为丰富; 数据一致性问题: 二者都会产生缓存与数据库数据不一致问题。一方面,二者都需要解决缓存与 db 数据不一致问题;另一方面,一级缓存在集群部署环境的数据变更时,除了要解决双写一致性问题外,还要通知剩余节点更新缓存;
缓存的过期时间、过期策略以及多线程访问的问题也都需要考虑进去。
参考
SpringBoot 整合 Ehcache 缓存(二十二)史上最全的 Spring Boot Cache 使用与整合Spring Cache,从入门到真香
二级缓存与纯 redis 操作有什么优劣? SpringBoot+Redis+Ehcache 实现二级缓存 dataeye/overseas-web/ProductRankingServiceImpl
效果: 查询的结果会放到 ehcache 中,以后的每次查询,都会从本地缓存中查询,提高了查询效率 使用查询条件作为 key
思考: 这种方案与【先查 redis 缓存,如果 redis 中不存在就查数据库,查完之后再放到 redis 中;如果有直接返回】的方案有何优劣?
- 方案一,也就是二级缓存,适合单实例部署的场景,如果部署多个后端服务实例,可能会造成多个服务实例都有同一个