字节跳动评论中台重构一周年留念

7,770 阅读16分钟

〇、序章

  互联网用户的评论发表和评论浏览这类UGC场景作为普通用户的核心交互体验已成为UGC产品的标配,从BBS/贴吧跟帖到微博的“围观改变中国” 概莫能外。   从公司第一款产品“搞笑囧图”开始,评论服务就已经相伴而生。目前全公司已有X产品线接入评论服务,仅国内日增评论量X亿+,评论列表接口晚高峰QPS可达Xk+。   为了给各业务场景提供更灵活的数据模型以及更高效更为稳定的服务能力,评论组从2018年四月上旬开始对评论服务从底层数据模型到整体服务架构进行了彻底的改造重构。从项目启动至今刚好满一年,现在回头看有收益也有遗憾,在此简述一二供大家参考。其中服务重构直接收益如下:

  1. 数据模型抽象: 将评论(Comment)和回复(Reply)抽象成统一的评论模型;
  2. 存储结构优化: 从之前Redis—MySQL(SpringDB)到现在的LocalCache—Redis—ABase+MySQL 三级存储;
  3. 服务性能优化:
    • 评论列表接口(GetComments)晚高峰pct99从100ms 降至60ms;
    • 评论发表接口(PostComment)晚高峰pct99从250ms 降至25ms;
    • 全服务占用CPU 从7600核降至4800核;
  4. 公共轮子产出:

一、一年前的困境与挑战

  因历史业务需求老评论服务数据模型在底层数据库表结构划分成了评论(Comment)和回复(Reply)两级,而数据结构反向作用于产品形态,导致限制公司各APP的评论都是两级结构。唯一的区别就在于二级评论的展现形态是以“今日头条”为代表的评论详情页样式,或是“抖音”的楼中楼样式。评论系统本身的数据结构不应成为束缚产品形态的瓶颈,因此提供更抽象的数据模型已支持未来更为丰富的业务场景是这次重构的首要目标。   老评论服务数据存储中采用MySQL作为落盘存储,除评论Meta字段外还包括Text和Extra字段,这两个字段容量占据总容量的90%,造成了大量的无效IO开销和DB存储压力。   老评论服务中对MySQL基于GroupId进行分库键拆分成101个分库,并通过SpringDB进行CommentId到GroupId的映射,当SpringDB(后期已不再开发迭代)出现问题时会出现严重的读放大问题;   老评论服务数据层与业务层逻辑耦合严重,不利于保障针对各产品线业务逻辑迭代,以及后续的性能调优和运维保障;此外评论服务存储组件访问权限不收敛,导致多次线上问题以及运维成本的增加;   老评论服务的评论计数依赖于CounterService,整体写入链路中间环节过多(comment_post_service → MySQL → canal(binlog) → Kafka(upstream) → Flink → Kafka(downstream) → CounterService)。导致的问题有数据更新存在≈10s延时;链路过长导致稳定性下降以及监控缺失;数据校验修复困难;数据读取不收敛等一系列问题;

二、新服务架构

2.1 数据模型设计

评论服务数据模型演化经历了三个阶段:

  1. 第一个阶段是单拉链模式,将文章ID作为拉链链表的表头以此拉取该文章下所有评论内容;
  2. 第二个阶段是多叉树模式,对某条评论进行评论动作在数据结构上就是向下分叉出一个叶子节点。以老服务为例,数据采用分表存储的方式分成group_comment和reply_comment表,分别对应评论(Comment)和回复(Reply)两级结构;
  3. 第三个阶段是森林模式,当多叉树各子节点需要打标/染色时,根节点就会从GroupId泛化成为GroupId+Tags。
    • 在新数据模型中,将评论和回复两级结构抽象成独立的单行Comment结构,通过Level、GroupId、ParentId维护之前的树形结构。其中文章(CommentId=GroupId, ParentId=0)为Level0,老评论(ParentId=GroupId)为Level1,老回复(ParentId=Level1_CommentID)为Level2。新表字段及其与老表字段映射逻辑可见附录;
    • 同时为了将来各业务线的存储隔离,以及数据隔离、数据单向隔离、数据互通做准备,在相同GroupId/ParentId前提下,通过AppId和ServiceId(未来将通过AppId中提取ProductId后进一步抽象一个SourceId)进行数据隔离。最终,评论数据模型的根节点将由GroupId+SourceId组成;

图1.png

2.2 整体架构设计

  在老服务中服务被拆分成comment_post_service(写)和comment_service(读)两部分,两个子服务以及评论推荐都能直接访问包括MySQL在内的各存储组件,此外包括推荐、审核在内均可访问在线MySQL库。为此新评论服务将存储组件全部收敛至全新的Data服务内部,整体架构拆分成Post(&PostSubsequent)、Pack和Data倒三角结构:

  • Post(&PostSubsequent): Post服务承担包括发表、更新、点赞在内所有写入相关的业务逻辑;
  • Pack: Pack 服务承担所有浏览相关的业务逻辑,包括评论列表中推荐Sort服务的调用以及相应打包操作;
  • Data: 其中Data服务剥离几乎所有业务逻辑作为一个纯粹的CURD层工作,同时也借此机会收敛了所有基础存储组件的读写权限;其中MySQL仅保存Meta字段作为拉链索引,ABase保存全量数据供在线场景使用。

  如前文所述,老服务核心存储MySQL中所含Text、Extra字段占据较大的存储空间,在可预见的未来将会成为严重的性能瓶颈。在重构服务中落盘存储分成异构的MySQL+ABase两部分,其中MySQL作为核心拉链场景仅保存Meta字段,而ABase中保存Meta+Text+Extra三部分数据。因为对ABase存储中采用Protobuf打包,再加上ABase内部的snappy压缩策略,预计可节省30%+的带宽及存储开销。   此外评论重构中提供comment_go_types公共库,用于收敛三个服务的公共逻辑、公有数据结构以及业务字段的统一管理;

图2.png

2.3 Post服务简述

  在评论写入场景中,Post服务拆分成Post和PostSubsequent两个子服务。拆分后PostComment接口晚高峰延时从250ms降至25ms,既一个数量级的优化提升。   Post服务承担包括参数校验、文本检查在内的前置业务逻辑以及调用Data服务写入数据。在此前的业务需求迭代中老Post服务对外暴露了12个接口,新服务中在保持接口不变的前提下收敛逻辑为Post、Update、Action三大逻辑。因评论发表、评论更新以及评论点赞这类写入逻辑流程类似,在Post服务中采用WorkFlow串联各业务流程。

func PostComment(ctx context.Context, request *post.PostCommentRequest) (*post.PostCommentResponse, error) {
	workFlow := postCommentWorkflow{
		request:  request,
		response: rsp,
	}
	rsp, err := workFlow.init().check(ctx).process(ctx).persist(ctx).subsequent(ctx).finish()
	return rsp, err.ToError()
}

  PostSubsequent服务负责在评论成功落库后其他的业务逻辑的处理。因为数据落库后本次调用核心流程就已经结束,所以Post服务以OneWay的形式调用PostSubsequent后对上游返回成功。在PostSubsequent服务内部通过EventBus组件(EventBus组件于facility库内提供)进行各子任务(Task)的分发管理;在EventBus中通过EventType注册执行不同的Task,而Task本身分为Subscribe(同步串行任务)SubscribeAsync(异步任务)SubscribeParallel(异步并行任务)三类;   此外针对评论Emoji白名单检测需求,新Post服务通过Trie树结构实现评论文本的Emoji字符检测。此需求延时从此前调用Antidirt服务的5ms降至1ms以内;

2.4 Pack服务简述

  Pack服务是承担评论所有浏览相关的业务逻辑的打包服务,基本业务逻辑可以拆分成Load和Pack两部分。Load部分负责从下游依赖中获取本次服务调用中所需的原始数据,Pack部分将Load获取的原始数据进行业务逻辑相关的打包操作。   Load部分通过ParallelLoader组件(ParallelLoader组件于facility库内提供)进行依赖下游的并发调用。Load部分通过LoadManager进行打包管理,其中LoadManager内分成多级LoaderContainer,存在依赖关系的Loader之间由LoaderContainer保证Loader执行前已完成依赖的加载。在LoaderContainer内部所有的Loader均为并行执行,从而减少服务整体延时。为了保证并行操作的安全性,ParallelLoader利用Golang interface接口特性采用双重注册制公职数据流规范。首先各个子Loader需要通过各自LoaderParamer的interface注册将会使用到的Get和Set方法,同时各LoadManager需要注册各业务使用到的Loader,并注册全部的Get及Set方法。   Pack这类高并发的打包服务一个典型特点是存在大量IRQ,而且小包较多。据架构同学测试,pack单实例(4核4G)OS内部中断超过300k/s。针对这类情况优化分为两部分,首先是由架构同学优化内核,提高处理PPS能力;其次当调用对实时性不高的下游服务时(ie. RtCounter),Pack服务内实现merge组件,将多个请求汇总后再调用下游服务。

2.5 Data服务简述

  Data服务作为一个纯粹CURD服务收敛了所有对基础存储的读写控制不承担业务逻辑,并且只为上层的Post、Data以及Comment-Sort服务使用。同时为了实现读写隔离及缓存命中率等原因,将Data服务拆分成Post、Default和Offline三个集群,分别承担写入、读取以及离线拉取的职责。   在存储架构方面LocalCache—Redis—ABase/MySQL 三级存储架构。因MySQL仅存储Meta字段,因此MySQL设计职责可专注于优化评论列表拉取操作。   LocalCache使用的是开源的freecache,freecache通过控制对象指针数及分段保证了极小的内存开销和高效的并发访问能力。   评论主存储Redis采用CommentId作为主key,因评论场景本身的离散性保证了没有热key倾斜的问题(可通过计算各ID切片后方差验证);

对账机

  对账机是独立于评论三大服务组件之外独立部署的一个监控保障服务,它采用ElasticSearch作为搜索存储引擎。该服务用于实现评论发表阶段生命周期监控和海外双机房同步数据一致性监控两大目的,其中海外双机房同步的实现是在此次评论重构海外对齐阶段实现的。当时项目的背景是MALIVA机房的Musically和ALISG机房的Tiktok两个APP要实现全内容互通,因种种原因评论没有采用当时通用的底层存储组件DRC同步的方案,而是选择采用上游业务打标回放的方案。评论组利用异构存储的ElasticSearch对海外双机房抽样进行数据一致性监控,进而保障了MT融合评论场景的数据稳定性。

三、迁移方案

  在评论重构项目中因设计数据模型和存储架构的修改,所以需要评论团队自身完成包括数据迁移、服务迁移在内的迁移工作。整个过程完成了300亿+数据的导入,以及Post、Pack两个服务的迁移工作。同时本迁移方案对上层业务全透明,在迁移过程中上游业务无任何感知。

3.1 数据迁移

  数据迁移分成存量数据迁移和增量数据追平两部分。评论选用的方案是存量数据由现有Hive dump 数据至HDFS后通过Spark任务完成新老数据的转换;增量数据追平通过老MySQL binlog日志回放方式完成。在迁移过程中遇到如下小坑特此记录:

  • 在Spark Quota资源充足的情况下,需要灵活控制写入QPS,否则会打垮下游存储;
  • 因系统Quota管理的问题,会存在任务重启,所以需要业务自己记录迁移位点,或者通过切块多个子文件并行执行;
  • 因binlog日志默认保存7天,所以需要在7天内完成存量数据导入并开始增量追平流程;
  • 现有Hive表采用增量更新的方式会存在数据丢失的问题,时间越久丢失越多;建议迁移前直接从MySQL dump一份至HDFS;
  • 向新MySQL库导入数据时因冲突问题,先导数据后建索引所用时长会比先建索引快;
  • canal发送binlog日志时会使用主键id作为Partition Key,从而保证了单行有序。如果迁移脚本同步消费Worker速度不达预期需要通过异步分发提速时,也需要注意通过主键id Hash来保证有序性。
  • 因为binlog日志本身的有序性,所以在binlog日志追平增量数据时不需要记录每个Partition的精确位点,只需要保证Offset提前于存量Hive最新Dump的日期即可;
  • 最后,监控无论多么详细都不过分;

3.2 服务迁移

  服务迁移分成Pack服务迁移和Post服务迁移两部分。

  Pack迁移 其中Pack服务迁移重点是数据验证及逻辑回归,评论老comment_service服务将上游读请求除执行业务逻辑外,作为镜像同步调用至新Pack服务。新老服务以LogID为Key将序列化后的Response存入Redis中,由线下Diff脚本读取Redis内数据进行字段校验。   Post迁移 Post服务逻辑较为复杂,大致流程简述如下图所示:

图3.png

四、相伴而生的轮子

4.1 comments_build_tools工具

  comments_build_tools是一套工程规范和实践标准,能帮助开发者快速扩展功能,提升代码质量,提升开发速度。comments_build_tools的主要功能包涵:

  • 针对thriftidl文件里面的每个方法定义生成好用,好扩展且标准的rpc代码。包括四个golang函数以及两个钩子函数;
  • 生成高度可读,且无错的数据库代码,类似于java的jpa或者mybatis,生成ORM到对象的映射;
  • 标准化的测试框架,让单元测试变得简单,解脱服务端单元测试对环境的依赖;
  • 帮助你检查代码质量,包括不安全的,可能产生bug的代码;
  • 生成标准高效的枚举代码,枚举提供string、equal等多种方法;
  • 辅助生成泛型库: 具体可参考下文facility工具库;
  • PS 截止至2019年4月1日该轮子已在GitLab收获67个Star,有任何疑问和需求欢迎加入Lack "comments_build_tools使用群"骚扰轮子作者zhouqian.c;

4.2 facility工具库

  facility库除了上文提到的EventBus、LoadParallel组件外还提供assert语句、类型安全转换以及afunc等功能。   此外,因为Golang是一种强类型语言,不允许隐式转换;同时Golang1.x 还不支持泛型;所以在开发过程中语言不如C++、Java精炼。facility 库是利用comments_build_tools 模板编程生成的一个语法糖库,提供各个数据类型的基础功能,包括且不限于如下功能:

  • Set数据结构: 支持Exist、Add、Remove、Intersect、Union、Minus 等操作
  • Map数据指定默认值;
  • 有序Map: 顺序、逆序、指定序;
  • Slice 数据的查找、类型转换、消重、排序;
  • ?: 三元操作符;

五、尾声

5.1 经验与教训

  1. 周会制度: 评论重构项目从启动到完成整体迁移坚持周会制度,并且邀请了多位经验丰富且深刻理解业务的同学一起参与。周会讨论范围涵盖从架构设计到迁移方案讨论甚至代码走读,从而保证了方案到细节都经过了充分的讨论和思辨;
  2. 快速迭代: 在重构过程中快速迭代勇于试错。当时尝试过但是最终未能上线的功能有:服务自身snappy打包、单机Redis进程进行LocalCache管理、基于gossip的LocalCache管理、基于kite client middleware的一致性哈希LoadBlancer etc;
  3. 最大的前向兼容性: 在评论重构至到服务最终上线,对上游业务方均是透明的;因为评论服务涉及数十个业务线、上百个上游PSM,如果涉及到上游业务改动,整个排期将不可控;
  4. 对既定目标没有激进的拿结果: 为了保证排期和降低兼容性风险,在方案设计和代码结构上存在妥协,导致一些工作延后到现在重新开始完成;
  5. 对MySQL表设计尤其是索引设计需要慎之又慎,作为业务场景相对固定的表,索引只需要最大限度的保证在线业务需求即可;比如评论为了可能的回扫场景设置了CreateTime索引,现在此类需求已完全由ElasticSearch完成,所以这个索引现在是废弃状态;
  6. 如果业务中存在某单表读写QPS特别高,可以针对性的采用单库单表的模式,防止一主N从结构扩容时,其它次级表占用存储空间;
  7. 评论服务存储组件繁多(ABase * 2、MySQL、Redis * N、ElasticSearch),但是对柔性事务保障能力不足;

5.2 开放讨论

  在服务重构过程中有很多激烈的讨论,本文述而不论罗列出来供大家讨论:

  1. Data服务定位为纯粹的CURD层还是可以承载业务逻辑;
  2. 评论计数缓存Redis、评论列表兜底Redis这类轻量级存储组件应该由Data服务执行还是上抛至Pack服务直接访问。比如自建于Redis之上的兜底索引的读取是由Pack服务直接读取,还是下沉至Data服务;
  3. 在代码结构中配置文件的定位是代码既配置还是配置为文件;