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,这使得我们不必处理完所有的数据就可以得到前几个结果,在只需要头部数据的时候非常有用。下一篇博客再讲。

 

天堂的另一面

英国“迷幻”摇滚 Glass Animals 发布了 Zaba 之后,开始了非常激进的巡回演唱会,一共办了将近 140 多场。在演唱会的旅行中,他们从不同的人那里听到了一些故事,有些甚至是亲身经历的。GA 将这些故事写成了新的专辑,叫做《如何做人》(How to Be a Human Being) 。

这张专辑一共 11 首歌,专辑的封面上有 11 个人,每一首歌的背后都是一个故事,虽然不是每一个故事都对剧情讲的很清晰,但是表达的感情却很强烈,悲伤,后悔,愤怒……

我最喜欢的一首是《天堂的另一面》( The Other Side Of Paradise ). 这首歌的故事我觉得讲的最清楚,具有非常强烈的愤怒和无力感。

这首歌以女生的口吻讲述,将故事的开头作为音乐的开头,心爱之人为了梦想背井离乡,并用非常安心的口吻安稳自己。

When I was young and stupid my love
Left to be a rock and roll star
He told me please don’t worry
Wise little smile that spoke so safely

男孩买了一张单程票去了西方(应该是加州),那个地方会让挤在一间屋子的 6 个人,有机会成为百万富翁。

He booked a one way ticket
Out west that’s where they make it
Six kids stuck in a bedsit
To sunswept poolside riches

然后有一天,他遇到了范思哲女孩,穿戴奢侈,珠光宝气,他也有机会成为明星了。

He met a girl who wore Versace
Pink feather coats and jumbo jewellery
Gonna be a hoop phenomenon
He’s gonna be Hakeem Olajuwan

然后是第二段 Verse,他在电话中告诉她,他终于成功了,每天都能都能赚很多钱。但是在女孩这里,却像电影里的慢镜头一样。

He’s got a gold Camaro
He said over the payphone
I try to keep my cool but
My life turns in slow motion

接下来就是 Chorus 了。大段的内容描述了女生内心的活动,对急速成功的渴望让他越来越远,他被欲望杀掉了,他成了 Ghost。

Bye bye baby blue
I wish you could see the wicked truth
Caught up in a rush it’s killing you
Screaming at the sun you blow into
Curled up in a grip when we were us
Fingers in a fist like you might run
I settle for a ghost I never knew
Superparadise I held on to
But I settle for a ghost

然后是第三段 Verse,依然是心里活动。这一段对后来的剧情至关重要。在“我出生的”新奥尔良,没有人会为了成为明星背井离乡,男人们会 stay and treat his lady, Give everything to his new baby

When I was from n.o.l.a no one
Left to be a rock and roll star
He’d stay and treat his lady
Give everything to his new baby

然后是 Bridge 的部分,她也变了一个人,变了自己的态度,她要用自己的愤怒毁灭他。但是这一段也开始变得模糊了,歌词中有枪,down,shake这些字眼,愤怒无疑是来自女生,但是也说不清是开枪打了谁,不知道歌词中的 Girl 指的是她,还是范思哲女孩,不知道她杀了别人,还是自杀。只能从歌词中明确的知道,她变得麻木了,“My body’s looking wrong”…

I know you don’t but I
I know you don’t but I still try
My thunder shook him down
My thunder came and shook him down
That girl is gone but I
That girl is gone but I still try
I think it’s over now
The bullet hit but maybe not
I feel so fucking numb
It hits my head and I feel numb
My body’s looking wrong

据说这个故事来自于一个真实的篮球运动员,搬到美国之后真实发生的。

 

P.S. 因为 Pork Soda 里面有一句 “Pineapples are in my head”,所以很多乐迷在演唱会现场会带菠萝,演唱会结束后让现场难以打扫。后来演唱会将烟花,武器,菠萝列为了违禁品