在CockroachDB中实现Unicode Collation

143 阅读7分钟

CockroachDB最近获得了对Unicode整理的支持,这是一个以不同方式对字符串进行排序的标准,也是我们世界各地的用户所期望的。这篇文章描述了Unicode整理的动机,以及在提供整理过的字符串作为第一类类型时的实现挑战。

整理过的字符串在这里有记录。请注意,CockroachDB并不支持PostgreSQL的每一种整理方式,部分原因是我们计划解决的实现缺陷,部分原因是我们认为隐式类型转换带来的错误和性能问题超过了其便利性。然而,我们已经为全面支持留下了大门。

一个奇怪的种类

下面是牛津3000中的一段摘录,这是一个按字母顺序排列的重要英语单词列表。

ocean
o'clock
October
odd

让我们看看CockroachDB是如何给这些字符串排序的。

CREATE TABLE words (word STRING PRIMARY KEY);
INSERT INTO words VALUES ('ocean'), ('o''clock'), ('October'), ('odd');
SELECT word FROM words ORDER BY word ASC;

+---------+
|  word   |
+---------+
| October |
| o'clock |
| ocean   |
| odd     |
+---------+
(4 rows)

你能发现其中的差别吗?

像大多数软件一样,CockroachDB默认按照UTF-8编码对字符串进行排序,下面是十六进制的显示。

October 4f63746f626572
o'clock 6f27636c6f636b
ocean   6f6365616e
odd     6f6464

October 是第一个,因为 (大写 )小于 (小写 )。 先于 ,因为 ( )小于 ( )。相比之下,英语中的字母顺序不考虑大写字母和标点符号。4f O 6f o o'clock ocean 27``' 63``c

新的词序

为什么CockroachDB不默认按字母顺序排列?除了性能方面的考虑(后面会详细介绍),没有任何一种顺序可以满足所有的用户。例如,在德语中,öffnen 先于zumachen ,而在瑞典语中,zon 先于öppna 。还有一个小问题,就是在没有字母表的语言中,"字母表顺序 "意味着什么。

多年来,许多标准组织定义了针对语言、文化和用途的字符串顺序,最终形成了Unicode技术标准#10。#10描述了一种通用的整理算法,Go项目已经很好地实现了这种算法(golang.org/x/text/collate)。让我们看看英文校对在CockroachDB中是如何工作的。

CREATE TABLE words (word STRING COLLATE en PRIMARY KEY);
INSERT INTO words VALUES
  ('ocean' COLLATE en),
  ('o''clock' COLLATE en),
  ('October' COLLATE en),
  ('odd' COLLATE en);
SELECT word FROM words ORDER BY word ASC;

+---------+
|  word   |
+---------+
| o'clock |
| ocean   |
| October |
| odd     |
+---------+
(4 rows)

October 现在是按字母顺序排序,但 ,不按字母顺序排序。对于真正的字母排序,整理器必须忽略标点符号,虽然#10提到这是个选项,但Go库缺乏支持。o'clock

COLLATE 操作符的左边操作数可以是一个字符串类型或一个字符串值。右边的操作数是校对的地区设置(en 代表英语)。其结果是一个具有相同内容的整理过的字符串。整理过的字符串根据其共享的整理区位进行比较。

让我们再来看看德语(de)和瑞典语(sv)之间的排序差异。

SELECT ('öffnen' COLLATE de < 'zumachen' COLLATE de, 'zon' COLLATE sv < 'öppna' COLLATE sv);

(true,true)

真正的类型

在CockroachDB中,STRING,STRING COLLATE en, 和STRING COLLATE de 是三种不同的类型。相比之下,PostgreSQL模糊了这种区别。这两个系统都拒绝('a' COLLATE en) < ('b' COLLATE de) ,而且是正确的--比较应该使用英语规则还是德语?然而,只有PostgreSQL允许将一个英文规则的字符串插入到一个德文规则的列中。

虽然我们通常会努力争取与PostgreSQL兼容,但我们觉得我们的设计

  1. 更容易理解和实现。
  2. 可以防止用户SQL语句中的错误,并且
  3. 为我们不断发展的类型系统保留了更多的可能性。

作为3的一个特例,我们可以在以后切换到PostgreSQL的设计而不破坏向后的兼容性。

一路走来的键

SQL表的每一列都是一个(主)键列或一个值列。在值列中存储整理过的字符串很容易--只要写出它们的UTF-8编码,就像你对普通字符串那样。让我们来看看为什么在键列中存储带校对的字符串会比较困难。

介绍CockroachDB作为一个SQL系统的文章中,你可能记得CockroachDB将SQL主键编码为键值存储键(字节串)的方式,使前者和后者的排序相同(更确切地说,编码功能是一个顺序嵌入)。由于CockroachDB对普通字符串使用UTF-8顺序,它们的密钥编码几乎是逐字的。然而,整理过的字符串的密钥编码必须反映整理的地域性。

幸运的是,Unicode技术标准#10用从(整理过的)字符串到称为整理键的字节字符串的顺序嵌入来定义整理。让我们暂时假设这种嵌入将所有字母大写。这并不是实际的程序!当列的类型是STRING ,商店里的键值对看起来像这样。

Key                         Value
--------------------------- -------
/words/primary/'October'    Ø
/words/primary/'o''clock'   Ø
/words/primary/'ocean'      Ø
/words/primary/'odd'        Ø

当列类型为STRING COLLATE en_u_ks_level1 (英文,忽略大小写)时,键值对看起来像这样。

Key                         Value
--------------------------- ------------
/words/primary/'O''CLOCK'   'o''clock'
/words/primary/'OCEAN'      'ocean'
/words/primary/'OCTOBER'    'October'
/words/primary/'ODD'        'odd'

对于每一行,CockroachDB必须同时存储整理键和原始字符串,因为前者并不能决定后者(考虑'a''A' )。我们对这个程序进行了调整,我们称之为复合编码,适用于浮点数和小数,即其他具有非相同等值的类型(正零和负零,有尾部零和无尾部零的小数)。为了节省空间,只有负零和带尾巴零的小数有复合编码。

有一个问题是,整理键在伴随#10的表格的修订中并不稳定。前面提到的Go库还没有更新,但如果这种情况改变了,我们很可能会把它卖掉,然后思考我们的下一步行动。

通用的困境

整理支持的一个缺点是,大多数字符串函数和运算符不接受整理过的字符串。

SELECT 'a' COLLATE en || 'b' COLLATE en;

pq: unsupported binary operator:  || 
Error: pq: unsupported binary operator:  || 
Failed running "sql"

我们推荐的解决方法是将其转换为STRING

SELECT (('a' COLLATE en) :: STRING || ('b' COLLATE en) :: STRING) COLLATE en;

1 row
(('a' COLLATE en)::STRING || ('b' COLLATE en)::STRING) COLLATE en
ab

由于我们的SQL类型系统(Summer)的限制,以及在Go中编写高性能通用代码的难度,我们推迟了对这个问题的修复。

类型系统(Summer)

Summer为我们提供了很好的服务,但其复杂的重载函数和操作符的类型化策略有一个不幸的特性,即添加签名会破坏后向兼容性。此外,目前的实现假设这些签名可以被枚举,而(原则上)有无限多的整理区域。整理过的字符串和其他参数类型(TIMESTAMP/TIMESTAMPTZ ,数组,固定宽度的整数)正在引导我们重新思考Summer。

CockroachDB提供了许多函数,这些函数应该同时接受普通字符串和整理过的字符串。出于性能的考虑,普通字符串和整理过的字符串有不同的底层Go类型--整理过的字符串应该缓存它们的整理键,而不会引起普通字符串的膨胀。这意味着我们可以接触到大家最喜欢的话题,即在Go中编写通用代码。

通常的建议是

  1. 使用接口。
  2. 复制代码,或者
  3. 生成代码。

接口需要为每一个字符串值进行额外的分配--这是不可接受的。我们试过为TIMESTAMP/TIMESTAMPTZ ,发现在一组较小的函数上重复代码是很乏味和容易出错的。我们可能会使用一个高阶适配器函数作为权宜之计,直到我们开始生成代码。

见你的整理器

像往常一样,如果你发现整理过的字符串的问题,请在我们的GitHub上告诉我们。

串联字符串的实现需要对CockroachDB的一些SQL子系统进行修改。如果你对这些子系统的工作原理感兴趣,请看我们在github.com/cockroachdb… 中的博文(即将发布?

Alligator.