作者:vivo 互联网平台产品研发团队- shi jianhua、sun song
是一个核心的基础服务,对于基础服务而言就是生命线。在这篇文章中,将与大家分享我们在帐号稳定性建设方面的经验和探索。
vivo帐号是用户畅享整个vivo生态服务的必备通行证,也是生态内各业务开展的基石。伴随公司业务快速增长,帐号系统目前服务的在网用户已达到2.7亿,日均调用量破百亿,作为一个典型的三高(高性能、高并发、高可用)属性的系统,帐号系统的稳定性显得尤为重要。而要保障系统的稳定性,我们需要综合考虑多方面因素。本文将从应用服务、数据架构、监控三个维度出发,分享帐号服务端在稳定性建设方面的经验总结。
《架构整洁之道》书中将软件的价值总结为“行为”、“架构”两个维度。
行为价值:让机器按照某种指定方式运转,给系统的使用者创造或提高利润。
架构价值:始终保持软件的灵活性,以便让我们可以灵活地改变机器的工作行为。
行为价值描述的是当下,对于用户最直观的感受就是易用性、功能丰富程度等。好的行为价值能够吸引用户,进而对服务提供者能有一个正向回报。
架构价值描述的是未来,指服务系统的内在结构、技术体系、稳定性等,这些价值虽然对用户是不可见的,但它决定了服务的延续性。
应用服务的治理目的是让系统保持“架构价值”,进而延续“行为价值”,我们在“服务治理”章节将重点介绍两点内容:“服务拆分”、“关系治理”。
2.1 服务拆分
服务拆分是指将一个服务拆分为多个小型、相对独立的微服务。服务拆分有非常多的收益,包括提高系统的可扩展性、可维护性、稳定性等等。下面将介绍我们在系统建设过程中遇到的拆分场景。
2.1.1 基于组织架构调整拆分
康威定律 ( conway's law) 由马尔文·康威于1967年提出:"设计系统的架构受制于产生这些设计的组织的沟通结构。"。即系统设计本质上反映了企业的组织结构,系统各个模块间的关系也反映了企业各个部门之间的信息流动和合作方式,内容示意如下图(图1):
图1 (图片来源:work life)
组织架构调整是企业发展过程中常常需要面对的重要挑战,其原因通常与市场需求、业务变化、协同效率相关。如果不及时跟进服务拆分,跨团队协作不畅、沟通困难等问题就会接踵而来。本质上,这些问题都源于团队分工和核心目标的差异。
案例介绍
vivo在互联网早期就开展了游戏联运业务,游戏联运全称是游戏联合运营,具体指的是游戏研发厂商以合作分成的方式将产品嫁接到vivo平台上运营。起初vivo互联网团队规模较小,和帐号相关的业务统一归属于现在的系统帐号团队。在游戏联运业务中,我们提供为不同的游戏创建对应的子帐号(即游戏小号)的服务,子帐号下包括游戏角色等相关信息。
随着游戏业务快速发展,游戏事业部成立,其核心目标是服务好游戏用户。而系统帐号的目标,则是要从整个vivo生态出发,为我们的手机用户,提供简单、安全的使用体验。在组织架构变动后不久,两个团队便快速达成了业务边界共识,并完成了对应服务的拆分。
图2(游戏小号拆分)
2.1.2 基于稳定性述求拆分
针对组织架构调整导致的服务拆分,属于外因,其内容范围和时间点相对容易确定。而基于对稳定性的考虑进行的拆分,属于内因,则需要在恰当的时机进行,以避免对业务正常版本迭代造成影响。在实践过程中,拆分策略上我们更多是基于核心流程的拆分。
(1)核心行为拆分
一个业务系统中,都会存在核心流程。核心流程承担了系统中核心的工作。以帐号为例:注册、登录、凭证校验,毫无疑问就是系统中核心的流程,我们将核心流程独立拆分,主要为了下面两个目标达成:
服务隔离
避免不同流程之间的相互影响。以帐号凭证校验流程为例,验证逻辑固定,架构上只依赖分布式缓存。一旦和其它流程耦合,除了带来更多外部依赖风险外,其它流程修改、发版同样会影响到凭证校验流程的稳定性。
资源隔离
服务拆分使得服务器资源得以隔离,这种隔离为横向资源扩容提供了更加灵活的可能性。例如,对于核心流程服务,资源可以做适当冗余,动态扩缩容的策略可以定制等。
如何识别核心行为?
有些核心流程是显而易见的,比如帐号中的注册和登录,但有些流程需要进行识别和判断。我们的实践是根据“业务价值”和“调用频度”这两个维度进行判断,其中“业务价值”可以选择与核心业务指标相关联的流程,而“调用频度”则对应流程的执行次数。将这两个维度叠加,我们可以得到一个四象限矩阵图。下图是帐号业务的矩阵示意图(图3)。最核心的流程位于图中右上角(价值高、调用高),这里有个原则,位于对角线的流程要尽可能的相互隔离;
图3(矩阵图)
(2)最少要素聚合
服务并非拆分得越细越好,过于细致的拆分会导致服务数量过多,反而增加了系统的复杂度和维护成本。为了避免过度拆分,我们可以对流程中依赖的业务要素进行分析,并适当进行流程间的聚合。以注册为例,流程最简化的情况下,只需围绕帐号四要素(用户名、密码、邮箱、手机号)完成即可。而对于换绑手机号流程,它依赖于密码或原手机号的验证(四要素中的其中两项)。因此,我们可以将注册和手机号换绑这两个流程合并到同一个服务中,以降低维护成本。
图4(最小要素闭环)
(3)整体拆分示意
早期的帐号主服务包含了帐号登录、注册、凭证校验、用户资料查询/修改等流程。如果需要对服务进行拆分,我们应该首先梳理核心流程。按照上面图4的示意,我们应该先完成登录、注册、凭证校验与用户资料的拆分。用户资料主要包含昵称、头像等扩展信息,不包括帐号主体的四个要素(用户名、密码、邮箱、手机号)。
对于登录、注册、凭证校验这三个行为,随着已注册用户数量的增加,登录和凭证校验的频度远远超过注册。因此,我们进行了二次拆分,将登录和凭证校验拆分为一个服务,将注册拆分为另一个服务。拆分后的结构如下图所示(图5)。
图5
(4)业务价值变化
业务价值是动态变化的,因此我们需要根据业务的变化来适时地调整服务拆分的结构。实践案例有帐号信息服务中实名模块的拆分。早期实名信息只是用在评论场景中,因此其价值和昵称、头像等信息区别不大。但随着游戏业务深度开展以及国家防沉迷的要求,如果用户未实名认证,则无法提供相关服务。实名信息对于游戏业务的重要性等同于凭证校验。因此,我们将实名模块拆分为独立的服务,以便更好地支持业务的发展和变化。
2.1.3 拆分实施方案
在对成熟业务进行服务拆分时,稳定性是关键。必须确保对业务没有任何影响,并且用户无感知。为了降低拆分实施的难度,我们会采取先拆服务(图6),再拆数据的方案。在服务拆分时,为了进一步降低风险,可以考虑下面两点做法:
服务拆分阶段,只做代码迁移,不做代码重构
引入灰度能力,通过可控的流量进行梯度验证
图6
需要再次强调灰度的重要性,用可控的流量去验证拆分后的服务。这边介绍两种灰度实现思路:
在应用层中做转发,具体处理细节:为新服务申请一个内网域名,在原有服务内进行拦截实现请求转发的逻辑。
在架构的更加前置的环节,完成流量分配。例如:在入口网关层或反向代理层(如nginx)进行流量转发配置。
2.2 关系治理
服务之间的依赖关系对于服务架构来说是至关重要的。为了使服务间的依赖关系清晰、明确,我们可以采用以下几个优化措施:首先服务之间的依赖关系应该是层次化的。每个服务应该处于一个特定的层次,依赖关系应该是层次化的,避免跨层级的依赖关系。其次依赖应该是单向的,要符合adp(acyclic dependencies principle)无依赖环原则。
2.2.1 adp原则
adp(acyclic dependencies principle)无依赖环原则,下图(图7)中红色线标识出来的依赖关系都是违背了adp原则的存在。这种关系会影响“部署独立”的目标达成。试想下a、b服务互相依赖的场景,一次需求需同时对a、b相互依赖的接口改造,发版顺序应该是被依赖的先部署,相互依赖就进入了死循环。
图7
2.2.2 关系处理
在服务架构中,服务之间的关系可以根据依赖的强度分为弱依赖和强依赖。当a服务依赖于b服务时,如果b服务异常故障时,不会影响a服务的业务流程,那么这种依赖关系被称为弱依赖;反之,如果b服务出现故障会导致a服务无法正常工作,那么这种依赖关系被称为强依赖。
(1)强依赖冗余
针对强依赖的关系,我们会采用冗余的策略,去保障核心服务流程的稳定性。在帐号系统中,“一键登录”、“实名认证”都采用了同样的方案。这种方案的实施前提是要能找到提供相同能力的多个服务,其次服务本身需要做一些适配工作,如下图(图8)增加流量分配处理模块,作用是监控依赖服务的质量,动态调整流量分配比例等。
图8
除了采用动态流量分配的实现,还可以选择相对简单的主次方案,即固定依赖其中一个服务,当该服务出现异常或熔断时,再依赖另一个服务。这种主次方案可以在一定程度上提高服务的可用性,同时也相对简单易行。
(2)弱依赖异步
异步常用方案是依赖独立的消息组件(图9),把原本同步调用的处理改为消息发送。这样做除了能实现依赖关系的解耦,同时能增加系统吞吐量。回顾adp原则中我们提到的循环依赖,是可以通过消息组件进行解耦规避的。
图9
需要提醒的是使用消息组件会增加系统的复杂性,异步天生要比同步更复杂,需要额外考虑消息乱序、延迟、丢失等问题。针对这些问题可以尝试下面方案:不在服务流程中直接发送消息,而是依赖服务流程产生的数据,进行消息生产,如下图(图10)。帐号系统中使用场景有帐号注册、注销后的业务通知。
图10
选择kafka组件是可以提供消息的有序性的特征。方案中从binlog采集、到推送消息,可以理解成是一个数据传输服务(data transmission service,简称dts),在vivo内部有自研的“鲁班平台”实现了dts能力,对于读者朋友可以借助类似开源的canal项目达成同样的效果。
3.1 缓存
在高并发的系统架构中,缓存是提升系统性能最有效的方式之一。缓存可以分为本地缓存和分布式缓存两种。在帐号系统中,为了应对不同的场景,我们采用了本地缓存和分布式缓存结合的方式。
3.1.1 本地缓存
本地缓存就是将数据缓存到服务本地内存中,好处是响应时间快、不受跨进程通信等外部因素影响。但弊端也非常多,受服务内存大小的限制,以及多节点的一致性问题等,在帐号中使用的场景是缓存相对固定不变的数据。
3.1.2 分布式缓存
分布式缓存能有效规避服务内存大小限制等问题,同时提供了相对数据库更好的读写性能。但是引入分布式缓存同样会带来额外问题,其中最突出的就是数据一致性问题。
(1)数据一致性
处理数据一致性的方案有很多选择,根据帐号使用的业务场景,我们选择的方案是:cache aside pattern。cache aside pattern 具体逻辑如下:
数据查询:从缓存取,命中直接返回,未命中则从数据库取并设置到缓存。
数据更新:先更新数据到数据库,后直接删除缓存。
图11 (cache aside pattern示意图)
处理的核心要点是数据更新时直接删除缓存,而不是刷新缓存。这是为了规避,并发修改可能导致的数据不一致。当然cache aside pattern是不能杜绝一致性问题。
主要是下面两种场景:
第一种情况删除缓存异常。这种要么可以尝试重试,或直接依赖设定合理的过期时间来降低影响。
第二种情况是理论上的可能性,概率非常低。
一个读操作,没有命中缓存,到数据库中取数据,此时来了一个写操作,写完数据库后删除了缓存,然后之前的读再把老的数据写入缓存。说它理论上存在是因为条件过于苛刻,首先需要发生在读缓存时缓存失效,而且并发一个写操作。然后我们知道数据库的写操作通常会比读操作慢得多,而发生问题是要求读操作必需在写操作前进入数据库操作,而又要晚于写操作更新缓存,所以说它只是理论上的可能性。
基于上述情况综合考虑,我们选择的是cache aside pattern方案,尽可能去降低并发脏数据发生的概率,而非通过复杂度更高的2pc或是paxos协议保证强一致性。
(2)批量读操作优化
尽管使用缓存可以显著提升系统的性能,但并不能解决所有的性能问题。在帐号服务中,我们提供了用户资料查询能力,根据用户标识获取用户的昵称、头像、签名等信息。为了提高接口的性能,我们将相关信息缓存在redis中。然而,随着用户量和调用量的快速增长,以及批量查询的新增需求,redis的容量和服务接口的性能都面临着压力。
为了解决这些问题,我们采取了一系列有针对性的优化措施:
首先,我们在将缓存数据写入redis前,先对其进行压缩。这样可以减小缓存数据的大小,从而降低了数据在网络传输和存储过程中的开销。
接着,我们更换默认的序列化方式,选择了protostuff作为替代方案。protostuff是一种高效的序列化框架,相比其他序列化框架具有以下优势:
高性能:protostuff采用了零拷贝技术,直接将对象序列化为字节数组,避免了中间对象的创建和拷贝,从而大幅度提高了序列化和反序列化的性能。
空间效率:由于采用了紧凑的二进制格式,protostuff可以将对象序列化为更小的字节数组,从而节省了存储空间。
易用性:protostuff是基于protobuf开发,但对java语言的支持更加完善,只需要定义好java对象的结构和注解,就可以进行序列化和反序列化操作。
序列化的方案还有很多,例如thrift等,关于它们的性能对比,可以参考下图(图12),读者可以自己项目实际情况进行选择。
图12(图片来源:google code)
最后,是redis pipeline命令的应用。pipeline可以将多个redis命令打包成一个请求,一次性发送给redis服务器,从而减少了网络延迟和服务器负载。redis pipeline的主要作用是提高redis的吞吐量和降低延迟,尤其是在需要执行大量相同redis命令的情况下,效果更加明显。
以上优化最终给我们带来了一半的redis容量的节省和5倍左右的性能提升,但同时也增加了大概10%的额外cpu消耗。
3.2 数据库
数据库相对于应用服务,在高并发系统更容易成为系统的瓶颈。它无法做到和应用一样便利的横向扩容,所以数据库的规划工作一定要打提前量。
3.2.1 读写分离
帐号业务特点是读多写少,所以最早遇到的压力是数据库读的压力,而读写分离架构(图13)可以有效降低主库的负载。读写分离方案中由主库承担全部写流量,从库和主库共同承担读流量。从库同时可以配置多个,通过多个从库来分担高并发的查询流量
图13
保留主库的读能力,是因为 “主从同步延迟” 问题存在,对不能接受数据延迟的场景继续查询主库。 读写分离方案的好处是简单,几乎没有代码改造成本,只需要新增数据库的主从关系。缺点也比较多,比如无法解决tps(写) 高的问题,从库也不能无节制添加,从库数量过多会加重延迟问题。
3.2.2 分表分库
读写分离肯定是解决不了所有的问题,一些场景需要结合分表分库的方案。分表分库的方案分为垂直拆分和水平拆分两种,vivo互联网技术公众号有过分库分表方案的详解,这边不在赘述,有兴趣的可以前往阅读 详谈水平分库分表 。在这边和大家聊聊分表分库动机及一些辅助决策的经验总结。
(1)分表解决什么问题
笼统的回答就是解决大表带来的性能问题。具体影响在哪里?怎么判断是不是要分表?
① 查询效率
大表最直接给人的感受是会影响查询效率,我们以 mysql-innodb为例分析下具体影响。innodb存储引擎是以b tree结构组织索引,以主键索引(聚簇索引)为例,它的特性是叶子节点存放完整数据,非叶子节点存放键值 页地址指针。这边的节点,对应到存储就是数据页的概念。数据页是innodb最小存储单元,默认大小为16k。一个聚簇索引的示意图(图14)如下:
图14
聚簇索引树上做数据的查询操作,是从根节点出发,节点内做二分查找来确定树下一层的数据页位子,到达叶子节点后同样通过二分查找来定位数据。从这个查找过程,我们可以看出对查询的影响,主要取决于索引树的高度。多一个层高,会多出一次数据页的load(内存不存在发生)和一次数据页内的二分查找。
想评估数据量对查询的影响,可以通过估算索引树的高度和数据量的关系来达成。前面提到非叶子节点存放键值 页地址指针,页地址指针大小固定是6个字节,那么一个非叶子节点存储量计算公式大概是 pagesize/(index size 6)。叶子节点存储的是具体数据,存储的数量公示可以简化为pagesize/(data size),这样树的高度和数据量的关系如下:
根据公式,我们以自增bigint字段做主键,单行数据大小1k,数据页大小为默认16k为例,3层的树结构容纳的数据量大概在两千万样子。这个方式只是辅助你做估算,如果要确定真实值,是可以借助一些工具直接在数据页中获取。
了解了这些后再看分表方案背后的逻辑。水平拆分是主动控制表中的数据量,来达到控制树高度的目的。而表的垂直拆分是增加叶子节点的容量,这样相同高度的树,可以容下更多数据。
② 表结构调整效率
业务变更偶尔会牵扯到表结构调整,例如:新增字段、调整字段大小、增加索引等等。你会发现表的数据量越大,一些ddl 的执行时间会越来越长,有些线上大表增加字段的执行时间可能会花费数天。具体哪些ddl会比较耗时呢?可以参考mysql凯发娱发k8官网关于online-ddl的操作说明(详情),关注操作是否涉及rebuilds table,如果涉及,数据量越大越大越费时。
除了表结构调整、数据查询这些影响外,数据量越大对于失误的容错性越差,这对于稳定性保障工作是个隐患。
基于上面的原因描述,业务中劲量把索引树的高度控制在3层,这时候表数据量级大概在千万级别。如果数据量增长超过这个预期后,就要评估数据表对业务的重要程度、使用场景等,然后适时进行表的拆分。
(2)分库解决什么问题
分库通常理解解决的是资源瓶颈的问题。单个数据库,即使硬件再强大,它也是有连接数、磁盘空间等上限问题。分库后就可以将不同的实例部署在不同的物理机上,突破磁盘、连接数等资源瓶颈,同时能提供更好的性能表现。
分库的处理除了基于资源限制的考虑外,帐号中还会结合可靠性等述求,进行数据库的拆分。这样可以把核心模块和非核心模块隔离,减少之间的相互影响。目前帐号系统的拆后情况示意如下(图15)。
图15
拆分后的帐号主体库,是最核心业务库。库里围绕帐号四要素(用户名、密码、邮箱、手机号)组织数据,这样帐号的核心流程登录、注册的数据依赖就不再受其他数据的干扰。这种拆分方式属于垂直拆分,将表根据一定的规则划入不同的库。
(3)数据迁移实践
分库分表方案实施中代价最大的是数据迁移,帐号系统在垂直分库实践中主要利用mysql的主从复制机制来降低数据迁移的成本。先让dba在原有主库上挂新的从库,将表数据复制到新库中。为保证数据一致性,线上切库时分三步处理(图16)。
step1:禁写主库,确保主从数据同步一致 ;
step2:断开主从,新库成为独立主库;
step3:应用完成新库路由切换(开关实现)。
图16
这些操作在dba的配合下,可以把对业务的影响控制在分钟级,影响相对可控。而且整个方案代码层面改造成本也非常小。唯一要注意的是一定要做上线前的演练。
除了上面垂直分库的场景外,帐号还经历过单个核心业务表数据量过亿后的水平拆分,这个场景复制迁移的方案就不适用。拆分是在18年底实施的,方案借助开源的canal实现数据迁移。整体方案如下(图17)。
图17
监控治理的目的,是让我们实时了解系统状况,及时进行故障的预警,并能辅助快速的问题定位。早期帐号就经历过,告警内容不全面,研发不能及时收到告警。有时收到了告警,但因为原因指向不明,告警问题排查困难,处理时间过长等。随着持续治理,经过多次线上的验证,我们能做到问题感知灵敏,处理迅速。
4.1 监控内容
我们把监控的内容归纳为三个维度(图18),从上到下分别是:
上层的应用服务监控:监控应用层的状况,例如:服务访问的吞吐量、返回码(失败量)、响应时间、业务异常等;
中层独立组件监控:独立组件涵盖服务运行的中间件,例如:redis(缓存)、mq(消息)、mysql(存储)、tomcat(容器)、jvm 等;
底层系统资源监控:监控主机和底层资源,例如:cpu、内存、硬盘 i/o、网络吞吐等;
监控内容涵盖三层的原因,如果你只关注应用服务,如果问题发生,你只是知道了一个结果,无法进行快速定位分析,只能根据经验排查各项的可能性,这样的故障处理速度是没办法忍受的。而往往上层的应用的告警,可能就是一些组件或则底层系统资源的异常引起的。假设我们遇到服务响应时长告警时,如果这时候有对应jvm fgc 时长告警、或myql的慢查sql告警,这就很方便我们快速的明确优先排查的方向,确定后续的处理措施。
图18
组件监控、底层资源监控除了有支撑定位问题的作用外,另一个目的是可以提前排除隐患。很多隐患一开始对应用服务影响比较有限,但这种影响会随着调用量等外部因数变化慢慢放大。
监控内容的维护,三个维度的监控内容中,底层系统资源和中层独立组件,内容相对固定,不需要经常维护。而上层的应用服务监控中涉及业务异常的,就需要随着功能版本迭代,不停的做加减法。
4.2 关联指标聚合
三个维度监控的内容,因为公司内分工的存在,研发、应用运维、系统运维,容易出现各管各的,监控指标也可能会分散在不同系统,这样是非常不利于问题定位分析。最好的监控系统是能将这三个维度的指标进行打通,这样问题分析处理会更加高效。下面是我们在跟踪“偶发性dubbo服务线程满”问题时的经历。偶发性问题排查的难点,不能拿一次的分析结果定论。借助公司业务监控系统的帮助,我们排除了redis等中间组件的影响后,我们就开始将关注点放在了主机指标上,为了方便问题定位,我们自己做了 虚拟机反推 宿主物理 再到宿主机上所有虚拟机的关键指标(cpu、io、net)聚合,效果如下(图19)。经过多次验证后确定了宿主机上个别应用磁盘io异常过高导致。
图19
4.3 调用方区分
应用服务监控中,都会将服务接口调用量top n作为重点监控对象。但在中台服务中,只到接口的颗粒度还不够,需要细化到能区分调用方的维度,去监控具体某个接口上top n的调用方增长趋势。这样做的好处,一是监控的粒度越细,越能提前感知到风险。二是一旦确认是不合理流量时,也可以有针对性地做流控等处理。
本文从服务拆分、关系治理、缓存、数据库、监控治理几个维度,介绍了帐号系统在稳定性建设方面做的一些经验总结。然而,仅仅做到这些是远远不够的。稳定性建设需要一套严谨科学的工程管理体系,涉及内容不仅包括研发的设计、开发和维护,还应该包含项目团队中各个角色的工作内容。总而言之,稳定性建设需要在整个项目生命周期中不断进行细致的规划和实践。我们也希望本文所述的经验和思路,能够对读者在实践中起到一定的指导作用。
参考文献:
thrift-protobuf-compare - benchmarking.wiki
conway’s law:
the little-known principle that influences your work more than you think