📚 电商核心业务实战 · 系列文章目录
(1) 电商项目核心订单系统设计与实现
(2) 电商促销流程设计与实现
(3) 分布式唯一ID 实战
(4) 订单系统读写分离方案设计与实现
(5) 订单系统分库分表方案设计与实现
(6) 订单系统历史数据归档方案设计与实现
(7) 电商项目订单支付实战
(8) 使用RocketMQ优化订单超时取消流程
(9) 分布式事务在电商项目中的应用场景分析与实战
分库分表
除了访问MySQL的并发问题,还要解决海量数据的问题,很多的时候,我们会使⽤分布式的存储集群,因为MySQL本质上是⼀个单机数据库,所以很多场景下,其并不适合存储TB级别以上的数据。
但是绝⼤部分电商企业的在线交易类业务,⽐如订单、⽀付相关的系统,还是⽆法离开MySQL的。原因是只有MySQL 之类的关系型数据库,才能提供⾦融级的事务保证。⽬前的分布式事务的各种解法⽅案多少都有些不够完善。
虽然 MySQL ⽆法⽀持这么⼤的数据量,以及这么⾼的并发需求,但是交易类系统必须⽤它来保证数据⼀致性,那么,如何才能解决这个问题呢?这个时候我们就要考虑分⽚,也就是拆分数据。如果⼀个数据库⽆法⽀撑1TB的数据,那就把它拆分成100个库,每个库就只有10GB的数据了。这种拆分操作就是MySQL的分库分表操作。
如何规划分库分表
以订单表为例,⾸先,我们需要思考的问题是,选择分库还是分表,或者两者都有,分库就是把数据拆分到不同的MySQL 数据库实例中,分表就是把数据拆分到⼀个数据库的多张表⾥⾯。在考虑到底是选择分库还是分表之前,我们需要⾸先明确⼀个原则,能不拆就不拆。
原因很简单,数据拆得越分散,并发和维护就越麻烦,系统出问题的概率也就越⼤。遵循上⾯这个原则,还需要进⼀步了解,哪种情况适合分表,哪种情况适合分库。选择分库或是分表的⽬的是解决如下两个问题。
第⼀,是为了解决因数据量太⼤⽽导致查询慢的问题。这⾥所说的“查询”,主要是事务中的查询和更新操作,因为只读的查询可以通过缓存和主从分离来解决。分表主要⽤于解决因数据量⼤⽽导致的查询慢的问题。第⼆,是为了应对⾼并发的问题。如果⼀个数据库实例撑不住,就把并发请求分散到多个实例中,所以分库可⽤于解决⾼并发的问题。
简单地说,如果数据量太⼤,就分表;如果并发请求量⾼,就分库。⼀般情况下,我们的解决⽅案⼤都需要同时做分库分表,我们可以根据预估的并发量和数据量,分别计算应该拆分成多少个库以及多少张表。
商城订单服务的实现
数据量
在设计系统,我们预估订单的数量每个⽉订单2000W,⼀年的订单数可达2.4亿。⽽每条订单的⼤⼩⼤致为1KB,按照我们在MySQL中学习到的知识,为了让B+树的⾼度控制在⼀定范围,保证查询的性能,每个表中的数据不宜超过2000W。
在这种情况下,为了存下2.4亿的订单,我们似乎应该将订单表分为16(12往上取最近的2的幂)张表。但是这样设计,有个问题,我们只考虑了订单表,没有考虑订单详情表。我们预估⼀张订单下的商品平均为10个,那既是⼀年的订单详情数可以达到24亿,同样以每表2000W记录计算,应该订单详情表为128(120往上取最近的2的幂)张,⽽订单表和订单详情表虽然记录数上是⼀对⼀的关系,但是表之间还是⼀对⼀,订单表也要为128张。
经过再三分析,我们最终将订单表和订单详情表的张数定为32张。这会导致订单详情表单表的数据量达到8000W,为何要这么设计呢?原因我们后⾯再说。
选择分片键
既然决定订单系统分库分表,则还有⼀个重要的问题,那就是如何选择⼀个合适的列作为分表的依据,该列我们⼀般称为分⽚键(Sharding Key)。选择合适的分⽚键和分⽚算法⾮常重要,因为其将直接影响分库分表的效果。
选择分⽚键有⼀个最重要的参考因素是我们的业务是如何访问数据的?⽐如我们把订单ID作为分⽚键来拆分订单表。那么拆分之后,如果按照订单ID来查询订单,就需要先根据订单ID和分⽚算法,计算所要查的这个订单具体在哪个分⽚上,也就是哪个库的哪张表中,然后再去那个分⽚执⾏查询操作即可。
但是当⽤户打开“我的订单”这个⻚⾯的时候,它的查询条件是⽤户ID,由于这⾥没有订单ID,因此我们⽆法知道所要查询的订单具体在哪个分⽚上,也就没法查了。如果要强⾏查询的话,那就只能把所有的分⽚都查询⼀遍,再合并查询结果,这个过程⽐较麻烦,⽽且性能很差,对分⻚也很不友好。
那么如果是把⽤户ID作为分⽚键呢?答案是也会⾯临同样的问题,使⽤订单ID作为查询条件时⽆法定位到具体的分⽚上。这个问题的解决办法是,在⽣成订单ID的时候,把⽤户ID的后⼏位作为订单ID的⼀部分。这样按订单ID查询的时候,就可以根据订单ID中的⽤户ID找到分⽚。 所以在我们的系统中订单ID从唯⼀ID服务获取ID后,还会将⽤户ID的后两位拼接,形成最终的订单ID。
然⽽,系统对订单的查询⽅式,肯定不只是按订单ID或按⽤户ID查询两种⽅式。⽐如如果有商家希望查询⾃家家店的订单,有与订单相关的各种报表。对订单做了分库分表,就没法解决了。这个问题⼜该怎么解决呢?
⼀般的做法是,把订单⾥数据同步到其他存储系统中,然后在其他存储系统⾥解决该问题。⽐如可以再构建⼀个以店铺ID作为分⽚键的只读订单库,专供商家使⽤。或者数据同步到Hadoop分布式⽂件系统(HDFS)中,然后通过⼀些⼤数据技术⽣成与订单相关的报表。
在分⽚算法上,我们知道常⽤的有按范围,⽐如时间范围分⽚,哈希分⽚,查表法分⽚。我们这⾥直接使⽤哈希分⽚,对表的个数32直接取模
⼀旦做了分库分表,就会极⼤地限制数据库的查询能⼒,原本很简单的查询,分库分表之后,可能就没法实现了。分库分表⼀定是在数据量和并发请求量⼤到所有招数都⽆效的情况下,我们才会采⽤的最后⼀招。
具体实现
如何在代码中实现读写分离和分库分表呢?⼀般来说有三种⽅法。
- 纯⼿⼯⽅式:修改应⽤程序的DAO层代码,定义多个数据源,在代码中需要访问数据库的每个地⽅指定每个数据库请求的数据源。
- 组件⽅式:使⽤像Sharding-JDBC 这些组件集成在应⽤程序内,⽤于代理应⽤程序的所有数据库请求,并把请求⾃动路由到对应的数据库实例上。
- 代理⽅式:在应⽤程序和数据库实例之间部署⼀组数据库代理实例,⽐如Atlas或Sharding-Proxy。对于应⽤程序来说,数据库代理把⾃⼰伪装成⼀个单节点的MySQL实例,应⽤程序的所有数据库请求都将发送给代理,代理分离请求,然后将分离后的请求转发给对应的数据库实例。
在这三种⽅式中⼀般推荐第⼆种,使⽤分离组件的⽅式。采⽤这种⽅式,代码侵⼊⾮常少,同时还能兼顾性能和稳定性。如果应⽤程序是⼀个逻辑⾮常简单的微服务,简单到只有⼏个SQL,或者应⽤程序使⽤的编程语⾔没有合适的读写分离组件,那么也可以考虑通过纯⼿⼯的⽅式。不推荐使⽤代理⽅式(第三种⽅式),原因是代理⽅式加⻓了系统运⾏时数据库请求的调⽤链路,会造成⼀定的性能损失,⽽且代理服务本身也可能会出现故障和性能瓶颈等问题。代理⽅式有⼀个好处,对应⽤程序完全透明。所以在我们的订单服务中,使⽤了第⼆种⽅式,引⼊了Sharding-JDBC,考虑要同时⽀持读写分离和分库分表,配置如下:
在分⽚键的选择上,订单信息的查询往往会指定订单的ID或者⽤户ID,所以oms_order的分⽚键为表中的id、member_id两个字段。⽽oms_order_item表通过order_id字段和oms_order的id进⾏关联,所以它的分⽚键选择为order_id。对应在代码中有专⻔的分⽚算法实现类:OmsOrderShardingAlgorithm和OmsOrderItemShardingAlgorithm,分别⽤于对订单和订单详情进⾏分⽚。
其中的OmsOrderShardingAlgorithm负责对订单进⾏分⽚,在实现上获得订单的Id或者member_id的后两位,然后对表的个数进⾏取模以定位到实际的物理oms_order表。OmsOrderItemShardingAlgorithm负责对订单详情进⾏分⽚,实现上与OmsOrderShardingAlgorithm类似。