今天我们来聊一聊在大型分布式系统中,缓存应该怎么玩,从毕业到现在也有三年多了,大大小小的系统也经历了几十个,今天就从各个角度来讨论一下,我们的不同的缓存应该怎么玩,才能用的高效。
我们团队现在做的是直播类的产品,就拿抖音来说,比如说要开发一个榜一大哥、榜二大哥等各类大哥的排行榜单,要怎么开发,对于抖音这个dau十亿级别的产品,缓存的设计肯定是家常便饭。
对于一个百万、千万级别的接口调用,若是没有缓存的设计,直接打到数据持久层,那将是毁灭性的灾难。之前我就经历过,一个接口一天几百万次的调用,因为缓存的设计不严谨,缓存失效后,瞬间直接打在数据库层,幸好有告警,及时修复,差点就领了p0的故障。
大体来说缓存分为客户端缓存 和服务端缓存 ,客户端缓存我们比较常见的就是浏览器缓存,也就是通过http进行控制的缓存。
客户端缓存
基于请求-应答模式下,在大多数场景下客户端都是通过https协议,请求后台获取数据,若是高频的接口一天几百万次的调用,即使短时间的客户端缓存也会带来高效的收益。
因为客户端到服务端要经过漫长的网络链路,多变的网络环境,数据包可能小的几十K到大的数据包几十M,这样就能够省去复杂多变的网络请求的时间。
客户端缓存减少了客户端到服务端之间的通信次数以及成本,只要缓存可用,就能够及时响应数据。
客户端缓存常见的也就是浏览器缓存,简而言之也就是http缓存,不知道大家在实际开发过程中有没有用过这段代码:
ResponseEntity.ok().cacheControl(CacheControl.maxAge(3, TimeUnit.SECONDS)).body()
看他的包,他就是属于springframework 框架下http包下的一个工具类。
import org.springframework.http.CacheControl; import org.springframework.http.ResponseEntity;
在浏览器缓存中,http协议header有这么个key-value字段进行控制,叫做Cache-Control:max-age=30 ,max-age标志该资源在客户端缓存多少秒。
假如max-age=0,表示不缓存数据,除了max-age可以控制数据的缓存状态,还有以下三个属性来控制缓存状态no_store、no_cache、must-revalidate 。
- no_store表示不缓存数据,每次都去服务器获取 。
- no_cache看起来也是不缓存的意思,但是它表示的意思是可以缓存的,只不过在使用缓存之前,都要去服务器验证数据是否有效,是否过期,是否是最新版本 。
- must-revalidate和no_cache有点类似,就是缓存不过期的话可以继续使用,过期了就需要去服务器验证一下 。
除了Cache-Control可以使用客户端缓存,在http里面还有一个条件请求的header更加智能的使用客户端缓存。
条件请求是基于响应报文返回的“Last-modified ”和“ETag ”实现的。Last-modified资源最后的一次修改时间,ETag则表示资源的唯一标识,你可以理解为只要资源修改后都不一样了 。
再次请求的时候在请求头里面就会带上"If-None-Match:ETag返回的值 ",去验证资源是否有效。
假如有效的话,就会返回"304 Not Modified ",表示缓存资源有效还可以继续使用。
但是这种方式我较少使用,基本上使用Cache-Control就够了,控制好实效的时间,一般的场景都是允许短暂的不一致。
除了客户端能够发送Cache-Control之外,客户端也能够发送Cache-Control两者进行协商使用客户端缓存的方案。
像我在浏览器访问一个连接,在输入框敲一下回车,Request的Headers里面就有Cache-Control:max-age = 0,表示不使用缓存,直接去后台获取数据。
所以Cache-Control来控制客户端缓存也不太好控制,要两者协商好,但是Cache-Control有一个好处就是可以控制CDN缓存。
基于 Spring Boot + MyBatis Plus + Vue & Element 实现的后台管理系统 + 用户小程序,支持 RBAC 动态权限、多租户、数据权限、工作流、三方登录、支付、短信、商城等功能
服务端缓存
CDN缓存
上面聊到Cache-Control来控制客户端缓存,它也同时影响CDN缓存,告诉CDN客户缓存这个接口的数据。
CDN服务一般是由第三方提供的内容分发网络服务,主要是用于缓存静态的数据,比如:图片、音频、视频,这些数据,都是不不变的,那么命中率就很高。
不用回源获取数据,效率高,毕竟使用CDN的费用高,一般小公司也不会用,可能大公司采用。
CDN厂商花费大价钱在全国各地建立CDN的服务站点,用于用户的就近访问,减少响应时间。
所以这个对于应用层的来说是0开发的,一般只要在你的服务治理平台针对某一个接口配置一下就好了。
除了缓存静态数据,想一些动态数据,但是不会经常变的数据CDN也是可以缓存的,只不过可能缓存的时间设置的比较短,那么在高并发场井下取得的效益也是比较大的。
好了,关于CDN的也没啥好说的,我还没接触过CDN开发,但是项目中使用到了,就是简单而配置一下而已,等我接触到开发,再和你们详聊,CDN其实就是代理源站服务器缓存数据而已。
Redis缓存
在服务端缓存中Redis缓存可能是我们最常见的缓存,可以说Redis已经是各大公司常用的缓存中间件选型之首,也不为过。
我们项目中也是在使用Redis,只不过在Redis的层面上进行封装,包括Redis哨兵、Redis分片 ,都是基于自己的业务情况下进行二次开发,然后供自己的业务使用。
在Redis的基础数据类型中,有五大类型供大家选择,包括String、List、Set、SortedSet、Hash 。这五种数据类型在百分之九十五的场景下都能够解决,并且在这五种基本数据类型的底层运用了高效与省空间的数据结构,所以Redis的高性能之一也是因为有这些数据结构作为支撑。
图片来源于Redis核心技术与实战
比如:要实现一个排行榜单,凸显直播间榜一大哥以及上榜大哥财大气粗的实力。
那么这个明显是按照某个字段进行排序,比如刷的抖音币进行排序,那个在redis中List和Sorted Set都是可以实现有序的缓存。
List是按照写入List顺序进行存储,而Sorted Set是按照某一个字段的权重来排序,并且可以查询权重范围内的数据。
对于我们的场景List可能不太适合,因为是对数据每次都是新产生的,并且按照时间来顺序来写入,List集合就比较适合。
我们的场景是某个在榜大哥不断的刷礼物,就需要重新对他进行排序,并不是按照每次新增写入缓存的顺序取数,那么按照大哥刷的抖音币的多少就可以当做权重来排序,很好的服务我们的场景,按照时间来排序的场景Sorted Set都可以来做。
还有一些聚合统计的场景,比如要统计两个key数据集的交集、并集、差集可以使用set集合来做。
假如某一天你的老板让你开发一个统计每天新增的用户数据功能,其实那么也就两个集合差集,一个set集合用户保存所有用户的id,一个set用户保存当天用户的id集合,然后当天用户集合与所有用户集合的差集就是新增的用户集合。可以使用set集合中的SDIFFSTORE 命令进行实现。
还有一些二值统计的场景,也就是基于redis的Bitmap来统计,他并不记录数据的本身,只能判断是否存在,有没有,Bitmap保存的是bit 位,所以亿级别数量的存储只要M级别的存储单位就可以了,所以Bitmap非常的节省空间。
Bitmap中提供SETBIT设置bit位,以及GETBIT获取某个bit位的值,还可以使用BITCOUNT统计bit位位1的值,比如可以统计某个月签到场景。
Redis高性能的缓存给我们系统带来了极大的性能提升,但是同时也会有一些类的问题,比如数据一致性的问题、缓存的三大问题(击穿、穿透、雪崩)、与Redis网络通信接口超时、Redis里面缓存的数据变多,操作时间复杂度大的导致Redis变慢 。
数据的一致性
数据一致性问题指的是缓存与数据库的一致性问题,只要使用缓存就会有一致性问题,现在市场上都不会要求强一致性,都是追求最终一致性 。
缓存按照是否可写分为读写缓存与只读缓存 ,大部分是只读缓存,现在我们来讨论一下只读缓存一致性问题。
只读缓存的一致性问题包括以下以下两种场景:
- 先更新数据库,然后删除缓存。
- 先删除缓存,然后更新数据库。
但是这两种场景在高并发场景下都会有问题,先来看看第一种场景:先更新数据库,然后删除缓存。
这种场景也会有一致性问题,当我们更新了数据库后,然后删除缓存,删除缓存失败了,此时请求读取的缓存还是旧的值。
这种情况下的解决方案就是重试 ,可以在应用层重试,也可以放入消息队列里面重试,当重试次数达到最大的限制,就需要发送告警进行人工排查了。
或者设置比较短的缓存失效时间,短暂的不一致性,也是可以接受的。
第二种场景:先删除缓存,然后再更新数据库。在高并发场景下也有可能数据不一致。
假如线程A删除了缓存,但是还没有更新数据库,然后线程B读取缓存发现缓存缺失,然后从数据库里面读取旧值,并且缓存到Redis中,后面的请求就会从Redis中读取旧值。
这种场景市面上推荐使用延迟双删的方案进行解决,就是在请求A删除缓存后,更新数据库,然后等一段时间删除缓存,请求A的sleep的时间大于请求B的读取数据写入缓存的时间。
但是这种一般等的时间不调好估计,而且在高并发场景下,让线程去等无疑是降低性能,这个通常是不允许的。
所以一般建议采用第一种方案,先更新数据库,然后删除缓存的方式。
我们项目中也会用到读写缓存,之前遇到一个需求就是,在直播过程中,主播的公屏的流水,要显示用户中奖的横幅,也就是“恭喜某某在某某直播间抽中了XXX礼物”。
礼物的抽奖流水之前就已经发送消息队列了,所以只要监听对应抽奖流水topic就行了,然后将中奖的流水按照排序规则放入Redis中。然后客户端从Redis中读取,其实很简单,流水也不需要存库,只要展示就行了。
所以Redis的应用场景还是很多的,几乎可以覆盖开发中的95%以上的需求
缓存击穿、穿透、雪崩
使用分布式缓存还会涉及到缓存的三大问题,也就是缓存击穿、缓存穿透、缓存雪崩 。
缓存穿透 的解决方案有两种:
- 缓存空对象:代码维护较简单,但是效果不好。
- 布隆过滤器:代码维护复杂,效果很好。
缓存空对象是指当一个请求过来缓存中和数据库中都不存在该请求的数据,第一次请求就会跳过缓存进行数据库的访问,并且访问数据库后返回为空,此时也将该空对象进行缓存。
若是再次进行访问该空对象的时候,就会直接击中缓存,而不是再次数据库,缓存空对象实现的原理图如下:
缓存空对象的实现代码如下:
public class UserServiceImpl { @Autowired UserDAO userDAO; @Autowired RedisCache redisCache; public User findUser(Integer id) { Object object = redisCache.get(Integer.toString(id)); // 缓存中存在,直接返回 if(object != null) { // 检验该对象是否为缓存空对象,是则直接返回null if(object instanceof NullValueResultDO) { return null; } return (User)object; } else { // 缓存中不存在,查询数据库 User user = userDAO.getUser(id); // 存入缓存 if(user != null) { redisCache.put(Integer.toString(id), user); } else { // 将空对象存进缓存 redisCache.put(Integer.toString(id), new NullValueResultDO()); } return user; } } }
布隆过滤器是一种基于概率的数据结构,主要用来判断某个元素是否在集合内,它具有运行速度快(时间效率),占用内存小的优点(空间效率),但是有一定的误识别率和删除困难的问题。它只能告诉你某个元素一定不在集合内或可能在集合内。
在计算机科学中有一种思想:空间换时间,时间换空间。一般两者是不可兼得,而布隆过滤器运行效率和空间大小都兼得,它是怎么做到的呢?
在布隆过滤器中引用了一个误判率的概念,即它可能会把不属于这个集合的元素认为可能属于这个集合,但是不会把属于这个集合的认为不属于这个集合,布隆过滤器的特点如下:
- 一个非常大的二进制位数组 (数组里只有0和1)
- 若干个哈希函数
- 空间效率和查询效率高
- 不存在漏报(False Negative):某个元素在某个集合中,肯定能报出来。
- 可能存在误报(False Positive):某个元素不在某个集合中,可能也被爆出来。
- 不提供删除方法,代码维护困难。
- 位数组初始化都为0,它不存元素的具体值,当元素经过哈希函数哈希后的值(也就是数组下标)对应的数组位置值改为1。
实际布隆过滤器存储数据和查询数据的原理图如下:
缓存击穿是指一个key非常热点,在不停的扛着大并发,大并发集中对这一个点进行访问,当这个key在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库,瞬间对数据库的访问压力增大。
缓存击穿这里强调的是并发,造成缓存击穿的原因有以下两个:
- 该数据没有人查询过 ,第一次就大并发的访问。(冷门数据)
- 添加到了缓存,reids有设置数据失效的时间 ,这条数据刚好失效,大并发访问(热点数据)
对于缓存击穿的解决方案:
- 加锁,用户出现大并发访问的时候,在查询缓存的时候和查询数据库的过程加锁,只能第一个进来的请求进行执行,当第一个请求把该数据放进缓存中,接下来的访问就会直接集中缓存,防止了缓存击穿。
- 不设置热点key的失效时间
缓存雪崩 是指在某一个时间段,缓存集中过期失效。此刻无数的请求直接绕开缓存,直接请求数据库。
造成缓存雪崩的可能原因有:
- reids宕机
- 大部分数据失效
对于缓存雪崩的解决方案有以下两种:
- 搭建高可用的集群,防止单机的redis宕机。
- 设置不同的过期时间,防止同一时间内大量的key失效。
接口超时&操作时间复杂度高
Redis数据第三方缓存中间件,要与Redis通信,必须经过网络,那么经过网络就有可能出现网络超时的现象。
之前我们也出现过,某个机房因为网络波动,出现了一系列的Redis查询网络超时的告警。
所以为了解决一时的网络超时,我们有可能还要做好接口重试的机制,提高接口的可用性。
并且对Redis五种基本数据类型的底层数据结构熟悉的,Redis中对集合类型的操作HGETALL、SMEMBERS,以及对集合进行聚合统计 等,时间复杂度都是O(N)
那么Redis中存储的数据越多,这个N就越大,操作的复杂度就越高,这就是所谓的bidkey现象,已经出现查询阻塞了。
当然出现这种问题时,可以将bigkey按照一定规律进行拆分,这样分成多个key进行存储,查询的效率就会变高。
当然Redis的数据分片解决方案也可以,将原来一个实例中存储全量数据,按照16384进行crc16(key) % 16384 决定数据存储于哪个槽中。
这样扩展性也比较好,不过一般优先推荐拆分key的方案,这样实现成本低,实现简单。
缓存消息队列玩法
有一些场景还可以使用消息队列进行更新缓存,用户更新数据,异步的发送消息队列,消费者就可以监听消息队列的消息,消费消息后更新缓存。
因为有些数据的更新是需要发送消息队列的,被其他消费者监听使用,所以你只要监听消息队列就行了。
并且消息的队列的消息由消息队列的方式来保证,包括生产者可靠的发送消息队列,通过ack以及重试保证,消息队列本身通过持久化机制来保证,而消费者也是通过消费后手动ack来确认消息消费。
消息对垒更新缓存
定时任务
定时任务其实就是本地缓存了,在分布式系统中,定时任务就是每个服务中都会缓存一份,这样数据不一致性也会加大。
但是在某些场景下,他带来的收益也是非常可观的,比如说某个场景下你要查询一些安全中台的白名单/黑名单列表,而且这些列表不会经常变,可能需求上线后只要配置一下就ok了,后面的更改频率也是非常的低。
但是你的接口可能是高流量接口,每次用户进来都会请求一次,进行判断,而且用户是千万级别的,那有可能一天的请求就是上百万次的请求。
那你有两种选择来请求安全中台的白名单/黑名单列表,要么就是实时请求,要么就是定时任务请求本地缓存一份,然后查询只要从本地获取就行了。
在这种情况下肯定是定时任务请求,带来的效益更大,在SprngBoot项目中开启定时任务很简单,只需要在你的启动主类上加上这个注解:**@EnableScheduling**
然后在需要定时任务的执行类的方法上加上这个注解:**@Scheduled(cron = "0 0 2 * * ?")** , 其实就是cron表达式,执行的规律隔多久执行一次。
只要你的时间配置的足够短,这样数据也是近实时的,不会差太远,你可以配置成30秒或者几十秒执行一次,或者几分钟执行一次都可以,这个可以和产品进行协商,看产品可以接受多久的延迟。
然后,查询的中台的列表数据缓存在本地的一个map里面,用户的uid作为map的key,然后后面需要查询的时候,直接从map里面获取。
这样就不用每次请求过来都会实时的调用中台的http/rpc接口查询数据,直接从本地获取提高效率,这也是空间换时间的思想。
接口超时
这里需要注意的是,就是要提高你的接口调用的可用性 ,毕竟中台属于另一个服务,那么服务之间涉及远程调用,就有可能存在超时的现象。
那么你就要确保你的接口99.9%可用,对于接口超时,你可以就要设置接口重试 。
因为有时候可能是网络的原因导致的一时超时,设置被调用方一时因为网络抖动导致超时,那么重试成功的概率就可能比较高。
一般重试的次数会设置为2-3 次比较合理,除非网络故障了或者接口一直调不通,这样的话就需要及时告警,通知到开发人员,及时检查到底是哪里的问题,确保好接口的兜底方案。
并且还要设置每次的超时时间,设置超时时间也是非常的重要,假如超时时间设置的太短,还没有查出来就已经超时了,这样就会导致频繁超时,浪费资源。
要是设置的超时间太长,那么线程就会一直阻塞在那里等待调用的结果返回,这样在高并发场景下,就会资源耗尽,系统崩溃。
所以我给你的建议就是可以结合线上服务所在服务器的配置以及qps进行配置,配置一个合理的超时时间,合理的时间内能够超时返回并且不会导致资源耗尽。
重试这种机制,在很多中间件的思想中都会涉及到,比如:在分布式事务中2PC和3PC 。
2PC在第二阶段提交失败,那么只能不断重试,直到所有参与者都成功(回滚或者提交成功)。
图片来源于网络
因为除了重试,没有更好的办法,只能不断重试直到都成功,而且多数情况可能都是一时的网络抖动的原因导致的,这样重试成功的概率就非常高。
批量查询数据
定时任务缓存其实也是一种集中式缓存 ,假如缓存的数据量也比较大,那么在接口调用时就需要批量获取,但是一次性又不能查询太多,一般严谨的中台设计,都会都传参进行参数校验。
因为对于调用方完全是透明的,不可信任的 ,什么参数都有可能传过来,假如调用方一下子查几万个或者是几万个数据集,那不是接口都爆了。
所以,必须要做好分批调用,调用方分批、分页调用 ,中台对参数做校验一次只能查询几百个,这样子去规定,保证接口的可用性。
调用方的伪代码如下:
boolean end = true; int page = 1; int pageSize = 500; while (end){ // 设置好超时,失败重试 Data data = getData(page, pageSize); Map map =data.getDataMap(); // data里面的字段hashMore表示查询下一个分页是否还有数据 end = Objects.equals(data.getHasMore(),1); page++; }
本地缓存@Cacheable
@Cacheable是springframework下提供的缓存注解类,在spring中定义了 org.springframework.cache.Cache 和 org.springframework.cache.CacheManager 接口来统一实现cache。
除了@Cacheable用户缓存数据,也可以使用@CachePut用于缓存更新,这两个是比较常用的。
他们缓存的数据也是缓存在本地和定时任务一样,除了使用@Cacheable还可使用谷歌研发的cache工具类LoadingCache ,他也是本地缓存的一种,并且可以设置缓存的大小,重新刷新的时间。
相对比Cacheable会更加方便,因为你发现Cacheable还缺少缓存时间和缓存更新的属性配置实现,可能还需要自己再二级开发,比如加入缓存失效时间、多少秒后自动更新更新缓存,这样Cacheable才能更加完善。
private final LoadingCache<String, List<ParentTag>> tagCache = CacheBuilder.newBuilder() .concurrencyLevel(4) .expireAfterWrite(5, TimeUnit.SECONDS) .initialCapacity(8) .maximumSize(10000) .build(new CacheLoader<String, List<ParentTag>>() { @Override public List<ParentTag> load(String cacheKey) { return get(cacheKey); } @Override public ListenableFuture<List<ParentTag>> reload(String key, List<ParentTag> oldValue) { ListenableFutureTask<List<ParentTag>> task = ListenableFutureTask.create(() -> get(key)); executorService.execute(task); return task; } } );
相比@Cacheable就是代码比较冗余,注解方式会更加直观简洁,不过LoadingCache的灵活性更高。
我们自己对Cacheable进行了扩展,加入了实效时间以及自动更新的方案,这样的Cacheable更加适用于我们的业务。
总结
在项目中可能多种缓存并行使用,使用不同的缓存都要基于业务进行考量,包括成本,数据一致性,性能问题等,不同的缓存方式有不同的特点。
redis缓存分布式系统中共享数据,性能高效,扩展性强,redis可以基于数据分片、哨兵模式进行扩展,但是要额外的费用进行运维,并且引入第三方中间件,系统的复杂度也高,排查困难,而且每次都要经过网络调用,有可能存在网络超时的现象,数据丢失,所以要做好数据兼容,兜底方案。
本地缓存使用简单方便,低成本,每个服务实例都会冗余一份数据,一致性问题加大,但是效率非常高效,不用通过网络传输获取数据。
一般我们的项目中都会分配6-8G的内存,所以一般本地缓存都够使用的,所以一般能用本地缓存的话,都可以优先使用本地缓存。
一些场景不得不使用分布式缓存的,就是用Redis缓存来共享数据,综合使用不同的缓存来解决项目中的问题。
从上面的几种缓存方案中可以看到重试方案,重试是解决很多问题的重要手段之一,但是重试次数,重试的超时时间也要控制,防止资源耗尽,在大多数场景下,重试都可以解决,要是重试次数达到限制都不成功,就有可能是网络故障或者接口问题,此时就需要应用发送告警通知开发人员进行排查,这是兜底方案。
客户端缓存和CDN缓存这两个对于服务端来说,比较少使用,一般公司都是用不到,大家可以把关注点放在服务端缓存中。