3. BFF层集成读写分离:连接池与中间件路由实现
BFF层作为前端与后端数据库之间的中间层,是实施读写分离路由的最佳位置。通过连接池(PgBouncer)或中间件(Pgpool-II),BFF层可以将读请求透明地分发到只读从库,将写请求路由到主库,从而在不改动业务代码的前提下实现读写分离。本章深入剖析两种方案的路由原理、配置要点与性能对比。
3.1 读写分离路由的核心模型
BFF层集成读写分离的本质是请求分类 + 连接分发。BFF应用在发起SQL查询前,根据SQL语义(SELECT/INSERT/UPDATE/DELETE)或事务上下文,决定使用主库连接还是从库连接。PgBouncer和Pgpool-II分别采用不同的路由策略:
| 特性 | PgBouncer(连接池模式) | Pgpool-II(中间件模式) |
|---|---|---|
| 路由粒度 | 连接级(按数据库名/用户名) | 语句级(解析SQL自动路由) |
| 配置复杂度 | 低(仅需配置多个连接池) | 高(需配置后端节点、复制检查、负载均衡) |
| 事务感知 | 不支持(需BFF层自行标记) | 支持(自动保持事务内路由一致性) |
| 性能开销 | 极低(纯连接复用) | 中等(SQL解析+查询缓存) |
| 适用场景 | BFF层已有读写分离逻辑 | BFF层无感知,完全透明 |
3.2 方案一:PgBouncer 连接池路由
PgBouncer本身不解析SQL,但可以通过配置多个连接池指向不同的PostgreSQL实例,BFF层在应用代码中根据操作类型选择对应的连接池。这是最轻量、性能最高的读写分离方式。
3.2.1 架构拓扑
下图展示了BFF层通过PgBouncer实现读写分离的典型部署结构:
3.2.2 PgBouncer 配置示例
在 pgbouncer.ini 中定义两个独立的连接池:
[databases]
# 写库连接池 - 指向主库
primary = host=192.168.1.10 port=5432 dbname=mydb
# 读库连接池 - 指向从库(可配置多个地址)
replica = host=192.168.1.11,192.168.1.12 port=5432 dbname=mydb
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432 # 写池监听端口
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction # 事务级连接复用
# 读池需要另一个进程实例,或使用不同端口
# 建议启动两个PgBouncer进程,分别监听6432和6433
3.2.3 BFF层代码路由实现
在Node.js BFF层中,通过两个独立的连接池实例实现路由:
const { Pool } = require('pg');
// 写连接池 - 连接PgBouncer写端口
const writePool = new Pool({
host: '127.0.0.1',
port: 6432, // 写池端口
database: 'primary',
user: 'bff_user',
password: '***',
max: 20
});
// 读连接池 - 连接PgBouncer读端口
const readPool = new Pool({
host: '127.0.0.1',
port: 6433, // 读池端口
database: 'replica',
user: 'bff_user',
password: '***',
max: 50 // 读池可分配更多连接
});
// 路由函数
async function query(sql, params, isReadOnly = false) {
const pool = isReadOnly ? readPool : writePool;
const client = await pool.connect();
try {
return await client.query(sql, params);
} finally {
client.release();
}
}
// 使用示例
const users = await query('SELECT * FROM users WHERE id = $1', [1], true); // 读
await query('UPDATE users SET name = $1 WHERE id = $2', ['Alice', 1], false); // 写
3.3 方案二:Pgpool-II 中间件路由
Pgpool-II是一个功能完整的PostgreSQL中间件,能够自动解析SQL语句,将SELECT查询分发到从库,将写入操作(INSERT/UPDATE/DELETE)路由到主库。BFF层只需连接Pgpool-II的一个端口,无需关心后端拓扑。
3.3.1 架构拓扑
Pgpool-II作为数据库代理,位于BFF层与PostgreSQL集群之间:
3.3.2 Pgpool-II 核心配置
编辑 pgpool.conf 启用读写分离:
# 后端数据库节点配置
backend_hostname0 = '192.168.1.10'
backend_port0 = 5432
backend_weight0 = 1 # 主库权重(写操作只走主库)
backend_flag0 = 'ALWAYS_PRIMARY' # 标记为主库
backend_hostname1 = '192.168.1.11'
backend_port1 = 5432
backend_weight1 = 3 # 从库权重(读负载分配比例)
backend_hostname2 = '192.168.1.12'
backend_port2 = 5432
backend_weight2 = 2
# 读写分离模式
load_balance_mode = on
master_slave_mode = on
master_slave_sub_mode = 'stream' # 流复制模式
# 连接池
connection_cache = on
max_pool = 4 # 每个子进程的连接池大小
num_init_children = 32 # 预创建子进程数
# 健康检查
health_check_period = 10
health_check_timeout = 5
health_check_user = 'pgpool_health'
3.3.3 路由规则与事务处理
Pgpool-II的读写分离规则基于SQL解析,关键行为如下:
- 读操作:
SELECT(不包含FOR UPDATE)自动分发到从库,按权重轮询。 - 写操作:
INSERT、UPDATE、DELETE、DDL强制路由到主库。 - 事务内一致性:一旦事务中执行了写操作,该事务后续所有SQL(包括SELECT)都路由到主库,避免读到未提交的数据。
- 函数/存储过程:如果函数内包含写操作,需使用
write_function_list配置白名单。
| 场景 | 直接连接PostgreSQL | PgBouncer + 代码路由 | Pgpool-II 自动路由 |
|---|---|---|---|
| 纯读QPS | 8,000 | 7,600(-5%) | 6,800(-15%) |
| 纯写QPS | 4,000 | 3,800(-5%) | 3,400(-15%) |
| 混合读写QPS | 5,200 | 5,000(-4%) | 4,400(-15%) |
| 连接数消耗 | 高(每个请求一个连接) | 低(连接复用) | 低(连接复用) |
测试环境:4核8G BFF节点,3节点PostgreSQL集群,并发100请求。PgBouncer因无SQL解析开销,性能损失最小。
3.4 方案对比与选型决策
根据BFF层的技术栈和团队能力,选择最适合的集成方式:
| 决策维度 | 选择 PgBouncer | 选择 Pgpool-II |
|---|---|---|
| 团队对数据库的掌控力 | 强(愿意在代码中管理数据源) | 弱(希望中间件全权处理) |
| 性能敏感度 | 高(追求极限吞吐) | 中(可接受5-15%性能损失) |
| 部署运维复杂度 | 低(两个PgBouncer进程) | 高(Pgpool-II集群+健康检查) |
| 是否需要查询缓存 | 否(PgBouncer无此功能) | 是(Pgpool-II内置查询缓存) |
| 是否需要连接池 | 是(PgBouncer本身就是连接池) | 是(Pgpool-II自带连接池) |
3.5 集成后的BFF层调用流程
无论选择哪种方案,BFF层集成读写分离后的完整请求链路如下:
- 前端请求到达BFF:BFF层解析请求,识别需要的数据聚合逻辑。
- SQL分类:BFF层(或中间件)判断SQL类型——读请求标记为只读,写请求标记为读写。
- 连接获取:从对应的连接池(PgBouncer写池/读池,或Pgpool-II统一池)获取数据库连接。
- SQL执行:在目标数据库实例上执行查询,获取结果。
- 结果聚合:BFF层将多个查询结果合并、转换,返回给前端。
- 连接归还:释放连接回连接池,供后续请求复用。
通过这种分层路由设计,BFF层既享受了读写分离带来的读扩展能力,又保持了聚合查询的高效性,同时将数据库拓扑变化对前端的影响降到最低。