一、前言
好的方案是一步步演进出来的。当前最优的系统方案,可能在下一个月、三个月或半年后,就会遇到瓶颈,需要调整自身以便适应新的业务场景。系统的演进就是一个快进版的人类进化史。
我之前负责的一个系统,一开始基本没啥数据量,短短几个月数据量就达到了30w+/天,也就是1个月后核心业务表就接近1千万(MySQL数据库),为此对系统进行了从单个数据库到分片库的升级改造。
二、演进历程
该系统从20年5月初首次发版以来,由于业务量的增长,在数据存储层,经历过3个阶段的发展,如下图:
2.1、单库阶段:
20年5月,系统搭建初期,支持的用户并不多,因此初版只是完成了数据模型的确立和基本功能的实现,采用了1主2从的单库结构,2个从库部署在不同的机房,防止单点故障。数据库cpu/内存/磁盘配置:8C/12G/128G。
2.2、Redis + 读写分离:
20年9月,系统支持了实物+服务的业务场景,跟订单中心打通,打通后系统数据量开始稳步提升,增量数据大约在5万/天。系统流量的增长,一方面让大家看到了系统带来的价值,另一方面也意识到了,需要做些什么来保护数据库。具体措施:
①针对查询接口,增加了主动式缓存:在查询频率较高的场景下,提前把数据放入缓存进行预热,减少回表查询。
②读写分离+从库负载均衡:JED弹性库支持通过不同的账号,进行不同权限的数据库操作:rr账号支持读写,ro账号仅支持读。因此数据层增加了只有读权限的数据源配置,通过逻辑改造,实现读写分离。另外ro账号可以通过DBA调整配置,对读操作进行负载均衡(默认是不支持的),进一步提升数据库读操作的吞吐量。
③数据库配置升级:从原来8C/12G/128G升级到了16C/16G/256G,从库升级为3个。
2.3、ES + Redis + 数据库分片:
20年双11,业务进一步增加了推广力度,增量数据30万+/天,其中11.11当天突破百万。系统流量的再次增加,按照目前的方案,不到1个月单表数据量就会达到千万级别,到了这个级别后,很难保证MySQL的性能,可能原来某个正常在用的功能,第二天就会出现因为慢SQL导致的接口超时、操作无响应、页面白屏等。并且针对业务的发展来说,未来会投放更多的入口,系统会迎来更大的流量。因此在方案上又进行了优化,具体如下:
①排查慢SQL:梳理DAO层SQL,排查未走索引的查询,优化相关SQL语句。避免表的关联查询,统一调整为单表查询,数据的加工处理放在逻辑层,数据库只做存储和简单查询(这点在系统搭建初期贯彻的就比较好,基本没有关联查询)。
②数据库分库:从单库切为24个分片库,按照30万/天的增量规划,支持未来2年的发展。根据用户PIN的hash值做路由,由于面向的是整个京东的C端用户而非特定用户群体,因此可以避免数据倾斜。
③引入ElasticSearch:将数据在ElasticSearch中异构一份,对外提供查询服务,进一步降低对数据库的压力,同时支持更丰富的查询场景。MySQL跟ElasticSearch之间的数据同步,是通过binlog实现监听MySQL数据库变更的日志来完成的。
本文重点围绕第3阶段的数据库分库内容展开。由于系统不是一开始就进行的分片,因此需要将数据从单库,迁移到分片库,并且保证整个迁移过程平滑,不能对现有业务有任何影响。如果迁移过程出现异常,支持快速回滚,并且不能丢失数据,即实现不停服的数据迁移。
三、不停服的数据迁移
3.1、技术选型及对比
数据库分库分表技术已经很成熟,很多互联网公司的系统发展到一定量级后,都会通过垂直拆分、水平扩展的方式,来提升数据库的性能。
垂直拆分:把一个有很多字段的表给拆分成多个表,或者是多个库上去。每个库表的结构都不一样,每个库表都包含部分字段。一般来说,会将较少的访问频率很高的字段放到一个表里去,然后将较多的访问频率很低的字段放到另外一个表里去。因为数据库是有缓存的,你访问频率高的行字段越少,就可以在缓存里缓存更多的行,性能就越好。
水平拆分:把一个表的数据给分到多个库的多个表里去,但是每个库的表结构都一样,只不过每个库表放的数据是不同的,所有库表的数据加起来就是全部数据。水平拆分的意义,就是将数据均匀放更多的库里,然后用多个库来扛更高的并发,还有就是用多个库的存储容量来进行扩容。
水平拆分,可以通过只分表、只分库或分库+分表的方式去做,对于分库分表,有很多成熟的数据库中间件,按照实现原理可以分为2类:应用层依赖中间件 和 代理层依赖中间件。
分类 | 应用层依赖中间件 | 代理层依赖中间件 |
---|---|---|
原理介绍 | 重新实现JDBC的API,通过重新实现DataSource、PrepareStatement等操作数据库的接口,让应用层在基本不改变业务代码的情况下透明的实现分库分表的能力。 | 在应用和数据库的连接之间搭起代理层,上层应用以标准的MySQL协议来连接代理层,然后代理层负责转发请求到底层的MySQL物理实例,这种方式对应用只有一个要求,就是只要用MySQL协议来通信即可。 |
优点 | 不用额外部署,运维成本低;不需要代理层的二次转发请求,性能很高; | 应用层无感知,接入成本低;如果遇到升级之类的,proxy代理层改造即可,业务系统不需要升级发布; |
缺点 | 不能跨语言,比如Java写的sharding-jdbc显然不能用在C#项目中;如果遇到升级,各个系统都需要重新升级版本再发布; | 需要有专门的中间件团队维护,运维成本高,一般只有大中型企业有自己能力开发、维护; |
代表产品 | 当当的sharding-jdbc、蘑菇街的TSharding、携程开源的Ctrip-DAL等 | 阿里的MyCat、京东内部的JED弹性库 |
最终方案:采用JED弹性库,有独立的运维团队支持,并且公司内部很多核心业务都在使用。实现方式上,采用了只分库不分表的方式,以用户PIN做为分库的路由(之前考虑到会分库,因此从系统搭建时每个表就保留了该属性)。
3.2、关键点
①数据迁移:存量数据需要从单库迁移到分片库。增量数据需要实现双向同步。
②灰度切量:为保证整个过程平稳,需要做灰度切量。即按照一定的流量比例(比如千分比、万分比等),将流量逐步切到分片库上,保证有较长的窗口期进行充分验证,验证通过后可以全量切到分片库。
③数据校验:验证两边数据源数据是否一致。
整个过程,可以用下图来表示:
3.3、如何做数据迁移
3.3.1、数据单向同步
通过DBA进行存量&增量数据的迁移。如下图:
从单库迁移到分片库,在这个过程中迁移了1200万份用户数据,用时不到1个小时,不得不说还是很给力。正常情况下,DBA迁移完数据,业务系统将DAO层数据源改为分片库,直接上线,就完事了。但,这只是理想情况……,会有以下问题:
①必须要停服务:为了防止上线过程中单库跟分片库都在写数据造成两边不一致,因此要先把服务停下来,不能再往单库里写新数据,等上完线后,再启动服务。会影响到正常的业务操作。
②只能一刀切,切换风险大:如果上线后发现某些SQL未覆盖到,不支持分片库操作,只能进行回滚,因此在上线到回滚期间,会造成数据丢失。
鉴于以上情况,我们决定在DBA完成历史数据的迁移后,由我们自己的业务系统承接迁移任务,实现数据双向同步,以便支持回滚,保证迁移过程不发生任何一笔数据丢失。
3.3.2、数据双向同步
业务系统改造,主动监听单库、分片库的binlake,进行增量数据正向、逆向同步。如下图:
在进行数据双向同步过程中,有以下几点需要注意:
①由DBA同步切换为业务系统同步,要保证无缝衔接,即这个切换过程既不能丢失数据,又不能插入重复数据:在前者停掉后,后者立即启动,实际中很难保证,所以需要并行混跑一段时间(比如30分钟)来保证切换的过程中不会有数据丢失。
②重点:Binlake双向同步,容易导致数据循环更新,直到把数据库打挂,需要识别出重复的binlake数据。可以使用update_time字段,如果binlake中的值,比数据库新,则在数据库中更新该条记录;如果update_time的值,比数据库中的旧或相等,说明是重复binlake,可以忽略
③重点:DAO层XML中的update_time字段,不能使用now()函数,这样会造成循环更新。比如单库执行了update table set update_time = now(),分片库监听到binlake后,发现该条记录时间比自己新,需要执行update进行更新,也会set updat_time=now(),这时候时间就又变成了最新的,导致循环更新!
④数据双向同步,不涉及业务逻辑的改造,可以建立新集群,只承担数据同步的功能。
3.4、如何做灰度切量
3.4.1、灰度流程
业务系统改造,入口处增加AOP切面,支持灰度切读、写接口。如下图:
说明:
①AOP切面解析入口方法的参数,识别出用户PIN,根据用户PIN做路由,根据一系列规则的ducc配置,决定当前请求走哪个数据源。
②重点:读写接口的路由规则要一致,否则会存在数据延迟的情况。比如用户A的请求,在写接口路由到了分片库,那么用户A的查询也必须路由到分片库。因为数据的同步是异步进行的,在接口实时性要求较高的场景下,用户A的查询路由到单库,可能数据还没同步过来,导致查询不到数据!另外为了进一步降低延迟,单库跟分片库都不再进行读写分离,统一走主库。
③重点:业务侧系统需改造SQL,保证所有SQL都会通过路由key进行查询、修改操作,否则SQL命令需要在所有分片上执行,增加了执行时间。
3.4.2、数据源动态切换:
现在有了2个数据源,在获取链接的时候,就需要做有机制来实现数据源的路由,可以利用spring的AbstractRoutingDataSource来实现,流程及数据库配置如下:
AbstractRoutingDataSource原理如下图。在我们的场景中,根据用户PIN解析后,将对应的数据源信息放入到ThreadLocal线程本地变量中,执行数据库操作时,从ThreadLocal中获取当前请求对应的数据源,然后执行相应SQL。
3.5、数据一致性校验
关于数据库对比验证,前提是不管单库,还是分片库,严格情况下都需要查询全量数据做对比校验(当然实际情况可以根据业务场景来确定对比数据的范围,比如是否只关注最近半年、3个月还是1个月的数据,以及允许的误差是万分之一,还是百万分之一等),保证数据同步的一致性,这点非常重要,是项目平稳上线的重要保证!
3.5.1、存量数据
DBA通过transfer工具完成数据迁移,并且有自己的校验机制:CDC校验。是一种消息摘要算法(信息指纹),将2个数据源的数据,分别生成对应的信息摘要,然后对比是否一致。
3.5.2、增量数据
根据指定时间范围,将对应每条数据转化为相应的HashCode,然后对比2个数据源中指定记录的Hashcode是否相等。为了提升对比的效率,这里使用了Redis的有序集合(sorted set),它的跳跃表结构,能更高效的进行2个数据源的查询和对比。
关于数据校验,这里遇到了非常有意思的问题:要对比数据,首先要查询,当然一次查询出上千万条数据肯定是不可行的,直接导致数据库服务器OOM,因此只能分页查询。但是针对千万级别的分库数据而言,分页查询也并非易事。
对于单库可以对limit查询改造,比如改为select * from table_1 a inner join(select id from table_1 limit pageNo, pageSize) b where a.id = b.id
,但是对于分片库却无法分页查询,即不能使用limit。
原因:分析下这样的SQL:select * from table limit #offset, #pageSize
①深分页的查询中,查询代价非常大:这样的SQL在分片库上查询,由于不走路由,需要在每个分片上执行,执行的逻辑是每个分片查询前N页的所有数据,而非只查询第N页,然后在网关层把所有分片的前N页数据汇总,假如M个分片,即要从 (N * pageSize ) * M 条记录中,取出第N页的数据,类似于ES的分页查询。
②结果集不是稳定的:这样的SQL在网关层做汇总时,由于没有排序,从(N * pageSize ) * M条记录中取出第N页数据,由于SQL语义不明确,所以并不能保证是一个稳定的结果集。如果要保证结果集稳定,必须要增加order by,这样会更增加每个分片、网关层的查询代价
解决方案:使用MySQL的流式查询,流式查询与普通查询不同之处在于并不是一次性将所有数据加载到内存,在调用next()方法时,MySQL驱动只从网络数据流获取到1条数据,然后返回应用,这样就避免了内存溢出问题。相关原理介绍
以下展示了如何开启流式查询
四、注意事项
1、所有的SQL语句都要梳理一遍,保证①所有查询语句,都带路由PIN;②所有update语句,都会更新update_time字段。③所有insert语句,都带update_time字段。
2、SQL语句不支持update路由字段,可能会导致数据逻辑丢失,因此JED会在语法上进行约束,update路由字段时,直接提示不支持。即使更新前后路由key的值一样也不行,proxy层不会判断是否相等,所以直接拒绝。
3、表变更的binlake消息,是共用一个topic,还是每个表一个topic?
建议每个表一个binlake的topic,减少消息积压,以及数据同步的延迟。或者量比较小的表,可以共用一个topic,量比较大或者关键的表,单独一个topic
4、表变更的binlake消息,是无序的,有可能第2次变更的消息先到,目标数据库提前更新成最新值。当第1个变更的消息到达时,通过消息里update_time会早于目标库的update_time,因此可以直接忽略;
5、源库、目标库都开启了binlake,因此1次表变更,源库会发出binlake消息,目标库也会发出binlake消息,但是binlake消息中的update_time跟对方库里的update_time是一致的,因此可以直接忽略;
6、如果只更新了数据,但是未更新update_time,会导致在另一个数据源的表中丢失变更,因为两侧时间一致,无法确认谁是最新的数据;
7、针对历史数据的update_time为空,可以约定为旧数据,前提是所有变更都更新了update_time,这样源库中update_time有值,目标库中update_time为空的话,作为旧数据来更新即可。
(END)