关于java:数据分片库内分表实践

44次阅读

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

因为疫情不再进行防控,我的项目的根本业务是扫码乘坐公交车,订单表的增量相比过来有了较大的减少,目前零碎中存在订单表保留 3 个月的数据,数据在 1300W 左右,按当初日增数据为 30W,月增量为 900W 那么将来某个时刻的数据会达到峰值 2700W(3 个月),整体数据量翻倍增长。

剖析与实际

前置常识

分库分表

垂直分表 程度分表 垂直分库 程度分库
概念 以字段为根据,依照 活跃度,将表中的数据拆分到不同表(主表和子表) 以字段为根据,依照肯定的策略,将一个表中的数据拆分到多个表中 以表为根据,依照业务归属不同将不同表拆分到不同库中 以字段为根据,依照肯定策略,将一个库中的数据拆分到多个库中
后果 每个表的构造,数据都不同,子表存在和主表的关联字段;相干的表的并集是全量数据 每个表的构造雷同,数据不同;所有表的并集是全量数据; 每个库的构造,数据不同;所有库的并集是全量数据; 每个库的构造雷同,数据不同;所有库的并集是全量数据;
场景 零碎并发量不大,表的字段多,单行数据空间占用大,表中字段存在热点和非热点数据; 零碎并发量不大,仅是单表数据过多,影响了 SQL 效率,减轻 CPU 累赘; 零碎并发量较大,业务模块划分清晰; 零碎并发量较大,分表难以解决问题;
图示

罕用架构模式

分库分表架构次要有两种模式:client 客户端模式 和 proxy 代理模式

客户模式

client 模式指分库分表的逻辑都在你的零碎利用外部进行管制,利用会将拆分后的 SQL 直连多个数据库进行操作,而后本地进行数据的合并汇总等操作。

代理模式

proxy 代理模式将应用程序与 MySQL 数据库隔离,业务方的利用不在须要直连数据库,而是连贯 proxy 代理服务,代理服务实现了 MySQL 的协定,对业务方来说代理服务就是数据库,它会将 SQL 散发到具体的数据库进行执行,并返回后果。该服务内有分库分表的配置,依据配置主动创立分片表。

区别

客户模式 代理模式
性能 性能方面体现的稍好一些,它是间接连贯 MySQL 执行命令 代理服务则将整个执行链路缩短了,利用 -> 代理服务 ->MySQL,可能导致性能有一些损耗
复杂度 在开发应用通常引入一个 jar 能够 须要搭建独自的服务,有肯定的保护老本,既然是服务那么就要思考高可用,毕竟利用的所有 SQL 都要通过它转发至 MySQL
降级 分库分表个别是依赖基础架构团队的 Jar 包,一旦有版本升级或者 Bug 批改,所有利用到的我的项目都要跟着降级小规模的团队服务少降级问题不大,如果是大公司服务规模大,且波及到跨多部门,那么降级一次老本就比拟高 在降级方面劣势很显著,公布新性能或者修复 Bug,只有重新部署代理服务集群即可,业务方是无感知的,但要保障公布过程中服务的可用性
治理,监控 因为是内嵌在利用内,利用集群部署不太不便对立解决 在对 SQL 限流、读写权限管制、监控、告警等服务治理方面更优雅一些。
开源组件 sharding-jdbc,zebra sharding-jdbc,MyCAT,DBProxy,cobar,Atlas

目前状况

  • 依据序中论述的内容将来的订单表寄存三个月的数据将会寄存 2700W 左右的数据量甚至更多,数据量上单表曾经存在了一些压力,数据库 RDS 零碎中查问到的慢 SQL 中存在订单表的慢查问,但剖析上数据库语句曾经无奈优化了;
  • 订单表作为业务的外围表来说曾经做了 冷热数据拆散(即针对 3 个月之前的数据进行数据归档寄存到订单备份表中)
  • 业务存在显著的高峰期,但基于 MQ 做了削峰填谷,不会呈现连接数不够;

基于上述剖析:仅仅是单表内数据量过大,且针对目前的体量来说,对订单表数据分片进行 库内程度分表 是一种比拟适合的形式。

问题剖析

根本方向已确定,那么接下来就是针对库内分表所要面临的问题进行剖析。

  1. 程度分表以字段为根据,那么哪个字段作为分表的键比拟适合?
  2. 程度分表具体要分多少张表?
  3. 分片算法或者说分片策略是什么?
  4. 业务革新计划?数据如何平滑迁徙?

    抉择分片键

    对上述问题咱们先看下第一个问题

    程度分表以字段为根据,那么哪个字段作为分表的键比拟适合?思考的因素次要有哪些?

既然是对订单表分片,那么首先剖析下订单表的字段,订单实质上是一种交易单方的合约,那么必然存在买方和卖方,字段上蕴含如下:

  • oid:order_id 订单表 id 或订单号
  • uid:user_id 用户 id
  • mid:merchant_id 商户 id
  • price,time 等其余字段

    这里的买方是用户,花钱买公交服务,卖方是公交公司,提供公交车营运的业务。

其次咱们再剖析下业务场景,通过具体业务场景来具体分析解决方案。

  1. 谁在用这个零碎?他们的查问维度是什么?

零碎用户大体上可分为两类:用户侧 经营侧,用户侧蕴含乘客(用户)和公交公司(商户),经营侧蕴含 产品经理、经营、开发等。用户侧的查问需要仅仅是查问本人相干的数据,并发量大,且对服务查问品质容忍度低;经营侧的查问需要是查问大多是多个商户的数据,并发量低,且对服务查问品质绝对较高。
罕用查问需要:

  • 用户侧

    • 乘客

      • 订单列表:依据 uid 查问本人肯定创立工夫内的订单列表
      • 订单详情:依据 oid 查问具体订单
    • 公交公司

      • 订单列表:多维度(工夫,线路,设施等)依据 mid 查问商户的订单列表
      • 订单详情:依据 oid 查问具体订单
  • 经营侧:

    • 管理员

      • 订单列表:后盾多维度,多条件的分页查问

基于上述咱们来看一下依照各个字段来作为分片键会产生什么样的成果?

字段 解决的问题 未解决的问题
uid 依据 uid 进行分片,查问中存在 uid 的 sql 可能疾速查问,如查问乘客订单列表等等 没有 uid 的查问则须要通过其余办法来进行查问
oid 依据 oid 进行分片,查问中存在 oid 的 sql 可能疾速查问,如查问订单详情等等 没有 oid 的查问则须要通过其余办法来进行查问
mid 依据 mid 进行分片,查问中存在 mid 的 sql 可能疾速查问,如查问商户订单列表等等 没有 mid 的查问则须要通过其余办法来进行查问
time 依据 time 进行分片,查问中存在 time 的 sql 可能疾速查问,如管理员查看的订单列表等 没有 time 的查问则须要通过其余办法来进行查问

从上述的剖析表格当中能够显著看出,无论抉择哪个字段作为分片键都会存在如果该查问不存在该分片键,那么这个查问都会须要通过其余办法来进行解决,存在肯定局限性。这个其余办法通常有哪些呢?

  • 遍历法

    • 遍历所有数据分片的表进行查问
  • 索引表法

    • 所须要查问的字段与分片键建设关联关系,在查问之前先查问关系再定位到对应的分片,毛病则是须要多查问一次
  • 缓存映射法

    • 同样是建设查问须要的字段与分片键建设关联关系,若不存在则通过遍历法获取而后放到 cache,之后间接通过 cache 来查问对应关系,毛病依然是须要多查问一次
  • 基因法

    • 是指将某个字段融入到分片键中,比方 oid 中融入 uid,这样通过 uid 或者 oid 都能通过疾速匹配到对应数据分片

抉择这个分片键,哪些因素是咱们须要思考的?

  • 业务优先级

    • 为什么要剖析查问需要,就是要确定哪些查问是重要的,哪些查问是能够斗争的
  • 字段可变性

    • 分片键是不可扭转的,如果能够变动,就会呈现数据存在但查问不到的状况

业务优先级通常是用户侧比拟重要,毕竟是客户起源,其次咱们这个零碎实质是个 saas 零碎,那么对应的优先级最高的应该是用户侧中的商户,且零碎中大部分的查问都存在 mid 这个字段,所以抉择 mid 作为咱们的分片键是比拟适合的,假如是 2c 的业务,那么抉择 uid 作为分片键也是比拟常见的计划;对于字段可变性来说,上述抉择的分片键都是不可变的,所以都是能够抉择的。
既然抉择了 mid 作为分片键,尽管对于用户侧(商户)的查问是比拟不便的,然而对于用户侧(乘客)的查问就不那么不便,用户侧(乘客)又是间接应用公司小程序的,如果查问迟缓必然会引起一些客诉等等。因为是库内分表,且零碎中对于用户侧(乘客)的查问较少且查问根本为 oid+uid 或独自为 uid,在建设好对应的索引且分表数量不是很多的状况下应用 UNION ALL 来查问也是一个较为适合的折中计划。
假如是分库分表呢?

有一种计划是利用空间换工夫的,就是 冗余表数据,将订单数据分为两份,一份数据应用商户的分片键来分表,一份数据用乘客的分片键来分表,即商户的用 mid 作为分片键,用户的用 uid 作为分片键,这样不同的查问就应用不同的数据反对,然而由此也带来了一些问题:

  • 因为是数据冗余,为了避免单方同时批改订单状态,所以批改时要订单上锁(分布式锁),避免两端同时同步;
  • 因为是数据冗余,数据是同步的,那么必然存在数据不统一的工夫窗口;
  • 加大了零碎的整体复杂度;

下面把用户侧的查问需要根本都解决了,那么接下来的就是经营侧的查问需要,经营侧的查问需要查问多个商户之间的数据,那么这里对于应用 mid 作为数据分片键的话就无奈提供较好的反对了,通常经营侧的查问也是公司内部人员在应用,咱们为了缩小零碎整体的复杂度对经营侧的查问页面做了一些斗争,针对大部分状况下经营侧必须抉择商户能力查问,但小局部比拟罕用的查问则无需必填商户查问,例如(订单号,手机号,uid 等等),这类查问同样是应用 UNION ALL 反对。

此处也有一种计划是将将数据数据灌入 es 解决,通过 es 反对多条件分页查问,如果 es 数据量过大,能够配合 hive 数据联合应用,es 只落入要害数据,hive 落入全副数据,每次进行条件查问获取 rowId,而后依据再到 hive 中查问所有数据。

分片数量

抉择好了分片键,接下来看下第二个问题

程度分表具体要分多少张表?

通常在设计分片数量时要思考业务将来的增长量,这样次要是为了防止或缩小扩容次数。那么以后数据库容量为 1300W(3 个月)

  • 依照目前的增量 每日 30W 左右,月增量为 3030=900W,将来某个工夫点订单表为 9003=2700W(3 个月);
  • 因为产品对将来拓展商户持激进态度,则将来 5 年咱们依照 50% 的增量算,30(1+0.5)=45W, 月增量为 4530=1350W,将来某个工夫点订单表为 1350*3 = 4050W;

这里咱们预估将来 5 年内的数据在 4000W 左右,那么每张分片的数量咱们权且依照阿里巴巴 Java 开发手册中的 500W 来作为节点,那么 4000/500 = 8,分片的数量为 8,能够撑持将来 5 年内的数据增长。

真的是 500W 为规范吗?
MySQL 单表数据最大不要超过多少行?为什么?

分片策略

抉择完了分片键和确定分片数量之后,接下来看看第三个问题

分片算法或者说分片策略是什么?

分片策略实质上就是说数据通过肯定的策略晓得本人到哪个分片去,先看看支流的分片策略别离有哪些

分片策略 形容 长处 毛病
范畴(Range) 将数据依照范畴划分为不同分片,例如 id,工夫; 单表数据可控,不便扩容 热点问题:比方依照工夫划分,因为数据是间断的,那么最近的数据就会频繁读写
取模(Hash) 分片键取模(对 hash 后果取余数 hash(分片键) mod N),N 为数据库实例数或子表数量) 实现简略,无效防止数据歪斜 扩容麻烦,若产生扩容,数据则须要迁徙,所以倡议提前布局好分片规模或分表实例为 2 的 N 次方再或者应用一致性 hash;
范畴 + 取模(Range+Hash) 将数据先依照范畴划分到不同分片库,再在分片库内应用取模将数据划分到分片表,罕用在分库分表场景 蕴含了上述两者的长处,解决了 hash 的扩容麻烦 如同没啥毛病
预约义(List) 对分片键进行预约义列表划分,间接将某类数据路由到指定数据分片 实现简略,不便扩容 须要提前将数据划分到对应的分片规定,后续若有新增分片键须要批改配置

基于咱们上述的剖析,曾经明确了分片键和分片数量的场景下,目前可能抉择的只有取模或者预约义,那么假如应用取模的话,这里就存在一个问题,因为咱们是通过 mid 进行分片,每一个 mid 的数据量不同,有的商户数据量大,有的商户数据量小,hash 取模之后分到的数据分片的数据量可能存在数据歪斜的状况,而预约义列表咱们能够通过已知的订单数据来将商户进行分组,划分到不同的表,能够做到数据尽量不歪斜,实现上也绝对简略,对前期的扩容也不会有太大的影响。

业务革新

业务革新计划?数据如何平滑迁徙?

对于数据分片咱们曾经实现了一大半的工作量,剩下的就是实际操作要面临的一些问题了,诸如代码革新数据的读写(插入,查问,连表查问等),历史数据迁徙等等。

代码革新

首先基于读写须要提供一个工具类在读写之前可能依据 mid 获取对应的数据分片(表名),以下是一个示例:

public class TableNameRouter {
    
    // key 为 mid, value 为表名
    private static final Map<String, String> tableRouter;

    private static final String[] allOrderInfoTables = {
            "order_1","order_2","order_3","order_4",
            "order_5","order_6","order_7","order_8"
    };
    
    static {tableRouter = new ConcurrentHashMap<>();
        loadConfig();}

    /**
    *  加载配置
    */
    private static void loadConfig(){
        // 从配置文件读取 省略···
        // 此处写个固定代码示意示例
        tableRouter.put("mid1","order_1");
        tableRouter.put("mid2","order_2");
        tableRouter.put("mid3","order_3");
        tableRouter.put("mid4","order_4");
        tableRouter.put("mid5","order_5");
        tableRouter.put("mid6","order_6");
        tableRouter.put("mid7","order_7");
        tableRouter.put("mid8","order_8");
        tableRouter.put("mid9","order_1");
        // 略···
    }

    /**
    *  依据 mid 获取表名
    */
    public static String getTableNameByMid(String mid){
        // 校验逻辑 判空 自定义逻辑省略···        
        return tableRouter.get(mid);
    }

    public static List<String> getAllOrderInfoTable() {return Arrays.asList(allOrderInfoTables);
    }
}

有了上述这个工具类,那么在当写入数据时就能够通过 mid 获取到表名,进而插入到正确的表;对于插入和更新时都存在这 mid,所以革新写操作还是很简略的,只有留神不漏改了就行;那么当读操作时状况就会略微简单了,咱们下面也提及过当存在 mid 时间接同样应用和写操作一样的办法去获取表名即可,当局部查问没有 mid 时则是通过 UNION ALL 来兼容这种状况,上面给个依据订单号查问示例作为参考:


<select id="findByOrderNo" resultMap="BaseResultMap">
    <foreach collection="allOrderList" item="table" index="index" separator="UNION ALL" >
        SELECT * FROM ${table} WHERE oid = #{oid}
    </foreach>
</select>

查问操作还有一种是联表查问,因为联表查问中不存在 mid,所以须要将联表查问拆开,首先依据主表获取关联的数据,再通过 UNION ALL 的模式来将数据查出,最初在程序中匹配数据。

平滑迁徙

数据迁徙就是将已存在表里的数据别离依照之前定义好的分片键和分片策略将数据别离汇入到对应的数据分片表里。
数据迁徙过程中要满足的以下几个指标

  • 迁徙应该是在线的迁徙,也就是在迁徙的同时还会有数据的写入;
  • 数据应该保障完整性,也就是说在迁徙之后须要保障新的库和旧的库的数据是统一的;
  • 迁徙的过程须要做到能够回滚,这样一旦迁徙的过程中呈现问题,能够立即回滚,不会对系统的可用性造成影响。

一般来说,通常的做法有以下几种:

订阅 binlog

  1. 通过订阅 binlog 日志同步的形式,同步实现之后在业务低峰期切换读写新数据源,具体步骤如下:

    1. 革新代码,将数据的增删改查改为到新数据源,增加开关配置,默认关
    2. 通过 MQ 生产 binlog 日志(存量数据 + 增量数据)将数据分到新数据源中
    3. 新数据源在同步追上旧数据源时(MQ 内基本上无音讯,不可能做到齐全同步除非进行对外提供接口,但依然可能有一些进入)做数据验证,以老数据为基准校准新数据源的数据,确保生产正确
    4. 业务低峰期上线新代码(尽量减少数据的写操作【如敞开定时工作,数据归档等】),并关上写新数据源开关
    5. 流量曾经切到新数据源后,即 MQ 无生产音讯后,敞开消费者,再做数据验证,保证数据一致性

长处 :上线周期短
毛病:1. 上线工夫有要求 2. 会呈现不满足回滚的要求,因为当你上线之后,一旦呈现问题,数据曾经写入新的数据源(分表或分库)但没有写入旧的数据源(旧表或旧库),不可能再将数据源改回旧的数据源

双写

  1. 通过双写的计划,此处的双写就是 新旧数据源都写,具体步骤如下:

    1. 将老的数据源的数据同步到新的数据源(通过订阅 binlog 或者代码)
    2. 革新业务代码,数据写入的时候,不仅要写入旧数据源,也要写入新数据源

      1. 这里应用同步(损耗性能)或异步(通过 MQ)都能够,只有保障写入胜利即可
      2. 写入新数据源失败的状况下须要记录下该日志,不便后续对这些失败的数据补写,保障一致性
    3. 业务低峰期上线,上线新业务代码前须要断开同步,为了避免数据抵触
    4. 数据验证,能够抽取局部数据,具体数据量根据总体数据量而定,只有保障这些数据是统一就能够
    5. 业务代码对数据的读取进行灰度,一部分数据从新数据源读取,并继续察看
    6. 察看没问题后,全量放开新数据源的读取
    7. 敞开双写,敞开旧数据源的写入,只写入新数据源

tips: 断掉同步和开启双写时数据有可能在一直的写入,如果在这个工夫窗口期内呈现数据写入 则须要通过后续的数据校验和补写,保证数据一致性
长处 :迁徙的过程能够随时回滚,将迁徙的危险降到了最低
毛病:工夫周期长,存在革新老本

渐进式双读

  1. 读新旧数据源来保障程序运行,次要代码存在大量兼容的逻辑:

对于 read 操作:先从新数据源中读,如果没有的话再去旧数据源中读,如果从旧数据源中读到了数据,则将数据写入到新数据源中
对于 update 操作:应用相似的逻辑,如果在旧数据源中有数据,而新数据源中没有,则将数据迁到新数据源中,再进行 update
对于 write 操作:写入到新数据源,而后将旧数据源中的数据删除
等到旧数据源中的数据全副迁徙实现,改为间接读写新数据源
长处 :比较简单
毛病:迁徙的过程齐全依赖于应用程序对数据的拜访,存在一部分数据长时间没有被拜访,那么这些数据也就没有机会被迁徙到新数据源,计划不能像双写计划那样随时回滚到旧数据源,因为数据只会存在一份,要么在新数据源,要么在旧数据源,一旦从旧数据源迁徙到新数据源,就没有办法迁徙回来了

数据校验: MD5 是一种比拟快的形式

上述几种形式,比拟稳当的就是双写计划。但无论任何一种计划,数据一致性都是重中之重,因为咱们就是围绕数据在提供服务。在具体实施时综合思考下应用了 binlog 计划,也属于是逼上梁山的一种计划,毕竟该计划是没有方法回滚操作的,这就须要咱们对整体我的项目有了比拟清晰的认知,将对我的项目产生的影响到最小。

预先思考

是否能够通过业务斗争来防止分表操作?例如只保留最近一个月的数据

数据分片毕竟是比拟大的数据改变,如果可在业务上斗争也未必不是一个好的计划,因为实际上用户的数据实际上未必须要保留三个月,毕竟乘公交车这种较时效性的订单,用户大体不太会关注之前的数据,而对于商户来说,每个月的月初生成对账单也足以撑持相应的业务。

总结

数据分片的根本步骤:抉择分片键 - 确定分片数量 - 决定分片策略 - 业务革新(代码革新,平滑迁徙)。
后期分片键的抉择,分片数量的确定,决定分片策略都须要基于我的项目的过来和我的项目的将来综合思考,找到适宜我的项目自身的计划;前期的业务革新次要思考我的项目在运行过程中切换数据源的平滑性以及保证数据一致性。拆分来看,数据分片并不是多难的一个技术且市面上又有很多相似的我的项目实战,但难就难在数据分片是具体问题具体分析,所以切记后期剖析不要焦急,做好短缺筹备,实战时留神细节,能力保障十拿九稳!

参考链接

用 uid 分库,uname 上的查问怎么办?
分库分表经典 15 连问
数据迁徙时该如何做
如何做在线数据迁徙
分库分表,用图来阐明清晰多了

正文完
 0