MySQL是目前使用多的开源数据库,但是MySQL数据库的默认设置性能非常的差,必须进行不断的优化,而优化是一个复杂的任务,本文描述淘宝数据库团队针对MySQL数据库Metadata Lock子系统的优化,hash_scan 算法的实现解析的性能优化,TokuDB·版本优化,以及MariaDB·的性能优化。本文来自淘宝团队内部经验分享。
  背景
  引入MDL锁的目的,初是为了解决的bug#989,在MySQL 5.1及之前的版本,事务执行过程中并不维护涉及到的所有表的Metatdata 锁,极易出现复制中断,例如如下执行序列:
  Session 1: BEGIN;
  Session 1: INSERT INTO t1 VALUES (1);
  Session 2: Drop table t1; ——–SQL写入BINLOG
  Session 1: COMMIT; —–事务写入BINLOG
  在备库重放 binlog时,会先执行DROP TABLE,再INSERT数据,从而导致复制中断。
  在MySQL 5.5版本里,引入了MDL, 在事务过程中涉及到的所有表的MDL锁,直到事务结束才释放。这意味着上述序列的DROP TABLE 操作将被Session 1阻塞住直到其提交。
  不过用过5.5的人都知道,MDL实在是个让人讨厌的东西,相信不少人肯定遇到过在使用mysqldump做逻辑备份时,由于需要执行FLUSH TABLES WITH READ LOCK (以下用FTWRL缩写代替)来获取全局GLOBAL的MDL锁,因此经常可以看到“wait for global read lock”之类的信息。如果备库存在大查询,或者复制线程正在执行比较漫长的DDL,并且FTWRL被block住,那么随后的QUERY都会被block住,导致业务不可用引发故障。
  为了解决这个问题,Facebook为MySQL增加新的接口替换掉FTWRL 只创建一个read view ,并返回与read view一致的binlog位点;另外Percona Server也实现了一种类似的办法来绕过FTWRL,具体点击文档连接以及percona的博客,不展开阐述。
  MDL解决了bug#989,却引入了一个新的热点,所有的MDL锁对象被维护在一个hash对象中;对于热点,正常的想法当然是对其进行分区来分散热点,不过这也是Facebook的大神Mark Callaghan在report了bug#66473后才加入的,当时Mark观察到MDL_map::mutex的锁竞争非常高,进而推动官方改变。因此在MySQL 5.6.8及之后的版本中,引入了新参数metadata_locks_hash_instances来控制对mdl hash的分区数(Rev:4350);
  不过故事还没结束,后面的测试又发现哈希函数有问题,somedb. someprefix1 … .somedb .someprefix8 的hash key值相同,都被hash到同一个桶下面了,相当于hash分区没生效。这属于hash算法的问题,喜欢考古的同学可以阅读下bug#66473后面Dmitry Lenev的分析。
  Mark进一步的测试发现Innodb的hash计算算法比my_hash_sort_bin要更高效, Oracle的开发人员重开了个bug#68487来跟踪该问题,并在MySQL5.6.15对hash key计算函数进行优化,包括fix 上面说的hash计算问题(Rev:5459),使用MurmurHash3算法来计算mdl key的hash值。
  MySQL 5.7 对MDL锁的优化
  在MySQL 5.7里对MDL子系统做了更为彻底的优化。主要从以下几点出发:
  第一,尽管对MDL HASH进行了分区,但由于是以表名+库名的方式作为key值进行分区,如果查询或者DML都集中在同一张表上,会hash到相同的分区,引起明显的MDL HASH上的锁竞争。
  针对这一点,引入了LOCK-FREE的HASH来存储MDL_lock,LF_HASH无锁算法基于论文”Split-Ordered Lists: Lock-Free Extensible Hash Tables”,实现还比较复杂。 注:实际上LF_HASH很早被应用于Performance Schema,算是比较成熟的代码模块。由于引入了LF_HASH,MDL HASH分区特性自然直接被废除了 。对应WL#7305, PATCH(Rev:7249)
  第二,从广泛使用的实际场景来看,DML/SELECT相比DDL等高级别MDL锁类型,是更为普遍的,因此可以针对性的降低DML和SELECT操作的MDL开销。
  为了实现对DML/SELECT的快速加锁,使用了类似LOCK-WORD的加锁方式,称之为FAST-PATH,如果FAST-PATH加锁失败,则走SLOW-PATH来进行加锁。
  每个MDL锁对象(MDL_lock)都维持了一个long long类型的状态值来标示当前的加锁状态,变量名为MDL_lock::m_fast_path_state 举个简单的例子:(初始在sbtest1表上对应MDL_lock::m_fast_path_state值为0)
  Session 1: BEGIN;
  Session 1: SELECT * FROM sbtest1 WHERE id =1; //m_fast_path_state = 1048576, MDL ticket 不加MDL_lock::m_granted队列
  Session 2: BEGIN;
  Session 2: SELECT * FROM sbtest1 WHERE id =2; //m_fast_path_state=1048576+1048576=2097152,同上,走FAST PATH
  Session 3: ALTER TABLE sbtest1 ENGINE = INNODB; //DDL请求加的MDL_SHARED_UPGRADABLE类型锁被视为unobtrusive lock,可以认为这个是比上述SQL的MDL锁级别更高的锁,并且不相容,因此被强制走slow path。而slow path是需要加MDL_lock::m_rwlock的写锁。m_fast_path_state = m_fast_path_state | MDL_lock::HAS_SLOW_PATH | MDL_lock::HAS_OBTRUSIVE
  注:DDL还会获得库级别的意向排他MDL锁或者表级别的共享可升级锁,但为了表述方便,这里直接忽略了,只考虑涉及的同一个MDL_lock锁对象。
  Session 4: SELECT * FROM sbtest1 WHERE id =3; // 检查m_fast_path_state &HAS_OBTRUSIVE,如果DDL还没跑完,会走slow path。
  从上面的描述可以看出,MDL子系统显式的对锁类型进行了区分(OBTRUSIVE or UNOBTRUSIVE),存储在数组矩阵m_unobtrusive_lock_increment。 因此对于相容类型的MDL锁类型,例如DML/SELECT,加锁操作几乎没有任何读写锁或MUTEX开销。对应WL#7304, WL#7306 , PATCH(Rev:7067,Rev:7129)(Rev:7586)
  第三,由于引入了MDL锁,实际上早期版本用于控制Server和引擎层表级并发的THR_LOCK 对于Innodb而言已经有些冗余了,因此Innodb表完全可以忽略这部分的开销。
  不过在已有的逻辑中,Innodb依然依赖THR_LOCK来实现LOCK TABLE tbname READ,因此增加了新的MDL锁类型来代替这种实现。实际上代码的大部分修改都是为了处理新的MDL类型,Innodb的改动只有几行代码。对应WL#6671,PATCH(Rev:8232)
  第四,Server层的用户锁(通过GET_LOCK函数获取)使用MDL来重新实现。
  用户可以通过GET_LOCK()来同时获取多个用户锁,同时由于使用MDL来实现,可以借助MDL子系统实现死锁的检测。注意由于该变化,导致用户锁的命名必须小于64字节,这是受MDL子系统的限制导致。对应WL#1159, PATCH(Rev:8356)
  MySQL·性能优化·hash_scan 算法的实现解析
  问题描述
  首先,我们执行下面的TestCase:
  --source include/master-slave.inc
  --source include/have_binlog_format_row.inc
  connection slave;
  set global slave_rows_search_algorithms='TABLE_SCAN';
  connection master;
  create table t1(id int, name varchar(20);
  insert into t1 values(1,'a');
  insert into t2 values(2, 'b');
  ......
  insert into t3 values(1000, 'xxx');
  delete from t1;
  ---source include/rpl_end.inc
  随着 t1 数据量的增大,rpl_hash_scan.test 的执行时间会随着 t1 数据量的增大而快速的增长,因为在执行 ‘delete from t1;’ 对于t1的每一行删除操作,备库都要扫描t1,即全表扫描,如果 select count(*) from t1 = N, 则需要扫描N次 t1 表, 则读取记录数为: O(N + (N-1) + (N-2) + …. + 1) = O(N^2),在 replication 没有引入 hash_scan,binlog_format=row时,对于无索引表,是通过 table_scan 实现的,如果一个update_rows_log_event/delete_rows_log_event 包含多行修改时,每个修改都要进行全表扫描来实现,其 stack 如下:
  #0 Rows_log_event::do_table_scan_and_update
  #1 0x0000000000a3d7f7 in Rows_log_event::do_apply_event
  #2 0x0000000000a28e3a in Log_event::apply_event
  #3 0x0000000000a8365f in apply_event_and_update_pos
  #4 0x0000000000a84764 in exec_relay_log_event
  #5 0x0000000000a89e97 in handle_slave_sql (arg=0x1b3e030)
  #6 0x0000000000e341c3 in pfs_spawn_thread (arg=0x2b7f48004b20)
  #7 0x0000003a00a07851 in start_thread () from /lib64/libpthread.so.0
  #8 0x0000003a006e767d in clone () from /lib64/libc.so.6
  这种情况下,往往会造成备库延迟,这也是无索引表所带来的复制延迟问题。
  如何解决问题:
  RDS 为了解这个问题,会在每个表创建的时候检查一下表是否包含主建或者建,如果没有包含,则创建一个隐式主建,此主建对用户透明,用户无感,相应的show create, select * 等操作会屏蔽隐式主建,从而可以减少无索引表带来的影响;
  官方为了解决这个问题,在5.6.6 及以后版本引入参数 slave_rows_search_algorithms ,用于指示备库在 apply_binlog_event时使用的算法,有三种算法TABLE_SCAN,INDEX_SCAN,HASH_SCAN,其中table_scan与index_scan是已经存在的,本文主要研究HASH_SCAN的实现方式,关于参数slave_rows_search_algorithms的设置。
  hash_scan 的实现方法:
  简单的讲,在 apply rows_log_event时,会将 log_event 中对行的更新缓存在两个结构中,分别是:m_hash, m_distinct_key_list。 m_hash:主要用来缓存更新的行记录的起始位置,是一个hash表; m_distinct_key_list:如果有索引,则将索引的值push 到m_distinct_key_list,如果表没有索引,则不使用这个List结构; 其中预扫描整个调用过程如下: Log_event::apply_event
  Rows_log_event::do_apply_event
  Rows_log_event::do_hash_scan_and_update
  Rows_log_event::do_hash_row  (add entry info of changed records)
  if (m_key_index < MAX_KEY) (index used instead of table scan)
  Rows_log_event::add_key_to_distinct_keyset ()
  当一个event 中包含多个行的更改时,会首先扫描所有的更改,将结果缓存到m_hash中,如果该表有索引,则将索引的值缓存至m_distinct_key_list List 中,如果没有,则不使用这个缓存结构,而直接进行全表扫描;
  执行 stack 如下:
  #0 handler::ha_delete_row
  #1 0x0000000000a4192b in Delete_rows_log_event::do_exec_row
  #2 0x0000000000a3a9c8 in Rows_log_event::do_apply_row
  #3 0x0000000000a3c1f4 in Rows_log_event::do_scan_and_update
  #4 0x0000000000a3c5ef in Rows_log_event::do_hash_scan_and_update
  #5 0x0000000000a3d7f7 in Rows_log_event::do_apply_event
  #6 0x0000000000a28e3a in Log_event::apply_event
  #7 0x0000000000a8365f in apply_event_and_update_pos
  #8 0x0000000000a84764 in exec_relay_log_event
  #9 0x0000000000a89e97 in handle_slave_sql
  #10 0x0000000000e341c3 in pfs_spawn_thread
  #11 0x0000003a00a07851 in start_thread ()
  #12 0x0000003a006e767d in clone ()
  执行过程说明:
  Rows_log_event::do_scan_and_update
  open_record_scan()
  do
  next_record_scan()
  if (m_key_index > MAX_KEY)
  ha_rnd_next();
  else
  ha_index_read_map(m_key from m_distinct_key_list)      
  entry= m_hash->get()
  m_hash->del(entry);
  do_apply_row()
  while (m_hash->size > 0);
  从执行过程上可以看出,当使用hash_scan时,只会全表扫描一次,虽然会多次遍历m_hash这个hash表,但是这个扫描是O(1),所以,代价很小,因此可以降低扫描次数,提高执行效率。
  hash_scan 的一个 bug
  bug详情:http://bugs.mysql.com/bug.php?id=72788
  bug原因:m_distinct_key_list 中的index key 不是的,所以存在着对已经删除了的记录重复删除的问题。
  bug修复:http://bazaar.launchpad.net/~mysql/mysql-server/5.7/revision/8494
  问题扩展:
  在没有索引的情况下,是不是把 hash_scan 打开能提高效率,降低延迟呢?不一定,如果每次更新操作只一条记录,此时仍然需要全表扫描,并且由于entry 的开销,应该会有后退的情况;
  一个event中能包含多少条记录的更新呢?这个和表结构以及记录的数据大小有关,一个event 的大小不会超过9000 bytes, 没有参数可以控制这个size;
  hash_scan 有没有限制呢?hash_scan 只会对更新、删除操作有效,对于binlog_format=statement 产生的 Query_log_event 或者binlog_format=row 时产生的 Write_rows_log_event 不起作用;
  TokuDB·版本优化·7.5.0
  TokuDB 7.5.0大版本已发布,是一个里程碑的版本,这里谈几点优化,以飨存储引擎爱好者们。
  a) shutdown加速
  有用户反馈TokuDB在shutdown的时候,半个小时还没完事,非常不可接受。在shutdown的时候,TokuDB在干什么呢?在做checkpoint,把内存中的节点数据序列化并压缩到磁盘。
  那为什么如此耗时呢?如果tokudb_cache_size开的比较大,内存中的节点会非常多,在shutdown的时候,大家都排队等着被压缩到磁盘(串行的)。
  在7.5.0版本,TokuDB官方针对此问题进行了优化,使多个节点并行压缩来缩短时间。
  BTW: TokuDB在早期设计的时候已保留并行接口,只是一直未开启。
  b) 内节点读取加速
  在内存中,TokuDB内节点(internal node)的每个message buffer都有2个重要数据结构:
  1) FIFO结构,保存{key, value}
  2) OMT结构,保存{key, FIFO-offset}
  由于FIFO不具备快速查找特性,利用OMT来做快速查找(根据key查到value)。这样,当内节点发生cache miss的时候,索引层需要做:
  1) 从磁盘读取节点内容到内存
  2) 构造FIFO结构
  3) 根据FIFO构造OMT结构(做排序)
  由于TokuDB内部有不少性能探(ji)针(shu),他们发现步骤3)是个不小的性能消耗点,因为每次都要把message buffer做下排序构造出OMT,于是在7.5.0版本,把OMT的FIFO-offset(已排序)也持久化到磁盘,这样排序的损耗没了。
  c) 顺序写加速
  当写发生的时候,会根据当前的key在pivots里查找(二分)当前写要落入哪个mesage buffer,如果写是顺序(或局部顺序,数据走向为右边路径)的,可以避免由"查找"带来的额外开销。