分库分表是两回事,有可能需要只分库不分表,也有可能只分表不分库.
随着业务数据的不断增加单表数据量越来越大,可能到几千万.这时就会出现sql执行性能问题,这时你就需要分表了.
分表是为了解决SQL执行效率问题,效率问题可能来自以下方面:
本质:数据表记录太多,导致索引过大,mysql无法缓存全量的索引信息,就需要从磁盘读取,在读取、更新、写入、删除时性能降低
一般而言,一个Mysql实例最多支撑到并发2000左右,而一个健康的单库并发值最好保持在每秒1000左右.
所以为了应对并发量的增加,我们需要分库,提高并发度.
单库出现的瓶颈问题:
主要有以下3种:
数据库分库分表的方式有两种:一种是垂直拆分,另一种是水平拆分.
这两种方式,拆分方式是关键,理解拆分原理是核心.
垂直拆分,就是对数据进行内容进行拆分,将一条记录拆分到多个表或是将多个业务信息拆分到多个库中.
垂直拆分的原则一般按照业务类型来拆分,核心思想是专库专用,将业务耦合度比较高的表拆分到单独的库中.
以社交app为例,常见的有用户信息、用户发帖信息,可以将这两部分内容拆分到单独的数据库中.
还以社交APP来说,随着业务的增长,用户的发帖信息表越来越大,单库也不能满足存储和查询需求,这时就需要将用户发帖信息拆分到多个数据库和数据表中,这就是数据库和数据表的水平拆分,然后根据一定的路由规则,查询、更新、插入时,找到对应的数据库、数据表.
以下说下拆分的路由规则.
常用的分库分表路由算法有:哈希取模和Range、
按照某一个字段的哈希值进行拆分,这种拆分规则比较适用于实体表,比如用户表,内容表,一般按照这些实体表的ID字段来拆分.
加入将内容表拆分成16个库,64张表,那么可以先对用户ID进行哈希运算,哈希的目的是将ID尽量打散,然后再对16取余,这样就得到了分库的值;对64取余就得到了分表的值
Range区间路由,是按照某一个字段的区间范围来进行路由,比较常用的字段如ID,时间字段(如订单的创建时间).
分库分表的中间件主要分为两种: Proxy模式、client模式
Client方式是指分库分表的逻辑都在应用本地进行控制,应用本地会直连多个数据库进行操作,然后本地进行数据的聚合汇总等操作逻辑。
Proxy方式是指挥有一个独立的应用,这个应用实现了Mysql的协议,可以对外提供服务。业务方的应用不需要直接连接数据库,而是连接这个Proxy的应用,把这个Proxy就当做一个数据库使用。Proxy会将Sql分发到具体的数据库进行执行,并返回结果。
在性能方面,Client模式会比较好,因为Client模式是通过sql重写后直连数据库的,基本上和没分库分表没有区别.而代理模式,中间多了一次路由,即sql先发送到Proxy服务,然后在Proxy服务进行sql解析、重写然后发送到mysql.另外Proxy服务还要维护一份路由关系表,这个关系表一般不会写死,而是会有一个管理服务维护,而Proxy服务需要定时拉取或监听管理服务的数据变更.
Client模式,取回数据后,是在本地进行数据合并,所以会占用本地的cpu和内存资源.
Proxy模式是单独部署的,所以是隔离状态,不会占用调用者的cpu和内容资源.
Client模式只需引入一个jar包,比较简单不会有单点故障问题.
Proxy需要单独部署服务,要考虑高可用,提高整体架构的复杂度.
Client只需引用jar包,而这个jar包一般有其他团队负责维护、升级,所以升级时可能需要改代码配置,推动比较耗时.
Proxy模式对调用者透明,升级维护对调用者无感知.
Proxy模式中间件有:
Client模式:
spring:
shardingsphere:
datasource:
names: m1
m1:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://192.168.56.101:3306/test_db?useUnicode=true&useSSL=false&characterEncoding=utf8&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai
username: root
password: 123456
sharding:
tables:
t_user:
key-generator-column-name: uid
key-generator:
column: uid
type: SNOWFLAKE
actual-data-nodes: m1.t_user_$->{0..1}
table-strategy:
inline:
sharding-column: uid
algorithm-expression: t_user_$->{uid%2}
binding-tables: t_user
props:
sql.show: true
如上面所示:
spring.shardingsphere.sharding.tables:配置需要路由的表
spring.shardingsphere.sharding.tables.xxx.key-generator-column-name: uid 路由字段
spring.shardingsphere.sharding.tables.xxx.key-generator: uid 路由字段生成策略
spring.shardingsphere.sharding.tables.xxx.key-generator.column :uid 路由字段
spring.shardingsphere.sharding.tables.xxx.key-generator.type: 路由字段生成策略算法
spring.shardingsphere.sharding.tables.xxx.actual-data-nodes: 当前路由表的表名。t_user_{0…1}代表t_user_0,t_user_1
spring.shardingsphere.sharding.tables.xxx.table-strategy:路由算法配置
spring.shardingsphere.sharding.tables.xxx.table-strategy.inline:行表达式,配置路由算法
spring.shardingsphere.sharding.binding-tables: 绑定需要分片的表名
如果在分库的情况下,每个库中需要一份相同的字典表,例如业务的配置信息.那么sharding-jdbc支持,在操作该字典表时,同时操作所有库中的字典表.
配置如下:
spring.shardingsphere.sharding.broadcast-tables=dict 配置表名
spring.shardingsphere.sharding.tables.dict.key-generator.column=id 配置全局id
spring.shardingsphere.sharding.tables.dict.key-generator.type=SNOWFLAKE 配置生成id的算法
Sharing-jdbc支持主从数据库配置,配置如下:
配置数据源名称:
spring.shardingsphere.datasource.names=ds-0,ds-1m,ds-2m,ds-1s1,ds-2s1,ds->1s2,ds-2s2
//下面配置每个数据源信息
配置主从关系:
spring.shardingsphere.sharding.master-slave-rules.ds-1(数据库名).master-data-source->name=ds-1m
spring.shardingsphere.sharding.master-slave-rules.ds-1.slave-data-source->names[0]=ds-1s1
spring.shardingsphere.sharding.master-slave-rules.ds-1.slave-data-source->names[1]=ds-1s2
spring.shardingsphere.sharding.master-slave-rules.ds-2.master-data-source->name=ds-2m
spring.shardingsphere.sharding.master-slave-rules.ds-2.slave-data-source->names=ds-2s1,ds-2s2
然后配置分片信息:
spring.shardingsphere.sharding.tables.xxx.actual-data-nodes: 当前路由表的表名。t_user_{0..1}代表t_user_0,t_user_1
xxx应该修改为上面的ds-1
然后sharding-jdbc会自动将insert/update类型的sql路由到主库
分布式事务问题,查看该文章.
分布式事务
在分库分表后,主键将无法使用自增长来实现了,在不同的表中我们需要统一全局主键 ID。因此,我们需要单独设计全局主键,避免不同表和库中的主键重复问题。
全局唯一ID的实现方案有如下几个:
使用 UUID 实现全局 ID 是最方便快捷的方式,即随机生成一个 32 位 16 进制数字,这种方式可以保证一个 UUID 的唯一性,水平扩展能力以及性能都比较高。但使用 UUID 最大的缺陷就是,它是一个比较长的字符串,连续性差,如果作为主键使用,性能相对来说会比较差。
雪花算法: Twitter 开源的分布式 ID 生产算法——snowflake 解决全局主键 ID 问题,snowflake 是通过分别截取时间、机器标识、顺序计数的位数组成一个 long 类型的主键 ID。这种算法可以满足每秒上万个全局 ID 生成,不仅性能好,而且低延时。
参考博客
雪花算法有以下几个优点:
高并发分布式环境下生成不重复 id,每秒可生成百万个不重复 id。
基于时间戳,以及同一时间戳下序列号自增,基本保证 id 有序递增。
不依赖第三方库或者中间件。
算法简单,在内存中进行,效率高。
雪花算法有如下缺点:
依赖服务器时间,服务器时钟回拨时可能会生成重复 id。算法中可通过记录最后一个生成 id 时的时间戳来解决,每次生成 id 之前比较当前服务器时钟是否被回拨,避免生成重复 id。
设计架构:
设计思路:
全局唯一id服务:
客户端:
数据库:
表结构:
业务线信息 | tag(调用ID接口参数tag) | id(当前id) | step(步长) | status(状态) |
---|---|---|---|---|
订单业务线 | biz_order | 10000 | 2000 | 1 |
用户信息业务线 | biz_user | 1000 | 1000 | 1 |
基于Redis INCR 命令生成 分布式全局唯一id
INCR 命令主要有以下2个特征:
实现方式有两种,一种通过节点,一种通过节点的版本号
由于分库分表后,业务关联数据被分散到多个库,多个表中.
当程序中出现统计查询、业务列表查询排序、分页时就可能出现跨库join查询、多表分页查询等问题.
可以按照以下两种方案解决:
统计查询一般实时性比较低,后台使用,所以可以不直接查询业务库,可以单独抽取后台统计库,监听业务库数据变更binlog,将数据同步到后台统计库中,进行统计查询.
在统计库中,可以按照查询维度或业务场景,周期性生成统计表.
对于互联网C端业务而言,列表查询,一般借助单独的搜索引擎架构,如下:
索引服务:
缓存服务:
业务系统查询列表数据流程:
在高并发业务系统中应尽量避免join查询,如果业务进行了分库分表,join查询将变得更加复杂.
可以将join查询拆成多个主键、索引的单表查询,热点数据进行缓存,尽量查询缓存,降低join查询的使用呢.
随着数据的增加,进行分库分表,那么根据路由规则,原先在一个库,某张表的数据将被分散到多个库,多个表中,如何进行迁移?
如果进行扩容,那么根据路由规则,怎么更好的扩容,减少数据的迁移?
解决方案,看下面两个文章:
扩容方案
迁移方案
内容摘抄
如果要进行分库分表,我们要做哪些工作,以下我们来梳理一下一些注意事项和最佳实践:
业务背景:
我们在业务重构阶段需要重点排查一下涉及分库分表的表,用到了哪些字段进行查询.
1)哪些查询是无法拆分的?例如分页(尽可能地改造,实在改不了只能以冗余列的形式)
2)哪些查询是可以业务上join拆分的?
3)哪些表/字段是可以融合的?
4)哪些字段需要冗余?
5)哪些字段可以直接废弃了?
6)根据业务具体场景和sql整体统计,识别关键的分表键。其余查询走搜索平台。
这一步基于查询的拆分分析,得出旧表融合、冗余、废弃字段的结果,设计新表的字段。
产出新表设计结构后,必须发给各个相关业务方进行review,并保证所有业务方都通过该表的设计。有必要的话可以进行一次线下review。
如果新表的过程中,对部分字段进行了废弃,必须通知所有业务方进行确认。
对于新表的设计,除了字段的梳理,也需要根据具体查询,重新设计、优化索引。
在做新表融合的时候,一开始只是简单归并表A 和 表B的表,因此很多字段名相同的字段做了重命名。
后来字段精简过程中,删除了很多重复字段,但是没有将重命名的字段改回来。
导致后期上线的过程中,不可避免地需要业务方进行重构字段名。
因此,新表设计的时候,除非必不得已,不要修改原表的字段名称!
新表的索引不能简单照搬旧表,而是需要根据查询拆分分析后,重新设计。
尤其是一些字段的融合后,可能可以归并一些索引,或者设计一些更高性能的索引。
根据第一阶段整理的查询梳理结果,我们总结了这样的查询规律。
因此,我们设计了如下的整体架构,引入了数据库中间件、数据同步工具、搜索引擎(阿里云opensearch/ES)等。
Mysql分表的维度是根据查询拆分分析的结果确定的。
我们发现pk1\pk2\pk3可以覆盖80%以上的主要查询。让这些查询根据分表键直接走mysql数据库即可。
原则上一般最多维护一个分表的全量数据,因为过多的全量数据会造成存储的浪费、数据同步的额外开销、更多的不稳定性、不易扩展等问题。
但是由于本项目pk1和pk3的查询语句都对实时性有比较高的要求,因此,维护了pk1和pk3作为分表键的两份全量数据。
而pk2和pk1由于历史原因,存在一一对应关系,可以仅保留一份映射表即可,只存储pk1和pk2两个字段。
搜索平台索引,可以覆盖剩余20%的零散查询。
这些查询往往不是根据分表键进行的,或者是带有模糊查询的要求。
对于搜索平台来说,一般不存储全量数据(尤其是一些大varchar字段),只存储主键和查询需要的索引字段,搜索得到结果后,根据主键去mysql存储中拿到需要的记录。
当然,从后期实践结果来看,这里还是需要做一些权衡的:
1)有些非索引字段,如果不是很大,也可以冗余进来,类似覆盖索引,避免多一次sql查询;
2)如果表结构比较简单,字段不大,甚至可以考虑全量存储,提高查询性能,降低mysql数据库的压力。
这里特别提示,搜索引擎和数据库之间同步是必然存在延迟的。所以对于根据分表id查询的语句,尽量保证直接查询数据库,这样不会带来一致性问题的隐患。
一般新表和旧表直接可以采用 数据同步 或者 双写的方式进行处理,两种方式有各自的优缺点。
一般根据具体情况选择一种方式就行。
本次项目的具体同步关系见整体存储架构,包括了四个部分:
1)旧表到新表全量主表的同步
一开始为了减少代码入侵、方便扩展,采用了数据同步的方式。而且由于业务过多,担心有未统计到的服务没有及时改造,所以数据同步能避免这些情况导致数据丢失。
但是在上线过程中发现,当延迟存在时,很多新写入的记录无法读到,对具体业务场景造成了比较严重的影响。(具体原因参考4.5.1的说明)
因此,为了满足应用对于实时性的要求,我们在数据同步的基础上,重新在3.0.0-SNAPSHOT版本中改造成了双写的形式。
2)新表全量主表到全量副表的同步
3)新表全量主表到映射表到同步
4)新表全量主表到搜索引擎数据源的同步
2)、3)、4)都是从新表全量主表到其他数据源的数据同步,因为没有强实时性的要求,因此,为了方便扩展,全部采用了数据同步的方式,没有进行更多的多写操作。
在申请mysql存储和搜索平台索引资源前,需要进行容量评估,包括存储容量和性能指标。
具体线上流量评估可以通过监控系统查看qps,存储容量可以简单认为是线上各个表存储容量的和。
但是在全量同步过程中,我们发现需要的实际容量的需求会大于预估,具体可以看3.4.6的说明。
具体性能压测过程就不再赘述。
从上文可以看到,在本次项目中,存在大量的业务改造,属于异构迁移。
从过去的一些分库分表项目来说,大多是同构/对等拆分,因此不会存在很多复杂逻辑,所以对于数据迁移的校验往往比较忽视。
在完全对等迁移的情况下,一般确实比较少出现问题。
但是,类似这样有比较多改造的异构迁移,校验绝对是重中之重!!
因此,必须对数据同步的结果做校验,保证业务逻辑改造正确、数据同步一致性正确。这一点非常非常重要。
在本次项目中,存在大量业务逻辑优化以及字段变动,所以我们单独做了一个校验服务,对数据的全量、增量进行校验。
过程中提前发现了许多数据同步、业务逻辑的不一致问题,给我们本次项目平稳上线提供了最重要的前提保障!!
在做容量评估的时候,需要关注一个重要问题。就是分表带来的查询流量放大。
这个流量放大有两方面的原因:
因此,我们需要注意:
本项目中,存在一种业务情况会变更字段pk3,但是pk3作为分表键,在数据库中间件中是不能修改的,因此,只能在中台中修改对pk3的更新逻辑,采用先删除、后添加的方式。
这里需要注意,删除和添加操作的事务原子性。当然,简单处理也可以通过日志的方式,进行告警和校准。
3.4.3 数据同步一致性问题
我们都知道,数据同步中一个关键点就是(消息)数据的顺序性,如果不能保证接受的数据和产生的数据的顺序严格一致,就有可能因为(消息)数据乱序带来数据覆盖,最终带来不一致问题。
我们自研的数据同步工具底层使用的消息队列是kakfa,,kafka对于消息的存储,只能做到局部有序性(具体来说是每一个partition的有序)。我们可以把同一主键的消息路由至同一分区,这样一致性一般可以保证。但是,如果存在一对多的关系,就无法保证每一行变更有序,见如下例子。
那么需要通过反查数据源获取最新数据保证一致性。
但是,反查也不是“银弹“,需要考虑两个问题。
1)如果消息变更来源于读写实例,而反查 数据库是查只读实例,那就会存在读写实例延迟导致的数据不一致问题。因此,需要保证 消息变更来源 和 反查数据库 的实例是同一个。
2)反查对数据库会带来额外性能开销,需要仔细评估全量时候的影响。
延迟主要需要注意几方面的问题,并根据业务实际情况做评估和衡量。
1)数据同步平台的秒级延迟
2)如果消息订阅和反查数据库都是落在只读实例上,那么除了上述数据同步平台的秒级延迟,还会有数据库主从同步的延迟
3)宽表到搜索平台的秒级延迟
只有能够满足业务场景的方案,才是合适的方案。
由于数据同步过程中,对于单表而言,不是严格按照递增插入的,因此会产生很多”存储空洞“,使得同步完后的存储总量远大于预估的容量。
因此,在新库申请的时候,存储容量多申请50%。
具体原因可以参考我的这篇文章 为什么MySQL分库分表后总存储大小变大了?
前两个阶段完成后,开始业务切换流程,主要步骤如下:
1)中台服务采用单读 双写 的模式
2)旧表往新表开着数据同步
3) 所有服务升级依赖的projectDB版本,上线RPC,如果出现问题,降版本即可回滚(上线成功后,单读新库,双写新旧库)
4)检查监控确保没有 中台服务 以外的其他服务访问旧库旧表
5)停止数据同步
6)删除旧表
如何验证我们前两个阶段设计是否合理?能否完全覆盖查询的修改 是一个前提条件。
当新表设计完毕后,就可以以新表为标准,修改老的查询。
以本项目为例,需要将旧的sql在 新的中台服务中 进行改造。
1)读查询的改造
可能查询会涉及以下几个方面:
a)根据查询条件,需要将pk1和pk2的inner join改为对应分表键的新表表名
b)部分sql的废弃字段处理
c)非分表键查询改为走搜索平台的查询,注意保证语义一致
d)注意写单测避免低级错误,主要是DAO层面。
只有新表结构和存储架构能完全适应查询改造,才能认为前面的设计暂时没有问题。
当然,这里还有个前提条件,就是相关查询已经全部收拢,没有遗漏。
除了相关字段的更改以外,更重要的是,需要改造为旧表、新表的双写模式。
这里可能涉及到具体业务写入逻辑,本项目尤为复杂,需要改造过程中与业务方充分沟通,保证写入逻辑正确。
可以在双写上各加一个配置开关,方便切换。如果双写中发现新库写入有问题,可以快速关闭。
同时,双写过程中不关闭 旧库到新库 的数据同步。
为什么呢?主要还是由于我们项目的特殊性。由于我们涉及到几十个服务,为了降低风险,必须分批上线。因此,存在比较麻烦的中间态,一部分服务是老逻辑,一部分服务是新逻辑,必须保证中间态的数据正确性,具体见4.5.1的分析。
为什么需要新建一个 服务来 承载改造后的查询呢?
一方面是为了改造能够方便的升级与回滚切换,另一方面是为了将查询收拢,作为一个中台化的服务来提供相应的查询能力。
将改造后的新的查询放在服务中,然后jar包中的原本查询,全部替换成这个服务的client调用。
同时,升级jar包版本到3.0.0-SNAPSHOT。
为了降低风险,需要安排从非核心服务到核心服务的分批上线。
注意,分批上线过程中,由于写服务往往是核心服务,所以安排在后面。可能出现非核心的读服务上线了,这时候会有读新表、写旧表的中间状态。
1) 所有相关服务使用 重构分支 升级projectdb版本到3.0.0-SNAPSHOT并部署内网环境;
2) 业务服务依赖于 中台服务,需要订阅服务
3) 开重构分支(不要与正常迭代分支合并),部署内网,内网预计测试两周以上
使用一个新的 重构分支 是为了在内网测试两周的时候,不影响业务正常迭代。每周更新的业务分支可以merge到重构分支上部署内网,然后外网使用业务分支merge到master上部署。
当然,如果从线上线下代码分支一致的角度,也可以重构分支和业务分支一起测试上线,对开发和测试的压力会较大。
4)分批上线过程中,如果碰到依赖冲突的问题,需要及时解决并及时更新到该文档中
5)服务上线前,必须要求业务开发或者测试,明确评估具体api和风险点,做好回归。
这里再次提醒,上线完成后,请不要漏掉离线的数据分析业务!请不要漏掉离线的数据分析业务!请不要漏掉离线的数据分析业务!
1)检查监控确保没有中台服务以外的其他服务访问旧库旧表
2)检查数据库上的sql审计,确保没有其他服务仍然读取旧表数据
3)停止数据同步
4)删除旧表
在分批上线过程中,遇到了写完立即读可能读不到的情况。由于业务众多,我们采用了分批上线的方式降低风险,存在一部分应用已经升级,一部分应用尚未升级的情况。未升级的服务仍然往旧表写数据,而升级后的应用会从新表读数据,当延迟存在时,很多新写入的记录无法读到,对具体业务场景造成了比较严重的影响。
延迟的原因主要有两个:
1)写服务还没有升级,还没有开始双写,还是写旧表,这时候会有读新表、写旧表的中间状态,新旧表存在同步延迟。
2)为了避免主库压力,新表数据是从旧表获取变更、然后反查旧表只读实例的数据进行同步的,主从库本身存在一定延迟。
解决方案一般有两种:
1)数据同步改为双写逻辑。
2)在读接口做补偿,如果新表查不到,到旧表再查一次。
由于分表后,继续使用单表的自增主键,会导致全局主键冲突。因此,需要使用分布式唯一ID来代替自增主键。各种算法网上比较多,本项目采用的是数据库自增sequence生成方式。
数据库自增sequence的分布式ID生成器,是一个依赖Mysql的存在, 它的基本原理是在Mysql中存入一个数值, 每有一台机器去获取ID的时候,都会在当前ID上累加一定的数量比如说2000, 然后把当前的值加上2000返回给服务器。这样每一台机器都可以继续重复此操作获得唯一id区间。
但是仅仅有全局唯一ID就大功告成了吗?显然不是,因为这里还会存在新旧表的id冲突问题。
因为服务比较多,为了降低风险需要分批上线。因此,存在一部分服务还是单写旧表的逻辑,一部分服务是双写的逻辑。
这样的状态中,旧表的id策略使用的是auto_increment。如果只有单向数据来往的话(旧表到新表),只需要给旧表的id预留一个区间段,sequence从一个较大的起始值开始就能避免冲突。
但该项目中,还有新表数据和旧表数据的双写,如果采用上述方案,较大的id写入到旧表,旧表的auto_increment将会被重置到该值,这样单鞋旧表的服务产生的递增id的记录必然会出现冲突。
所以这里交换了双方的区间段,旧库从较大的auto_increment起始值开始,新表选择的id(也就是sequence的范围)从大于旧表的最大记录的id开始递增,小于旧表auto_increment即将设置的起始值,很好的避免了id冲突问题。
1)切换前:
sequence的起始id设置为当前旧表的自增id大小,然后旧表的自增id需要改大,预留一段区间,给旧表的自增id继续使用,防止未升级业务写入旧表的数据同步到新库后产生id冲突;
2)切换后
无需任何改造,断开数据同步即可
3)优点
只用一份代码;
切换可以使用开关进行,不用升级改造;
如果万一中途旧表的autoincrement被异常数据变大了,也不会造成什么问题。
4)缺点
如果旧表写失败了,新表写成功了,需要日志辅助处理
这一章主要再次强调稳定性的保障手段。作为本次项目的重要目标之一,稳定性其实贯穿在整个项目周期内,基本上在上文各个环节都已经都有提到,每一个环节都要引起足够的重视,仔细设计和评估方案,做到心中有数,而不是靠天吃饭:
1)新表设计必须跟业务方充分沟通、保证review。
2)对于“数据同步”,必须有数据校验保障数据正确性,可能导致数据不正确的原因上文已经提到来很多,包括实时性、一致性的问题。保证数据正确是上线的大前提。
3)每一阶段的变动,都必须做好快速回滚都预案。
4)上线过程,都以分批上线的形式,从非核心业务开始做试点,避免故障扩大。
5)监控告警要配置全面,出现问题及时收到告警,快速响应。不要忽略,很重要,有几次出现选过数据的小问题,都是通过告警及时发现和解决的
6)单测,业务功能测试等要充分
操作要有SOP
设计要有具体文档