编写Postgres扩展之四:测试


在关于编写Postgres扩展的第三部分中,我们使用LLDB调试器修复了一个严重的错误,并使用类型转换完成了base36类型。现在是时候恢复我们实际上已经取得的成就——并做更多的测试。

你可以在github branch part_iii上查看当前代码库。

全功能/功率测试套件

只是简单地在Postgres-console中尝试一些东西就论断一切都可以正常工作是一个坏主意,特别是在开发扩展时引入了一些严重的bug之后。因此,我们了解到拥有一个完全覆盖的测试套件是多么重要,它不仅可以测试“快乐路径”,还可以测试边缘和错误情况。

在第一篇文章中,我们已经在测试方面做得很好,我们使用了内置的扩展回归测试。所以让我们在一些测试脚本中写下我们的发现。

文件名:sql/base36_test.sql

CREATE EXTENSION base36;
SELECT '120'::base36;
SELECT '3c'::base36;
CREATE TABLE base36_test(val base36);
INSERT INTO base36_test VALUES ('123'), ('3c'), ('5A'), ('zZz');
SELECT * FROM base36_test;
SELECT '120'::base36 > '3c'::base36;
SELECT * FROM base36_test ORDER BY val;
EXPLAIN (COSTS OFF) SELECT * FROM base36_test where NOT val < 'c1';
SELECT 'abcdefghi'::base36;

注意,我在EXPLAIN命令中添加了(COSTS OFF),以确保测试不会在具有不同成本参数的不同机器上失败。

如果我们现在运行:

make clean && make && make install && make installcheck

我们在results/base36_test.out中获取输出,并将其复制到sql/expected /。 但等等 - 让我们先仔细阅读,以确保这一切都符合预期。

SELECT 'abcdefghi'::base36;
 base36
--------
 r0bprq
(1 row)

显然不符合预期。当我们在base36_in中放太长的字符串时,它似乎也有一个严重的错误。让我们看看strtol的文档:

man strtol
strtoimax, strtol, strtoll, strtoq -- convert a string value to a long, long long, intmax_t or quad_t integer

所以在第13行中我们将一个long int转换为一个int型发生了溢出。

result = strtol(str, NULL, 36);

重用内部的DirectFunctionCall

让我们通过再次重用Postgres内部功能来地进行正确的转换:那么Postgres如何将bigint转换为int呢?

test=# \dC bigint
                             List of casts
 Source type  |     Target type       |      Function      |   Implicit?
--------------+-----------------------+--------------------+---------------
 bigint       | bit                   | bit                | no
 bigint       | double precision      | float8             | yes
 bigint       | integer               | int4               | in assignment

这里使用的sql函数int4是如何定义的?

test=# \df+ int4
                           List of functions
 Name | Result data type | Argument data types |  Source code
------+------------------+---------------------+---------------------
 int4 | integer          | "char"              |  chartoi4
 int4 | integer          | bigint              |  int84
 int4 | integer          | bit                 |  bittoint4
 int4 | integer          | boolean             |  bool_int4
 int4 | integer          | double precision    |  dtoi4
 int4 | integer          | numeric             |  numeric_int4
 int4 | integer          | real                |  ftoi4
 int4 | integer          | smallint            |  i2toi4
(8 rows)

所以int84就是我们要找的。你可以在utils/int8.h中找到它的定义,我们需要将它include到源代码中才能使用它。你已经在第一篇文章中了解到,为了在SQL中使用C函数,你必须使用“版本1”调用约定来定义它们。因此,这些函数具有int84的特定签名:

extern Datum int84(PG_FUNCTION_ARGS);

所以我们不能直接从代码中调用这个函数,我们必须使用来自fmgr.hDirectFunctionCall宏:

DirectFunctionCall1(func, arg1)
DirectFunctionCall2(func, arg1, arg2)
DirectFunctionCall3(func, arg1, arg2, arg3)
DirectFunctionCall4(func, arg1, arg2, arg3, arg4)
DirectFunctionCall5(func, arg1, arg2, arg3, arg4, arg5)
DirectFunctionCall6(func, arg1, arg2, arg3, arg4, arg5, arg6)
DirectFunctionCall7(func, arg1, arg2, arg3, arg4, arg5, arg6, arg7)
DirectFunctionCall8(func, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8)
DirectFunctionCall9(func, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9)

有了这些宏,我们可以根据参数的数量在我们的C代码中直接调用任何函数。但是使用这些宏时要小心:这些宏不是类型安全的,因为传递和返回的参数只是Datums,而Datums可以是任何类型的数据。使用这个你不会从编译器得到错误。如果你传递了错误的数据类型,你只会在运行时得到奇怪的结果——这是拥有一个完全覆盖的测试套件的又一个原因。

由于宏已经返回了一个Datum类型的数据,我们最终得到:

文件名:base36.c

PG_FUNCTION_INFO_V1(base36_in);
Datum
base36_in(PG_FUNCTION_ARGS)
{
    long result;
    char *str = PG_GETARG_CSTRING(0);
    result = strtol(str, NULL, 36);
    PG_RETURN_DATUM(DirectFunctionCall1(int84,(int64)result));
}

最后:

# SELECT 'abcdefghi'::base36;
ERROR:  integer out of range
LINE 1: SELECT 'abcdefghi'::base36;

Pimp the Makefile

为了更好地了解不同的测试,让我们将它们分成不同的文件并将它们存储在test/sql目录下。为了实现这一点,我们还需要调整Makefile。

文件名:Makefile

EXTENSION     = base36                          # the extensions name
DATA          = base36--0.0.1.sql               # script files to install
TESTS         = $(wildcard test/sql/*.sql)      # use test/sql/*.sql as test files

# find the sql and expected directories under test
# load base36 extension into test db
# load plpgsql into test db
REGRESS_OPTS  = --inputdir=test         \
                --load-extension=base36 \
                --load-language=plpgsql
REGRESS       = $(patsubst test/sql/%.sql,%,$(TESTS))
MODULES       = base36                          # our c module file to build

# postgres build stuff
PG_CONFIG = pg_config
PGXS := $(shell $(PG_CONFIG) --pgxs)
include $(PGXS)

TESTS定义了我们在test/sql /* .sql下可以找到的不同测试文件。此外,我们还添加了REGRESS选项,将测试输入目录更改为test(—inputdir=test),回归运行程序期望sql目录包含测试脚本,expected目录包含预期输出。我们还定义了扩展base36应该事先在测试数据库中创建(--load-extension = base36),避免在每个测试脚本的顶部运行CREATE EXTENSION命令。我们还定义了将plpgsql语言加载到测试数据库中,这实际上不是我们的测试套件所需要的。但是它不会造成伤害,并且为我们未来的项目提供了一个更通用的Makefile。

组织测试文件

现在让我们添加测试文件:

文件名:test/sql/base36_io.sql

-- simple input
SELECT '120'::base36;
SELECT '3c'::base36;
-- case insensitivity
SELECT '3C'::base36;
SELECT 'FoO'::base36;
-- invalid characters
SELECT 'foo bar'::base36;
SELECT 'abc$%2'::base36;
-- negative values
SELECT '-10'::base36;
-- too big values
SELECT 'abcdefghi'::base36;

-- storage
BEGIN;
CREATE TABLE base36_test(val base36);
INSERT INTO base36_test VALUES ('123'), ('3c'), ('5A'), ('zZz');
SELECT * FROM base36_test;
UPDATE base36_test SET val = '567a' where val = '123';
SELECT * FROM base36_test;
ROLLBACK;

注意,我将状态更改命令封装在一个事务中,该事务将在最后回滚。这是为了确保每个脚本都以一个干净的状态开始。如果我们现在看看我们在results/base36_io中得到了什么,我们会发现我们在恶意输入上又有了一些有趣的行为。

-- invalid characters
SELECT 'foo bar'::base36;
 base36
--------
 foo
(1 row)

SELECT 'abc$%2'::base36;
 base36
--------
 abc
(1 row)

strtol函数转换为给定的基数,在字符串的末尾或在给定基数中不产生有效数字的第一个字符处停止。我们绝对不想要这个惊喜,所以让我们阅读man page(man strtol)并修复它。

If endptr is not NULL, strtol() stores the address of the first invalid
character in *endptr. If there were no digits at all, however, strtol()
stores the original value of str in *endptr.
(Thus, if *str is not `\0' but **endptr is `\0' on return, the entire string was valid.)

文件名:

PG_FUNCTION_INFO_V1(base36_in);
Datum
base36_in(PG_FUNCTION_ARGS)
{
    long result;
    char *bad;
    char *str = PG_GETARG_CSTRING(0);
    result = strtol(str, &bad, 36);
    if (bad[0] != '\0' || strlen(str)==0)
        ereport(ERROR,
            (
             errcode(ERRCODE_SYNTAX_ERROR),
             errmsg("invalid input syntax for base36: \"%s\"", str)
            )
        );
    PG_RETURN_DATUM(DirectFunctionCall1(int84,(int64)result));
}

运行make clean && make && make install && make installcheck,results / base36_io.out看起来不错。 让我们将其复制到预期的文件夹中:

mkdir test/expected
cp results/base36_io.out test/expected

并重新运行我们的测试套件

make clean && make && make install && make installcheck

文件名:test/sql/operators.sql

-- comparison
SELECT '120'::base36 > '3c'::base36;
SELECT '120'::base36 >= '3c'::base36;
SELECT '120'::base36 < '3c'::base36;
SELECT '120'::base36 <= '3c'::base36;
SELECT '120'::base36 <> '3c'::base36;
SELECT '120'::base36 = '3c'::base36;

-- comparison equals
SELECT '120'::base36 > '120'::base36;
SELECT '120'::base36 >= '120'::base36;
SELECT '120'::base36 < '120'::base36;
SELECT '120'::base36 <= '120'::base36;
SELECT '120'::base36 <> '120'::base36;
SELECT '120'::base36 = '120'::base36;

-- comparison negation
SELECT NOT '120'::base36 > '120'::base36;
SELECT NOT '120'::base36 >= '120'::base36;
SELECT NOT '120'::base36 < '120'::base36;
SELECT NOT '120'::base36 <= '120'::base36;
SELECT NOT '120'::base36 <> '120'::base36;
SELECT NOT '120'::base36 = '120'::base36;

--commutator and negator
BEGIN;
CREATE TABLE base36_test AS
SELECT i::base36 as val FROM generate_series(1,10000) i;
CREATE INDEX ON base36_test(val);
ANALYZE;
SET enable_seqscan TO off;
EXPLAIN (COSTS OFF) SELECT * FROM base36_test where NOT val < 'c1';
EXPLAIN (COSTS OFF) SELECT * FROM base36_test where NOT 'c1' > val;
EXPLAIN (COSTS OFF) SELECT * FROM base36_test where 'c1' > val;
-- hash aggregate
SET enable_seqscan TO on;
EXPLAIN (COSTS OFF) SELECT val, COUNT(*) FROM base36_test GROUP BY 1;
ROLLBACK;

这里我们使用了一些运行时查询配置来强制使用索引和哈希聚合。

SET enable_seqscan TO off;
EXPLAIN (COSTS OFF) SELECT * FROM base36_test where NOT val < 'c1';
                        QUERY PLAN
----------------------------------------------------------
 Index Only Scan using base36_test_val_idx on base36_test
   Index Cond: (val >= 'c1'::base36)
(2 rows)

EXPLAIN (COSTS OFF) SELECT * FROM base36_test where NOT 'c1' > val;
                        QUERY PLAN
----------------------------------------------------------
 Index Only Scan using base36_test_val_idx on base36_test
   Index Cond: (val >= 'c1'::base36)
(2 rows)

EXPLAIN (COSTS OFF) SELECT * FROM base36_test where 'c1' > val;
                        QUERY PLAN
----------------------------------------------------------
 Index Only Scan using base36_test_val_idx on base36_test
   Index Cond: (val < 'c1'::base36)
(2 rows)

-- hash aggregate
SET enable_seqscan TO on;
EXPLAIN (COSTS OFF) SELECT val, COUNT(*) FROM base36_test GROUP BY 1;
          QUERY PLAN
-------------------------------
 HashAggregate
   Group Key: val
   ->  Seq Scan on base36_test
(3 rows)

因此,我们可以确保COMMUTATORNEGATOR的设置是正确的。

因为我们没有编写太多自己的代码,而是使用了Postgres的内部功能,我们看到results / operators.out看起来不错。 我们同样复制它。

cp results/operators.out test/expected
make clean && make && make install && make installcheck

得到

============== running regression test queries        ==============
test base36_io                ... ok
test operators                ... ok

=====================
 All 2 tests passed.
=====================

又一个测试

到目前为止,我们实现了输入和输出函数,重用了Postgres比较函数和操作符,并对所有内容进行了测试。我们做完了吗?不!我们还可以添加一个测试:

文件名:test/sql/operators.sql

-- storage
BEGIN;
CREATE TABLE base36_test(val base36);
INSERT INTO base36_test VALUES ('123'), ('3c'), ('5A'), ('zZz');
SELECT * FROM base36_test;
UPDATE base36_test SET val = '567a' where val = '123';
SELECT * FROM base36_test;
UPDATE base36_test SET val = '-aa' where val = '3c';
SELECT * FROM base36_test;
ROLLBACK;

在这里,我们尝试更新到一个负值,应该会失败:

UPDATE base36_test SET val = '-aa' where val = '3c';
SELECT * FROM base36_test;
ERROR:  negative values are not allowed
DETAIL:  value -370 is negative
HINT:  make it positive

但它没有...嗯,它确实,但不是在更新步骤 - 只有在检索值时。虽然我们不允许输出函数为负值,但它仍然允许输入值为负值。当我们执行以下命令时

SELECT '-aa'::base36;
ERROR:  negative values are not allowed
DETAIL:  value -370 is negative
HINT:  make it positive

同时调用输入和输出函数,导致错误.但是对于UPDATE命令,只调用输入,导致磁盘上出现一个负值,之后将永远无法检索该值。让我们快速解决这个问题

文件名:base36.c

PG_FUNCTION_INFO_V1(base36_in);
Datum
base36_in(PG_FUNCTION_ARGS)
{
    int64 result;
    char *bad;
    char *str = PG_GETARG_CSTRING(0);
    result = strtol(str, &bad, 36);
    if (bad[0] != '\0' || strlen(str)==0)
        ereport(ERROR,
            (
             errcode(ERRCODE_SYNTAX_ERROR),
             errmsg("invalid input syntax for base36: \"%s\"", str)
            )
        );
    if (result < 0)
        ereport(ERROR,
            (
             errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
             errmsg("negative values are not allowed"),
             errdetail("value %ld is negative", result),
             errhint("make it positive")
            )
        );
    PG_RETURN_DATUM(DirectFunctionCall1(int84,result));
}

所有的努力值得吗?

虽然扩展Postgres很有趣,但是不要忘记我们为什么要构建所有这些。让我们将base36方法与使用varchar类型的Postgres-native方法进行比较。我们将比较两个方面:每种类型的存储需求和相应的查询性能。

存储需求

我们最初的动机是节省空间,只存储4个字节的整数而不是6个字符,根据文档,这将浪费7个字节。

让我们比较一下。

test=# CREATE TABLE base36_check (val base36);
CREATE TABLE
test=# CREATE TABLE varchar_check (val varchar(6));
CREATE TABLE
test=# INSERT INTO base36_check SELECT i::base36 from generate_series(1,1e6::int) i;
INSERT 0 1000000
test=# INSERT INTO varchar_check SELECT i::base36::text from generate_series(1,1e6::int) i;
INSERT 0 1000000
test=# SELECT pg_table_size('base36_check') as "base36 size", pg_table_size('varchar_check') as "varchar_check size";
 base36 size | varchar_check size
-------------+-----------------------
    36249600 |              36249600
(1 row)

哎呀......我们没有保存一个字节! 对于我们在数据类型中所做的所有努力,这是非常不幸的。这是怎么发生的呢?我们必须知道Postgres实际上是如何存储数据的。我们的小示例将以以下内容结束:

  • base36_check: 23 字节用于header + 1 字节用于null bitmap + 4 字节用于数据 = 28 字节
  • varchar_check: 23 字节用于header + 1 字节用于null bitmap + 7 字节用于数据 = 31 bytes

所以我们确实应该每行节省3个字节,但最终表大小居然相同。我们还需要考虑,Postgres将数据存储在通常包含8kB(8192字节)数据的页面中,并且单个行不能跨两个页面。每行最终还会有一个最大数据对齐设置的倍数,即现代64位系统上的8个字节。

所以最后,在这两种情况下,我们需要每行32字节+4字节元组指针。

(8192 per page - 24 page header)
-----------------------------------------------------  = 226 rows per page
(32 byte data and alignment + 4 byte tuple pointer)

真实世界的例子中,情况(当然)会完全改变:

test=# DROP TABLE base36_check;
DROP TABLE
test=# DROP TABLE varchar_check;
DROP TABLE
test=# CREATE TABLE base36_check (val base36, num integer);
CREATE TABLE
test=# CREATE TABLE varchar_check (val varchar(6), num integer);
CREATE TABLE
test=# INSERT INTO base36_check SELECT i::base36, i from generate_series(1,1e6::int) i;
INSERT 0 1000000
test=# INSERT INTO varchar_check SELECT i::base36::text,i from generate_series(1,1e6::int) i;
INSERT 0 1000000
test=# SELECT pg_size_pretty(pg_table_size('base36_check')) as "base36 size", pg_size_pretty(pg_table_size('varchar_check')) as "varchar_check size";
 base36 size | varchar_check size
-------------+--------------------
 35 MB       | 42 MB

当我们向数据库中添加数据时,由于base36检查表上的对齐浪费了4个字节,所以它没有增长,而varchar检查表每一行增加了4个字节的数据加上4个字节的对齐。

现在我们节省了20%的空间。

查询性能

我们做一些计时

test=# \timing
Timing is on.
test=# SELECT * FROM varchar_check ORDER BY VAL LIMIT 10;
 val  |  num
------+-------
 1    |     1
 10   |    36
 100  |  1296
 1000 | 46656
 1001 | 46657
 1002 | 46658
 1003 | 46659
 1004 | 46660
 1005 | 46661
 1006 | 46662
(10 rows)

Time: 601,551 ms

test=# SELECT * FROM base36_check ORDER BY VAL LIMIT 10;
 val | num
-----+-----
 1   |   1
 2   |   2
 3   |   3
 4   |   4
 5   |   5
 6   |   6
 7   |   7
 8   |   8
 9   |   9
 a   |  10
(10 rows)

Time: 73,575 ms

除了base36的排序更自然之外,它的速度也快了8倍。如果你记住排序是数据库的关键操作,那么这个事实为我们提供了真正的优化。 例如,在创建索引时:

test=# CREATE INDEX ON varchar_check(val);
CREATE INDEX
Time: 13585,451 ms
test=# CREATE INDEX ON base36_check(val);
CREATE INDEX
Time: 294,433 ms

它对于连接操作或按语句分组也很有用。

更多内容

既然我们已经修复了所有的bug并添加了测试来确保它们不会再出现,那么我们的扩展就差不多完成了。在本系列的下一篇文章中,我们将使用bigbase36类型完成扩展,并看看如何更好地构造代码。

posted @ 2019-09-10 13:31  Tacey Wong  阅读(662)  评论(0编辑  收藏  举报