Redis RESP3 的一些想法

在 Python 中使用 Redis,基本上都会选择 redis-py 这个库。本质上,它是封装了 Redis 命令,将它们变成 Python 的函数。比如 SET 这个命令,它的使用方式,在 redis 文档中是这么定义的:

然后 redis-py 对应的 Python 函数是这样的:

redis-py 中有几百个函数,封装了对应的 redis 命令。

这样的好处是你可以使用原生的函数来调用 Redis,返回结果也是 Python 的原生类型。在写代码的时候编辑器可以自动为你补全 Python 函数,调用的什么 Redis 命令也比较清楚,对 Python 代码静态分析就知道对 Redis 做了什么操作。

缺点也显而易见:

  • 如果 Redis 对已有命令支持了新的参数,需要 redis-py 支持才能使用。比如 让 HSET 支持多对 key value给 SET 添加 KEEPTTL 参数,这两个PR。合并之后要等 redis-py 发布新的版本,到应用能真的用的上 redis 的 Feature,将会是很漫长的一个过程;
  • 第二个问题是从 Redis 的命令,到 Python 的函数,相当于加了一层中间的转换。首先我要去看 Redis 的文档,知道了 SET 的用法,然后我还要去看 redis-py 的文档,去学习一下在 Python 中是如何抽象的,哪些是值是字面值,哪些值翻译成了 Python 的 bool;

在群里看到过网友这么吐槽,对这个问题总结的比较精辟:

所以,这一层抽象真的有必要吗?Redis 的命令如此简单,我们能否直接给 Redis 发送想要执行的命令呢?

我一直在想, 理想的 Redis Python 客户端应该是这样使用的:

而接口的定义,显然可以只需要一个,能够通过这个接口接收任何命令:

以目前的 RESP,实现起来还是有点难度的。我在 IRedis 中做了类似的事情,先分享一下 IRedis 里面是怎么做的,阻止我们实现一个这样的客户端的问题是什么。

IRedis 是一个 redis-cli 的替代品,一个命令行 REPL 工具。在 IRedis 中,如果使用 redis-py 这些函数的话,那么一个命令从用户输入,到真正到达 Redis server,要经过这样的转换:cli input -> 分解出来命令,和这些命令的参数 -> 调用相应的 redis-py 里面的函数 -> redis-py 打包成 RESP 协议的请求 -> 发送给 Redis Server。显然太复杂了。并且我认为上面的 问题2 对 IRedis 也是一个不小的限制:使用 IRedis 应该体验到 Redis 最新的 Feature,而不是等到 redis-py 发布之后。

redis-py 中 的 connection.py 是封装了 RESP 协议的内容的,这部分代码解耦的比较好,所以目前 IRedis 只依赖了打包和解析 Redis RESP 协议的部分代码。在 IRedis 中向 Redis 发送命令的代码是这样的(简化了重试之类的细节代码):

这样如果 Redis 支持了新的命令,IRedis 用户不需要升级 redis-py,甚至不需要升级 IRedis 本身,就可以直接使用新的命令。

So far, so good. 但是有一个我没提到的缺点是,RESP2(redis-py 是屏蔽了 RESP 协议的,如果不熟悉当前 RESP 的话,可以读一下这篇文章,解释的比较好:理解 Redis 的 RESP 协议)的类型太少,比如 LRANGE SMEMBERS HGETALL 三个命令,返回的都是 Array(术语中叫做 multi bulk reply),但其实这三个命令返回的结果分别是:list, set, dict。redis-py 里面是对这些命令的结果做了转换。所以,现在的 RESP 中是无法区分出返回的类型的,这就要求 Redis 的客户端必须记住每一个命令的返回类型。因此,redis-py 在当前的 RESP 协议中,对每一个命令封装是最合理的做法。

IRedis 中我也有同样的问题,所以我用一个 csv 文件整理了所有的命令返回的类型,以针对不同的类型做不同的渲染(redis-cli中并没有区分出类型,所以用户看到的都是 Array):

在 IRedis 中将不同类型分开解析了

redis-cli 中全部当成 list 显示

另外,当前的 RESP 协议也缺乏很多其他类型,比如浮点数是没有的,要用 string 来返回,boolean 也没有,要用 int 来返回。比如在写 Lua 代码的时候,因为 Redis 协议的本身并没有 Boolean 类型,如果我们要在 Lua 脚本中判断 True/False 的话,必须通过字符串相等来替代。比如这个PR

除此以外,当前的 RESP 还有其他一些限制。但是我觉得最严重的就是混用了一些类型,导致开发一个语言的客户端必须一个一个地按照 Redis 的文档来实现它的命令。

好在,这个问题在下一代的 Redis 协议 RESP3 里面彻底解决了。RESP3 有丰富的类型

  • Null: a single null value replacing RESP v2 *-1 and $-1 null values.
  • Double: a floating point number
  • Boolean: true or false
  • Blob error: binary safe error code and message.
  • Verbatim string: a binary safe string that should be displayed to humans without any escaping or filtering. For instance the output of LATENCY DOCTOR in Redis.
  • Map: an ordered collection of key-value pairs. Keys and values can be any other RESP3 type.
  • Set: an unordered collection of N other types.
  • Attribute: Like the Map type, but the client should keep reading the reply ignoring the attribute type, and return it to the client as additional information.
  • Push: Out of band data. The format is like the Array type, but the client should just check the first string element, stating the type of the out of band data, a call a callback if there is one registered for this specific type of push information. Push types are not related to replies, since they are information that the server may push at any time in the connection, so the client should keep reading if it is reading the reply of a command.
  • Hello: Like the Map type, but is sent only when the connection between the client and the server is established, in order to welcome the client with different information like the name of the server, its version, and so forth.
  • Big number: a large number non representable by the Number type

这样,我们可以实现这样一个客户端,完全不关心执行的命令,不需要对特定的命令进行支持(一些影响客户端行为比如 CLIENT REPLY OFF 依然需要特殊支持),就可以与 Redis Server 交互,无论 Redis 对已有命令做出了修改,或者支持了新的 Feature 新的命令,客户端都不需要做升级,应用就能直接使用。

我尝试写一个基于 RESP3 的这样的客户端,如果有兴趣可以在这里 follow:https://github.com/laixintao/resp3-py

RESP3 并没有采用基于现有数据打包结构(BSON 或者 msgpack)的方式实现,简单来说原因是 1)这些只是数据结构,依然要实现通讯层的东西,没有脱离要“设计一个通讯协议”的问题。2)引入了复杂性,用户本质上要和两个库相处 3)这些数据结构不是基于流处理的,比如 json,你要读完(至少读一定程度)才能开始处理,所以需要某种 buffer,可能是潜在的性能瓶颈。而 RESP3 是在 TCP 上的一种简单的协议,在协议上发送大的字符串的时候不需要全部读完,客户端就可以开始处理。

往远处想,RESP3 的意义不仅仅是适用于 Redis 的协议,甚至不局限于数据库,基于 RESP3 实现 RPC 也是完全可以的。

在 Unix 的哲学中,文本是优于二进制的,RESP3 中说道:

RESP 在过去的几年中工作的非常好。这说明,如果用心设计,那么一种简单的、用户可读的协议不会成为通信的性能瓶颈,而且这种对用户阅读友好的协议对客户端生态的建设大有好处。

最后,RESP3 是不兼容 2 的,而 Redis6 只支持 RESP3,这意味从 Redis5 迁移到 Redis6 可能要麻烦一些。这个设计选择可以看 Antirez 的博客:Why RESP3 will be the only protocol supported by Redis 6

RESP3 的 spec: RESP3

 

请不要再使用 __file__ 啦!

一般来说,Python 的 module 会有一个 __file__ 属性,定义了 module 的 path。在 Python 中,使用这个属性非常常见,比如获取 module 所在的目录地址,以便于读取这个 module 同级的,非 Python 脚本的其他文件,比如库需要依赖的数据等:module_dir = os.path.abspath(os.path.dirname(__file__)) ;或者用来获取脚本的位置,来进行魔法 import 操作。

但严格来说 __file__ 不总是有的。Python 文档中说:

__file__ 当 module 是从文件中加载的,才会有。如果是静态编译的 C Module,就不会有这个 __file__,extension module 中如果动态链接了 shared lib,那么 __file__ 的值就是动态链接库的位置。

另外,如果你的包是在一个 zip 里面的,__file__ 也没有。

所以,所有使用了 __file__ 的 Python 代码:

  • 要么是 broken 的;
  • 要么选择了不会支持其他的 module loader(不从文件系统 load module)

但是我们经常有读文件的需求,比如图片、json、csv 等,不用 __file__ 的话,怎么知道它们在哪里呢?

如果 Python 版本 < 3.7,解决方案会有点复杂。因为 3.7 之前一直没有一个完美的方案来读取文件资源。有一段时间 pkg_resources 是推荐的方式,Python3 后来引入了 ResourceLoader 接口,然后在 3.7 又 deprecated 了这个接口,并引入了 ResourceReader ,提供了对应的 API importlib.resources module 。即使最近的 ResourceReader,也有一些行为的不一致。但是到目前为止,应该是这些 API 中最好的选择了。

如果你使用 Python3.7+ 的话,可以直接使用内置的 importlib。如果是使用 3.5+ 的 Python 的,建议使用 importlib_resources,这个库是 importlib.resources 的向后兼容版本(实际上,这个库推荐 3.9 之前都使用这个库,3.9 使用标准库)。如果是用 3.5 以下的 Python 的话,应该尽快升级 Python 的版本。

当然了,一些小的脚本,可以不考虑兼容性直接使用 __file__ 的。

可能有人认为 Python 总是用脚本来跑的,可以依赖文件系统的路径,没有什么大不了的。但是我觉得现在 Python 的应用场景越来越多,Python 代码应该考虑运行在各种环境,最好不要对这些环境做一些“假设”。

这些想法起源与最近把 IRedis 打包成一个二进制来跑,希望用户在使用 IRedis 的时候,只要用 cURL 下载下来,然后解压,就可以运行了,不用装 pip,不用使用 pip 安装,甚至不装 Python 或者发行版的 Python 不支持 IRedis 都没问题,因为 IRedis 运行所需要的所有东西都已经包装在这个二进制里面了。在某些情况下是非常有用的,比如说 Redis 的 Docker 镜像,里面是没有 Python 的。

另外打包成 binary 体积也更小,现在用 docker 下载 Python 的 image 将近 1G 了,而打包好的 iredis.tar.gz 才 22M(包含Python解释器)。

这项打包工作是 Mac Chaffee 完成的,他写的打包脚本非常精彩,有兴趣的可以看一下这项工作的记录,很有意思:

Anyway,我们遇到的问题是,很多包都依赖了 __file__ ,去 patch 这些 package 几乎是不可能的:

所以就采用了一个这种的方案,将这些依赖放到一个 lib/ 下,依然让它们作为文件系统的文件存在。虽然这样让打包出来的东西并不是一个纯正的 binary,解压之后会有一个 iredis binary,还有一个 lib/ 目录,但综合考虑可能是性价比的方案了。

如果你正在开发的是一个 lib 的话,强烈建议不要在代码中依赖 __file__ 了。为 Python 更广阔的应用场景做一份贡献!

 

每行80个字符在今天(2020年)依然合理!

很多代码库每行长度最多为 80,这是因为古老的打孔纸的最大长度是 80,一开始的显示器每一行显示的字符也并不是特别多。这一 Max Length = 80 的传统被一直延续下来了。

IBM 打孔纸

现在宽屏显示器非常流行,有一种声音说在代码中将每行的字符限制为 80 是没有必要的,因为这会浪费显示器的空间。

但是我认为行长度设置为 80 依然有道理:

“浪费宽屏显示器的空间” 是不实的。基本上所有的编辑器都有“分屏”的功能,80个字符更有利于分屏展示代码。相反,如果行长度太长,反而会给分屏带来麻烦,比如10行代码中有1行是100多个字符,那么分屏的时候会将这一行“折行”(wrap),显示的很糟糕;如果行长80,可以自由的分屏。

相反,行太长才是“浪费显示器的空间”,屏幕的右边只是偶尔出现几行,大部分都是空白。分屏才能有效利用宽屏显示器。

另外,笔记本的屏幕依然没有“那么大”,如果短行在哪里显示都是OK的,但是长行在小屏幕下显示很糟糕。

Vim 分屏

80字符为一行更容易阅读代码。每行字符更少,分一行来表达一个操作,容易理解。(阮一峰的博客,读起来比较轻松,他比较喜欢用短句,来表达比较复杂的东西,比如你现在在阅读的,括号里面的这段文字)

再比如以下 Java 代码,从 items 中随机选择 5 个。(可能有更简单的方法,这里只是为了举个例子)。

不限制行的长度

增加换行使代码更易读。

80 个字符可以让代码在其他阅读器上阅读更友好。代码并不总是在 Vim 中打开的,比如你躺在床上,打开 ipad,将 more-itertools 的代码放大到和屏幕同样宽度,阅读这些短小巧妙的函数,非常惬意。

用 ipad 在 Github 上阅读代码

当然,死板的遵守 80 个字符也是没有意义的(比如 URL)。Kernel 代码中就有超过 80 的行。Linux 说(2012年的邮件):

有时候打破 80 比强行拆成两行要好。所以代码库中有超过80的情况,这并不是说应该行长100,而是 80+ 在这种情况下是最好的选择。

所以这是一个折中的方案。把 80 想象成是一个 Hard Limit 是不对的,试图将这个 Hard Limit 扩展到 100 也是不对的。

 

综上,一行代码的最大长度依然是 80 !

 

Use the Index, Luke! 笔记2 性能、Join操作

这是读 use-the-index-luke 的第二篇笔记,是 Testing and ScalabilityThe Join Operation 两章的内容。

 

存储大小和请求量对性能的影响

在开发环境工作很好的 SQL 到了生产环境可能执行的很慢,主要会受两个方面的影响:

  1. 数据量:同样一个查询,在几百条开发数据里面跑和几亿的数据里面跑,效果是不一样的;
  2. 请求量:开发环境只有几个连接查询,在生产环境中可能同时有几百个并发查询,速度也会显著下降。

分析 Execution Plan 的结果会比简单的 Benchmark 更有自信(要格外注意 Predicate Information 中的 filter,filter 随时可能爆炸)。完全的压测也是有必要的,但是通常成本很高。

有人认为生产环境的机器配置更高,机器更多,可以解决查询慢的问题。但是大部分的慢查询都不能通过增加硬件来解决。21世纪以来 CPU 的性能没有很大提升,CPU 厂商都开始用多核技术来提高 CPU 的性能。如果仅仅是一个任务,那么开发用的笔记本和服务器效果是差不多的。生产环境就像高速公路,可以允许更多的车在上面跑,而它本身并不会让车跑的更快。相反的,生产环境的设施更加复杂,服务器之间的交互更多,加上防火墙等设施,在生产环境运行更有可能更慢。NoSQL 宣称解决一切问题,但是本质上它是用最终一致性换取了水平扩展写的能力。如果一个查询在关系型数据库很慢,那么即使迁移到 NoSQL 也会很慢。加快查询最靠谱的方法是利用好 B-Tree 索引。

另一个导致查询慢的原因是硬盘磁头 Seek 的次数太多了,虽然一次 Seek 只需要几毫秒,一次索引查询(B-Tree)最多也只会 Seek 4次,但是如果 join 表太多的话,就会 Seek 几百次。

所以再次强调,最靠谱的验证方式还是看执行计划。否则很容被开发环境小量数据的效果“欺骗”。

Join 操作

先来两个笑话吧(No hard feelings)。

笑话1:

SQL 走进了一家酒吧,看到有两张桌子(Table),他问:我能加入(Join)你们吗?

笑话2:

为什么前端程序员总是独自吃饭?

因为他们不懂 Join Tables.

回到正题,join 可以将分散在多个表中的数据重新组合起来。因为 join 需要聚合分散的数据,所以 join 操作对磁盘的 seek 非常敏感。正确的索引可以加快 join 操作,如何索引取决于使用哪种 join 算法。常用的一共有 3 种。但是这些算法的一个共同点是:一次只能 join 两个 Table。如果有多个 Table,那么先 join 起来前两个 Table,然后作为中间表,再去和后面的 join。所以下文在讨论的时候,都是讨论两张表之间的 join。

虽然 join 的顺序对最后的结果没有什么影响,但是对查询性能是有影响的。优化器需要从所有的可能中选一个最优的查询计划出来。在这种情况下,光优化查询的参数是不够的,因为优化器做选择本身就会花一些时间(n! 复杂度,n是查询条件)。当然,如果使用 bind parameters 就没有这个问题啦(参考我的第一篇笔记),所以参数越多,使用 bind parameters 就越有必要。

接下来介绍 3 种最常用的 join 算法:

  1. Nested Loops – 用循环来 join 两张表;
  2. Hash Join – 将一张表构建出来 Hash 表,然后对表2中的每一行记录,找到 hash 表中对应的行;
  3. Sort Merge – 将两张表排好序,然后用 Merge Sort 算法进行 join;

Nested Loops

这是实现起来最简单的一种方法:Join 表1 和表2,只要遍历表1中每一行,去表2 Select 就行了。

如果你使用 ORM 的话,可能遇到过一个经典的问题:N+1 select ——错误做法:从两张表查询 N 行记录,先在表1中查出来所有的行,然后用一个循环 Select 表2中的行。因为一共会执行 N + 1次 Select(第一次查询出所有记录,然后遍历N次),所以叫做 N+ 1 Select 问题。这样做可能在上线之后引起性能问题,因为执行了大量的 Select。

通过上面这个问题,可以比较好的理解 Nested Loops. 可以看出,join 的条件有没有索引,对 join 的性能影响很大。因为相当于执行的是 select。

通过 Nested loops 来进行 join 虽然和 ORM 这种 N+1 Select 的原理是相同的,但是用 SQL join 会更加高效。这是因为 join 是直接在数据库服务里面做的,而 N + 1 Select 要客户端和服务器来来回回对每一条 Select 进行请求和响应。

性能一般反应在两个方面:Response Time 和 Throughput,在网络上反应为延迟和带宽。对于数据库来说,延迟更加关键,带宽并不是很重要。

所以最好打开执行的 SQL 日志(一般的 ORM 都有这种选项),来看一下类似的操作是否是通过 SQL 来 join 的。

如果 join 条件有索引,并且数据集不大的话,Nested Loops 的性能是不错的。如果数据量大的话,优化器就会选择其他的 join 方式。

Hash Join

Hash Join 的原理是对一个表执行 Full table scan,将这个表 load 进 hash 表中,以 join 的条件作为这个 hash 表的 key;然后选出第二张表中,符合 where 查询条件的行,在 hash 表中找到对应的第一张表的数据。

将一张表加载到内存中,来避免了 join 的过程中多次 select。因为 join 的条件会被作为 hash 表的 key,本身就是 O(1) 的速度了,所以对 join 的条件建立索引,在 hash Join 中并不会起作用。

优化 Hash Join 速度的方法主要有两个:

  1. 加速遍历第二章表的速度,即对 where 条件建索引;
  2. 尽量减少 join 的内容的数量,这样降低 load 进内存的大小;水平角度,我们可以用 where 语句过滤出尽量少的行;垂直角度,我们只 select 需要的 column,而不是 select *

Sort Merge

Sort Merge 的原理很简单,将两张表排好序,然后用 sort merge 的算法将两张表 join 起来。

Sort Merge 所需要的索引和 Hash join 一样,尽可能加速查询,对 where 语句索引。join 的条件是没有必要索引的。

Sort Merge 对 join 的顺序完全不感冒,A join B 和 B join A 的结果是完全一样的,性能也是一样;

因为 Sort Merge 要将两边的表都排好序,所以代价很高,相比之下,Hash Join 只需要处理一边的表。所以 Hash Join 实际上更常用一些。但是有一种情况,就是如果输入的数据已经是排好序的,那么 Sort Merge 是非常快的。

 

集合里的元素怎么“不见了”?

昨天花时间在 debug 一个非常诡异的问题,Java 代码里面的一个 HashSet 集合里面命令包含我这个元素,equals hashCode 都一样,甚至对象的 id 都是一样的,但是 contains 方法返回的结果总是 false 的!最后花了很多时间,百思不得其解,一度怀疑我生活在 Matrix 里面。最后发现问题的一刻也恍然大悟,发现这是一个我早就知道的问题。这必定成为我职业生涯的一个污点,所以我打算记录一下这个问题。

先卖个关子吧,我来描述一下问题的背景,看你能否想到答案。

问题是这样的,我们用 JGraphT 来解决一个图的问题。这个图是我们从应用的调用关系链中生成的,生成之后会导出到 json,放到一个地方。然后所有的计算节点都可以通过这个 json 来 load 图,就不用每个节点都去清洗一遍了。一个节点清洗过后,所有的节点都从这里加载。问题主要出现图的导出和导入,图中每个节点都有一个 id,一开始我用应用的名字作为 id,导出到 json,但是导入的时候发现 Importer 会重新生成 ID,图的关系是对的,但是节点的 ID 从字符串变成了重新生成的 id 了,那么应用名字的信息就丢失了。我又给节点加上 name 属性,期望这个属性 import 之后还是好的。结果发现 import 只是 import 图的关系,并没有 import 进来其他属性。(这个库看起来很 nice 啊,不知道为啥文档这么差,import 的细节都没有文档)于是我参考 Test 里面的做法,用一个 Map 存下来节点的其他属性。然后在 import 完成之后,将这些属性 set 进去。

OK,总结一下,简单来说就是,我先从 json 导入进图,导入的时候也存下来每个节点的属性(其实就是name),导入之后遍历图的节点,将每个属性设置进去。

问题就出现了,我用图来找最短路径的时候,报错:节点不存在!

定位到库里面,判断节点不存在的 contains 函数是这么写的:

我 debug 了这个 Set 和 v 的关系,发现 Set 中的一个元素,跟 v 是一模一样的!对象 id 都是一样的。

equals 返回值是一样的:

hashCode 返回值是一样的:

但是这个 contains 函数就是返回 false.

为了让这个问题更明显一些,我将这个问题简化成以下可以直接运行的一段 Java 代码:

运行结果如下:

明明 equalshashCode 都一样,为什么 contains 就是 false 呢?


答案就在查找 Hash 表的方式。我之前写过一篇文章,介绍如果发生 hash 碰撞,那么 hash 表一般会通过某种方式存放 hash 相同的元素。这就要求,在 hash 表中查找元素的时候,必须满足以下两个条件,才算是找到了元素:

  1. 按照 hash 值能找到这个元素所在的 hash 位置,但是这个位置存放着很多 hash 值相同的元素,所以还要满足2;
  2. 必须满足相等(equals)

HashSet 其实就是没有 value 的 HashMap,本质上也是个 hash 表,所以 contains 要返回 true,也必须满足上面两个条件。元素在存进去的时候,name 是空的,按照 namenull 得到了一个 hash 值,放到了 HashMap 的一个地方,记做位置A。然后我后来修改 name 的值,再 hash 的时候,就会得到另一个 hash 值,记做位置B. 然后 contains 去位置 B 一看,这个位置是个null,就认为这个元素不在集合中了。

为什么 hashCode 和 equals 返回都是相等的呢?因为我们先按照 name = null 保存了进去,保存的时候 hash 值已经确定了,后来修改了 name,hash 值已经不会修改(不会在 HashSet 里面移动的)。虽然对象即使是同一个对象,但是 hash 值已经和放进去的时候变了。拿现在的对象(Set里面的那个对象,和现在的要确定是否被 contains 的对象,都是“现在的对象”,name 已经被修改了的)来对比 hash 值肯定是相等的,但是已经和放进去的时候的那个 hash 值不同了。去看 HashSet 中,现在的这个 hash 值的位置,肯定是个 null,所以判断为元素不存在。

简单总结一下,就是放入 Hash 中的元素,一定要是不可修改的(这个和 Python 为什么list不能作为字典的key?的原理是一样的)。如果修改了,那这个元素就从集合中找不回来了。

最后,从这个故事中我们能学到什么呢?

感觉学不到什么,现在回想起来就跟自己的智商收到了降维打击一样。

哦对了,如果你看懂了这个问题,那么就会理解,之所以找不到这个元素是因为这个元素放进去的时候的 hashCode 和现在的这个元素的 hashCode 已经不一样了。我不禁回忆起另外一个问题:

有三个人去住旅馆,住三间房,每一间房$10元,于是他们一共付给老板$30,第二天,老板觉得三间房只需要$25元就够了,于是叫小弟退回$5给三位客人。

谁知小弟贪心,只退回每人$1,自己偷偷拿了$2,这样一来便等于那三位客人每人各花了九元,于是三个人一共花了$27,再加上小弟独吞了不$2,总共是$29。可是当初他们三个人一共付出$30那么还有$1呢?