如何调试分析PostgreSQL代码?
前言
随着 PostgreSQL 生态的不断发展,过往看似复杂的疑难杂症现在大都可以通过查阅资料得以解决,但是还是有着许许多多案例只能通过阅读代码,深入底层细节才能定位到具体原因,光是笔者本人就遇到了无数案例需要调试代码才能定位到根因:
那么作为 DBA 该如何丰富自己的武器库,在面对涉及到 kernel 的场景与案例不至于茫然无措?
搜代码
首先便是搜代码了,最简单的方式是根据错误直接定位代码,这种方式是最简单也是最快捷的。举个栗子
postgres=# create table test1(id int);
ERROR: relation "test1" already exists
但是直接搜 ERROR: relation 也不好找,另外错误信息有时候还因为格式化的原因,直接搜错误是搜不到的,好在 PostgreSQL 提供了一个优雅的接口
postgres=# create table test1(id int);
ERROR: relation "test1" already exists
postgres=# \errverbose
ERROR: 42P07: relation "test1" already exists
LOCATION: heap_create_with_catalog, heap.c:1148
这样便可以直接定位到具体的源码文件和函数。
/* --------------------------------
* heap_create_with_catalog
*
* creates a new cataloged relation. see comments above.
*
* Arguments:
* relname: name to give to new rel
* relnamespace: OID of namespace it goes in
* reltablespace: OID of tablespace it goes in
...
另外可以使用一些通配符搭配 IDE 进行搜索,就以最近的 DropRelationsAllBuffers 为例,既然是删除对象相关的buffer,那么大概按照代码中一贯命名风格,我们便可以通过如下方式缩小代码范围。
[postgres@xiongcc postgresql-16.1]$ grep -ri "DropRelations*" --include="*.c"
src/backend/storage/smgr/md.c: * DropRelationFiles -- drop files of all given relations
src/backend/storage/smgr/md.c:DropRelationFiles(RelFileLocator *delrels, int ndelrels, bool isRedo)
src/backend/storage/smgr/md.c: XLogDropRelation(delrels[i], fork);
...
...
另外现在网上关于 PostgreSQL 内核分析的资料和文献一大堆,并且也有诸多优质内核书籍,加之完善的官方文档:
《PostgreSQL 数据库内核分析》 《PostgreSQL查询引擎源码技术探析》 《PostgreSQL技术内幕查询优化深度探索》 《PostgreSQL技术内幕事务处理深度探索》 《PostgreSQL内幕指南》 《PostgreSQL 14 internal》,笔者正在翻译成中文 阿里内核月报系列 PostgreSQL源码解读系列 ...
学会利用好轮子,站在前辈肩膀上,都可以大大方便我们查找跟踪代码。
另外一个就是积累的过程了,需要对 PostgreSQL 的代码树有一个大概的了解,每个文件做什么,每个模块负责什么,看多了之后也可以快速定位到具体某个文件。
比如你想看 CREATE/ALTER 命令位于哪个源文件中,那么熟悉的话,直接 src → backend → access,现在随着我看代码越来越多,很多时候我都可以直接找到对应的源文件。
都找不到怎么办?
但是如前文所述,就是有着很多 corner case,你找不到相关的文献和资料,这个时候需要自己去定位代码细节,按照传统手段 GDB + 断点的形式可能也不够用,因为 GDB 你需要提前知晓在何处打断点,那么你不知道代码流程,也没有报错该怎么办?
举个栗子,我们知道在 truncate 表的时候,底层文件还会留在那里,只是截断为零的占位符,最终在 checkpoint 的时候移除,那么我们该如何定位是 checkpoint 的哪个函数做了这个操作?
postgres=# create table test(id int);
CREATE TABLE
postgres=# insert into test values(generate_series(1,1000));
INSERT 0 1000
postgres=# select pg_relation_filepath('test');
pg_relation_filepath
----------------------
base/5/135312
(1 row)postgres=# checkpoint ;
CHECKPOINT
postgres=# \! ls -l /home/postgres/16data/base/5/135312
-rw------- 1 postgres postgres 40960 Jan 7 21:56 /home/postgres/16data/base/5/135312
postgres=# truncate table test;
TRUNCATE TABLE
postgres=# \! ls -l /home/postgres/16data/base/5/135312 ---文件还在,只是截断为了零
-rw------- 1 postgres postgres 0 Jan 7 21:56 /home/postgres/16data/base/5/135312
postgres=# checkpoint ;
CHECKPOINT
postgres=# \! ls -l /home/postgres/16data/base/5/135312
ls: cannot access /home/postgres/16data/base/5/135312: No such file or directory
这个时候就可以借助一些其他工具了,比如 DTRACE,官网也提供了一个样例:
#!/usr/sbin/dtrace -qs
postgresql$1:::transaction-start
{
@start["Start"] = count();
self->ts = timestamp;
}
postgresql$1:::transaction-abort
{
@abort["Abort"] = count();
}
postgresql$1:::transaction-commit
/self->ts/
{
@commit["Commit"] = count();
@time["Total time (ns)"] = sum(timestamp - self->ts);
self->ts=0;
}
在 Linux 的平替是 Systemtap (需要在编译的时候打开 --enable-dtrace)。此处用一个最简单粗暴的语法,将所有运行过的代码全部抓下来:
[postgres@xiongcc ~]$ cat checkpoint_debug.result | sort -rn | uniq -c | egrep -i 'remove|md|delete'
6 postgres: WalUsageAccumDiff
8 postgres: smgrGetPendingDeletes
8 postgres: smgrDoPendingDeletes
1 postgres: SlruScanDirCbDeleteCutoff
1 postgres: SlruMayDeleteSegment
10 postgres: ResourceOwnerDelete
922 postgres: ResourceArrayRemove
2 postgres: remove_timeout_index
1 postgres: RemoveProcFromArray
1 postgres: RemoveOldXlogFiles
165 postgres: RemoveLocalLock
19 postgres: proclist_delete_offset
1 postgres: ProcArrayRemove
43 postgres: pgstat_relation_delete_pending_cb
138 postgres: pgstat_entry_ref_hash_delete
46 postgres: pgstat_delete_pending_entry
9 postgres: pairingheap_remove_first
78 postgres: pairingheap_remove
2 postgres: MemoryContextDeleteChildren
19 postgres: MemoryContextDelete
6 postgres: mdwriteback
6 postgres: mdwrite
8 postgres: mdunlinkfork
1 postgres: mdunlinkfiletag
8 postgres: mdunlink
7 postgres: mdsyncfiletag
1 postgres: mdread
24 postgres: mdopenfork
45 postgres: mdopen
12 postgres: _mdnblocks
12 postgres: mdnblocks
1 postgres: mdinit
1 postgres: _mdfd_segpath
18 postgres: _mdfd_open_flags
13 postgres: _mdfd_getseg
6 postgres: mdexists
8 postgres: mdcreate
59 postgres: mdclose
2 postgres: KnownAssignedXidsRemoveTree
2 postgres: KnownAssignedXidsRemovePreceding
2 postgres: KnownAssignedXidsRemove
163 postgres: dlist_delete
26 postgres: Delete
2 postgres: construct_md_array
4 postgres: CheckXLogRemoved
6 postgres: CatCacheRemoveCTup
16 postgres: BufTableDelete
1 postgres: binaryheap_remove_first
19 postgres: AllocSetDelete
最后通过关键字,比如 delete|unlink|remove 和删除相关的关键字找到相应的函数。
可以看到上面 systemtap 一下就过滤除了关键函数:mdunlink,最终根据注释一下就确认了我们的猜想:
For regular relations, we don't unlink the first segment file of the rel, but just truncate it to zero length, and record a request to unlink it after the next checkpoint.
对于常规关系,我们不会解除对关系的第一个分段文件的链接,而只是将其截断为零长度,并在下一个 checkpoint 后记录解除链接的请求。
/*
* mdunlink() -- Unlink a relation.
*
* Note that we're passed a RelFileLocatorBackend --- by the time this is called,
* there won't be an SMgrRelation hashtable entry anymore.
*
* forknum can be a fork number to delete a specific fork, or InvalidForkNumber
* to delete all forks.
*
* For regular relations, we don't unlink the first segment file of the rel,
* but just truncate it to zero length, and record a request to unlink it after
* the next checkpoint. Additional segments can be unlinked immediately,
* however. Leaving the empty file in place prevents that relfilenumber
* from being reused. The scenario this protects us from is:
* 1. We delete a relation (and commit, and actually remove its file).
* 2. We create a new relation, which by chance gets the same relfilenumber as
* the just-deleted one (OIDs must've wrapped around for that to happen).
* 3. We crash before another checkpoint occurs.
* During replay, we would delete the file and then recreate it, which is fine
* if the contents of the file were repopulated by subsequent WAL entries.
* But if we didn't WAL-log insertions, but instead relied on fsyncing the
* file after populating it (as we do at wal_level=minimal), the contents of
* the file would be lost forever. By leaving the empty file until after the
* next checkpoint, we prevent reassignment of the relfilenumber until it's
* safe, because relfilenumber assignment skips over any existing file.
PostgreSQL 的代码说美如画可能有点过分吹了,但是其注释和 README 有一说一,的确十分详细,很多时候阅读注释即可:
关于 DTRCE 官网有详细的说明,可以参照:https://www.postgresql.org/docs/current/dynamic-trace.html,比如我们想知道
有索引和没有索引插入的性能差距 开启一个事务要耗时多久、 pending_list 对于 GIN 索引的影响何如 ...
等等,都可以使用 DTRCE 进行分析,这是我们要深入分析 PostgreSQL 性能问题必不可少的武器。
此处我大概写了一个,各位就可以进行观察了。
[postgres@xiongcc ~]$ cat postgres.stp
probe process("/usr/pgsql-16/bin/postgres").mark("transaction__start")
{
printf("Transaction for process %d start: \n",pid())
printf("%-25s: %s (%d) is execing\n",ctime(gettimeofday_s()), execname(), pid())
}probe process("/usr/pgsql-16/bin/postgres").mark("transaction__commit")
{
printf("Transaction for process %d commit: \n",pid())
printf("%-25s: %s (%d) is execing\n",ctime(gettimeofday_s()), execname(), pid())
printf("%s %#x\n", "transaction__commit", $arg1);
}
probe process("/usr/pgsql-16/bin/postgres").mark("query__start")
{
printf("%-25s: %s (%d) is execing\n",ctime(gettimeofday_s()), execname(), pid())
printf("%s %#x\n", "query_start", $arg1);
}
[postgres@xiongcc ~]$ stap -v -c /usr/pgsql-16/bin/postgres postgres.stp --runtime=dyninst
另外还有一个工具 GPROF (需要在编译的时候打开--enable-profiling),GPROF 本身并不是专门用来搜索代码的工具,主要是用于性能分析,但是其记录所有函数执行时间,这也可以缩减我们代码定位时间的消耗。
定位到了代码
定位到了具体代码之后,就是调试了,调试之前记得调整编译参数:enable-depend、enable-debug、enable-cassert、CFLAGS=-O0 。GDB 工具就不做过多介绍了,网上教程一堆,一些常见的命令如下:主要是主要父子进程开关 set follow-fork-mode
| 命令 | 缩写 | 说明 |
|---|---|---|
| set args | 设置主程序的参数 例如:./test 12 15 设置参数的方法是:gdb test (gdb) set args 12 15 | |
| break [file:][function|line] | b | 设置断点,b 20 表示在第20行设置断点,可以设置多个断点。 |
| run | r | 开始运行程序,程序运行到断点的位置会停下来,如果没有遇到断点,程序一直运行下去。 |
| next | n | 执行当前行语句,如果该语句为函数调用,不会进入函数内部执行。 |
| step | s | 执行当前行语句,如果该语句为函数调用,则进入函数执行其中的第一条语句。注意了,如果函数是库函数或第三方提供的函数,用s也是进不去的,因为没有源代码,如果是您自定义的函数,只要有源码就可以进去。 |
| p | 显示变量值,例如,p name表示显示变量name的值。 | |
| continue | c | 继续程序的运行,直到遇到下一个断点。 |
| set var name=value | 设置变量的值,假设程序有两个变量: int ii; char name[21]; set val ii=10 把ii的值设置为10; set var name="西施"把name的值设置为"西施",注意,不是strcpy。 | |
| quit | q | 退出gdb环境 |
| list | l | 显示源码 |
| Backtrace | bt | Backtrace: display the program stack. |
| set follow-fork-mode [parent|child] | 设置调试父进程或子进程,缺省为 parent | |
| set detach-on-fork [on|off] | 表示调试当前进程时,其它进程继续运行或挂起。缺省为on | |
| info inferiors | 查看调试的进程 |
推荐阅读《GDB 100个小技巧》
改代码
到了后面的大乘阶段,各位就可以尝试修改代码,添加指定功能了,这个是日积月累的过程,如果有速成法,请各位务必告诉我。一些必要的网站可以了解一下:
https://commitfest.postgresql.org
https://wiki.postgresql.org/wiki/Development_information
http://www.postgres.cn/document/dev_faq
postgresql.org/list/
pgsql-hackers
pgsql-general
pgsql-docs
pgsql-performance
pgsql-bugs
pgsql-novice
小结
调代码,看代码这是一个日积月累的过程,需要持续精进,笔者也是一步一步积累起来的。除了文中提到的,像 perf/trace/ebpf 这些工具都是值得去了解与学习的。
最后,祝各位都能从小工到专家,从 enthusiast 到 committer!
推荐阅读
Feel free to contact me
微信公众号:PostgreSQL学徒 Github:https://github.com/xiongcccc 微信:_xiongcc 知乎:xiongcc 墨天轮:https://www.modb.pro/u/39588