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

故事背景

最近接了一个慢 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。目前新建了一个交流群,一个人走得快,一群人走得远,另外,本人长期维护一套飞书云文档笔记,涵盖后端、大数据系统化的面试资料,可私信免费获取

相关推荐
Leon-Ning Liu23 分钟前
Oracle数据库常用视图:dba_datapump_jobs
数据库·oracle·dba
数据库生产实战1 小时前
Oracle 19C RAC下TRUNCATE TABLE的REUSE STORAGE选项作用和风险浅析!
数据库·oracle
小白银子1 小时前
零基础从头教学Linux(Day 60)
linux·数据库·mysql·oracle
瀚高PG实验室2 小时前
数据库安全配置指导
服务器·数据库·瀚高数据库
憋问我,我也不会2 小时前
MYSQL 命令
数据库·mysql
24K老游2 小时前
postgres15 flink cdc同步测试
数据库
无泡汽水3 小时前
MySQL入门练习50题
数据库·mysql
JIngJaneIL3 小时前
助农惠农服务平台|助农服务系统|基于SprinBoot+vue的助农服务系统(源码+数据库+文档)
java·前端·数据库·vue.js·论文·毕设·助农惠农服务平台
云外天ノ☼3 小时前
待办事项全栈实现:Vue3 + Node.js (Koa) + MySQL深度整合,构建生产级任务管理系统的技术实践
前端·数据库·vue.js·mysql·vue3·koa·jwt认证
小光学长4 小时前
基于Vue的智慧楼宇报修平台设计与实现066z15wb(程序+源码+数据库+调试部署+开发环境)带论文文档1万字以上,文末可获取,系统界面在最后面。
前端·数据库·vue.js