消息推送 架构设计
架构目标
构建企业级统一基础推送服务,支持通过多渠道推送,能够统一集成的电子邮件、短信、聊天、钉钉、企业微信和其他公共社交应用:
-
聊天 - 微信Wechat/QQ
-
站内推送通知(移动设备和Web浏览器)
-
站外推送通知(移动设备,APP没有开启)
-
短信(如登录密码、营销活动)
-
电子邮件
-
钉钉
-
企业微信
企业级统一基础推送服务,是一个通用特性,适用于所有现代分布式应用,无论采用何种编程语言和技术。
推送能力的演进
第一阶段(模块化):各自为政、各自封装
企业内部,早期业务量比较少,各系统基本都是有自己的推送模块,类型也是五花八门:
-
聊天模块
-
短信模块
-
电子邮件模块
-
websocket 模块
各自封装模块比较简单,但是实现分散、各系统模块的质量也很难统一保证。
第二阶段(框架化):集成框架
为了减少重复性设计、开发成本, 设计了统一的推送框架。同一套微服务框架,共用一个统一的推送框架。为了解决上述分散实现的问题,企业内部统一实现了一个综合各类推送功能的基础库,供业务方统一调用。
-
聊天基础starter
-
短信基础starter
-
电子邮件基础starter
-
websocket 基础starter
于是,我们把 springboot-starter的逻辑封装到了服务治理框架内,微服务服务启动时,每一个服务对各种的starter进行运维管理、配置管理。
第三阶段(服务化):推送服务
集成到框架,每一套服务,都需要重复性的解决3高问题。
-
推送服务,数据量大,需要解决跨库查询问题
-
推送服务,性能要求高,需要解决高并发问题
大数据量、并发量高,意味着:
-
硬件资源投入大
-
运维成本高
这样的基础服务,需要进行沉淀,剥离,集中成统一的、基础服务,由专门团队负责维护、迭代、运维。降低重复投入、重复建设成本, 真正的降本增效。
于是, 推送框架 演进为 推送服务
推送服务在业务系统中的位置
一个业务应用, 基本上有很多原子服务编排、整合而来,最终构建出一个完整的架构图。
-
接入层,这是外部请求进入内部系统的门户,所有的请求都必须通过 API 网关。
-
应用层,也被称为聚合层,它为相关业务提供聚合接口,并调用中台服务进行组合。
-
原子服务,包括就是原子技术服务,原子业务服务,根据业务需求提供相关的接口。原子服务为整个架构提供可复用的能力。
例如,在B站视频网站平台上,评论服务作为一项原子服务,在B站的视频、文章、社区都需要,那么为了提高复用性,评论服务就可以独立为原子服务,不能与特定需求紧密耦合。
在这种情况下, 评论服务,需要供一种可以适应不同场景的复用能力。
类似的,文件存储、数据存储、推送服务、身份验证服务等功能,都会沉淀为原子服务,业务开发人员,在原子服务基础上,进行编排、配置、组合,可以快速构建业务应用。
推送服务功能要求
-
发送通知
-
对通知进行优先级排序
-
根据客户的保存偏好发送通知
-
支持单个/简单的通知消息和批量通知消息
-
各种通知的分析用例
-
通知消息的报告
推送非功能性需求(NFR)
-
高性能:qps > 1W
-
高可用性(HA):99.99%
-
低延迟:TP99 在10ms以下
-
高扩展:可扩展/可插拔的设计,以便添加更多适配器和提供商,与所有通知模块的API集成以及与客户端和服务提供商/供应商的外部集成
-
跨平台:支持Android/iOS移动设备和桌面/笔记本电脑的Web浏览器
-
自伸缩:可在本地(VMware Tanzu)和 AWS、GCP 或 Azure 等公共云服务上扩展负载
推送系统设计架构
这些解决方案设计的考虑因素和组件包括:
1. 通知客户端
这些客户端通过 API 调用请求单个和批量消息。它们将向简单和批量通知服务发送通知消息。
-
简单通知客户端:专门用于发送单个通知的客户端,负责向用户发送单一通知。这些客户端通常用于向特定用户发送重要通知,例如密码找回或账户异常提醒。
-
批量通知客户端:专门用于发送批量通知的客户端,负责向用户批量推送通知。这些客户端通常用于需要通知大量用户的场景,例如企业内部通知或营销活动。
2. 通知服务
作为入口点的这些服务,通过暴露 REST API 与客户端互动。它们负责构建通知消息,通过调用"模板服务"。这些消息将使用"验证服务"进行验证。
-
简单通知服务:该服务将提供 API,主要负责处理简单通知请求,提供与后端服务集成的 API,以便将通知发送给用户。这种服务通常用于处理较少的通知请求,例如针对特定用户或事件的简单通知。
-
批量通知服务:该服务将提供 API,主要负责处理批量通知请求,提供与后端服务集成的 API,以便批量发送通知。这种服务通常用于处理大量的通知请求,例如企业内部的批量通知或营销活动的批量推送。
此服务还将管理通知消息。它将发送的消息持久化到数据库并维护活动日志。可以使用这些服务的 API 重新发送同一条消息。它将提供添加/更新/删除和查看旧消息和新消息的 API。它还将提供 Web 仪表板,该仪表板应具有筛选选项,以根据不同的条件(如日期范围、优先级、模块用户、用户组等)筛选消息。
3. 模板服务
此服务主要负责所有可用的一次性密码(OTP)、短信、电子邮件、聊天以及其他推送通知消息的模板管理。它还提供了 REST API,以便创建、更新、删除和管理模板。除此之外,它还将提供一个用户界面(UI)的仪表板页面,使用户能从网络控制台检查和管理各种消息模板。
4. 消息分发服务
-
定时分发服务:
该服务将提供API来安排立即或指定时间的通知。可以是以下任何一种:
-
秒
-
分钟
-
每小时
-
每天
-
每周
-
每月
-
每年
-
自定义频率等。
还可能有其他自动触发的服务,基于预定时间进行消息触发。
-
消息验证服务:
此服务全权负责根据业务规定和预期格式对通知信息进行核实。批量通知需由授权的系统管理员同意。
-
消息优先级服务:
该服务负责对通知进行优先级排序,分为高、中、低三个等级。通知信息具有较高的优先级和有时间限制的到期时间,它们将始终以较高优先级发送。
"通用出口处理器"会接收消息并根据相同的优先级从高、中和低三个不同的队列中发送和处理。
在非工作时间,可以以低优先级发送批量通知。在交易过程中的应用程序通知可以发送到中优先级,如电子邮件等。企业可以根据通知的重要性确定优先级。
5. 事件优先级队列(消息队列)
此服务提供事件中心功能,负责接收通知服务的高、中、低三个优先级的信息。它会根据业务的优先级来发送和接收通知。企业可以根据通知的重要性来设定优先级。服务内部包含三个主题,用于根据业务优先级接收和发送通知:
-
低优先级:主要用于在非工作时间发送批量通知。
-
中优先级:适用于在交易过程中发送的应用程序通知,如电子邮件等。
-
高优先级:通知信息具有较高的优先级和有时间限制的到期时间,它们将始终以较高优先级发送。
6. 通用出站处理程序
该服务通过轮询事件优先级队列来接收事件中心中的通知信息,并根据其优先级进行处理。高优先级的通知会优先处理"高"队列,依次类推。最后,它通过事件中心将通知信息发送到特定的适配器。此外,该服务还从用户选择服务中获取目标用户/应用程序,以便进行通知的分发。
在处理过程中,通用出口处理器会根据事件的优先级进行相应的操作,确保重要事件得到优先处理。这样,企业可以根据通知的优先级来确定处理顺序,从而提高通知的处理效率。除此之外, 通用出站处理程序,还能进行消息的进一步按照通道类型进行分发:
该服务将消息发送到各种支持的适配器。这些适配器会根据不同的设备(如桌面/移动设备)和通知类型(如短信/OTP/电子邮件/聊天/推送通知)进行转换。
7. 通知适配器
这些转换器将从消息队列(rocketmq)接收传入信息并根据其所支持的格式传递给外部合作伙伴。
以下是一些转换器,根据需求可以增加更多:
-
QQ 通知适配器服务
-
微信Wechat 聊天通知适配器服务
-
应用内通知适配器服务
-
电子邮件适配器服务
-
短信适配器服务
-
OTP 适配器服务
8. 通道供应商
这些是外部的 SAAS(云上/本地)服务提供商,利用它们的基础设施和技术实现实际的通知传递。它们可能是像 AWS SNS、MailChimp 等的付费推送通道服务。
-
QQ 供应商集成服务
-
微信Wechat 供应商集成服务
-
应用推送通知供应商集成服务
-
电子邮件供应商集成服务
-
短信供应商集成服务
9. 用户选择服务
该服务提供选择目标用户和各种应用程序模块的功能。这可能包括将批量消息发送到特定的用户组或不同的应用程序模块。可能是 AD/IAM/eDirectory/用户数据库/用户组,具体取决于客户的偏好。
在服务内部,它将使用"用户配置文件服务"API 来消费和检查客户的通知偏好。
10. 用户配置文件服务
此服务提供各种功能,包括管理用户配置文件及其偏好设置。还管理内部用户标识,和外部通道标识之间的关联关系
-
钉钉用户标识 和 用户标识 关联关系
-
企业微信 用户标识 和 用户标识 关联关系
-
用户和邮箱的关联关系
-
等等
它还将提供取消订阅通知以及通知接收频率等功能。"通知服务"将依赖于此服务,以便根据用户的通知偏好来发送通知。此外,该服务还可以用于统计和分析用户对通知的偏好,以帮助企业优化通知策略。
11. 分析服务
该处理器将负责执行所有的分析工作,识别通知使用情况、趋势并生成报告。它将从分析数据库(Cassandra)和通知数据库中提取所有最终的通知信息,用于分析和报告目的。
以下是一些用例:
-
每天/每秒的总通知数
-
哪个通知系统使用最频繁
-
消息的平均大小和频率
-
基于优先级过滤消息等等...
12. 通知跟踪器
此服务将持续监视事件中心队列并跟踪所有发送的通知。它捕获通知的元数据,如传输时间、传送状态、通信渠道、消息类型等。
13. 通知数据库:MySQL数据库集群
通知数据库,用于存储库用于存储所有通知信息,包括发送时间、状态等。它包括一个数据库集群,其中领导者用于执行所有写操作,读取操作则在读取副本/跟随者上进行。这个数据库群集将持久化所有通知,供分析和报告使用。
它基于“写入更多,读取更少”的理念。它能提供良好的性能和低延迟,适应大量的通知,因为它内部处理大量的写操作,并与其他数据库节点同步,保持高可用性和可靠性的冗余数据/消息。在任何节点崩溃的情况下,消息将始终可用。当然,也可以是 nosql 集群型数据库。
百亿级访问量,如何做缓存架构设计
分布式缓存系统架构基础知识
在分布式系统中,缓存系统的构建是关键一环,其基础架构主要包括以下几个重要部分:
-
数据分片
为了防止单一节点压力过大,导致系统崩溃,我们需要对缓存数据进行分片。这样,不同的缓存节点就可以分别管理不同的数据片,并负责处理相关的读写请求。这种分片的方式,既可以有效地分散数据压力,也能提高系统的响应速度。
-
负载均衡
当客户端发起请求时,我们需要选取一个适当的缓存节点来处理。这就需要负载均衡算法的介入,该算法可以根据当前系统的负载情况、网络拓扑结构等因素,来选择最适合处理该请求的节点。这样,既可以保证客户端请求的及时响应,也能避免某个节点压力过大,影响系统的整体运行。
-
容错处理
在分布式环境下,由于网络通信等问题,可能会导致缓存节点间的故障。为了提高系统的可用性,我们需要设计一些容错机制。例如,数据备份可以防止数据丢失,故障转移可以确保即使某个节点出现问题,系统也能继续运行。这些容错机制,可以有效地提高系统的稳定性和可靠性。
-
数据一致性
由于数据在多个节点上进行分布存储,因此需要确保数据的一致性。为了实现这一目标,我们需要采用一些协议和算法来处理并发读写操作。例如,我们可以使用分布式事务协议来确保数据的原子性和一致性,或者使用乐观锁算法来避免并发写操作的冲突。这些手段,都可以有效地保证数据的一致性,确保系统的正确运行。
整体架构设计
在构建一个具有高可用性和高并发能力的分布式缓存系统时,以下几个方面是必须要考虑的:
-
缓存服务节点
一个缓存服务节点负责处理对应的数据分片,同时提供各类缓存操作的接口。通过使用多个缓存服务节点,我们可以构建一个缓存集群,从而提升系统的可扩展性和可用性。
-
共享存储设备
共享存储设备用于存放缓存数据和元数据,这些数据能够被多个缓存服务节点所共享。常见的共享存储设备包括分布式文件系统、分布式块存储以及分布式对象存储等。
-
负载均衡设备
负载均衡设备的作用是将客户端的请求分发到适当的缓存服务节点,并且可以根据缓存服务节点的负载状况进行动态调整。
-
配置服务
配置服务负责维护和管理缓存系统的元数据信息,包括节点信息、分片信息、负载均衡规则以及故障转移设置等。
实现原理
一个分布式缓存系统的实现需要涉及到很多技术,如数据分片算法、负载均衡算法、容错处理算法、一致性算法等。
数据分片算法
在分布式缓存系统中,数据分片算法是一项关键技术。常见的数据分片算法包括哈希算法、范围算法、顺序算法以及随机算法等。哈希算法作为最常用的分片算法之一,它能够通过哈希函数将数据的键值映射为一个数字,进而根据这个数字进行分片。
-
负载均衡算法
负载均衡策略在缓存系统中起着至关重要的作用。它需要综合考虑缓存节点的负载状况、网络拓扑以及客户端请求等多个因素。常见的负载均衡策略包括轮询策略、最少连接数策略、IP 哈希策略以及加权轮询策略等。
-
一致性算法
在分布式环境下,数据一致性是一个核心问题。当多个客户端同时对同一个键值进行读写操作时,需要确保数据的一致性。
常见的一致性算法包括 Paxos 算法、Raft 算法以及 Zab 算法等。
-
强一致性和最终一致性的区别
强一致性是指所有客户端在读取同一个键值时,都能得到相同的结果。而最终一致性是指,如果客户端在缓存节点中写入了数据,那么在未来的某个时间点,所有的缓存节点最终会拥有相同的数据。
强一致性可以通过复制数据来实现,但这可能会对系统的性能和可用性产生影响。最终一致性则可以通过异步复制和协议算法来实现,从而在一定程度上提高系统的性能和可用性。
-
如何处理故障和异常
在分布式缓存系统中,故障和异常是不可避免的。为了确保系统的可用性,我们需要采取一系列措施来处理故障和异常,如数据备份、故障转移、数据恢复以及监控告警等。同时,还需要定期对系统进行维护和优化,以确保系统的高可用性和高性能。
分布式缓存系统架构总结
如何构建高可用的分布式缓存系统,包括架构基础知识、整体架构设计、实现原理、一致性模型以及故障处理等方面。一个优秀的缓存系统需要具备高可用性、高性能,并能保证数据的一致性和容错性。
百亿级访问,微博如何做缓存架构?
微博作为拥有 1.6 亿 + 日活跃用户,每日访问量达百亿级的大型社交平台,其高效且不断优化的缓存体系在支撑庞大用户群的海量访问中起到了至关重要的作用。首先是微博在运行过程中的数据挑战,然后是Feed系统架构,接下来会着重分析Cache架构及演进,最后是总结、展望。
微博的访问流量挑战
微博Feed平台系统架构
整个系统可以分为五个层次,最顶层是终端层,例如 Web 端、客户端(包括 iOS 和安卓设备)、开放平台以及第三方接入的接口。接下来是平台接入层,主要是为了将优质资源集中分配给关键核心接口,以便在突发流量时具备更好的弹性服务能力,提高服务稳定性。再往下是平台服务层,主要包括 Feed 算法、关系等。然后是中间层,通过各种中间介质提供服务。最底层是存储层,整个平台架构大致如此。
1. Feed timeline
-
构建流程
当我们在日常生活中使用微博时,例如在主页或客户端刷新一下,会看到最新的十到十五条微博。那么这个过程是如何实现的呢?
刷新操作会获取到用户的关注关系,例如1000个关注,就会获取这 1000 个 ID,根据这1000 个UID,获取每个用户发布的微博,同时会获取这个用户的Inbox,就是她收到的特殊的一些消息。
比如分组的一些微博,群的微博,下面她的关注关系,她关注人的微博列表,获取这一系列微博列表之后进行集合、排序,从而获取需要的微博 ID,再对这些ID去取每一条微博ID对应的微博内容。
如果这些微博是转发的,还会获取原微博,并根据原微博获取用户信息,通过原微博取用户信息,进一步根据用户的过滤词对这些微博进行过滤,过滤掉用户不想看到的微博,留下这些微博后,再进一步来看,用户对这些微博有没有收藏、赞,做一些flag设置,还会对这些微博各种计数,转发、评论、赞数进行组装,最后才把这十几条微博返回给用户的各种端。
这样看,用户一次请求会得到十几条记录,后端服务器需要对几百甚至几千条数据进行实时处理并返回给用户,整个过程对Cache体系强度依赖。因此,Cache架构设计优劣会直接影响微博系统的表现。
2. Feed Cache架构
然后我们看一下Cache架构,它主要分为六层:
-
第一层 Inbox,这部分主要包括分组微博和群主微博,Inbox 的数量相对较少,主要采用推送方式。
-
第二层 Outbox,每个用户都会发布常规微博,这些微博都会存储在 Outbox 中,根据存储的 ID 数量,实际上被划分为多个缓存,通常情况下大约有 200 多,如果是长的大概是2000条。
-
第三层 Social Graph,就是一些关系,包括关注、粉丝和用户。
-
第四层 Content,是内容,每条微博的一些内容都存储在这里。
-
第五层 Existence,是存在性判断,例如微博中,某条微博是否被点赞过,有些明星曾经表示自己在某条微博上点赞了,但实际上没有,这会引起一些新闻,实际上是因为她在某个时刻点赞后忘记了。
-
第六层 Counter,是计数,包括微博的评论、转发等计数,以及用户的关注数、粉丝数等数据。
微博Cache架构及演进
1. 简单KV数据类型
接下来,我们会重点讨论微博Cache架构演进过程,在微博刚上线时,我们把它作为一个简单的KV键值对数据类型来存储,我们主要采取哈希分片存储在MC池中,而,上线几个月后,我们发现了一些问题,比如由于某些节点机器宕机或其他原因,大量的请求会穿透Cache层达到DB上去,从而导致整个请求速度变慢,甚至DB僵死。
为了解决这个问题,我们迅速对系统进行了改造,增加了一个高可用(HA)层。这样,即使主层出现某些节点宕机或无法正常运行的情况,请求也会进一步穿透到 HA 层,而不会穿透到DB层,这样可以确保在任何情况下,系统的命中率都不会降低,从而显著提高系统服务的稳定性。
目前,这种做法在业界得到了广泛应用。然而,有些人直接使用哈希技术,这其实是有一些风险的。例如,如果一个节点(如节点 3)宕机了,主层会将其摘除,并将节点 3 的一些请求分配给其他节点。如果这个业务量不是很大,数据库可以承受住这种压力。但是,如果节点 3 恢复后重新加入,它的访问量会回来,如果因为网络或其他原因再次宕机,节点 3 的请求又会分配给其他节点。这时就可能会出现问题,之前分配给其他节点的请求已经没有人更新,如果没有被及时删除,就会出现数据混乱的情况。
微信和微博之间存在很大差异。实际上,微博更像是一个开放的广场型业务。例如,在突发事件或某明星恋情曝光等情况下,瞬间流量可能会激增至 30%。在这种情况下,大量的请求会集中出现在某些节点上,使得这些节点变得异常繁忙,即使使用 MC 也无法满足如此巨大的请求量。这时,整个 MC 层就会成为瓶颈,导致整个系统变慢。为了解决这个问题,我们引入了 L1 层,它实际上是一个主关系池。每个 L1 层的大小约为 Main 层的六分之一、八分之一或十分之一,具体取决于请求量。在请求量较大时,我们会增加 4 到 8 个 L1 层。这样,当请求到来时,首先会访问 L1 层。如果 L1 层命中,请求就会直接访问;如果没有命中,请求会继续访问 Main-HA 层。在突发流量情况下,L1 层可以承受大部分热请求,从而减轻微博的内存压力。对于微博来说,新数据会变得越来越热门,而只需要增加很少的内存,就可以应对更大的请求量。
-
Key Point
-
Write:多写
-
Read:逐层穿透,miss 回写
-
Memcached 为主
-
层内 HASH 节点不漂移,miss则穿透
-
多组 L1 读取性能升 峰值流量成本降读写策略
-
Json/xml --> Protocol Buffer
-
QuickLZ 压缩
-
总结一下,我们通过简单的 KV 数据类型存储,主要以 MC 为主,层内 HASH 节点不漂移,Miss 则穿透到下一层读取。通过多组 L1 读取性能提升,可以应对峰值和突发流量,同时降低成本。对于读写策略,我们采用多写,读的话采用逐层穿透,如果 Miss 就进行回写。对于存储的数据,我们最初采用 Json/xml,2012 年后直接采用 Protocol|Buffer 格式,对于较大的数据,我们使用 QuickL 进行压缩。
2. 集合类数据
-
业务特点
-
部分修改
-
分页获取
-
资源计算:联动计算
-
类型:关注,粉丝,分组,共同关注,XX也关注
-
-
方案:Redis
-
Hash 分布,MS,cache/storage
-
30+T 内存,2-3万亿rw/day
-
关于简单的 QA 数据,我们已经知道如何处理。但是对于复杂的集合类数据,例如关注了 2000 个人,新增一个人就涉及到部分修改。有一种方式是把 2000 个 ID 全部拿下来进行修改,这样会带来更大的带宽和机器压力。还有一些分页获取的需求,例如我只需要取其中的第几页,比如第二页,也就是第十到第二十个,能否不要全量把所有数据取回去。还有一些资源的联动计算,例如关注某些人里面的 ABC 也关注了用户 D,这种涉及到部分数据的修改、获取和计算,对于 MC 来说,它实际上并不擅长。所有的关注关系都存在 Redis 里面,通过 Hash 分布和储存,一组多存的方式来进行读写分离。现在 Redis 的内存大概有 30 个 T,每天都有 2-3 万亿的请求。
-
Redis扩展 (Longset)
-
Long型开放数组,Double Hash 寻址
-
Client 构建数据结构,elements 单次写入
-
Lsput:填充率过高,由client重建
-
Lsgetall --> Lsdump
-
少量而超热数据:mc抗读
-
在使用 Redis 的过程中,我们还是遇到了一些其他问题。例如,从关注关系来看,我关注了 2000 个 UID,有一种方式是全量存储,但是微博有大量的用户,有些用户登陆比较少,有些用户特别活跃,这样全部放在内存里面成本开销是比较大的。所以我们就把 Redis 使用改成 Cache,只存活跃的用户,如果你最近一段时间没有活跃,会把你从 Redis 里面踢掉,再次有访问到你的时候再把你加进来。但是,Redis 的工作机制是单线程模式,如果它加某一个 UV,关注 2000 个用户,可能扩展到两万个 UID,两万个 UID 塞回去基本上 Redis 就卡住了,没办法提供其他服务。因此,我们扩展了一种新的数据结构,两万个 UID 直接开了端,写的时候直接依次把它写到 Redis 里面去,读写的整个效率就会非常高,它的实现是一个 long 型的开放数组,通过 Double Hash 进行寻址。
-
Redis其他扩展
-
热升级: 10+分钟>毫秒级
-
AOF : Rotate
-
RDB : Pos of AOF
-
全增量复制
-
落地/同步速控
-
对于 Redis,我们还进行了一些其他的扩展。例如,之前的一些分享中,大家可以在网上看到,我们把数据放到公共变量里面,整个升级过程,我们测试 1G 的话加载要 10 分钟,10G 大概要十几分钟以上,现在是毫秒级升级。对于 AOF,我们采用滚动的 AOF,每个 AOF 是带一个 ID 的,达到一定的量再滚动到下一个 AOF 里面去。对于 RDB 落地的时候,我们会记录构建这个 RDB 时,AOF 文件以及它所在的位置,通过新的 RDB、AOF 扩展模式,实现全增量复制。
3. 其他数据类型-计数
-
业务特点
-
单key有多计数 (微博/用户多种计数)
-
Value size较小 (2-8个字节)
-
每日新增记录近十亿级,总记录千亿级
-
单次请求多条kv
-
接下来,我们将讨论其他一些数据类型,例如计数。实际上,在互联网公司的每个领域,计数都是必不可少的。对于一些中小型业务,MC 和 Redis 已经足够满足需求。然而,在微博中,计数具有一些特殊性,例如一条微博可能有多个计数,包括转发数、评论数和点赞数。此外,一个用户可能有粉丝数、关注数等各种数字。由于计数的特性,其 Value size 通常较小,大约为 2-8 个字节,最常见的是 4 个字节。每天新增的微博大约有十亿条记录,总的记录数量则更为庞大。在一次请求中,可能需要返回数百条计数。
-
选型1:Memcached
-
MC 剔除,重启数据丢失
-
大量计数为0,如何存
-
-
选型2:Redis
-
内存有效负荷低
-
访问性能
-
-
最终方案:自研CounterService
-
Shema 支持多列,按bit分配
-
Tables 预分配,double-hash寻址
-
内存降为1/5-1/15以下
-
冷热分离,SSD 存放老数据,老热数据入LRU
-
落地 RDB + AOF全增量复制
-
单机:热数据百亿级,冷数据千亿级
-
4. 计数器-Counter Service
最初,我们选择使用 Memcached,但它存在一个问题,即当计数超过其容量时,会导致部分计数被剔除,或在宕机或重启后计数将丢失。此外,有许多计数为零,此时如何存储,是否需要存储,以及如何避免占用大量内存等问题需要考虑。微博每天有十亿计数,仅存储零值就会占用大量内存。如果不存储,可能会导致穿透到数据库层,从而影响服务性能。
从 2010 年开始,我们转向使用 Redis 进行访问。然而,随着数据量的不断增加,我们发现 Redis 的内存利用率相对较低。一条 KV 大约需要 65 个字节,但实际上我们只需要 8 个字节来存储一个计数,加上 Value 的 4 个字节,实际有效存储只有 12 个字节。其余的 40 多个字节被浪费。这还只是单个 KV 的情况,如果一个 Key 有多个计数,浪费的空间会更多。例如,四个计数,一个 Key 占 8 个字节,每个计数占 4 个字节,总共需要 16 个字节,但实际上只用了 26 个字节。
然而,使用 Redis 存储需要约 200 个字节。后来,我们通过自主研发 Counter Service,将内存使用量降低到 Redis 的五分之一至十五分之一以下。同时,我们实现了冷热数据分离,将热数据存储在内存中,将冷数据放入 LRU 中。当冷数据重新变热时,将其放到 RDB 和 AOF 中,实现全增量复制。通过这种方式,单机可以存储百亿级的热数据和千亿级的冷数据。
整个存储架构的概述如下:顶部是内存,底部是 SSD。内存中预先划分为 N 个 Table,每个 Table 根据 ID 的指针序列分配一定范围。当有新的 ID 过来时,首先找到它所在的 Table,然后进行增加或减少操作。当内存不足时,将一个小 Table 导出到 SSD 中,并保留新的位置以供新的 ID 使用。
有人可能会有疑问,如果在某个范围内,ID 的计数原本设定为 4 个字节,但由于微博的热度,计数超过了 4 个字节,变成了很大的一个计数,这种情况如何处理?对于超过限制的计数,我们将其存放在 Aux dict 中。对于存储在 SSD 中的 Table,我们有专门的 IndAux 进行访问,并通过 RDB 方式进行复制。
5. 其他数据类型-存在性判断
-
业务类型需求
-
检查是否存在(阅读 赞)
-
单条记录量小,value 1bit (0/1)
-
总数据量巨大,大量value为0
-
每日新增数量大 千亿级
-
除了计数之外,微博还有一些业务,如存在性判断。例如,一条微博是否已获得点赞、阅读或推荐。如果用户已经阅读过该微博,就不再向其显示。这类数据的特点是,虽然每条记录非常小(例如,Value 只需 1 个 bit),但总数据量巨大。例如,微博每天发布约 1 亿条新微博,阅读量可能达到上百亿、上千亿。如何存储这些数据是一个大问题。而且其中许多存在性为 0。前面提到的问题再次出现:0 是否需要存储?如果存储,每天将存储上千亿条记录;如果不存储,大量请求将穿透 Cache 层到达 DB 层,任何 DB 都无法承受如此大的流量。
-
选型1:Redis
-
单条kv:65 bytes
-
每日新增内存 6T (不考虑HA)
-
-
选型2:CounterService
-
单条kv:9 bytes
-
每日新增内存 900G (不考虑HA)
-
我们也进行了一些选型,首先直接考虑我们能不能用Redis,单条KV65个字节,一个KV可以8个字节的话,Value只有1个bit,这样算下来我每日新增内存有效率是非常低的。第二种我们新开发的Counter Service,单条KV Value1个bit,我就存1个byt,总共9个byt就可以了,这样每日新增内存900G,存的话可能就只能存最新若干天的,存个三天差不多快3个T了,压力也挺大,但比Redis已经好很多。
-
最终方案:自研 Phantom
-
Table 分段预分配,段内 bloomfilter
-
每条kv:1.2 bytes (1%误判)
-
每日新增内存:120G < 800G < 6T
-
我们最终方案采用自己开发Phantom,先采用把共享内存分段分配,最终使用的内存只用120G就可以,算法很简单,对每个Key可以进行N次哈希,如果哈希的某一个位它是1,如果进行3次哈希,三个数字把它设为1,把X2也进行三次哈希,后面来判断X1是否存在的时候,进行三次哈希来看,如果都为1就认为它是存在的,如果某一个哈希X3,它的位算出来是0,那就百分百肯定不存在的。
-
Phantom 系统架构
-
数据存放共享内存,重启不丢失数据
-
落地 RDB+AOF
-
兼容 Redis 协议
-
它的实现架构比较简单,把共享内存预先拆分到不同Table里面,在里面进行开方式计算,然后读写,落地的话采用AOF+RDB的方式进行处理。整个过程因为放在共享内存里面,进程要升级重启数据也不会丢失。对外访问的时候,建Redis协议,它直接扩展新的协议就可以访问我们这个服务了。
6. 小结
-
关注点
-
集群内高可用
-
集群内扩展性
-
组件高性能
-
存储成本
-
小结一下,到目前为止,关注Cache集群内高可用、它的扩展性,包括它的性能,还有一个特别重要就是存储成本,还有一些我们没有关注到,比如21运维性如何,微博现在已经有几千差不多上万台服务器等等。
7. 进一步优化
面向资源/组件管理
-
如何简化运维?
本地配置模式
-
如何快速变更?
常规峰值、突发流量
-
如何快捷、低成本应对?
业务数据分类多
-
如何独立管控SLA?
业务关联资源太多
-
如何简化开发?
8. 服务化
-
本地 Confs --> 配置服务化
-
configServer 管理配置/服务,避免频繁重启
-
资源/服务管理 API 化
-
变更方式:script 修改,smart client 异步更新
-
采取的方案首先就是对整个Cache进行服务化管理,对配置进行服务化管理,避免频繁重启,另外如果配置发生变更,直接用一个脚本修改一下。
-
Cache 访问
-
Proxy 化
-
-
IDC 数据一致性
-
Collecting/replication
-
-
ClusterManager
-
脚本化 --> Web 界面化
-
服务校验 业务SLA
-
面向服务管控资源
-
-
服务治理
-
扩容、缩容
-
SLA 保障
-
监控报警
-
故障处理
-
-
简化开发
-
屏蔽Cache资源细节
-
单行配置访问
-
服务化还引入Cluster Manager,实现对外部的管理,通过一个界面来进行管理,可以进行服务校验。服务治理方面,可以做到扩容、缩容,SLA也可以得到很好保障。另外对于开发来说,现在就可以屏蔽Cache资源。
总结与展望
最后简单总结一下,对于微博Cache架构来说,从它数据架构、性能、储存成本、服务化不同方面进行优化增强。
参考文献
https://blog.csdn.net/java_cpp_/article/details/130663371
https://blog.csdn.net/k6T9Q8XKs6iIkZPPIFq/article/details/108271182
多级缓存 架构设计
高并发场景分析
一般来说, 如果 10Wqps,或者20Wqps ,可使用分布式缓存来抗。比如redis集群,6主6从:主 提供读写,从 作为备,从 不提供读写服务。6主6从架构下,1台平均抗3w-4W并发,还可以抗住 18Wqps -24Wqps。并且,如果QPS达到100w,通过增加redis集群中的机器数量,可以扩展缓存的容量和并发读写能力。6主6从的架构,可以扩容到 30主30从
同时,缓存数据对于应用来讲都是共享的,主从架构,实现高可用。
问题:如何解决缓存热点(热key)问题?
一旦出现缓存热点现象,例如有 10 w流量访问同一 Key,并集中于某一个 Redis 实例,可能会导致该实例的 CPU 负载过高。
这种情况下,即便增加 Redis 集群数量,也无法根本解决问题。那么解决热 key 问题的有效手段,到底是什么呢?非常有效的手段之一,本地缓存。其主要原因是:本地缓存避免了 Redis 单个缓存服务器的高负载。同时,本地内存缓存拥有更快的访问速度,因为数据直接存储在应用程序的内存中,无需通过网络传输数据。
本地缓存的实质:是多副本, 空间换时间。通过复制多份缓存副本,将请求分散,可以缓解由缓存热点引发的单个缓存服务器压力。
引入本地缓存又会带来哪些问题呢?主要问题有:
-
数据一致性问题
-
本地缓存数据污染问题
通用多级缓存方案
京东服务端应用多级缓存架构方案, 其实是一种常用的2级缓存的架构方案:
(1)L1一级缓存:本地缓存guava
(2)L2二级缓存:分布式缓存redis
2级缓存的架构方案的缓存访问流程:
-
请求优先打到应用本地缓存
-
本地缓存不存在,再去redis 集群拉取,同时缓存到本地
以上流程,类似于cache aside 旁路缓存模式。具体的缓存访问流程,大致如下:
有关 DB与Redis 缓存之间的 cache aside 旁路缓存模式的数据一致性问题,请参考:Java高并发核心编程 卷3 加强版
那么,引入本地缓存又会带来哪些问题呢?主要问题有:
-
数据一致性问题
-
本地缓存数据污染问题
多级缓存数据一致性问题
如何解决多级缓存数据一致性问题呢?主要是 多级缓存同步。主要使用发布订阅模式、或者底层组件RPC通讯机制,完成本地cache与Redis缓存的数据同步。
-
京东采用的是 发布订阅模式
-
有赞采用的 底层组件RPC通讯机制
-
J2cache采用的是 发布订阅模式
首先看看 发布订阅, 深入下去,也有两种模式:
-
推送模式:每个频道都维护着一个客户端列表,当发送消息时,会遍历该列表并将消息推送给所有订阅者。
-
拉取模式:发送者将消息放入一个邮箱中,所有订阅该邮箱的客户端可以随时去收取。在确保所有客户端都成功收取完整邮件后,才会删除该邮件。
首先,来看看 京东的数据一致性问题:多级缓存同步方案
-
运营后台保存数据,写入Redis缓存,同时利用 Redis 的发布订阅功能发布信息。
-
业务应用集群作为消息订阅者,接受到运营数据消息后,删除本地缓存。
-
当 C 端流量请求到达时,若本地缓存不存在,则从 Redis 中加载缓存至本地缓存。
-
防止极端情况下,Redis缓存失效,通过定时任务,将数据重新加载到Redis缓存。
其次,再看看 有赞的数据一致性问题: 使用 通信模块 实现每个节点 之间的数据一致性。
另外,行业内有部分成熟的二级缓存中间件,主要使用消息队列rocketmq /kafka,实现本地缓存与分布式缓存之间的数据一致性。
京东发布订阅缓存同步组件选型
京东使用redis的 channel(频道)机制,完成本地cache与Redis缓存的数据同步。在 Redis 的channel(频道)机制,发布订阅模式是一种推送模式。
-
通过使用 SUBSCRIBE 命令,可以订阅一个或多个频道,以便在相关频道发布消息时接收到通知。
-
PUBLISH 命令则用于向一个或多个频道发送消息。当某个频道有消息发布时,所有订阅该频道的客户端都会收到相应的通知。
另外,Redis 的发布订阅模式是异步的。当有消息发布到某个频道时,Redis 会异步地将消息推送给所有订阅该频道的客户端。这就意味着,客户端不会因为等待消息而阻塞,而是继续执行其他任务,仅在需要接收消息时才去获取。这种异步方式有助于提高系统的并发性和效率。
什么是缓存污染问题?
引入本地缓存又会带来哪些问题呢?主要问题有:
-
数据一致性问题
-
本地缓存数据污染问题
前面咱们看了 数据一致性问题。再来看看,缓存污染问题。
缓存污染问题指的是留存在缓存中的数据,实际不会再被访问了,但是又占据了缓存空间。
如果这样的数据体量很大,甚至占满了缓存,每次有新数据写入缓存时,还需要把这些数据逐步淘汰出缓存,就会增加缓存操作的时间开销。
因此,要解决缓存污染问题,最关键的技术就是能识别出这些只访问一次或是访问次数很少的数据,在淘汰数据时,优先把他们筛选出来淘汰掉。所以,解决缓存污染的核心策略,叫做
缓存中主要常用的缓存淘汰策略:
-
random 随机
-
lru
-
lfu
(1)random 随机:是随机选择数据进行淘汰 主要包括volatile-random和allkeys-random。随机淘汰,比如volatile-random和allkeys-random ,无法把不再访问的数据筛选出来,可能会造成缓存污染。
(2)LRU:LRU算法的基本思想是,当缓存空间不足时,要淘汰最近最少使用的缓存项,即淘汰访问时间最长的数据项。这样可以保证最常用的数据项始终保留在缓存中,从而提高系统的响应速度和吞吐量。由于LRU策略只考虑数据的访问时效,对于只访问一次的数据来说,LRU策略无法很快将其筛选出来。
(3)LFU策略再LRU策略基础上进行了优化,在筛选数据时,首先会筛选并淘汰访问次数少的数据,然后针对访问次数相同的数据,再筛选并淘汰访问时间最久的数据。
在实际业务应用中,LRU和LFU两个策略都有应用。
LRU和LFU两种策略关注的数据访问特征各有侧重,LRU策略更加关注数据的时效性,而LFU策略更加关注数据的访问频次。
通常情况下,实际应用的负载具有较好的时间局部性,所以LRU策略的应用会更加广泛。
但是,在扫描式查询的应用场景中,LFU策略就可以很好地应对缓存污染问题了,建议你优先使用。
京东本地缓存用的是guava,那么策略是LRU,LRU策略更加关注数据的时效性, 具有较好的时间局部性,使用于大部分数据场景。
大部分本地缓存用的建议使用caffeine,那么策略是LRU+LFU,既 具有较好的时间局部性,使用于大部分数据场景。也具有关注数据的访问频次, 避免扫描式查询的应用场景中数据污染问题。
多级缓存架构的注意事项
-
由于本地缓存会占用 Java 进程的 JVM 内存空间,因此不适合存储大量数据,需要对缓存大小进行评估。
-
如果业务能够接受短时间内的数据不一致,那么本地缓存更适用于读取场景。
-
在缓存更新策略中,无论是主动更新还是被动更新,本地缓存都应设置有效期。
-
考虑设置定时任务来同步缓存,以防止极端情况下数据丢失。
-
在 RPC 调用中,需要避免本地缓存被污染,可以通过合理的缓存淘汰策略,来解决这个问题。
-
当应用重启时,本地缓存会失效,因此需要注意加载分布式缓存的时机。
-
通过发布/订阅解决数据一致性问题时,如果发布/订阅模式不持久化消息数据,如果消息丢失,本地缓存就会删除失败。所以,要解决发布订阅消息的高可用问题。
-
当本地缓存失效时,需要使用 synchronized 进行加锁,确保由一个线程加载 Redis 缓存,避免并发更新。
参考文献
https://it.sohu.com/a/696701644_121438385
https://blog.csdn.net/crazymakercircle/article/details/128533821