提示:公众号展示代码会自动折行,建议横屏阅读

TXRocks是TXSQL适配RocksDB的版本,基于Facebook开源的MySQL进行了深度定制和优化。相对于当前线上常用的InnoDB引擎,RocksDB的主要优势是空间占用少。主要原因有两点,第一:RocksDB的数据页是压缩后append方式存储,而InnoDB的数据默认是先凑齐16K,然后再压缩对齐,对齐会造成额外的空间占用;第二:InnoDB的B+树的页面本身也有空洞。一般情况下,RocksDB的空间占用大概是压缩InnoDB的1/2左右。而且数据的冗余越多,InnoDB的补齐开销就越大,RocksDB的优势就越明显。

最近一个内部项目打算上线TXRocks,这个业务数据冗余较多,而且大部分都是数字,因此压缩比很高。经过内部测试TXRocks的空间占用只有带压缩的InnoDB的1/10。但是,测试也发现TXRocks的sum性能较差,只有InnoDB的60%左右(InnoDB耗时38.29s, TX-Rocks耗时 62.8s)。

经过分析,性能差的原因主要有三点:(1)server层遍逐条遍历记录的代价较大; (2)引擎层对遍历的每条记录的所有列都进行了解析,由于sum操作只针对少数列,因此这里对操作不涉及的列进行解析都是没有必要的;(3)server层单线程处理聚合请求,并发不够。针对这几点原因,我们从聚合操作下推、优化单条记录的处理开销、多线程并发三个方面进行优化。经过优化,sum查询时延最低降到1.74s,只有InnoDB的5%不到。下面就详细介绍问题现象及优化过程。

1. 问题剖析

1.1 perf 火焰图

部分火焰图如下:

通过火焰图发现CPU时间大部份花在三个地方:

(1) myrocks::ha_rocksdb:: convert_record_from_storage_format

(2) rocksdb::DBIter::Next

(3) sub_select到rnd_next_with_direction的函数调用开销。

1.2 top

Top命令查看Mysqld线程占用一个核,CPU消耗100%。

1.3 瓶颈及优化方案总结
第二个现象说明当前是单线程进行CPU消耗型操作;而第一个现象则说明了当前CPU的主要消耗点。因此,我们明确了几个主要瓶颈点及优化方案:
(1) convert_record_from_storage_format:sum操作仅需要解析操作所涉及的列,而当前的流程是解析了所有的列。这里可以通过只解析需要的列来优化。
(2) Rocksdb内部迭代器Next: 操作涉及到Rocksdb底层迭代器的固有机制,暂不优化。
(3) SQL层循环迭代开销大:sum操作下推的方式来优化。
(4) 单线程操作导致并发不够:多线程并发聚合。

2.优化点一:sum操作下推

针对SQL层循环迭代开销大的问题,我们决定采用sum操作从SQL层下推到引擎层的方式解决,目前这种优化只针对整型。
2.1 下推的实现方式
从上面的堆栈看,当前的sum执行方式为在sub_select函数里不停的通过rr_sequential获取引擎层的记录并计算。rr_sequential的调用层次依次为rr_sequential->handler::ha_rnd_next->ha_rocksdb::rnd_next->……。因此我们的做法是在SQL侧对下推条件进行判断,如果判断满足条件则在handler中设置相应标志位;在引擎的rnd_next中如果发觉设置了标志位,则遍历所有的列进行聚合运算。

2.2 SQL层sum操作下推的条件

(1) 带order by,group by, having, where, 涉及多表的操作不下推;
(2) 非sum/count操作不下推;
(3) 如果涉及多个field的不下推。

2.3 引擎层rnd_next的下推操作处理逻辑

if (agg_sum_push_down)
{
  /*1.遍历整个表的数据*/
  for (;;) {
    ......
    scan_it->Next();

    /*2.遍历结束*/
    if (!scan_it->Valid()) {
      rc = HA_ERR_END_OF_FILE;
      break;
    }
    ......
    /*3.将需要的field从storage format中解压出来*/
    rc = convert_needed_filed_from_value(&value, value, field_is_null);
    ......

    /*4.sum结果溢出判断,如果溢出则结束本轮遍历,由SQL层发起下一轮遍历*/
    if (agg_sum_is_overflow(local_sum, value)) {
      rc = HA_ERR_ROCKSDB_STATUS_TRY_AGAIN;
      break;
    }

    /*5.进行sum*/
    local_sum += value;
  }
}
else
{
   获取一行记录返回;
}
  ......

3.优化点二:convert_record_from_storage_format函数优化

convert_record_from_storage_format的主要作用是将record从memcomparable格式转SQL层的记录格式。主要流程如下:

  ......
for (auto it = m_decoders_vect.begin(); it != m_decoders_vect.end(); it++) {
    const Rdb_field_encoder *const field_dec = it->m_field_enc;
    Field *const field = table->field[field_dec->m_field_index];

    if (isNull) {
      if (decode) {
        /* This sets the NULL-bit of this record */
        field->set_null();
        /*
          Besides that, set the field value to default value. CHECKSUM TABLE
          depends on this.
        */
        memcpy(field->ptr, table->s->default_values + field_offset,
               field->pack_length());
      }
    } else { /*解析非空列*/
      if (decode) {
        field->set_notnull();
      }

      if (field_dec->m_field_type == MYSQL_TYPE_BLOB) {
        err = convert_blob_from_storage_format(
            (my_core::Field_blob *) field, &reader, decode);
      } else if (field_dec->m_field_type == MYSQL_TYPE_VARCHAR) {
        err = convert_varchar_from_storage_format(
            (my_core::Field_varstring *) field, &reader, decode);
      } else {
        err = convert_field_from_storage_format(
            field, &reader, decode, field_dec->m_pack_length_in_rec);
      }
    }
  ......
  }
  ......

从上面的流程可以看到,解压记录的过程中对记录的所有field都进行了解析。但是我们的业务场景是对某一列进行sum操作,因为仅仅只涉及其中一列,没有必要对所有的field进行解析。因此,我们专门针对我们的场景做了优化,只解析需要的那一列。

4.优化点三:多线程并发

多线程并发最主要的是要解决数据的并发拆分问题,在讨论具体的拆分策略之前,我们首先要明确几点:

4.1 拆分对象内容的获取

由于MyRocks的多个索引共享一个Column Family参考1,其数据视图对应于Rocksdb的Version ,MyRocks及Rocksdb中并没有一个可以和索引相对应的数据视图,那么需要怎么获取待拆分索引的全部内容?所幸,MyRocks对索引中每条Record进行编码时都带上了indexid做前缀参考2,因此(indexid_0000, (indexid+1)_0000)的双开区间即可以表示某个column family中属于某个索引的全部数据,通过这个范围即可对应的version中过滤出需要的数据。

4.2 拆分的依据

基于什么信息进行拆分?怎么保证拆分的尽量均匀?这里有两种备选拆分策略:
(1) 静态拆分。即假如需要拆分成4个线程,那么用(indexid_0000, indexid_0000_00], (indexid_0000_00, indexid_0000_01],(indexid_0000_01, indexid_0000_10],(indexid_0000_10, (indexid+1)_0000),四个区间即可对整个索引进行拆分。但是这种策略有一个坏处就是各个区间的记录个数不容易均匀,这会降低并发效果。
(2) 基于数据分布直方图的拆分。也就是根据实际的数据分布范围情况进行分布,尽量使每个分区内的记录数目相近,这样多个并发处理的线程会几乎同时完成,并发效果最好。因此,我们选择了这种方式进行拆分。

4.3 数据分布直方图的获取:

Rocksdb中每个Version对象会有一个VersionStorageInfo类型结构体storage_info来保存当前属于Version的所有文件的记录数据、记录范围、以及所处的level等相关信息,这是天然的数据分布直方图,我们只需要选择其中的记录数目最多的层进行范围拆分即可。在代码实现上,这个结构体层次比较深,而且这个对象当前Rocksdb并不对外暴露,不过这些都不是问题。

4.4 拆分的粒度

可以基于文件级别,也可以基于记录级别。由于我们的数据直方图中只有文件级别的统计信息,因此只能基于文件级别进行拆分。

基于以上考虑,我们的并发拆分策略如下:

1.获取storage_info;
2.根据storage_info获取当前LSM数的level数目;
3.判断level数据及层次,决定拆分所依据的level层次。
    3.1如果为1并且是level0层,由于lvel0层的文件之间范围有可能相互重叠,无法拆分,因此这种情况不能进行多线程并发,对应的区间为(indexid_0000, (indexid+1)_0000),拆分算法结束,return;
    3.2如果level数目为1且不是level0层,则将该层作为待拆分的层次;
    3.3如果level数目不为1,则遍历除level0层以外的所有层,找到记录数目最多的层次,作为待拆分的层次;
4.获取当前CPU空闲的个数,根据一定算法确定当前可以进行并发的线程数。
5.遍历待拆分的层文件,获取当前层记录的数目,计算出每个线程处理的记录数目为:待拆分记录数/并发线程数。
6.遍历待拆分层的文件,根据每个线程处理的记录数目,将该层的文件分为并发线程数个区间。

5.优化效果

5.1 优化后的perf图

5.1 优化后的查询时延

6.总结

本文介绍了TXRocks中sum操作的相关优化,主要是关键函数优化、下推到引擎、多线程并发,虽然优化思路很常规,但是效果明显。由于当前业务仅仅涉及整型,因此目前只针对整型优化。按照同样的思路,我们也优化了count操作。类似的需要优化的地方很多,后续我们会不断完善。


腾讯数据库技术团队对内支持微信红包,彩票、数据银行等集团内部业务,对外为腾讯云提供各种数据库产品,如CDB、CTSDB、CKV、CMongo, 腾讯数据库技术团队专注于增强数据库内核功能,提升数据库性能,保证系统稳定性并解决用户在生产过程中遇到的问题,并对生产环境中遇到的问题及知识进行分享。

文章来源于腾讯云开发者社区,点击查看原文