近况更新

这是一篇碎碎念,最近有一些想法想分享一下,但是内容又比较简单,不适合写成一篇博客,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”,所以很多乐迷在演唱会现场会带菠萝,演唱会结束后让现场难以打扫。后来演唱会将烟花,武器,菠萝列为了违禁品

 

Use the Index, Luke! 笔记3:避免回表

你一定听说过一种加速查询的技术,使得只使用索引就可以得到查询的内容,而“避免回表”,至少应该在别人的面试经验中看过,或者自己面试中被问到过吧。这篇文章是 use the index luke! 中 clusting data 这一章节的笔记,解释了这样做的原理,实践。

Data Cluster

Data Cluster 的意思是将连续的,相关的数据尽量存储在一起,这样查找的时候 IO 的成本更低,因为顺序读比随机读速度快,磁头移动少。我们的索引也有这样的功能,帮我们安排数据的顺序,让读的速度更快。

我们通过索引可以达到这一目的。之前,我们介绍过通过执行计划的 filter 可以推测出哪些索引创建的不合理,因为 filter 是一个遍历操作,速度很慢,access 的速度很快。其实,filter 的信息信息很有用,还能帮助我们将数据聚集,来加速查询。

索引的第一大能力是 B-Tree 比那里,第二大能力就是数据聚集。

比如下面这个查询:

因为 LIKE 条件是以 % 开头的,所以无论是对 last_name 建立索引还是对 UPPER(last_name) 建立索引,查询都不会用到。

所以,这个查询的瓶颈在于,按照 subsidiary_id 过滤之后,找到符合 LIKE 条件的 row。这一步的流程是这样:取出一个 row,判断它的属性是否满足条件,然后再去取出下一个 row,再判断。对于这样的操作,如果 row 分散在不同的 block 上的话,一个一个取出来读的话,性能是很差的。如果这些 row 能尽量在同一个 block 上,或者相邻的 block 上,读起来就会快很多了。

有一种方式是将 row 按照索引查找的顺序排序,这样就可以将 filter 的这个操作从随机读,变成顺序读了。但是这样会影响整个表的顺序,只能根据一个索引优化,如果出现另一个查询,就无法根据另一个查询重新设定 row 的顺序了。并且数据库提供的这种 “row sequencing” 的技术和工具都颇为古老,很少使用。

更实用的一个解决方案是将 last_name 也放到索引中。这样就相当于将要查找的 column 放到了一起,我们遍历索引的时候,直接可以得到 last_name 这一利,不需要根据索引读出来 row 再进行判断。这时候索引中 last_name 这个数据不再是提供遍历树的索引功能,而是提供查询功能。

在执行计划中,可以看到 filter 这一步的成本显著降低。因为我们不需要去读表中的数据了,在索引的数据中就可以完成 access,判断是否符合 LIKE 这两步操作。

Covering Index

如果一个索引,能使一次查询避免 table access,那么这个索引就叫做 covering index.

比如我们建立下面这个索引:

那么这个查询就不需要 table access:

因为索引中已经有一份 eur_value 的 copy 了。Oracle 中的执行计划如下:

执行计划中的 INDEX ONLY SCAN 极大地提高了性能,执行计划说一共要聚合 40388 行,假如不是 INDEX ONLY SCAN 的话,这意味着数据库要读 40388(最多,如果这些 row 都没有在同一个 block 的话)次。

INDEX ONLY SCAN 是一种非常激进的优化策略,潜在的增加了数据的维护成本。比如会降低 update 操作的速度。使用 INDEX ONLY SCAN 需要三思。

另外,INDEX ONLY SCAN 也会带来潜在的性能问题。比如下面这个查询:

假如一个同事写了这样的查询,认为这个查询查到的 row 更少,理应更快才是。

但实际上却不是的,这个查询增加了一个不在索引中的 row,原来不需要 table access 的,现在需要了。所以查询的速度会慢很多。

Oracle 的查询计划:

在无法使用 INDEX ONLY SCAN 的时候,优化器会选择第二优的执行计划。在这个例子中,选择了 SALE_DATE 这个索引。这里的选择原因有2:1)通过 sale_date 过滤出来的 row 更少,执行计划显示是 ~10000 行,而上面的 subsidiary_id 有 ~40000 行。2)sale_date 这个索引,数据更加聚集。因为订单创建的顺序和时间是相关的,都是 append 在最后面。这可以可以看出,有些索引“自然”具有 cluster 的性质。

这个例子中使用 sale_date 这个索引正好是一个巧合,虽然它让 INDEX ONLY SCAN 失效了,但是使用它的这个索引,聚集效果也是很好的。但是通常,给 select 添加查询条件会导致聚集很差的查询路径,所以记得用 comment 或者其他的形式,来提醒自己这里的查询是一个 INDEX ONLY SCAN。避免后来添加查询条件导致性能急剧下降。

另外要格外注意,很多数据库多索引大小是有限制的。比如 Postgres 数据库,从 9.2 开始支持 index-only scans,B-Tree Entry 最大为 2137 bytes(硬编码)。如果插入或更新的时候超过了这个大小,会得到错误 “index row size … exceeds btree maximum, 2713” ,B-Tree 索引最多有 32 列。

Clustered Index

由上面一小节自然而然就会想到:那我们可以将整个表都保存在索引中吗?这样都不用回表查询了。答案是可以的。Oracle 将这种所有的数据都在索引中的形式,叫做 index-organized tables (IOT),名字很好的反应了它表达的意思,即表是完全按照索引组织的。另一些数据库将这个术语叫做 clustered index. 其实他们都是一回事。

简单说,clustered index 就是 B-Tree 索引中保存了所有数据,没有 heap table。

这样有两个好处:

  1. 省掉了表的空间,即 heap 的空间,因为所有的数据都在索引中;
  2. 每次查询都不需要回表操作,所有的查询都是 index-only scan;

坏处也显而易见:

因为 row 都是按照索引来组织的,所以 row 的顺序(物理位置)无法固定。正常情况下,我们在 heap table 中保存 row 的时候,我们随便找到一个空的地方保存下来就好了,然后在索引中更新 ROWID 的位置。这个 row 一旦保存好了,它的物理位置永远不会变了,我们只会更新索引中这个 ROWID 的位置。

然而这在 clusted index 中是无法做到的,因为 row 的顺序是按照索引来组织的,一个 row 的位置无法固定。一个 row 即使没有更新它,添加新的 row 的时候,它的位置也可能受到影响,跟着索引移动。

这是根本问题,导致的表面问题是什么呢?

假如我们现在有第二个查询语句,不是根据主索引来的(secondary index),按照有 heap table 的情况下,我们是这么查询的:先在 secondary index 中找到 ROWID 的位置,然后去 heap table 中找到这个 ROW。

但是在 clustered index 中,我们没有 ROWID,只有主索引,所以必须在 secondary index 中找到主索引,然后再去主索引中查找这个 row 的位置。

clustered index 中的这个查询(文中第二个)比 heap table 查询要慢很多,因为第一个查询是 INDEX RANGE SCAN + TABLE ACCESS 的操作,第二个操作需要经过两个索引,INDEX RANGE SCAN + INDEX UNIQUE SCAN 的操作。

当然,对于这个查询,其实可以针对 secondary index 再建立一个 index-only scan,这样针对这个查询又避免了回表操作。比起上面两个查询都是最优的。

总的来讲,clustering index 并不实用,clustering index 的 key 也比 ROWID 要长很多,所以占用更多的空间,适用场景非常有限。

如果 table 能确定,查询总是按照一个顺序的,绝对不会需要 secondary index 的话,可以考虑使用这种索引。

另外,不同的数据库对 index-organized tables 的支持差异很多。PG 不支持没有 heap table,但是可以用 CLUSTER 来将整个 table 放到 index 中。