09 | 普通索引和唯一索引,应该怎么选择

60 阅读8分钟

查询过程

假设一个ID为主键的表,表中还有一列k,k上有索引。

mysql> create table T(
      id int primary key,
      k int not null,
      name varchar(16),
      index (k)
    )engine=InnoDB;

查询的语句为:

select id from T where k=5;

表中 R1~R5 的 (ID,k) 值分别为 (100,1)、(200,2)、(300,3)、(500,5) 和 (600,6),两棵树的示例示意图如下

image.png 这个查询语句在索引树上查找过程:
显示通过B+树从树根开始,按层搜索到叶子节点。

  • 对于普通索引而言,查找到满足条件的第一个记录(5,500)后,需要查询下一个记录,直到找到第一个不满足k=5条件的记录。
  • 对于唯一索引而言,由于索引定义了唯一性,找到第一个满足条件的记录,就会停止检索。

这两者的不同带来的性能差距极其小,因为InnoDB是按数据页为单位来读写的。要读取一条记录时,不是将这个记录本身从磁盘读取出来,而是以页为单位,将其整体读入内存。在InnoDB中,每个数据页的大小默认是16K。

由于引擎按页读取,所以找到k=5的记录的时候,它所在的数据也都在内存了,对于普通所以而言,要多做的“查找和判断下一条记录”的操作,变成了只需要一次指针寻找和一次计算。

当然,如果恰好k=5这个记录刚好是这个数据页的最后一条记录,要取下一个记录,必须读取下一个数据页。

对于整型数据,一个数据页可以存放近千个key,出现上述这种情况的概率很低,而计算平均性能差异时,这个操作对于目前的CPU而言可以忽略不计。

更新过程

change buffer

当需要更新一个数据页时,如果数据页在内存就直接更新,如果这个数据页还没有在内存中,在不影响数据一致性的前提下,InooDB会将这些更新操作缓存在change buffer中,这样就不需要从磁盘中读入这个数据页了。在下次查询的时候如果要访问这个数据页,将这个数据页读入内存,然后执行change buffer中与这个页有关的操作。

虽然名字叫作 change buffer,实际上它是可以持久化的数据。也就是说,change buffer 在内存中有拷贝,也会被写入到磁盘上。

将 change buffer 中的操作应用到原数据页,得到最新结果的过程称为 merge。 除了访问这个数据页会触发 merge 外,系统有后台线程会定期 merge。在数据库正常关闭(shutdown)的过程中,也会执行 merge 操作。

显然,将更新操作先记录在 change buffer,减少读磁盘,语句的执行速度会得到明显的提升。而且,数据读入内存是需要占用 buffer pool 的,所以这种方式还能够避免占用内存,提高内存利用率。

什么条件下可以使用change buffer?

唯一索引,所有的更新操作都需要判断当前操作是否违反唯一性约束,如果k是唯一索引,要插入(4,400)这个记录,就需要先判断表中是否存在k=4的记录,而这必须要将数据页读入内存才能判断,此时已经读入到内存了,直接更新内存会更快,没必要使用change buffer了。

因此:唯一索引的更新不能使用change buffer,实际上只有普通索引可以使用。

change buffer 用的是 buffer pool 里的内存,因此不能无限增大。change buffer 的大小,可以通过参数 innodb_change_buffer_max_size 来动态设置。这个参数设置为 50 的时候,表示 change buffer 的大小最多只能占用 buffer pool 的 50%。

在表中插入一条新纪录(4,400)的话,InnoDB的处理流程是怎样?

情况一:这条记录要更新的目标在内存中。

image.png InnoDB处理流程如下:

  • 对于唯一索引而言,找到3和5之间的位置,判断到没有冲突,插入这个值,语句执行结束。
  • 对于普通索引而言,找到3和5之间的位置,插入这个值,语句执行结束。

结论:这种情况普通索引和唯一索引对更新语句的性能影响的差别,只是一个判断,只会耗费微小的CPU时间。

情况二:这个记录要更新的目标页不在内存中

InnoDB处理流程如下:

  • 对于唯一索引而言,需要将数据页读入内存,判断到没有冲突,插入这个值,执行结束。
  • 对于普通索引而言,将更新记录存在change buffer,语句执行结束了。

将数据从磁盘读入内存涉及随机IO的访问,是数据库里面成本最高的操作之一,change buffer因为减少了随机磁盘的访问,所以对于更新性能的提升是会很明显的。

所以上述情况二普通索引的性能会更高。

change buffer使用场景

在普通索引下,change buffer对更新过程有加速作用,但是change buffer不适用于唯一索引。

change buffer的目的是将记录的变更动作先缓存下来,merge的时候才是真正进行数据更新的时刻,所以在一个数据页做merge之前,change buffer记录的变更越多,说明收益越大。

结论:对于写多读少,且写完后被访问到的概率较低的场景,使用change buffer的效果最好。常见的业务类似账单类,日志类的系统。

如果一个业务在更新后,需要立马做查询,那么即使满足了条件,将更新先记录在 change buffer,但之后由于马上要访问这个数据页,会立即触发 merge 过程。这样随机访问 IO 的次数不会减少,反而增加了 change buffer 的维护代价。所以,对于这种业务模式来说,change buffer 反而起到了副作用。

索引选择和实践

唯一索引和普通索引,两类索引在查询能力上是没差别的,主要考虑的是对更新性能的影响。所以,建议尽量选择普通索引。

change buffer 和 redo log

现在,我们要在表上执行这个插入语句:

mysql> insert into t(id,k) values(id1,k1),(id2,k2);

这里,我们假设当前 k 索引树的状态,查找到位置后,k1 所在的数据页在内存 (InnoDB buffer pool) 中,k2 所在的数据页不在内存中。如下图所示是带 change buffer 的更新状态图。 image.png 这条更新语句,涉及4部分:

  • 内存
  • redo log(id_log_fileX)
  • 数据表空间(t.ibd)
  • 系统表空间(ibdata1)

这条更新语句做如下操作(如托中的数字顺序)

  • 1.Page1在内存中,直接更新内存。
  • 2.Page2没有在内存中,就在内存的change buffer区域,记录“我要往Page2插入一行”这个信息。
  • 3.将上述两个动作记入 redo log中(图中的3和4)

做完上面这些,事务就可以完成了,所以更新语句的成本很低,就是写了两处内存,然后写了一处磁盘(两次操作合在一起写了一次磁盘),并且是顺序写的。

后续执行查询语句:

select * from t where k in (k1, k2)

如果读语句发生在更新语句后不久,内存中的数据都还在,那么此时的这两个读操作就与系统表空间(ibdata1)和 redo log(ib_log_fileX)无关了。所以,在图中就没画出这两部分。

image.png

  1. 读 Page 1 的时候,直接从内存返回。有几位同学在前面文章的评论中问到,WAL 之后如果读数据,是不是一定要读盘,是不是一定要从 redo log 里面把数据更新以后才可以返回?其实是不用的。你可以看一下图 3 的这个状态,虽然磁盘上还是之前的数据,但是这里直接从内存返回结果,结果是正确的。
  2. 要读 Page 2 的时候,需要把 Page 2 从磁盘读入内存中,然后应用 change buffer 里面的操作日志,生成一个正确的版本并返回结果。

可以看到,直到需要读 Page 2 的时候,这个数据页才会被读入内存。

所以,如果要简单地对比这两个机制在提升更新性能上的收益的话,redo log 主要节省的是随机写磁盘的 IO 消耗(转成顺序写),而 change buffer 主要节省的则是随机读磁盘的 IO 消耗。