从Greenplum一个WARN的排查浅析PostgreSQL MemoryContext内存管理

Greenplum(GP)是一款开源的MPP数据库,兼容PostgreSQL生态。我们尝试基于开源GP支持多个副本,改造让集群从初始的最多只支持一个standby Master,到支持多个standby。

相关实现并不复杂,内核和工具中没有太多对于standby个数的限制。经过多次的修改后,遗留的问题只剩下了一个:由一行代码引起的、但是找到这行代码花费了很久的WARN。本文就从这个WARN排查的角度,浅度分析下PostgreSQL 基于MemoryContext的内存管理。


到处出现的WARN


我们拉起一个多副本的集群,通过gpinitstandby来向集群添加standby,当我们添加1个standby,集群工作的很正常。而当我们添加的standby个数超过1个之后,集群就会打出一条WARN:

WARNING: problem in alloc set cdb components Context: detected write past chunk end in block 0x2e23c30, chunk 0x2e23d00 (aset.c:2027)

当时看到这个WARN之后第一反应是这应该是一个并不棘手的问题,毕竟连产生WARN的代码文件和行数都给了,大不了直接捋调用链就好了,总能找到原因,而且就算找不到,仅仅是一条WARN而已。

但是随着使用发现,这个WARN在集群正常使用的过程中会不停的打印在日志中,打开pg_log,满屏的WARN;另外,在用户使用过程中,它会多次出现在psql的输出中,甚至会干扰正常命令输入。甚至我们在代码中埋点,打印出会print这条WARN的进程id之后发现,包括bgworker、fts、postgres、postmaster在内的多个进程都会不停打印这条WARN。这就成了一个必须解决的问题。

从Greenplum一个WARN的排查浅析PostgreSQL MemoryContext内存管理

怎么解决呢,首先的思路当然还是查看报错信息、整理调用堆栈,去代码里看看到底是哪段代码搞出了WARN,又或者是gpinitstandby的哪段逻辑调用产生WARN的代码。直接找到打印WARN的代码并不难,aset.c 2027行。

/*
* AllocSetCheck
* Walk through chunks and check consistency of memory.
*
* NOTE: report errors as WARNING, *not* ERROR or FATAL. Otherwise you'll
* find yourself in an infinite loop when trouble occurs, because this
* routine will be entered again when elog cleanup tries to release memory!
*/
static void
AllocSetCheck(MemoryContext context)
{
... many code before ...

if (dsize > 0 && dsize < chsize &&
!sentinel_ok(chunk, ALLOC_CHUNKHDRSZ + dsize))
elog(WARNING, "problem in alloc set %s: detected write past chunk end in block %p, chunk %p (%s:%d)",
name, block, chunk, CDB_MCXT_WHERE(&set->header));

... many code after ...
}

原来,这是一个为MemoryContext做memory check的函数,出WARN那段代码的逻辑就是检查传入的MemoryContext,当如果发现了某段不应该被访问到的内存被修改了之后,就会打印出这条WARN,这是一条内存访问越界的WARN。

Ok,问题清晰了一点,增加副本超过1个之后,导致某段内存的访问超过初始所申请的内存段。所以现在核心的问题变成,定位导致WARN的内存访问越界发生的位置,以及思考两个问题:1)这个WARN为啥在不同的时间、不同的进程内到处出现;2)以及既然发生了内存访问越界,为啥没core。

访问基于MemoryContext的内存越界与普通的内存越界不太相同,它并不会在发生内存越界的位置直接停止、报错;在PostgreSQL中内存的申请、使用时没有输出,而是在对应MemoryContext释放或者事务完成时进行check的时候打出WARN,在下面的章节会详细介绍MemoryContext的这些特性,以及阐述怎么去定位内存访问越界的位置。

MemoryContext是啥

postgresql早期版本都是直接向操作系统申请和释放内存的,postgresql 7.1以后引入了MemoryContext对内存进行统一管理。MemoryContext一般被称为内存上下文,postgresql每个进程都会存在自己的MemoryContext来对自己所申请的内存进行追踪和管理。

从Greenplum一个WARN的排查浅析PostgreSQL MemoryContext内存管理

MemoryContext是一种树状的结构,TopMemoryContext是所有内存节点的根节点,当创建出新的内存上下文之后,会添加到现有的某个内存上下文的子节点,通过根节点可以遍历到为这个进程分配的所有的内存的使用状况。

在进程运行过程中,会有一个CurrentMemoryContext表示当前所操作的内存节点,操作一个新的内存上下文的时候,需要调用MemoryContextSwitchTo(context)。

从Greenplum一个WARN的排查浅析PostgreSQL MemoryContext内存管理

其中methods字段是一组函数指针,是对MemoryContext的一组操作函数,用来实现内存的申请,检查和回收。

从Greenplum一个WARN的排查浅析PostgreSQL MemoryContext内存管理

值得注意的是,MemoryContext是一个抽象类,methods里面定义的是虚函数,在postgresql中AllocSet是对MemoryContext的实现。在postgresql的内存使用中,很多情况下,会调用宏定义的palloc、pfree等函数,而不会调用原生的malloc。而palloc、pfree等自定义的内存管理函数,会通过methods函数指针,实际去调用aset.c里面相关内存分配和管理函数,拿palloc举个例子:

void *

palloc(Size size)

{

  /* duplicates MemoryContextAlloc to avoid increased overhead */
  void      *ret;

  AssertArg(MemoryContextIsValid(CurrentMemoryContext));

  if (!AllocSizeIsValid(size))

     elog(ERROR, "invalid memory alloc request size %zu", size);

  CurrentMemoryContext->isReset = false;

  ret = (*CurrentMemoryContext->methods.alloc) (CurrentMemoryContext, size);

  VALGRIND_MEMPOOL_ALLOC(CurrentMemoryContext, ret, size);

  return ret;

}

而我们打出WARN的函数是对MemoryContext进行check的实现。

下面我们简要介绍下PostgreSQL基于MemoryContext的内存申请算法,这部分逻辑并不复杂,面向单进程,没有太多并发控制的东西。而内存的realloc、free逻辑有相通之处,这里并不赘述。

在PostgreSQL中有内存块(AllocBlock)和内存片(AllocChunk)的概念,内存块AllocBlock由一个或多个内存片AllocChunk组成。在PostgreSQL中,往往是小块内存的申请和释放都很多。所以大块内存的申请实际上会执行调用malloc直接从操作系统分配,而小块的内存片则池化管理,通过freelist重复利用。为了进行内存对齐,对于每次内存分配,都会自动向上“取整”为2的幂次。

PostgreSQL内存申请流程如下:

1)如果申请的内存大小超过了当前内存上下文中最大内存限制(allocChunkLimit),就会调用malloc分配一块单独的内存块,只由一个内存片组成,返回给调用者结束调用;否则执行步骤2)。

2)根据所申请的内存大小,在freelist寻找有没有大小合适的内存片,如果能找到,则返回给调用者,否则执行步骤3)。

3)对当前内存上下文(MemoryContext)中的内存块链表blocks中的第一个内存块进行检查,如果它的未分配空间足够满足所申请内存,则从这里分配出一个内存片,返回给调用者,否则执行步骤4);

4)将当前blocks链表中的第一个内存块划分成多个内存片,加入到freelist中,然后申请一个新的内存块,加入到blocks链表首部,并从这个新内存块中分配一个内存片,返回给调用者。

 

问题的定位

目前我们想通过堆栈寻找产生问题的代码就比较费力了,这不是一个很明显去解决的问题,对于这个在多个进程不同调用栈导出出现的WARN,很难去追踪和分析每一个调用的地方。现在,我们再去观察这条WARN:

WARNING:  problem in alloc set cdb components Context: detected write past chunk end in block 0x2e23c30, chunk 0x2e23d00 (aset.c:2027)

通过阅读MemoryContext的代码我们发现,每个MemoryContext都会有自己独特的name,如我们上文所介绍,切换所使用的内存上下文需要调用MemoryContextSwitchTo(),所以我们可以通过查找名称为cdb components Context的MemoryContext从产生到被switch过程中对于内存的访问情况,找到这块产生问题的内存从申请到被释放过程中的所有使用情况了。

static CdbComponentDatabases *
getCdbComponentInfo(void)
{
    // many code before

    if (!CdbComponentsContext)
        CdbComponentsContext = AllocSetContextCreate(TopMemoryContext, "cdb components Context",
                                ALLOCSET_DEFAULT_MINSIZE,
                                ALLOCSET_DEFAULT_INITSIZE,
                                ALLOCSET_DEFAULT_MAXSIZE);

    oldContext = MemoryContextSwitchTo(CdbComponentsContext);
  
  // many code after
}

这个方向果然省力的多,根据比对我们发现cdb components Context这个MemoryContext是在getCdbComponentInfo中产生,在这个MemoryContext下的内存访问的代码主要位于cdbutil.c下面,所产生的数据结构CdbComponentDatabases主要传递给各个进程用于读取系统各个segment健康状况,在各个进程的初始化阶段以及FTS进程探测阶段使用很多。

所以我们只需要关注cdbutil.c、进程初始化(initPostgres)、fts(fts.c/fts_probe.c)相关代码中内存访问相关的部分,特别是可能与standby相关的代码,是否存在内存访问超出的情况。最终我们将问题定位于cdbutils中的getCdbComponentInfo,产生问题的代码段如下:

static CdbComponentDatabases *
getCdbComponentInfo(void)
{
    

    if (!CdbComponentsContext)
        CdbComponentsContext = AllocSetContextCreate(TopMemoryContext, "cdb components Context",
                                ALLOCSET_DEFAULT_MINSIZE,
                                ALLOCSET_DEFAULT_INITSIZE,
                                ALLOCSET_DEFAULT_MAXSIZE);

    oldContext = MemoryContextSwitchTo(CdbComponentsContext);

    ...

    component_databases->segment_db_info =
        (CdbComponentDatabaseInfo *) palloc0(sizeof(CdbComponentDatabaseInfo) * total_dbs);

    /* @adbpg multi-replca begin */
    /* we allow more than one standbys */
    component_databases->entry_db_info =
        (CdbComponentDatabaseInfo *) palloc0(sizeof(CdbComponentDatabaseInfo) * 2);
    /* @adbpg multi-replca end */

    for (i = 0; i < total_dbs; i++)
    {
  ......
        /*
         * Determine which array to place this rows data in: entry or segment,
         * based on the content field.
         */
        if (config->segindex >= 0)
        {
            pRow = &component_databases->segment_db_info[component_databases->total_segment_dbs];
            component_databases->total_segment_dbs++;
        }
        else
        {
            pRow = &component_databases->entry_db_info[component_databases->total_entry_dbs];
            component_databases->total_entry_dbs++;
        }
    pRow->cdbs = component_databases;
        pRow->config = config;
        pRow->freelist = NIL;
        pRow->numIdleQEs = 0;
        pRow->numActiveQEs = 0;
  }
  ......
}

我们追踪entry_db_info这个变量相关的内存申请和修改可以看出:

component_databases->entry_db_info =

 (CdbComponentDatabaseInfo *) palloc0(sizeof(CdbComponentDatabaseInfo) * 2);

这个变量是为了存储QD相关的元信息,但是他最初为两副本设计,最大只申请了两副本的空间;

但是在我们允许系统表容纳多个standby以后,通过component_databases->total_entry_dbs变量修改相关的内存时,却修改了超过两副本的内存。造成了我们最开始的那个WARN:

WARNING:  problem in alloc set cdb components Context: detected write past chunk end in block 0x2e23c30, chunk 0x2e23d00 (aset.c:2027)

也就是我们为了某个结构申请了某个chunk的内存,但是最终却使用了超过了所申请的内存。

果然,我们在内存申请的部分加大允许的最大standby个数,这个WARN就消失了。

 

为啥到处出现

现在内存访问越界问题已经被定位和解决了,但是还有一些外在表现值得思考,比如:这个WARN为啥在不同的时间、不同的进程内到处出现。

这个疑问其实在问题排查初期给笔者造成了很大的困惑,如上文的介绍,PostgreSQL基于MemoryContext的内存管理机制,其实是一种单机内存管理机制,各个进程的MemoryContext独立地管理自己进程的内存,并不涉及并发内存管理。所以当时很困惑,为什么会存在一个action发生后,多个进程内同时打出访问越界的WARN。

而最后的答案也很直接,这些进程就真的每一个都会调用 getCdbComponentInfo 获取集群状态,也就在standby个数超过1个之后发生访问越界;我们看一下gdb的堆栈:

#0  0x0000000000000000 in ?? ()

#1  0x0000000000b37e6a in getCdbComponentInfo () at cdbutil.c:373

#2  0x0000000000b38865 in cdbcomponent_getCdbComponents () at cdbutil.c:710

#3  0x0000000000b3a535 in getgpsegmentCount () at cdbutil.c:1801

#4  0x0000000000ad8e65 in initConnHashTable (cxt=<optimized out>, ht=0x11135e0 <ic_control_info+160>) at ic_udpifc.c:1564

#5  InitMotionUDPIFC (listenerSocketFd=listenerSocketFd@entry=0x12a7b30 <UDP_listenerFd>, listenerPort=listenerPort@entry=0x7ffd562cb11e) at ic_udpifc.c:1425

#6  0x0000000000ac793f in InitMotionLayerIPC () at ic_common.c:242

#7  0x0000000000b391cd in cdb_setup () at cdbutil.c:1034

#8  0x0000000000a4bde2 in InitPostgres (in_dbname=in_dbname@entry=0xb84241 "postgres", dboid=dboid@entry=0, username=username@entry=0x0, out_dbname=out_dbname@entry=0x0)

   at postinit.c:1238

#9  0x0000000000834eea in BackgroundWorkerInitializeConnection (dbname=dbname@entry=0xb84241 "postgres", username=username@entry=0x0) at postmaster.c:6188

#10 0x0000000000b3d899 in DtxRecoveryMain (main_arg=<optimized out>) at cdbdtxrecovery.c:640

#11 0x00000000008241da in StartBackgroundWorker () at bgworker.c:753

#12 0x000000000083078e in do_start_bgworker (rw=0x1386340) at postmaster.c:6308

#13 maybe_start_bgworker () at postmaster.c:6548

#14 0x000000000083476f in reaper (postgres_signal_arg=<optimized out>) at postmaster.c:3309

#15 <signal handler called>

#16 0x00007fc57fa4dfc3 in __select_nocancel () from /lib64/libc.so.6

#17 0x000000000049bf5c in ServerLoop () at postmaster.c:1902

#18 0x0000000000836337 in PostmasterMain (argc=argc@entry=6, argv=argv@entry=0x135d1d0) at postmaster.c:1528

#19 0x00000000004a09c7 in main (argc=6, argv=0x135d1d0) at main.c:245

发生内存访问越界的函数是进程初始化函数InitPostgres的一部分,所以这个内存越界问题在各个进程间发生的十分普遍。而每次一个事务结束时,都会调用MemoryContextCheck对内存进行检查,系统表、用户表的访问都可能产生和结束事务,这就导致了这条WARN会不间断地打印出来。

 

为啥没core

WARN产生的原因我们搞清楚了,但是同时又引入了另外一个问题,那就是:内存访问越界了,为啥没有core掉?

这个问题其实上面也给出答案了,就是不管是池化管理的小块内存,还是直接malloc申请的大块内存。PostgreSQL在真正分配内存前,都会对申请的内存进行向上"取整",取整为2的幂次,然后返回。

这也就代表,返回给用户的内存,往往在真是需要内存的后面会留有一些buffer,所以发生内存越界之后,如果对于内存的访问在这些buffer的范畴内,就不会出现操作系统级别的访问越界,造成终止报错。

 

总结

内存管理算法在计算机学科中是一个存在比较悠久的问题了,KMA类算法(Kernel Memory Allocator)经过几十年的演化已经比较成熟,以Buddy、Slab为代表的KMA算法到目前为止变化和扩展已经比较少了。而UMA类算法则一直在发展中,除了编程语言提供的UMA算法(C语言的alloc,free, Java的gc算法)之外,大型软件也会开发自己的UMA算法来做内存管理。

在笔者看来,这样做有几个好处:一是可以方便的做内存的统一管理,内存对齐,统一摆放、缓存与回收、并发控制等等等,提高内存的申请和访问的效率;二是防止可能出现的内存访问问题,比如常见的内存泄漏、指针空悬,访问越界等等。

PostgreSQL中也实现了自己的内存分配算法,逻辑相对简单,每个进程的MemoryContext只维护自己进程的内存,不处理并发的内存管理,主要是对内存进行统一的对齐和管理。在PostgreSQL的内存管理中,会将内存的申请向上转换为2的幂次以进行内存对齐,以内存页面为单位进行分配,对于大块的内存申请调用malloc,对于小的内存片则进行池化,通过freelist进行统一管理。

PostgreSQL使用MemoryContext对多进程所使用的内存进行统一的管理,能够减少内存碎片和内存泄漏的产生,但是客观上也加大了发生内存访问越界等典型问题时进行排查的难度。如果真的发生类似问题,每个MemoryContext有自己的命名,根据MemoryContext name追溯内存上下文产生和切换的流程可以有效溯源发生访问越界的代码调用位置。

 

上一篇:基于AngularJS的企业软件前端架构[转载]


下一篇:关于sharedPreferences的使用