背景
作为一个以新闻、资讯为主的 App,今日头条上的主要内容都是由文章组成,文章服务自然伴随着今日头条 App 的产生就已出现,之后又逐步扩展为目前的内容云,为头条、西瓜、小说、懂车帝等多个 App 服务的业务内容中台。截止 2021 年底,内容云接入子业务已经达到数百个,高峰期主要读服务 QPS 数百万,维护超过 2200 个属性,存量数据达到百亿条级别。然而由于历史悠久,经手人众多,加上历史上一些环境或周边系统的特殊性,业务模式发生转变等,使得内容云成为一个标准的大型遗留系统,早期的一些存储、架构上的设计已经逐渐无法满足当前的业务场景,并给维护者带来了较大维护和迭代成本。
因此我们启动了内容云存储层的迁移项目,随着调研和与其他业务的讨论的不断深入,发现各业务对存储层的痛点及需求基本一致,存储模型和实现方案逐渐趋同,因此决定基于 ByteKV 开发一个宽表数据服务(本文主要聚焦在遗留系统存储层迁移的过程,暂不涉及新存储层的设计与实现细节),下沉存储层通用逻辑,供其他业务接入,并替换内容云原有的存储层。最终历时将近 1 年时间将在线流量切换至新的存储层。
迁移一个系统的存储能有多复杂?无非是双写、迁移数据、切读、停写罢了,为何内容云存储层的迁移竟花费将近一年时间?本文主要分享内容云存储层迁移的血泪史,过程中的一些坑和经验,望能给其他大型系统迁移存储或做重构带来一些流程上的参考。
名词解释
- 文章服务,内容云:字节跳动内部提供内容全生命周期解决方案的内容业务中台。
- ByteKV:字节跳动内部自研强一致 KV 模型存储组件。
- ABase:字节跳动内部自研高可用 KV 模型存储组件。
- 宽表数据服务:新的存储层,通用的表格模型数据服务,通过下沉存储层的通用能力,减少重复建设,降低维护成本,提升研发效率。
难点
领域边界调整
虽然大体目标是将原存储层替换成新的存储层,但预期本次迁移也需要解决原存储层由来已久的多存储不一致问题、容量瓶颈、主从延迟等问题,这要求在迁移过程中也需要对内容云业务层进行大量改造,将原有业务层中包含的存储层功能下沉到新的存储层,使业务层和存储层边界明确,带来了额外的工作量。
数据模型变更
由于原有主要存储为 MySQL,本身数据模型为表格型,而新存储使用 ByteKV,数据模型为 KV 模型,虽然在新存储层建设过程中已经完成了基于 KV 模型提供表格模型能力的开发,但相关功能的能力与旧存储层的能力仍有偏差,需要在迁移过程中不断的完善和进一步改造。
数据量、请求量大
迁移时内容云数据量已经达到数百亿条,主要读服务请求高峰期流量数百万 QPS,大的数据量+大请求量使得在双写、做 diff、刷数据等每个阶段都需要考虑性能问题,资源问题。本身双写双读期间就需要引入额外的资源消耗,使得过程中不得不抽出一些时间优化之前系统的性能,以释放出一些资源进行双写、双读、消 diff 及压测等验证工作。后面会详细介绍两次大的性能优化过程。
迭代中迁移
唯一不变的是变化,在整个迁移过程中内容云系统也在持续进行迭代,整个迁移的过程如同给正在奔跑的汽车换轮胎,给正在飞行的飞机换发动机,需要做到业务无感。新 feture 的加入需要同时作用到两套存储上,否则就会产生 diff,时刻关注 diff 情况并追齐新加的 feature 同样花费了不少时间。
历史包袱众多
由于业务经手人数较多,历史悠久,遗留系统中都有众多黑盒及不可解释的逻辑,对这些逻辑的理解及兼容是前期项目计划之外的。此外历史数据的混乱,已经无法用现有系统的标准去度量,为保证切换过程中透明,甚至需要去兼容历史上错误的数据。
痛点分析
内容云本身对存储层的依赖如下图:
此架构主要有以下几个问题:
- 存储组件使用 2 个 MySQL+2 个 ABase 协同提供服务,但业务上操作四个存储很难保证事务性,即使做一些补偿也很难保证四个存储同时成功或同时失败,导致产生较多的多存储不一致问题。
- 对于一些存储层的通用能力,如加密、版本、审计、缓存等与业务层没有明显边界,相应逻辑揉杂在业务代码中,对业务代码侵入较强。
- 主要存储为 MySQL,原生 MySQL 并非存储、计算分离的架构,在大数据量的业务上存储容量常常成为瓶颈,只能不断进行拆库"续命"。
- 大多数上层业务对数据一致性要求较高,MySQL 的主从延迟的抖动会造成缓存中存在脏数据,引发数据不一致。
- MySQL 中单列存储容量存在上限,导致业务上对于一些"大"文章的存储需求无法满足。
过程
前置代码准备
此阶段主要进行数据双写代码准备,及写 diff 流程、监控的搭建。
从上述存储架构可以看出,上层业务统一通过了抽象接口层(data_source)访问底层的存储,理论上在抽象接口层新增一个宽表数据服务的实现,并把旧存储的实现直接替换为新存储的实现即可完成存储的替换,即基于新存储的依赖如下图所示:
然而,理想很美好,现实总是很骨感,这样虽然能做到替换存储,但并没有达到重构的目的,即解决多存储不一致等问题,之前分别处理多个存储的代码在业务层进行,通过 data_source 中的不同接口进行不同存储数据的操作,因此需要进行 data_source 接口的改造和在线写服务中操作多存储部分的代码改造。同时把写 diff 的流程搭建起来。
此阶段主要开发工作有:
- 存储抽象接口层(data_source)的接口改造,使得可以通过一次请求替代之前操作存储的多个请求。
- 在线写服务操作多存储的逻辑下沉,在业务层不再感知到存储层相关的逻辑。
- 开启双写,把新存储作为弱依赖双写数据。
- 基于写数据事件触发 diff 服务,搭建写 diff 流程(收到事件重新读取两存储中的数据,并比较进行打点监控)。
- 进行压测。资源总是紧张的,需要预先申请,此时的新存储集群只能够承担双写的流量,此阶段需要进行初步压测并预估最终所需资源数量并提交申请。
写 diff 消除
写 diff 过程不管做的多细致都不过分
上阶段代码准备完成后,开始无尽的消 diff 工作,由于内容云字段已经超过 2000 个,需要对有 diff 的字段逐个进行排查,并不断进行代码改造以消除这些 diff,是一个极度需要细致和耐心的过程。
最终写 diff 消除用时 1 个月左右。后面也证明写 diff 阶段不管多细致都不过分,因为写 diff 消除完成证明了数据写入已经没有问题了,可以进行历史数据迁移,如果历史数据迁移完后又发现有写 diff,很可能需要再次全量刷一遍数据,费时费力。然而虽然用时一个月后来发现仍有一些坑,导致大大小小最终刷了不下 10 遍数据,后面说。
下面总结下比较有代表性的写 diff:
1.自身逻辑实现的 bug 及新流程未与旧流程完全对齐(这类导致的 diff 其实是最多的,具体要看本身的业务逻辑,没什么参考意义,只能不断的去追平逻辑再验证)。
2.旧存储特性导致的 diff,有默认值。即业务上没有写对应数据,但旧存储 MySQL 每个列可以配置默认值。
3.旧存储本身配置不合理导致的 diff,如:
- 字符集配置的 UTF-8,导致本身存储中不支持 emoji 表情,而新存储中支持导致的 diff。
- 字段类型配置为 tinyint,导致业务上如果写一个较大的值时会发生溢出,而新存储不会。
4.两个存储一个成功、一个失败导致的 diff,需要在一个存储失败时进行后续的补偿重试,因此搭建了数据修复流程,期望两存储能够达到最终一致的状态。
5.请求乱序,如下图,可能会发生请求 2 比请求 1 先到的情况。需要在写请求之前加锁,并在两存储写完后再释放锁,前提是能确保新存储的性能不会对上游产生影响。
6.时间戳问题
由于两存储无法保证准确的同一时刻写入,导致有些时间戳会出现 diff,这种解决方案分两种情况,对于无法接受 diff 的时间戳需要在业务层统一时间戳,再指定使用统一时间戳写入两存储。对于能够接受 diff 的时间戳需要在 diff 时忽略掉。
7.序列化问题
一些反序列化方法会把 JSON 中的数字转为 json.Number,这在业务中类型断言或 diff 比较时都会留坑,应尽量在下层处理好这类问题。
8.序列化的顺序
由于 map 结构的无序性,在序列化成字符串时会导致顺序不一致,可能在某些业务逻辑中有坑,较好的方法是在序列化时保证进行有序的序列化,已经有许多开源的 JSON 库能够做这样的事情。
9.服务本身的异步写入
这种 diff 可能是内容云独有的,之前有较多逻辑直接在写服务写完主存储后,起异步协程再进行一些计算和数据操作,这使得这些写入的请求顺序无法得到保证。较好的做法是把操作存储的逻辑收敛到统一的写服务接口上。
10.存储一前一后写入,或一前一后读取导致的 diff
由于无法保证在做 diff 时的事务隔离性(会影响在线服务,不太能接受),会存在在 diff 读取时刚好有并发的数据写入操作,导致的不一致,这种即使延迟一段时间再次进行 diff 也无法完全消除,因此最终 diff 的消除也无法达到 100%的一致率,最终在一致率达到 99.99%时经追查仍有 diff 的 case,发现都属于这种情况,这时认为写 diff 已经消除完成了。
历史数据迁移
尝试探索更高效的历史数据迁移方案能提升存储迁移的效率,除非能保证只刷一遍数据
经过写 diff 消除阶段,此时理论上新增的数据写入已经没有问题了(只是理论上,后面读 diff 时发现还是有一些边缘 case 导致的写 diff)。这个阶段主要是把历史存量数据从旧存储导入新存储中。这个过程依然基于统一接口层 data_source 实现。
这个阶段同样需要做完备 diff,需要验证导入的历史数据是否符合预期,需要进行历史数据的正确性校验,但当时由于新存储本身资源不足,离线数据也还不支持产出,此时进行历史上 400 亿条数据的对比是无法进行的,因此这个阶段只进行了有明显问题 diff 的修复,把历史数据 diff 的校验工作放到了切读前的最后一步,但更合理的做法是在此时就校验好历史数据的正确性,否则之后可能会产生重复的刷数据工作。
此阶段主要会遇到的问题是如果一些数据是在真实数据写入时生成的,可能有问题,需要新存储支持这些数据可以指定写入,如:
- create_time 类数据,是在新数据写入时根据时间戳生成的,但历史数据的 create_time 不能使用刷数据时的时间,因此需要新存储支持上游指定写入 create_time 的值,进行一些代码改造。
刷数据的工作主要是依赖业务上层的实现进行,因此刷数据的过程需要进行大量的计算逻辑,是比较低效的,理论上把刷数据的工作越下沉越高效,比如参考 MySQL 迁移数据时的文件级别拷贝等。由于当时考虑内容云迁移本身 1. 数据导入速度不会成为整个项目的瓶颈 2. 新旧存储数据模型差别过大,通过离线数据导入也需要大量适配、验证工作,当时并没有考虑更加高效的存量数据迁移的方案,后期刷全量数据约需要 5 天时间,但在存储迁移的过程中如果能把数据迁移的时间压缩到比较短,如半天能完成存量数据的全量迁移,对整个迁移工作是比较有利的,可以进行快速的验证和试错。
缓存优化
性能优化初见成效
在历史数据迁移的过程中,我们也对新存储层的性能进行了又一次压测,发现在数据写入 QPS 到达 3w 时,基本就会把 ByteKV 打挂,虽然此时只有部分机器资源到位,但也开始对性能产生深深的担忧,因为此时压测比四月份的压测更接近真实业务场景。按照此时的压测数据来看,即使到了开始预估的全量机器,也很可能无法承接所有流量。因此在七月份开启了缓存的优化改造,主要两点考虑:
- 期望通过缓存的优化能够提高缓存命中率,减小到达存储层的流量。
- 之前新存储的压测是在没有缓存的情况下进行的,需要有额外的缓存资源用来压测得到更贴近真实的压测数据。而如此大的流量的缓存资源再部署一套是不被接受且浪费的。
主要缓存优化的思路是根据内容云实际业务场景出发,发现之前使用缓存的方式存在很大浪费,优化思路可能并不能直接复用于其他业务,这里不详细展开介绍。但值得注意的是对于类似大型遗留系统由于业务历史上的转变,总会发现一些系统中不合理的点,经过简单优化后可能能得到意想不到的收获。
简单说下此阶段主要进行了两点业务上的优化:
- 在线读服务的缓存把一篇文章的数据分为四份来存储,在早期来看这种设计的确合理,但由于业务的发展,在 18 年之后,四份缓存中的数据就存在着大量的重复,造成缓存空间的极大浪费。
- 在线读服务之前有两层服务,两层缓存,上层的缓存时间 6 分钟,下层缓存 30 分钟,上下两层缓存中的数据也基本相同,这使得下层的缓存数据比较浪费,因为缓存的数据在 30 分钟内不考虑并发的情况下只会有 5 次请求。
因此,对在线读服务的缓存进行了改造,合并了多份缓存的数据,并且把两层缓存改为一层,从而释放出了 Redis 资源供新存储使用,此次优化后缓存命中率得到提升 90%->98%,且节省出的缓存空间足够新老两套存储同时使用。
经过缓存的优化,对新存储加上缓存再进行压测,此时的压测数据基本可以保证如果预期资源能如约到位,ByteKV 是基本能够承担内容云的所有流量的。
读 diff 消除
每个 diff 的消除都是在解决切换过程中的隐藏炸弹,diff 越仔细,切流时越安心
与很多业务中台一样,内容云的读服务在读取数据之后进行一些计算打包逻辑,此阶段主要对内容云业务层两个出口服务的读接口进行 diff 流程搭建和消除工作。对于读服务来说进行了一些重构,预期把老的回源服务下掉以保持整体架构的简洁。服务改造图如下:
主要改造点:
- 缓存适配新存储模型,由于新的存储是大宽表的模型,无法一次读出一篇文章所有信息,因此缓存模式需要进行改造适配。
- 老回源服务的业务逻辑上移到在线读服务,业务和存储层边界更加清晰。
- 计算逻辑适配新存储。
- 读数据事件的解析和 diff 打点监控。
- 产出快速查看 diff 和快速修复数据的工具,提升消 diff 的效率。
相比于写 diff 阶段,读 diff 需要消除的 diff 并不算多,更多的 diff 是由于部分需要重构和适配的逻辑与原逻辑没有对齐导致的,但由于读接口流量较大,一般无法打印比较详细的日志,导致对于 diff 的排查工作较难进行,常常需要根据数据和代码的蛛丝马迹在脑中进行编译执行来定位具体产生 diff 的原因,这里也是极度需要耐心和细致的过程。
性能优化终见曙光
终于找到 ByteKV 的正确打开方式!
读 diff 消除完成后,理论上已经可以进行逐步切流至新存储,但意外总是不期而遇,最早预估的机器资源由于整体资源紧张并没有如期到位,导致此时新存储的资源不能承担所有流量。因此需要进行进一步的性能优化。
在一次小的性能优化上偶然发现,写数据时把每次写存储的 Key 数量缩小一半,性能不止能翻一倍。基于尽量减少 Key 的个数这个思路开始进行代码的重构和调整(当然又需要全量刷一遍历史数据),主要进行了两点优化:
- 尽量减少非必要的 Key 写入,如之前会记录每个字段的创建、修改时间,但业务上并没有实际使用,反而会使 Key 的数量膨胀为最初的三倍,因此暂时放弃了字段维度时间的记录。
- 由于业务上历史字段众多,且由于历史原因需要全量返回,因此对历史字段进行了第二版合并,原则是除特殊情况,能合并的都合并。
经过上次两点优化,保证了对于大部分请求读写一篇文章的数据,能够保证读写新存储 4-5 个 Key 即可完成,这使得一切变得美好起来,接口的延迟能够稳定保持在 10ms 以下,错误率也不会像之前那样有突刺了。经过优化之后再压测,当前的机器已经足以承担所有流量,甚至还有富裕。
做字段合并是基于内容云的历史包袱和整体资源不足的无奈之举,虽然提高了性能,但也会在其他场景引入坑,如非必要请勿作此妥协。
历史数据 diff
对于历史上的脏数据如果无法兼容,尝试把它改对吧
你永远无法想象一个历史遗留系统中的数据能有多混乱,历史数据的混乱总在不断的颠覆对内容云这个系统的认知。如:
- 一个原则是草稿不会记录到历史库中,但历史数据中竟然发现好多草稿记录到了历史库中。
- 不需要记录版本的字段,却在版本库里。
- 一些不需要记录版本的业务,版本号会有几百甚至上千。
- 底层的存储层中竟然有对某一个历史业务做的特殊逻辑,导致又花费了一些时间做逻辑的兼容,并重新刷一遍数据。
每次发现这种问题都仿佛是跟前人的一次对话,慢慢可以理解或者想象当时发生了什么事情,如可能某几天线上有 bug,造成脏数据,但并不影响整体使用,逐渐的这些脏数据也就留在了遗留系统中。
前期为了保证切换存储对上游完全透明,即对于这些脏数据我们也想办法尽可能让他继续保持现状,然后随着兼容的脏数据越来越多,发现我们新写的逻辑逐渐不可解释和维护,最终痛定思痛决定还是按照合理的方式把脏数据变成本来该有的样子(又进行了一遍全量刷数据),最终结果发现把历史上的脏数据改对可能确实是正确的,上游也没有依赖脏数据做逻辑,切换无感知。
切读
切流量是一个漫长、危险,如履薄冰的过程,需要保证每一步可回滚,可快速恢复
经过前面的阶段,已经基本保证了新存储读、写的功能和性能满足要求,在 12 月份终于迎来了切量到新存储。由于此时一些旧存储的调整导致此时旧存储的主从延迟问题更加严重,导致业务上反馈较多,因此选择优先把主要读服务切换到新存储上。
此步骤主要就是把读接口流量切换到新的链路来承接,本身开发工作不大,主要是需要观察切量过程中是否有问题,切量前后的系统流量,稳定性等是否满足需求,同时需要做好线上问题的处理预案。保证任何时候出现问题能够快速回滚,及时止损。
最终历时三周时间,把在线读服务的所有流量切换到新的链路上,彻底告别了主从延迟导致的数据不一致问题。
切主存储
在线流量切换完成。需要做好切换过程中的数据补偿
切主存储,主要是写入相关接口,之前还是以旧的存储作为主存储,旧存储成功即返回成功,旧存储失败接口返回失败。需要切换到以新存储返回为准。需要注意的是需要做好数据补偿,如切之前,旧存储成功,新存储失败,需要利用旧存储的数据尝试修复新存储的数据,切完之后,新存储成功,旧存储失败需要利用新存储的数据尝试修复旧存储的数据,需要保证切换过程平滑可回滚,不会出现数据不一致的 badcase。如下图,把新存储切为主依赖,旧存储切成弱依赖。
最终又历时两周,切主存储完成,在线流量全部切换到新存储上,整个项目完成。
收益分析
解决多存储不一致
新的存储层基于强一致的 ByteKV,不会产生一篇文章部分属性写成功,部分写失败的问题,切换后消除了不一致问题的反馈。
历史包袱清理
迁移中附带解决了业务中的一些历史包袱,对历史不一致脏数据尝试修复,明确业务层和存储层的边界。使整体系统架构更加清晰。
系统可用性提升
存储层可用性 99.8->大于 99.99%。
更多业务特性支持
新存储支持了大 Key 的拆分,解决 MySQL 单列存储上限问题,满足部分业务对单列大容量存储的需求。
解决容量瓶颈
将 MySQL 替换为计算、存储分离的 ByteKV,使得存储容量不再是存储层的瓶颈。
干掉主从延迟
同样得益于 MySQL->ByteKV, 切换后无主从延迟导致的缓存脏数据问题反馈。
成本降低
- 新存储相比旧存储成本节省超过 60%。
- 优化缓存使用方式,缓存命中率 90%->98%,节省 XTRedis 资源。
- 对 ByteKV 使用方式进行优化,完成迁移时只使用了启动时预估资源的 50%。
- 迁移中对服务日志进行治理,框架、组件升级,节省计算资源若干。
总结
本文从存储层迁移流程的角度详细阐述了大型系统存储迁移的过程,分析了其中的难点和过程中的一些坑,总结来说过程中也有一些不足和感悟:
- 对于写 diff 应尽量细致和耐心的进行消除,后期再发现写数据的问题会带来较多重复的工作,再次强调写 diff 不管做的多细致都不过分。
- 历史数据的迁移,如果数据量过大应尝试探索更加高效的迁移手段,迁移逻辑越下沉越高效。
- 历史数据的 diff 和一些业务流程上的改造应该尽量前置,后期再进行大的改造需要重新进行刷数据、diff 校验等工作,费时费力。
- 切流的过程要做好数据补偿,保证出现任何问题可快速回滚和恢复。
- 遗留系统中总能发现一些业务上使用不合理的点,与其想方设法去提升底层存储组件的性能(当然也很重要),不如去尝试进行一些业务使用方式上的改造,可能能达到意想不到的收获。
希望能给其他系统做数据或存储层迁移重构带来一些帮助或参考,能够更加快速、安全的进行存储或数据的迁移工作。