关于safari:浅谈分库分表那些事儿

8次阅读

共计 8740 个字符,预计需要花费 22 分钟才能阅读完成。

简介:本文次要论述在分库分表革新过程中须要思考的因素以及对应的解法,还有踩过的那些坑。

本文适宜浏览大众:须要从单库单表革新为多库多表的老手。

本文次要论述在分库分表革新过程中须要思考的因素以及对应的解法,还有踩过的那些坑。

一 前言

咱们既然要做分库分表,那总要有个做事的动机。那么,在入手之前,首先就要弄明确上面两个问题。

1 什么是分库分表?

其实就是字面意思,很好了解:

  • 分库:从单个数据库拆分成多个数据库的过程,将数据散落在多个数据库中。
  • 分表:从单张表拆分成多张表的过程,将数据散落在多张表内。

2 什么要分库分表?

关键字:晋升性能、减少可用性。

从性能上看

随着单库中的数据量越来越大、数据库的查问 QPS 越来越高,相应的,对数据库的读写所须要的工夫也越来越多。数据库的读写性能可能会成为业务倒退的瓶颈。对应的,就须要做数据库性能方面的优化。本文中咱们只探讨数据库层面的优化,不探讨缓存等应用层优化的伎俩。

如果数据库的查问 QPS 过高,就须要思考拆库,通过分库来分担单个数据库的连贯压力。比方,如果查问 QPS 为 3500,假如单库能够撑持 1000 个连接数的话,那么就能够思考拆分成 4 个库,来扩散查问连贯压力。

如果单表数据量过大,当数据量超过一定量级后,无论是对于数据查问还是数据更新,在通过索引优化等纯数据库层面的传统优化伎俩之后,还是可能存在性能问题。这是质变产生了量变,这时候就须要去换个思路来解决问题,比方:从数据生产源头、数据处理源头来解决问题,既然数据量很大,那咱们就来个分而治之,化整为零。这就产生了分表,把数据依照肯定的规定拆分成多张表,来解决单表环境下无奈解决的存取性能问题。

从可用性上看

单个数据库如果发生意外,很可能会失落所有数据。尤其是云时代,很多数据库都跑在虚拟机上,如果虚拟机 / 宿主机发生意外,则可能造成无法挽回的损失。因而,除了传统的 Master-Slave、Master-Master 等部署层面解决可靠性问题外,咱们也能够思考从数据拆分层面解决此问题。

此处咱们以数据库宕机为例:

  • 单库部署状况下,如果数据库宕机,那么故障影响就是 100%,而且复原可能耗时很长。
  • 如果咱们拆分成 2 个库,别离部署在不同的机器上,此时其中 1 个库宕机,那么故障影响就是 50%,还有 50% 的数据能够持续服务。
  • 如果咱们拆分成 4 个库,别离部署在不同的机器上,此时其中 1 个库宕机,那么故障影响就是 25%,还有 75% 的数据能够持续服务,复原耗时也会很短。

当然,咱们也不能无限度的拆库,这也是就义存储资源来晋升性能、可用性的形式,毕竟资源总是无限的。

二、如何分库分表

1、分库?分表?还是既分库又分表?

从第一局部理解到的信息来看,分库分表计划能够分为上面 3 种:

2、如何抉择咱们本人的切分计划?

如果须要分表,那么分多少张表适合?

因为所有的技术都是为业务服务的,那么,咱们就先从数据方面回顾下业务背景。

比方,咱们这个业务零碎是为了解决会员的征询诉求,通过咱们的 XSpace 客服平台零碎来服务会员,目前次要以同步的离线工单数据作为咱们的数据源来构建本人的数据。

假如,每一笔离线工单都会产生对应一笔会员的征询问题(咱们简称:问题单),如果:

  • 在线渠道:每天产生 3w 笔聊天会话,假如,其中 50% 的会话会生成一笔离线工单,那么每天可生成 3w * 50% = 1.5w 笔工单;
  • 热线渠道:每天产生 2.5w 通电话,假如,其中 80% 的电话都会产生一笔工单,那么每天可生成 2.5w * 80% = 2w 笔 / 天;
  • 离线渠道:假如离线渠道每天间接生成 3w 笔;

共计共 1.5w + 2w + 3w = 6.5w 笔 / 天

思考到当前可能要持续笼罩的新的业务场景,须要提前预留局部扩大空间,这里咱们假如为每天产生 8w 笔问题单。

除问题单外,还有另外 2 张罕用的业务表:用户操作日志表、用户提交的表单数据表。

其中,每笔问题单都会产生多条用户操作日志,依据历史统计数据来能够看到,均匀每个问题单大概会产生 8 条操作日志,咱们预留一部分空间,假如每个问题单均匀产生约 10 条用户操作日志。

如果零碎设计应用年限 5 年,那么问题单数据量大概 = 5 年 365 天 / 年 8w/ 天 = 1.46 亿,那么估算出的表数量如下:

  • 问题单须要:1.46 亿 /500w = 29.2 张表,咱们就按 32 张表来切分;
  • 操作日志须要:32 10 = 320 张表,咱们就按 32 16 = 512 张表来切分。

如果须要分库,那么分多少库适合?

分库的时候除了要思考平时的业务峰值读写 QPS 外,还要思考到诸如双 11 大促期间可能达到的峰值,须要提前做好预估。

依据咱们的理论业务场景,问题单的数据查问起源次要来自于阿里客服小蜜首页。因而,能够依据历史 QPS、RT 等数据评估,假如咱们只须要 3500 数据库连接数,如果单库能够承当最高 1000 个数据库连贯,那么咱们就能够拆分成 4 个库。

3、如何对数据进行切分?

依据行业常规,通常依照 程度切分、垂直切分 两种形式进行切分,当然,有些简单业务场景也可能抉择两者联合的形式。

(1)程度切分

这是一种横向按业务维度切分的形式,比方常见的按会员维度切分,依据肯定的规定把不同的会员相干的数据散落在不同的库表中。因为咱们的业务场景决定都是从会员视角进行数据读写,所以,咱们就抉择依照程度形式进行数据库切分。

(2)垂直切分

垂直切分能够简略了解为,把一张表的不同字段拆分到不同的表中。

比方:假如有个小型电商业务,把一个订单相干的商品信息、交易家信息、领取信息都放在一张大表里。能够思考通过垂直切分的形式,把商品信息、买家信息、卖家信息、领取信息都独自拆分成独立的表,并通过订单号跟订单根本信息关联起来。

也有一种状况,如果一张表有 10 个字段,其中只有 3 个字段须要频繁批改,那么就能够思考把这 3 个字段拆分到子表。防止在批改这 3 个数据时,影响到其余 7 个字段的查问行锁定。

三、分库分表之后带来的新问题

1、分库分表后,如何让数据平均散落在各个分库分表内?

比方,当热点事件呈现后,怎么防止热点数据集中存取到某个特定库 / 表,造成各分库分表读写压力不均的问题。

其实,细思之下能够发现这个问题其实跟负载平衡的问题很类似,所以,咱们能够去借鉴下负载平衡的解法来解决。咱们常见的负责平衡算法如下:

咱们的抉择:基于 一致性 Hash 算法 裁剪,相较于一致性 Hash 算法,咱们裁剪后的算法
次要区别在以下几个点:

(1)Hash 环节点数量的不同

一致性 Hash 有 2^32- 1 个节点,思考到咱们依照 buyerId 切分,而且 buyerId 基数本就很宏大,整体曾经具备肯定的均匀度,所以,咱们把 Hash 环的数量升高到 4096 个;

(2)DB 索引算法的不同

一致性 Hash 通过相似 hash(DB 的 IP) % 2^32 公式计算 DB 在 Hash 环的地位。如果 DB 数量较少,须要通过减少虚构节点来解决 Hash 环偏斜问题,而且 DB 的地位可能会随着 IP 的变动而变动,尤其是在云环境下。

数据均匀分布到 Hash 环的问题,通过之前的判断,咱们能够通过 Math.abs(buyerId.hashCode()) % 4096 计算定位到 Hash 环地位,那么剩下的问题就是让 DB 也均匀分布到这个 Hash 环上即可。因为咱们都是应用阿里的 TDDL 中间件,只须要通过逻辑上的分库索引号定位 DB,因而,咱们把分库 DB 均分到这个 Hash 环上即可,如果是 hash 环有 4096 个环节,拆分 4 库的话,那么 4 个库别离位于第 1、1025、2049、3073 个节点上。分库的索引定位可通过 (Math.abs(buyerId.hashCode()) % 4096) / (4096 / DB_COUNT) 这个公式计算得出。

分库索引的 Java 伪代码实现如下:

/**
 * 分库数量
 */
public static final int DB_COUNT = 4;

/**
 * 获取数据库分库索引号
 *
 * @param buyerId 会员 ID
 * @return
 */
public static int indexDbByBuyerId(Long buyerId) {return (Math.abs(buyerId.hashCode()) % 4096) / (4096 / DB_COUNT);
}

2、分库分表环境下,如何解决分库后主键 ID 的唯一性问题?

在单库环境下,咱们的问题单主表的 ID 采纳的 MySQL 自增的形式。然而,分库之后如果还持续应用数据库自增的形式,就很容易呈现各门口的主键 ID 反复问题。

对于这种状况,有很多种解决方案,比方采纳 UUID 的形式,不过 UUID 太长,查问性能太差,占用空间也大,而且主键的类型也变了,也不利于利用平滑迁徙。

其实,咱们也能够对 ID 持续拆分,比方对 ID 进行分段,不同的库表应用不同的 ID 段,但也会产生新的问题,这个 ID 段要多长才适合?如果 ID 段调配完了,那可能会占用第二个库的 ID 段,产生 ID 不惟一问题。

然而,如果咱们让所有的分库应用的 ID 段依照等差数列进行分隔,每次 ID 段用完之后,再依照固定的步长比递增的话,那是不是就能够解决这个问题了。

比方,像上面这样,假如每次调配的 ID 距离为 1000,也就是步长 1000,那么每次调配的 ID 段起止索引则能够依照上面的公式计算得出:

  • 第 X 库、第 Y 次调配的 ID 段起始索引就是:
X * 步长 + (Y-1) * (库数量 * 步长)
  • 第 X 库、第 Y 次调配的 ID 段完结索引就是:
X * 步长 + (Y-1) * (库数量 * 步长) + (1000 -1)

如果是分 4 库,那么最终调配的 ID 段就会是上面这个样子:

咱们的问题单库采纳的就是这种先对 ID 分段,再按固定步长递增的形式。这也是 TDDL 官网提供的解决方案。

除此之外,理论场景下,通常为了剖析排查问题不便,往往会在 ID 中减少一些额定信息,比方咱们本人的问题单 ID 就蕴含了日期、版本、分库索引等信息。

问题单 ID 生成 Java 伪代码参考:

import lombok.Setter;
import org.apache.commons.lang3.time.DateFormatUtils;

/**
 * 问题单 ID 构建器
 * <p>
 * ID 格局(18 位):6 位日期 + 2 位版本号 + 2 位库索引号 + 8 位序列号
 * 示例:180903010300001111
 * 阐明这个问题单是 2018 年 9 月 3 号生成的,采纳的 01 版本的 ID 生成规定,数据寄存在 03 库,最初 8 位 00001111 是生成的序列号 ID。* 采纳这种 ID 格局还有个益处就是每天都有 1 亿 (8 位) 的序列号可用。* </p>
 */
@Setter
public class ProblemOrdIdBuilder {
  public static final int DB_COUNT = 4;    
    private static final String DATE_FORMATTER = "yyMMdd";

    private String version = "01";
    private long buyerId;
    private long timeInMills;
    private long seqNum;

    public Long build() {int dbIndex = indexDbByBuyerId(buyerId);
        StringBuilder pid = new StringBuilder(18)
            .append(DateFormatUtils.format(timeInMills, DATE_FORMATTER))
            .append(version)
            .append(String.format("%02d", dbIndex))
            .append(String.format("%08d", seqNum % 10000000));
        return Long.valueOf(pid.toString());
    }

    /**
     * 获取数据库分库索引号
     *
     * @param buyerId 会员 ID
     * @return
     */
    public int indexDbByBuyerId(Long buyerId) {return (Math.abs(buyerId.hashCode()) % 4096) / (4096 / DB_COUNT);
    }
} 

3、分库分表环境下,事务问题怎么解决?

因为分布式环境下,一个事务可能跨多个分库,所以,解决起来绝对简单。目前常见的有 2 种解决方案:

(1)应用分布式事务

  • 长处:由应用服务器 / 数据库去治理事务,实现简略
  • 毛病:性能代价较高,尤其是波及到分库数量较多时尤为显著。而且,还依赖于一些特定的应用服务器 / 数据库提供的分布式事务实现计划。

(2)由应用程序 + 数据库独特管制

  • 原理:大事化小,将多个大事务拆分成可由单个分库解决的小事务,由应用程序去管制这些小事务。
  • 长处:性能良好,少了一个分布式事务协调解决层
  • 毛病:须要从应用程序本身上做事务管制的灵便设计。从业务利用上做解决,应该革新老本高。

针对下面 2 种分布式事务解决方案,咱们该如何抉择?

首先,没有万能的解决方案,只有适宜本人的计划。那就先看看咱们的业务中,事务的应用场景有哪些吧。

无论是来征询问题的会员,还是为会员解决问题的客服小二,亦或者从第三方零碎同步相干数据。次要有 2 个外围动作:

  • 以会员维度查问相干进度数据,蕴含会员问题数据,以及对应的问题解决操作日志 / 进度数据;
  • 以会员视角提交相干凭证 / 反馈新状况等数据,或者是客服小二代会员提交这些数据。提交的数据也可能会决定问题是否解决(被完结)。

因为问题单数据、操作日志都是离开查问,所以,不波及分布式关联查问场景,这个能够疏忽不思考。

那么就剩下用户提交数据场景了,可能会同时写入问题单以及操作日志数据。

既然应用场景确定了,那么能够抉择事务解决方案了。尽管分布式事务实现简略,但这个简略是因为中间件帮咱们解决了它自身的复杂性。复杂性越高,必然会带来肯定的性能损耗。而且,目前大部分利用都是基于 SpringBoot 开发,默认应用的都是内嵌 tomcat 容器,不像 IBM 提供的 WebSphere Application Server、Oracle 的 WebLogic 这些重量级应用服务器,都提供了内置的分布式事务管理器。因而,如果咱们要接入,必然要本人引入额定的分布式事务管理器,这个接入老本就更高了。所以,这种计划就暂不思考了。那么,就只能本人想方法把大事务切分成单库能够解决的小事务了。

所以,当初问题就成了,如何让同一个会员的问题单数据和这个问题单相干的操作日志数据写入到同一个分库中。其实,解决方案也比较简单,因为都是应用会员 ID 做切分,那么应用雷同的分库路由规定即可。

最初,我来看下最终的 TDDL 分库分表规定配置:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE beans PUBLIC "-//SPRING//DTD BEAN//EN" "http://www.springframework.org/dtd/spring-beans.dtd">

<beans>
  <bean id="vtabroot" class="com.taobao.tddl.interact.rule.VirtualTableRoot" init-method="init">
    <property name="dbType" value="MYSQL" />
    <property name="defaultDbIndex" value="PROBLEM_0000_GROUP" />
    <property name="tableRules">
      <map>
        <entry key="problem_ord" value-ref="problem_ord" />
        <entry key="problem_operate_log" value-ref="problem_operate_log" />
      </map>
    </property>
  </bean>
  <!-- 问题 (诉求) 单表 -->
  <bean id="problem_ord" class="com.taobao.tddl.interact.rule.TableRule">
    <property name="dbNamePattern" value="PROBLEM_{0000}_GROUP" />
    <property name="tbNamePattern" value="problem_ord_{0000}" />
    <property name="dbRuleArray" value="((Math.abs(#buyer_id,1,4#.hashCode()) % 4096).intdiv(1024))" />
    <property name="tbRuleArray">
      <list>
        <value>
          <![CDATA[def hashCode = Math.abs(#buyer_id,1,32#.hashCode());
            int dbIndex = ((hashCode % 4096).intdiv(1024)) as int;
            int tableCountPerDb = 32 / 4;
            int tableIndexStart = dbIndex * tableCountPerDb;
            int tableIndexOffset = (hashCode % tableCountPerDb) as int;
            int tableIndex = tableIndexStart + tableIndexOffset;
            return tableIndex;
          ]]>
        </value>
      </list>
    </property>
    <property name="allowFullTableScan" value="false" />
  </bean>
  <!-- 问题操作日志表 -->
  <bean id="problem_operate_log" class="com.taobao.tddl.interact.rule.TableRule">
    <property name="dbNamePattern" value="PROBLEM_{0000}_GROUP" />
    <property name="tbNamePattern" value="problem_operate_log_{0000}" />
    <!--【#buyer_id,1,4#.hashCode()】-->
    <!-- buyer_id 代表分片字段;1 代表分库步长;4 代表一共 4 个分库,当执行全表扫描时会用到 -->
    <property name="dbRuleArray" value="((Math.abs(#buyer_id,1,4#.hashCode()) % 4096).intdiv(1024))" />
    <property name="tbRuleArray">
      <list>
        <value>
          <![CDATA[def hashCode = Math.abs(#buyer_id,1,512#.hashCode());
            int dbIndex = ((hashCode % 4096).intdiv(1024)) as int;
            int tableCountPerDb = 512 / 4;
            int tableIndexStart = dbIndex * tableCountPerDb;
            int tableIndexOffset = (hashCode % tableCountPerDb) as int;
            int tableIndex = tableIndexStart + tableIndexOffset;
            return tableIndex;
          ]]>
        </value>
      </list>
    </property>
    <property name="allowFullTableScan" value="false" />
  </bean>
</beans> 

4、分库分表后,历史数据如何平滑迁徙?

数据库复制计划,阿里云下面也凋谢了以前阿里外部应用的数据库复制、迁徙计划《数据传输服务(Data Transmission Service)》[1],详情可征询阿里云客服或者阿里云数据库专家。

分库切换公布流程可抉择停机、不停机公布两种:

(1)如果抉择停机公布

  • 首先,要抉择一个夜黑风高、到处无人的夜晚。寒风刺骨能让你苏醒,到处无人,你好办事打劫偷数据,咱们就挑了个凌晨 4 点沉寂无人的时候做切换;如果能够,能长期敞开业务拜访入口最好。
  • 而后,在 DTS 下面新增一个全量的数据复制工作,把单库的数据复制到新的分库中(这个过程很快,千万级数据应该 10 分左右就能搞定);
  • 之后,切换 TDDL 配置(单库 -> 分库),并重启利用,查看是否失效。
  • 最初,凋谢业务拜访入口,提供服务。

(2)如果抉择不停机公布话,流程会稍微简单点

  • 首先,同样须要抉择一个夜黑风高的夜晚,来烘托你的帅气。
  • 而后,通过 DTS 复制某个工夫点前的数据,比方:明天前的历史数据。
  • 之后,从单库切换到分库(最好是提前公布好利用、筹备好配置),这样切换时只须要几分钟重启失效即可。在切换到分库前,分割 DBA 在切换期间进行老的单库读写。
  • 最初,分库切换实现后,再通过 DTS 增量复制老的单库中明天凌晨之后产生的数据。
  • 最初的最初,继续察看一段时间,如果没问题,老的单库就能够下线了。

5、TDDL 配置分库分表路由时的注意事项

因为阿里的 TDDL 中间件应用 groovy 脚本计算分库分表路由,而 groovy 的 / 运算符 或者 /= 运算符 可能会产生一个 double 类型的后果,并非像 Java 那样得出一个整数,因而须要应用 x.intdiv(y) 函数做整除运算。

// 在 Java 中
System.out.println(5 / 3); // 后果 = 1

// 在 Groovy 中
println (5 / 3);       // 后果 = 1.6666666667 
println (5.intdiv(3)); // 后果 = 1(Groovy 整除正确用法)

详情可查看 Groovy 官网阐明《The case of the division operator》:

四、分库分表文中案例图示

作者:开发者小助手_LS
原文链接
本文为阿里云原创内容,未经容许不得转载

正文完
 0