《深入分布式缓存-从原理到实践》笔记
笔记作者:arthury.dy.lee
日期:2018.12.05
笔记只是本人觉得重要的部分的一些摘要或总结,更侧重于后5章。其它更详细内容,请自行买书阅读。
文章目录
一、 社交场景中的缓存应用
1.1 大缓存和千百万级的计算数量问题
1.热点用户过长的缓存list,极高的网络传输量,查询频度也很高。
对list进行分布查询。每次对第一页的查询频度 远高于后续分页。对于follower很多的热点用户,单个分布式value列表无法承载过长的follower列表。首先,同一个用户的follower列表的的前N页(假设为前5页)的访问概率占到总访问量的绝大部分(假设超过99%),其次 ,follower列表的展示以follow时间进行排序,最近加入的follower通常排在最前面,即增量化最新数据最有可能放在首N页。
2.查询中,部分热点用户存在百万甚至千万级别的count数量,查询followee和follower数量,如何期望实现秒级数据延迟。
引入单独的计数服务使用count计算做到O(1)的查询复杂度,计数服务可以设计成key-value结构,持久化到分布式缓存,key为用户id,value为该用户的follower/flollowee数量。面对计数器每个key埋版本记录,可以实现去重防丢失等需求。
1.2 帖子查询
查询某用户发送的帖子列表
select * from post where userId=? and posttime between ? and ?
查询优化
如果采用userId+postime的二级索引方式,对上述查询存在严重的回表(对二级索引查到的每条记录都需要到聚簇索引中重新查询主数据)问题,降低DB的吞吐量。
重新设计postId , 可以合并字段:前6位用userId,中间6位用timeCompress,后2位用seq。
首6位userId,每一无国界09/AZ这36个字符中的某一个,6位可以表示21亿个不同的用户,后续时间戳(精确到秒)可以标识70年的任意一秒,单个用户每秒发帖子不超过两位seq表达的最大值。14位的postId可以适用于本设计系统的规模。
优化后,查询就变成了
select * from post where postId between postId1 and postId2
或者
select * from post where postId like ‘userAprefix%’
1.3 redis热点问题
redis热点问题,可以引入本地缓存缓解服务端缓存的压力。
对于新增数据(帖子),本地缓存没数据,降级为查询一次服务端缓存即可。对相同用户相同时间范围的查询,通过并发控制,做到单台服务器一个并发。
对于删除数据(帖子),当查询本地缓存有数据时,如何知道该数据是否已被删除?解法是,可以为每个缓存中的用户保留一个最近更新时间,当这个用户的本地缓存上次查询服务端缓存距当前超过一定时间(假设1秒)时,再重新查询一次服务端缓存。
1.4 热点用户,拥有上亿的follower,那么这个用每一次新增删除帖子操作,将会被复制上亿次,造成增删帖子瓶颈。
push/pull结合
当某个用户发布一个帖子时,只需要将post同步到100个数据库分片上,无论多少个follower,只复制100份;数据库按照timeline所属用户进行分片,那么用户所有的followee的最新帖子都落在同一个DB分片上,即,每个用户刷新timeline,只需要一次DB查询,查询数据得到控制。
全量化查询改为timeline增量查询
二、缓存在社交网络Feed系统中的架构实践
基于Memcached的海量数据、大并发访问场景,不能简单将多种数据进行混存,需要进行容量评估,分析业务缓存数据的平均size,数量、峰值读写QPS等,同时结合业务特性,如过期时间、命中率、cache穿透后的加载时间等。
2.1 微博Feed系统缓存简介
采用Main-HA双层架构
对后端缓存访问,会先访问Main层,如果miss继续访问HA层,如果HA层命中,则返回Client结果后,再将value回写到Main层,后续对相同key的访问可以直接在Main层命中。
对于明星的热点数据,单个节点不能承载热数据的访问量,在Main-HA前,引入了小容量的L1缓存,构成L1-Main-HA,L1内存容量远远小于Main,稍冷的数据会迅速剔除,所以L1会持续存储最热的数据,同时由于L1多组,大量热数据访问会平均分散到多个L1。
2.2 Redis实例问题
单个Redis实例不能分配过大内存空间,否则会因为重启、rewrite时间特别长而影响服务的可用性。Redis加载处理1G数据大概需要1分钟,所以线上Redis单个实例最好不要超过20G。
因为Redis属于单进程/线程模型,可以根据CPU核数进行单机多Redis实例部署,具体实例数最高可以为N(CPU)-1。
对于大集合数据,可以增加多套Redis slave,成本开销圈大,可以在Redis前加一层Memcached,全列表读取采用Memcached的get来,miss后才读取Redis或DB层回写。
除了Memcached做前置缓存,还可以在调用端增加local-chche,进一步提升获取效率。
在使用多组合cache时,多种cache存在穿透、回写策略,这些策略比较通用,可以在client端进行抽象封装,使业务开发者使用起来像使用单个cache一样,从而提高开发效率。
2.3 Redis和memcached简单比较
由于Redis只使用单核,而Memcached可以使用多核,所以在比较上,平均每一个核上 Redis在存储小数据时比Memcached性能更高。而在100k以上的数据中,Memcached性能要高于Redis ,虽然Redis最近也在存储 大数据的性能上进行优化,但是比起Memcached,还是稍有逊色。说了这么多,结论是,无论你使用哪一个,每秒处理请求的次数都不会成为瓶颈。(比如 瓶颈可能会在网卡)
如果要说内存使用效率,使用简单的key-value存储的话,Memcached的内存利用率更高,而如果Redis采用hash结构来做key-value存储,由于其组合式的压缩,其内存利用率会高于Memcached。当然,这和你的应用场景和数据特性有关。
如果你对数据持久化和数据同步有所要求,那么推荐你选择Redis,因为这两个特性Memcached都不具备。即使你只是希望在升级或者重启系统后缓存数据不会丢失,选择Redis也是明智的。
memcache操作大块类型的数据性能更高
redis单线程,无法用多核并行处理,如果遇到IO量大的操作,出现等待IO的情况会增多,即使redis底层用的是异步非阻塞,IO的事件句柄也是要占用资源的。而且redis有落盘机制,数据请求会不断持久化到磁盘上,如果数据请求的IO量大,持久化占用的时间也就多,在持久化过程中redis会阻塞主线程的IO处理,那么阻塞的耗时也就多了。总结就是,redis的优势有单线程的功劳,但是弱点也是由于单线程,在大IO场景下,是天生的弱。
最终总结,memcache是由于单个指令无谓损耗较多而在指令执行效率上较差,redis是由于单核特性而在IO并行上有缺陷。
2.4 存在性判断
当每日新增的阅读记录高达百亿级别时,Redis的存储结构根本fqiffcri,cf于存在性判断业务,直接记录的机器成本太大,可以考虑采用Bloomfilter算法,在业务能容忍一定误差的前提下,可以大幅的降低内存占用。
Bloom Filter的算法
Bloom filter是一个时间效率很高的随机访问结构,它包含一个m位的bit数组,使用k个独立的hash函数,它们分别将集合N中的每个元素映射到{1…m}的范围内,在判断一个元素是否属于集合N时,使用这k个hash函数进行验证,当所有hash函数映射的bit全部是1时,认为该元素属于集合N,否则认为元素不属于集合N,当然这存在假阳的现象,也就是说元素实际上不属于集合N,但是被判断属于集合N,这是由于存在某个bit有可能被其他的元素置1。
若其中任何一位不为1则可以判定元素一定没有被记录过。若全部位都是1,则认为字符串str存在。注意:这里也可能存在误判,因为有可能该字符串的所有位都刚好是被其他字符串所对应,这种将该字符串划分错的情况称为false positive 。误差率可以根据高速记录内存进一步降低。
Bloom Filter字符串加入了就被不能删除了,因为删除会影响到其他字符串。
2.5 缓存服务化
主要的方案及策略:
1、缓存的服务化 引入一个proxy层,用于接受并路由业务对资源的请求,通过cacheProxy支持多种协议(Memcached、Redis等)、多种业务访问,不同业务通过namespace Prefix进行区分。
2、引入一个updateServer层,用于处理写请求和数据同步。
3、读写及数据复制策略,cacheProxy收到资源请求后,对redia类请求直接路由到后端资源,对write类请求路由到本idc的updateServer。Master idc的updateServer接爱所有的write请求,记录到AOF,并逐级复制到其他idc的udpateServer,实现idc间的cache数据同步。
4、引入cluster,并内嵌了Memcached Cluster、Redis cluster等访问策略,包括多层的更新、读取,以及msiss后的穿透、回写等。
5、接入配置中心,可以方便的支持API化、脚本化管理资源和proxy。
6、接入监控体系,方便查看缓存体系的服务状态。
7、Web化管理,通过Web界面管理缓存的整个生命周期。
业务方面只需要知晓namespace,即可实现对后端各种业务的多层缓存进行访问。
# 三、典型电商应用与缓存
3.1 多级缓存介绍
整体流程如下:
1)轮询可以使服务器的请求更加均衡,而一致性哈希可以提升应用Nginx的缓存命中率,相对于轮询,一致性哈希存在热点问题,一种解决热点直接推送到接入层Nginx,另一种办法是设置一个阀值,当超过阀值,改为轮询算法。
2)接着应用 Nginx读取本地缓存,如果本地缓存命中则直接返回。应用 Nginx本地缓存可以提升整体的吞吐量,降低后端的压力,尤其应对热点问题非常有效。本地缓存可以使用 Lua Shared Dict、 Nginx Proxy Cache(磁盘/内存)、 Local Redis实现。
3)如果 Nginx本地缓存没命中,则会读取相应的分布式缓存(如 Redis缓存,另外可以考虑使用主从架构来提升性能和吞吐量),如果分布式缓存命中则直接返回相应数据(并回写到 Nginx本地缓存)
4)如果分布式缓存也没有命中,则会回源到 Tomcat集群,在回源到 Tomcat集群时也可以使用轮询和一致性哈希作为负载均衡算法。
5)在 Tomcat应用中,首先读取本地堆缓存,如果有则直接返回(并会写到主 Redis集群)
6)作为可选部分,如果步骤4没有命中可以再尝试一次读主 Redis集群操作,目的是防止当从 Redis集群有问题时的流量冲击。
7)如果所有缓存都没有命中,只能查询DB或相关服务获取相关数据并返回。
8)步骤7返回的数据异步写到主 Redis集群,此处可能有多个 Tomcat实例同时写主
Redis集群,造成数据错乱,如何解决该问题将在14.3.5节详细介绍。应用整体分了三部分缓存:应用 Nginx本地缓存、分布式缓存、 Tomcat堆缓存每一层缓存都用来解决相关的间题,如应用 Nginx本地缓存用来解决热点缓存回题,分布式缓存减少访问回源率,Tomcat堆缓存用于防止相关缓存失效/崩溃之后的冲击。
如果抽象一下上次同的请求过程,简单化一下,那么:
1、Nginx做负载均衡,先读取本地缓存,本地缓存可以使用 Lua Shared Dict、 Nginx Proxy Cache(磁盘/内存)、 Local Redis实现。
2、如没命中,则赢取分布式缓存,如Redis,再回写到本地缓存。
3、如果没命中,则回源到Tomcat集群。
4、Tomcat应用中,先取本地缓存,如果没有命中,可以再尝试一次读主Redis缓存,防止当从 Redis集群有问题时的流量冲击。
5、查询DB,并异步写到主Redis集群。
注意:开启Nginx Proxy Cache后,性能不升反降。一段时间内存使用率达到98%,解决方案:
内存占用率过高的问题是内核问题,原理是内核使用LRU机制,可以修改内核参数:
sysctl -w vm.extra_free_kbytes=6436787
sysctl -w vm.vfs_cache_pressure=10000
使用Nginx Proxy Cache在机械盘上性能差,可以通过tmpfs缓存或nginx共享字典缓存元数据,或者使用SSD。书上作者使用内存文件系统。
3.2 如何缓存数据
不过期缓存
缓存不要放在事务中。
对于缓存数据一致性要求不是那么高,数据量也不是很大,则可以考虑定期全量同步缓存,可以考虑订阅数据库日志的架构,如使用canal订阅MySQL的binlog实现缓存同步。
若缓存的空间足够则可以考虑不过期,比如用户、分类、商品、价格、订单等,当缓存满了可以考虑LRU机制驱逐老的缓存数据。
过期缓存
过期缓存机制,即采用懒加载,一般用于缓存其它系统的数据(无法订阅变更消息,或者成本很高)、缓存空间有限、低频热点缓存等场景。步骤是:首先查缓存,命中返回;不命中则查询数据,然后异步写入缓存并过期缓存,设置缓存过期时间。这种缓存可能会存在数据不一致的情况,需要根据场景来决定如何设置过期时间。如库存数据可以在前端应用上缓存几秒,短时间的不一致是可以忍受的。
大value缓存
要警惕缓存中的大Value,尤其是使用Redis时。遇到这种情况时可以考虑使用多线程实现的缓存(如Memcached)来缓存大Value;或者对Value进行压缩:或者将Value拆分为多个小Value,客户端再进行查询、聚合。
热点缓存
对于那些访问非常频繁的热点缓在如果每次都去远程缓存系统中获取,可能会因为访问量太大导致远程缓存系统请求过多、负载过高或者带宽过高等问题,最终可能导致缓存响应慢,使客户端请求超时。种解决 方案是通过挂更多地从缓存,客户端通过负载均衡机制读取从缓存系统数据。不过也可以在客户端所在的应用代理层本地存储一份,从而避免访问远程缓存,即使像库存这种数据,在有此应用系统中也可以进行儿秒钟的本地缓存,从而降低远程系统的压力。
建立实时热点发现系统来对热点进行统一推送和更新。
3.3 负载均衡与一致性哈希
负载较低时使用一致性哈希,比如普通商品访问。热点请求时降级一致性哈希为轮询。当然,某些场景是将热点数据推送到接入层Nginx,直接响应给用户,比如秒杀商品的访问。
对于秒杀,可以把相关数据预先推送到接入层Nginx并将负载均衡机制降低为轮询。
3.4 更新缓存与原子性
1、更新缓存时,使用更新时间戳或者版本对比。若使用Redis,可以使用其单是线程机制进行原子化更新。
2、使用如果canal订阅数据库binlog,然后异步更新到Redis。
3、将更新请求按照相应的规则分散到多个队列,然后每个队列进行单线程更新,更新时摘取最新的数据保存。
4、用分布式锁,更新之前获取相关的锁。
3.5 缓存快速修复
1、主从机制,做好冗余。
2、因为缓存导致应用可用性下降,可以考虑,部分用户降级,然后慢慢减少降级量,后台通过worker预热缓存数据。
3.6 多种压测方案
线下压测:Apache ab, Apache Jmeter,这种方式是固定url压测,一般通过访问日志收集一些url进行硅油,可简单压测单机峰值吞吐量,但不能做为最终压测结果,因为这种压测会存在热点问题。
线上压测,可以使用Tcpcopy直接把线上流量导入到压测服务器,这种方式可以压测出机器的性能,而且可以把流量放大,也可以使用Nginx+Lua协程机制把流量分必到多台压测服务器,或者页面埋点,让用户压测。
四、同程凤凰系统基于Redis的设计与实践
4.1 问题描述
主从+keepalived方案问题
Redis单进程、单线粒的设计会导致Redis接收到复杂指令时会忙于计算而停止响应,可能就因为一个Zset成者keys之类的指令,Redis 计算时间稍长,Kepalived 就认为其停止了响应,直接更改虚IP的指向,然后做-次主从切换。过不了多久,Zset 和keys之类的指令又会从客户端发送过来,于是从机上又开始堵塞,Keepalived 就直在 主从机之间不断地切换IP。终于主节点和从节点都堵了,Keepalived 发现后,居然直接将虚IP释放了,然后所有的客户端都无法连接Redis了,只能等运维到线上手工绑定才行。
解决方案:方从+哨兵模式
**落盘问题 **
RDB属于非阻塞式的持久化,子进程内的数据相当于是父进程的一个拷贝, 这相当于两个相同大小的Redis进程在系统上运行,会造成内存使用率的大幅增加。如果在服务器内存本身就比较紧张的情况下再进行RDB配置,内存占用率就会很容易达到100%,继而开启虚拟内存和进行磁盘交换,然后整个Redis的服务性能就直线下降了。
解决方案:只把Redis做Cache,不要做storage角色用
主从同步问题
主从同步失败,同步失败就直接开启全同步,于是200GB的Redis瞬间开始全同步,网卡瞬间打满。
解决方案:对大容量的Redis拆分多片。
4.2 改变Redis用不好的误区
必须搭建完善的监控系统,在这之前要先预警,不能等发生了,我们才发现问题。自己研发替换目前使用的客户端,在系统中植入日志记录,对Redis所有的操作事件,如耗时、Key、Value大小、网络断开等,在系统后台收集,进行分析和处理,取消直接IP的端口连接方式,通过一下配置中心分配IP地址和端口。
4.3 系统化的改造
基于Doker将它改造成为一个云化的缓存系统,能称之为云,那么平台最基本的要求就是具备资源计算、资源调度功能,且资源分配无需人工参与。并带有高可用、自动备份、监控告警、日志分析等功能,无须用户关心资源背后的事情。其次是各种日常的运维操作需求服务化输出。
Redis 的用量大了,会自动扩容;扩容空间不够了,会自动分片:网络流量大了,也会自动负载均衡。 每个Redis都很小,不会超过8GB。备份和处理都不再是难事。
4.4 Redis的Docker化部署
通过Docker解决部署问题。
某项目新申请Redis是10GB,我们在通过Docker部署这个Redis的时候,只开启了1个500MB的Redis,随着项目的使用,当Redis实际空闲内存量小于250MB的时候,我们就通过Redis命令设置maxmemory为1GB,然后继续监控,直到内存为10GB。万一超过了,可以放弃掉部分冷数据,如果项目很重要,可以设置一下超额的量,这样程序会自动进行扩容同时发生警报,让项目开发人员及时检查,防止出现问题。
用Redis都有个很头疼的问题,就是Redis的网卡打满问题,由于Redis 的性能很高,在大并发请求下,很容易将网卡打满。 所以基于开源的Contiv netplugin项目限制了网卡的使用,可以无视底层的网络基础架构,向上层容器提供一致的虚拟网络。
4.5 分片优化
在分片后,多key的操作无法使用。优化方案:将Redis的分槽策略进行改动,原本针对key进行分区,改造为当key满足似{{prefix}}key这样的格式时我们将只针对{{}}内的内容计算hash值。这样,相关性的一组key可以使用统一的前缀,并保存到同一片中。
4.6 Redis运维上的工具
Redis有个命令monitor,可以将当前的Redis操作全部导出。作者基于这个命令,开发一个Redis监控程序。
五、新的旅程
5.1 缓存组件的选择
如果待缓存的数据量特别大,而数据的访问量不太大或者有冷热区分,也必须将所有数据全部放在内存中,缓存成本(特别是机器成本)会特别高。如果业务遇到这种场景,可以考虑用pika、ssdb等其他缓存组件。pika、 ssdb都兼容Redis协议,同时采用多线程方案,支持持久化和复制,单个缓存实例可以缓存数百G的数据,其中少部分的热数据存放内存,大部分温热数据或冷数据都可以放在磁盘,从而很好地降低缓存成本。
常用的缓存组件
缓存组件 | 数据类型 | 数据容量(单实例) | 访问方式 | 同步 |
---|---|---|---|---|
Memcached | 简单KV | 100GB以下 | GET SET DEL 等常规接口 | client 多写 |
Redis | 丰富 | 30GB以下 | 更丰富的常规接口、事务更新等 | 主从复制 |
Pika/ssdb | 较丰富,部分Redis数据结构不支持 | 数百GB以下 | 较丰富 | 主从复制 |
5.2 缓存命中率
Memcached中,运行state命令可以查看Memcached服务的状态信息,第三方工具对整个Memcached集群进行监控。
Redis中,可以运行info命令查看Redis状态信息。keyspace_hits为总的命中次数,keyspace_misses为总的miss次数,命中率=keyspace_hits/(keyspace_hits+keyspace_misses)
通过情况下,缓存的粒度越小,命中率会越高。
六、arthur.dy.lee的总结
大缓存
在业务上分页,不要一次取。如果涉及到计算,可单独服务处理。一些查询还可以在DB端做冗余优化。
如果数据量不是很大,是大value的话,那么在Redis前加一层Memcached。
热点
加多级缓存。这里分2种,一种是微博的多层缓存:本地缓存池+Main+HA。另一种:Nignix local cache+Redis+tomcat cache+应用缓存+Redis
加local-cache
一致性哈希降级为轮询
更新
热点用户的更新,可以考虑推拉结合的方式。比如用户新增数据时,推到10/100台数据库中,其它用户follower取的时候,分片从这10/100台中取云。
canal订阅数据库binlog,然后异步更新到Redis
更新加和版本号或时间戳,用Redis单线程更新
分布式锁
切片,然后单线程队列更新。我觉得也可以用分布式定时任务做更新。
缓存优化
缓存服务化,开发只需要知道命名空间即可。所有的扩容、切片、容灾、主备切换都由服务化系统搞定。
缓存做监控和预警
缓存命中率监控