进度服务是什么
要说明进度服务是什么,首先要说明进度是什么。得到app主要提供内容服务,用户在使用内容服务的时候,就会产生进度。如图,进度的元素无处不在,收听百分比,已听完等。
进度服务是提供进度数据上报和进度信息查询的服务。一般流程如下图:
为什么重构
了解了什么是进度服务。下面来说明一下为什么要重构:
- 不完善的重构
- 不明确的边界。
不完善的重构
在做本次重构之前,实际上已经进行过一次重构了。但是由于第一次重构不完善,导致将变得更加复杂了。
首先,经过第一次重构之后,进度项目变得更多了。在原有生产者、消费者和查询服务的基础上,新增了新的消费者和查询者。因此当时除了重新设计新的项目,还需要维护额外的5个老项目。同时,库表也增多了。由于采用分库分表,数据库多达6个,表多达5千个。如此多的库表,在刚接手时,维护服务异常痛苦。
第三个是上报的接口本来一个就可以满足需要,最终采用的是不同业务不同接口,导致上报多达5个。
不明确的边界
进度服务该包含哪些东西,什么是进度服务的边界,之前是完全没有概念的。一有需求就加进来,导致服务越来越臃肿。
最后,更可怕的是,之前维护老进度项目的人都走了。
如何重构
根据进度服务的流程从三个部分进行解决:
- 简化内部服务
- 统一上报接口
- 简化第三方打点。
如下图,绿色的框表示进度上游客户端上报,红色的表示进度服务内部系统,蓝色是针对下游服务打点。
简化内部服务
针对以上三个部分的工作,首先选择简化内部服务。只有一个稳定的内部服务,才能保证系统的稳健。简化内部服务主要分为四步: 1)抽象数据结构和流程 2)双写 3)迁移数据 4)切换服务。
抽象数据结构和流程
首先是抽象数据结构和流程。要抽象数据结构先来看看得到服务业务和资源的关系。听书和电子书只包含一个资源,而课程包含文章和音频资源。产品要求学完任意一个就算学完该类资源。因此抽象出面向资源的子资源进度,面向业务的主资源进度。如下图
针对于包含两种资源的业务,如何按照要求进行合并呢?一般分为两种方案,一种是查询的时候,进行合并,另一种是写入的时候合并。如下图:
查询合并的好处在于只需要记录子资源,在查询的时候进行合并处理进度。缺点是针对多个资源查询翻倍,批量时更是需要处理多对数组的合并工作,比较复杂。写入合并的好处是查询方便,缺点是需要写入两张表。由于考虑到后者比较简单,且进度状态不可回退,采用写入合并的方案。
下面看看进度服务的流程。对于资源进度,无论什么业务,都会按照三个阶段的方式记录数据。因此抽象出统一的上报流程。如下图:
上报流程经历的流程比较长,任何阶段都可能出现异常。需要采用重试,以保证数据的一致性。如果采用全流程重试就需要更大范围的事务,会影响性能。因此采用阶段重试的方法,阶段方法内部进行事务保证。那么就需要一个灵活的流程控制。
流程代理就是来完成这个工作的。如下图,红色的部分是阶段方法的接口标准,需要根据通用参数完成对应的阶段业务处理。其中分别包含三个阶段方法的具体实现。绿色的部分是通用参数的封装,整个过程中通过通过参数传递数据。下面的蓝色部分是流程代理,其中包含一个通用方法的map,处理函数主要包含方法数组和通用参数。处理流程是通过循环执行传入的阶段函数,完成流程灵活处理。
双写
通过抽象数据结构和流程,完成内部处理的简化。这样就可以写数据了。由于之前的重构导致项目比较复杂,不可能一次性替换,因此采用双写的方式。双写有两种方案:
- 消费topic
- 双写到新接口。
消费topic主要用于新的数据结构与老的结构基本一致,这样可以更加快速的接入数据。
双写到新接口。由于新的数据结构与老的结构差异比较大。因此在原有的消费者里面进行数据适配到新服务的接口,最终完成双写的目的。
通过双写完成了新数据的写入,要保证进度数据的完成性,就要导入老数据了。
3迁移数据
首先进行存储选型。原有的mysql存在6个库和5千多张表,实在是无法继续维护。因此采用mongo数据库。首先mongo比较便捷,代码无需做分表分库的处理,通过mongo的自动分片完成数据的切分。另外是mongo的高并发,写入最高可达到20wqps。第三点是可弹性扩容,再也不用担心容量的问题了。最后是有mongo大佬。选择mongo作为数据库,针对于大约100亿数据,要进行迁移就需要选择工具了。由于需要进行一个业务类型的合并以及数据的补全,最终采用跑脚本的方式。
脚本的流程分为三个部分,首先是根据环境和数据库参数初始化环境,然后是根据开始分区和结束分区进行表级的并发处理。并发处理中主要是在限定时间段按照id进行循环批量扫描的方式进行。如果扫描到数据则写入mongo,没有就表明表已经扫描完毕直接退出。如此循环至所有分区处理完毕。
迁移数据最重要的是断点续传的问题,主要有一下几个办法。
- 打印扫描的id,中断时可根据最后一个id作为起始id进行继续执行。
- 标记数据来源。
- 记录自增id。
另一个问题是先双写还是先导数据。双写的好处在于不需要增量更新数据,缺点在于对于有状态的数据无法批量处理。先导数据的好处在于可以批量新增数据,但是双写后,需要增量更新导入数据时间节点到双写开始节点的数据。
迁移数据最重要的是数据的验证。
- 脚本记录累计查询数和写入数
- 对比数据总数
- 抽样对比数据
- 大数据校验趋势
最终,通过抽象数据,双写,迁移数据和验证完成了简化内部服务的目标。
统一上报接口
内部服务稳定之后,开始处理上游上报。进度服务提供各个业务统一的上报接口,并完成数据上报。同时客户端上报架构变更为业务负责数据的组装,上报组件上报数据,并完成打包、重试等工作。如下图所示:
与客户端上报统一之后,上报数据的准确性和可追溯有了保障,这样查询问题就更加简单了。
第三方打点
客户端上报不仅仅记录进度数据,同时需要及时触发第三方效益,也称为第三方打点。首先了解一下调用关系。当用户上报时,通过主动通知,下游方可以及时完成效益处理,通过回调获取进度数据。用户还可以在具体的业务页面进行被动处理。
第三方打点流程主要包括三个部分,检验条件、组装参数和调用接口。
随着第三方打点越来越多,不想每次重复开发,因此需要通过配置开发简化打点工作。根据对打点流程进行分析,从其中三个方面抽象出配置化所需要的数据结构。
对于判断条件是根据进度内部相关的数据进行判断。这些内部数据就是进度服务的数据结构,也是进度服务的领域数据。所有的判断只能是进度服务领域内的数据,例如进度大小、资源类型等。
调用接口主要包含服务名和请求地址。请求参数是进度模块所有包含对外的通用数据结构,这样能够保证进度参数更加灵活。
根据上面三个过程的说明,抽象出对应的配置化数据结构如下。其中事件表示不同类型的进度上报模块。控制条件表示频率的控制,由于进度服务请求比较大,需要对频率进行控制,防止下游被打挂。
下游服务配置好配置数据后,程序就可以根据配置数据进行逻辑处理。配置化的处理流程如下图,首先服务启动时会初始化配置管理器。配置管理器通过查询配置化数据,实例化对应事件的检测器、条件解析器、调用器等组件。当有上报事件发生时,会从配置管理器中获取对应的处理器,然后通过检测器判断是否执行调用,如果需要执行则调用对应的调用器发送请求,如果不需要则返回,执行其他的处理器继续执行。调用器会调用一个新的调度服务,完成重试等调用工作。
最终,通过抽象数据结构和流程、双写、迁移数据完成了简化内部服务的目标,保证了内部服务的稳定。通过统一接口简化上游上报,保证了数据上报的准确性和简单。通过配置化开发简化了第三方打点流程。通过以上三部,完成进度服务的最终重构。
经验
重构中遇到很多问题,下面从mongo超时、kafka积压和海亮数据处理说说遇到的问题。
mongo超时问题
mongo超时问题主要数据均衡、IO限制和索引命中。
由于使用的是mongo集群。需要手动设置分片键,这样数据才会根据分片键决定存储在集群的具体物理机器上。如下图,假设以user_id为分片键,按照范围来存放到chunk,如上图6个chunk盒子。那么对应范围的user_id的所有数据会落到对应的chunk上。配置服务会记录分片键和chunk的关联,以及chunk与物理机器的关联。默认情况不做分片,所有的chunk会写到主节点上,即所有的数据会写到主节点。很悲剧,行为数据所有的数据都写到了主节点。很快1T的硬盘不够用了。很明显,上图这种情况,数据就发生了倾斜。如果开启均衡,数据就会从a机器上转移到b或c机器上。此时机器io可能会被打满,导致超时。
另外阿里云选择的搭建mongo的ECS配置比较低,IO不够,导致容易打满。同时与其他服务混合部署,其他服务发生大查询时影响进度服务。最后采用高配阿里云高配ECS独立部署服务集群。
索引选择问题。mongo索引是通过采样的⽅方式选择的。因此在数据写入量比较⼤且数据可能出现倾斜的情况下,采样不不准确导致索引选择不合适。最终采⽤hint强制走具体索引的⽅方式解决。如下图:
kafka积压
重构的过程中,也发生kafka积压的问题。kafka的partition只能被群组中的一个消费者消费。阿里云的partition是有限制的,因此不能够通过增加partition来解决。最后通过加入线程池的方式解决该问题。虽然一个partition只能被一个消费者连接。但是可以有多个消费线程去执行具体业务。因此如下图:
主线程连接kafka获取消息,同时新建一定数量的goroutine去等待消费。主线程获取到消息后写入到内部channel。多个goroutine从内部channel获取消息并发执行。通过这种方式解决消息积压的问题。
海量数据处理
另一个头疼的问题是接近55亿的行为表,需要修改双写10天的数据。采用导数据的方式id大于某个值,遍历数据修改。但是执行的时候总是出现超时,更新数据脚本总是无法执行完毕。最后采用分而治之的方式,将10天的数据分别写入10张新表,并附加自增id。然后通过并发的方式,更新原始表。通过这种方式解决了海量数据处理的问题。
总结
最后总结一下,做服务明确边界,多做抽象;海量数据处理分而治之,并发处理。