TiDB MVCC 多版本保存机制及其对性能的影响

从接触TiDB以来,就看到过TiDB官方文档上的提示,gc_life_time设置过大,会因为历史版本过多,影响查询效率,但是为什么SQL非要去扫描历史版本呢?下面列举一些知识点一步一步来解析这个问题

1. TiDB key的编码方式

TiDB 会对每个表分配一个全局唯一的table_id,每一个索引都会分配一个表内唯一的 index_id,每一行分配一个 row_id(如果表有整数型的 Primary Key,那么会用 Primary Key 的值当做 row_id,如果没有,那么TiDB会自动生成一个隐式主键_tidb_rowid)

数据编码方式:

t{table_id}_r{row_id}-->[col1,col2,col3,...]

索引编码方式:

unique index

t{table_id}_i{index_id}_{index_column_value}-->[row_id]

非unique index

t{table_id}_i{index_id}_{index_column_value}_{row_id}-->null

举个栗子:

CREATE TABLE `test_table` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '自增主键',
  `column1` varchar(10) DEFAULT NULL,
  `column2` varchar(10) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `idx_column1` (`column1`)
id column1 column2
1 “a” “b”
2 “c” “d”

那么主键编码可以抽象为

t10_r1-->[1,"a","b"]

t10_r2-->[2,"c","d"]

索引idx_column1编码可以抽象为

t10_i1_a_1-->null

t10_i1_b_2-->null

2. MVCC多版本信息是如何保存的?

TiDB使用基于Percolator的事务模型,将一行数据抽象为default、write 和 lock 3 个 CF(column family)存储,其中:

  • default CF存储的真正数据

    ${key}_${start_ts} --> ${value}

  • write CF存储数据的版本信息,commit_ts代表一行记录的真正版本

    ${key}_${commit_ts}-->${start_ts}

  • lock CF存放锁信息,提交中的事务会加lock,包含primary lock的位置

    ${key}-->${start_ts,primary_key,..etc}

一个读取操作的过程如下:

  1. 事务begin时,从PD获取start_ts

  2. 读取key,先判断lock CF有没有锁,如果:

    a. 有锁

    判断primary key状态是否超时

    • 若锁未超时,等待
    • 若锁已超时,根据primary key的状态rollback或commit残留事务

    b. 无锁

    根据当前事务获取的start_ts对比数据的commit_ts(write CF)

    • start_ts大于commit_ts,返回这行数据
    • start_ts小于commit_ts,继续查找当前行的下一个(更早的)版本
  3. 根据步骤2得到的commit_ts,从default CF中获取真正的数据

TiDB将一行记录的多个版本按照从新到老的顺序排列,这样方便我们获得满足查询条件的最新记录,TiKV默认存储引擎是RocksDB,RocksDB一个seek操作要比next操作昂贵很多,如下这个例子

假设key1,key2,key3都有多次更新,生成的mvcc版本从老到新分别为key1_v1,key1_v2,key1_v3,key1_v4…

几个相邻key的存放方式抽象为下图:

Rocksdb没办法精确去定位每一个key,如果扫描每一个key都走seek接口,这样代价太大。所以如图,假设一个范围查询seek到第一个key,key1之后,就开始调用next函数获取后面的key2、key3值,这样需要遍历key1甚至key2的所有历史版本。如此,就能解释为什么过多的历史版本会让查询效率急剧下降了。

我们日常工作经常碰见的几个问题:

一、SQL执行时间不稳定 慢日志中会发现这些SQL语句的total keys比process keys大很多,这就是典型的历史版本过多,导致扫描了大量历史数据。 解决方法

  1. 减小gc_life_time,或者让业务缩小查询范围。
  2. 升级TiDB3.0版本 TiDB3.0之前的版本,全局GC效率不高,容易积压大量历史版本数据。3.0之后改成了分布式GC,能够快速释放大量已删除的历史版本,再加上更完善的region merge功能,会让整个集群的性能提升一个很大的台阶。

二、删除&归档特定日期以前的记录

while True:
    delete table where {$condition} limit n
    if affectrows==0:
        break

这个场景的现象是delete语句会越来越慢。 因为扫描范围{$condition}是固定不变的,delete删除语句在TiDB处理方式是标记删除,删除本身实际上也是插入一条kv记录,只不过value变成了delete,最后通过逻辑GC和compaction来删除真实数据。所以,循环执行delete 语句,每次删除n条记录,下一次delete语句要扫描的key就会+n,执行时间越来越长(大家可以去做个实验,观察慢日志文件,同样的delete语句total keys会不断增加)。

那么,怎样去删除&归档特定日期前的记录比较高效呢?

首先,我们知道TiDB对事务大小是有限制的

  1. 单个事务包含的SQL语句不超过5000条
  2. 操作的单条记录不超过 6MB
  3. 事务操作的总keys不超过 30w
  4. 事务操作的所有记录总大小不超过 100MB

由于TiDB的事务限制和TiDB mvcc的实现原理,想要删除&归档一个特定范围的数据,目前没有太好的方法。整理一些个人心得供大家参考:

第一种方式:

尽量缩小范围删除的粒度,比如提前按分钟将数据分段,打开tidb_batch_delete,提高并发去删除。注意使用开闭区间,分段之间不要出现冲突,TiDB解决事务冲突的代价比较大。

set @@session.tidb_batch_delete=1;
delete from table where create_time > '$start_step' and create_time <= '$end_step';

如果分段内的数据超出事务大小限制,TiDB会自动将delete操作拆分成多个batch。 个人亲测,这种方式删除数据的速度还是比较快的。

第二种方式:

按照日期分表,删除过期的表即可。TiDB删表是秒级的,后续空间回收也比较快,缺点是侵入业务。 两种方式各有利弊,大家可以各取所需。

4赞

吕神开始出文章了。期待

房老师回复神速

1赞

这里获取 start_ts 的说法可能会误导读者哦。 TiDB 在事务 begin 时获取 start_ts, 并不是每次读取的时候获取一次 start_ts 哦

这里最好指定一下 primary key 会更清晰一点呢?

指明了主键后,这里的内容可以更一目了然一些?

感谢shirly姐,已经修改了

mvcc 里面删除是追加写操作,再 compaction。代价比较大。

如果涉及到大量的删除的业务场景,高效的做法是用 table partition 然后直接 drop 掉 partition。

:+1:

研究这个问题很久了,终于有了篇说清楚的文章,感谢楼主:+1: