拿Greenplum源码当课外书啃的时候,我第一个卡住的概念就是BackgroundWorker。这个东西在PostgreSQL里就有,Greenplum作为基于PostgreSQL的并行数据库,把它继承过来之后又改了不少东西。从表面上看,它就是一套“让数据库在后台启动若干辅助进程”的通用机制,但真往上追的时候会发现它牵涉到进程模型、信号处理、共享内存、分布式事务,以及查询执行时的多个环节。这篇笔记会带着你从“这东西到底干嘛的”开始,一路拆到源码里的注册、启动、销毁流程,再手写一个最小的BackgroundWorker跑起来。适合正在读Greenplum或PostgreSQL源码、想搞清楚后台进程机制的人;就算目前只是DBA,了解这块对你排查诡异的后台进程问题也很有帮助。1. 先搞懂BackgroundWorker到底是个什么机制1.1 数据库里为什么需要“临时工”进程数据库主进程在启动之后,会fork出一堆完成特定工作的子进程。最常见的是backend,也就是一个客户端连接对应一个进程,负责处理SQL查询。但随着功能越来越复杂,很多活儿并不适合塞在客户端连接的上下文里干。举个例子:数据库需要定期做清理工作,或者要检测分布式环境中的死锁,或者某个查询执行过程中需要并行地多开几个辅助进程来帮助计算。这些任务的特点是“没有客户端直接在等它”,如果把它们交给某个backend去轮询执行,那这个backend一旦退出或断开连接,任务就跟着失败了。更合理的做法是有一个统一的进程注册和生命周期管理框架,让数据库自身能在需要的时候拉起一个独立进程,干完活再让它退出。这个框架在PostgreSQL里就叫BackgroundWorker。这个设计思路其实很像公司里的“临时工”机制:正式员工(backend)每个人有自己明确的岗位,临时工(BackgroundWorker)则是按需招进来的,干完了就解散,不影响正式员工的编制。Greenplum和PostgreSQL的系统结构还不一样,Greenplum有一个coordinator(或者叫master)和多个segment节点。master负责接收客户端SQL、生成计划、分发执行;segment节点各自持有本地数据,负责执行局部任务。一个后台任务可能需要在master上启动,也可能需要在每个segment上启动,甚至同一个任务的多个worker还要能互相通信。所以Greenplum对BackgroundWorker框架的依赖,比普通PostgreSQL更重。1.2 从PostgreSQL到Greenplum:框架的继承与改造Greenplum社区版本质上是“PostgreSQL的并行化改造版本”。它继承了PG的postmaster进程管理机制,也就顺理成章地继承了BackgroundWorker框架。但Greenplum并不是原样照搬。它在这个框架之上做了好几层改造。第一层是进程类型的扩展。PG里的大多数后台进程本来都是每个实例一套的,比如checkpointer、bgwriter、walwriter这些。但Greenplum有分布式概念,一些全局性的后台操作需要在master和所有segment上同步。比如全局死锁检测,它要排查多个segment之间跨节点的锁等待关系,这类任务如果在每个segment上各跑各的,信息根本凑不全,需要一个在master上统一协调的worker,还要和各个segment上的状态收集过程配合。第二层是动态注册机制的利用。Greenplum的并行执行不再像早期版本那样只靠固定数量的segment进程,而是会根据查询计划动态申请worker。查询执行过程中,通过RegisterDynamicBackgroundWorker这个入口来拉起临时进程的场景很常见。这样做的好处是资源利用率更灵活,不需要每个查询都预分配一批进程。第三层是配置和管理的接入。Greenplum里对后台进程数量有限制,max_worker_processes这个参数直接决定了实例范围内能启动多少个BackgroundWorker。在Greenplum集群里,这个参数需要在master和segment上都配置一致或者根据角色配置不同值,否则可能在执行时机上出现某类worker数量不足的报错。理解这三层,再看源码就不会觉得乱。你看到的很多带“worker”“prober”“detector”字样的后台进程,几乎都可以归到这个框架下。2. 源码入门:读懂BGWorker的三张关键图纸2.1 BackgroundWorker结构体:一个进程的“身份证”在源码里,核心数据结构是BackgroundWorker结构体。这个名字在头文件和实现文件里出现频率极高,先从它的字段入手是最快的。以PG源码主线上的结构体为参考,它大约包含下面这些字段:字段作用备注bgw_nameworker名称,用于日志和进程标识会出现在ps输出和日志里,起名要能让人一眼看懂bgw_type用于进程类型分类,类似标签比如“logical replication launcher”bgw_mainworker入口函数指针fork完成后,进程就从这个函数开始执行bgw_main_arg传给入口函数的参数可以是整数ID,也可以是指针(指针时通常配合动态共享内存)bgw_flags标志位控制是否能访问共享内存、是否能连接数据库等bgw_start_time启动时机有的worker要在系统启动阶段就跑起来,有的要等standbybgw_restart_time崩溃后重启间隔设为-1表示不自动重启,设为0表示立即重启bgw_sighupSIGHUP处理函数postmaster reload配置时会调用bgw_sigtermSIGTERM处理函数请求关闭worker时调用bgw_notify_pid通知后端进程PID某个前台进程在等待这个worker启动结果时用有一个特别容易理解不到位的地方是bgw_flags。如果你写的worker需要访问共享内存,就需要在flags里带上BGWORKER_SHMEM_ACCESS;如果这个worker还需要连接数据库、启动事务、执行SQL,那就要加上BGWORKER_BACKEND_DATABASE_CONNECTION。而如果不要数据库连接,那它就是一个纯后台计算进程,能访问共享内存但不能做常规SQL操作。很多刚写扩展的人忘了设置连接数据库的权限,结果在worker执行SQL时报那些“cannot start a transaction”之类的错,追半天发现是flags的问题。2.2 从注册到启动:系统如何把worker拉起来注册入口主要有两个:RegisterBackgroundWorker和RegisterDynamicBackgroundWorker。前者是静态注册,使用场景比较固定。典型路径是扩展库通过shared_preload_libraries在启动阶段被加载,加载时postmaster会执行库里的_PG_init函数,这个函数内部构造一个BackgroundWorker结构体填好字段,然后调用RegisterBackgroundWorker。这批worker会被记录在postmaster内部的全局列表中,之后postmaster按照启动流程把一个个worker fork出来。后者是动态注册,使用场景更灵活。某个backend在运行过程中,例如执行并行查询时,需要额外辅助进程,就可以调用RegisterDynamicBackgroundWorker。这个函数会返回一个BackgroundWorkerHandle,命途比较曲折,它可能被用来和worker通信、拿到worker的启动状态等。动态worker同样要由postmaster实际fork,但是注册时机不同,postmaster每次处理完主循环后,会扫描动态注册请求,并且尽可能早地把进程拉起来。为什么要绕这么大一圈去走postmaster,而不是后端进程自己fork子进程?很简单,多个backend同时fork会带来竞争,而且子进程需要继承正确的进程状态,只有postmaster统一调度,才能保证进程初始化顺序和抢占共享内存这些逻辑是安全的。你可以理解成所有临时工的入职流程都要经过HR的统一审批,部门主管虽然可以提需求,但不能绕过HR直接招人。启动过程中的一个关键点是StartWorker系列函数。它会从请求队列里取出注册信息,做资源检查,比如判断工作进程数是否已经达到max_worker_processes上限,然后fork,调用bgw_main指定的入口函数。入口函数执行后的第一件事通常是调用BackgroundWorkerUnblockSignals来解除信号阻塞,否则之前收到的一些信号会被丢弃,后续的信号处理函数可能不生效。2.3 进程收尾:退出、信号与崩溃恢复BGWorker的正常退出有两种方式。一种是入口函数执行完,进程最后走到proc_exit(0);另一种是收到SIGTERM后,由bgw_sigterm处理函数做清理,再走到退出流程。关键点是崩溃处理。如果worker因为段错误或者断言失败崩溃,postmaster并不会直接重启整个数据库,而是把它的退出状态记录下来,然后按照bgw_restart_time决定要不要重新拉起。这个机制保证了某些后台任务崩溃后还能自愈,代价是如果入口函数本身有bug,比如每次都固定崩溃,那postmaster就会陷入反复重启该worker的状态。日志里会看到worker failed之类提示。这里有一个实操心得:调试BGWorker问题的时候,第一件事就是看postmaster日志,第二件事是看进程是否还在循环重启。如果发现某个worker每隔几秒重启一次,基本可以断定是这个worker的main函数在启动阶段就崩了,这时候直接把日志级别调到debug1以上,或者用gdb去附上刚fork出来的进程,往往一抓一个准。3. Greenplum里哪些核心功能在悄悄用BGWorker3.1 并行执行场景中的动态workerGreenplum的并行执行体系里,最底层的数据节点是segment进程。但是你如果直接去代码里搜索RegisterDynamicBackgroundWorker的调用点,会发现不少位置都和“并行查询时启用的辅助进程”有关。典型场景是把一部分计算任务从原来固定的segment进程里拆出来,用动态BGWorker去执行局部计划片段。这种设计跟我们搭服务时临时扩容的思路很像。平时流量不大,不至于给每个查询都预分配一堆进程;但遇到大查询需要并发时,就在执行过程中动态申请一批worker,跑完直接回收。注意这里的动态worker并不一定在每个流程里都执行完整的SQL,有些只是帮忙做数据预聚合、外部表扫描,或者处理某些不能塞进常规executor路径的IO任务。如果你在ps里看到一个进程名字类似bgworker后面跟着一串plan信息,那基本就是这类动态worker。排查时如果发现并行查询变慢,可以看看这些动态worker是否启动缓慢,多半是max_worker_processes设得太小,或者注册的worker请求在postmaster队列中阻塞了。3.2 分布式事务与后台清理任务Greenplum的分布式事务使用两阶段提交来保证跨segment的一致性。分布式事务在协调点(master)上记录状态,在多个segment上逐节点提交。假如某一刻某个节点宕机或者网络中断,有些prepared事务就会悬在中间状态,占着锁和空间,不及时清理会导致后续事务阻塞。后台清理任务就是典型的BackgroundWorker用例。Greenplum会通过注册的后台worker定期扫描分布式事务状态表,对超时的prepared事务发起回滚或重试提交。这类worker通常很早就在_PG_init里静态注册了,启动后常驻系统。另外一个例子是全局死锁检测。单机数据库死锁检测可以简单扫描本地锁表,但Greenplum的死锁可能发生在多节点之间:节点A上的事务等节点B的资源,节点B上的事务又等节点C的资源,节点C等节点A。单个segment只能看到自己的那部分锁信息,无法做出全局判断。所以Greenplum在master上跑了一个专门的BGWorker,周期性地收集各个segment的锁等待信息,构建全局等待图,再判断是否存在死锁,如果存在就选一个牺牲者回滚。这类全局协调型的worker,单看某个segment上发生的事是理解不了的。你需要在master日志里观察它的调度周期,再结合segment上报的状态才能看到全貌。3.3 我的源码阅读路线图:别从上往下硬啃很多人拿Greenplum源码不知道从哪开始看。我的建议是不要从头到尾按目录顺序看,而是反向追踪注册点。第一步,全局搜索注册入口。直接在源码根目录跑一下:grep -rn "RegisterBackgroundWorker" --include="*.c" . grep -rn "RegisterDynamicBackgroundWorker" --include="*.c" .搜到的每一个调用点,就是一个后台功能模块的入口。比如搜到fault_prober之类名字的调用点,顺着它找到对应的结构体初始化,再看bgw_main指向的函数,一条线索就串起来了。第二步,挑一个简单的worker,从bgw_main指向的函数开始读。只要把这个函数从头读一遍,基本就知道这个worker平时在干啥。至于它调用到哪些辅助函数,再逐个往下追,这样你不会迷失。第三步,等读过三四个worker之后,再回头读postmaster的启动调度代码。这时候你已经知道worker模块想要什么,再去看框架怎么满足它们,就很容易理解。这个方法比直接一头扎进postmaster那一大坨主循环里效率高得多。我的习惯是读完一个worker,就在笔记里记录它的注册名、入口函数、主要职责和大概周期。积累几周以后,整个后台服务体系会形成一张完整的画像。4. 手写第一个自定义BackgroundWorker(实操)4.1 最小worker实现:注册、入口与日志输出光看不动手,印象不深。这里我给出一个最小可运行的示例。它做的事情很简单:启动之后每5秒往日志里打一行心跳,收到SIGTERM就退出。你需要准备一个C文件,比如就叫demo_worker.c,核心代码大致如下:#include "postgres.h" #include "miscadmin.h" #include "storage/latch.h" #include "storage/proc.h" #include "storage/shmem.h" #include "utils/guc.h" #include "utils/ps_status.h" #include "fmgr.h" PG_MODULE_MAGIC; volatile sig_atomic_t got_sigterm = false; void _PG_init(void); static void demo_worker_sigterm(SIGNAL_ARGS) { got_sigterm = true; } static void demo_worker_main(Datum main_arg) { int heartbeat = 0; /* 重要:先解除信号阻塞,否则后续信号处理不会生效 */ BackgroundWorkerUnblockSignals(); /* 让进程在ps输出里有个可识别的名字 */ set_ps_display("demo_worker", false); /* 处理SIGTERM,让postmaster能优雅地关掉我们 */ pqsignal(SIGTERM, demo_worker_sigterm); for (;;) { if (got_sigterm) break; heartbeat++; elog(LOG, "demo_worker heartbeat %d", heartbeat); /* 等待5秒;同时检查latch和postmaster死亡 */ WaitLatch(MyLatch, WL_LATCH_SET | WL_TIMEOUT | WL_POSTMASTER_DEATH, 5000L, PG_WAIT_EXTENSION); ResetLatch(MyLatch); } proc_exit(0); } void _PG_init(void) { BackgroundWorker worker; MemSet(&worker, 0, sizeof(worker)); strcpy(worker.bgw_name, "demo_worker"); strcpy(worker.bgw_type, "demo"); worker.bgw_main = demo_worker_main; worker.bgw_main_arg = (Datum) 0; worker.bgw_flags = BGWORKER_SHMEM_ACCESS; worker.bgw_start_time = BgWorkerStart_ConsistentState; worker.bgw_restart_time = 5; worker.bgw_sighup = NULL; worker.bgw_sigterm = demo_worker_sigterm; worker.bgw_notify_pid = 0; RegisterBackgroundWorker(&worker); }有几点要解释一下。BgWorkerStart_ConsistentState表示等到数据库启动到一致性状态之后才启动这个worker;bgw_restart_time = 5表示如果worker意外崩溃,5秒后自动重启,这里特意设为5秒,方便观察自动重启行为。set_ps_display是为了让ps输出里能看到一个叫demo_worker的进程,这个在日志排查时太有用了。SIGNAL_ARGS这个宏在PG的不同版本里可能长得不太一样,以你当前版本的头文件里定义为准,或者直接用带POSTGRES_PGR那种平台定义的方式。重点不是签名完全保持一致,而是理解它要设置一个标志位,让主循环里的判断能生效。4.2 与前端连接共享内存:计数器小Demo只打日志太单调。我再扩展一下:让worker往共享内存里维护一个计数器,前端连接可以通过一个自定义SQL函数把计数器读出来。这样你能同时看到BGWorker的共享内存分配和自定义函数两条路径。先定义共享内存结构体:typedef struct DemoWorkerShared { int counter; slock_t lock; } DemoWorkerShared; static DemoWorkerShared *demo_shared;然后是共享内存申请和初始化。使用RequestAddinShmemSpace请求额外的共享内存空间,再用ShmemInitStruct去初始化。这个逻辑要放在_PG_init和shmem_startup_hook里配合完成:static shmem_startup_hook_type prev_shmem_startup_hook = NULL; static void demo_shmem_startup(void) { bool found; if (prev_shmem_startup_hook) prev_shmem_startup_hook(); demo_shared = ShmemInitStruct("demo_worker_shared", sizeof(DemoWorkerShared), &found); if (!found) { demo_shared->counter = 0; SpinLockInit(&demo_shared->lock); } }在_PG_init里加上:RequestAddinShmemSpace(sizeof(DemoWorkerShared)); prev_shmem_startup_hook = shmem_startup_hook; shmem_startup_hook = demo_shmem_startup;worker主循环里不再只打日志,而是每轮递增计数器。注意因为可能有多个进程同时访问,操作计数时需要加锁:SpinLockAcquire(&demo_shared->lock); demo_shared->counter++; SpinLockRelease(&demo_shared->lock);前端要读取这个计数器,可以写一个简单的C函数,在函数里访问demo_shared->counter并通过PG_RETURN_INT32返回。这样你在psql里执行select demo_worker_read_counter();就能看到实时数值。这里要特别记住:RequestAddinShmemSpace必须在_PG_init里调用,而且这个调用发生在postmaster进程环境里。如果你把它写在worker的入口函数中,系统会直接报共享内存相关错误。这个坑我见过不止一次。4.3 编译、部署、验证全流程编译命令大致如下:gcc -O2 -fPIC -shared \ -I/path/to/gpdb/src/include \ -I/path/to/gpdb/src/backend \ -o demo_worker.so demo_worker.c如果在单机PostgreSQL上测试,就把include路径换成PostgreSQL的源码include目录。部署时有几个步骤容易踩坑。第一步是把demo_worker.so拷贝到数据库的lib目录下,比如$GPHOME/lib。第二步是修改postgresql.conf或greenplum.conf:shared_preload_libraries = 'demo_worker' max_worker_processes = 20Greenplum集群里,shared_preload_libraries要用gpconfig同步到master和所有segment;手动只改某一个节点会导致集群配置不一致。第三步是重启或者重载配置。如果只是注册静态worker,必须重启数据库进程,shared_preload_libraries这个参数并不是所有情况下都能热加载的。第四步是验证:ps -ef | grep demo_worker或者查看数据库日志,应该能看到类似demo_worker heartbeat 1这样的输出。如果你同时在跑Greenplum集群,建议先在PostgreSQL单实例上把逻辑调通,再往Greenplum上搬。Greenplum里还要注意不同segment上都有worker同时启动,日志输出量会翻倍,尽量设置log_min_messages = info就能看到。5. 调试血泪史:常见问题与排查技巧实录5.1 进程没起来?先查这四处后台worker没起来是最常见的现象。我整理了一个快速排查表,按优先级排列。现象大概率原因处理方向ps里完全没有这个worker扩展库加载失败,或者_PG_init没执行先看postmaster日志,确认shared_preload_libraries里的库是否被加载;查看日志中是否有could not access file之类的报错postmaster日志里有“too many background worker”max_worker_processes太小调大这个参数,重启后重试worker起来之后反复退出入口函数崩了,或bgw_sighup/bgw_sigterm处理函数有问题把日志级别调低,看崩溃堆栈worker能启动但是看不到SQL操作结果flags缺少BGWORKER_BACKEND_DATABASE_CONNECTION检查bgw_flags是否带了连接数据库的权限位第一处要看的就是postmaster日志,绝大多数启动阶段的问题都会在这里留下痕迹。如果日志里一行相关内容都没有,那基本可以确认库还没加载到。可以用下面这条SQL验证库是否已经加载:SHOW shared_preload_libraries;第二处要检查的是_PG_init是否真的被调用了。一个很反直觉的情况是:如果你同时配了多个扩展库,其中某个库在_PG_init里抛了错误,后面库的初始化可能会中断。排查时把shared_preload_libraries临时清掉,只留demo_worker再测,往往很快就能定位。第三处是权限问题。demo_worker.so的文件权限要确保postgres系统用户可读可执行,否则动态加载过程会失败。5.2 崩溃定位:从日志到core文件的路径BGWorker和普通backend一样,崩溃后可能生成core文件。但Linux默认的core生成策略经常会让core直接消失,或者写到奇怪的地方。建议在测试机上先配置好:ulimit -c unlimited echo '/tmp/core.%p' > /proc/sys/kernel/core_pattern这里的%p是进程PID,方便你拿到崩溃进程对应的core文件。在Greenplum多实例环境里,每个节点的core文件落在各自节点上,需要去对应主机的/tmp目录找。拿到core文件之后,用gdb加载数据库二进制:gdb /path/to/postgres /tmp/core.12345进入gdb后执行:thread apply all bt把每个线程的堆栈打出来。因为BGWorker通常是单进程单线程,所以直接bt也能看。从堆栈里定位到崩溃函数,再回到源码里对照变量值,通常很快能发现问题。我自己的经验是,如果是worker启动一两秒内就崩,直接gdb跟踪更高效:gdb -p $(pgrep -f demo_worker)但要注意,这个操作会暂停进程,如果进程本身已经在自动重启循环里,你会发现刚attach完进程下一刻就没了。所以更稳妥的做法是先把bgw_restart_time设大,比如设成60秒,给调试留够时间。5.3 调试经验:如何安全地在测试集群里做实验Greenplum集群不是一个理想的调试环境,因为master、segment多个postmaster同时运行,日志分散在各节点,配置同步也需要额外操作。我个人的建议是分三步走:第一步,在本地用PostgreSQL单实例调试自定义worker逻辑。BGWorker框架本身在GP和PG里差别没那么大,大部分代码可以直接复现。第二步,如果必须要在Greenplum环境里验证,尽量先从单个segment节点开始。手动修改这个segment的配置,单独重启这个segment的postmaster,观察它上面的worker行为。等单节点没有问题,再通过gpconfig同步到全集群。第三步,做实验前把log_min_messages调到debug1,并设置一个独立的日志文件位置。这样崩溃信息不会和其他节点日志混杂,定位问题的难度会直线下降。还有一个非常实用的习惯:自定义worker的名称一定要起得足够特别。比如用demo_worker这种名字,而不要叫helper之类的泛化名称。因为Greenplum集群里同一时间可能有几十个后台进程,名字太普通的话你用ps grep根本分不清哪个是你的worker。最后再分享一点个人体会。每次读这种庞大数据库源码里的某个框架,我都习惯先用“算命式追踪法”——就是先不管全局,只沿着注册入口找到worker的main函数,把main函数看懂,再去回头理解框架调度。BackgroundWorker框架尤其适合这种读法,因为每个worker的边界很清晰:它有一个明确的入口函数、一组信号处理函数和一个生命周期。把三五个worker读透之后,你会突然觉得后台进程不再是一群黑盒,而是一批有名字、有职责、有退出策略的“正式员工”,系统设计也就没那么神秘了。后面如果你再往里走,去读共享内存、锁接口或者分布式事务,都会有更踏实的底座。
拿Greenplum源码当课外书啃的时候,我第一个卡住的概念就是BackgroundWorker。这个东西在PostgreSQL里就有,Greenplum作为基于PostgreSQL的并行数据库,把它继承过来之后又改了不少东西。从表面上看,它就是一套“让数据库在后台启动若干辅助进程”的通用机制,但真往上追的时候会发现它牵涉到进程模型、信号处理、共享内存、分布式事务,以及查询执行时的多个环节。这篇笔记会带着你从“这东西到底干嘛的”开始,一路拆到源码里的注册、启动、销毁流程,再手写一个最小的BackgroundWorker跑起来。适合正在读Greenplum或PostgreSQL源码、想搞清楚后台进程机制的人;就算目前只是DBA,了解这块对你排查诡异的后台进程问题也很有帮助。1. 先搞懂BackgroundWorker到底是个什么机制1.1 数据库里为什么需要“临时工”进程数据库主进程在启动之后,会fork出一堆完成特定工作的子进程。最常见的是backend,也就是一个客户端连接对应一个进程,负责处理SQL查询。但随着功能越来越复杂,很多活儿并不适合塞在客户端连接的上下文里干。举个例子:数据库需要定期做清理工作,或者要检测分布式环境中的死锁,或者某个查询执行过程中需要并行地多开几个辅助进程来帮助计算。这些任务的特点是“没有客户端直接在等它”,如果把它们交给某个backend去轮询执行,那这个backend一旦退出或断开连接,任务就跟着失败了。更合理的做法是有一个统一的进程注册和生命周期管理框架,让数据库自身能在需要的时候拉起一个独立进程,干完活再让它退出。这个框架在PostgreSQL里就叫BackgroundWorker。这个设计思路其实很像公司里的“临时工”机制:正式员工(backend)每个人有自己明确的岗位,临时工(BackgroundWorker)则是按需招进来的,干完了就解散,不影响正式员工的编制。Greenplum和PostgreSQL的系统结构还不一样,Greenplum有一个coordinator(或者叫master)和多个segment节点。master负责接收客户端SQL、生成计划、分发执行;segment节点各自持有本地数据,负责执行局部任务。一个后台任务可能需要在master上启动,也可能需要在每个segment上启动,甚至同一个任务的多个worker还要能互相通信。所以Greenplum对BackgroundWorker框架的依赖,比普通PostgreSQL更重。1.2 从PostgreSQL到Greenplum:框架的继承与改造Greenplum社区版本质上是“PostgreSQL的并行化改造版本”。它继承了PG的postmaster进程管理机制,也就顺理成章地继承了BackgroundWorker框架。但Greenplum并不是原样照搬。它在这个框架之上做了好几层改造。第一层是进程类型的扩展。PG里的大多数后台进程本来都是每个实例一套的,比如checkpointer、bgwriter、walwriter这些。但Greenplum有分布式概念,一些全局性的后台操作需要在master和所有segment上同步。比如全局死锁检测,它要排查多个segment之间跨节点的锁等待关系,这类任务如果在每个segment上各跑各的,信息根本凑不全,需要一个在master上统一协调的worker,还要和各个segment上的状态收集过程配合。第二层是动态注册机制的利用。Greenplum的并行执行不再像早期版本那样只靠固定数量的segment进程,而是会根据查询计划动态申请worker。查询执行过程中,通过RegisterDynamicBackgroundWorker这个入口来拉起临时进程的场景很常见。这样做的好处是资源利用率更灵活,不需要每个查询都预分配一批进程。第三层是配置和管理的接入。Greenplum里对后台进程数量有限制,max_worker_processes这个参数直接决定了实例范围内能启动多少个BackgroundWorker。在Greenplum集群里,这个参数需要在master和segment上都配置一致或者根据角色配置不同值,否则可能在执行时机上出现某类worker数量不足的报错。理解这三层,再看源码就不会觉得乱。你看到的很多带“worker”“prober”“detector”字样的后台进程,几乎都可以归到这个框架下。2. 源码入门:读懂BGWorker的三张关键图纸2.1 BackgroundWorker结构体:一个进程的“身份证”在源码里,核心数据结构是BackgroundWorker结构体。这个名字在头文件和实现文件里出现频率极高,先从它的字段入手是最快的。以PG源码主线上的结构体为参考,它大约包含下面这些字段:字段作用备注bgw_nameworker名称,用于日志和进程标识会出现在ps输出和日志里,起名要能让人一眼看懂bgw_type用于进程类型分类,类似标签比如“logical replication launcher”bgw_mainworker入口函数指针fork完成后,进程就从这个函数开始执行bgw_main_arg传给入口函数的参数可以是整数ID,也可以是指针(指针时通常配合动态共享内存)bgw_flags标志位控制是否能访问共享内存、是否能连接数据库等bgw_start_time启动时机有的worker要在系统启动阶段就跑起来,有的要等standbybgw_restart_time崩溃后重启间隔设为-1表示不自动重启,设为0表示立即重启bgw_sighupSIGHUP处理函数postmaster reload配置时会调用bgw_sigtermSIGTERM处理函数请求关闭worker时调用bgw_notify_pid通知后端进程PID某个前台进程在等待这个worker启动结果时用有一个特别容易理解不到位的地方是bgw_flags。如果你写的worker需要访问共享内存,就需要在flags里带上BGWORKER_SHMEM_ACCESS;如果这个worker还需要连接数据库、启动事务、执行SQL,那就要加上BGWORKER_BACKEND_DATABASE_CONNECTION。而如果不要数据库连接,那它就是一个纯后台计算进程,能访问共享内存但不能做常规SQL操作。很多刚写扩展的人忘了设置连接数据库的权限,结果在worker执行SQL时报那些“cannot start a transaction”之类的错,追半天发现是flags的问题。2.2 从注册到启动:系统如何把worker拉起来注册入口主要有两个:RegisterBackgroundWorker和RegisterDynamicBackgroundWorker。前者是静态注册,使用场景比较固定。典型路径是扩展库通过shared_preload_libraries在启动阶段被加载,加载时postmaster会执行库里的_PG_init函数,这个函数内部构造一个BackgroundWorker结构体填好字段,然后调用RegisterBackgroundWorker。这批worker会被记录在postmaster内部的全局列表中,之后postmaster按照启动流程把一个个worker fork出来。后者是动态注册,使用场景更灵活。某个backend在运行过程中,例如执行并行查询时,需要额外辅助进程,就可以调用RegisterDynamicBackgroundWorker。这个函数会返回一个BackgroundWorkerHandle,命途比较曲折,它可能被用来和worker通信、拿到worker的启动状态等。动态worker同样要由postmaster实际fork,但是注册时机不同,postmaster每次处理完主循环后,会扫描动态注册请求,并且尽可能早地把进程拉起来。为什么要绕这么大一圈去走postmaster,而不是后端进程自己fork子进程?很简单,多个backend同时fork会带来竞争,而且子进程需要继承正确的进程状态,只有postmaster统一调度,才能保证进程初始化顺序和抢占共享内存这些逻辑是安全的。你可以理解成所有临时工的入职流程都要经过HR的统一审批,部门主管虽然可以提需求,但不能绕过HR直接招人。启动过程中的一个关键点是StartWorker系列函数。它会从请求队列里取出注册信息,做资源检查,比如判断工作进程数是否已经达到max_worker_processes上限,然后fork,调用bgw_main指定的入口函数。入口函数执行后的第一件事通常是调用BackgroundWorkerUnblockSignals来解除信号阻塞,否则之前收到的一些信号会被丢弃,后续的信号处理函数可能不生效。2.3 进程收尾:退出、信号与崩溃恢复BGWorker的正常退出有两种方式。一种是入口函数执行完,进程最后走到proc_exit(0);另一种是收到SIGTERM后,由bgw_sigterm处理函数做清理,再走到退出流程。关键点是崩溃处理。如果worker因为段错误或者断言失败崩溃,postmaster并不会直接重启整个数据库,而是把它的退出状态记录下来,然后按照bgw_restart_time决定要不要重新拉起。这个机制保证了某些后台任务崩溃后还能自愈,代价是如果入口函数本身有bug,比如每次都固定崩溃,那postmaster就会陷入反复重启该worker的状态。日志里会看到worker failed之类提示。这里有一个实操心得:调试BGWorker问题的时候,第一件事就是看postmaster日志,第二件事是看进程是否还在循环重启。如果发现某个worker每隔几秒重启一次,基本可以断定是这个worker的main函数在启动阶段就崩了,这时候直接把日志级别调到debug1以上,或者用gdb去附上刚fork出来的进程,往往一抓一个准。3. Greenplum里哪些核心功能在悄悄用BGWorker3.1 并行执行场景中的动态workerGreenplum的并行执行体系里,最底层的数据节点是segment进程。但是你如果直接去代码里搜索RegisterDynamicBackgroundWorker的调用点,会发现不少位置都和“并行查询时启用的辅助进程”有关。典型场景是把一部分计算任务从原来固定的segment进程里拆出来,用动态BGWorker去执行局部计划片段。这种设计跟我们搭服务时临时扩容的思路很像。平时流量不大,不至于给每个查询都预分配一堆进程;但遇到大查询需要并发时,就在执行过程中动态申请一批worker,跑完直接回收。注意这里的动态worker并不一定在每个流程里都执行完整的SQL,有些只是帮忙做数据预聚合、外部表扫描,或者处理某些不能塞进常规executor路径的IO任务。如果你在ps里看到一个进程名字类似bgworker后面跟着一串plan信息,那基本就是这类动态worker。排查时如果发现并行查询变慢,可以看看这些动态worker是否启动缓慢,多半是max_worker_processes设得太小,或者注册的worker请求在postmaster队列中阻塞了。3.2 分布式事务与后台清理任务Greenplum的分布式事务使用两阶段提交来保证跨segment的一致性。分布式事务在协调点(master)上记录状态,在多个segment上逐节点提交。假如某一刻某个节点宕机或者网络中断,有些prepared事务就会悬在中间状态,占着锁和空间,不及时清理会导致后续事务阻塞。后台清理任务就是典型的BackgroundWorker用例。Greenplum会通过注册的后台worker定期扫描分布式事务状态表,对超时的prepared事务发起回滚或重试提交。这类worker通常很早就在_PG_init里静态注册了,启动后常驻系统。另外一个例子是全局死锁检测。单机数据库死锁检测可以简单扫描本地锁表,但Greenplum的死锁可能发生在多节点之间:节点A上的事务等节点B的资源,节点B上的事务又等节点C的资源,节点C等节点A。单个segment只能看到自己的那部分锁信息,无法做出全局判断。所以Greenplum在master上跑了一个专门的BGWorker,周期性地收集各个segment的锁等待信息,构建全局等待图,再判断是否存在死锁,如果存在就选一个牺牲者回滚。这类全局协调型的worker,单看某个segment上发生的事是理解不了的。你需要在master日志里观察它的调度周期,再结合segment上报的状态才能看到全貌。3.3 我的源码阅读路线图:别从上往下硬啃很多人拿Greenplum源码不知道从哪开始看。我的建议是不要从头到尾按目录顺序看,而是反向追踪注册点。第一步,全局搜索注册入口。直接在源码根目录跑一下:grep -rn "RegisterBackgroundWorker" --include="*.c" . grep -rn "RegisterDynamicBackgroundWorker" --include="*.c" .搜到的每一个调用点,就是一个后台功能模块的入口。比如搜到fault_prober之类名字的调用点,顺着它找到对应的结构体初始化,再看bgw_main指向的函数,一条线索就串起来了。第二步,挑一个简单的worker,从bgw_main指向的函数开始读。只要把这个函数从头读一遍,基本就知道这个worker平时在干啥。至于它调用到哪些辅助函数,再逐个往下追,这样你不会迷失。第三步,等读过三四个worker之后,再回头读postmaster的启动调度代码。这时候你已经知道worker模块想要什么,再去看框架怎么满足它们,就很容易理解。这个方法比直接一头扎进postmaster那一大坨主循环里效率高得多。我的习惯是读完一个worker,就在笔记里记录它的注册名、入口函数、主要职责和大概周期。积累几周以后,整个后台服务体系会形成一张完整的画像。4. 手写第一个自定义BackgroundWorker(实操)4.1 最小worker实现:注册、入口与日志输出光看不动手,印象不深。这里我给出一个最小可运行的示例。它做的事情很简单:启动之后每5秒往日志里打一行心跳,收到SIGTERM就退出。你需要准备一个C文件,比如就叫demo_worker.c,核心代码大致如下:#include "postgres.h" #include "miscadmin.h" #include "storage/latch.h" #include "storage/proc.h" #include "storage/shmem.h" #include "utils/guc.h" #include "utils/ps_status.h" #include "fmgr.h" PG_MODULE_MAGIC; volatile sig_atomic_t got_sigterm = false; void _PG_init(void); static void demo_worker_sigterm(SIGNAL_ARGS) { got_sigterm = true; } static void demo_worker_main(Datum main_arg) { int heartbeat = 0; /* 重要:先解除信号阻塞,否则后续信号处理不会生效 */ BackgroundWorkerUnblockSignals(); /* 让进程在ps输出里有个可识别的名字 */ set_ps_display("demo_worker", false); /* 处理SIGTERM,让postmaster能优雅地关掉我们 */ pqsignal(SIGTERM, demo_worker_sigterm); for (;;) { if (got_sigterm) break; heartbeat++; elog(LOG, "demo_worker heartbeat %d", heartbeat); /* 等待5秒;同时检查latch和postmaster死亡 */ WaitLatch(MyLatch, WL_LATCH_SET | WL_TIMEOUT | WL_POSTMASTER_DEATH, 5000L, PG_WAIT_EXTENSION); ResetLatch(MyLatch); } proc_exit(0); } void _PG_init(void) { BackgroundWorker worker; MemSet(&worker, 0, sizeof(worker)); strcpy(worker.bgw_name, "demo_worker"); strcpy(worker.bgw_type, "demo"); worker.bgw_main = demo_worker_main; worker.bgw_main_arg = (Datum) 0; worker.bgw_flags = BGWORKER_SHMEM_ACCESS; worker.bgw_start_time = BgWorkerStart_ConsistentState; worker.bgw_restart_time = 5; worker.bgw_sighup = NULL; worker.bgw_sigterm = demo_worker_sigterm; worker.bgw_notify_pid = 0; RegisterBackgroundWorker(&worker); }有几点要解释一下。BgWorkerStart_ConsistentState表示等到数据库启动到一致性状态之后才启动这个worker;bgw_restart_time = 5表示如果worker意外崩溃,5秒后自动重启,这里特意设为5秒,方便观察自动重启行为。set_ps_display是为了让ps输出里能看到一个叫demo_worker的进程,这个在日志排查时太有用了。SIGNAL_ARGS这个宏在PG的不同版本里可能长得不太一样,以你当前版本的头文件里定义为准,或者直接用带POSTGRES_PGR那种平台定义的方式。重点不是签名完全保持一致,而是理解它要设置一个标志位,让主循环里的判断能生效。4.2 与前端连接共享内存:计数器小Demo只打日志太单调。我再扩展一下:让worker往共享内存里维护一个计数器,前端连接可以通过一个自定义SQL函数把计数器读出来。这样你能同时看到BGWorker的共享内存分配和自定义函数两条路径。先定义共享内存结构体:typedef struct DemoWorkerShared { int counter; slock_t lock; } DemoWorkerShared; static DemoWorkerShared *demo_shared;然后是共享内存申请和初始化。使用RequestAddinShmemSpace请求额外的共享内存空间,再用ShmemInitStruct去初始化。这个逻辑要放在_PG_init和shmem_startup_hook里配合完成:static shmem_startup_hook_type prev_shmem_startup_hook = NULL; static void demo_shmem_startup(void) { bool found; if (prev_shmem_startup_hook) prev_shmem_startup_hook(); demo_shared = ShmemInitStruct("demo_worker_shared", sizeof(DemoWorkerShared), &found); if (!found) { demo_shared->counter = 0; SpinLockInit(&demo_shared->lock); } }在_PG_init里加上:RequestAddinShmemSpace(sizeof(DemoWorkerShared)); prev_shmem_startup_hook = shmem_startup_hook; shmem_startup_hook = demo_shmem_startup;worker主循环里不再只打日志,而是每轮递增计数器。注意因为可能有多个进程同时访问,操作计数时需要加锁:SpinLockAcquire(&demo_shared->lock); demo_shared->counter++; SpinLockRelease(&demo_shared->lock);前端要读取这个计数器,可以写一个简单的C函数,在函数里访问demo_shared->counter并通过PG_RETURN_INT32返回。这样你在psql里执行select demo_worker_read_counter();就能看到实时数值。这里要特别记住:RequestAddinShmemSpace必须在_PG_init里调用,而且这个调用发生在postmaster进程环境里。如果你把它写在worker的入口函数中,系统会直接报共享内存相关错误。这个坑我见过不止一次。4.3 编译、部署、验证全流程编译命令大致如下:gcc -O2 -fPIC -shared \ -I/path/to/gpdb/src/include \ -I/path/to/gpdb/src/backend \ -o demo_worker.so demo_worker.c如果在单机PostgreSQL上测试,就把include路径换成PostgreSQL的源码include目录。部署时有几个步骤容易踩坑。第一步是把demo_worker.so拷贝到数据库的lib目录下,比如$GPHOME/lib。第二步是修改postgresql.conf或greenplum.conf:shared_preload_libraries = 'demo_worker' max_worker_processes = 20Greenplum集群里,shared_preload_libraries要用gpconfig同步到master和所有segment;手动只改某一个节点会导致集群配置不一致。第三步是重启或者重载配置。如果只是注册静态worker,必须重启数据库进程,shared_preload_libraries这个参数并不是所有情况下都能热加载的。第四步是验证:ps -ef | grep demo_worker或者查看数据库日志,应该能看到类似demo_worker heartbeat 1这样的输出。如果你同时在跑Greenplum集群,建议先在PostgreSQL单实例上把逻辑调通,再往Greenplum上搬。Greenplum里还要注意不同segment上都有worker同时启动,日志输出量会翻倍,尽量设置log_min_messages = info就能看到。5. 调试血泪史:常见问题与排查技巧实录5.1 进程没起来?先查这四处后台worker没起来是最常见的现象。我整理了一个快速排查表,按优先级排列。现象大概率原因处理方向ps里完全没有这个worker扩展库加载失败,或者_PG_init没执行先看postmaster日志,确认shared_preload_libraries里的库是否被加载;查看日志中是否有could not access file之类的报错postmaster日志里有“too many background worker”max_worker_processes太小调大这个参数,重启后重试worker起来之后反复退出入口函数崩了,或bgw_sighup/bgw_sigterm处理函数有问题把日志级别调低,看崩溃堆栈worker能启动但是看不到SQL操作结果flags缺少BGWORKER_BACKEND_DATABASE_CONNECTION检查bgw_flags是否带了连接数据库的权限位第一处要看的就是postmaster日志,绝大多数启动阶段的问题都会在这里留下痕迹。如果日志里一行相关内容都没有,那基本可以确认库还没加载到。可以用下面这条SQL验证库是否已经加载:SHOW shared_preload_libraries;第二处要检查的是_PG_init是否真的被调用了。一个很反直觉的情况是:如果你同时配了多个扩展库,其中某个库在_PG_init里抛了错误,后面库的初始化可能会中断。排查时把shared_preload_libraries临时清掉,只留demo_worker再测,往往很快就能定位。第三处是权限问题。demo_worker.so的文件权限要确保postgres系统用户可读可执行,否则动态加载过程会失败。5.2 崩溃定位:从日志到core文件的路径BGWorker和普通backend一样,崩溃后可能生成core文件。但Linux默认的core生成策略经常会让core直接消失,或者写到奇怪的地方。建议在测试机上先配置好:ulimit -c unlimited echo '/tmp/core.%p' > /proc/sys/kernel/core_pattern这里的%p是进程PID,方便你拿到崩溃进程对应的core文件。在Greenplum多实例环境里,每个节点的core文件落在各自节点上,需要去对应主机的/tmp目录找。拿到core文件之后,用gdb加载数据库二进制:gdb /path/to/postgres /tmp/core.12345进入gdb后执行:thread apply all bt把每个线程的堆栈打出来。因为BGWorker通常是单进程单线程,所以直接bt也能看。从堆栈里定位到崩溃函数,再回到源码里对照变量值,通常很快能发现问题。我自己的经验是,如果是worker启动一两秒内就崩,直接gdb跟踪更高效:gdb -p $(pgrep -f demo_worker)但要注意,这个操作会暂停进程,如果进程本身已经在自动重启循环里,你会发现刚attach完进程下一刻就没了。所以更稳妥的做法是先把bgw_restart_time设大,比如设成60秒,给调试留够时间。5.3 调试经验:如何安全地在测试集群里做实验Greenplum集群不是一个理想的调试环境,因为master、segment多个postmaster同时运行,日志分散在各节点,配置同步也需要额外操作。我个人的建议是分三步走:第一步,在本地用PostgreSQL单实例调试自定义worker逻辑。BGWorker框架本身在GP和PG里差别没那么大,大部分代码可以直接复现。第二步,如果必须要在Greenplum环境里验证,尽量先从单个segment节点开始。手动修改这个segment的配置,单独重启这个segment的postmaster,观察它上面的worker行为。等单节点没有问题,再通过gpconfig同步到全集群。第三步,做实验前把log_min_messages调到debug1,并设置一个独立的日志文件位置。这样崩溃信息不会和其他节点日志混杂,定位问题的难度会直线下降。还有一个非常实用的习惯:自定义worker的名称一定要起得足够特别。比如用demo_worker这种名字,而不要叫helper之类的泛化名称。因为Greenplum集群里同一时间可能有几十个后台进程,名字太普通的话你用ps grep根本分不清哪个是你的worker。最后再分享一点个人体会。每次读这种庞大数据库源码里的某个框架,我都习惯先用“算命式追踪法”——就是先不管全局,只沿着注册入口找到worker的main函数,把main函数看懂,再去回头理解框架调度。BackgroundWorker框架尤其适合这种读法,因为每个worker的边界很清晰:它有一个明确的入口函数、一组信号处理函数和一个生命周期。把三五个worker读透之后,你会突然觉得后台进程不再是一群黑盒,而是一批有名字、有职责、有退出策略的“正式员工”,系统设计也就没那么神秘了。后面如果你再往里走,去读共享内存、锁接口或者分布式事务,都会有更踏实的底座。