likes
comments
collection
share

MySQL——百亿大表拆分实践

作者站长头像
站长
· 阅读数 30

背景

  • 目前存储项目的核心表(文件信息表)已经达到单表百亿级别(170亿+),随着业务增长,单表的增长速度极快,对比去年增长超一倍,所以需要提前做好拆分的规划。
  • 海量数据的存储和访问无疑会对 MySQL 数据库造成了很大的压力,同时对于系统的稳定性和扩展性提出很高的要求。
  • 单台服务器的资源(CPU、磁盘、内存等)总是有限的,最终数据库所能承载的数据量、数据处理能力都将遭遇瓶颈。

数据库现状

表结构和索引

CREATE TABLE `file_info` ( 
    `id` bigint unsigned NOT NULL AUTO_INCREMENT, 
    `app_id` smallint unsigned NOT NULL DEFAULT '0', 
    `file_key` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL DEFAULT '', 
    `file_size` int unsigned NOT NULL DEFAULT '0', 
    `service_provider_id` tinyint unsigned NOT NULL DEFAULT '0', 
    `bucket_id` tinyint unsigned NOT NULL DEFAULT '0', 
    `store_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '文件存储时间', 
    PRIMARY KEY (`id`), 
    UNIQUE KEY `uiq_file_key_appid` (`file_key`,`app_id`), 
    KEY `idx_appid_filekey` (`app_id`,`file_key`), 
    KEY `idx_store_time` (`store_time`) 
) ENGINE=InnoDB AUTO_INCREMENT=19143687461 DEFAULT CHARSET=utf8 ROW_FORMAT=DYNAMIC
  • 统计:字段数 7,索引数 4
  • 索引树高:4个索引(id,uiq_file_key_appid,idx_appid_filekey,idx_store_time)的树高分别为:4、5、5、4层

查询梳理

  • 该数据表核心流程只涉及到 appId+fileKey的精确查询,或者是对fileKey的批量查询(in查询),不涉及联表等复杂查询操作

数据量

  • 文件信息表当前数据量170亿+,数据容量为1.5TB,索引容量为3.4TB,而且增长速度较快 MySQL——百亿大表拆分实践
  • 统计方式:
SELECT
  table_name as '表名',
  table_rows as '记录数',
  truncate(data_length/1024/1024, 2) as '数据容量(MB)',
  truncate(index_length/1024/1024, 2) as '索引容量(MB)'
FROM 
  information_schema.tables
WHERE 
  table_schema=‘${需要统计的数据库名}’
ORDER BY
  data_length desc, index_length desc;

DB运行情况

QPS

MySQL——百亿大表拆分实践

平均查询时间

MySQL——百亿大表拆分实践

CPU使用率

MySQL——百亿大表拆分实践

磁盘读写情况

MySQL——百亿大表拆分实践 MySQL——百亿大表拆分实践

IO情况

MySQL——百亿大表拆分实践

总结

  • 目前单表数据量太大,且增长速度快,数据库硬件资源和处理能力有限
  • 索引太大,单个索引树已经到达5层,查询性能差

拆分原则

  • 不影响原有功能
  • 尽量简单,能简单处理,就不引入复杂的组件和逻辑

拆分方式

针对不同的情况,采取不同的处理方式,主要归类为以下几种:

  • 归档/清理:对于一些表,数据膨胀的原因主要是由垃圾数据/历史数据引起的,且这部分数据在一定时间后,永远不会再访问,那么我们可以定时的对这些表做清理或归档即可。
  • 分库:非业务强相关,且相对独立的表,从主库中移除,独立建库,减小主库压力
  • 分表:业务强相关,且单表数据量膨胀明显,才使用分表处理。

优先级: 归档 > 分库 > 分表, 如无必要,尽量不使用分表来处理,会引入极高的复杂度。

技术选型

水平拆分在业界有很多比较成熟的解决方案,主要分为两类:客户端架构(JDBC proxy)和代理架构(DB proxy)

  • 目前分库分表中间件虽然有很多,但是开源比较活跃和主流的就是 MyCatShardingJDBC 这两个项目了,所以主要聚焦这两个中间件上
  • 参考:shardingsphere.apache.org/document/cu…
    类型大致流程主流项目优缺点
    JDBC proxyMySQL——百亿大表拆分实践 MySQL——百亿大表拆分实践* ShardingSphere-JDBC优点:1. 运维成本较小缺点:1. 开发语言限制2. 对业务方而言,DB不透明3. 占用较多的数据库连接
    DB proxyMySQL——百亿大表拆分实践 MySQL——百亿大表拆分实践* MyCat 2* ShardingSphere-Proxy优点:1. 业务无感,不需要改动业务逻辑2. 异构支持缺点:1. 链路变长,增加耗时(多了代理层)2. 网络单点问题3. 运维负担大

总结:最终决定使用 JDBC-proxy 的方式,使用 ShardingSphere-JDBC 这个组件。

  • 对于该表来说,查询逻辑简单,且服务对于查询时延要求高
  • 目前公司已有多个项目使用该组件进行分库分表的落地经验
  • 减少运维压力,目前并没有专门运维 DB-proxy 的人员
  • ShardingSphere 开源社区活跃,文档完善

题外话

  • 针对存储文件记录表这种对事务要求不高、查询简单的,可以考虑使用NoSQL,天然支持水平扩展
  • 使用分布式数据库:如TiDB

详细设计

1)分表方案

  • 分表主要分为垂直分表水平分表,本次主要是为了解决单表数据量太大、降低单数据库的压力的问题,因此选择水平分表。
  • 水平分表有以下几种常见的类型:
    • 表分区
      • 优点:MySQL原生支持,对用户透明,1. 可以突破单表的容量瓶颈(会物理分开多个文件存储数据)
      • 缺点:有很多限制,且表分区底层逻辑较为复杂,开发人员比较难以控制
    • 单库分表:跟表分区类似,无法解决库级别的物理瓶颈
    • 分库分表:能够有效的缓解单机和单库的性能瓶颈和压力,突破IO、连接数、硬件资源等瓶颈

总结:无论是分区表或者单库分表,都是单纯地解决了单一表数据过大的问题,由于没有把表数据分布到不同的机器上,因此对于减轻MySQL服务器压力来说,并没有太大的作用(大家都竞争同一个物理机上的IO、CPU、网络等),要想减轻服务器的压力,就要分库分表来解决。

2)分片设计

分片原则

  • 能不分就不分
  • 分片数量尽量少,因为一个查询SQL跨分片越多,则整体性能越差,只有在必要的时候进行扩容,增加分片的数量
  • 分片尽量分布均匀。分片规则需要慎重选择做好提前规划,分片规则的选择,需要考虑数据的增长模式、数据的访问模式、分片关联性问题,以及分片扩容问题
    • 范围分片,枚举分片,一致性Hash分片,这几种分片都有利于扩容
  • 一般来说,分片的选择是取决于最频繁的查询SQL的条件

水平拆分

  • 本次拆分表的特点:所有查询条件基本都包含file_key字段,而且fileKey本身是一个uuid随机字符串,对fileKey字段使用 hash分表 可以让数据更散列,但缺点是IN查询可能会导致跨分片查询。

hash函数选择

  • 常见的Hash算法有:MD5、SHA-1、HMAC、HMAC-MD5、HMAC-SHA1等
  • Java自带hash函数:hashCode()
    • HashMap等常用的集合框架使用该方法进行hash计算
    • 结合扰动函数,让高位参与hash,能更好的均匀下标:key.hashCode()) ^ (h >>> 16)
    • 高性能:经过多次测试取平均值(循环次数100w)
      • hashCode计算耗费约10ms,MD5计算耗费约384ms,SHA-1计算耗费约401ms
    • 要注意对hashCode取绝对值
  • 一致性hash算法
    • 优点:节点增减只需要重新定位部分数据,可扩展性好
    • 缺点:
      • 需要实现自定义分片算法,开发难度相对较大
      • 使用数据库工具迁移时的使用成本也有所增加
  • 总结
    • 理论上一致性哈希算法可以降低扩容时的数据迁移成本,实际上目前的弹性伸缩的工具如:ShardingSphere 提供的 Scaling 工具,不能在当前集群上直接做迁移,必须提供新的集群,也就是说最终所有数据还是会重新迁移
    • 本身节点扩容就是一个很低频的操作,在第一次分库分表的时候进行合理估算,尽量在拆分后的很长一段时间不需要重新扩容
    • 所以先选择比较简单的方式:使用hashCode()方法对分片键进行hash,然后取模

取模算法

  • 普通的取模函数:mod() 或者 hash % length
  • 使用位运算:hash & (length - 1)

3)分布式主键

分库分表之前使用的数据库自增ID,分成多个表后,需要考虑全局唯一ID。

  • 利用数据库自增ID:引入一个全局的专门生成自增ID的库,每次插入之前先去获取一个ID
    • 优点:方便简单
    • 缺点:单点风险、单机性能瓶颈
  • 利用数据库集群并设置相应的步长
    • 优点:高可用、ID简单
    • 缺点:需要单独的数据库集群
  • Snowflake(雪花算法)
    • 优点:高可用、易拓展
    • 缺点:项目集群部署时需要防止机器ID冲突
  • UUID
    • 优点:本地即可生成,几乎不可能出现冲突
    • 缺点:太长,且无序,不适合做数据库主键

Snowflake(雪花算法)

官方提供了雪花算法 / UUID可供选择(分布式序列算法),这里着重介绍下采用雪花算法。

  • 简单介绍:雪花算法是由 Twitter 公布的分布式主键生成算法,它能够保证不同进程主键的不重复性,以及相同进程主键的有序性。
    • 在同一个进程中,它首先是通过时间位保证不重复,如果时间相同则是通过序列位保证。 同时由于时间位是单调递增的,且各个服务器如果大体做了时间同步,那么生成的主键在分布式环境可以认为是总体有序的,这就保证了对索引字段的插入的高效性。
  • 详细结构:
    • MySQL——百亿大表拆分实践
  • 雪花算法生成ID重复问题:工作进程位是关键
    • ID冲突的前提条件:
      1. 服务通过集群的方式部署,其中部分机器标识位一致
      2. 业务存在一定的并发量,没有并发量无法触发重复问题
      3. 生成 ID 的时机:同一毫秒下的序列号一致
    • 具体参考:juejin.cn/post/700758…
    • 由于项目本身依赖redis,所以我这边使用redis执行lua脚本动态分配进程号,下面是lua脚本,使用的redis命令是:EVAL script numkeys key [key ...] arg [arg ...]Redis脚本 | 菜鸟教程
local workId = redis.call('GET', KEYS[1]);
if not workId then
    workId = 0;
else
    if (workId == 1023) then
        workId = 0;
    else
	workId = (workId + 1);
    end
end
redis.call('SET', KEYS[1], workId);
return workId;

MySQL——百亿大表拆分实践

4)数据同步

由于本次拆分的数据表涉及的是核心流程,必须有回滚措施,即出现问题的时候可以切回原库(在sharding方案稳定落地前,数据必须是双向同步的,主库必须包含全量数据)

  • 使用官方提供的弹性伸缩工具:ShardingSphere-Scaling
    • 目前这个工具处在内测版本,生产使用有风险
    • 只能进行单向同步
  • DTS + sharding-proxy
    • DTS双向同步(主从延迟在1s内)
    • 但目前向阿里官方技术支持了解到,DTS只能对 MySQL -> MySQL的双向同步,无法对 MySQL -> proxy的双向同步
      • 单向同步目前DTS也不支持,DTS不支持sharding-proxy作为下游的权限校验
  • 业务双写
    • 存量数据:通过数据库迁移工具从旧库迁移到新库(全量同步 + 双写前的增量同步)
      • 使用pt-archive工具进行迁移
    • 增量数据:修改业务代码,实现数据对新旧两个库的同步双写

总结:按照目前的情况来看,要保证数据双向同步,只能依赖业务修改实现同步双写了。

5)平滑上线

对于数据库拆分的第一原则是不能影响服务,所以上线需要慎重,随时做好回滚的准备。

在上线之前,由于引入了新的组件,而且服务核心流程涉及批量查询,可能存在跨库查询,最好先对核心流程进行压测,保证性能上不会有问题。

平滑上线流程:

  1. 对数据进行全量同步,将旧库数据同步到新库
  2. 对服务实现动态版本控制,上线新服务,配置版本0,版本0的读写逻辑和旧服务一样
    • 目的:保证新上线的版本能够正常运行,风险可控
  3. 确认服务无异常后配置版本1,双写,先旧后新,数据全部从旧库读取(该版本运行1天)
    • 目的:验证新库的写入无异常,同时保证旧库具有完整的数据
      • 业务逻辑对新库写入失败做降级,不能影响用户请求,但要对写入失败的异常通知给开发人员进行处理
      • 类似目前的主从模式,从库挂了不能影响主库写入
    • 双写前未同步到新库中的数据(即版本0到版本1期间产生的增量数据),需补齐到新库中
  4. 配置版本2,双写,先新后旧,数据全部从新库中读取(由于读流量全部切换到了新库,该版本需要运行2天时间)
    • 目的:验证新库的读取无异常
      • 该版本需要保证新旧两个库数据一致,即同步双写成功,请求才算成功
        • 将版本1对新库写入失败的降级去掉
      • 假设此时新库出了问题,我们可以人工切换回版本1,虽然可能已经造成了部分请求失败。但理论上我们在版本1已经验证了新库的写入没问题,如果这里出现问题大概率只是数据库读能力有问题或者高可用没做好
    • 这个版本也可以按需多运行一段时间,因为之后就要下线新库了
  5. 配置版本3,去除双写机制,只需在新库中读写数据,旧库废弃
    • 目的:完成最终的形态,旧库平滑迁移到新库
    • 按照业务需要,看看是否需要使用MQ等异步方式对新库到旧库进行异步的单向数据同步,会更加稳妥
      • 理论上版本2多运行一段时间也能保证稳妥的平滑过渡,但同步双写有一定的副作用:对DB的高可用有更高的要求,且性能肯定是有所降低的
  6. 版本3在线上运行一段时间(大概几天)没问题后,去除平滑过渡的相关代码逻辑

总结

  • 选择分库分表的方式,以突破单库的性能瓶颈
  • 选择fileKey作为分片键,使用hashCode()方法对分片键进行hash计算,然后使用位运算的方式计算数据对应所在的子库
  • 使用雪花算法作为新库的主键,注意对服务进程ID进行合理的动态配置,防止生成重复ID
  • 使用业务双写的方式保证新旧两个库的数据一致性
  • 实现动态多版本控制的方式,逐步对新库的读写能力进行验证,以达到平滑过渡的目的

结语

  • 很荣幸有机会主导这一次对核心项目的分库分表方案设计与落地实践,对此进行一些经验分享和总结
  • 对于方案设计,主要是通过查阅各种资料和文档,结合业务情况进行思考,并内部进行评审讨论,如有不足或者建议欢迎在评论区留言讨论
  • 最近也发布了图片处理相关的专栏,以及音视频安全领域的文章,都是在项目实践中的一些经验和沉淀,有兴趣的朋友可以去主页看下
  • 文章都是原创,个人对于文章的内容和排版都有比较高的要求,创作不易,如果你觉得文章对你有帮助,可以点点赞或者点个关注~

参考文章

转载自:https://juejin.cn/post/7069231909179490341
评论
请登录