目录
5.3 Write Behind Caching Pattern(异步缓存写入)
一、高并发系统三大利器
保护高并发系统的三大利器:限流、熔断降级、缓存。
限流是指在面临瞬时巨大流量访问系统时(商品秒杀等)为了保证系统的可用性的一个限制手段。
熔断降级一般一起使用,是为了在某些大流量业务场景(双11双12等)下保证核心业务可用的解决方案。(降级是主动的,熔断是被动的。熔断是指当上游服务调用下游服务出现不可用时,暂时切断请求,防止系统雪崩;降级是指某些情况下保证核心业务,将边缘业务服务暂时关闭。)
缓存大多是为了缓解数据库的查询压力,对某些热点数据和核心业务数据添加缓存层进行访问,高并发系统常使用Redis作为缓存层。(做数据冗余,以空间换时间,提高系统可用性)
在之前的文章里讲到了限流(高并发系统三大利器之限流),这次谈一谈缓存。
缓存原指CPU上的一种高速存储器,它先于内存与CPU交换数据,速度很快,现在泛指存储在计算机上的原始数据的复制集,便于快速访问。
二、缓存的使用场景
2.1 减轻DB压力
在并发量大的系统中,除了对DB进行分库分表和读写分离外,需要添加缓存层减轻DB的压力,因为一旦DB挂掉,可能会导致整个系统雪崩。通常采取的方案是用户的请求先到缓存,缓存命中则返回,如果未命中,则从DB中读取并回填缓存。
2.2 提高系统响应
在缓存的这一层通常使用Redis对DB的数据进行冗余。DB的数据大部分都是存储在磁盘上,而磁盘的读写相对内存是比较慢的,Redis将数据直接放在内存,并且Redis天然支持高并发(qps到达11万/S读请求 8万写/S),所以缓存层使用Redis是最常见的方案。
2.3 session分离
Http协议是无状态的,为了维持客户端与服务端的状态,Tomcat等会使用sessionId来区分客户端,这个信息是存放在服务端的,当然客户端也可以使用Cookie来记录用户身份。在选择Session的时候,假如服务是集群模式则需要每个节点复制session的信息,这时可以选择使用Redis存放Session信息,每个服务器节点从Redis读取会话信息。
现在主流的解决方案是JWT令牌,除了保持会话,同时解决了用户认证和鉴权
2.4 分布式锁
一般情况下的锁指的是单机器上的多线程竞争时为了保证共享数据安全采用的一种手段。在不同机器节点上的不同线程则需要分布式锁来保证线程之间的同步状态,Redis中的setnx则可以实现分布式锁。
Redis中的setnx直接使用可能会出现死锁、无法续期、无法重入等问题,可以使用Redission框架实现分布式锁,其内部封装了许多lua脚本保证事务性。(Zookeeper也可以实现分布式锁的的功能)
三、缓存的分类
3.1 客户端缓存
客户端包括移动APP端,浏览器端。
APP端:可以把数据图片等缓存到本地数据库SQlite中
浏览器:浏览器可以将页面的整个资源进行缓存,还有一些数据可以缓存再Cookie、LocalStorage、SessionStorage等。
3.2 网络端缓存
Web代理(比如Nginx)将html等静态资源做一层缓存,只有动态请求才真正发送到服务端。
CDN(Content Delivery Network,内容分发网络)也是为了使用户就近访问,提高响应而将用户的访问指向距离最近的工作正常的缓存服务器上,由缓存服务器直接响应用户请求。
3.3 服务端缓存
数据库缓存:在访问DB时,先根据查询SQL语句到缓存中查找结果,如果命中则返回。
在MySQL中输入 show variables like "%cache%" 可以查看缓存相关参数
本地缓存:应用内部借助GuavaCache、EhCache、OSCache(操作系统页缓存)等具有缓存功能的应用框架根据需要缓存相应的业务数据。
分布式缓存:在分布式集群架构中,本地缓存无法保证数据的一致性,多个应用部署在不同的机器节点,此时需要分布式缓存。Redis、Memcached、EVCache(AWS)、Tair(阿里 、美团)等都是常见的分布式缓存中间件,特点是高性能高并发高扩展。
四、缓存的优势和代价
4.1 优势
- 提升用户体验(缓存响应快)
- 减轻服务端压力(减少DB访问)
- 提升系统整体性能(响应时间、延迟时间、吞吐量等的提升)
4.2 代价
- 额外的硬件支出
- 高并发缓存失效问题(缓存穿透、缓存雪崩、缓存击穿)
- 缓存同步问题(数据库和缓存、缓存主从等无法实时一致)
- 缓存竞争(多客户端对同一key操作)
五、缓存的读写模式
5.1 Cache Aside Pattern(旁路模式)
旁路缓存是最常见的模式,当读请求到来的时候先从缓存中读取,缓存命中则返回,如果未命中则从DB读取,再回填缓存。
更新的时候先更新数据库,再删缓存。
为什么是先更新DB再删缓存?
- 懒加载,使用的时候再回填缓存
- 如果缓存的Value是List等集合则需要遍历,比较耗时
高并发情况下出现的数据不一致(脏读)问题
1、先更新DB,再更新缓存
线程1:Begin开启事务----->Update----->更新缓存----->Rollback回滚--->后续操作
线程2:------------------------------------------>读取缓存(脏数据)--->后续操作
线程1在更新完DB的时候出现了异常,此时事务回滚,但是缓存数据已经更新。线程二在缓存更新后读取的数据为脏数据。
2、先删缓存,再更新DB
线程1:Begin开启事务----->删除缓存----->Update----->Commit--->后续操作
线程2:----------------------------->读取缓存为空,读DB--->回写缓存--->后续操作
线程3:-------------------------------------------------------->读取缓存(脏数据)
线程1先删除了缓存但还数据库事务还未提交,线程2此时读取同样的数据时发现缓存没有,则从DB读数据并回写,回写的是未提交的脏数据,后面的线程再去读则读到的也是脏数据。
3、先更新DB,再删缓存(推荐)
旁路模式是推荐的缓存读写模式,但同样也面临数据不一致的问题。这个问题和上面的类似。
线程1:Begin开启事务---->Update----->删除缓存----->Commit--->后续操作
线程2:------------------------------------>读取缓存为空,读DB--->回写缓存--->后续操作
线程3:-------------------------------------------------------->读取缓存(脏数据)
解决方案是使用延时双删策略,保证数据最终一致。
保证数据的最终一致性(延时双删)
- 先更新数据库同时删除缓存项(key),等读的时候再填充缓存
- 2秒后再删除一次缓存项(key)
- 设置缓存过期时间 Expired Time 比如 10秒 或1小时
- 将缓存删除失败记录到日志中,利用脚本提取失败记录再次删除(缓存失效期过长 7*24)
升级方案:通过数据库的binlog来异步淘汰key,利用工具(canal)将binlog日志采集发送到MQ中,然后通过ACK机制确认处理删除缓存。
5.2 Read/Write Through(读写穿透)
应用程序只操作缓存,缓存操作数据库。该模式下系统隔离更好,业务端应用只需专注业务,但是存储端需要维护DB和Cache的一致性,存储端的开发较为复杂。
5.3 Write Behind Caching Pattern(异步缓存写入)
应用程序只更新缓存,缓存通过异步的方式将数据批量或合并后更新到DB中。这种方式效率很高,但是数据不能实时一致,很可能会丢数据,适用于对一致性要去不高的场景。
六、缓存问题
6.1 缓存穿透
高并发下查询key不存在的数据(不存在的key),会穿过缓存查询数据库,导致数据库压力过大而宕机。
解决方案:
- 将不存在的数据进行缓存,设置过期时间,或者insert时候删除缓存
- 在缓存之前设置布隆过滤器,过滤掉不存在的key
布隆过滤器实际上是一个很长的二进制向量和一系列随机hash映射函数。布隆过滤器可以用于检索一个元素是否在一个集合中,它的优点是空间效率和查询时间都远远超过一般的算法。
布隆过滤器的原理:当一个元素被加入集合时,通过K个Hash函数将这个元素映射成一个数组中的K个点,把它们置为1。检索时,我们只要看看这些点是不是都是1就(大约)知道集合中有没有它了。如果这些点有任何一个0,则被检元素一定不在;如果都是1,则被检元素很可能在。
6.2 缓存雪崩
当缓存服务器重启或者大量缓存集中在某一个时间段失效,这样在失效的时候,会给后DB带来很大压力。 突然间大量的key失效了,或Redis缓存服务重启,大量请求直接访问DB会导致DB宕机。
解决方案:
- key的失效期分散开,不同的key设置不同的有效期
- 设置多级缓存(数据不一定一致)
6.3 缓存击穿
热Key突然失效。缓存在某个时间点过期的时候,恰好在这个时间点对这一个Key有大量的并发请求过来,这个时候大量请求直接打到DB,可能会导致DB宕机。
解决方案:
- 用分布式锁(redis、zookeeper)控制访问DB的线程,这样其他线程就处于等待状态,保证不会有大并发操作去操作DB。
- 设置过期策略禁止驱逐或者volatile-lru(从已设置过期时间的数据集中挑选最近最少使用的数据淘汰),采用延迟双删保证数据最终一致。
6.4 数据不一致
DB和缓存数据不一致的根本原因是数据源不一致。对于大部分的互联网业务来说,可用性的要求是大于一致性的,所以大部分情况下保证最终一致就可以了。
DB+缓存的操作本质不是一个原子操作,在高并发的情况下无法保证强一致性。缓存的读写模式选择旁路缓存并结合延时双删策略可以保证最终一致。也可以使用MySQL的binlog日志+MQ异步淘汰缓存Key。
6.5 数据竞争
在并发量高的情况下,可能多个客户端set同一个key,无法保证操作的时序性。常用的解决方案有两个。其本质都是并行操作串行化。
- 分布式锁+时间戳(判断set顺序):先拿到锁的先操作
- MQ:利用MQ消息中间件保证顺序
6.6 大Key(Big Key)
大Key指存储的Value值非常大,这种大Key无法均衡用户的请求,复制过慢,直接删除时会导致缓存整体性能下降。
如何发现大Key:使用redis-cli --bigkeys命令(生产慎用!)或者通过Rdbtools分析rdb生成csv文件,导入其他数据源再进一步分析。
大Key处理:大Key一般是集合类型或者序列化后的文件。
- 对于集合类型可以拆分成多个
- 对于序列化后的文件可以使用MongoDB进行存储
- 删除的时候使用懒删除(Redis的del是阻塞命令,使用unlink进行异步删除)
6.7 热Key(Hot Key)
热Key是指热点Key,当几十万甚至上百万的请求访问该数据时,可能会导致缓存击穿,请求打到DB进而导致DB宕机。
如何发现热Key:对于热点数据进行预估,比如秒杀或者热点新闻等;使用Redis自带的命令,monitor、hotkeys(生产慎用!);使用大数据领域的流计算技术对实时访问数据进行统计分析。
热Key的处理:
- 添加应用本地等多级缓存
- 将热key备份到多个缓存节点
- 对热点数据的访问进行熔断降级
七、Redis中的缓存淘汰策略
Redis采用主动+惰性删除方式
Redis中的缓存数据存放在内存中,默认是没有设置maxmemory(内存占用大小)的,如果设置了maxmemory的大小,则数据大小将要达到设置值时,通过缓存淘汰策略,从内存中删除对象。
例如在内存即将到达1024M时,对所有的Key使用LRU算法进行淘汰:
#设置缓存的阈值为1024m
maxmemory 1024m
#设置缓存的淘汰策略为allkeys-lru
maxmemory-policy allkeys-lru
7.1 定时删除
在设置键的过期时间的同时,创建一个定时器,让定时器在键的过期时间执行对键的删除操作。 这种方式需要创建定时器,而且消耗CPU,一般不推荐使用。
7.2 惰性删除
在key被访问时如果发现它已经失效,那么就删除它。 Redis通过调用expireIfNeeded函数实现,该函数的意义是:读取数据之前先检查一下它有没有失效,如果失效了就删除。
7.3 主动删除
在redis.conf文件中可以修改主动删除策略。lru表示使用LRU算法,lfu表示使用LFU算法,allkeys表示对所有的Key生效,volatile表示只对设置了过期时间的Key生效。LFU算法是在Redis4.0之后才支持,主要是为了解决缓存污染的问题。
- noeviction: 不删除策略, 达到最大内存限制时, 如果需要更多内存, 直接返回错误信息。(默认值)
- allkeys-lru: 所有Key通用; 优先删除最近最少使用(Least recently used ,LRU) 的 key。
- volatile-lru: 只限于设置了过期时间的Key; 优先删除最近最少使用(Least recently used ,LRU) 的 key。
- allkeys-random: 所有Key通用; 随机删除一部分 key。
- volatile-random: 只限于设置了过期时间的Key; 随机删除一部分 Key。
- volatile-ttl: 只限于设置了过期时间的Key; 优先删除剩余时间(time to live,TTL) 短的Key。(给数据设置合理的过期时间,当缓存写满时,会淘汰剩余存活时间最短的数据,避免滞留在缓存中,造成污染)
- volatile-lfu:只限于设置了过期时间的Key;优先删除最不经常使用的Key
- allkeys-lfu:所有Key通用;优先删除最不经常使用的Key
在这里需要简单介绍一下LRU、LFU重要的概念。在之前的文章里有用LinkedHashMap对LRU算法的简单实现:基于Java实现LRU算法
LRU (Least recently used) 最近最少使用,算法根据数据的历史访问记录来进行淘汰数据,其核心思想 是“如果数据最近被访问过,那么将来被访问的几率也更高”。 最常见的实现是使用一个链表保存缓存数据。
LFU (Least frequently used) 最不经常使用,如果一个数据在最近一段时间内使用次数很少,那么在将 来一段时间内被使用的可能性也很小。
7.4 实现原理
以上提到了LRU和LFU这两个重要的算法,在Redis里面试如何实现呢?
Redis对于Value的存储是封装了redisObject结构体。
typedef struct redisObject {
unsigned type:4;//类型 对象类型
unsigned encoding:4;//编码
void *ptr;//指向底层实现数据结构的指针
//...
int refcount;//引用计数
//...
unsigned lru:LRU_BITS; //LRU_BITS为24bit 记录最后一次被命令程序访问的时间
//...
}robj;
redisObject结构体中有一个成员属性unsigned lru就是用来记录对象被程序访问的最后时间。
该属性占用24位(4.0之后),高18位记录一个分钟级别的时间戳,低8位记录访问次数。淘汰的时候就是根据数据的这个属性来进行删除。
八、Eureka缓存机制
Eureka是SCN(Spring Cloud Netflix)中的注册中心,从CAP理论来看,该组件是一个AP模型,为了保证系统高可用,除了支持Server集群,还采用了多级缓存。缓存内部使用Guava或者ConcurrentHashMap。
Eureka可用性保证:
对于Eureka Server集群来说,他们之间如果有一个节点收到了服务注册(Eureka Client)请求,则通过P2P点对点的方式将服务的元数据信息进行同步。
当Eureka Server检测到85%的服务宕掉时,会采取自我保护机制,自我保护机制开启后不会剔除服务信息,而是会认为出现了分区故障,此时接收的新的注册请求会等待网络稳定后再进行数据同步。
Eureka Server存在三个变量:(registry、readWriteCacheMap、 readOnlyCacheMap)保存服务注册信息,默认情况下定时任务每30s将 readWriteCacheMap同步至readOnlyCacheMap,每60s清理超过90s未续约的节点,Eureka Client每30s从readOnlyCacheMap更新服务注册信息,而UI则从 registry更新服务注册信息。
registry和readWriteCacheMap是实时更新的。
当一个服务正常上线时,消费者感知的时间为30+30+30=90s
- 30s:readWriteCacheMap----->readOnlyCacheMap(读写缓存同步到只读缓存)
- 30s:readOnlyCacheMap----->EurekaClient(Eureka客户端从只读缓存拉数据)
- 30s:EurekaClient----->Ribbon(Ribbon从Eureka客户端拉数据)
同样,正常下线时,客户端向服务端发送DELETE命令请求注销,消费者感知下线也是90s。
正常来讲,服务的生产端需要每隔30s向Eureka Server报活续期,但是遇到非正常下线时(kill进程或者服务节点宕机),Server端有个定时任务负责心跳监测,当检测到服务不可用时会从registry和readWriteCacheMap移除。该任务检测的间隔为60s,当服务检测到90s没有心跳时才会移除该服务。此时服务消费者感知到服务下线的最大时长为60s+90s+90s=240s。
无论怎样,数据都会达到最终一致,在大部分互联网系统中常常使用缓存(数据冗余)等方案保证整体的可用性。对于注册中心来说,Eureka是常用的选择,他是对外提供HTTP接口进行通信,这样不同语言的服务都可以向Eureka注册;如果想要一致性,通常使用zookeeper进行服务注册,但是这样一来服务生产端必须使用ZookeeperClient,无法跨语言。具体使用哪种注册中心需要根据实际的业务进行选择。