一、背景
随着机票订单业务的不断增长,当前订单处理系统的架构已经不能满足日益增长的业务需求,系统性能捉襟见肘,主要体现在以下方面:
- 数据库CPU资源在业务高峰期经常达到50%以上,运行状况亮起了黄灯
- 磁盘存储空间严重不足,需要经常清理磁盘数据腾挪可用空间
- 系统扩容能力不足,如果需要提升处理能力只能更换配置更好的硬件资源
因此我们迫切需要调整和优化机票订单数据库的架构,从而提升订单系统的处理性能。通过建立良好的水平扩展能力,来满足日益增长的业务需求,为后续系统优化和支撑10x订单量的增长打下良好基础。
1.1 存储架构的演进
我们选择一个新的系统架构,应该基于当下面临的问题,综合成本、风险、收益等多方面因素,选择出的最合适的方案。机票订单库的架构演进也不例外。
我们最开始接触机票订单数据库时,它是一个非常庞大的数据集合,所有的订单业务全部都集中一个数据库上,因此整体BR非常高。同时,我们的SQL语句也非常复杂,混杂着很多历史遗留下来的存储过程。可想而之,整个数据库当时的压力巨大,维护成本居高不下。DBA每天的工作也非常忙碌,想方设法降高频,解决慢SQL等线上问题。生产偶尔也会因为某些没有review的SQL导致数据库短暂的停止服务。
初期,我们采用了最常见的几种手段进行优化,包括:
- 索引优化
- 读写分离
- 降高频
虽然手段比较常规,通过一段时间的治理,订单库的稳定性也得到了一定的增强。总体实施成本较低,效果也是立竿见影的。
随着时间的推移和数据的积累,新的性能瓶颈逐渐显露。我们再次对系统进行了升级,对数据库架构做了改进。主要包括以下几个方面:
- 垂直拆分
订单库根据业务属性拆分成了多个数据库
基于业务对数据库进行垂直拆分在很大程度上提高了系统的可靠性和可维护性。一个上百人的团队,同时对一套数据库进行维护,对于发布变更来说是一种煎熬,同时也存在很大的风险。当一个非核心链路上的发布出现了问题,例如某些操作导致了锁表或者占用过多的系统资源,其他关键链路的数据库访问都会因此受到影响。
我们根据不同的业务场景,例如:订单管理系统、出票、退票、改签等业务,将数据库进行垂直拆分。使各自业务系统数据隔离,减少相互的影响。这些拆分的数据库,可以根据不同性能要求,灵活调整数据库的部署方式,来降低总体成本。
- 水平拆分(冷热数据分离)
通常来说,当航班过了起飞时间并且用户已经使用了当前机票,那么我们认为该订单服务已经完成,后续订单数据发生改变的可能性很小,于是会将该数据迁移到一个具有相同结构的冷数据库中。该数据库仅提供查询功能,不提供修改功能。但是我们发现少数场景仍然需要对这些数据进行修改。于是我们开发了一套数据还原功能,将处于冷数据库中的数据,还原到热数据库中,然后再进行操作。
注:我们当时采用的数据库和数据结构是完全一致的,这样做备份和还原、查询会比较方便。其实也可以采用其他类型的数据库,例如Mongo等。在读取性能和使用成本等方面可能会更具优势。
这次升级同样解决了不少问题,使数据库的稳定性得到了很大的增强。
1.2 基于冷热数据分离的适用性
虽然基于冷热数据的分库方案,在目前来看遇到了瓶颈。但是我认为它是一个非常值得借鉴的方案。我们现在仍然有大量的业务系统数据库采用这种方案对数据进行拆分。它不仅实施简单,同时运维成本也相对较低。
优势:
- 功能简单
- 实施成本低
局限性:
- 数据冷处理的规则应该相对简单,不应该经常发生变化
- 热数据的膨胀需要受到限制,否则热数据的量一旦累积过多,性能瓶颈仍然会出现
- 需要额外的查询来找到订单所处的位置(冷/热数据库)
- 因为冷数据量庞大,冷数据的查询能力、表结构调整能力都收到了限制,不能进行复杂的业务查询操作
根据我们的系统规划,在当下或者可预见的未来满足以上提到原因中的多个,那么就得谨慎选择采用此方案。或者在改方案的基础上进行优化。
正是由于我们目前的业务场景恰好命中了上面列举的所有问题,我们才需要对这个架构进行进一步调整,选择一个更好的水平扩展的方式,解决当前系统面临的问题。
1.3 当时面临的主要问题
从2019年开始,我们就开始着手研究和规划订单数据库sharding项目。当时主要面临如下问题:
1.3.1 订单的存储要求
受制于当前订单数据库架构的限制以及机票业务的特殊性(通常不超过2年的处理生命周期),改造前的订单数据库仅能够支持2年的订单存储。超过2年,我们会将数据进行归档。用户和员工都无法通过在线查询的方式获取订单信息。
但是基于以下几个方面原因,原本2年的存储和处理周期已经不能满足客户和业务的需要:
1) 从客户的角度出发,仍然有查询历史订单的需求;
2) 业务场景的拓展导致机票订单整个服务周期变长;
原先机票使用完成(出行)一段时间后就可以视为服务结束,大部分订单3个月后就不会发生变化,但是由于新业务的推出,热点数据查询和处理周期明显变长。
1.3.2 系统架构瓶颈
1)热数据膨胀
热数据原本仅千万级别,由于业务的变化热数据数量不断膨胀。
2)冷数据量庞大
由于订单存储周期拉长和订单量的增长,冷数据的数量也不断攀升。冷数据库查询性能不断下降;索引调整也变得非常困难,经常出现修改失败的场景。
3)数据库高峰期BR达到了10w+;
4)系统存储了20TB的数据,磁盘使用率达到80%以上,经常触发使用容量告警;
5)主库的CPU使用率高峰期接近50%;
6)由于采用了读写分离的架构,当主库的服务器的性能受到影响的时候,AG延迟变得非常高,偶尔达到分钟级,有的时候甚至更长。
主从同步的延迟,导致了数据新鲜度的降低。我们之前的ORM层封装了一个新鲜容忍度的参数。当不能满足新鲜度要求的时候,读取会切换到主库,从而进一步加重了主库的负担。
因此,订单库的整体性能压力非常大,如果想快速解决性能问题,只能对机器进行扩容。但是由于数据库本身就是消耗资源大户,CPU和内存消耗非常高,只能通过进一步提高数据库的硬件配置来解决问题,因此整体升级的成本居高不下。另外硬件升级完成后,SQLServer的授权成本可能也会进一步提升。
为了彻底解决以上问题,我们计划通过优化架构来提升系统的水平扩展能力,从而进一步提升我们系统的性能和服务水平。
二、项目目标和实施方案
2.1 目标
基于上文提到的这些问题,为了确保系统能够长久持续的稳定运行并且提升订单系统的处理能力,我们计划对数据库的架构进行升级,总体实现以下目标:
- 订单的存储和处理周期至少达到5年
- 提升订单系统的处理能力,支撑订单QPS10倍的规模增长
- 在提升系统性能的前提下,降低总体成本
- 提高系统的水平扩展能力,通过简便的操作可以快速扩容以应对长期的业务增长
我们希望通过1-2年的时间,实现对数据库架构升级改造以及完成SQLServer迁移到MySQL的目标。
2.2 架构改造
2.2.1 新旧架构的对比
旧系统架构:
架构说明:
1)订单数据库
为热数据的主库,提供读写功能
2)订单数据库 Slave
为热数据的从库,在保障新鲜度的前提下提供只读功能,采用SQLServer的AlwaysOn技术
3)订单备份数据库
为冷数据库(没有主从之分),仅提供只读功能
4)Archive
备份机制,根据业务的需要,为了缓解主数据库的压力,对于符合条件的订单(通常时起飞后+已出行)定时迁移到冷数据库的操作
5)Restore
还原机制。在一些特殊情况下,需要对已经备份的数据进行修改,我们需要将数据从冷数据库中恢复到热数据库然后才能进行操作。这个操作叫做还原。
当前存在的问题:
1)数据量变多,业务场景变得复杂后,主库的数据量从千万级增长到亿级别,对数据库的性能产生明显影响
2)冷数据库的历史累计数据量也在不断膨胀,受到本地SSD磁盘容量的限制,磁盘空间使用率达到了80%以上
3)冷数据库的订单数量达到了十亿级,数据库索引调整,结构调整变得较为困难;查询性能也受到了很大的影响
4)备份和还原逻辑需要根据业务要求不断调整
新系统架构:
架构说明:
1)订单数据库Shard Cluster
新数据库基于订单号将数据水平拆分成64个分片,目前部署在16台物理机上
2)订单聚合数据库
针对热点数据,通过Binglog和有序消息队列同步到订单聚合数据库,方便数据监控,并且用于提高数据聚合查询的性能
2.2.2 新旧架构的差异
新旧系统的主要差别包括:
- 新数据库的拆分维度从冷热数据变更成了根据订单号进行水平拆分
- 数据库从1拆2,变成了1拆64解决了磁盘存储空间不足的问题
- 新数据库的部署方式更加灵活,如果16台物理机器资源不足时,可以通过增加服务器的数量快速提高数据库的处理性能
- 如果64个分片的数量不足时,可以通过调整分片计算的组件功能,扩展分片数量
- 原先的SQLServer采用的一主多从一DR的模式进行配置。当前系统每个分片物理服务器采用一主一从一DR的模式进行配置
- 通过增加订单聚合数据库将部分跨分片的数据通过Binglog+有序消息的方式聚合到新的数据库上,降低跨分片查询带来的性能损失
2.3 技术方案
在项目执行过程中有非常多的技术细节问题需要分析和解决。我们列举一些在项目过程中可能会遇到的问题:
- 如何选择分片键
- 如何解决跨分片查询性能的损失
- 如何提高开发效率,降低项目风险
- 全链路的灰度切换方案
- 分片故障的处理方案
下面我们就选择几个典型的例子,来说明我们在项目过程中遇到的问题,以及解决这些问题的方案。
2.3.1 分片键选择
分库的第一步也是最重要的一步,就是选择分片键。选择的原则是:
- 分片键必须是不会被更新的字段
- 各个分库的数据量和读写压力要均匀,避免热点分库
- 要尽量减少单次查询涉及的分库数量,降低DB压力
分片键的选择,是需要根据具体的业务场景来确定。对于订单数据的拆分,常见的选择是订单ID和用户ID两个维度,这也是业内最常用的两个分片键。我们最终采用的是主订单ID,主要是基于四个因素:
- 90%的请求都是基于订单ID进行查询
- 主订单ID是对应于用户的一个订单,包含多个行程和贵宾休息室等附加产品,后台会可能将这些拆分为多个子订单,而子订单之间会做Join等关联处理,所以不能选择子订单维度
- 一个主订单可能关联多个用户ID,比如用户A为用户B购买机票,用户B又可以自己为这个订单添加值机的功能。一个订单ID关联了两个用户ID,从而使用用户ID用作分片键会导致订单分布在不同的分片
- 分销商的订单量非常大,按用户ID分库会导致数据不均衡
我们决定采用主订单号作为分片键后,进行了下列改造,用于实现并且加速分片选择的过程。
1)订单ID索引表
【问题】:如何获取主子订单对应的分片ID?
按主订单ID分库,首先产生的问题是子订单ID如何计算分库,需要查询所有分库么?我们是采用异构索引表的方式,即创建一个订单ID到主订单ID的索引表,并且索引表是按订单ID进行分库。每次查询订单ID查询时,从索引表中获取对应的主订单ID,计算出分库,再进行业务查询,避免查询所有分库。
2)索引表多级缓存
【问题】:通过索引表查询分片ID会增加了查询的二次开销,使查询性能损失严重,如何减少数据库二次查询的开销来提高查询性能呢?
订单ID的二次查询,仍然会带来数据库的压力明显上升,实际上订单ID是不会更新的,订单ID和主订单ID的映射关系也是不会发生变化的,完全可以把订单ID索引表的信息缓存起来,每次查询时从缓存中就可以获取主订单ID。
我们设计了多级缓存来实现查询加速,所有的缓存和分库逻辑都封装在组件中,提供给各个客户端使用。三级缓存结构如下:
注:图下方的数字代表在当前缓存和它的所有上级缓存命中率的总和。例如Redis的99.5%代表1000个订单有995个在本地缓存或者是Redis缓存中命中了。
客户端本地缓存:将最热门的订单ID索引存放在应用的本地内存中,只需要一次内存操作就能获取主订单号,不需要进行额外的网络IO
Redis分布式缓存:将大量的索引信息存放在Redis中,并且所有客户端可以共用Redis缓存,命中率超过99%,并且由于订单的映射关系是不会发生变化的,因此可以在生成订单号的阶段对缓存进行预填充
服务端本地缓存:对DB索引表的读取,都是在特定的应用中实现,未命中缓存时客户端是通过服务端获取索引信息。服务端也有本地缓存,使用Guava实现用于减缓热点key的流量尖刺避免缓存击穿
3)本地缓存的内存优化
【问题】:使用本地缓存可以减少索引表查询开销,如果需要提高缓存命中率,就需要消耗更多的内存使用,那么如何减少内存占用的问题呢?
本地缓存的效率是最高的,存储在本地的索引信息自然是越多越好。但本地内存是宝贵而有限的,我们需要尽量减少单个索引占用的内存。订单ID都是Long类型,每个Long类型占用24个字节,通常情况下,单个索引中包含两个Long类型, 还需要缓存内部的多层Node节点,最终单个索引大约需要100个字节。
我们主要是结合业务场景来改进内存的使用。订单ID是有序的,而且主子订单ID的生成时间是非常接近的,大部分情况下,主订单ID和子订单ID的数值差异是很小的。对于连续的数字,数组的方式是非常节省空间的,100个Long类型占用2400个字节,而一个长度为100的long数组,则只占用824个字节。同时不直接存储主订单ID,而是只存储主子订单ID的差值,从long类型缩减为short类型,可以进一步减少内存占用。
最终的缓存结构为:Map
改造后:
【Key】表示订单ID所在的桶,计算方式为订单ID对 64(数组长度)取模
【下标】表示订单ID的具体位置,计算方式为订单ID对 64(数组长度)取余数,即【KEY】和【下标】合计起来表示订单ID
【偏移量】表示主订单ID的信息,计算方式是主订单ID减去订单ID
最优情况下,存储64个索引只需要一个Long类型、一个长度64的short数组和约50个字节的辅助空间,总计200个字节,平均每个索引3个字节,占用的内存缩减到原来的100个字节的3%。
值得注意的是对于偏移量的设计仍然有一定的讲究。我们需要分析主子订单的差异区间范围。Short的取值范围是-32768 ~ 32767,首先将-32768定义为非法值。我们还发现大部分的订单分布区间其实并没有和这个取值范围重叠,因此需要额外再给偏移量增加二次偏移量来优化这个问题,实际的取值范围是:-10000 ~ 55534,进一步提高了short偏移量的覆盖面。
4)主子订单ID同余
【问题】我们对订单ID索引做了各种改进,使它运行的越来越顺畅,但三级缓存的引入,也使得我们的系统结构变复杂,是否有办法跳过索引表呢?
我们将未使用的订单ID按余数分成多个桶,新增订单在拆分订单时,子订单ID不再是随机生成,而是按照主订单ID的余数确定对应的桶,然后只允许使用这个桶内的订单ID,即保证主订单ID和子订单ID的余数是相同的。在查询时,子订单ID直接取余数就能确定对应的分库,不需要读取订单索引。
再进一步,生成主订单ID时也不再是随机选择,而是基于用户ID来分桶和选择,做到一个UID下的订单会尽量集中到单一分库中。
用户ID/主订单ID/子订单ID三者同余
2.3.2 跨分片查询优化
数据分库后,当查询条件不是分片键时,例如使用用户ID、更新时间等作为查询条件,都需要对所有分片进行查询,在DB上的执行次数会变为原来的64倍,消耗的CPU资源也会急剧放大。这是所有分库分表都会遇到的问题,也是一个分库项目最具有技术挑战的环节。我们针对各种场景,采取多种方式来进行优化。
1)UID索引表
【问题】UID是除了订单号以外消耗资源最多的查询之一,大约占用大约8%的数据库使用资源。使用allShards查询会消耗非常多的资源,严重降低查询性能。那么我们如何对UID查询进行优化,从而提升查询效率呢?
索引表是一种常见的解决方案,需要满足三个条件:
- 索引字段不允许更新
订单库中用户ID是不会被更新的 - 单个字段值关联的数据要少,或者关联的分库数量少
关联的数据过多,最终还是到所有分库中获取数据,也就失去了索引表的意义。对于我们的业务场景来说,用户购买机票是一种较为低频的行为。因此,大部分用户的订单数量相对有限,平均每个用户的订单涉及的分库数量远小于所有分库数量。 - 查询频率要足够高索引表本质上是一个“空间换时间”的思路,只有足够高的查询频率,有足够的收益,才值的实现索引表。
以用户ID作为条件的查询,是业务中非常重要的一类查询,也是排除订单ID查询后,最多的一类查询。基于业务和现有数据来分析,由于单个用户购买机票的总数并不是很多,用户ID分布在了有限的分库上。我们增加一个用户ID索引表,存储用户ID与订单ID的映射信息,并按照用户ID进行分库存储。如下图,每次用户ID的查询,会先查询索引,获取包含此用户订单的所有分库列表,通过一次额外的查询,能够快速排除大量无关的分库。再结合前面提及的用户ID与订单ID同余的策略,单个用户ID的新增订单会集中存储在单一分库中,随着历史数据的逐步归档,单个用户查询的分库数量会越来越少。
UserIDIndex表结构
2)镜像库
【问题】:并不是所有的查询都可以像用户ID一样,通过建立一个二级索引表来优化查询问题,而且建立二级索引表的代价比较大,我们需要一个更通用的方案解决这些查询问题。
AllShards查询中的另一类查询就是时间戳的查询,尤其是大量的监控查询,大部分请求是可以接受一定的延迟,同时这些请求只是关注热点数据,比如尚未被使用的订单。
我们新建了一套MySQL数据库,作为镜像库,将64个分库中的热点数据,集中存储到单一数据库中,相关的查询直接在镜像库中执行,避免分库的问题。
镜像库的数据同步,则是通过Canal+QMQ的方式来实现,并定时对比数据,业务应用上则是只读不写,严格保证双边数据一致性。
3)ES/MySQL对比
【问题】:镜像库存在多种实现方案,很多系统采用了ES作为查询引擎,我们该如何选择?
ES也是解决复杂查询场景的一种常见方案,我们曾经考虑采用ES来提升查询性能,并且进行了详细的评估和测试,但最终放弃了ES方案,主要考虑到以下几点原因:
- 项目前期对所有的查询进行了充分简化和规整,目前所有的查询使用MySQL都可以很好的运行。
- 在已经正确建立索引和优化SQL语句的情况下,MySQL消耗的CPU可能远小于ES,尤其是订单ID、时间戳等数字类型的查询,MySQL消耗的CPU只是ES消耗的20%甚至更低。
- ES并不擅长数字查询,而是更合适索引字段多变的场景。
因此具体采用ES还是MySQL,或者是其他数据库来建立镜像数据库,最重要的一点还是要基于现有的业务场景和实际生产上的需求进行综合分析和验证后,找出一个最适合自己当前情况的方案。
2.3.3 双写组件设计
因为技术栈的问题,目前我们的ORM采用的是公司的DAL组件。这个组件本身对公司的环境支持较好,而且该组件对于Sharding数据库也提供了良好的支持。因此我们在该项目上仍然使用DAL作为我们数据库的访问组件。
但是原生的DAL并不支持双写的功能,不支持读写的切换。针对项目的特性,我们需要尽可能的让开发少感知或者不感知底层数据库的双写和读写切换的操作。一切对于用户来说变得更简单、更透明。另一方面,我们打算优化组件本身的使用接口,让用户使用起来更傻瓜化。
组件的升级改造需要符合以下原则:
- 对业务代码侵入少
- 改造少,降低工作量
- 使用简单
- 符合直觉
这些改造的意义是非常重大的,它是我们能够高质量上线的关键。于是我们对组件进行了一些封装和优化。
1)业务层对象和数据库层对象进行隔离
为了统一维护方便我们将团队内所有的数据库对象(Pojo)都维护在了公共组件中。因此,在公共jar包中生成的对象通常是一个大而全的数据库实体。这种大而全的实体信息存在以下几个问题:
- 单表查询时直接只用pojo返回了全量信息,影响查询性能
- 直接在代码中使用pojo带来了大量无用的字段,不符合按需使用的原则
- 很难统计应用对于数据库字段的依赖的问题
- 数据库字段和代码直接耦合,在代码编写期间不能对字段的命名等问题进行优化
为了解决以上问题,我们中间新增了Model层,实现数据库pojo和业务代码的隔离。例如我们的航班信息表(Flight)有200多个字段,但是实际在代码中仅需要使用航班号和起飞时间。我们可以在业务代码中定义一个新的FlightModel,如下图所示:
@Builderpublic class Flight implements DalDto { /* 订单号 */ private Long orderId;
/* 航班号 */ [DalField=”flight”] private String flightNo;}
扩展组件将该对象映射到数据库的Pojo上,并且可以改变字段的命名甚至类型从而优化代码的可读性。在数据库查询时也进行了优化,仅仅查询必要字段,减少了开销。
2)双写功能
我们实现的双写方案是先写SQLServer再写MySQL,同时也实现了失败处理相关的策略。
双写模式包括:
- 异步双写
这个主要是在双写功能实现的初期,我们会使用队列+异步线程的方式将数据写入到MySQL。采取这种方式的数据一致性是比较差的,之所以采用这种方式也是在初期我们对数据库处于探索阶段,避免MySQL数据库故障对当前系统产生影响。
- 同步双写
当SQLServer写入成功后,在相同的线程中对MySQL进行写入。这种模式相对来说数据一致性会比较好,但是在极端情况下仍然可能存在数据不一致的情况。
如下图所示。当任务1更新MySQL数据库之前,如果有别的任务抢先更新了相同的数据字段就有可能产生脏写的问题。
我们可以通过以下手段减少数据不一致的问题:
- 数据表的读写尽可能收口
- 访问收口以后,通过对业务系统增加分布式锁等手段缓解此类问题的产生
- 可以增加数据比对的工具,主动发现数据的不一致并进行修复,通过一个异步的扫描时间戳的工具来主动进行数据对比注和修复
- 写入失败需要根据当前的模式触发自动补偿的策略,这个可以参考下文提到的数据双写异常的补偿方案
注:数据对比和补偿需要注意热点数据频繁更新和由于读取时间差导致的不一致的问题
刚才提到我们抽象了Model层的数据,在此基础上,我们的双写改造对用户来说非常的容易。
@DalEntity(primaryTypeName = “com.ctrip.xxx.dal.sqlserver.entity.FlightPojo”, secondaryTypeName = “com.ctrip.xxx.dal.mysql.entity.FlightPojo”)@Builderpublic class Flight implements DalDto {
我们仅需在Model对象上增加DalEntity注解实现数据库Pojo的双边映射。除此之外,开发人员不需要对业务代码做其他调整,即可以通过配置实现双写、数据源切换等操作。
3)双写异常处理模式
双写时,我们需要尽可能保证数据的一致性,对于MySQL数据写入异常时,我们提供了多种异常处理模式。
- AC
异步双写时,如果从库发生异常进行数据捕获,不抛出异常,仅输出告警信息
- SC
同步双写时,如果从库发生异常进行数据捕获,不抛出异常,仅输出告警信息
- ST
同步双写时,如果从库发生异常,抛出异常,中断处理流程
4)双读功能
双写功能相对比较好理解。在灰度切换过程中,假如存在灰度控制的订单A以SQLServer为主,订单B以MySQL为主。但是我们查询到结果中同时包含了订单A和订单B的场景。这个时候我们希望的是,同时查询SQLServer和MySQL的数据源,并且从不同数据源中获取相应的订单数据,然后进行组合、排序、拼接。这些筛选逻辑由我们的组件来自动完成,从而实现了更加精细的灰度控制。
值得注意的是allShard查询的结果在部分情况下(例如分页查询)和单库查询的结果存在较大的差异,也需要组件的支持。
5)数据写入异常的补偿方案
我们需要在不同阶段设计不同的补偿方案。初期MySQL的数据并不会对外提供服务,即使数据写入失败,也不能影响系统流程的正常运行,同时也要保证数据写入的准确性。因此,我们采用了SC的异常处理模式,并且增加了主动和被动的数据补偿。
但是我们的目标是使用MySQL的数据。因此,当主数据源需要SQLServer切换到MySQL后,虽然数据库写入的顺序仍然保持先写SQLServer再写MySQL,但是数据写入失败的处理模式需要发生变化。
这里先插播一个问题,就是为什么不能先写MySQL然后同步更新SQLServer。主要考虑到以下两个因素:
- 数据库主键生成的历史遗留原因
由于MySQL是Sharding数据库,如果先插入该数据库,默认情况下会通过雪花算法生成主键。写入完成后,我们将该主键同步给SQLServer。
但是受到公司ORM框架和历史遗留的技术限制,SQLServer不会使用该数据,仍然采用自增的方式生成主键。导致数据严重不一致。
- 数据双向同步的复杂度问题
当我们以SQLServer作为主数据库时,如果数据不一致需要同步给MySQL(异步存在延时);当以MySQL作为主数据库时,如果发生数据不一致,需要进行反向同步。
一来,数据补偿程序复杂度很高。二来,如果我们如果在MySQL和SQLServer数据库谁作为主库之间切换频繁,数据同步程序就会变得非常迷茫,到底谁该同步给谁?
那么如何提高在以MySQL为主的情况下,双边数据库的一致性呢?
首先,我们得关闭自动补偿功能,异常处理模式需要从SC切换到ST,遇到MySQL失败直接抛出数据库异常,然后基于系统的业务场景进行如下操作:
- 依赖业务系统的自动补偿
对于订单处理系统,大部分的流程其实具备了自动补偿的能力,因此哪怕SQLServer更新成功,MySQL未成功。下次补偿程序仍然读取MySQL,SQLServer会被二次更新,从而达到最终一致性。
这个时候,需要考虑的SQLServer的可重入性。
- 无法自动补偿的场景,提供手工数据补偿的功能
因为此时MySQL已经作为主要数据源,如果SQLServer存在不一致的场景可以提供手工的方式将数据补偿回SQLServer。这边没有实现自动补偿,因为理论上只有在数据不一致的场景,并且发生了回切才会产生影响。
- 数据的比对功能仍然正常开启,及时发现数据的不一致
6) 组件设计的功能和策略分离
我们整体的功能都整合在名为Dal-Extension的系统组件里,主要分为功能实现和策略两大部分。
功能就是前面提到的例如双写,读切换,异常处理模式切换等。策略就是引擎,它实现了功能和功能间的联动。例如上文提到的,如果以SQLServer作为主数据源,那么系统自动采用SC的异常处理模式,并且主动调用数据补偿功能。如果是以MySQL作为主数据源,那么系统自动切换到ST的异常处理模式。
相较于基于应用、表维度的切换策略。我们提供了维度更丰富的切换组合策略。
- 表
- 应用/IP地址
- 读/写
- 订单区间
通过对以上维度的配置进行灵活调整,我们即可以实现单表,单机器的试验性切换控制,也可以进行全链路的灰度切换,确保一个订单在整个订单处理生命周期使用相同的数据源,从而避免因为数据双写或者同步导致的数据读取结果不一致的问题。整体的数据切换操作由配置中心统一托管。
2.3.4 分片故障处理
原先的数据库如果发生了故障,会导致整个系统不可用。但是新的数据库扩展成64个分片后,其实相对来说故障概率提高了64倍。因此,我们需要避免部分分片故障导致整个系统失效的情况。另外增加故障转移和隔离功能,避免故障扩散,减少损失也是我们重点关注的功能。
(当然,如果发生分片故障,首选的故障恢复方案是数据库的主从切换)。
1)返回仅包含查询成功分片的部分数据
【问题】针对跨分片查询的场景,如果一个分片故障默认情况下会导致整个查询失败,那么如何提高查询成功率呢?
我们调研了数据使用端,发现有很多场景,例如人工订单处理的环节,是可以接受部分数据的返回。也就是说有查询出尽可能多符合条件的订单,放入人工待处理列表中。我们增加了continueOnError参数来表示当前查询可以接受部分分片失效的场景。并且,系统返回了查询结果后,如果存在分片查询失败的场景,系统会提供了错误分片的信息。这样业务上不仅能够确保了很多业务环节处理不中断,同时针对它提供的错误分片信息可以让我们快速感知失效的分片,以便系统自动或者人工对这些分片进行干预。
2)故障分片隔离
【问题】当故障分片出现大规模错误后,如果是因为响应时间长会导致大量线程block,从而拖累整个应用服务器。那么如何解决此类问题呢?
当分片发生故障时,有可能我们的数据库请求被hang住。我们allShards查询的底层实现是基于共享线程池。当部分分片的响应慢时,会拖累整个线程池。另外单表查询时,也可能会因为数据库响应时间的问题导致工作线程数量上涨的问题。
我们为此增加了分片屏蔽的参数。当我们启用分片临时屏蔽功能后,底层数据库查询时,发现该分片被屏蔽直接抛出异常,让应用程序能够得到快速响应。从而避免了网络和数据库访问时间消耗,提高了异常执行的效率,避免问题扩散到正常的分片的数据处理。
3)故障订单转移
【问题】根据之前的介绍,用户订单号是根据UID的哈希值进行分配的。也就是说同一个用户分配的分片是固定的。如果该分片故障时,用户就无法提交订单。那么如何避免或者减少此类问题呢?
如上图所示,用户ID_1和用户ID_2根据哈希算法,原先会在分片1上生成订单。但是如果发生了分片1故障时,我们的UID分片计算组件会将分片1标记为不可用,然后通过新的Hash算法计算出新的分片。
这里需要注意的是,新hash算法的选择。
方法1:
使用同样的哈希算法,但是生成结果后取模的值为63(64-1),但是这个存在的问题是用户ID_1和用户ID_2计算出来的分片结果是一致的。假如新的分片号为2的话,如果发生分片1、分片2同时失效的情况下。那么仍然有1/64的订单出现问题。
方法2:
采用新的哈希算法,尽量使订单分布在出了分片1以外的其他分片上。那么这种方法,即使分片1、分片2同时失效。那么仅仅会影响到1/64 1/63的订单。受影响的订单量大幅降低。
三、项目规划
除了以上提到的技术问题以外,我们再谈谈项目的管理和规划问题。首先,圈定合理的项目范围,划清项目边界是项目顺利实施的重要前提。这个项目的范围包括两个重要的属性:数据和团队。
数据范围
1)划定数据表范围,先进行表结构优化的工作
我们需要在项目初期明确数据表的范围,针对一些可以下线的表或者字段,先完成合并和下线的工作,来缩小项目范围。避免表结构的变化和该项目耦合在一起,造成不必要的困扰。
2)相关数据表中哪些数据需要被迁移
我们在处理这个问题上,有一些反复。
方案1:仅迁移热数据
因为订单数据分为冷热数据,所以我们最开始考虑是不是只要迁移热数据就好了,冷数据仅保留查询功能。
但是,这个方案有两个很大的问题:一是存在冷数据需要被还原到热数据的场景,增加了系统实现的复杂度。二是冷数据保留时限的问题,无法在短时间内下线这个数据库。
方案2:部分数据自然消亡的表和字段不进行迁移
针对有一些表由于业务或者系统改造的原因,可能后续数据不会更新了,或者在新的订单上这些字段已经废弃了。大家在设计新表的时候其实往往很不喜欢把这些已经废弃了的信息加到新设计的表中。但是,我们需要面临的问题是,旧数据如何兼容是一个非常现实的问题。
因此,当我们开发到中间的过程中,还是将部分表和字段重新加了回来。来确保旧数据库尽快下线以及历史逻辑保持兼容。
方案3:保留当前所有的表结构和信息
我们最后采用了这个方案,哪怕这个数据表或者字段未来不会做任何修改。
团队范围
确定好数据范围后,我们需要根据这些数据,确定我们需要做的工作以及找到完成这些工作的相关团队并提前安排好资源。整个项目的资源分为核心成员和相关配合改造的团队。
核心成员需要做到组织分工明确,并且需要经常一起头脑风暴,提出问题,解决问题,消除隐患。核心成员的另一个职责是帮助配合改造的团队,协调并且解决技术问题、资源问题等等。特别是涉及到的改动点较多、改造难度较大的团队,需要提前介入,在适当的时候提供更多的帮助。
*3.1 规划
确定了项目的目标和范围后,我们为项目设计了6个里程碑,来帮助我们更好的完成这个项目。
阶段1:通过API对读取进行收口
这个阶段虽然难度并不大,但是周期很长,沟通成本较高。在这个阶段重点在于任务的协调和跟进。DBA帮助我们研发了生产Trace查询的工具,能够准实时的知道数据表的访问情况,帮我们快速验收并且圈定改造范围。
我们建立了任务的看板,为每一个任务设定了负责人以及预期解决的时间,定期对任务进行进行跟踪。项目的负责人也作为验收人,确认每个任务的完成情况。
通过一段时间的努力,数据库的访问收口在极少数内部应用当中。实现了数据访问的收口。
阶段2:开发双读/双写功能来实现平滑的数据切换
这个阶段需要将整个项目的技术点、难点都逐一的找到,并且给出解决方案。如何提高效率和质量也是这个阶段重点关注的话题,我们尽量把这些双写、切换的功能进行封装,让业务逻辑层尽可能少感知,或者不感知这些底层逻辑。降低代码开发量,不仅能提高效率,还能提升质量。
总的来说,这个阶段需要提升开发效率,提高开发质量并且降低项目风险。
阶段3:验证数据一致性
这个是对阶段2的验证工作,需要注意的是在验证中减少噪音,提高验证的自动化率,能有效的提升项目的开发质量。
阶段4:通过压测,故障模拟等手段验证系统性能。在数据库故障时,提供可靠的系统的灾备和故障隔离能力。
阶段5:数据读取从SQLServer切换到MySQL
这个阶段可能不需要有太多的资源投入,但是风险却是最大的。这个阶段是对前面所有阶段成果的验收。做好数据监控、制定良好的切换方案、出现问题时能够回退是这个阶段顺利实施的重点。
阶段6:停止SQLServer写入并且下线相关数据表
相比起阶段5,阶段6没有后悔药。一旦停止了SQLServer的写入,就非常难进行回切的操作。所以得仔细做好白名单的验证,并且及时响应和解决相关问题。
3.2 原则
整个项目周期较长,我们需要制定好每个阶段的目标,每个任务的目标。由于数据库承载了非常核心的业务,因此整个阶段、所有任务以及技术方案其实围绕着一个原则展开,就是降低风险。
所以我们在设计每个技术方案的时候,尽可能考虑这点。例如在数据源切换的开关虽然涉及较多的服务实例,但是我们通过一个集中控制的平台,来实现全链路的切换和灰度控制。
四、经验分享
该项目整体的周期较长,每个阶段的挑战不尽相同。为了确保项目的上线质量,后续在读切换、写切换两个流程的灰度时间比较久。项目大约在2021年下半年顺利完成。
实现了以下主要目标和功能:
- 系统的水平扩展能力得到大幅提升
系统分片数量为64,部署在16台物理机上。后续根据业务需要机器的部署方式和分片数量可以进行灵活调整。
- 数据库资源利用率大幅下降,可靠性提升
数据库服务器的CPU利用率从高峰期40%下降到目前的3%-5%之间。
- 订单处理能力提升和存储能力提升
原先区分冷热数据,热数据大约仅能支持3个月的订单,按照现在硬件资源推算,系统可以处理至少5年以上的订单。
- 数据访问收口
原先近200个应用直接访问数据库,给我们的改造带来很大的不便。目前仅有限的内部应用允许直接访问订单库。
- 整体成本下降
原先主从服务器的CPU为128核,内存256G;现在服务器缩减为40核心的标准配置。
在项目过程中也积累了不少的经验,例如:
- 项目的规划要清晰,任务要明确,跟踪要及时
整个项目中大约建立了数百个子任务,每一个任务需要落实负责人以及上线时间,并对上线结果进行验收。才能确保整个项目的周期不至于拉的非常长,减少后续的项目返工和风险。
- 减少例外情况的发生
当一个大型的项目存在非常多的例外情况,这些特殊情况就得特殊处理,那么到最后总会有一些没有处理干净的尾巴。这些问题都是项目的潜在隐患。
- 减少项目的依赖
这个和我们日常开发关系也非常密切,当一项任务有多个依赖方的时候,往往项目的进展会大幅超出我们的预期。因此减少一些前置依赖,在不是非常确定的情况下。我们得先做好最坏的打算。
- 一次干好一件事
很多时候我们往往会高估自己的能力,例如在这次的改造中,我们会顺便优化一些表的结构。于是造成了MySQL和SQLServer的数据表差异过大的问题。那么这些差异其实为后面的开发造成了不小的困扰。所有的方案,包括数据补偿、迁移、数据源的切换等等场景都得为这些特殊差异的表单独考虑方案,单独实现逻辑。一不留神或者没有考虑的很周全的情况下,往往会漏掉这部分的差异。导致项目返工,甚至出现生产故障。
项目的成功上线离不开每一个成员的努力。在实施过程中,遇到的问题比这篇文章列举的问题多得多,很多都是一些非常琐碎的事情。特别是项目初期,我们往往是解决了一个,冒出了更多的问题。但是每次遇到问题后,团队的成员都积极思考,集思广益,攻破了一个又一个的技术问题和业务问题。通过一年多时间的锻炼,团队成员的项目能力、技术能力进步显著;发现问题的角度更敏锐,思考的角度更全面;团队的凝聚力也得到了明显提升。