1、数据迁移的原因
在业务发展过程中,有很多的原因会导致我们需要进行数据迁移:
- 机器的性能或容量问题需要更换机器
- 原有的分表设计跟不上业务发展导致单表性能出现瓶颈
- 原有的数据库选型无法适应业务的发展导致数据库存在性能问题
- 原有的数据结构设计无法满足新的业务场景
- ……
上述原因导致的数据迁移中,有些只需要将原数据原封不动地腾挪到新的机器;有些虽然保持数据结构不变,但需要更换数据库,或者更换数据的路由方式;如果还牵涉到数据结构的变更,则数据迁移会变得更加地麻烦。
2、数据迁移的目标
做数据迁移工作,首要面对的目标当然是数据不能丢,但也不止这个,随业务的特点不同,要考虑:
- 数据允不允许有记录丢失
- 数据允不允许有字段内容的缺失
- 数据允不允许有记录重复
- 数据能不能只读不写
- 系统允不允许停止提供服务
- 系统有没有访问量特别少甚至为0的时间段
- 迁移的时长能不能持续一段较长的时间
- ……
以上的考量点,答案为否的条数越多,则迁移的难度越大。
3、数据迁移的基本策略
随着人们不断地执行一次次的数据迁移,已经得出了几套基本的迁移策略,来供不同的业务诉求场景下去使用。常见的迁移策略有:
- 停机切换
- 在线切换-增量写新
- 在线切换-双写
3.1、停机切换
对于第一种停机切换来说,操作步骤是最简单的,分为三步:
- 停机挂公告
- 迁移数据
- 恢复服务
为什么说它简单,是因为这种做法只涉及停机前的存量数据的迁移,数据没有因为系统的运行而持续变化,因此只需要将固定的存量数据复制到新库即可,即使是要更换存储方式或数据结构,也是一个一次性的工作。
这同样是最稳妥的方式,由于系统暂停了服务,不会再有新数据的产生,因此只需要仔细处理好固定的一份存量数据的迁移,做好对账检查确认无误,即可恢复服务完成迁移。
相对应的,就是这种方式对业务的巨大限制——停机。
大部分对外提供服务的业务,都不会希望自己需要为了一个技术上的问题,去暂停重要的服务能力。
但我认为其实很多时候这个看似无法被接受的限制是可以探讨的:热门游戏如王者荣耀,也会偶尔有停服维护的时候;民生服务如12306,也在每天凌晨不提供购票能力。
归根结底,这是一个业务可用性与技术优化上的权衡,作为研发人员,需要清晰地让业务同学理解数据迁移的重要性和好处,以及方案选型的考量,供业务同学去理解其意义,才能做出权衡与选择。
如果是非常关键的业务,比如作为移动支付渠道却无法支付,或者作为电商却无法提交订单,无疑会对业务带来巨大损失,这也是为什么这种业务从来不曾停机维护,甚至一旦不可用就会上热搜。
但作为非关键流程的业务,如果能找到一个访问量非常低的时间段(如凌晨),提前挂出通知做好对用户的周知,在尽量短的时间完成数据迁移并恢复服务,是可以被业务所接受的,也能大大降低数据迁移的复杂度和风险。
3.2、在线切换
如果业务实在无法接受暂停提供服务,那就只能在线切换。
在线切换是指服务不停机的情况下,通过同时提供新旧两套数据的访问能力,并按某种规则将来自用户的请求从旧的数据切换到新数据的过程。
在这个过程中,不可避免的,会有部分用户访问到新数据,部分用户访问到旧数据,最好能做到用户无感知。
如果在切换的过程中,发现有问题,还要能够方便地回退回旧数据,回退后同样希望保障数据是一致的。
3.2.1、在线切换-增量写新
对数据的切换,必然有一个存量数据的迁移过程,但和停机切换不同,在存量数据的迁移过程中,数据是持续在产生的,因此就像阿基米德的龟兔赛跑悖论,你永远也无法保障在完成存量数据迁移的那一刻,和切换使用新数据的那一刻之间,没有新数据产生,何况这两个时刻的间隔往往比我们想象的时间更长。
因此你需要有一个将新增的数据写入新的数据库的过程。这就是增量写新。
由此整个逻辑基本确定了:
- 读请求双读新旧并组装数据
- 则良辰吉日开始,所有的新的数据写请求都访问新数据库
- 迁移存量数据到新数据库
- 将读请求保留仅访问新数据库
这里有几个问题需要探讨一下:
首先为什么是先写新,然后再迁移存量数据呢?
这里考虑切换写新和迁移数据都要做什么。切换写新,即将写请求从访问旧数据库改为访问新数据库,这个操作很好完成,只需要改变写操作的入口即可。那么数据迁移需要做什么呢?我们需要遍历旧数据,一条一条地按规则写入新的数据库。由于数据迁移往往发生在发展了一段时间的旧业务上,因此数据量一般较大,这个过程也就耗时比较长。
如果先迁移数据再切换写新,由于迁移数据耗时较长,在迁移过程中可能也有新的数据写入,等迁移完切换写新后,会存在部分数据没有迁移到新库中,导致又要再来一次存量数据的迁移。因此一般是先切换写新。
其次是为什么要双读呢?
当切换写新后,数据就会部分存在于旧数据库,部分存在于新数据库,此时访问哪个库,数据都是不完整的,因此需要双读来组装形成完整的数据,需要注意的是由于写操作不仅仅是新增数据,还会更新/删除数据,因此双读时需要处理好这里的数据组装逻辑。
这个流程基本可以保障系统能够不停歇地提供服务,只是需要额外做一些双读和切写的逻辑处理。只要数据迁移的逻辑仔细些,做好对账,是能够做到数据不漏不多的,迁移的时长也能接受较长的时间(只是在此期间的数据读会比较麻烦)。
但是这个流程也存在一个很大的问题,那就是回退很麻烦。
可以看到,增量数据的写操作只写入了新数据库,此时旧数据库是没有增量数据的,服务的提供全靠读操作时把数据组装起来。如果此时发现新数据库有问题,想要退回读写旧数据库,就不好回退了。
想要回退,就需要反向来一次类似的数据迁移过程:先保持双读,切回写旧库,然后将写入新库的数据迁移回旧库,再切回读旧库。
为了解决这个问题,就要引出下一个方法:
3.2.2、在线切换-增量双写
顾名思义,增量双写和增量写新最大的区别就是,对于新增的数据写入,在新旧库会一起写。
在新库写是为了保障数据迁移后数据完整,在旧库写是为了保障回退时可以直接回退,无需将新数据反向迁移回旧库。
因此整个流程可以描述为:
- 读旧数据
- 开始将增量的写请求双写新旧库
- 迁移存量数据到新库
- 读新数据
- 停止双写,读新写新
这里同样是先启动双写,然后再进行数据迁移,原因同前面的增量写新是一样的,为了确保不遗漏迁移的数据。
但和前面不同的是,这里没有双读,而不是直接从读旧,切换到读新。前面需要双读,是因为切换写新后,旧数据库就没有新产生的数据了,需要双读来组装完整的数据。而这里因为一直在双写,所以实际上旧数据一直是有完整数据的,而迁移完存量数据后,新数据库也有了完整的数据,因此也可以直接切换到读新。
在读方面,因为不用双读组装数据,所以会简单很多,而且也很好控制灰度过程:只需要确保存量数据完整迁移了,就可以开始灰度,由于此时新旧数据库都有完整的数据,因此可以很方便自由地按任何维度灰度读新,也可以随时回退读旧。这是它的优势。
但在写方面,因为要保持双写,因此写逻辑会变得复杂一些。
由于要写两份数据,这里首先需要考虑是同步双写还是异步双写。
和数据同步机制类似,当有写操作发生时,可以选择是同步将两个库都写成功才算成功,还是只要写入一个库成功就算成功,另一个库通过异步完成写入。
如果选择同步双写,从结果上来说是最好的,两份数据是强一致的,如果其中某个库写失败,可以采取某些重试写入的策略,但如果一直失败,则需要回退另一个写成功的库。
如果选择异步双写,写操作会比较顺畅,省去了写失败的回退操作,可以在异步持续尝试补偿,但缺点是两份数据存在一段时间的不一致,在切换读的过程中可能会出现一些异常。
通常情况下,如果写入的失败率极低,则会优先选择同步双写,对写失败的情况做一定的重试,如果真的出现极端异常就是无法成功,又不想或者不方便回退,也可以再补充上异步补偿的逻辑。
另外,如果选择异步双写,还需要考虑的一个点是先写新数据库还是先写旧数据库。这决定了哪个数据库是更新更全的数据。
这个选择同样需要考虑业务场景。
如果数据不是用户自己产生的,用户只是读数据,则建议先写新,因为迁移的过程是从读旧改为读新,因此先写新的情况下,即使旧的没写成功或写晚了,由于用户看不到,没有太大影响,但如果先写旧成功了,新的还没写,此时切换后会发现数据缺失。
如果数据是用户自己产生的,则建议优先写旧,用户在读旧的时候能立马看到写入的数据,等迁移到读新后,此时离直接写新时间不会太久,因此看不到写入的数据影响不会太大。
当然,如果能够根据用户是读旧还是读新来决定先写旧还是先写新是最好的,但这会导致写操作的逻辑变得很复杂且难以排查问题。
4、数据迁移的相关问题
4.1、数据版本
如果是在线迁移,一定会涉及双写,为了保障数据迁移的完整性,会先开启双写,再迁移存量数据。
在双写时,由于新数据库是空的,因此可以直接写入/覆盖。
需要注意的是,对于更新操作,只会更新部分数据,此时在新库写入时,需要取完整的数据更新后的数据进行写入,否则未更新的字段会是空值。
但在迁移存量数据的时候,由于此时某条记录可能已经双写过新的数据,那么不应该被存量数据覆盖。
一种做法是,在迁移存量时,只要发现新数据库已经有了对应记录的内容,就跳过不写入。
但如果双写和迁移同时进行,此时可能会被误覆盖。
另一种做法更保险一点,即对每一条记录都设置一个数据版本号,在写操作时,都需要对比当前想要写入的数据版本号,是否大于等于当前数据库中的数据版本号。
如果是,则说明当前数据是更加新的,可以覆盖;如果不是,则说明在此过程中发生了其他写操作,此时不能直接写入,需要根据情况决定是放弃还是取最新数据来重试更新。
4.2、数据主键
如果数据格式做了重新的设计,此时新数据可能会有新的主键设置方式。
比如旧数据可能是一个自增ID,而新数据可能会希望设置一个包含业务规则的有结构的ID(如根据用户ID+场景+时间戳+随机数组装新主键)。
此时如果直接在数据迁移时全部使用新的ID生成方式,那么可能会很难做数据对账。因为主键已经不同了。
如果在数据迁移过程中依然使用旧的ID生成方式,等到迁移完成后再启用新的方式,虽然可以做对账,但会导致数据存在新旧两种完全不同的主键格式,难以根据ID做一些切面的操作(如路由规则)。
一种可能更适合的解法是,对新主键的ID组装规则,设置一个版本号在其中,如用户ID+版本号+场景+时间戳+随机数组装新主键。
在数据迁移过程中,使用一个版本(如版本0),此时在新主键中,将旧主键的ID组装进去。比如格式为:用户ID+版本号(0)+场景+时间戳+旧ID。
等迁移完成后,修改主键生成规则,并把版本改为1,此时新主键的格式为:用户ID+版本号(1)+场景+时间戳+随机数。
此时就保障了新数据的主键全部都是类似的格式,可以从其中获取一些信息进行切面操作,比如取用户ID进行路由。
同时,也可以从中取出旧数据的ID,来做数据对账,并且可以根据版本来确认哪些记录是旧数据迁移而来需要对账的记录。
4.3、数据对账
数据的迁移一定要考虑数据能够容忍的误差,是完全不能有误差,还是可以容忍缺失数据,或是可以容忍重复数据,不同的业务场景下要求会不一样。
但无论要求如何,我们都应该对自己迁移的数据准确性如何有一个把控,因此数据对账是必须要做的。
对账的工具依托于业务当前能够支持的技术架构。
如果只能访问到新旧在线数据库,那么对账大概只能通过脚本遍历新旧数据进行对比的方式,如,先编译旧数据,看是否在新的数据中全部存在,再遍历新数据,看新数据是否在旧数据中都一致。这里的数据访问需要注意频率和时间,避免因为对账导致太消耗数据库性能,影响线上访问。另外由于在线数据库中的数据一直在随着用户的请求产生变化,因此对账对到什么粒度也是需要根据业务来制定的。
如果业务有离线数据仓库,则更适合使用离线数据进行对账,离线数据一般是周期性地将在线数据库中的数据迁移入库,因此数据有延迟性,但优势就是它是某个时间分片下的固定数据,不会因为用户的请求导致数据变化,更好用来确认某个时间下的数据是否一致。
需要注意的是对账是一个和业务的数据特性强相关的事情,不同场景下的对账逻辑不同,随着数据迁移的策略不同,对账方法也不同,不如如果使用停机迁移,那么数据就应该是完全一致的,但如果是增量写新的策略,则新数据一定比旧数据更新。
4.4、灰度切换
无论哪种数据迁移策略,都会涉及一个灰度放量的过程,避免一把梭访问新数据库,导致出现问题时成倍放大。
一般的灰度过程是:先内部白名单用户进行体验,然后按照业务的特点,设计灰度规则,比如根据用户比例,或者用户类型,或者用户地域,进行逐步地灰度放量。
回滚的过程则与此相反。
在灰度的过程中,需要持续地观察监控、日志和数据是否符合预期,一旦出现问题,及时进行处理。
这也要求在灰度之前制定详尽的计划,提前考虑可能出现的问题,针对每个可能的问题做好预案,如何埋点,如何发现问题,如何回退,如何补偿用户等。
比如,对所有读写操作、新旧数据库的读写请求情况进行埋点,确认能了解灰度的进程以及灰度范围是否符合预期;定期对账及时发现数据出入;迁移完成后可以双读并进行数据对比一致性告警一段时间等等。
很多系统会提供按流量比例灰度放量的能力,但是要根据业务特性设置更符合业务要求的灰度维度,则可能需要特殊开发设计。
比如如果需要根据用户地域灰度,则大致需要这些步骤:
- 如果涉及前端变动,特别是APP/小程序的,需要提前发版支持查询后台确认当前用户展示的前端页面版本
- 开发根据需要的维度按名单/比例筛选用户的能力
- 提供根据用户确认前端展示版本与新旧数据的接口
- 在后台读写接口中支持根据3的接口返回的信息选择读写策略
4.5、接口兼容
数据迁移不仅仅是简单的换一个数据库使用,有时候如果数据的变化特别大(如数据结构、使用方式都变了),那么还会影响数据读写的接口格式。
如果涉及到提供给其他系统的接口,由于其他系统的开发人员不一定能够跟随我方的数据迁移安排进行接口调用的变动,因此这里存在一个接口兼容的问题。
接口兼容不完全等同于双写。
首先即使是停机迁移,如果涉及数据接口的变化即接口的变化,一样会有接口兼容的问题。
其次一般双写的话,更好做双写的入口是在新接口,即对新接口的协议做好设计,使其能够满足新旧数据格式的诉求,然后将写请求转发到新旧两个库。
而旧接口由于仅针对当时的旧数据格式设计,可能无法满足新数据格式的需要。
但如果其他调用我们写接口的系统无法在我们上线前完成调整,则其依然会调用旧接口,此时我们只能从旧接口进行双写,就需要我们在旧接口对新接口需要的内容做好兼容。
此时需要我们对新旧接口需要的数据进行分析对比,找出字段间的对应关系,对于旧接口没有而新接口需要的内容,需要明确一个默认值。
当然这个过程不要持续太久,最好明确一个接口的迁移截止时间,推动其他调用的系统按期完成从旧到新接口的调整,才能获取到最准确的新数据。
4.6、脏数据
旧数据之所以旧,是因为从过去运行了一段时间,而在这个过程中,可能随着业务发展,对数据结构和使用方式进行了很多调整,比如新增了字段、新增了数据校验规则、新增了数据间的依赖关系等。
而在新增这些数据、规则、关系之前的数据中,这些内容可能是空的、不满足规范的。因此在数据迁移的过程中,极可能遇到一些不符合预期的“脏数据”。
这些脏数据,都会影响我们的数据迁移进程,如果没有进行恰当的处理,极有可能导致数据迁移失败、不完整,而如果我们的迁移时间比较紧张,就更可能会忙中出错。
要规避这个问题,有两个应对手段。
第一,尽量减少所要迁移的历史数据时间跨度,只迁移最近一段时间的数据。
前面分析过,数据是随着业务的发展不断规范的,因此越靠近当下的数据,越规范,越古早的数据,则越可能因为规范的缺失,出现脏乱差的局面。
因此如果可以,尽量减少需要迁移的数据时间,这样需要特殊处理的脏数据会更少。
而对于需要完整历史数据的场景,可以通过其他手段提供,比如保留旧数据库,但仅提供离线访问场景,只用于需要查询历史数据的情况,而且不提供在线查询,并从业务规则上,限制对过于久远的历史数据的写操作。
最极端的,是完全不迁移历史数据,这适合一些对于历史数据的可读写时间很短的业务,可以通过延长双写时间,让新数据库拥有需要提供读写的短期新数据,而更久远的数据,一概不提供读写,就可以做到完全能不迁移。
第二,是提前摸排数据,掌握脏数据的分布情况,和产品同学沟通脏数据的处理手段,尽量规避在数据迁移的过程中出现不可预知的无法处理的脏数据,导致影响数据迁移过程。
比如一个枚举字段,按当前的规范应该有四种可能的枚举值,但历史数据可能有一些无数据或其他枚举值的情况,此时只能通过对历史数据的摸排去看到底有没有,如果有,需要沟通合适的处理方法, 比如在新数据中对不属于四种合理值的情况都默认设为某个值。
最极端和简单的做法,是对所有字段,明确一个迁移的检查规则,不符合规则的,就都设置一个兜底的默认值,这样可以规避大部分问题。
通常情况下,上述两种方法需要共同使用。第一个方法可以极大减少工作量,第二个方法可以极大减少异常出现的概率。
5数据迁移的质量评估
5.1、完整性
完整性是指数据应该有新数据的逻辑规范所要求的所有必要属性。
旧数据由于历史的发展迭代,可能存在大量数据内容的缺失,此时就需要有默认的规则对其进行补齐。
还需保障有明确的规则来评估新数据在新规则上是完整的。
5.2、唯一性
新数据在新的规则下,要维持自己的记录唯一性,这个唯一性和旧数据可能相同,也可能不同。
也许旧数据出于历史原因,会存在重复的数据,那么在新数据下,要有合理手段保障只保留唯一有效的记录(唯一键、幂等性)。
5.3、及时性
数据的迁移一定涉及存量数据迁移和增量数据同步的问题,对于存量数据很好做到仔细的核对,但同时也要保障增量数据的及时同步。
数据从产生,到能被消费,需要保障新数据和旧数据的延迟尽量低,以满足业务在数据迁移期间的正常数据诉求。
5.4、合法性
无论新数据还是旧数据,其实都应该保障数据合法,所谓的合法,既是指要符合业务的逻辑(比如订单数据内一定应该包含商户信息),也是指要符合数据字段本身的要求,比如常见的电话号码、邮箱格式,或者业务特定的数据格式。
对于旧数据的不符合规范的数据,要有排查摸底,以及合理的筛选发现逻辑与处理规则。
对于增量数据的写入,则应当经过严格的数据校验。
5.5、一致性
一致性很好理解,新数据和旧数据通常来说应该是能一一对应上的(重复数据除外)。数据对账是一种常见的一致性比对方式。
但除了旧数据,在特定业务场景下,新数据最好还能够保障和真正的数据源(上游数据来源)做到一致性,这是因为数据最终是为了业务服务的,如果和上游数据不一致,则会导致业务流程中出现错漏的情况,而旧数据本身和上游,并不一定保障了一致性。
6、小结
关于数据迁移,由于变化太剧烈,而且可以造成的影响太大,因此无论对细节思考到何种程度都是不为过的,本文也仅是记录下我在工作中思考的内容,实际上在每个业务的数据迁移过程中,都可能会有特殊的需要注意的地方。也引用三体中的一句话来结尾:
章父:要多想
章北海:想了以后呢?
章父:北海,我只能告诉你在那以前要多想
——《三体》