MySQL 写入记录到连接缓冲区代码分析

JOIN_CACHE::write_record_data() 是往连接缓冲区中写入记录的方法,BNL、BKA、BKA_UNIQUE 都是调用这个方法往连接缓冲区写入数据,本文基于 MySQL 5.7.35 版本的源码对该方法进行详细的分析

1. 连接缓冲区中会写入哪些字段?

  • 记录的长度,不含存储长度本身占用的字节数(可选

  • 关联的前一个连接缓冲区中的记录的 Offset(如果有前一个缓冲区的话)

  • 写入数据字估到缓冲区的表的所有表的标记字段:

    • 匹配标记(可选
    • 所有表的记录中字段 null 标识区域
    • 所有表的行是否为 null 的标记(布尔值)
  • 所有数据字段的值,包含如下:

    • 固定长度并且不会在尾部补充空格的字段(INT、FLOAT 等)的内容
    • 固定长度并且尾部会填充空格的字段(CHAR、BINARY),会去掉尾部空格,并且会先写入内容的长度,再写入内容
    • blob 字段(TINYBLOB、TINYTEXT、TINYBLOB、TINYTEXT 等),会先写入内容的长度,再写入内容
    • 除 blob 之外的变长字段(VARCHAR、VARBINARY 等),会先写入内容的长度,再写入内容
  • 连接缓冲区记录中字段的内容的首地址相对于记录第一个标记字段内容的首地址的偏移量,即字段在记录中的 Offset

MySQL 的内部实现中,并不是只有创建表结构时定义为 BLOB 的字段才是 blob 字段,像 JSONTEXT 在内部都是 blob 字段

关于连接缓冲区中记录的存储布局,以及关于 blob 字段的说明,可以看这篇文章:MySQL 数据在连接缓冲区是怎么存储的?

2. JOIN_CACHE::write_record_data()

由于方法的代码比较多,有 182 行,所以接下来会按照代码的实现逻辑,分成多个不同的代码片段来分析

 1// sql/sql_join_buffer.cc JOIN_CACHE::write_record_data()
 2
 3// 1241 行
 4uint len= pack_length;
 5
 6// 1250 ~ 1265 行
 7if (blobs)
 8{
 9  CACHE_FIELD **copy_ptr= blob_ptr;
10  CACHE_FIELD **copy_ptr_end= copy_ptr+blobs;
11  for ( ; copy_ptr < copy_ptr_end; copy_ptr++)
12  {
13    Field_blob *blob_field= (Field_blob *) (*copy_ptr)->field;
14    if (!blob_field->is_null())
15    {
16      uint blob_len= blob_field->get_length();
17      (*copy_ptr)->blob_length= blob_len;
18      // blob 字段内容的长度加到 len
19      len+= blob_len;
20      // 把 blob_field->ptr 中保存的指向 blob 字段内容的首地址赋值给 (*copy_ptr)->str
21      blob_field->get_ptr(&(*copy_ptr)->str);
22    }
23  }
24}

上面的代码,主要干了两件事:

  • 计算要写入连接缓冲区的所有 blob 字段的内容的实际长度,并把每个 blob 字段的内容长度累加到 len 上,在后面的代码中,len 会用来判断当前写入连接缓冲区之后,缓冲区是不是就写满了
  • 把 Field_blob 对象的 ptr 属性中保存的 blob 字段内容的首地址赋值给 CACHE_FIELD::str(即上面代码中的 (*copy_ptr)->str)

为什么要把 Field_blob 对象的 ptr 属性中保存的 blob 字段内容的首地址赋值给 CACHE_FIELD::str ?

在初始化 CACHE_FIELD 对象时,CACHE_FIELD::str 的值为 Field_blob::ptr 的地址,而 Field_blob::ptr 的值为 blob 字段内容的首地址,此时如果要获取 blob 字段内容,需要先找到 blob 内容的首地址,再拷贝内容

而如果把 CACHE_FIELD::str 直接指向 blob 字段内容,后面就可以直接通过 CACHE_FIELD::str 读取到 blob 字段内容了

在存储引擎中(此处特指 InnoDB,别的存储引擎还没研究过),每读取一条包含 blob 字段的记录,会分配新的内存用于存储本条记录中的所有 blob 字段的内容

对于每条记录来说,因为不会复用为之前的记录分配的内存,所以每条记录中的每个 blob 字段的内容首地址都会发生变化

1// sql/sql_join_buffer.cc JOIN_CACHE::write_record_data()
2
3// 1277 行
4bool last_record= (len+pack_length_with_blob_ptrs) > rem_space();

rem_space() 计算连接缓冲区中的剩余空间

这行代码的逻辑是比较简单的,就是判断当前写入连接缓冲区的这条记录,会不会是最后一条记录,但是为什么要这么判断,就需要展开说一说了

先说 len 和 pack_length_with_blob_ptrs,len 指的是把一条记录中 blob 字段的内容和其它字段的内容拷贝到连接缓冲区时,缓冲区中一条记录的长度(为了方便,后面把这种记录叫做带 blob 内容的记录);pack_length_with_blob_ptrs 指的是把 blob 字段的内容首地址和其它字段的内容拷贝到连接缓冲区时,缓冲区中一条记录的长度(为了方便,后面把这种记录叫做带 blob 指针的记录

带 blob 内容的记录带 blob 指针的记录唯一的区别是:带 blob 内容的记录会把 blob 字段的内容拷贝到缓冲区,带 blob 指针的记录只会把指向 blob 字段内容的地址拷贝到缓冲区,而不会拷贝 blob 字段的内容,这样就能减少拷贝到缓冲区的数据大小,从而提升点性能

知道了 len 和 pack_length_with_blob_ptrs 表示的含义,再来理解代码就心里有底了,上面的代码就是判断连接缓冲区中的剩余空间是不是能够容纳 1 条带 blob 内容的记录和 1 条带 blob 指针的记录,如果不能够容纳下这样的 2 条记录,那就说明当前记录是本轮写入连接缓冲区的最后一条记录(判断是不是最后一条记录,是干嘛用的?这个后面会有说明)

 1// sql/sql_join_buffer.cc JOIN_CACHE::write_record_data()
 2
 3// 1284 ~ 1289 行
 4uchar *rec_len_ptr= NULL;
 5if (with_length)
 6{
 7  // 把写入记录长度的址址记下来
 8  rec_len_ptr= cp;
 9  // 然后指针移动到记录长度最后一个字节之后的位置
10  cp+= size_of_rec_len;
11}

如果需要在记录的最前面存储记录的长度,会先用 rec_len_ptr 把要写入长度的地址记录下来,因为需要等一条记录的所有内容都写入连接缓冲区之后,才知道记录的长度,所以现在就是先给记录长度占个位置

保存了记录长度写入位置的首地址后,指针移动到记录长度之后,就准备好了写下一个字段的内容了,size_of_rec_len 表示记录长度本身需要几个字节来存储

 1// sql/sql_join_buffer.cc JOIN_CACHE::write_record_data()
 2
 3// 1295 ~ 1299 行
 4if (prev_cache)
 5{
 6  // 获取要存储前一个缓冲区中的记录的 Offset,需要多少字节
 7  cp+= prev_cache->get_size_of_rec_offset();
 8  // link 是关联的前一个缓冲区中的记录的首地址,在 store_rec_ref() 中会转换成 Offset
 9  // 然后把 Offset 存储到当前记录中存储 Offet 的内存空间中
10  // 上面 cp 加上了 size_of_rec_offset 的值,那么 cp 指针就指向了存储 Offset 的内存空间的末尾处了
11  // 因为在 store_rec_ref() 中有相应的处理,能够保证把 Offset 存放到正确的位置上
12  prev_cache->store_rec_ref(cp, link);
13}

如果当前要写入的记录所属的连接缓冲区,前面还有一个连接缓冲区(多个连接缓冲区组成缓冲区链表),那么当前记录就会关联前一个缓冲区中的记录,所以要把关联的前一个缓冲区的记录在前一个缓冲区中的 Offset 保存到当前记录中,后面就可以通过这个 Offset 找到关联的前一个缓冲区的那条记录

关于链接缓冲区链表,可以看一下这篇文章:MySQL 数据在连接缓冲区是怎么存储的?

1// sql/sql_join_buffer.cc JOIN_CACHE::write_record_data()
2
3// 1301 行
4// curr_rec_pos 是 JOIN_CACHE 对象的属性
5curr_rec_pos= cp;

这行代码是把记录的第一个标记字段的首地址作为记录的首地址,用于后面计算记录中的字段内容在记录中的偏移量

 1// sql/sql_join_buffer.cc JOIN_CACHE::write_record_data()
 2
 3// 1304 ~ 1314 行
 4CACHE_FIELD *copy= field_descr;
 5if (with_match_flag)
 6  *copy[0].str= 0;
 7
 8/* First put into the cache the values of all flag fields */
 9CACHE_FIELD *copy_end= field_descr+flag_fields;
10for ( ; copy < copy_end; copy++)
11{
12  memcpy(cp, copy->str, copy->length);
13  cp+= copy->length;
14} 

copy[0].str 是个指针,指向 QEP_TAB 类的 found 属性(qep_tab->found),是个布尔值,*copy[0].str= 0 就是把 qep_tab->found 设置为 false

for 循环里的逻辑是把需要的标记字段的内容拷贝到连接缓冲区

 1// sql/sql_join_buffer.cc JOIN_CACHE::write_record_data()
 2
 3// 1320 ~ 1327 行
 4Field *field= copy->field;
 5if (field && field->maybe_null() && field->is_null())
 6{
 7  /* Do not copy a field if its value is null */
 8  if (copy->referenced_field_no)
 9    copy->offset= 0;
10  continue;              
11}

上面代码是判断字段内容是否为 NULL,如果是 NULL 就不需要再把 NULL 拷贝到连接缓冲区,因为通过字段 NULL 标记这个标记字段就可以知道某个字段内容是否为 NULL

如果需要计算并保存字段在记录中的 Offset,对于内容为 NULL 的字段,它的 Offset = 0

因为这段代码是在挨个读取字段内容并写入到连接缓冲区的 for 循环中,所以处理完字段为 NULL 的逻辑之后,不需要处理循环中的其它逻辑了,continue 到下一个字段

1// sql/sql_join_buffer.cc JOIN_CACHE::write_record_data()
2
3// 1329 ~ 1330 行
4if (copy->referenced_field_no)
5  copy->offset= cp-curr_rec_pos;

如果需要计算并保存字段在记录中的 Offset,用 cp 减去第一个标记字段内容的首地址(curr_rec_pos),其中 cp 是当前字段的内容的首地址

 1// sql/sql_join_buffer.cc JOIN_CACHE::write_record_data()
 2
 3// 1322 ~ 1351 行
 4if (copy->type == CACHE_BLOB)
 5{
 6  Field_blob *blob_field= (Field_blob *) copy->field;
 7  if (last_record)
 8  {
 9    last_rec_blob_data_is_in_rec_buff= 1;
10    /* Put down the length of the blob and the pointer to the data */
11    // copy->length 是 blob 字段内容的长度占用的字节数
12    // sizeof(char *) 是 blob 字段内容指针占用的字段数
13    // 对于连接缓冲区中的最后一条记录,只拷贝 blob 字段内容的长度、内容的首地址到连接缓冲区
14    blob_field->get_image(cp, copy->length+sizeof(char*),
15                                blob_field->charset());
16    cp+= copy->length+sizeof(char*);
17  }
18  else
19  {
20    /* First put down the length of the blob and then copy the data */
21    // 如果记录不是连接缓冲区的最后一条记录
22    // 先把 blob 字段内容的长度拷贝到连接缓冲区
23    blob_field->get_image(cp, copy->length,
24              blob_field->charset());
25    // 再从 copy->str 中拷贝 blob 字段内容到连接缓冲区
26    // blob_field->ptr 中保存着 blob 字段内容的长度、内容的首地址
27    // copy->str 中保存着 blob 内容的首地址          
28    memcpy(cp+copy->length, copy->str, copy->blob_length);
29    cp+= copy->length+copy->blob_length;
30  }
31}

这段代码处理 blob 字段的拷贝逻辑,分为两个分支:

  • if (last_record) ,如果写入当前记录到连接缓冲区之后,缓冲区就满了,说明当前记录是缓冲区中的最后一条记录,此时,是不需要把 blob 字段的内容拷贝到连接缓冲区中的,只需要拷贝 blob 字段内容的首地址到连接缓冲区中,减少了拷贝的数据量,对性能有一点提升
  • else,需要把 blob 字段的内容拷贝到连接缓冲区

MySQL 为了提升性能,哪怕只能提升一点点,也会在代码层面上想各种办法进行优化,从上面关于 blob 字段的处理可见一斑

 1// sql/sql_join_buffer.cc JOIN_CACHE::write_record_data()
 2
 3// 1354 ~ 1387 行
 4switch (copy->type) {
 5case CACHE_VARSTR1:
 6  /* Copy the significant part of the short varstring field */ 
 7  // copy->str[0] 是字段内容的长度,1 表示存储字段内容的长度占用 1 字节
 8  // len = 字段内容长度 + 存储字段内容长度占用的字节数
 9  len= (uint) copy->str[0] + 1;
10  // 拷贝字段内容长度、字段内容到连接缓冲区
11  memcpy(cp, copy->str, len);
12  cp+= len;
13  break;
14case CACHE_VARSTR2:
15  // copy->str[0] 是字段内容的长度,2 表示存储字段内容的长度占用 2 字节
16  // len = 字段内容长度 + 存储字段内容长度占用的字节数
17  /* Copy the significant part of the long varstring field */
18  len= uint2korr(copy->str) + 2;
19  // 拷贝字段内容长度、字段内容到连接缓冲区
20  memcpy(cp, copy->str, len);
21  cp+= len;
22  break;
23case CACHE_STRIPPED:
24{
25  /* 
26    Put down the field value stripping all trailing spaces off.
27    After this insert the length of the written sequence of bytes.
28  */ 
29  uchar *str, *end;
30  // 指针从内容尾部往前移动,计算出尾部空格的数量
31  // 不从内容头部往后移动,是基于这样的假设的:
32  // 使用 CHAR、BINARY 字段时,内容尾部补充空格的数量是小于内容数量的
33  // 这样从尾部往前移动,可以用更少的循环次数计算出尾部数量的空格
34  for (str= copy->str, end= str+copy->length;
35       end > str && end[-1] == ' ';
36       end--) ;
37  len=(uint) (end-str);
38  // 把实现内容长度写入连接缓冲区
39  int2store(cp, len);
40  memcpy(cp+2, str, len);
41  cp+= len+2;
42  break;
43}
44default:      
45  /* Copy the entire image of the field from the record buffer */
46  memcpy(cp, copy->str, copy->length);
47  cp+= copy->length;
48}

case CACHE_VARSTR1,表示 varchar、varbinary 字段内容的长度需要用 1 个字节来存储,要把字段内容长度、字段内容都拷贝到连接缓冲区

case CACHE_VARSTR2,表示 varchar、varbinary 字段内容的长度需要用 2 个字节来存储,要把字段内容长度、字段内容都拷贝到连接缓冲区

case CACHE_STRIPPED,表示 char、binary 字段内容的长度需要用 2 个字节来存储,要把字段内容长度、字段内容都拷贝到连接缓冲区

因为这两种类型的字段是固定长度存储的,如果内容长度小于定义字段时指定的长度,则会在内容后面补上相就数量的空格,而在把这两种类型的字段的内容存储到连接缓冲区时,为了让缓冲区能够尽可能的存储更多的内容,在写入内容时会把尾部空格去掉,后面从连接缓冲区读出内容时会再补上相应数量的空格

default,是处理除了 CACHE_VARSTR1、CACHE_VARSTR2、CACHE_STRIPPED、CACHE_BLOB 对应字段类型之外的其它类型字段的,只需要拷贝字段内容到连接缓冲区,而不需要拷贝字段内容的长度

 1// sql/sql_join_buffer.cc JOIN_CACHE::write_record_data()
 2
 3// 1392 ~ 1405 行
 4if (referenced_fields)
 5{
 6  uint cnt= 0;
 7  for (copy= field_descr+flag_fields; copy < copy_end ; copy++)
 8  {
 9    if (copy->referenced_field_no)
10    {
11      store_fld_offset(cp+size_of_fld_ofs*(copy->referenced_field_no-1),
12                       copy->offset);
13      cnt++;
14    }
15  }
16  cp+= size_of_fld_ofs*cnt;
17}

如果有些字段需要保存它们的内容在记录中的 Offset,计算出这些字段内容的 Offset 并写入到连接缓冲区中

1// sql/sql_join_buffer.cc JOIN_CACHE::write_record_data()
2
3// 1407 ~ 1411 行
4if (rec_len_ptr)
5  store_rec_length(rec_len_ptr, (ulong) (cp-rec_len_ptr-size_of_rec_len));
6last_rec_pos= curr_rec_pos; 
7end_pos= pos= cp;
8*is_full= last_record;

if (rec_len_ptr),计算记录的长度,并写入到 rec_len_ptr 占位的内存处

last_rec_pos 是 JOIN_CACHE 类的属性,保存着连接缓冲区中最后一条记录的开始处的地址(第一个标记字段的首地址),用于从连接缓冲区中读取记录时,判断一条记录是不是缓冲区中的最后一条记录(blob_data_is_in_rec_buff() 中会用到)

end_pos、pos 也是 JOIN_CACHE 类的属性,保存着连接缓冲区中最后一条记录的结尾处的地址,也就可以写入下一条记录的开始处的地址

3. JOIN_CACHE::set_constants()

JOIN_CACHE::set_constants() 是在连接缓冲区初始化的时候调用的,这个方法里计算了读写缓冲区时都会使用的一些数值,所以这里也一并拿出来分析下:

 1// sql/sql_join_buffer.cc JOIN_CACHE::set_constants()
 2
 3// 410 ~ 450 行
 4// 以下代码中删掉了源码中的注释
 5void JOIN_CACHE::set_constants()
 6{ 
 7  with_length= is_key_access() || with_match_flag;
 8  uint len= length + fields*sizeof(uint)+blobs*sizeof(uchar *) +
 9            (prev_cache ? prev_cache->get_size_of_rec_offset() : 0) +
10            sizeof(ulong) + aux_buffer_min_size();
11  buff_size= max<size_t>(join->thd->variables.join_buff_size, 2*len);
12  size_of_rec_ofs= offset_size(buff_size);
13  size_of_rec_len= blobs ? size_of_rec_ofs : offset_size(len); 
14  size_of_fld_ofs= size_of_rec_len;
15  pack_length= (with_length ? size_of_rec_len : 0) +
16               (prev_cache ? prev_cache->get_size_of_rec_offset() : 0) + 
17               length;
18  pack_length_with_blob_ptrs= pack_length + blobs*sizeof(uchar *);
19
20  check_only_first_match= calc_check_only_first_match(qep_tab);
21}

with_length 表示是否需要在连接缓冲区的记录中写入记录的长度:

  • is_key_access(),使用 BKA、BKA_UNIQUE 类型的连接缓冲区时,此方法返回结果为 true,缓冲区的记录中需要写入记录的长度
  • with_match_flag,外连接的第一个内表和首次匹配半连接的第一个内表的连接缓冲区,需要匹配标记字段,此时 with_match_flag 为 true,缓冲区的记录中需要写入记录的长度

uint len= ... 计算连接缓冲区中的一条记录占用的内存空间大小(包括存储记录的长度占用的空间),这个计算大部分情况下都是不准确的,并且也不是为了准确而进行的计算,是为了尽可能的保证连接缓冲区能够容纳至少 2 条记录,不过遗憾的是,由于 blob 字段的长度不确定,当包含 blob 字段的记录长度大于连接缓冲区的大小时,连接缓冲还是有可能只能够容纳 1 条记录的

接下来详细说说 len 的计算逻辑,len 是由 5 个部分相加得到的

  • length,这是要写入到连接缓冲区的记录的标记字段、数据字段的内容长度之和,length 长度的计算涉及到各种类型的字段,比较复杂,后面会单独用一段来说明
  • fields*sizeof(uint),连接缓冲区中记录的字段内容相对于第一个标记字段的首地址的偏移量(Offset),每个字段的 Offset 需要用一个 uint 的空间来存储,有多少个字段,就有可能需要多少个 uint 的空间。BNL 类型的连接缓冲区是不需要字段 Offset 的,BKA 类型的连接缓冲区需要,但是在此刻还不知道需要存储哪些字段的 Offset,所以这里用字段数量乘以 sizeof(uint),是按照最多(每个字段都需要)需要存储多少个 Offset 来计算需要占用的字节数
  • blobs*sizeof(uchar *),要写入到连接缓冲区的 blob 字段的内容首地址占用的空间,有多少 blob 字段,就需要多少个指针
  • (prev_cache ? prev_cache->get_size_of_rec_offset() : 0),如果连接缓冲区有前一个缓冲区,则要在当前缓冲区中存储关联的前一个缓冲的记录的 Offset,一条记录只会存储一个这样的 Offset,占用的空间通过调用 prev_cache->get_size_of_rec_offset() 获取,如果没有前一个缓冲区,就不需要占用空间了
  • sizeof(ulong),存储连接缓冲区中一条记录的长度占用的字节数,连接缓冲区中不一定需要(with_length = false 时)存储记录的长度,但也还是没有区分的都给算上了
  • aux_buffer_min_size(),辅助空间,只在使用 BKA 类型的连接缓冲区时需要辅助空间

length 里包含标记字段内容长度、数据字段内容长度,其中,匹配标记(match flag)行 NULL 标记 都只占用 1 字节,而 字段 NULL 标记 占用多少字节,取决于连接缓冲区所属表结构定义:有多少个字段的定义是没有加 NOT NULL 的(每个字段的 NULL 标志位占 1 bit),以及表中的 BIT 类型的字段,有多少 bit 是要存储在行开头的,这两部分占用的总 bit 数量,按 8 bit 对齐,就是 字段 NULL 标记占用的字节数

数据字段内容的长度,也根据 COPY_TYPE->type 来分别说一下:

  • COPY_TYPE->type = CACHE_BLOB 时,length 里只包含 blob 字段内容长度占用的字节数(1~4 字节,取决于是什么类型的 blob:tiny、blob、medium、long)
  • COPY_TYPE->type = CACHE_VARSTR1 时,length 里包含内容占用的字节数,以及内容长度占用的 1 字节
  • COPY_TYPE->type = CACHE_VARSTR2 时,length 里包含内容占用的字节数,以及内容长度占用的 2 字节
  • COPY_TYPE->type = CACHE_STRIPPED 时,length 里包含实际内容(去掉了 char、binary 尾部填充的空格)占用的字节数,以及内容长度占用的 2 字节
  • 其它(除 char、binary 之外的其它固定长度字段),只包含内容占用的字节数

buff_size= max<size_t>(join->thd->variables.join_buff_size, 2 * len)

这一行是为了尽可能的保证连接缓冲区中至少能够存下 2 条记录,当 join_buffer_size 小于 2 * len,则认为缓冲区不能容纳 2 条记录,所以就把缓冲区大小设置为 2 * len

当一个表中的记录数量很多,每个字段的内容定义的都很长时,可能一个连接缓冲区占用的内存会很大

举个例子说明:假设表中有 1 个 int 字段、100 个 varchar 字段,都定义为 NOT NULL,每个 varchar 字段长度定义为 21504(对于 utf8 字符集来说,一个字段最大占用空间为 21504 * 3 = 64512,即 63k),那么上面计算出来的 length 至少等于 1 * 4 + 100 * (2 + 64512) = 6451404,大概是 6.15M,此时如果对这个表的连接查询很多,那会是非常占用内存的

上面这个例子,我最开始看代码时认为是会存在这样的问题的,不过在 MySQL 5.7.35 中试了一下,不会出现这样的问题,因为在创建表结构时就做了限制,创建表时会限制所有字段长度加起来不能超过 65535 字节(即不超过 64k)




欢迎扫码关注公众号,我们一起学习更多 MySQL 知识: