有时,PostgreSQL 中的某个查询会运行异常缓慢,甚至陷入"挂起"状态。通常情况下,我们可以轻松地将其中断(取消)。但有时这会变成一个棘手的问题:查询无法被取消。本文将探讨可能导致这一问题的原因,并介绍一个解决方案的技巧(该操作风险较高,请谨慎评估!)
如何取消正在运行的查询
PostgreSQL 协议中规定了中断正在执行的语句的方法:建立一个新的连接,并发送一个包含秘钥 的 CancelRequest 消息。该秘钥在原始连接建立时由服务器发送。没有这个秘钥,任何人都可以取消您的查询,这将带来严重的安全隐患。
C 语言库 libpq 提供了 PQgetCancel() 和 PQcancel() 函数来取消查询,其他数据库 API 通常也有类似的功能。在交互式 psql 会话中,您只需按下 Ctrl+C 即可发送取消请求,图形化客户端也通常会提供一个用于此目的的按钮。
此外,也可以通过调用数据库函数 pg_cancel_backend() 来取消他人的查询。另一个选项是 pg_terminate_backend(),其作用更进一步------直接终止他人的数据库会话。要使用这些函数,您必须是超级用户,或者是默认角色 pg_signal_backend 的成员(原因将在下一节解释),或者您必须使用与目标会话相同的数据库用户登录(即您有权取消自己的语句)。
服务器如何处理取消请求
PostgreSQL 中的进程间通信高度依赖信号机制。
当 postmaster 进程收到 CancelRequest 消息时,它会向对应数据库会话的后台进程发送 SIGINT 信号。pg_cancel_backend() 函数执行的也是此操作。而 pg_terminate_backend() 发送的是 SIGTERM 信号。
每个 PostgreSQL 进程都设有一个信号处理器,用于处理接收到的信号。该处理器不会立即中断后台进程,而是为进程设置全局标志位 :SIGINT 设置 QueryCancelPending,SIGTERM 设置 ProcDiePending。后台进程有责任在合适的时机检查这些标志位并做出响应。这种设计确保了进程不会在不安全的状态(例如,正在修改共享内存时)被意外中断。
在 PostgreSQL 代码中的安全位置,遍布着 CHECK_FOR_INTERRUPTS() 宏(该宏会调用 ProcessInterrupts() 函数)。该函数会根据设置的标志,抛出错误以取消当前语句,或终止后台进程。
取消查询可能失败的原因
查询取消失败可能有以下几种原因:
- 执行卡在了一个不包含
CHECK_FOR_INTERRUPTS()的循环中:这属于 PostgreSQL 自身的缺陷,修复方法是增加对该宏的调用。 - 执行卡在了一个通过 SQL 语句调用的第三方 C 函数里:请将此问题报告给该函数的作者。
- 执行卡在了一个无法中断的系统调用中:这通常表明操作系统或硬件层面存在问题。需要注意的是,当进程处于内核空间时,信号的传递会被延迟。
谨慎使用 kill -9
直接使用普通的 kill 命令处理 PostgreSQL 后台进程是可行的。这会发送 SIGTERM 信号,效果等同于为后台进程调用 pg_terminate_backend()。如果此操作无效,有人可能会尝试使用 kill -9,它会发送 SIGKILL 信号。该信号无法被捕获 ,会立即终止进程。问题在于,postmaster 会检测其子进程是否正常退出。一旦检测到异常终止,它会杀死所有其他 PostgreSQL 进程并执行崩溃恢复,这将导致整个数据库短暂停服,持续时间从数秒到数分钟不等。
请注意,对后台进程使用 kill -9 仅会造成短暂停机,但对 postmaster 进程本身使用 kill -9 则会产生更严重的后果,应坚决避免 。这会打开一个时间窗口,在此期间新的 postmaster 可能被启动,而旧 postmaster 的某些子进程仍然存活,极有可能导致磁盘上的数据损坏。永远不要使用 kill -9 杀死 postmaster 进程!
有时,即使是 kill -9 也无法杀死一个 PostgreSQL 后台进程。这意味着该进程卡在了一个不可中断的系统调用 中,例如,在对已不可用的网络附加存储执行 I/O 操作。如果这种状况持续存在,唯一的方法就是重启操作系统。
高级技巧:在不重启服务器的情况下取消卡住的查询
有时,您可以按照以下步骤操作,以避免数据库崩溃恢复和停服 。以下示例基于 Linux 环境下的 GNU 调试器 gdb;其他环境请自行适配。
1. 创建一个会挂起的函数示例
我们编写一个简单的 C 函数(源文件 loop.c):
c
#include "postgres.h"
#include "fmgr.h"
#include <unistd.h>
PG_MODULE_MAGIC;
PG_FUNCTION_INFO_V1(loop);
Datum
loop(PG_FUNCTION_ARGS)
{
/* 一个无限循环 */
while(1)
sleep(2);
}
按如下方式构建共享库(请根据实际环境调整包含路径):
bash
gcc -I /usr/pgsql-14/include/server -fPIC -shared -o loop.so loop.c
将生成的 loop.so 文件复制到 PostgreSQL 的共享库目录(可通过 pg_config --libdir 获取)。
2. 定义并调用函数
以超级用户身份在 SQL 中定义函数:
sql
CREATE FUNCTION loop() RETURNS void
LANGUAGE c AS 'loop';
然后,以普通用户身份调用该函数:
sql
SELECT loop();
执行将陷入挂起。您可以尝试取消查询,但它会持续运行。
3. 定位挂起的后台进程并发送终止信号
使用相同的数据库用户打开另一个数据库连接,通过以下查询找出该会话的后台进程 ID:
sql
SELECT pid, query
FROM pg_stat_activity
WHERE query LIKE '%loop%';
获取进程 ID 后,向其发送 SIGTERM 信号:
sql
SELECT pg_terminate_backend(12345);
(请将 12345 替换为实际进程 ID)。该函数返回 TRUE,表示信号已发送,但查询仍在执行。
4. 用调试器附加到进程
确保已安装 gdb 调试器。为获得可读的堆栈跟踪,建议安装 PostgreSQL 服务器的调试符号(但这并非本技巧的必需步骤)。以 PostgreSQL 用户(通常名为 postgres)身份登录到数据库服务器,按如下方式调用 gdb(请使用正确的 postgres 可执行文件路径和进程 ID):
bash
gdb /usr/pgsql-14/bin/postgres 12345
在 (gdb) 提示符下,输入 bt 命令生成堆栈跟踪,输出类似如下:
#0 __GI___clock_nanosleep (clock_id=clock_id@entry=0, flags=flags@entry=0,
req=req@entry=0x7ffdaf61cde0, rem=rem@entry=0x7ffdaf61cde0)
at ../sysdeps/unix/sysv/linux/clock_nanosleep.c:71
#1 0x00007f113d864897 in __GI___nanosleep (req=req@entry=0x7ffdaf61cde0,
rem=rem@entry=0x7ffdaf61cde0) at ../sysdeps/unix/sysv/linux/nanosleep.c:25
#2 0x00007f113d8647ce in __sleep (seconds=0) at ../sysdeps/posix/sleep.c:55
#3 0x00007f113e623139 in loop () from /usr/pgsql-14/lib/loop.so
#4 0x00000000006d71fb in ExecInterpExpr (state=0x13837b8, econtext=0x13834e0,
isnull=<optimized out>) at executor/execExprInterp.c:1260
#5 0x000000000070e391 in ExecEvalExprSwitchContext (isNull=0x7ffdaf61ced7,
econtext=0x13834e0, state=0x13837b8)
at executor/../../../src/include/executor/executor.h:339
#6 ExecProject (projInfo=0x13837b0)
at executor/../../../src/include/executor/executor.h:373
#7 ExecResult (pstate=<optimized out>) at executor/nodeResult.c:136
#8 0x00000000006da8b2 in ExecProcNode (node=0x13833d0)
at executor/../../../src/include/executor/executor.h:257
#9 ExecutePlan (execute_once=<optimized out>, dest=0x137f4c0, direction=<optimized out>,
numberTuples=0, sendTuples=<optimized out>, operation=CMD_SELECT,
use_parallel_mode=<optimized out>, planstate=0x13833d0, estate=0x13831a8)
at executor/execMain.c:1551
[...]
堆栈跟踪有助于定位问题根源。如果您需要向 PostgreSQL 报告此问题,请附上此信息。
如果您不想继续下一步,可以在 (gdb) 提示符下输入 detach 分离调试器,让进程继续运行。
5. 通过让卡住的后台进程干净退出以取消执行
观察上面的堆栈跟踪,可以看到当前执行点位于一个自定义函数中(loop () from /usr/pgsql-14/lib/loop.so),而不是 PostgreSQL 内部代码。这种情况下,让进程退出是相对安全的。如果执行点位于 PostgreSQL 服务器内部,则可能存在一定的风险(例如,进程可能正持有自旋锁或处于修改共享状态)。若您熟悉 PostgreSQL 源码,可以通过调用栈评估风险。
现在,如果决定继续,请在 (gdb) 提示符下调用 ProcessInterrupts() 函数。由于此前已设置了 ProcDiePending 标志,该调用将导致进程退出:
(gdb) print ProcessInterrupts()
[Inferior 1 (process 12345) exited with code 01]
The program being debugged exited while in a function called from GDB.
Evaluation of the expression containing the function
(ProcessInterrupts) will be abandoned.
(gdb) quit
6. 改进函数以支持取消
为了避免此类问题,函数代码应修改为定期检查中断标志:
c
#include "postgres.h"
#include "fmgr.h"
#include "miscadmin.h"
#include <unistd.h>
PG_MODULE_MAGIC;
PG_FUNCTION_INFO_V1(loop);
Datum
loop(PG_FUNCTION_ARGS)
{
/* 一个无限循环,但允许中断 */
while(1)
{
CHECK_FOR_INTERRUPTS();
sleep(2);
}
}
修改后,函数将每两秒检查一次中断标志,从而能够被安全地取消。
结论
取消 PostgreSQL 查询的本质是向后台进程发送 SIGINT 信号。如果 SIGINT 和 SIGTERM 均无法中断进程,您可以使用 gdb 附加到该进程,并直接调用 ProcessInterrupts() 函数,使其干净退出。