在社交网络发展如火如荼的今天,人们越来越倾向于新媒介来展现自我和沟通交互。以新浪微博为例,作为移动社交时代的重量级社交分享平台,2017年初日活跃用户1.6亿,月活跃用户近3.3亿,每天新增数亿条数据,总数据量达千亿级,核心单个业务的后端数据访问QPS高达百万级。
在社交网络系统运行过程中,面对庞大用户群的海量访问,良好架构且不断改进的缓存体系具有非常重要的支持作用。本文以新浪微博Feed系统架构的发展历程作为背景,基于一个典型的社交网络Feed系统架构,介绍Feed系统的缓存模型、缓存体系架构,以及缓存体系如何伴随业务规模来扩展及演进。
Feed系统架构
互联网从门户/搜索时代进入移动社交时代,互联网产品从满足单向浏览的需求,发展到今天的以用户、关系为基础,通过对海量数据进行实时分析计算,来满足用户的个性信息获取及社交的需求。
类似微博的信息条目在技术上也称之为status或feed,其技术的核心主要包含三个方面:
- Feed的聚合与分发,用户打开Feed首页后能看到关注人的feed信息列表,同时用户发表的feed需要分发给粉丝或指定用户。
- Feed信息的组装与展现。
- 用户关系管理,即用户及其关注/粉丝关系的管理。
对于中小型的Feed系统,feed数据可以通过同步push模式进行分发。如下图所示,用户每发表一条feed,后端系统根据用户的粉丝列表进行全量推送,粉丝用户通过自己的inbox来查看所有最新的feed。新浪微博发展初期也是采用类似方案,通过LAMP架构进行feed push分发,从而实现快速开发及上线。
随着业务规模的增长,用户的平均粉丝不断增加,特别是V用户的粉丝的大幅增长,信息延迟就会时有发生,单纯的push无法满足性能要求。同时考虑社交网络中多种接入源,移动端、PC端、第三方都需要接入业务系统。于是就需要对原有架构进行模块化、平台化改进,把底层存储构建为基础服务,然后基于基础服务构建业务服务平台。对数据存储进行了多维度拆分,并大量使用cache进行性能加速,同时将同步push模式改成了异步hybrid模式,即pull+push模式。用户发表feed后首先写入消息队列,由队列处理机进行异步更新,更新时不再push到所有粉丝的inbox,而是存放到发表者自己的outbox;用户查看时,通过pull模式对关注人的outbox进行实时聚合获取。基于性能方面的考虑,部分个性化数据仍然先push到目标用户的inbox。用户访问时,系统将用户自己的inbox和TA所有的关注人outbox一起进行聚合,最终得到Feed列表,如下图所示。
对于大型Feed系统,还可以在此基础之上继续对Feed架构进行服务话、云化改进,最终形成如下图所示的一个典型的Feed系统架构。
如上图所示,其对外主要以移动客户端、Web主站、开放平台三种方式提供服务,并通过平台接入层访问Feed平台体系。其中平台服务层把各种业务进行模块化拆分,把诸如Feed计算、Feed内容、关系、用户、评论等分解为独立的服务模块,对每个模块实现服务化架构,通过标准化协议进行统一访问。中间层通过各种服务组件来构建统一的标准化服务体系,如motan提供统一的rpc远程访问,configService提供统一的服务发布、订阅,cacheService提供通用的缓存访问,SLA体系、Trace体系、TouchStore体系提供系统通用的健康监测、跟踪、测试及分析等。存储主要通过MySQL、HBase、Redis、分布式文件等对业务数据提供落地存储服务。
为了满足海量用户的实时请求,大型Feed系统一般都有着较为严格的SLA,如微博业务中核心接口可用性要达到99.99%,响应时间在10~40ms以内,对应到后端资源,核心单个业务的数据访问高达百万级QPS,数据的平均获取时间要在5ms以内。因此在整个Feed系统中,需要对缓存体系进行良好的架构并不断改进。
Feed缓存模型
Feed系统的核心数据主要包括:用户/关系、feed id/content以及包含计数在内的各种feed状态。Feed系统处理用户的各种操作的过程,实际是一个以核心数据为基础的实时获取并计算更新的过程。
以刷新微博首页为例,处理用户的一个操作请求,主要包括关注关系的获取、feed id的聚合、feed内容的聚合三部分,最终转换到资源后端就是一个获取各种关系、feed、状态等资源数据并进行聚合组装的过程,如下图所示。
这个过程中,一个前端请求会触发一次对核心接口friends_timeline的请求,到资源后端可能会存在1-2+个数量级的请求数据放大,即Feed系统收到一个此类请求后,可能需要到资源层获取几十个数百甚至上千个的资源数据,并聚合组装出最新若干条(如15条)微博给用户。整个Feed流构建过程中,Feed系统主要进行了如下操作:
- 根据用户uid获取关注列表。
- 根据关注列表获取每一个被关注者的最新微博ID列表。
- 获取用户自己收到的微博ID列表(即inbox)。
- 对这些ID列表进行合并、排序及分页处理后,拿到需要展现的微博ID列表。
- 根据这些ID获取对应的微博内容。
- 对于转发feed进一步获取源feed的内容。
- 获取用户设置的过滤条件进行过滤。
- 获取feed/源feed作者的user信息并进行组装。
- 获取请求者对这些feed是否收藏、是否赞等进行组装。
- 获取这些feed的转发、评论、赞等计数进行组装。
- 组装完毕,转换成标准格式返回给请求方。
Feed请求需要获取并组装如此多的后端资源数据,同时考虑用户体验,接口请求耗时要在100ms(微博业务要求40ms)以下,因此Feed系统需要大量使用缓存(cache),并对缓存体系进行良好的架构。缓存体系在Feed系统占有重要位置,可以说缓存设计决定了一个Feed系统的优劣。
一个典型的Feed系统的缓存设计如下图所示,主要分为INBOX、OUTBOX、SOCIAL GRAPH、CONTENT、EXISTENCE、CONTENT共六部分。
Feed id存放在INBOX cache和OUTBOX cache中,存放格式是vector(即有序数组)格式如下图所示。
其中INBOX缓存层用于存放聚合效率低的feed id(类似定向微博directed feed)。当用户发表只展现给特定粉丝、特定成员组织的feed时,Feed系统会首先拿到待推送(push)的用户列表,然后将这个feed id推送(push)给对应粉丝的INBOX。因此INBOX是以访问者UID来构建key的,其更新方式是先gets到本地,变更后再cas到异地Memcached缓存。
OUTBOX缓存层用于直接缓存用户发表的普通类型feed id,这个cache以发表者UID来构建key。其中outbox又主要分为vector cache和archive ddata cache;vector cache用于缓存最新发表的feed id、comment id等,按具体业务类型分池放置。如果用户最近没有发表新feed,vector cache为空,就要获取archive data里的feed id。
SOCIAL GRAPH缓存层主要包括用户的关注关系及用户的user信息。用户的关注关系主要包括用户的关注(following)列表、粉丝(follower)列表、双向列表等。
CONTENT缓存层主要包括热门feed的content、全量feed的content。热门feed是指热点事件爆发时,引发热点事件的源feed。由于热门feed被访问的频率远大于普通feed,比如微博中单条热门feed的QPS可能达到数十万的级别,所以热门feed需要独立缓存,并缓存多份,以提高缓存的访问性能。
EXISTENCE缓存层主要用于缓存各种存在性判断的业务,诸如是否已赞(liked)、是否已阅读(readed)这类需求。
COUNTER缓存用于缓存各种计数。Feed系统中计数众多,如用户的feed发表数、关注数、粉丝数、单条feed的评论数、转发数、赞数及阅读数,话题相关计数等。
Feed系统中的缓存一般可以直接采用Memcached、Redis、Pika等开源组件,必要时可以根据业务需要进行缓存组件的定制自研。新浪微博也是如此,在Feed平台内,Memcached使用最为广泛,占有60%以上的内存容量和访问量,Redis、Pika主要用于缓存social graph相关数据,自研类组件主要用于计数、存在性判断等业务。结合feed cache架构,INBOX、OUTBOX、CONTENT主要用Memcached来缓存数据,SOCIAL GRAPH根据场景同时采用Memcached、Redis、Pika作为缓存组件,EXISTENCE采用自研的缓存组件Phantom,COUNTER采用自研的计数服务组件CounterService。
Feed缓存架构的设计
上节提到Feed平台缓存模型有6个主要层次,各个缓存层的数据类型、缓存格式各异,而且即便在同一缓存层内,不同业务的数据类型、size、命中率也会不同。因此在为业务数据进行缓存架构设计时,首先需要根据业务需求、数据结构确定缓存访问模型,然后确定缓存组件的选型,最后需要根据待缓存数据的SIZE、命中率、QPS等进一步进行缓存架构的细化调整。
本节将根据待缓存数据的数据类型,并结合具体的Feed业务,对各种缓存结构进行分析。
简单数据类型的缓存设计
一般系统中的大部分数据都是简单KV数据类型,Feed系统也是如此,比如feed content、user信息等。这些简单类型数据只需要进行set、get操作,不会用于特殊的计算操作,最适合以Memcached作为缓存组件。
基于Memcached的海量数据、大并发访问场景,不能简单将多种数据进行混存,而需要首先进行容量评估,分析业务待缓存数据的平均size、数量、峰值读写QPS等,同时结合业务特性,如过期时间、命中率、cache穿透后的加载时间等,最终确定Memcached的容量、分布策略。比如缓存数据的size分布会影响Memcached的slab分布,size差异大的不同数据不能混存,另外对于读写QPS、命中率等要求比较多的业务需要独立部署。
如微博Feed系统上线业务缓存时,会使用如下图所示的指标进行容量规划,通过分析业务数据的size、cache数量、峰值读写等,来确定Memcached的容量大小、节点数、部署方式等。
系统上线初期,需要把业务数据按容量评估属性进行分类。属性接近的数据尽量分配在相同端口的Memcached内存池,此阶段不同业务数据如果缓存属性接近可以缓存在相同的内存池,但缓存属性差异较大的不同类数据就要尽早使用独立端口的内存池了。Memcached内存池一般可以设4~6个节点,通过取模或一致性hash进行分布式存储,如下图所示。
考虑运维性,相同内存池内的节点实例在内存大小、端口、启动参数可以设置为完全相同。
随着业务量和用户量的不断快速增加,基于缓存数据的安全性、访问性能等的考虑,系统初期混部的核心业务缓存数据需要进行分拆及统一规划。微博Feed系统内部也是如此,这一阶段很多业务数据拆分到了独立缓存,Memcached总的缓存实例数很快增加到数百个,一些业务缓存由于机架紧张甚至同时部署在多个IDC。数据总量及访问量大增,缓存数据独立分拆,及其节点大量增加,如果再涉及到多IDC访问,就可能时常遇到机器故障/宕机、网络异常等情况,一些缓存节点不可用,从而导致缓存访问miss,这些miss的请求最终会穿透到DB中。
为了保证服务的可用性,运维人员可以进行服务调配,使每组缓存资源池尽量独立部署在同一个IDC内,避免IDC间的网络异常后抖动的影响。但如果机器故障宕机,可能会导致核心业务的缓存节点不可用,进而大量请求穿透到DB层,给DB带来巨大的压力,极端情况下会引发雪崩,这种情况可以参考微博Feed系统的做法,引入了Main-HA双层架构,如下图所示。
对后端数据的缓存访问,会先访问Main层,如果miss继续访问HA层,如果HA层命中,则返回client结果后,再将value回写到Main层,后续对相同key的访问可以直接在Main层命中。由于Main-HA两层cache中数据不尽相同,通过MAin-HA结构,业务可以获得更高的命中率。同时,即便出现部分Main节点不可用,也可以通过HA层保证缓存的命中率、可用性。因为主要压力在main层,为了降低机器成本,Feed系统一般将其他相邻IDC的Main层互作对方的HA层。
同时还可以进一步采取其他措施,来提升Memcached缓存层的可用性及运维性:
- Memcached内存池提前设置足够的分片数,并采用取模hash分布,在部分节点故障时,立即用新节点替换异常节点,避免数据漂移引入脏数据。
- 根据不同的访问频率、容量,对Memcached实例进行搭配部署,提高机器使用率。
- 对Memcached资源进行统一的监控,并提供各种维度的查询和预警。
随着业务访问量进一步增加,特别是在峰值期间,社交网络中突发事件爆发式的传播,Main-HA结构也会出现问题,主要是部分缓存节点的带宽被打满、CPU/负荷过载,导致Memcached响应严重变慢。微博也曾多次遇到这种问题,通过深入分析,我们发现问题的主因是大量热数据的集中访问,导致缓存服务节点过载,单个节点不能承载热数据的访问量(比如明星发表微博所在的节点),于是我们进一步引入了L1结构。
新的Memcached结构如下图所示,新增部署3组以上的小容量L1缓存,每组L1缓存的容量为Main层的1/3~1/6,构成L1-Main-HA三层架构。client访问时,首先随机选择一个L1缓存池进行访问,如果miss则再按Main→HA的顺序依次访问,如果中途命中数据,则在返回结果后按原路径数据回写。新数据写入缓存时,在写Main、HA内存池的同时,也会写所有的L1内存池。由于L1的内存容量远远小于Main,稍冷的数据会迅速剔除,所以L1中会持续存储最热的数据,同时由于L1有多组,大量热数据访问会平均分散到多个L1。通过L1层的加入,Memcached缓存层节点的负荷、带宽消耗得到有效控制,响应性能得到明显提升。
集合类数据的缓存设计
对于需要进行计算的集合类数据,如Feed系统中用户关系中的关注列表、分组、双向关注、最新粉丝列表等,需要进行分页获取、关系计算,从而满足诸如“共同关注”、“我关注的人里谁也关注了TA”等。如果直接用Memcached作为简单二进制value进行缓存,任何计算类请求都需要获取全量数据在本地进行,即一个微小变更也需要全量获取并更新回写,由于计算请求量大、列表数据量大、变更频繁,对带宽、缓存服务性能都存在严重挑战。
Redis提供了丰富的集合类存储结构,支持list、set、zset(sorted set)、hash,并提供了丰富的api接口用于服务端计算,可以更好的满足上述业务需求。微博Feed系统内部也广泛使用Redis,当前有数千个Redis实例,存储了千亿条记录,每天提供万亿级别的读写操作。
Feed系统使用Redis时,可以采用典型的Master-slave方式进行部署访问,如下图所示。每个业务数据提前分拆到多个hash节点(如8、16个),每个hash节点使用独立的端口,每个hash节点有一个master和多个slave。监控系统实时监控master、slave的状态,在必要时进行主从切换。Master、多个slave都采用域名方式对外暴露服务,这样master、slave变更后,只更新域名服务器即可。因为根据域名访问多个slave可能存在请求不均衡的现象,同时主从切换后client需要能够快速感知,所以需要在client端实现负载均衡和主从切换后的IP感知,微博采用clientBalancer组件来实现,目前已开源。
Redis的数据访问基本都落在内存,缓存数据会以AOF、RDB落在磁盘上,供重启时的数据恢复或主从复制使用,因此单个Redis实例不能分配过大的内存空间,否则会因为重启、rewrite时间特别长而影响服务的可用性。对于普通硬盘而言,Redis加载处理1G数据大概需要一分钟,所以线上Redis单个实例内存最好不要超过20G,避免Redis重启加载数据的耗时过长。同时,因为Redis属于单进程/线程模型,为了提高机器效率,还可以根据CPU核数进行单机多Redis实例部署,具体实例数最高可以为N(CPU)-1。
纯粹使用Redis的master-slave方式在一些场景下没法很好的满足业务需求。
首先是耗时特别大的复杂关系计算性业务,如“我关注的人里谁也关注了TA”业务,需要基于用户关注列表,多次请求计算哪些关注人也关注了目标UID,整个处理过程可能长达数十ms,不仅本次请求处理耗时较长,还会延迟其他请求。即便增加更多的slave,对整个计算过程的性能提升也还是非常有限。于是我们把计算的中间结果缓存到Memcached,同时对活跃用户进行预先计算,从而大幅提升请求性能。
其次在大集合数据进行全量读取的场景,如用hgetAll获取整个关注列表,单次请求可能需要返回数百上千个元素,对单进程/线程模型的Redis,这个响应结果的拼装及发送是一个重量级操作,如果要保证峰值期间的可用性,需要增加多套Redis slave,成本开销较大。此时也可以在Redis前面加一层Memcached(如下图所示),全列表读取采用Memcached的get来抗读,Memcached miss后,才读取Redis或DB层并回写,由于Memcached的多线程实现方式及对纯粹二进制kv的高效读取,可以用较少的Memcached内存换取全列表获取性能的大幅提升。
因此,通过在Redis前端部署Memcached,来缓存在中间计算结果、全量集合数据,可以很好的提升系统的读取性能,还可以减少slave数量来降低整体内存占用。
除了用Memcached做前置缓存,还可以在调用端增加local-cache,进一步提升获取效率。在使用多种组合cache时,多种cache存在穿透、回写策略,这些策略比较通用,可以在client端进行抽象封装,使业务开发者使用起来像使用单个cache一样,从而提高开发效率。
另外对于容量巨大、冷热区分较明显的集合类数据业务,还可以用Pika来代替Redis作为缓存组件。Pika可以将极热数据存到内存、其他数据存放在磁盘,单个节点可以存放百G级别的数据,同时兼容Redis协议,业务方可以无感的从Redis访问切换到Pika访问。
其他类型数据的缓存设计
在海量数据的缓存访问模型中,Feed系统中还有一些业务数据,无法直接当作简单数据类型或集合类数据类型来缓存,也就无法直接使用传统的cache组件(如Memcached、Redis),因为会存在巨大的成本挑战。
首先是存在性判断业务,判断一个用户是否赞了某条feed、是否阅读了某条feed等。如果直接缓存,存储容量几乎是0(用户数*Feed数),即使只存储最近几天的数据,也要耗费巨大的内存。微博的Feed系统最初利用Redis来缓存最近3天的“是否赞”记录,结果发现即使做了极度的存储优化,也要数T的内存空间,而且DB层有大量的穿透访问。
其次还有计数类业务,存储一个key为8字节、value为4字节的计数,Redis需要耗费65+字节,内存有效负荷小于(6+4)/65=18.5%。Feed系统存在海量计数,总内存消耗巨大,如按照传统Redis缓存方案,微博计数每日新增十亿条记录,每日新占用内存高达数百G,这在成本考量上是很难接受的。
对此,需要进行了定制化的缓存组件开发。微博开发了用于存在性判断的cache组件Phantom,内存占用降为原有的10%~20%,读写性能基本不变;开发了用于计数的cache组件CounterService,内存占用降为原有的10%以下,同时通过SSD的引入,单机支撑容量进一步增大1个数量级。下一节,我们将对这些组件进行进一步的说明。
Feed缓存的扩展
通过缓存组件在社交网络系统应用广泛,在Feed系统的性能、可用性保障中也占有重要地位。但直接使用通用cache组件,在运维性、成本控制等方面仍然有各种痛点,于是微博对通用组件进行了大量的优化、扩展,甚至进行全新定制化开发,来满足业务上的需求,预计这些组件在不远的将来,会逐渐出现在开源社区。
Redis的扩展
在Redis2.8版本之前,几乎每次slave连接master都会导致一次全量复制。在一些特殊场景下,如网络异常出现瞬断、Redis升级重启,短时间多个主从同步中断并重连,如果影响的slave数量较大,就会导致网络流量暴增甚至打满,从而导致同步初期网络内所有服务不可用。同时slave同步到全量rdb数据后,在加载过程中也无法对外提供访问(一个10G的Redis实例,加载rdb过程会阻塞10+分钟)。自2.8版本后,Redis通过psync实现了增量复制,一定程度上缓解了主从连接断开会引发全量复制的问题,但是这种机制仍然受限于复制积压缓冲区大小,同时在主库故障需要执行切主操作的场景下,主从仍然需要进行全量复制(Redis未来的4.0版本可能会对此进一步优化)。
于是微博Feed首先调整了Redis的持久化机制,将全量数据有机的保存在RDB和AOF中;然后基于新的持久化机制对同步方式也做了全面调整,实现了完全增量复制,如下图所示。
持久化全量数据的过程如下,通过bgsave构建RDB并落地,同时将当前AOF文件及POSITION也记录在RDB中,新数据写入记入AOF,AOF按固定size不断滚动存储,这样RDB和此后的所有AOF文件构成一份全量的Redis数据记录。为了避免滚动的AOF占用磁盘空间过大的问题,可以在构建新的RDB完毕后,在保留一定余量的基础上将RDB记录之前的AOF文件进行清理(比如将两天前的AOF清理掉),由于Redis是单机多实例部署,微博Feed同时通过定时持久化配置项cronsave,将单机部署的多个Redis实例分散在不同时间点进行错峰持久化。
Slave第一次请求复制时向Master发出sync指令,Master将RDB传给Slave,同时找到RDB记录的AOF及POSITION,将其及之后的所有AOF文件数据传给Slave,即可实现全量同步。后续持续读取最新的AOF文件数据并传给Slave,即可实现实时同步。当因为任何原因发生中断并再次重连时,Slave只需通过syncfrom要告诉Master自己复制位置对应的AOF文件及POSITION,Master即可找到对应位置并将之后的AOF记录持续发给Slave,即可完成增量同步。
微博的关注关系最初保存在Redis的hash结构中,在Redis cache发生miss后,重建关注关系的hash结构是一个重量级的操作。对关注数较多的用户,一次重建过程需要数十毫秒,这对单进程的Redis是无法接受的;同时Redis的Hash结构内存效率不高,为了保证命中率需要的cache容量仍然比较大。于是微博Feed扩展了longset数据结构。通过一个“固定长度开放寻址的hash数组”数据结构,在大大降低内存占用的同时,常规读写性能几乎相同。对于miss后的数据重建,可以通过client端构建longset二进制结构一次性写入,实现O(1)的时间复杂度。
微博Feed还对Redis做了许多其他方面的扩展,如热升级等。各种新功能扩展及版本发布后,会产生运维问题,因为每次升级需要重启,而重启过程需要十分钟以上的服务中断,这对线上业务来说是无法忍受的。于是微博Feed将Redis的核心处理逻辑封装到lib.so文件,缓存数据保存在全局变量中,通过调用lib.so中的函数来操作缓存数据,实现热升级功能。Redis版本升级时,只需要替换新的lib.so文件,无需重新加载数据,实现毫秒级的升级,升级过程基本对客户请求无任何影响。
计数器的扩展
Feed系统内部有大量的计数场景,如用户维度有关注数、粉丝数、feed发表数,feed维度有转发数,评论数,赞数以及阅读数等。钱买你提到,按照传统Redis、Memcached计数缓存方案,单单存每日新增的十亿级的计数,就需要新占用百G级的内存,成本开销巨大。因此微博开发了计数服务组件CounterService。
对于计数业务,经典的构建模型有两种:
- db+cache模型,全量计数存在db,热数据通过cache加速;
- 全量存在Redis中。方案1通用成熟,但对于一致性要求较高的计数服务,以及在海量数据和高并发访问场景下,支持不够友好,运维成本和硬件成本较高,微博上线初期曾使用该方案,在Redis面世后很快用新方案代替。方案2基于Redis的计数接口INCR、DECR,能很方便的实现通用的计数缓存模型,再通过hash分表,master-slave部署方式,可以实现一个中小规模的计数服务。
但在面对千亿级的历史海量计数以及每天十亿级的新增计数,直接使用Redis的计数模型存在严重的成本和性能问题。首先Redis计数作为通用的全内存计数模型,内存效率不高。存储一个key为8字节(long型id)、value为4字节的计数,Redis至少需要耗费65字节。1000亿计数需要100G*65=6.5T以上的内存,算上一个master配3个slave的开销,总共需要26T以上的内存,按单机内存96G计算,扣掉Redis其他内存管理开销、系统占用,需要300~400台机器。如果算上多机房,需要的计数器会更多。其次Redis计数模型的获取性能不高。一条微博至少需要3个计数查询,单次feed请求如果包含15条微博,仅仅微博计数就需要45个计数查询。
在Feed系统的计数场景,单条feed的各种计数都有相同的key(即微博id),把这些计数存储在一起,就能节省大量的key的存储空间,让1000亿计数变成了330亿条记录;近一半的微博没有转、评论、赞,抛弃db+cache方案,改用全量存储的方案,对于计数为0的微博不再存储,如果查不到就返回0,这样330亿条记录只需要存160亿条记录。然后又对存储结构做了进一步优化,三个计数和key一起以共只需要8+4*3=20字节。总共只需要16G*20=320G,算上1主3从,总共也就需要1.28T,只需要15台左右机器即可。同时进一步通过对CounterService增加SSD口占支持,按table滚动,老数据落在ssd,新数据、热数据在内存,1.28T的容量几乎可以用单台机器来承载(当然考虑访问性能、可用性,还是需要hash到多个缓存节点,并添加主从结构)。
计数器组件的架构如下图所示,主要特性如下。
- 内存优化:通过预先分配的内存数组Table存储计数,并且采用double hash解决冲突,避免Redis实现中的大量指针开销。
- Schema支持多列:一个feed id对应的多个计数可以作为一条计数记录,还支持动态增减计数列,每列的计数内存使用精简到bit。
- 冷热数据分离,根据时间维度,近期的热数据放在内存,之前的冷数据放在磁盘,降低机器成本。
- LRU缓存:之前的冷数据如果被频繁访问则放到LRU缓存进行加速。
- 异步IO线程访问冷数据:冷数据的加载不影响服务的整体性能。
通过上述的扩展,内存占用降为之前的5%~10%以下,同时一条feed的评论/赞等多个计数、一个用户的粉丝/关注/微博等多个计数都可以一次性获取,读取性能大幅提升,基本彻底解决了计数业务的成本及性能问题。
存在性判断的扩展
Feed系统中不少“存在性判断”业务,比如判断用户对某条feed是否已“赞”、是否“收藏”、、是否“阅读”等。
由于越新的Feed访问量越大,所以最初考虑直接使用Redis或CounterService来存储这些记录,上线后发现记录数增加太大,仅仅每日新增的阅读记录数就高达百亿级别,Redis的存储结构根本无法支撑,即便用CounterService存储,每天新增的数据也需要占用数百G内存,机器成本开销太大。
对于存在性判断业务,直接记录的机器成本太大,如果采用Bloomfilter算法,在业务能容忍一定误判的前提下,可以大幅的降低内存占用。
Bloomfilter利用bit数组来表示一个集合(如阅读了某条微博的所有用户),可以快速判断一个元素是否属于这个集合。写入前,Bloomfilter是每一位都是零的bit数组,加入元素时,采用k个相互独立的hash函数计算,将元素分布映射的K个位置设置为1。如下图所示,Bloomfilter将X1、X2用3个hash函数映射到bit数组。判断元素是否在集合中存在时,只需要对目标元素做K次hash,如果每次hash计算的bit位都是1,则认为目标元素是集合中的元素,否则就不是集合中的元素。
这种算法存在一定的误判率,因为可能存在一个元素虽然不在集合中,但k次hash都被命中的情况。Bloomfilter占用内存空间极低,且误判率可控,平均每条记录占用1.2字节时仅有1%的误判率,而且这个误判率还可以根据调整记录占用的内存空间来进一步降低。
于是微博Feed基于Bloomfilter算法开发了Phantom,实现架构如下图所示。
主线程采用循环队列实现缓存过期策略,通过将所有key排序并划分区间,依次将所有的key存放在不同的table中并滚动,数值越大的key放在越新的table中。过期时根据配置将最老的表一次性删除或落地,再将该表的内存空间初始化为最新的table进行新数据的存储。Phantom可以很好满足Feed业务场景,因为feed id也是随时间增长,且越新的feed访问量越大。
Phantom落地也采用RDB+AOF模式,执行落地操作时,将循环队列中的所有table写入RDB文件,并在RDB中记录当前时刻的AOF文件名及位置,后续的数据恢复可以直接加载RDB及之后AOF即可。基于这种落地方式,也可以方便的支持完全增量复制。同时,Phantom采用System V共享内存方案,将数据table放在共享内存,进程升级、重启甚至crash都不会丢失内存中的数据。
为了方便业务使用,Phantom采用Redis协议格式,目前主要支持bfset、bfget、bfmset、mfmget四组指令,这样可以通过对Redis client做简单的新指令扩展,即可访问Phantom服务。
通过使用Phantom组件,读写性能基本不变,内存占用却降为原有方案的10%~20%,可以很好的支持Feed系统中存在性判断的需求。
Feed缓存的服务化
通过前面介绍的多级Memcached缓存结构、混合Memcached-Redis结构,以及扩展的计数器、Phantom组件等,可以较好的解决访问性能与访问峰值的压力,大大降低内存占用。不过在缓存的运维性、可管理性方面依然存在不足。不同业务之间只有经验、缓存组件可以复用,在缓存的可用性、运维性方面经常需要各种重复的劳动。
首先,随着业务的发展,Feed缓存的访问量、容量都会非常大。线上有成千上万个缓存节点,都需要在业务前端去配置,导致缓存配置文件很大也很复杂。同时如果发生缓存节点扩容或切换,需要运维通知业务方,由业务方对配置做修改,再进行业务重启上线,这个过程比较长,而且会影响服务的稳定性。
其次,系统开发一般会主要选择一种语言开发,如微博的Feed平台主要采用Java开发,我们基于Java语言定制了缓存层Client来访问各种缓存结构、缓存组件,内置了不少访问策略。这时候,如果公司其他部门也想使用,但由于用的是其他开发语言如PHP,就没法简单推广了。
最后,资源的可运维性也不足,基于IP、端口运维复杂性比较高。比如一个线上机器宕机,在这个机器上部署了哪些端口、对应了哪些业务调用,没有简单直观的查询、管理入口。
于是就需要开始考虑缓存的服务化,主要的方案及策略如下:
- 引入了一个proxy层,用于接受并路由业务对资源的请求,通过cacheProxy支持多种协议(Memcached、Redis等)、多种业务访问,不同业务通过namespace Prefix进行区分。
- 引入了一个updateServer层,用于处理写请求和数据同步。
- 读写及数据复制策略如下图所示,cacheProxy收到资源请求后,对read类请求直接路由到后端资源,对write类请求路由到本idc的updateServer;updateServer首先更新本地资源,成功后再路由到master idc的updateServer。Master idc的updateServer接受所有write请求,记录到AOF,并逐级复制到其他idc的updateServer,实现idc间的cache数据同步。
- 引入cluster,并内嵌了Memcached Cluster、Redis cluster等的访问策略,包括多层的更新、读取,以及miss后的穿透、回写等。
- 接入配置中心,可以方便的支持API化、脚本化管理资源和proxy。
- 接入监控体系,方便查看缓存体系的服务状态。
- Web化管理,通过Web界面管理缓存的整个生命周期。
- 其他服务化策略。
通过上述方案及策略,可以大大简化业务前端的配置,简化开发、运维。业务方只需要知晓namespace,即可实现对后端各种业务的多层缓存进行访问。
对于缓存的服务化治理,不少工作可以逐步展开,采用小步快跑的方式进行循序推进。
接入配置中心
将缓存资源层、proxy层、updateServer层接入了配置中心configServer(微博内部叫vintage),实现缓存、cacheProxy、updateServer的动态注册和订阅。运维把cache资源/updateServer的IP端口、hash算法、分布式策略等也以配置的形式注册在配置中心,cacheProxy、updateServer启动后通过到配置中心订阅这些资源IP及访问方式,从而正确连接并访问后端缓存资源。同时cacheProxy在启动后,也把自身动态的注册到配置中心;client端即可到配置中心订阅这些cacheProxy列表,然后选择最佳的cacheProxy节点访问各种缓存资源。运维也可以在线管理缓存资源,在网络中断、机器宕机,或业务需要进行扩容时,只需要启动新的缓存节点,并通过脚本调用API通知配置中心修改资源配置,就可以使新资源快速生效,从而实现缓存资源管理的API化、脚本化。
IDC数据复制
通过updateServer实现不同IDC间的cache数据复制。不同IDC的updateServer数量相同,AOF文件也相同;updateServer的slave节点记录同步的AOF文件名及位置,在连接断开重连后,通过AOF文件名及位置确定复制位置,从而实现完全增量复制。
利用公有云部署服务应对日常峰值或突发峰值流量时,共用云上的cache资源不再需要持续部署及更新,只需要提前1小时左右部署并完成数据同步,即对公有云业务提供服务,从而较好的降低服务成本。
Web化管理
通过缓存层管理组件clusterManager(微博内部叫captain),把之前的API化、脚本化管理进一步升级为界面化管理。运维可以通过使用clusterManager,界面化管理缓存的整个生命周期,包括业务缓存的申请、审核,缓存资源的变更、扩缩容、上下线等。
监控与告警
把cacheProxy、updateServer、后端各种缓存资源纳入到了Graphite体系,通过日志工具将缓存的访问日志、内部状态推送到Graphite系统,用dashboard直接展现或者按需聚合后展现。
通过clusterManager对缓存资源、cacheProxy等进行实时状态探测及聚合分析,结合Graphite的历史数据,监控缓存资源的SLA,必要时进行监控报警。
在微博Feed内部,clusterManager后续会继续整合(编排发布系统)、DSP(混合云管理平台)等系统,实现了对cacheProxy、updateServer、各种缓存资源一键部署和升级。
开发工具
对于client端,可以基于分布式服务框架(如微博的motan)扩展Memcached、Redis协议,使client与后端资源解耦,后端资源变更不会导致业务系统重启,同时获取服务列表、调整访问策略也更加方便。方便开发者实现面向服务编程,比如微博内部业务开发时,开发者在和运维确定好缓存的SLA之后,通过一行spring配置<weibo:csnamespace="unred-feed" registry="vintage".../>即可访问unread-feed业务对应的后端资源;后续在资源层面的扩缩容、节点切换等不需要开发者接入,运维直接在线变更即可。
部署方式
由于updateServer需要将write类请求落地AOF,并担负复制任务,所以只能选择独立机器进行部署。而对于cacheProxy的部署,微博目前有两种方式,一种是本地化部署,就是跟业务前端部署在一起的,在对cacheProxy构建Docker镜像后,利用jpool管理系统进行动态部署。另外一种是集中化部署,即cacheProxy在独立的机器上部署,由不同的业务方进行共享访问。
处理流程及总结
缓存服务化后的业务处理流程如下图所示。
首先运维通过clusterManager把缓存资源的相关配置注册到configServer。cacheProxy/updateServer启动后通过configServer获取资源配置并预建连接;cacheProxy在启动准备完毕后将自己也注册到configServer,业务方client通过到configServer获取cacheProxy列表,并选择最佳的cacheProxy发送请求指令;cacheProxy收到请求后,对于write类请求直接路由到updateServer,对于read类请求则直接根据namespace选择缓存的cluster,并按照配置中的hash及分布策略进行read请求的路由、穿透、回写。updateServer根据namespace选择cluster完成写请求及数据同步。clusterManager同时主动探测cacheProxy、updateServer、缓存资源等,同时到Graphite获取历史数据进行展现和分析,发现异常后进行报警。
最后,对于服务化的其他一些方面的实践总结如下:
- 对于部分缓存节点故障,Memcached可以通过有多级Cache解决,Redis、CounterService、Phantom等通过master-slave切换、多slave来解决。
- 对于较多缓存/cacheProxy节点异常,我们通过重新部署新节点来替换异常节点,并通过captain在线通知配置中心,进而使新节点快速生效来解决。
- 对于updateServer节点异常,cacheProxy会将请求通过一致性hash均分路由到其他节点处理。
- 对于配置中心的故障,可以通过访问端的snapshot机制,利用之前的snapshot信息来访问cacheProxy或后端缓存资源。
- 对于运维,可以通过Graphite、clusterManager实现标准化运维,节点故障、扩缩容按标准流程进行界面操作即可。运维在处理资源变更时,不再依赖开发修改配置和业务重启,可以直接在后端部署及服务注册。对于是否可以通过系统自动判断故障,并由系统直接部署资源/组件、变更配置,实现自动化运维,微博Feed也还在探索中。
从微博缓存历年的演进经验来看,缓存服务化未来的道理还很长,需要进一步的对各Cache服务组件进行打磨和升级,微博缓存体系架构也会在这条路上不断前行。