压缩索引

MyISAM使用前缀压缩来减少索引的大小,从而让更多的索引可以放入内存中,这在某些情况下能极大地提高性能。默认只压缩字符串,但通过参数设置也可以对整数做压缩。

MyISAM压缩每个索引块的方法是,先完全保存索引块中的第一个值,然后将其他值和第一个值进行比较得到相同前缀的字节数和剩余的不同后缀部分,把这部分存储起来即可。例如,索引块中的第一个值是“perform”,第二个值是“performance”,那么第二个值的前缀压缩后存储的是类似“7,ance”这样的形式。MyISAM对行指针也采用类似的前缀压缩方式。 

压缩块使用更少的空间,代价是某些操作可能更慢。因为每个值的压缩前缀都依赖前面的值,所以MyISAM查找时无法在索引块使用二分查找而只能从头开始扫描。正序的扫描速度还不错,但是如果是倒序扫描——例如ORDER BY DESC——就不是很好了。所有在块中查找某一行的操作平均都需要扫描半个索引块。 

测试表明,对于CPU密集型应用,因为扫描需要随机查找,压缩索引使得MyISAM在索引查找上要慢好几倍。压缩索引的倒序扫描就更慢了。压缩索引需要在CPU内存资源与磁盘之间做权衡。压缩索引可能只需要十分之一大小的磁盘空间,如果是I/O密集型应用,对某些查询带来的好处会比成本多很多。 

可以在CREATE TABLE语句中指定PACK_KEYS参数来控制索引压缩的方式。

冗余和重复索引

MySQL允许在相同列上创建多个索引,无论是有意还是无意的。MySQL需要单独维护重复的索引,并且优化器在优化查询的是时候也需要逐个地进行考虑,这会影响性能。重复索引是指的在相同的列上按照相同的顺序创建的相同类型的索引,应该避免这样创建重复索引,发现以后也应该立即删除。

有时会在不经意间创建了重复索引,例如下面的代码:

CREATE TABLE test(
  ID INT NOT NULL PRIMARY KEY,
  A INT NOT NULL,
  B INT NOT NULL,
  UNIQUE(ID),
  INDEX(ID),
) ENGINE=InnoDB;

  

一个经验不足的用户可能是想创建一个主键,先加上唯一限制,然后再加上索引以供查询使用。事实上,MySQL的唯一限制和主键限制都是通过索引实现的,因此,上面的写法实际上在相同的列上创建了三个重复的索引。通常并没有理由这样做,除非是在同一列上创建不同类型的索引来满足不同的查询需求。

冗余索引和重复索引有一些不同。如果创建了索引(A,B),再创建索引(A)就是冗余索引,因为这只是前一个索引的前缀索引。因此索引(A,B)也可以当做索引(A)来使用(这种冗余只是对B-Tree索引来说的)。但是如果再创建索引(B,A),则不是冗余索引,索引(B)也不是,因为B不是索引(A,B)的最左前缀。另外,其他不同类型的索引(例如哈希索引或者全文索引)也不会是B-Tree索引的冗余索引,而无论覆盖的索引列是什么。 

冗余索引通常发生在为表添加新索引的时候。例如,有人可能会增加一个新的索引(A,B)而不是扩展已有的索引(A)。还有一种情况是将一个索引扩展为(A,ID),其中ID是主键,对于InnoDB来说主键列已经包含在二级索引中了,所以这也是冗余的。 

大多数情况下都不需要冗余索引,应该尽量扩展已有的索引而不是创建新索引。但也有时候出于性能方面的考虑需要冗余索引,因为扩展已有的索引会导致其变得太大,从而影响其他使用该索引的查询的性能。 

例如,如果在整数列上有一个索引,现在需要额外增加一个很长的VARCHAR列来扩展该索引,那性能可能会急剧下降。特别是有查询把这个索引当做覆盖索引,或者这是MyISAM表并且有很多范围查询(由于MyISAM的前缀压缩)的时候。 

有一个userinfo表,这个表有1000 000行,对每个state_id值大概有20 000条记录。在state_id列有一个索引对下面的查询有用,假设查询名为Q1:

SELECT count(*) FROM userinfo WHERE state_Id=5;

  

一个简单的测试表明该查询的执行速度大概是每秒115次(QPS)。还有一个相关查询需要检索几个列的值,而不是只统计行数,假设名为Q2:

SELECT state_id,city,address FROM userinfo WHERE state_id=5;

  

对于这个查询,测试结果QPS小于10。提升该查询性能的最简单办法就是扩展索引为(state_id,city,address),让索引能覆盖查询:

ALTER TABLE userinfo DROP KEY state_id, ADD KEY state_id_2(state_id,city,address);

  

索引扩展后,Q2运行得更快了,但是Q1却变慢了。如果我们想让两个查询都变得更快,就需要两个索引,尽管这样一来原来的单列索引是冗余的了。表1-3显示这两个查询在不同索引策略下的详细结果,分别使用MyISAM和InnoDB存储引擎。注意到只有state_id_2索引时,InnoDB引擎上的查询Q1的性能下降并不明显,这是因为InnoDB没有使用索引压缩。

表1-3   使用不同索引策略的SELECT查询的QPS测试结果

 

只有state_id

只state_id_2

同时有state_id和state_d_2

MyISAM, Q1

114.96

25.40

112.19

MylSAM, Q2

9.97

16.34

16.37

InnoDB, Q1

108.55

100.33

107.97

InnoDB, Q2

12.12

28.04

28.06

有两个索引的缺点是索引成本更高。表1-4显示了想表中插入100万行数据所需要的时间。 

表1-4   在使用不同索引策略时插入100万行数据的速度

 

只有state_id

同时有state_id和state_ id_2

InnoDB,对两个索引都有足够的内容

80秒

136秒

MylSAM,只有一个索引有足够的内容

72秒

470秒

可以看到,表中的索引越多插入速度越慢。一般来说,增加新索引将会导致INSERT、UPDATE、DELETE等操作的速度变慢,特别是当新增索引后导致达到了内存瓶颈的时候。

解决冗余索引和重复索引的方法很简单,删除这些索引就可以,但首先要做的是找出这样的索引。可以通过写一些复杂的访问INFORMATION_SCHEMA表的查询来找,不过还有两个更简单的方法。可使用Shlomi Noach的common_schema中的一些视图来定位,common_schema是一系列可以安装到服务器上的常用的存储和视图。这笔自己编写查询要快而且简单。另外也可以使用Percona Toolkit中的pt-duplicate-key-checker,该工具通过分析表结构来找出冗余和重复的索引。对于大型服务器来说,使用外部的工具可能更合适些;如果服务器上有大量的数据或者大量的表,查询INFORMATION_SCHEMA表可能会导致性能问题。

在决定哪些索引可以被删除的时候要非常小心。回忆一下,在前面的InnoDB的示例表中,因为二级索引的叶子节点包含了主键值,所以在列(A)上的索引就相当于在(A,ID)上的索引。如果有像WHERE A = 5 ORDER BY ID这样的查询,这个索引会很有作用。但如果将索引扩展为(A,B),则实际上就变成了(A,B,ID),那么上面查询的ORDER BY子句就无法使用该索引做排序,而只能用文件排序了。所以,建议使用Percona工具箱中的pt-upgrade工具来仔细检查计划中的索引变更。

未使用的索引

除了冗余索引和重复索引,可能还会有一些服务器永远不使用的索引,这样的索引完全是累赘,建议考虑删除,有两个工具可以帮助定位未使用的索引:

  • 在Percona Server或者Mariadb中先打开userstates服务器变量(默认是关闭的),然后让服务器运行一段时间,再通过查询INFORMATION_SCHEMA.INDEX_STATISTICS就能查到每个索引的使用频率。
  • 使用Percona Toolkit中的pt-index-usage工具,该工具可以读取查询日志,并对日志中的每个查询进行EXPLAIN操作,然后打印出关于索引和查询的报告。这个工具不仅可以找出哪些索引是未使用的,还可以了解查询的执行计划——例如在某些情况下有些类似的查询的执行方式不一样,这可以帮助定位到那些偶尔服务器质量差的查询,该工具也可以将结果写入到MySQL的表中,方便查询结果。

索引和锁

索引可以让查询锁定更少的行,如果查询从不访问那些不需要的行,那么就会锁定更少的行,从两个方面来看这对性能都有好处。首先,虽然InnoDB的行锁效率很高,内存使用也很少,但是锁定行的时候仍然会带来额外开销;其次,锁定超过需要的行会增加锁争用并减少并发性。 

InnoDB只有在访问行的时候才会对其加锁,而索引能够减少InnoDB访问的行数,从而减少锁的数量。但这只有当InnoDB在存储引擎层能够过滤掉所有不需要的行时才有效。如果索引无法过滤掉无效的行,那么在InnoDB检索到数据并返回给服务器层以后,MySQL服务器才能应用 WHERE子句。这时已经无法避免锁定行了:InnoDB已经锁住这些行,到适当的时候才释放。在MySQL5.1及更新的版本中,InnoDB可以在服务器端过滤掉行后就释放锁。

通过下面的例子再次使用数据库Sakila很好的解释这些情况:

mysql> SET AUTOCOMMIT = 0;
Query OK, 0 rows affected (0.00 sec)

mysql> SELECT actor_id FROM actor WHERE actor_id < 5 AND actor_id <> 1 FOR UPDATE;
+----------+
| actor_id |
+----------+
|        2 |
|        3 |
|        4 |
+----------+
3 rows in set (0.01 sec)

  

这条查询只返回2~4行数据,实际上获取1~4行排他锁。InnoDB锁住第1行,因为MySQL为该查询选择的执行计划是索引范围扫描:

mysql> EXPLAIN SELECT actor_id FROM actor WHERE actor_id < 5 AND actor_id <> 1 FOR UPDATE\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: actor
   partitions: NULL
         type: range
possible_keys: PRIMARY
          key: PRIMARY
      key_len: 2
          ref: NULL
         rows: 4
     filtered: 100.00
        Extra: Using where; Using index
1 row in set, 1 warning (0.00 sec)

  

换句话说,底层存储引擎的操作是“从索引的开头获取满足条件 actor_id < 5 的记录”,服务器并没有告诉InnoDB可以过滤第1行的WHERE 条件。注意到EXPLAIN的Extra出现“Using Where”表示MySQL服务器将存储引擎返回行以后再应用WHERE 过滤条件。

第二个查询就能证明第一行确实已经被锁定(重新开启一个MySQL的控制台),尽管第一个查询的结果并没有这个第一行。保持第一个连接的打开,然后开启第二个连接并执行如下查询:

mysql> SET AUTOCOMMIT = 0;
Query OK, 0 rows affected (0.00 sec)

mysql> BEGIN;
Query OK, 0 rows affected (0.00 sec)

mysql> SELECT actor_id FROM actor WHERE actor_id = 1 FOR UPDATE;

  

这个查询将会挂起,直到第一个事物释放第一行的锁。这个行为对于基于语句的复制的正常运行来说是必要的。就像这个例子显示的,即使使用了索引,InnoDB可能也会锁住一些不需要的数据。如果不能使用索引查找和锁定行的话问题可能会很糟糕,MySQL会做全表扫描并锁定所有的行,而不管是不是需要。关于InnoDB,索引和锁有一些很少有人知道的细节:InnoDB在二级索引上使用共享锁(读锁),但访问主键索引需要排他锁(写),这消除了使用覆盖索引的可能性,并且使得SELECT FOR UPDATE 比LOCK IN SHARE MODE  或非锁定查询要慢得多。