当前位置: 首页 > 科技观察

进来抄作业:分库分表的完美实践!

时间:2023-03-19 17:49:00 科技观察

图片来自当时Pexels使用的MySQL数据库。据监控,我们每秒的最高订单量已经达到2000单(不包括秒杀,秒杀的TPS已经达到数万,我们有秒杀的特殊??解决方案,详见《秒杀系统设计~亿级用户》)。但是直到这个时候,订单系统还是单库单表的。好在当时数据库服务器配置还不错,我们的系统才能够承受这么大的压力。业务量还在高速增长,不重构系统,迟早要出大事。我们花了一天的时间快速制定了重构计划。重构?要说这么高大上,不就是分库分表吗?确实是分库分表。但是除了分库和分表,它还包括管理端的解决方案,比如运营、客服、业务需要多维度查询订单数据。分库分表后,如何满足大家的需求?分库分表后,在线方案和数据不停机迁移方案都需要慎重考虑。为了保证系统的稳定性,还需要考虑相应的降级方案。为什么要分库分表?当数据库出现性能瓶颈时:IO瓶颈或CPU瓶颈。这两种瓶颈最终都会导致数据库的活跃连接数增加,进而达到数据库能够维持的最大活跃连接数的阈值。最终导致应用服务不可用,后果不堪设想。可以先从代码、SQL、索引等方面进行优化。如果这些方面优化空间不大,就该考虑分库分表了。第一类IO瓶颈:磁盘读IO瓶颈。由于热数据太多,数据库缓存根本放不下,查询时会产生大量的磁盘IO,查询速度会比较慢,从而导致大量活跃连接,最终可能会发展成无可用连接的后果。可以采用一主多从,读写分离的方案,使用多个从库共享查询流量。或者采用分库+水平分表的方案(将一张表的数据拆分成多张表存储,比如order表可以按照user_id拆分)。第二种:磁盘写IO瓶颈。由于频繁的数据库写入,会出现频繁的磁盘写IO操作,频繁的磁盘IO操作会导致大量的活跃连接,最终会发展成没有可用连接的后果。这时候只能采用分库的方案,使用多个库来分担写作压力。再加上表的水平分区策略,表分区后,单表存储的数据量会更小,插入数据时索引查找和更新的开销会更低,插入速度自然会更快。CPU瓶颈SQL问题:如果SQL中包含join、groupby、orderby、非索引字段条件查询等增加CPU运算量的操作,会对CPU造成不小的压力。这时候可以考虑SQL优化,创建合适的索引,或者把一些计算量大的SQL逻辑放到应用程序中处理。单表数据量过大:由于单表数据量过大,比如超过1亿条,查询时遍历树层级过深或者扫描的行数过多,SQL效率会很低,同时也会消耗大量的CPU。这时候可以根据业务场景对表进行横向划分。分库、表分库分表主要有两种方案:①使用MyCat、KingShard等代理中间件分库分表。优点是与业务代码的耦合度很低,只需要做一些配置,接入成本低。缺点是这种代理中间件需要单独部署,所以从调用连接上多了一层。而且分库分表的逻辑完全由代理中间件来管理,对于程序员来说完全是一个黑盒。一旦代理自身出现问题(如错误或宕机),将导致相关业务数据无法查询和存储,造成灾难性后果。如果不熟悉代理中间件源码,排查问题会非常困难。有一次公司使用MyCat,上线失败后被迫修改方案,花了三天三夜才恢复系统。CTO也被裁掉了!②使用Sharding-Jdbc、TSharding等轻量级组件以Jar包的形式呈现,分库分表。缺点是会有一定的代码开发工作量,对业务有一定的侵入性。好处是对程序员透明,程序员对分库分表的逻辑会有更强的控制力。一旦发生故障,排查问题会更容易。为了保险起见,我们选择了第二种方案,使用更轻量级的Sharding-Jdbc。在做系统重构之前,首先要确定重构的目标,其次要对未来的业务发展有一个预期。这一点可以向相关业务负责人了解,根据目标和业务预期确定改造方案。比如我们希望这次重构之后,系统可以维持两年,两年内不会有大的变化。业务方预计两年内日订单量将达到1000万,相当于两年后日订单量增长10倍。根据以上数据,我们分为16个数据库。以每日1000万的订单量计算,各库日均订单量为62.5万(1000万/16),每秒最大订单量理论上在1250(2000*(62.5/100))左右。这样数据库的压力基本可控,基本不会浪费服务器资源。每个库分为16个表。就算每天订单量1000万,两年的总订单量是73亿(73亿=1000万*365*2),每个库的平均数据量是4.56亿(456亿=7.3billion/16),每张表的平均数据量为2850万(2850万=4.56亿/16)。可以看出,未来两到三年,每个表的数据量都不会太多,完全在可控范围内。分库分表主要用于客户端的排序和查询。查询频率最高的是user_id,其次是order_id。所以我们选择user_id作为shardingcolumn,根据user_id做hash,将同一个用户的订单数据存储在同一个数据库的同一张表中。这样,用户在网页或APP上查询订单时,只需要路由到一张表就可以获取用户的所有订单,从而保证了查询性能。另外,我们在订单ID(order_id)中混入了用户ID(user_id)信息。简单来说,order_id的设计思路就是把order_id分成两部分,前面的部分是user_id,后面的部分是具体的订单号。两部分的组合构成order_id。这样我们就可以很方便的从order_id中解析出user_id。通过order_id查询订单时,首先从order_id中解析出user_id,然后可以根据user_id路由到具体的库表。另外,数据库分为16个,每个数据库又分为16个表。还有一个好处。16是2的N次方,所以哈希值对16取模的结果与哈希值和16位与运算的结果相同。我们知道,位运算是基于二进制的,跨越各种编译转换直接到最底层的机器语言,效率自然比取模运算高很多。可能有读者会问,如果查询直接查数据库会不会有性能问题?是的。所以我们在上层加了Redis,Redis做了一个分片的集群,用来存放最近50个活跃用户的订单。这样只有少量在Redis中找不到订单的用户请求才会去数据库中查询订单,减轻了数据库查询的压力,而且每个分库有两个从库,查询操作只去从数据库上,进一步分担各个分库的压力。可能还有读者会问,为什么不采用一致性哈希方案呢?如果用户查询最近50个订单,用户应该怎么做?在库和表中,如果需要根据userid以外的条件查询订单。比如一个运维同学,想从后台查出某天iphone7的订单量,就需要从所有的数据库表中查出数据,并聚合在一起。这个代码实现很复杂,查询性能会很差。所以我们需要一个更好的解决方案来解决这个问题。我们采用了ES(ElasticSearch)+HBase的组合,将索引与数据存储隔离开来。可能参与条件检索的字段会在ES中建立索引,如商户、商品名称、订单日期等,所有订单数据全部保存在HBase中。我们知道HBase支持海量存储,根据Rowkey查询速度超快。ES的多条件检索能力非常强大。可以说这个方案充分发挥了ES和HBase的优势。看一下这个方案的查询过程:先根据输入条件在ES的对应索引上查询符合条件的Rowkey值,然后使用Rowkey值查询HBase。后一步查询速度极快,查询时间几乎可以忽略不计。如下图所示:该方案解决了管理端通过各种字段条件查询订单的业务需求,也解决了商户端通过商户ID等条件查询订单的需求。如果用户想查询最近50个订单之前的历史订单,也可以使用这个方案。每天产生数以百万计的订单数据。管理后台要查找最新的订单数据,需要经常更新ES索引。在海量订单数据场景下,频繁的索引更新会不会给ES带来太大的压力?ES索引有个段(fragment)的概念。ES将每个索引分成几个更小的段片段。每个段都是一个完整的倒排索引,查询时依次扫描相关索引的所有段。每次刷新(刷新索引)都会生成一个新的segment,所以segment实际上记录了一组索引变化的值。由于每次索引刷新仅涉及单个段,因此更新索引的成本非常低。因此,即使默认的索引刷新间隔只有1秒,ES也能从容应对。但是由于每个segment的存储和扫描都需要一定的内存、CPU等资源,所以ES后台进程需要不断合并segment来减少segment的数量,从而提高扫描效率,减少资源消耗。MySQL中的订单数据需要实时同步到Hbase和ES,那么同步方案是什么?我们使用Canal实时获取MySQL数据库表中的增量订单数据,然后将订单数据推送到消息队列RocketMQ,消费者拿到消息后将数据写入Hbase,并更新ES中的索引。以上是Canal的示意图:Canal模拟mysqlslave的交互协议,伪装成mysql的slave库。将转储协议发送到mysqlmaster。mysqlmaster接收转储协议并将二进制日志发送到slave(Canal)。Canal解析二进制日志字节流对象,根据应用场景对二进制日志字节流进行处理。为了保证数据的一致性,没有数据丢失。我们使用RocketMQ的事务性消息来保证消息能够发送成功。另外,ack操作是在Hbase和ES都运行成功后进行的,以保证消息的正常消费。不停的数据迁移在互联网行业,很多系统的流量都很高,甚至是凌晨两三点。数据迁移导致的服务中断很难被业务方接受!说说我们没有用户感知的不间断数据迁移方案吧!数据迁移过程中需要注意哪些关键点?迁移后保证数据准确不丢失,即每条记录都是准确的,没有记录丢失。不影响用户体验,尤其是大流量的C端业务需要平滑迁移不停机。确保迁移后的系统性能和稳定性。常用的数据迁移方案主要有:挂从库、双写和使用数据同步工具。下面分别介绍一下。挂从库在主库上建一个从库。从库数据同步完成后,将从库升级为主库(新库),再将流量切换到新库。这种方式适用于表结构不变,空闲期间流量较低,允许宕机迁移的场景。一般发生在平台迁移的场景,比如从机房迁移到云平台,或者从一个云平台迁移到另一个云平台。大多数中小型互联网系统在空闲期间的流量都很低。在闲置期间,几分钟的宕机对用户影响不大,业务方可以接受。所以我们可以采用宕机迁移方案,步骤如下:新建一个从库(newdatabase),开始从主库同步数据到从库。数据同步完成后,找一个空闲时间段。为了保证主从数据库的数据一致性,需要先停止服务,再将从数据库升级为主数据库。如果使用域名访问数据库,则直接将域名解析到新数据库(从数据库升级到主数据库)。如果使用该IP访问数据库,则将IP修改为新数据库的IP。最后启动服务,整个迁移过程就完成了。这种迁移方案的优点是迁移成本低,迁移周期短。缺点是切换数据库的过程需要停止服务。我们的并发比较高,分库分表,表结构发生了变化,所以不能采用这种方案!旧库和新库同时双写,然后将旧数据批量迁移到新库。最后将流量切换到新库,老库关闭读写。这种方式适用于数据结构发生变化,不允许宕机迁移的场景。一般在系统重构的时候,表结构发生变化,比如表结构变化或者分库分表等场景。一些大型的互联网系统通常并发量很高,即使在空闲时间也有相当大的访问量。几分钟的宕机也会对用户造成很大的影响,甚至会导致一定的用户流失,这是业务方无法接受的。因此,我们需要考虑一种用户不感知的不间断迁移方案。下面说说我们具体的迁移方案。步骤如下:①代码准备。其中在服务层增删改表,需要同时操作新库(分库分表后的库表)和旧库,需要修改相应的代码(同时写入新数据库和旧数据库)。准备旧数据迁移的迁移程序脚本。准备验证程序脚本,验证新库和旧库的数据是否一致。②启用双写,旧库和新库同时写入。注意:任何对数据库的增删改查都必须双写;对于更新操作,如果新数据库中没有相关记录,则需要先从旧数据库中找出记录,并将更新后的记录写入新数据库中。为了保证写入性能,旧库写入完成后,可以使用消息队列异步写入新库。③使用脚本程序将某个时间戳之前的旧数据迁移到新数据库中。注意:时间戳一定要选择开启双写后的时间点,比如开启双写后10分钟,以免遗漏一些旧数据。如果在迁移过程中遇到记录冲突,则直接忽略,因为第2步的更新操作已经将记录拉取到新库中。迁移过程中一定要记录日志,尤其是错误日志。如果出现双写故障,我们可以通过日志恢复数据,保证新旧数据库中的数据是一致的。④第3步完成后,我们需要通过脚本程序进行数据校验,看新数据库中的数据是否准确,是否有缺失数据。⑤数据校验OK后,启用双读。一开始,少量流量放在新库上,新库和老库同时读取。由于延迟问题,新库和旧库之间可能存在少量不一致的数据记录,需要在新库无法读取时重新读取旧库。⑥然后逐步将阅读流量切换到新图书馆,相当于灰度上线的过程。如果遇到问题,可以及时将流量切换回老库。⑦读流量全部切换到新库后,关闭旧库的写(可以在代码中加一个热配置开关),只写新库。⑧迁移完成后,后期可以去掉双写双读相关的无用代码。使用数据同步工具可以看出,上面的双写方案比较麻烦,很多写数据库的地方都需要修改代码。有更好的解决方案吗?我们也可以使用Canal、DataBus等工具进行数据同步。以阿里开源的Canal为例。使用同步工具,无需开启双写,服务层无需编写双写代码,直接使用Canal进行增量数据同步。相应的步骤变为:①代码准备。准备Canal代码,解析二进制日志字节流对象,将解析后的订单数据写入新库。准备旧数据迁移的迁移程序脚本。准备验证程序脚本,验证新库和旧库的数据是否一致。②运行Canal代码,开始从旧库向新库同步增量数据(线上产生的新数据)。③使用脚本程序将某个时间戳之前的旧数据迁移到新数据库中。注意:时间戳必须选择在Canal程序启动后(比如Canal代码运行后10分钟),以免遗漏一些旧数据。迁移过程中一定要记录日志,尤其是错误日志。如果有些记录写入失败,我们可以通过日志恢复数据,保证新旧数据库中的数据是一致的。④第3步完成后,我们需要通过脚本程序进行数据校验,看新数据库中的数据是否准确,是否有缺失数据。⑤数据校验OK后,启用双读。一开始,少量流量放在新库上,新库和老库同时读取。由于延迟问题,新库和旧库之间可能存在少量不一致的数据记录,需要在新库无法读取时重新读取旧库。逐步将读流量切换到新库,相当于灰度上线的过程。如果遇到问题,可以及时将流量切换回老库。⑥读流量全部切换到新库后,再将写流量切换到新库(可以在代码中加一个热配置切换:因为切换过程中Canal程序还在运行,数据变化在旧库仍然可以获取并同步到新库,因此切换过程不会导致部分旧库数据无法与新库同步)。⑦关闭运河程序。⑧迁移完成。扩缩容方案需要对数据进行重新hash,然后将多个原数据库表的数据写入扩容数据库表中。整体扩容方案与上面的不停机迁移方案基本相同。可以使用双写或Canal等数据同步方案。更好的分库分表方案通过前面的描述,不难看出我们的分库分表方案存在一些缺陷。比如使用hash取模会导致数据分布不均匀,扩容和缩容也很麻烦。这些问题可以用一致的哈希方案来解决。基于虚拟节点设计原理的一致性哈希可以使数据分布更加均匀。而且一致性哈希采用循环设计思想,使得在增减节点时数据迁移的成本更低,只需要迁移相邻节点的数据即可。但是,当需要扩容时,基本上需要将容量翻倍。在哈希环上的每个节点间隙之间添加新节点,以分担所有原始节点的访问和存储压力。由于篇幅原因,这里不再详细介绍一致性哈希。网上有很多相关资料。有兴趣的可以仔细研究一下。降级方案当大促期间订单服务压力过大时,可以将同步调用改为异步消息队列方式,降低订单服务压力,提高吞吐量。大促期间,某些时间点瞬间产生的订单量非常大。我们采用异步批量写入数据库的方式来减少数据库的访问频率,从而减轻数据库的写入压力。详细步骤:后台服务收到订单请求,直接放入消息队列。订单服务取消息后,先将订单信息写入Redis,每隔100ms或累计10个订单时,批量写入数据库。在前端页面下单后,会定时从后台拉取订单信息,获取订单信息后,跳转到支付页面。采用这种异步批量写入数据库的方式,大大降低了数据库写入的频率,从而显着降低了订单数据库的写入压力。但是由于订单是异步写入数据库的,会出现数据库订单与对应的库存数据暂时不一致,用户下单后无法及时找到订单的情况。因为毕竟是降级方案,可以适当降低用户体验,我们只需要保证数据最终一致即可。根据系统压力,可以在大促开始时开启异步批量写入的降级开关,大促结束后关闭降级开关。流程如下:作者:二马度数编辑:陶家龙来源:转载自公众号二马度数(ID:ermadushu)

猜你喜欢