高性能的那些事儿-缓存设计
概述
在设计与开发高性能的系统时,基本都离不开缓存的设计,无论是在cpu的L1,L2,L2缓存,数据库的sql语句执行缓存,系统应用的本地缓存,乃至于现在用的最多的memcache,redis集中式缓存等,缓存总是解决性能的一把利器,对于缓存,本文主要从六个方面总结一下缓存及缓存的使用:
- 为什么需要缓存,缓存带来了什么
- 缓存的类型及常用缓存选型
- 缓存的基本算法思想
- 缓存带来的问题及解决
- 缓存组件的设计
为什么需要缓存,缓存带来了什么
在我实际工作中,主要在两种情况下一定需要引入缓存:
- 系统涉及到频繁和大量的读操作,例如像评论留言,商品页面这种主要需要大量读的功能模块,如果一直读数据库,数据库支撑不住。
- 需要引入一个分布式集中式存储,主要是像保存客户端会话信息,权限统一控制等这种功能模块,这里我遇到的实际情况是该系统没有数据库交互的模块,数据是本地存储的,但是会话信息需要在集群统一管理,所以利用redis作为统一的集中式缓存。
引入缓存,可以有效的帮助我们提高系统的性能,甚至可以很方便的实现一些功能,例如会话统一管理。引入缓存对于系统带来了什么呢?
- 缓存可以帮助系统提高用户体验,缓存解决的最大的问题就是将读压力从数据库分离,同时,无论是嵌入式缓存还是redis这种集中式kv缓存,读的性能都比关系数据库高很多。即使数据库支撑的住,在引入缓存后还是可以提高很多的用户访问响应时间。
- 解决数据库大量读操作带来的性能问题,数据库在修改和删除时才会对数据加锁,但是如果这种数据是大量需要读的数据,可能很多读操作都会被修改操作阻塞,导致数据库被hang住,从而带来大量的性能问题,引入缓存后,系统就可以先从缓存中获取数据,再访问数据库。减轻数据库读的压力。
- 通过redis解决一些功能实现的问题,例如有一些分布式系统并没有数据库,例如mqtt这种broker,涉及到集群数据共享时,可以用redis解决,还有的系统有分布式锁,得分排序等功能都可以用redis解决。
目前的系统一旦有一定规模,往往都离不开缓存,引入缓存除了带来一些优点外,也给系统带来了一定的复杂性:
- 数据一致性的问题,引入缓存后,数据需要保存两份,一份是数据库,一份是缓存,那么在更新数据时,是先更新缓存还是先更新数据库呢?先更新数据库,可能导致读取最新数据,但是更新数据库失败了,需要回滚,该数据就是脏数据,如果先更新数据库再更新缓存,那么读取的数据又是旧数据,也是脏数据了。
- 可维护性,引入缓存后,除了需要维护自己的系统外,还需要维护缓存
- 系统可用性,如果有的系统严重依赖缓存,例如将缓存当中央存储,那么缓存挂掉了,整个系统也就不可用了。导致系统的可用性降低
- 缓存带来的其他问题,例如本地缓存需要考虑内存大小,自己设计过期策略等等,集中式缓存需要考虑,缓存雪崩,缓存穿透,数据热点分布等一系列问题。
缓存的类型及常用缓存选型
这里仅列举几个我在实际中接触或使用过的缓存:
嵌入式(本地)缓存
特点是直接与应用一起启动,在java中直接引入java包就可以使用。
- mapdb:mapdb是java中使用过较多的嵌入式kv数据库,也可以当缓存来用,支持hash,map,set结构,在3.0已经不支持list和queue了,也支持数据落盘等,使用比较简单
- h2:java的嵌入式关系数据库,可以当缓存来用。
- encache:应用最多的本地缓存。也可以实现分布式缓存,不过使用比较重。
- hazelcast:分布式应用缓存,使用不好会带来严重的性能问题。
- 自实现本地缓存:这种缓存适合数据量小,同时不需要数据分布式缓存的情况,实现简单。
嵌入式缓存一般用作二级缓存或者目前系统还不需要引入外部缓存的情况,在业务规模较小时,本地缓存一般都可以解决问题了
集中式缓存
例如memcache和redis,在实际应用中我只使用过redis,这里我画了一个表格总结了下两者的区别:
|
数据结构 |
内存管理 |
集群 |
线程模型 |
内存使用率 |
Redis |
Hash,string,list Set,queue |
基于内存,可以使用aof和rdb实现数据持久化 |
支持客户端做数据分片,3.0后支持redis-cluster集群,支持master-slave模式保证数据的可靠性 |
单线程,多核机器上单服务性能比memcache低。 |
多数据结构,hash结构使用率较高 |
Memcache |
简单的key-value结构 |
只能基于内存 |
只支持客户端做数据分片 |
多线程,支持cas保证数据同步 |
只有key-value存储,Memcache使用率较高 |
在选型时可以得到下面一些观点:
1.Redis支持更多的数据库结构,如果需要多种数据结构,redis更适合
2.Redis支持数据的备份,一定程度保证数据的高可靠
3.Redis支持数据的持久化,意味做集中式存储时,我们可以单独存储将历史数据拷贝出来单独保存。
4.Redis支持主从复制,很容易实现故障恢复
5.Redis的redis-cluster机制,可以帮助使用者不用在客户端做数据分片了。
缓存的基本算法思想
缓存一般都是基于内存的,而内存往往是很珍贵的资源,比较有限,对于各种各样的缓存,其内部的数据结构实现都比较复杂而且多种多样,这里仅总结一下常用缓存的过期算法,也是我们在自实现缓存时最需要关注的:
- FIFO(First In First Out)算法:先进先出队列算法,适合只关注最新数据的缓存,实现很简单,例如java中线程池中的阻塞队列,线程+0池之所以能缓存执行任务,就是将执行任务放入了一个阻塞队列,每次从该队列中获取头结点的任务进行处理,该队列是有限的,如果任务超过队列大小,那么有以下几个处理方法:1.创建新的线程(如果最大线程大于核心线程),2.立即执行该任务,放弃在执行的任务,3.抛弃该任务,并抛出异常。
- LFU(Least Frequently Used)算法:最不常用算法,其基本思想是:“如果数据过去被访问了多次,那么将来被访问的频率越高”,该算法适合关心数据访问频次的,而这些数据在缓存中是不能被优先淘汰的。不难理解,该算法的缓存命中率较高。在实现时,需要维护一个队列专门记录所有数据的访问记录,每个数据需要维护引用计数,实现相对比较复杂,由于要维护一个计数队列,所以内存消耗较高,需要基于引用计数进行排序,性能消耗较高。
- LRU(Least Recently Used)算法:最近最少使用算法,其基本思想是:“如果数据最近被访问过,那么将来被访问的几率更高”,例如cpu的L1,L2缓存等都是该缓存算法思想,redis中的缓存过期也是该算法思想。大多数缓存也是采用该思想,相对于LFU,其实现难度比较低,缓存命中率低于LFU算法。其基本实现思想是:1.新数据插入到链表头部;2.当缓存命中时,将数据移到头部节点;3.当链表满时,将链表尾部的数据丢弃。
上面总结了三种最基本的缓存淘汰算法和基本实现思想,对于缓存中爸爸级的Redis来说,其内部的缓存淘汰策略是如何实现的呢,在那么大的数据量下,每次设置了expire time又是如何起作用的呢?在redis中的缓存淘汰策略基于LRU主要有两种思想:
- 主动淘汰:Redis会周期性的从设置了过期时间的缓存数据里获取一部分数据,判断这些数据是否过期,如果过期则直接淘汰,如果过期的数据在本次周期所有数据里超过一定比例,则立即再执行一次该方法,否则下次再进行扫描。当缓存已用内存超过maxmemory限定时,会触发主动清除策略。
- 被动淘汰:当该key被访问时,判断是否已经失效,如果失效就删除它。
LRU的算法如果所有数据维护访问链表,并且每次数据被访问都去更新的话,代价还是比较大的,借助redis的缓存过期思想可以解决这个问题。
缓存带来的问题及解决
本地缓存带来的数据不一致问题
本地缓存带来的一般问题就是数据不一致,如果使用encache,hazelcast等还支持分布式缓存同步,但是对于自定义缓存,mapdb等这种这样的本地缓存简直有点无解,例如我在实际工作中遇到过一个需求:目前我们的系统需要做权限访问控制,精确到URL,权限数据是需要进行缓存的,但是目前我们系统还没有引入Redis这种集中式缓存,也不想引入encache,hazelcast这种缓存,带来了更大的复杂性,但是仔细分析,权限变更非常少,对于数据实时性要求很低,所以在10分钟内保证数据一致性一般就够了,所以对于这类数据,其实不太需要分布式缓存。同理,对于不经常变更,或者变更对于数据的一致性的实时性要求不高,那么使用本地缓存完全足够。
缓存数据的一致性问题
引入缓存后,主要是解决读的性能问题,但是数据总是要更新的,是先更新缓存还是先更新数据库呢?
- 先更新缓存再更新数据库:更新缓存后,后续的读操作都会先从缓存获取从而获取的是最新的数据,但是如果第二步更新数据库失败,那么数据需要回滚,导致先前获取的数据是脏数据来带不可逆的业务影响,所以一般这种方法都是不可取的
- 先更新数据库后更新缓存:先更新数据库,但是缓存没有更新,再将数据从数据库同步到缓存这一过程中,所有的读操作读的都是旧数据,会带来一定问题,但是问题较小。推荐使用
- 先删除缓存,后更新数据库再同步缓存:每次需要更新缓存时,不更新缓存的数据,而是先删除缓存,再更新数据库中的数据,数据库写成功后再更新缓存,这样读缓存时读不到数据会从数据库读,数据是最新的,带来的问题就是数据库压力在这一过程中会比较大。
缓存雪崩
在使用集中式缓存时,需要考虑缓存雪崩,缓存穿透,数据热点这些问题。缓存雪崩是指缓存失效(过期)后导致所有读操作都打到数据库,导致数据库压力瞬间增大,系统性能急剧下降甚至拖垮系统,主要解决方法:
-
所有数据的过期时间不要设置成一样,防止出现数据批量失效,导致缓存雪崩的情况
-
采用互斥锁的方式:这里需要使用到分布式锁,在缓存失效后,如果访问同一数据的操作需要访问数据并去更新缓存时,对这些操作都加锁,保证只有一个线程去访问数据并更新缓存,后续所有操作还是从缓存中获取数据,如果一定时间没有获取到就返回默认值或返回空值。这样可以防止数据库压力增大,但是用户体验会降低。
-
后台更新:业务操作需要访问缓存没有获取到数据时,不访问数据库更新缓存,只返回默认值。通过后台线程去更新缓存,这里有两种更新方式:
- 启动定时任务定时扫描所有缓存,如果不存在就更新,该方法导致扫描key间隔时间过长,数据更新不实时,期间业务操作一直会返回默认值,用户体验比较差
- 业务线程发现缓存失效后通过消息队列去更新缓存,这里因为是分布式的所以可能有很多条消息,需要考虑消息的幂等性。这种方式依赖消息队列,但是缓存更新及时,用户体验比较好,缺点是系统复杂度增高了。
后台更新的方式一般结合这两种更新方式,结合消息队列可以保证更新及时,提高用户体验,定时扫描可以进行缓存预热。在业务上线时就缓存好了数据。
缓存穿透
缓存穿透是指:业务操作访问缓存时,没有访问到数据,又去访问数据库,但是从数据库也没有查询到数据,也不写入缓存,从而导致这些操作每次都需要访问数据库,造成缓存穿透。
解决办法一般有两种:
- 将每次从数据库获取的数据,即使是空值也先写入缓存,但是过期时间设置的比较短,例如3秒,后续的访问都直接从缓存中获取空值返回即可。
- 对所有有结果的查询参数进行hash算法,利用Bloom filter算法将有查询结果的存储下来,一个一定不存在的数据首先会在这个Bloom filter中过滤掉,从而防止后续访问底层存储的操作。
缓存热点
缓存的性能很高,但是对于缓存的数据可能只有那么百分之20是经常需要被访问的,不得不说28原理哪里都存在呀,比如微博,很多明星的一条微博会被成千上万的人访问,而对于我这样的小屁民发了条微博可能只有我一个人看~。缓存热点是指:对于缓存的数据,如果访问某个热点key的读操作很多,会导致这台缓存服务器压力十分大,从而出现性能问题。
解决方法:为热点数据缓存多个副本,例如某个明星的微博,如果以10万访问为单位,那么某个有1000万粉丝的明星发的微博,就存储100份,缓存的数据都是一样的,缓存的key按编号区分,所有的读操作随机读取其中的一份缓存,这样就可以防止所有的读操作都落到一台缓存服务器上,同时需要注意:这样的缓存的也需要设置不同的缓存时间,防止缓存同时失效,引发缓存雪崩。
缓存组件的设计
上面总结了大部分缓存需要注意的问题,对于设计缓存,一般主要从以下几个角度设计和考虑:
-
什么样的数据应该缓存
-
什么时候应该触发缓存,怎样触发,什么时候去更新缓存,怎样更新
-
缓存的层次和粒度
-
缓存的命名规则和淘汰规则
-
缓存的监控以及故障应对方案
-
缓存数据的可视化以及缓存的key内存设计和大小等
https://github.com/Cicizz/Recode