PostgreSQL学徒

如何调试分析PostgreSQL代码?

前言

随着 PostgreSQL 生态的不断发展,过往看似复杂的疑难杂症现在大都可以通过查阅资料得以解决,但是还是有着许许多多案例只能通过阅读代码,深入底层细节才能定位到具体原因,光是笔者本人就遇到了无数案例需要调试代码才能定位到根因:

  1. 活久见,不同用户不同执行计划
  2. 从一个案例聊聊我对社区的看法
  3. 深入浅出VACUUM内核原理(中): index by pass
  4. 深入浅出VACUUM内核原理(上)
  5. ...

那么作为 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 内核分析的资料和文献一大堆,并且也有诸多优质内核书籍,加之完善的官方文档:

  1. 《PostgreSQL 数据库内核分析》
  2. 《PostgreSQL查询引擎源码技术探析》
  3. 《PostgreSQL技术内幕查询优化深度探索》
  4. 《PostgreSQL技术内幕事务处理深度探索》
  5. 《PostgreSQL内幕指南》
  6. 《PostgreSQL 14 internal》,笔者正在翻译成中文
  7. 阿里内核月报系列
  8. PostgreSQL源码解读系列
  9. ...

学会利用好轮子,站在前辈肩膀上,都可以大大方便我们查找跟踪代码。

另外一个就是积累的过程了,需要对 PostgreSQL 的代码树有一个大概的了解,每个文件做什么,每个模块负责什么,看多了之后也可以快速定位到具体某个文件。

Image

比如你想看 CREATE/ALTER 命令位于哪个源文件中,那么熟悉的话,直接 src → backend → access,现在随着我看代码越来越多,很多时候我都可以直接找到对应的源文件。

Image

都找不到怎么办?

但是如前文所述,就是有着很多 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 有一说一,的确十分详细,很多时候阅读注释即可:

Image

Image

关于 DTRCE 官网有详细的说明,可以参照:https://www.postgresql.org/docs/current/dynamic-trace.html,比如我们想知道

  1. 有索引和没有索引插入的性能差距
  2. 开启一个事务要耗时多久、
  3. pending_list 对于 GIN 索引的影响何如
  4. ...

等等,都可以使用 DTRCE 进行分析,这是我们要深入分析 PostgreSQL 性能问题必不可少的武器。

Image

此处我大概写了一个,各位就可以进行观察了。

[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 本身并不是专门用来搜索代码的工具,主要是用于性能分析,但是其记录所有函数执行时间,这也可以缩减我们代码定位时间的消耗。

Image

定位到了代码

定位到了具体代码之后,就是调试了,调试之前记得调整编译参数: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行设置断点,可以设置多个断点。
runr开始运行程序,程序运行到断点的位置会停下来,如果没有遇到断点,程序一直运行下去。
nextn执行当前行语句,如果该语句为函数调用,不会进入函数内部执行。
steps执行当前行语句,如果该语句为函数调用,则进入函数执行其中的第一条语句。注意了,如果函数是库函数或第三方提供的函数,用s也是进不去的,因为没有源代码,如果是您自定义的函数,只要有源码就可以进去。
printp显示变量值,例如,p name表示显示变量name的值。
continuec继续程序的运行,直到遇到下一个断点。
set var name=value
设置变量的值,假设程序有两个变量: int ii; char name[21]; set val ii=10 把ii的值设置为10; set var name="西施"把name的值设置为"西施",注意,不是strcpy。
quitq退出gdb环境
listl显示源码
BacktracebtBacktrace: display the program stack.
set follow-fork-mode [parent|child]
设置调试父进程或子进程,缺省为 parent
set detach-on-fork [on|off]
表示调试当前进程时,其它进程继续运行或挂起。缺省为on
info inferiors
查看调试的进程

推荐阅读《GDB 100个小技巧》

Image

改代码

到了后面的大乘阶段,各位就可以尝试修改代码,添加指定功能了,这个是日积月累的过程,如果有速成法,请各位务必告诉我。一些必要的网站可以了解一下:

  1. https://commitfest.postgresql.org

  2. https://wiki.postgresql.org/wiki/Development_information

  3. http://www.postgres.cn/document/dev_faq

  4. postgresql.org/list/

  • pgsql-hackers

  • pgsql-general

  • pgsql-docs

  • pgsql-performance

  • pgsql-bugs

  • pgsql-novice

小结

调代码,看代码这是一个日积月累的过程,需要持续精进,笔者也是一步一步积累起来的。除了文中提到的,像 perf/trace/ebpf 这些工具都是值得去了解与学习的。

最后,祝各位都能从小工到专家,从 enthusiast 到 committer!

Image

推荐阅读

📙 使用DTRACE分析PostgreSQL
📙 使用ebpf分析PostgreSQL

Feel free to contact me 

  • 微信公众号:PostgreSQL学徒
  • Github:https://github.com/xiongcccc
  • 微信:_xiongcc
  • 知乎:xiongcc
  • 墨天轮:https://www.modb.pro/u/39588