系统炸了?数据库单表存了七十亿条数据

故事背景

最近接了一个慢 sql 查询优化的需求,告警电话疯狂 call,是时候对历史项目治理下了,首先开始梳理

  • 第一步:梳理出慢 SQL 的语句,按照最大响应时间分为超过 10 秒、5 到 10 秒、1 到 5 秒到区间
  • 第二步:根据这些慢 SQL 语句,分别梳理出涉及到的数据库表
  • 第三步:对数据库表现有数据量、索引等进行排查
  • 第四步:初步针对慢 SQL 新建索引,因为涉及一些历史项目,业务这块不太熟悉,不适合直接上手改 SQL

当进行第三步时,发现在 5 秒以上的慢 sql 数据库表数据量基本达到了千万级别,其中最大的是七千万条,我们的数据库表分布式数据库 OceanBase,历史原因是一张没有建立分区的大表。

在梳理的过程中,无意间在当前数据库中瞥见了一张七十亿条数据的大表,这表是怎么突破到这么高的??? 相关涉及到的查询接口不得炸了?在代码中查看该表,原来这是一张心跳表,只插入了数据但并未进行任何查询操作,日增大概 3 千万-4 千万条记录,历史大量无效信息被存储这也是一笔很大的开销,当即就准备清空该表数据,为了保险起见我们的清空数据流程如下:1. 为该表建立重命名为其他表名;2. 在数据库中创建心跳表;3. 查看新的心跳表被插入了数据,正常生效;4. 删除已经被重命名为其他表名的心跳表

慢 SQL 治理任重道远,今晚刚对一些冗余数据进行删除、对索引结构进行了调整。明天会初步看下效果,后续如果还有告警需要深入业务代码去改造了,是个苦活呀 ~

你工作中遇见最大数据量表是多少呢?欢迎留言分享~

表存储的最佳数量推算

让我们来看下表存储的内存计算方式

基础回顾

MySQL 的 InnoDB 引擎的存储结构是 B+ 树

  1. 一张数据表一般对应一颗或多颗树的存储,树的数量与建索引的数量有关,每个索引都有一颗单独的树

  2. B+ 树的查询是从上往下一层层查询的,一般情况下我们认为 B+ 树的高度保持在 3 层以内比较好,也就是两层索引,最后一层数据,这样在查表时只需进行三次磁盘 IO 即可(实际会少一次,根系欸但常驻内存)

  3. 每个节点默认可存 16KB 数据,可以修改为 4-64KB 之间

    1. 对于 4、8、16、32KB 设置,最大行长度略小于数据库页面的一半。
    2. 对于 64KB 页面,最大行长度略小于 16KB
    3. 若行超过最大行长度,则将可变长度列用外部页存储,直到该行符合最大行长度限制。把 varchar、text 这种长度可变的存到外部页中,减小这一行的数据长度
  4. 查询速度主要取决于磁盘的读写速度,查询时每次只读取一个节点到内存中,通过这个节点的数据找到下一个要读取的节点位置,再读取下一个节点的数据,直到查询需要的数据或发现数据不存在

InnoDB 节点的存储内容

页(节点) :InnoDB 存储引擎管理数据库的最小磁盘单位,默认存储 16KB 数据,包含了 页格式行格式 信息

页格式

每一页的基本信息如下

|---------------------|------|-------------------------------------------------------------------------|
| 名称 | 空间 | 含义和作用 |
| File Header | 38字节 | 文件头,用来记录页的一些头信息,包含校验和、页号、前后节点的两个指针、页的类型、表空间等 |
| Page Header | 56字节 | 页头、用来记录页的状态信息,包含页目录的槽数、空闲空间的地址、本页的记录数、已删除的记录所占用的字节数等 |
| Infimum & supremum | 26字节 | 用来限定当前页记录的边界值,包含一个最小值和最大值 |
| User Records | 不固定 | 用户记录,我们插入的数据就存储在这里 |
| Free Space | 不固定 | 空闲空间,用户记录增加时从这里取空间 |
| Page Directort | 不固定 | 页目录,用来存储页当中用户数据的位置信息,每个槽会放4-8条用户数据的位置,一个槽占用1-2个字节,每当一个槽位超过8条数据时会自动分成两个槽 |
| File Trailer | 8字节 | 文件结尾信息,主要用来校验页面完整性 |

当新记录插入到 InnoDB 聚集索引中时,InnoDB 会尝试留出部分页面空闲以供将来插入和更新索引记录

  • 顺序(升序/降序):生成的页大约可用 15/16 的空间
  • 随机:生成的页大约可用 1/2 到 15/16 的空间
  • dev.mysql.com/doc/refman/...

假设是顺序插入,固有已经占用了 38 + 56 + 26 + 8 = 128,每一页留给用户数据的空间还剩 16 * 15/16 * 1024 - 128 = 15232 字节,此时在未考虑页目录的情况下

行格式

MySQL5.6 默认行格式未 COMPACT(紧凑),5.7 及以后默认格式为 DYNAMIC(动态),不同的行格式存储方式也是有区别,本文后续主要基于 DYNAMIC

行记录所包含信息

|-----------|-------|----------------------------------------------------------------------------------------------------------------------|
| 名称 | 空间 | 含义和作用等 |
| 行记录头信息 | 5字节 | 包含一些标志位、数据类型等信息如:删除标志、最小记录标注、排序记录、数据类型、页中下一条记录的位置 |
| 可变长度字段列表 | 不固定 | 保存可变长度的字段占用的字节数,如varchar、text、blob等 1. 若可变长度的长度小于255字节,就用1字节表示 2. 若大于255字节,用2字节表示 3. 表字段中有几个可变长字段该列表就有几个值,如果没有就不存 |
| null值列表 | 不固定 | 1. 用来存储可以为null的字段是否为null 2. 每个可为null的字段在这里占用一个bit,bitmap思想 3. 该列表占用空间以字节为单位增长,例如有9-16个可以为null 的列,则使用两个字节,没有占1.5字节的情况 |
| 事务ID和指针字段 | 6+7字节 | 1. MVCC设计中,数据行包含了一个6字节的事务ID和一个7字节的回滚指针 2. 若没有定义主键,则还会多一个6字节的行ID字段 |
| 实际数据 | 不固定 | 存储的真实数据 |

溢出页的存储

当使用 DYNAMIC 创建表时,InnoDB 会将较长的可变长度序列(如 varchar、varbinary、blob、text 类型)的值剥离出,存储到一个溢出页上,只在该列上保留一个 20 字节的指针指向溢出页

  • COMMPACT 行格式将前 768 个字节和 20 字节的指针存储在 B+ 树节点的记录中,其余部分存储在溢出页上

列是否存储在页外取决于页大小和行的总大小。当一行太长时,选择最长的列进行页外存储,直到聚集索引记录适合 B+ 树页。小于等于 40 字节的 TEXT、BLOB 直接存储在行内,不会分页

优势:

  • DYNAMIC 行格式避免了用大量数据填充 B+ 树节点从而导致长列的问题
  • DYNAMIC 行格式的想法是,如果长数据值的一部分存储在页外,则通常将整个值存储在页外是最有效的
  • 使用 DYNAMIC 格式,较短的列会尽可能保留在 B+ 树节点中,从而最大限度地减少给定行所需的溢出页数
可变长度的字符编码的存储

char 、varchar、text 等需要设置字符编码的类型,在计算所占用空间时,需要考虑不同编码所占用的空间

varchar、text 等类型会有长度字段列表来记录他们所占用的长度,但 char 是固定长度的类型,情况比较特殊,假设字段 name 的类型为 char(10) ,则有以下情况:

  • 对于长度固定的字符编码(比如 ASCII 码),字段 name 将以固定长度格式存储,ASCII 码每个字符占一个字节,那 name 就是占用 10 个字节
  • 对于长度不固定的字符编码(比如 utf8mb4),至少将为 name 保留 10 个字节。如果可以,InnoDB 会通过修剪尾部空格空间的方式来将其存到 10 个字节中

3 层 B+ 树计算

非叶子节点计算

索引页为存索引的节点,也就是非叶子节点

每一条索引记录包含了当前索引值、一个 6 字节的指针信息、一个 5 字节的行标头,指向下一层数据页的指针

假设我们的主键 id 为 bigint 类型,占 8 个字节,那么索引页中每行占用 8 + 6 + 5 = 19 字节,每页可存储 15232 / 19 = 801 条索引数据

算上页目录,每个槽平均 6 条数据,至少有 801 / 6 = 134 个槽,需要占用 268 字节的空间,大约可存 (15232 - 268) / 19 = 787 条索引数据

前两层非叶子节点计算

java 复制代码
在B+树中,一个节点索引记录为N条时,它就会有N个子节点。由于我们3层B+树的前两层都是索引记录,第一层根节点有N条索引记录,第二层有N个节点,每个节点数据类型与根节点一致,第三层节点个数为N^2
    主键为 bigint 的表可以存放 787 ^ 2= 619369个叶子节点,

数据条数计算

最大行长度略小于数据库页面的一半,每个页面留存了点空间给页格式的其他内容,我们认为每个页面最少能放两条数据,每条数据略小于 8KB,若某行的数据长度超过这个值,InnoDB 会分一些数据到溢出页

每条数据 8KB 时,每个叶子节点只能存放 2 条数据,在主键为 bigint 的情况下,只能存放:2 * 619369 = 1238738 条数据,也就是一百二十万条数据。

案例 1:
sql 复制代码
-- 这是一张非常普通的课程安排表,除id外,仅包含了课程id和老师id两个字段
-- 且这几个字段均为 int 型(当然实际生产中不会这么设计表,这里只是举例)。

CREATE TABLE `course_schedule` (
  `id` int NOT NULL,
  `teacher_id` int NOT NULL,
  `course_id` int NOT NULL,
  PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

该表的行数据:无 null 值列表,无可变长字段列表,需要算上事务 ID 和指针字段、行记录头

<math xmlns="http://www.w3.org/1998/Math/MathML"> 4 + 4 + 4 + 6 + 7 + 5 = 30 4 + 4 + 4 + 6 + 7 + 5 = 30 </math>4+4+4+6+7+5=30 字节,每个叶子节点可存放 15232 / 20 = 507 条数据,算上页目录的槽位所占空间,每个叶子节点可以存放 502 条数据,那么三层 B+ 树可存放的最大数据量为 502 * 986049 = 494,996,598,近 5 亿条数据

案例 2:
sql 复制代码
CREATE TABLE `blog` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '博客id',
  `author_id` bigint unsigned NOT NULL COMMENT '作者id',
  `title` varchar(50) CHARACTER SET utf8mb4 NOT NULL COMMENT '标题',
  `description` varchar(250) CHARACTER SET utf8mb4 NOT NULL COMMENT '描述',
  `school_code` bigint unsigned DEFAULT NULL COMMENT '院校代码',
  `cover_image` char(32) DEFAULT NULL COMMENT '封面图',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `release_time` datetime DEFAULT NULL COMMENT '首次发表时间',
  `modified_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
  `status` tinyint unsigned NOT NULL COMMENT '发表状态',
  `is_delete` tinyint unsigned NOT NULL DEFAULT 0,
  PRIMARY KEY (`id`),
  KEY `author_id` (`author_id`),
  KEY `school_code` (`school_code`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COLLATE=utf8_general_mysql500_ci ROW_FORMAT=DYNAMIC;
  1. 行记录头信息:5 字节

  2. 事务 ID 和指针字段:13 字节

  3. 可变长字段列表:title 占用 1 字节,description 占用 2 字节,总共 3 字节

  4. null 值列表:表中仅 school_codecover_imagerelease_time 3 个字段可为 null,故仅占用 1 字节

  5. 字段内容信息:

    1. id、author_id、school_code 均为 bigint 型,各占用 8 字节,共 24 字节
    2. create_time、release_time、modified_time 均为 datetime 类型,各占 8 字节,共 24 字节
    3. status、is_delete 为 tinyint 类型,各占用 1 字节,共 2 字节
    4. cover_image 为 char(32),字符编码为表默认值 utf8,由于该字段实际存的内容仅为英文字母(存 url 的),结合前面讲的_字符编码不同情况下的存储_ ,故仅占用 32 字节
    5. title、description 分别为 varchar(50)、varchar(250),这两个应该都不会产生溢出页(不太确定),字符编码均为 utf8mb4,实际生产中 70% 以上都是存的中文(3 字节),25% 为英文(1 字节),还有 5% 为 4 字节的表情 😁,则存满的情况下将占用 (50 + 250)* (0.7 * 3 + 0.25 * 1 + 0.05 * 4) = 300 * 2.55 = 765

总计占用:5 + 13 + 3 + 1 + 24 + 24 + 2 + 32 + 765 = 869 字节,故每个叶子节点可存放 15232/869 = 17 条,三层 B+ 树可以存放的最大数据量为 17 * 619369 = 10,529,273,约一千万条数据

学习交流圈

你好,我是影子,曾先后在🐻、新能源、老铁就职,兼任Spring AI Alibaba开源社区的Committer。目前新建了一个交流群,一个人走得快,一群人走得远,另外,本人长期维护一套飞书云文档笔记,涵盖后端、大数据系统化的面试资料,可私信免费获取

相关推荐
麦兜*2 小时前
MongoDB 高可用部署:Replica Set 搭建与故障转移测试
java·数据库·spring boot·后端·mongodb·spring cloud·系统架构
DemonAvenger2 小时前
分库分表实战:应对数据增长的扩展策略
数据库·sql·性能优化
keep__go3 小时前
postgresql9.2.4 离线安装
linux·运维·数据库·postgresql
IvorySQL3 小时前
当数据库宕机时,PostgreSQL 高可用在背后做了什么?
数据库·postgresql
盒马coding3 小时前
PostgreSQL与SQL Server:B树索引差异及去重的优势
数据库·postgresql
^辞安3 小时前
MVCC是如何工作的?
数据库·oracle·mvcc
程序之巅4 小时前
数据传输,数据解析与写数据库
数据库
小虾米vivian6 小时前
达梦:存储过程实现多个用户之间表的授权
数据库·达梦数据库
chillxiaohan8 小时前
GO学习记录九——数据库触发器的使用+redis缓存策略
数据库·缓存·golang