物理复制(流复制 Streaming Replication )作为 PostgreSQL 高可用架构的核心技术,其安全性直接关系到数据库集群的可靠性;本文选择物理复制中备库向主库请求建立流复制连接的认证过程,即 walreceiver 进程连接主库时的认证机制,并结合源码解析其实现原理
01 数据库物理复制
图片来源:https://box.kancloud.cn/2015-11-15_5647dfc16eb07.jpg
如上图所示,PostgreSQL 的主备物理复制即流复制(Streaming Replication)机制确保主库(Primary)生成的预写日志(WAL)能实时传输到备库(Standby)并正确应用,从而实现数据的同步,其实现依赖于三个关键进程:
-
walsender(主库):推送 WAL 数据到备库
-
walreceiver(备库):接收并存储 WAL 数据
-
startup(备库):应用 WAL 数据到数据库文件
在流复制过程中,预写日志(Write Ahead Log)即图中的 XLOG 的生命周期如下:
-
主库生成 WAL:主库执行事务时,将变更写入 WAL 缓冲区,最终持久化到 WAL 文件
-
walsender 发送 WAL:
walsender
进程从 WAL 文件或缓冲区读取数据,通过复制协议发送给备库的walreceiver
-
walreceiver 接收并存储 WAL:备库的
walreceiver
将接收到的 WAL 数据写入本地pg_wal
目录,并通知startup
进程 -
startup 应用 WAL:
startup
进程读取本地 WAL 文件,按顺序将变更应用到备库的数据文件中,完成数据同步
02 连接主库认证
当备库以恢复模式(Recovery Mode)启动时(例如存在 standby.signal
或 recovery.conf
文件),PostgreSQL 主进程postmaster
会直接启动 startup
进程。在 startup
进程初始化过程中对 primary_conninfo
中的参数信息解析后填充到共享内存中的 WalRcvData
数据结构中,然后备库在启动 walreceiver
进程时根据配置尝试连接到主库。连接成功,该备库的 walreceiver
进程,与主库的 walsender
建立复制流
所以,备库想要和主库建立复制流,需要进行连接认证
2.1 根据配置文件获取密码
通过配置文件中的 primary_conninfo
参数 password
明文配置连接密码是最常用的方式,正确配置对应字段之后,walreceiver 进程则根据该信息进行连接认证, primary_conninfo
参数配置样例如下,
primary_conninfo = 'host=192.168.1.100 port=5432 user=replicator password=yourpassword application_name=standby1 sslmode=require sslcompression=0 keepalives=on connect_timeout=10'
primary_conninfo
中常见的配置项及其说明如下:
参数 | 说明 | 示例值 |
---|---|---|
host | 主库的 IP 地址或主机名 | host=192.168.1.100 |
port | 主库的监听端口(默认 5432 ) | port=5432 |
user | 主库上具有 REPLICATION 权限的用户名(用于复制的专用用户) | user=replicator |
password | 复制用户的密码 | password=yourpassword |
dbname | 主库的数据库名(通常固定为 replication 或主库的某个数据库) | dbname=postgres |
application_name | 备库的标识名称,主库的 pg_stat_replication 视图会显示此名称 | application_name=standby1 |
channel_binding | 是否启用通道绑定(Channel Binding),增强 SSL/TLS 安全性(可选) | channel_binding=prefer |
replication | 固定值 true 或 database ,用于声明连接为复制流(通常设置为 true ) | replication=true |
connect_timeout | 连接主库的超时时间(单位:秒) | connect_timeout=10 |
keepalives | 是否启用 TCP 保活机制(默认 on ) | keepalives=on |
keepalives_idle | TCP 保活包的空闲时间(单位:秒) | keepalives_idle=60 |
keepalives_interval | TCP 保活包的重试间隔(单位:秒) | keepalives_interval=5 |
keepalives_count | TCP 保活包的最大重试次数 | keepalives_count=3 |
sslmode | SSL 连接模式 | sslmode=require |
sslcompression | 是否启用 SSL 压缩(默认 0 ,即禁用) | sslcompression=0 |
sslkey | 客户端 SSL 私钥文件路径 | sslkey=/etc/ssl/client.key |
sslcert | 客户端 SSL 证书文件路径 | sslcert=/etc/ssl/client.crt |
sslrootcert | 根证书文件路径(用于验证主库证书) | sslrootcert=/etc/ssl/ca.crt |
如果需要避免在 primary_conninfo
中明文存储密码,可以通过接下来的两种方式进行认证:在备库启动时通过环境变量提供密码或通过.pgpass
密码文件提供密码
2.2 通过环境变量注入密码
PostgreSQL 的 libpq 库通过一系列环境变量为连接参数提供默认值,在代码中没有显式指定对应参数时,这些变量会在调用 PQconnectdb
、PQsetdbLogin
或 PQsetdb
时生效;这些环境变量同样可以适用于 walreceiver 进程向主库申请建立连接的认证过程
以下是 libpq 支持的常用环境变量,更多的环境变量适用说明可以参考官方文档
https://www.postgresql.org/docs/current/libpq-envars.html
环境变量 | 作用 | 示例值 |
---|---|---|
PGHOST | 数据库服务器主机名或 IP | localhost |
PGHOSTADDR | 数据库服务器的 IP 地址(跳过 DNS) | 192.168.1.100 |
PGPORT | 数据库端口号 | 5432 |
PGDATABASE | 要连接的数据库名 | mydb |
PGUSER | 数据库用户名 | postgres |
PGPASSWORD | 数据库密码 | yourpassword |
PGPASSFILE | 密码文件路径 | ~/.pgpass |
PGOPTIONS | 连接选项(如 -c search_path=... ) | -c statement_timeout=1000 |
PGSSLMODE | SSL 模式(disable /require 等) | require |
PGREQUIRESSL | 强制 SSL 连接(优先用 PGSSLMODE ) | 1 |
PGURI | 完整的连接 URI(覆盖其他参数) | postgresql://user:pass@host/db |
通过环境变量注入密码,需要确保 walreceiver 进程启动时的环境变量中已经配置了 PGPASSWORD
,即在备库启动之前需要先使用如下命令设置 PGPASSWORD
环境变量,当然也可以直接通过编辑 .bashrc
等文件进行配置
export PGPASSWORD="yourpassword"
这样就可以在 primary_conninfo
没有配置 password
字段的情况下进行验证,但需要保证该密钥与流复制用户正确匹配才能认证成功
但在实际使用中 PGPASSWORD
明文密码可能被进程监控工具捕获,同样存在安全风险,推荐使用 .pgpass
密码文件
2.3 通过密码文件获取密码
PostgreSQL 中通过密码文件 .pgpass
存储数据库密码是一种较为安全的方式,避免在代码、命令行或环境变量中暴露明文密码。当客户端工具连接数据库时,若未通过其他方式指定密码,会自动从 .pgpass
文件中匹配条目获取密码;该密码文件的默认路径是 ~/.pgpass
,文件格式如下
hostname:port:database:username:password
字段 | 说明 |
---|---|
hostname | 主机名或 IP,* 表示匹配任意主机(包括本地套接字) |
port | 端口号,* 表示匹配任意端口 |
database | 数据库名,* 表示匹配任意数据库 |
username | 用户名,* 表示匹配任意用户 |
password | 明文密码 |
需要注意的是,密码文件必须限制访问权限,仅允许文件所有者读写,否则 PostgreSQL 会忽略该文件
chmod 600 ~/.pgpass
除了默认的文件路径 ~/.pgpass
,也可以通过环境变量 PGPASSFILE
或者直接设置连接参数 passfile
来指定自定义密码文件路径
export PGPASSFILE=/path/to/custom_passfile
walreceiver 进程通过 libpq 进行认证时,如果未显示指定密码,则会尝试在备库的密码文件中查找匹配的密码,但作为流复制用户在 .pgpass
文件中该记录的数据库名称需要配置成 replication
hostname:port:replication:username:password
03 walreceiver 认证源码解析
前文提到 startup
进程在主进程postmaster
发现作为备库启动即以恢复模式(Recovery Mode)启动时直接启动;而 walreceiver
进程则是由 startup
进程在进行一系列条件判断后,通知 postmaster
来启动,该过程执行顺序如下:
-
触发条件:当备库负责 WAL 恢复的
startup
进程发现本地 WAL 日志不完整需要从主库流式传输时,会通过信号通知postmaster
启动walreceiver
进程 -
信号传递:
startup
调用SendPostmasterSignal(PMSIGNAL_START_WALRECEIVER)
,向postmaster
发送启动walreceiver
的请求 -
postmaster
响应:postmaster
收到信号后,在其主循环中调用LaunchMissingBackgroundProcesses()
,发现需要启动walreceiver
,随即创建子进程 -
进程启动:
postmaster
通过fork()
创建子进程,子进程执行WalReceiverMain()
,成为walreceiver
进程,连接到主库拉取 WAL 数据
StartupProcessMain() // 备库启动 startup 进程的主函数
->StartupXLOG() // 负责 WAL 恢复的核心逻辑
->InitWalRecovery() // 初始化 WAL 恢复环境
->XLogReaderAllocate() // 分配 WAL 读取器
->XLogPageRead() // 读取 WAL 页
->WaitForWALToBecomeAvailable() // 检查 WAL 是否可用
->RequestXLogStreaming() // 判断需要流复制,触发启动 walreceiver
->SendPostmasterSignal(PMSIGNAL_START_WALRECEIVER) // 通知 postmaster
// (postmaster 进程侧操作)
->process_pm_pmsignal() // 处理信号 PMSIGNAL_START_WALRECEIVER
->LaunchMissingBackgroundProcesses() // 检查并启动缺失的后台进程
->StartChildProcess(B_WAL_RECEIVER) // 启动 walreceiver 进程
->postmaster_child_launch() // 创建子进程
->WalReceiverMain() // walreceiver 主函数
walreceiver 进程启动之后,根据 WalRcvData
中已经初始化好的连接信息 conninfo 尝试和主库建立连接,连接过程使用 libpq 和核心函数 PQconnectStartParams
建立连接,认证密码获取方式有:
-
通过配置参数:在根据
primary_conninfo
初始化好的WalRcvData
中包含password
信息 -
通过环境变量:在调用
conninfo_add_defaults
获取默认值时,会使用getenv
函数遍历PQconninfoOptions
数组中的所有环境变量并获取对应的值,其中就包括PGPASSWORD
用于给pgpass
赋值 -
通过密码文件:在调用
pqConnectOptions2
函数时如果发现当前的conn->pgpass
仍然为空,则根据默认的密码文件~/.pgpass
或用户自定义的密码文件路径 PGPASSFILE 并调用passwordFromFile
函数获取所有 host 对应的密码
WalReceiverMain() // walreceiver 进程主入口
->walrcv_connect() // 触发连接主库的逻辑
->libpqrcv_connect() // 调用 libpq 库的封装接口
->libpqsrv_connect_params() // 增加一些额外的参数选项 options
->PQconnectStartParams() // 初始化非阻塞连接
->conninfo_array_parse() // 解析连接参数数组
->conninfo_add_defaults() // 补充默认连接参数(从 service file 或者环境变量中获取默认值)
->pqConnectOptions2() // 处理认证相关选项(如密码文件)
->passwordFromFile() // 从 .pgpass 文件读取密码
->pqConnectDBStart() // 启动异步连接过程
->PQconnectPoll() // 处理连接状态机(包括认证协商)
认证过程中使用密码时,优先使用从密码文件中获取的密码conn->connhost[conn->whichhost].password
,该逻辑由 PQpass
函数实现
char *
PQpass(const PGconn *conn)
{
char *password = NULL;
if (!conn)
return NULL;
if (conn->connhost != NULL)
password = conn->connhost[conn->whichhost].password;
if (password == NULL)
password = conn->pgpass;
/* Historically we've returned "" not NULL for no password specified */
if (password == NULL)
password = "";
return password;
}
04 libpq 的连接控制函数
在介绍 walreceiver 连接认证时,提到使用PQconnectStartParams
去建立于主库节点的连接,这个函数通过参数数组接收连接信息,这种直接传递键值对可以自动处理特殊字符,是新版本引入的启动异步连接函数
PQconnectStartParams
函数定义如下,接受两个数组:keywords 包含参数关键字,values 包含参数值,并通过 expand_dbname 指定是否允许扩展参数
PGconn *PQconnectStartParams(const char *const *keywords, const char *const *values, int expand_dbname)
PQconnectStart
函数是另一种支持连接字符串的连接控制函数,定义如下,数据库连接信息是用从 conninfo 连接字符串里取得的参数中解析出来的
PGconn *PQconnectStart(const char *conninfo)
PQconnectPoll
函数则是PQconnectStartParams
和PQconnectStart
最终进行连接建立时调用的函数,该函数轮询异步连接状态,推动连接过程直至完成或失败
PostgresPollingStatusType PQconnectPoll(PGconn *conn)
PQconnectPoll
函数返回状态 PostgresPollingStatusType
定义如下
typedef enum
{
PGRES_POLLING_FAILED = 0, // 连接成功
PGRES_POLLING_READING, // 需等待套接字可读
PGRES_POLLING_WRITING, // 需等待套接字可写
PGRES_POLLING_OK, // 连接成功
PGRES_POLLING_ACTIVE /* unused; keep for backwards compatibility */
} PostgresPollingStatusType;
上述三个函数PQconnectStart, PQconnectStartParams, PQconnectPoll
都是用于打开一个与数据库服务器之间的非阻塞的连接,即应用程序在执行这些函数的时候不会因远端的 I/O 而被阻塞
基于这三个函数,libpq 提供了三种连接控制接口:PQconnectdb, PQconnectdbParams, PQsetdbLogin
PQconnectdb, PQconnectdbParams
分别对应对PQconnectStart, PQconnectStartParams
函数的封装,函数调用参数一致,如下所示
PGconn *
PQconnectdbParams(const char *const *keywords,
const char *const *values,
int expand_dbname)
PGconn *
PQconnectdb(const char *conninfo)
PQsetdbLogin
函数则是 libpq 早期的遗留函数,仍保留对旧版本的兼容,接受不太灵活的分立的参数形式:host, port, options, dbname, user, password
,其定义如下
PGconn *
PQsetdbLogin(const char *pghost, const char *pgport, const char *pgoptions,
const char *pgtty, const char *dbName, const char *login,
const char *pwd)
这三种接口区别在于参数传递方式:
PQconnectdbParams
函数建立连接的示例如下,通过关键字和值的数组传递连接参数,这种方式在动态生成参数时更安全,无需转义能避免字符串拼接错误,而且支持参数扩展
const char *keywords[] = {"host", "port", "dbname", NULL};
const char *values[] = {"localhost", "5432", "mydb", NULL};
PGconn *conn = PQconnectdbParams(keywords, values, 0);
PQconnectdb
函数建立连接的示例如下,通过连接字符串传递连接参数,这种方式在处理密码等字符串时需要手动进行转义,也支持扩展参数
PGconn *conn = PQconnectdb("host=127.0.0.1 port=5432 dbname=mydb");
PQsetdbLogin
函数建立连接的示例如下,通过固定参数传递有限的连接参数,这种方式缺乏灵活性,新代码不建议使用该接口,该接口仅用于旧版本的兼容
PGconn *conn = PQsetdbLogin("localhost", "5432", "", "mydb", "postgres", "yourpassword");
参考资料
https://www.kancloud.cn/taobaomysql/monthly/81110
https://zhuanlan.zhihu.com/p/530628881
PostgreSQL: Documentation: 17: 19.6. Replication
PostgreSQL: Documentation: 17: 32.15. Environment Variables
PostgreSQL: Documentation: 17: 32.16. The Password File
https://www.postgresql.org/docs/current/libpq-connect.html//LIBPQ-PQCONNECTDB