type
status
date
slug
summary
tags
category
icon
password
 

1、分库分表概述

分库分表是一种数据库或者数据表分割的技术,它将原本存储在单个数据库中的数据分散到多个数据库中,将原本单个表中的数据分散到多个数据表中。通过这种方式提高数据库的查询性能和扩展性,同时降低数据库的负载。
分库分表一般有水平拆分垂直拆分两种方案。

1.1 水平切分策略

水平拆分是指按照数据行进行拆分,将同一表的不同行分散到不同的库/表中。例如 Mysql 表的行数超过 2000 万行时就会变慢,这时可以把一张的表的数据拆成多张表来存放。水平拆分策略在后期数据量起来之后比较常用
水平拆分策略中我们一般通过路由字段把数据路由到具体的库/表,用来路由的字段称作 shardingkey
比如订单表按照用户 ID 作为 shardingkey ,这样就可以根据用户 ID 进行查询。还可以在订单号的生成规则带上 10 位用户 ID 作为 shardingkey,落具体表的时候根据订单号中的用户 ID hash 取模,这样无论根据订单号还是用户 ID 查询效果都是一样的。
水平拆分常用拆分方式:
  • 哈希分片(对 shardingkey 取模)
  • 范围分片(按ID范围、时间范围)
  • 目录分片(维护分片规则表)

2.1.1 哈希分片

最常使用的方案,对 shardingkey 取模来切分。例如 user_id%2=0 即路由到 0 库, user_id%2=1 即路由到 1 库。
一般表数为 2^n,hash 算法为 mod2^n,便于后期扩展。
notion image
优点:实现简单
缺点:扩容时需要迁移历史数据

2.1.2 范围分片

iduser_idcreatetime 等具备范围属性的字段作为 shardingkey,按 shardingkey 进行范围分段切割,不同段数据放到不同的库。例如 user_id 的值属于 [0, 1亿] 范围分配到 0 库,属于 [1亿, 2亿] 范围分配到 1 库。
notion image
优点:单表大小可控,天然水平扩展
缺点:无法解决集中写入瓶颈的问题

2.1.3 目录分片

shardingkey 和库的映射关系记录在一个单独的库中。
notion image
优点:ID 和库的 Mapping 算法可以随意更改
缺点:引入额外的单点
 
  • 垂直分表:指数据表列的拆分,把一张列比较多的表拆分为多张表,垂直分表一般是数据表设计之初就执行的步骤。例如:
    • 把不常用的字段单独放在一张表;
    • 把text,blob等大字段拆分出来放在附表中;
    • 经常组合查询的列放在一张表中。
  • 水平分表:指数据表行的拆分,

1.2 垂直拆分策略

垂直拆分是指按照列进行拆分,将不同字段分散到不同的库/表中。例如 Mysql 单库有性能瓶颈,承受的操作峰值应该在 2000 左右,超过的话应该分库,将数据拆分到不同的库中。垂直拆分一般是数据库表设计之初就执行的步骤。
垂直拆分常用场景:
  • 按字段拆分
    • 把不常用的字段单独放在一张表。
    • 把text,blob等大字段拆分出来放在附表中。
    • 经常组合查询的字段放在一张表中。
  • 按业务模块拆分:比如将用户信息和订单信息分别存储在不同的数据库中,实现单功能模块单库。

2、分库分表常用实现方案

2.1 客户端分片

  • 实现方式:在应用层代码中实现分片逻辑
  • 优点:简单直接,无额外依赖
  • 缺点:侵入性强,维护成本高
  • 代表:基于 Mybatis 插件,实现 Interceptor 接口,拦截 SQL 后自定义分库分表逻辑。

2.2 中间件代理

  • 实现方式:使用独立代理服务处理分片逻辑
  • 优点:应用透明,可集中管理
  • 缺点:增加网络跳数,性能开销
  • 代表
    • MySQL Router
    • ProxySQL
    • Atlas(360,基于Mysql Proxy)

2.3 数据库中间件

  • 实现方式:使用专门的分库分表中间件
  • 优点:功能完善,支持多种分片策略
  • 缺点:学习成本,运维复杂度
  • 代表
    • ShardingSphere(Apache开源,含Sharding-JDBC、Sharding-Proxy)
    • MyCat(基于阿里Cobar开发)
    • TDDL(淘宝分布式数据层)

2.4 分布式数据库

  • 实现方式:使用原生支持分片的分布式数据库
  • 优点:天然支持,无需额外开发
  • 缺点:技术锁定,迁移成本
  • 代表
    • TiDB
    • CockroachDB
    • OceanBase

2.5 技术选型

  1. 中小规模:Sharding-JDBC(应用层集成)
  1. 大规模复杂场景:Sharding-Proxy 或 MyCat
  1. 全新项目:考虑 TiDB 等分布式数据库
  1. 云环境:优先考虑云厂商提供的分布式数据库服务

3、分库分表带来的挑战

分库分表的技术难点:
  • 跨库JOIN问题
  • 非shardingkey的查询问题
  • 分页排序问题
  • 分布式唯一主键
  • 数据迁移
  • 跨库事务

3.1 跨库JOIN问题

在分库分表架构中,因为数据被分散在不同的数据库或表中,传统的 SQL Join 操作无法直接使用,这就是跨库 JOIN 问题
💡
尽量避免跨库 Join
在设计时优先考虑单库操作,让需要 Join 的数据尽量在同一个分片。如果一定要实现跨库 JOIN,目前常见的技术方案有:
技术方案
实现
优点
缺点
应用层Join
先查询一个表的数据,根据结果再去查询另一个表,在应用内存中完成数据关联
• 实现简单直接 • 不需要中间件支持
• 需要多次查询,性能较差 • 网络开销大 • 应用代码复杂度高
全局表/广播表
将需要频繁Join的小表在所有分库中都保存一份,这些表数据通常很少变更
• Join操作可以在单个库内完成 • 性能较好
• 只适用于小表 • 数据同步有延迟问题
字段冗余
在表中冗余存储需要Join的字段,避免Join操作
• 查询性能好 • 实现简单
• 数据一致性维护困难 • 存储空间增加
使用中间件
使用ShardingSphere、MyCat等中间件,解析SQL并重写查询
• 对应用透明 • 可以支持部分跨库Join
• 性能开销 • 复杂Join可能不支持
分布式查询引擎
使用Presto、Doris等分布式查询引擎从各分片获取数据并合并
• 支持复杂查询 • 对应用透明
• 系统复杂度高 • 资源消耗大

3.2 非shardingkey的查询问题

一般情况下我们使用的 sharding key 都是针对 C 端的用户,这是数据库的主要流量。而非 shardingkey 一般都是针对B端用户的,例如来自商户端或者后台的查询,这种查询流量不大而且可以接受少量延迟,一般解决方案有:
1、双写。双写就是下单的数据落两份,C 端和 B 端的各自保存一份,C 端用你可以用单号、用户 ID 做 shardingkey 都行,B端就用商家卖家的 ID 作为 shardingkey 就好了。对于 B 端来说轻微的延迟是可以接受的,所以可以采取异步的方式去落 B 端订单。
notion image
2、走离线数仓或者ES查询。订单数据落库之后,不管你通过 binlog 还是 MQ 消息的都形式,把数据同步到数仓或者ES,他们支持的数量级对于这种查询条件来说就很简单了。同样这种方式肯定是稍微有延迟的,但是这种可控范围的延迟是可以接受的。这种解决方案还可以解决比如运营、业务、产品等复杂的查询条件。
notion image

3.3 分页排序问题

分页排序要解决的问题是
跨越多个水平切分数据库,且分库依据与排序依据为不同属性,并需要进行分页,实现 select * from T order by time offset X limit Y 的跨库分页SQL
这个问题目前仍是业界难题,常用有以下解决方案:

3.3.1 全局视野法

实现方案:
  1. order by time offset X limit Y,改写成 order by time offset 0 limit X+Y
  1. 服务层将改写后的 SQL 语句发往各个分库
  1. 假设共分为 N 个库,服务层将得到 N*(X+Y) 条数据
  1. 服务层对得到的 N*(X+Y) 条数据进行内存排序,内存排序后再取偏移量 X 后的 Y 条记录,就是全局视野所需的一页数据
优点:
  • 通过服务层修改SQL语句,扩大数据召回量,能够得到全局视野,业务无损,精准返回所需数据。
缺点:
  • 网络消耗增加:每个分库需要返回更多的数据,增大了网络传输量(耗网络);
  • CPU 消耗增加:除了数据库按照 time 进行排序,服务层还需要进行二次排序,增大了服务层的计算量(耗CPU);
  • 性能下降明显:这个算法随着页码的增大,性能会急剧下降,这是因为 SQL 改写后每个分库要返回 X+Y 行数据。假如要返回第 100页,offset 中的 X=9900,即每个分库要返回 100 页数据,数据量和排序量都将大增,性能平方级下降。

3.3.2 业务折衷法-禁止跳页查询

为了解决全局视野法的性能问题,很多产品并不提供“直接跳到指定页面”的功能,而只提供“下一页”的功能。这种设计可以大大降低难度:
  1. 用全局视野法取得第一页数据,并得到第一页记录的 time_max
  1. 每次翻页,将 order by time offset X limit Y,改写成 order by time where time > $time_max limit Y,以保证每次每个库只返回一页数据,性能为常量。
优点:
  • 数据的传输量和排序的数据量不会随着不断翻页而导致性能下降。
缺点:
  • 需要业务配合

3.3.3 业务折衷法-允许模糊数据

原理是:使用 shardingkey 进行分库,在数据量较大,数据分布足够随机的情况下,各分库所有非 patition key 属性,在各个分库上的数据分布,统计概率情况是一致的。
在允许数据精度损失的前提下。利用这一原理:
  1. order by time offset X limit Y,改写成 order by time offset X/N limit Y/N
  1. 每个库返回的都是精度不够准确的数据。
优点:
  • 大大降低了技术方案的复杂度,既不需要返回更多的数据,也不需要进行服务内存排序了。
缺点:
  • 返回的数据不够准确

3.3.4 二次查询法

这个方法要求数据均摊到各分表,就是说不会有一个分表的数据特别多或特别少。这个方案不适用数据分段切分的表。实现思路:
  1. order by time offset X limit Y,改写成 order by time offset X/N limit Y
  1. 找到最小值 time_min
  1. between 二次查询, order by time between $time_min and $time_i_max,第二次查询条件放宽了,故第二次查询会返回比第一次查询结果集更多的数据
  1. 找到 time_min 在各个分库的 offset,从而得到 time_min 在全局的 offset
  1. 得到了 time_min 在全局的 offset,自然得到了全局的 offset X limit Y
优点:
  • 即能够满足业务的精确需要,无需业务折衷,又高性能
缺点:
  • 需要二次查找数据库

3.4 分布式唯一主键

在分库分表后如何生成全局唯一的数据库主键,常见有以下解决方案:

3.4.1 业务字段组合唯一键

实现:使用业务字段(如用户ID、时间戳等)组合作为唯一标识,例如 yyMMddHHmm[10] + uid后四位[4] + server Id[2] + 随机数[4]
优点:无需额外组件
缺点:依赖业务规则,可能不够通用

3.4.2 UUID

实现:UUID(Universally Unique Identifier,通用唯一标识符)是一种用于唯一标识信息的标准化格式,通常由 32个十六进制数字 组成,以连字符分隔为五组,形式为: 8-4-4-4-12(例如: 550e8400-e29b-41d4-a716-446655440000)。Java 内置了 UUID 的生成工具。
优点:简单易实现,全局唯一
缺点:无序导致索引效率低,存储空间大(128位)
适用场景:对性能要求不高的小型系统

3.4.3 数据库自增

实现:单独部署一个数据库实例专门用于生成ID,创建一张用于生成 ID 的数据表,给 id 字段设置 auto_increment_incrementauto_increment_offset 来保证 ID 自增,每次业务使用下列 SQL 读写 MySQL 得到ID号。
流程如下所示
notion image
优点:简单易实现
缺点:
  • 强依赖 Mysql,当 Mysql 异常时整个系统不可用。
  • 受 Mysql 的读写性能限制,容易出现性能瓶颈。

3.4.4 号段模式

号段模式是数据库自增方案的优化。
实现:预先从数据库获取一批 ID 号段,缓存在本地内存中,当应用需要 ID 时直接从内存中获取,当号段使用完毕后再向数据库申请新的号段。通常需要设计一个专门的表来管理号段:
实现步骤:
  1. 应用启动时或当前号段用完时,向数据库申请新号段
  1. 数据库通过原子操作更新 max_id 并返回新号段范围
  1. 应用将号段范围缓存在内存中
  1. 应用从内存中顺序分配ID
优点
  • 高性能:ID生成在内存中完成,性能极高
  • 可扩展:通过增加step可以调整号段大小,适应不同吞吐量需求
  • 简单可靠:实现简单,依赖少,稳定性高
  • 趋势递增:生成的ID是趋势递增的,适合数据库索引
缺点:
  • ID不连续:号段之间不连续,但趋势递增
  • 重启可能浪费:应用重启可能导致号段中的ID未被使用而浪费
  • 数据库依赖:仍然依赖数据库,但访问频率很低
优化方案
  • 双Buffer优化:预加载下一个号段,实现无缝切换(美团 Leaf 的实现方案)
  • 动态调整step:根据使用情况动态调整号段大小
  • 多实例隔离:不同业务使用不同 biz_tag 隔离
  • 监控告警:监控号段使用情况,提前预警

3.4.5 snowflake算法

如果对性能要求较高,可以考虑 snowflake 算法方案,64-bit 分别划分成 4段 1-41-10-12 的 64 位字符串
  • 1bit不用
  • 41-bit:时间戳,可以表示69年的时间
  • 10-bit:workerID,可以表示1024台机器,也可以5-bit给IDC,分5-bit给工作机器。这样就可以表示32个IDC,每个IDC下可以有32台机器
  • 12-bit:自增序列号
notion image
优点:
  • 趋势递增,高性能
  • 不依赖第三方库
缺点:
  • 依赖机器时钟,时钟回拨会导致问题

3.4.6 分布式ID生成服务

业界常用的开源框架有:
  • 美团Leaf:提供号段模式和snowflake算法两种实现。
  • 滴滴Tinyid:基于美团  Leaf-segment 算法的扩展。
  • 百度(uid-generator):基于 snowflake 算法的扩展。
以 美团Leaf 为例。分为 Leaf-segmentLeaf-snowflake 模式。
  • Leaf-segment:对号段模式方案的优化
    • segment 分段取id,减少 mysql 的 IO 次数
    • 双buffer:号码消耗到一定程度(10%)就开启异步线程去取新的id
    • notion image
  • Leaf-snowflake:对 snowflake 算法的优化,依赖 zk 生成 workerID
    • notion image
优点:
  • 高可用
  • 解决了时钟回拨的问题
缺点:
  • 依赖 zookeeper

3.5 数据迁移

  1. 前提准备
    1. 代码改成双写。在服务层对订单表进行增删改的地方,需要修改相应的代码同时写新库和老库,代码需要提供灰度功能以及热开关切换功能。
    2. 准备一个空白的新库
    3. 准备数据迁移脚本,用于旧数据全量迁移
    4. 准备校验程序脚本,用于校验新库和老库的数据是否一致。
  1. 开启双写,老库和新库同时写入。任何对数据库的更改操作(增/删/改)都要双写。如果新库没有相关记录,需要先从老库查出记录,将更新后的记录写入新库。为了保证写入性能,老库写完后,可以采用消息队列异步写入新库。
  1. 开启数据迁移脚本,将某一时间戳之前的老数据迁移到新库。
    1. 时间戳一定要选择第 2 步开启双写后的时间点,比如开启双写后 10 分钟的时间点,避免部分老数据被漏掉。
    2. 迁移过程遇到主键冲突直接忽略,因为第 2 步的更新操作,已经把记录拉到了新库。
    3. 迁移过程一定要记录日志,尤其是错误日志,如果有双写失败的情况,我们可以通过日志恢复数据,以此来保证新老库的数据一致。
  1. 等待第 3 步完成后,开启校验程序脚本,校验新库数据的完整性和准确性。
  1. 数据校验没问题后,开启双读,起初使用灰度功能给新库放少部分流量,新库和老库同时读取。由于延时问题,新库和老库可能会有少量数据记录不一致的情况,所以新库读不到时需要再读一遍老库。
  1. 提高灰度比例,逐渐将读流量切到新库。遇到问题可以及时把流量切回老库。
  1. 读流量全部切到新库后,关闭老库写入,只写新库。
  1. 迁移完成,后续可以去掉双写双读相关无用代码。
notion image

3.6 跨库事务

常规的数据库事务都是在单个连接(Session) 里面实现,如果要跨库,就要涉及多个数据库连接,常规的数据库事务不支持这种场景,这就是跨库事务问题。
💡
尽量避免跨库事务
数据设计时尽量让需要事务的操作在同一个分片,避免在一个事务中修改 db0 中的表的时候同时修改 db1 中的表。
如果一定要实现跨库事务,就需要使用分布式事务框架,参考这一篇文章 https://www.mcbilla.com/article/86e9cb68-7041-4732-a44c-3385aec608cd
JFR和JMC使用教程—应用性能分析神器雪花算法介绍和实现
Loading...