软件测试是开发过程中十分重要的一环,在数据库领域更是如此。一款稳定、可靠的数据库离不开大量的测试作为支撑。
Greenplum 作为一款基于 Postgres 的开源数据库,在测试方面做出了大量的探索。除继承了 Postgres 原有的 regress 测试外,增加了 Fault Injector 框架。允许开发者在回归测试中,通过执行简单的 SQL 函数,对数据库注入真实场景中可能出现各种的故障。此外, Greenplum 还开发了新的 isolation 测试框架 (isolation2),开发者可以用更简单易懂的语法编写出在各种并发情况下,可能出现的数据竞争的测试用例。配合 Fault Injector 框架,能够涵盖范围非常广的测试场景。
本文主要结合一些实际的案例介绍这些测试框架的使用场景和使用方式, 在后续的文章中会介绍这些框架的原理,欢迎大家留言交流。
regress
regress 测试位于 Greenplum/Postgres 源代码的 src/test/regress 目录下。这些测试通常包含:
- 测试用例: 一组 SQL 语句 (.sql 文件)
- 期待输出:测试用例执行后, Postgres 的正确输出 (.out 文件)
这些测试由 pg_regress 调度执行。执行结束后通过对预期输出和实际输出进行对比 (通常会自动生成一个 regression.diffs 的文件),即可知道哪些测试没有通过,以及原因是什么。利用 regress 测试,我们可以做一些功能性的测试。例如:测试一些带有复杂过滤条件的 SELECT 语句的输出结果是否正确,测试一些 SQL 生成的执行计划是否符合预期。这些测试都有一个特点,那就是不管在什么情况下,它们的输出都应该是一致且恒定的。
尽管 regress 测试能够满足大部分的功能性测试,但还有一些有趣的测试场景值得讨论。 比如数据库中的隔离和并发问题。虽然 pg_regress 支持并行地执行多个测试用例, 但我们并不能利用它来验证一些时序上的问题 (我们倒是可以利用它来加速一些测试)。因为这些并行的测试用例中 SQL 语句的执行次序是没有保证的,我们无法得到稳定的输出,也就无法跟预期的输出进行对比了。而下面要介绍的 isolation & isolation2 就是为这类测试而设计的。
isolation & isolation2
isolation 测试位于 Greenplum/Postgres 源代码的 src/test/isolation 目录下。目前 Greenplum 中的 isolation 测试框架还不是很完善,Greenplum 仅在 Utility 模式下运行 Postgres 原生的 isolation 测试。Greenplum 大部分的和并发/隔离相关的测试由 isolation2 完成。
Postgres 原生的 isolation 测试包含:
- 测试用例:一组 .spec 文件,这些 .spec 文件除了包含待测试的 SQL 语句外,最重要的是还包含了对执行这些 SQL 语句顺序的定义。
- 期待输出:测试用例的 spec 被执行后, Postgres 的正确输出 (.out 文件)。
这些测试由 pg_isolation_regress 根据 .spec 文件中定义的执行顺序,调度不同的 Session 执行,下面给出的是 Postgres 中, 对于一种死锁的测试用例。
`## file: src/test/isolation/specs/deadlock-simple.spec`
`setup`
`{`
` CREATE TABLE a1 ();`
`}`
`teardown`
`{`
` DROP TABLE a1;`
`}`
`session s1`
`setup { BEGIN; }`
`step s1as { LOCK TABLE a1 IN ACCESS SHARE MODE; }`
`step s1ae { LOCK TABLE a1 IN ACCESS EXCLUSIVE MODE; }`
`step s1c { COMMIT; }`
`session s2`
`setup { BEGIN; }`
`step s2as { LOCK TABLE a1 IN ACCESS SHARE MODE; }`
`step s2ae { LOCK TABLE a1 IN ACCESS EXCLUSIVE MODE; }`
`step s2c { COMMIT; }`
`permutation s1as s2as s1ae s2ae s1c s2c`
其中,setup 和 teardown 中包含的 SQL 语句分别会在测试运行前和结束后运行,在这个例子中是创建和删除表 a1. s1 和 s2 分别是这个测试中两个 Session 的名称。
在 s1 中,定义了 setup 时需要开始一段事务,还定义了 s1as, s1ae, s1c 这三个执行步骤:
- s1as:对表 a1 以 ACCESS SHARE 模式上锁
- s1ae:对表 a1 以 ACCESS EXCLUSIVE 模式上锁
- s1c:提交当前事务
在 s2 中,定义了 setup 时需要开始一段事务,还定义了 s2as, s2ae, s2c 这三个执行步骤:
- s2as: 对表 a1 以 ACCESS SHARE 模式上锁
- s2ae: 对表 a1 以 ACCESS EXCLUSIVE 模式上锁
- s2c: 提交当前事务
permutation 中定义了这些 SQL 语句执行的顺序:
- s1: 对表 a1 以 ACCESS SHARE 模式上锁
- s2: 对表 a1 以 ACCESS SHARE 模式上锁
- s1: 对表 a1 以 ACCESS EXCLUSIVE 模式上锁 <等待, s2 拿了 ACCESS SHARE 模式的锁>
- s2: 对表 a1 以 ACCESS EXCLUSIVE 模式上锁 <死锁发生, s1, s2 互相等待>
pg_isolation_regress 执行完上面 .spec 文件中定义的 SQL 后, 正确输出如下:
`## file: src/test/isolation/expected/deadlock-simple.out`
`Parsed test spec with 2 sessions`
`starting permutation: s1as s2as s1ae s2ae s1c s2c`
`step s1as: LOCK TABLE a1 IN ACCESS SHARE MODE;`
`step s2as: LOCK TABLE a1 IN ACCESS SHARE MODE;`
`step s1ae: LOCK TABLE a1 IN ACCESS EXCLUSIVE MODE; <waiting ...>`
`step s2ae: LOCK TABLE a1 IN ACCESS EXCLUSIVE MODE;`
`ERROR: deadlock detected`
`step s1ae: <... completed>`
`step s1c: COMMIT;`
`step s2c: COMMIT;`
开发者可以在 .spec 文件中灵活的描述在不同 Session 中执行 SQL 语句的顺序,使得一些与并发/隔离相关的测试更为容易编写。
Greenplum 团队开发的 isolation2 测试框架,也是类似的思路,但 isolation2 的语法更简单易懂。下面是笔者利用 isolation2 编写的与上面一致的测试用例。
`CREATE TABLE a1();`
`1: BEGIN; -- s1 开始一段事务`
`1: LOCK TABLE a1 IN ACCESS SHARE MODE; -- s1 以 ACCESS SHARE 模式对 a1 上锁`
`2: BEGIN; -- s2 开始一段事务`
`2: LOCK TABLE a1 IN ACCESS SHARE MODE; -- s2 以 ACCESS SHARE 模式对 a1 上锁`
`1&: LOCK TABLE a1 IN ACCESS EXCLUSIVE MODE; -- s1 以 ACCESS EXCLUSIVE 模式对 a1 上锁`
`2: LOCK TABLE a1 IN ACCESS EXCLUSIVE MODE; -- s2 以 ACCESS EXCLUSIVE 模式对 a1 上锁`
`1<: -- 等待 s1 返回`
`1: COMMIT; -- s1 提交事务`
`2: COMMIT; -- s2 提交事务`
`DROP TABLE a1;`
其中不同的数字表示在不同的 Session 中执行对应的 SQL,由上至下表示了 SQL 的执行顺序。‘&’ 表示当前的 Session 执行的 SQL 会被阻塞,‘<’ 表示等待当前的 Session 返回。isolation2 支持的语法非常丰富,这里就不一一列举了,有兴趣的读者可以浏览 Greenplum 源代码中 src/test/isolation2/sql 目录下的测试用例。
通过对比 isolation 与 isolation2,在功能方面,二者并没有很大的区别;在语法方面, isolation 的 .spec 文件更为严谨,当测试用例中需要多次调用相同的 SQL 语句时会十分方便。solation2 的语法更为灵活直观, 编写起来比较容易上手。
Fault Injector
真实世界中的故障往往要复杂许多,比如:网络可能会有很大的延迟,磁盘中的数据可能丢失,集群中的某台主机可能突然下线。Greenplum 团队开发的 Fault Injector 框架让这类测试变得十分容易。例如,我们希望在 heap 中插入 tuple 的时候,注入一些故障,观察这些故障对系统的影响。在 Greenplum master 分支的 src/backend/access/heap/heapam.c 中,有如下代码:
`// 注: 笔者为了叙述方便, 对这里的代码进行了调整, 与实际代码可能有些出入.`
`void`
`heap_insert(Relation relation, HeapTuple tup, CommandId cid,`
` int options, BulkInsertState bistate, TransactionId xid)`
`{`
` ...`
`#ifdef FAULT_INJECTOR`
` FaultInjector_InjectFaultIfSet("heap_insert", /*faultname*/`
` DDLNotSpecified, /*ddlstatement*/`
` "", /*databasename*/`
` RelationGetRelationName(relation));`
`#endif`
` ...`
`}`
当我们启用了 Fault Injector 后,每当代码执行到 FaultInjector_InjectFaultIfSet() 时,Fault Injector 会检测当前的注入点是否被注入了故障,如果有,则执行相关的故障逻辑。利用 gp_inject_fault 插件可以很容易的在注入点注入故障逻辑。下面这段 SQL 演示了如何在 Greenplum 集群中,在编号为 1 的 Segment Server 上, ‘heap_insert’ 这一注入点,注入一段死循环。以此来模拟实际场景中,某个 Segment Server 发生了故障,无法正常地插入 tuple 到表 my_table 中的情况。
`CREATE EXTENSION gp_inject_fault;`
`SELECT gp_inject_fault('heap_insert' /* inject location */,`
` 'infinite_loop' /* fault type */,`
` '' /* DDL */,`
` '' /* database name */,`
` 'my_table' /* table name */,`
` 1 /* start occurrence */,`
` 10 /* end occurrence */,`
` 0 /* extra arg */,`
` dbid)`
` FROM gp_segment_configuration WHERE content=1 AND role='p';`
除了可以注入 ‘infinite_loop’ 外,Fault Injector 还支持注入以下常见的几种故障:
- error:等价于 elog(ERROR)
- fatal:等价于 elog(FATAL)
- panic:等价于 elog(PANIC)
- sleep:休眠一段时间
- suspend:阻塞当前的进程, 并且不检查中断信号
- resume:恢复被注入 ‘suspend’ 故障的进程
- skip:用来注入一些自定义的故障逻辑
- reset:移除之前注入的故障
- segv:使当前的进程崩溃 (发送 SIGSEGV 信号)
…
有关其它的故障类型以及更多的使用方法,可以参阅 gpcontrib/gp_inject_fault/README。目前 Postgres 还不支持 Fault Injector 框架, Greenplum 团队向上游提交了 Patch,感兴趣的读者可以下载这个 Patch[1] 体验。
后 记
笔者自己上手一个新项目时,喜欢从一些测试用例开始摸索。通过一些简单的测试用例可以窥知一款软件的基本功能,之后配合 git blame 便可以顺藤摸瓜,找到引入这些测试用例的 commit 记录。有时候它可能是修复了一个 Bug,也可能是引入了一个新功能,这样就可以学习到开发这个功能时该如何写测试,该去修改哪些代码。最后,希望大家多多为 Greenplum 贡献代码/Issue。
参考资料
[1] Fault Injector Framework: https://www.postgresql.org/message-id/flat/CANXE4TdxdESX1jKw48xet-5GvBFVSq%3D4cgNeioTQff372KO45A%40mail.gmail.com
