Use the Index, Luke! 笔记6:增删改的索引

前面我们讨论的都是查询语言,但是 SQL 不仅仅是查询,还需要修改数据。索引是完全冗余的数据,是用空间换时间的一种形式。对于修改数据来说,这就意味着不仅要修改表中的数据,还要修改索引中的数据,索引对修改数据来说会带来负面的性能影响。

Insert

Insert 语句几乎是唯一无法从索引获益的语句,因为 insert 没有 where 条件。

索引越多,insert 执行的就越慢。

在表中添加一条记录的过程如下:首先,在数据库中找一个地方放这条记录。在一般的 heap table 中,没有对 row 的顺序的要求,所以随便找一个空闲的 table block 放就可以了。基本上都是顺序写,速度很快。

但是如果有索引的话,必须要保证在索引中能找到这条数据,所以也要在索引中添加这条数据。

索引中的数据都是有顺序的,并且还要保证索引的 BTree 添加了这条记录之后还是平衡的。这个操作就慢很多。虽然数据库的索引本身可以帮助这个过程快速的找到这个记录所在的 block 位置,但是依然要查几(1-4)个block。

找到这个 block 之后,数据库需要确认这个 block 现在是否有足够的空间放这个记录,如果没有的话,就要将叶子节点分裂,将当前 block 的记录平均分在新老的叶子节点中,保持树的平衡。最糟糕的情况下,如果父节点这个时候也同时满了,就需要再来一次分裂。

下图是没有索引到有5个索引的情况下,插入的性能:

可以看到,在第一个索引出现的时候,性能相比没有索引下降了上千倍。随着索引的增加,性能在不断的下降。所以尽可能的复用索引也很重要。

那么什么索引都不建的情况下不是插入很快吗?但是没有是索引的话,这些数据几乎没办法使用,也就没什么价值了。即使 write-only log 表也有一个主键索引。

但是有一种情况,就是从其他的 SQL 系统 load 大量的数据的时候,暂时 drop 掉索引是一个加速数据加载的好方法。数据仓库里面这样操作比较常见。

Delete

Delete 可以受益于索引了,因为 delete 可以有 where 语句。所以我们在前文中讨论过的使用索引的场景,同样适用于 delete。(基本上就是索引可以用于 select 的地方都可以用于 delete)

找到了要删除的数据之后,就可以从 table 中删除这条数据,同 insert 一样,在 table 中删除之后,还要在 BTree 索引中执行删除。这一步操作和 insert 是一样的,在索引中删除之后可能要设计叶子节点的合并,是比较耗时的。所以随着索引越多,性能下降的跟上面 insert 那个图差不多。

Update

Update 操作同样需要更新索引,所以跟上 insert delete 差不多。

有一点不同的是,update 可能更多多列,可能更新一列,更新的列数更多,涉及需要更新的索引就越多。

显然,如果一列数据没有变,那我们就最好不要更新它。

这句话看起来是废话,但是很多 ORM 的 save() 操作,每次都会更新所有的属性的。比如 Hibernate,只有显示地关闭 dynamicUpdate 才不会每次都去更新。这一点格外重要。

一个保险的办法是在开发的时候打开 ORM 的真正执行的 SQL 日志,进行审计。

 

Use the Index, Luke! 笔记5:查询部分结果

上一篇笔记中降到了排序相关的索引技巧。排序经常和“取部分结果”这种用法结合。比如分页,我们只需要取 1 页数据来展示,如果需要读完所有的 row 然后取出来前 20 条作为 1 页展示的话,未免也太浪费资源了。在排好序的情况下,显然我们可以只读 20 rows 就直接返回,而不必读完整张表的数据。索引可以帮我们做到这一点。

Top N查询

我们需要某种 SQL 语法来告诉数据库只取前 N 条数据。SQL 对此并没有一个明确的标准。一方面是因为这并不是一个核心的 Feature,另一方面因为每个数据库对此都有了自己的实现。比如 MySQL 是 LIMIT N, pg 是 FETCH FIRST 10 ROWS ONLY, 也可以用 LIMIT.

Oracle 12c 之前,并没有 fetch first 的语法,但是我们可以用 rownum 来 work around.

我们知道,即使在有索引的情况下,优化器仍可能选择扫全表的方案。因为在要取出的 ROW 很多的时候,索引回表查数据比直接 SCAN 全表要慢。但是如果我们告诉优化器,只需要前10条,那么优化器就知道,即使是需要从索引回表,但只需要10条数据,回表也是要比不走索引扫表快很多的。

Oracle 对上面的查询计划如下。COUNT STOPKEY 会在达到需要的数量之后停止查询,立即返回结果。

这是在有合适的 order by 索引的情况下。假如没有 SALE_DATE 索引的话,就意义着这些记录是没有事先排好序的。即使我们取 Top 1 条记录,数据库也必须将此表全部排序,只有读完全表并排完序,才能将第一条结果返回。

Pipelined top N query 带来的不仅仅是性能的提升,还有水平扩展性。如果没有索引的话,我们每次取 Top N 都要读完全表排序,查 Top N 的速度是和表的大小成正比的,表越大,查询的速度越慢。而如果有索引的话,随着表不断增大,查询速度一直是 O(1) 的,会有很好的扩展性。

但实际上,即使有索引,查询 Top N 的效率是和要取的 N 正比的。查 Top 100 条数据比 Top 10 条要慢很多。

paging 的时候,这个问题尤为显著。比如我们要取第 5 页的数据,也要将前 5 页都读出来,然后丢弃前 4 页的数据,返回第 5 页的数据。

有一种分页的方法可以避免这种依次读 Top N 带来的性能问题;

分页技术

分页技术一般有两种。最常用的一种是需要从头读到第 N 页的数据,然后丢掉前几页的数据。

这种方法非常容易实现,甚至很多数据库都有一个专门的  keyword 来处理这种情况:offset. 在 Postgres 中,可以用下列的 SQL 取第二页的数据(每页有10条记录,跳过前10条记录)。

这种分页只需要 offset 就可以取回任意一页的数据,但是有两个显而易见的缺点:

  1. 分页会漂移。以为每次 offset 都是从头开始数的,假设从第一页跳到第二页的时候,恰好在第一页插入了一条数据,那么第一页的最后一条数据就会出现在第二页上。一个现实的例子是豆瓣,豆瓣是按照时间倒序分页的,我经常翻页到第二页的时候看到在第一页看到过的帖子;
  2. 第二个缺点就是性能问题。可以想象,每次都要从头开始读,那页数越大,那么要读的 Top N 就越多,效率也就越慢;

第二种分页技术可以完美地解决这两个问题:基于 seek 的分页。解释一下这个方法,这里没有第几页的概念了,而是用一个 where 条件来分页。从这个条件往后继续查 10 条记录。

举个例子,假设现在每一天只有一条销售记录,那么你可以用下面这条 SQL 查出来一页(10条)的销售记录(在 SALE_DATE 有索引的情况下):

这里的 ? 上一页最后一条记录的时间。然后要查下一页的数据的话,继续将 ? 替换成这一页最后一条记录的时间即可。

这种方法的实现有几个要点需要注意:

  1. 必须要保证每次查询的结果是固定的顺序。显而易见,如果顺序不固定的话那么这个 Top N 是不可靠的。有一个陷阱是,可能你自己查了几次顺序都是固定的,但是上了生产顺序就不固定了,这是因为你看到的有序可能只是巧合,恰好是因为你本地的数据库每次用一样的 plan 来执行这个查询,到了服务器可能因为并行计算就出现乱序的情况。解决这个情况很简单,就是在排序的索引上添加固定的 column,比如 id;
  2. 基于此,查询下一页的时候要从上一页结束的地方开始,这也是比较 tricky 的地方。

接着上面的例子,假如不是一天只有一条销售记录了,那么我们可以创建这样的索引。

然后使用的查询语句如下:

当然,这个方法也不是没有缺点的。虽然可以解决第一种方法两个问题,但是 seek 方法本身的问题也很大:它只能按照一页一页的顺序查找,无法直接跳到某一页。但是对于现在很多应用(比如 Twitter)采用了无限滚动的方法,是比较实用的。

Window Function 也可以用来分页

Window Function 也可以基于索引分页。下面的查询:

这里按照排好序之后,按顺序分 ROW_NUMBER,然后限制 ROW_NUMBER 在 11 和 20 之间,同样可以使用索引。

查询计划如下:

但是生产中 Window Function 很少用,它真正的威力是在离线分析上。Window Function 功能很强大,值得好好读一读相关的文档。

 

近况更新

这是一篇碎碎念,最近有一些想法想分享一下,但是内容又比较简单,不适合写成一篇博客,twitter 又写不下,就一起写一篇博客好了。

首先是上个周做的有关命令行的分享,个人觉得比较满意吧,大家参与的热情也很高,提了不少问题,我自己想说的东西也都分享了。从另外两位讲师的分享中,我自己也学到很多东西。

命令行操作是一个程序员必备的技能。对我来说,写代码是我热爱的事情,我觉得要拿编程作为自己的饭碗(而不是焦虑着如何“在35岁之前转管理”),1)要不断学习新的技术;2)理解这些技术背后的原理更为重要,学习要学到精髓,技术一直在变,但是基本的原理(网络、容器、存储甚至机器学习等)几十年来的变化不大;3)要擅长使用自己的工具。第三点很容易被忽视,但是我觉得是程序员成长路上比较重要的一部分。如果你自己连自己的工具都用不溜,那你很难喜欢自己的编程工作。我见过一些蹩脚的程序员,解压 tar 包都要去网上下载一个解压工具。也难怪想方设法要转管理来脱离编程的“苦海”了。工具的使用不受重视的原因,有一点我觉得是技术氛围是比较受“面试官”导向的。比如面试喜欢问你读过什么源代码,就导致很多人读过各种牛逼项目的源代码,却连文档都没看完;比如面试喜欢问一些“JVM原理”,就造成候选人好像人人都是 Java 专家,却写不好 Java 代码;又比如很多人能回答上来 TCP 一些刁钻的问题,但是却不懂 TCP 的基本原理。面试中很少有人会问你工具如何使用,写一个命令行工具也远不如写一个xxx系统/中间件对面试官来说有吸引力,但是现实是,很多人连“将一个文件夹从一台服务器传到另一台”都做不好。

槽吐完了,流水账一下最近的工作和想法吧。

dbcli 放弃对 Python2 的支持

最近给 pgcli 放弃了对 Python2.7 3.4 3.5 。说实话 pgcli 到现在还能支持这些 Python 版本真够了不起了,但是 prompt-toolkit 3.0 支持的最小 Python 版本是 3.6,所以 pgcli 对这些 python 版本无法继续支持了。如果不升级 prompt-toolkit 到 3.0 的话,一些打包就会 broken。

我本来以为升级工作会很简单,删除这些 Python 版本的测试,添加对 3.8 的测试,然后升级依赖即可。但是遇到的坑实在太深了(可能是我太菜了)。三行代码花了我两个星期,push 了几十次(有些 commits 被 rebase 掉了)。

首先第一个坑是 CPR 的问题,之前以为 pexpect 的 terminal 是 dumb terminal,所以测试没有关闭 CPR 也一直没出过问题,但是升级 prompt-toolkit 这个问题暴露出来了。pexpect 应该是模拟了一个 shell,CPR 没被显式的关闭是有问题的。然后代码库里有个测试写错了,如果我关闭 CPR 这个测试还会挂掉,对发现这个问题的根因造成了极大的干扰。另外所有的测试在 mac 上是完全都能 pass 的,到 Linux 就出问题了(准确的说是在 travis-ci 的 Linux 会出问题,在我的虚拟 fedora 里面就不会),所以只能改代码 push ,让 travis 去测试,非常痛苦。

所以这三行代码背后有我巨大的工作量……

Coverage.py 4.5 对 Python 3.8 的 bug

我添加了 Python3.8 的测试之后,发现覆盖率神奇的下降了好多。一开始以为是 asyncio 的部分使得调度策略变了,导致一部分代码在测试的时候并没有运行?然后仔细看了一下并不是这样,coverage 生成的文件,竟然有一些代码是白色的,不是覆盖也不是未覆盖,成为 Phantom lines 了。然后查了一下,发现是 coverage.py 的问题。升级一下就 ok 了。

后来想了一下,对于已发布版本(向我们这样的)也无法加 warning 提示用户你用的是 Python3.8,只能发新版本,那么旧版本的用户总是会遇到这个坑,从这个 issue 也可以看到这个问题被 link 了无数次了。向前兼容好难啊!

IRedis 新的 feature

最近给 iredis 加 pager 的功能花了不少力气。要对所有的命令兼容 pager,然后要尊重系统的变量,比如 $LESS $PAGER等。prompt-toolkit 输出带颜色的 output 还无法 pipe 到 less,好烦。不过经过重构过一波代码,总算是实现了。再提一句,iredis 代码很多部分经过不同的人重构过,每一次都在变好了,单元测试功不可没啊!

Redis-py extend lock 可以直接接受新值

终于给 redis-py 的 lock 加上这个 feature 了。redis-py 的 lock.extend 语义一直让我很困惑。lock.extend(10) 的意思指的是,让 lock 的 timeout 变成“还剩下的 timeout + 10s”,这就导致我们在多数锁的场景下,这个锁的生命会越来越长,导致 fail over 的速度会来越长。(在这篇文章我有详细描述过)在 redbeat 里面我只能用一个新的 lua 脚本,来保持锁的最大超时时间总是 10s ,不会受之前剩下的 timeout 变得更加长寿。

 

Zoom 直播分享 Awesome Commandline 录像和资料下载

之前一直想说分享一下 iredis 开发的一些想法和经验来,这篇博客拖了很久没有写。上个周末我们直接搞了一场在线的分享形式,效果还不错。有幸请到了 iredis 这个项目的两位开发者 rhchen 和 wooden-robot,下午一起和大家通过 Zoom 的形式分享了一下。

这一期辛姐有帮我们录像,现在已经上传到了 Youtube 上,有兴趣的朋友可以在 Youtube 观看。

分享中提到的内容,以及分享的 slide,在下文 github 上可以找到。(P.S. 我把陈老师的 PPT 也上传到我自己的 Github 了哈哈哈哈)

三个演讲的大纲和PPT

赖信涛:awesome commandline

slide: https://github.com/laixintao/myslides/tree/master/awesome-commandline

1. 为什么命令行更加高效(演示demo,vim+tmux+shell命令可以互相配合)
2. 大部分时间我们都在和 Vim,终端相处,但是日常的开发工作还离不开另一个角色:REPL
3. 所以我们需要更好的命令行的REPL:mycli/pgcli/iredis
4. 如何开发这样的工具?
5. 开发理念?
6. What next?

WoodenRobot: awesome-pipeline

slide: https://github.com/Wooden-Robot/myslides/

协助开发 iredis pipeline feature 的始末
shell 的 pipeline原理,常用操作
python 的 subprocess 接口
如何参与开源项目

rhchen:awesome-BNF

slide: https://github.com/laixintao/myslides/tree/master/bnf-by-rhchen

什么是 BNF,为什么要用它,能用它做什么?(编译原理的实践应用)
针对 iRedis 的解析需求, 如何设计 BNF? (处理”未输入完全”的字符串)
使用 SLY 解析输入和 iRedis 当前的解析方式的不同点比较

 

Use the Index, Luke! 笔记4:sorting 和 grouping

排序和分组是非常耗费 CPU 和内存的一项操作。因为需要内存来放排序的中间结果,排序算法一般也都是 O(n*log(n)) 的复杂度。好在使用索引我们也可以优化这一项操作。原理非常简单:假如结果已经是排好序的,那么我们查出来结果之后就不需要额外的排序操作了。

如何使排序命中索引

要避免排序操作,我们需要索引 cover ORDER BY 的部分。原理很简单,如果 order by 中有一部分没有在索引里面,那么我们就无法保证查出来的内容是按照索引排好序的,所以还要再对这个结果排序一遍。

比如下面这个查询,因为 product_id 不在索引里面,所以执行计划中会有排序的操作。

假如我们再建立一个索引,覆盖住 product_id:

那么排序这一步,就会从查询计划中消失了。因为结果本身一定是排好序的。

P.S. TABLE ACCESS 这一行,第二个执行计划的 Cost 比前一个执行计划高了,是因为新的索引的聚簇效果更差了(上一篇笔记有提到过有关聚簇的知识)。为什么呢?拿 Oracle 来讲,当索引中两条数据的值相等的时候,Oracle 会自动把 ROWID 给加到索引里面去,这样一来索引的顺序和表中数据的顺序是一样的,就有了很好的聚簇效果 (clustering factor). 但是当我们往索引中添加新的一列的时候,留给数据库这种分配索引顺序的自由就更小了,所以只会让 clustering factor 变得更差。由此一来,数据库本来在聚簇效果很好的时候,可以顺序读 table 的 block,现在不能顺序读了,可能会 access 同一个 block 很多次。但是如果是同一个 block 的话,数据库会读缓存中的 block。因此,由于缓存带来的优势,使得我们实际上给这个索引增加一列造成的性能损失,是比执行计划分析出来的,要小的。

很显然,因为联合索引是在左列的值相等的时候,按照右列的值排序的。所以在左列的值相等的情况下,我们针对右边来 order by 查询,也是可以走索引的:

但是,如果左侧的值横跨两个的话,就无法使用索引了:

除了索引的顺序和 order by 的顺序不搭(上面这个例子),即使有索引,也有可能走扫表的情况,这种情况可能就是优化器认为每次从索引查到 ROWID 再回表,不如直接扫表来的快了。

升降序的问题

数据库是双向读索引的。也就是说,如果 order  by 的 column 顺序和建立索引的顺序完全反向,那么也可以走索引查询。

比如下面这个查询,跟上面的查询顺序完全相反,那么也可以走索引:

查询计划不会显示需要索引:

因为数据库是以双向链表保存索引的,所以可以以完全相反的顺序查询。

为什么要强调是完全相反呢?

考虑下面这个查询,一个是索引中的 ASC,另一个条件是 DESC。

这样实际在走索引的时候,需要“跳跃”,而这对于索引的数据结构是办不到的。

要想优化这个查询,只能重新按照 ASC DESC 的顺序再建立索引:

同理,新的索引建立之后,用前者 DESC,后者 ASC来查询,也是“完全相反”的顺序的,也可以走到这个索引。

Group By

SQL 数据库有两种算法来完成 group by:

  1. Hash算法,将所有的记录放到内存中临时的一个 Hash 表中,扫完所有记录,返回 hash 表;
  2. 排序-分组算法:先将所有的记录排好序,然后遍历一遍一组一组返回;

两种算法都要求有一个中间状态,并且不支持 pipeline 操作,必须全部计算完成才能返回。

但是利用前文提到的索引技术也能优化这种场景。比如下面这个查询,就可以命中索引,不需要排序,直接分组返回:

查询计划显示不需要排序(BY NOSORT):

需要命中索引的条件和 order by 一样,顺序必须和建立索引的顺序完全一致或者完全相反,只不过 ASC DESC 对于 group by 来说,是无所谓的。

对顺序的要求非常合理,比如我们这个查询,过去 24 小时的销售量,按照第二列索引来 group by,那么又要“跳索引”了,这是不可能的:

这种情况下比如使用非 pipeline 的操作,来 group by 数据。

这种情况下,Oracle 选择的是 hash 算法,因为 hash 只需要一种中间数据,节省了排序算法分组返回时所需要的内存。

索引使得 order by 能够执行的更快之外,另外的一大好处是 pipeline,这使得我们不必处理完所有的数据就可以得到前几个结果,在只需要头部数据的时候非常有用。下一篇博客再讲。