KernelRanch
返回文章列表

Python 连 PostgreSQL:连接比查询贵 40 倍,10 个线程反而比 1 个连接慢

2026-11-10 #Python

做个小服务连数据库,最常写的代码是"每次请求 connect 一次"。我按这个写法测了一遍,顺手把 psycopg3 和 asyncpg 放在同一张表上比了比,结果有两个反直觉的地方。

0. 环境与负载

项值
服务端PostgreSQL 18.6(Docker,shared_buffers=256MB,1GB 内存限制)
客户端python:3.12-slim 容器,与数据库同一 Docker 网络
驱动psycopg 3.3.8(binary 版)、asyncpg 0.32.0
数据1,000,000 行 pgbench_accounts,主键 aid
查询select abalance from pgbench_accounts where aid = ?,随机 3000 个 aid
计时单次查询用 time.perf_counter(),汇总 p50 / p99

把客户端放进容器而不是宿主机,是为了避免 Windows 到容器的端口转发干扰 —— 测的是驱动和连接本身,不是网络栈。

1. 结果

方式qpsp50p99
psycopg3 单连接顺序11,1840.066 ms0.297 ms
psycopg3 10 连接 / 10 线程5,0061.797 ms5.156 ms
psycopg3 每查询新建连接3602.5 ms5.5 ms
asyncpg 单连接顺序8,0420.105 ms0.269 ms
asyncpg 10 连接并发23,601——

连接建立本身(connect + close,各 30 次):

驱动p50平均
psycopg31.8 ms1.9 ms
asyncpg3.4 ms3.5 ms

2. 三个反直觉

连接比查询贵 40 倍

单次查询 p50 是 0.066ms,而建一次连接 p50 是 1.8ms,相差 27 倍。体现在吞吐上就是 11184 qps 掉到 360 qps —— 31 倍。

这也是 psycopg 官方文档开宗明义的话:"建立新连接的时间可能比较长,保持连接打开可以降低延迟"[1]。这句话不是客套,是它存在的理由。

10 个线程比 1 个连接更慢

同步驱动 + 10 线程,吞吐 5006 qps,只有单连接的 45%。p50 从 0.066ms 涨到 1.797ms,涨了 27 倍。

原因不神秘:GIL。这 10 个线程大部分时间在等 GIL,而不是在等数据库;同时 10 个连接都在抢一个 Python 解释器的执行权。当单次查询只有 66 微秒时,线程切换和 GIL 争用的成本远大于查询本身。

线程并不等于并发。 对"短查询 + 同步驱动"这个组合,加线程是在给 GIL 加压。

单连接下 psycopg3 比 asyncpg 快,并发下反过来

单连接顺序跑,psycopg3 是 11184 qps,asyncpg 是 8042 qps,psycopg3 快 39%。但 asyncpg 用 10 个连接并发能到 23601 qps,是 psycopg3 单连接的 2.1 倍、也是它自己单连接的 2.9 倍。

这说明 asyncpg 的每查询开销更高(可能是协议层与类型转换的实现路径不同),但它的并发模型能把这个劣势补回来并且反超。选型时不能只看单连接跑分。

3. 为什么会这样

PostgreSQL 每个客户端连接对应一个后端进程,连接建立包含 TCP 握手、认证、进程 fork、初始化 catalog 缓存等动作。1.8ms 已经是本地网络下的好成绩;跨机房时这个数字常常是 20–50ms。

所以"每请求新建连接"的成本不是 O(1),而是比一次点查高两个数量级。把连接复用起来,收益立竿见影。

asyncpg 官方文档对此的说明是:面向"处理频繁请求、每次只短暂使用数据库连接"的服务型应用,推荐使用连接池,并且它的连接池"消除了使用 PgBouncer 这类外部连接池的必要"[2]。这话有点营销味,但方向是对的——进程内的池足以覆盖绝大多数场景。

4. 该怎么写

同步场景用 psycopg_pool.ConnectionPool(独立包,pip install "psycopg[pool]"):

from psycopg_pool import ConnectionPool

pool = ConnectionPool(
    conninfo="host=127.0.0.1 port=5432 user=postgres dbname=postgres",
    min_size=4,
    max_size=8,
    open=True,
)
pool.wait()  # 启动时确认能连上,避免第一个请求才发现配置错

def get_balance(aid: int) -> float:
    with pool.connection() as conn:
        row = conn.execute(
            "select abalance from pgbench_accounts where aid = %s", (aid,)
        ).fetchone()
        return float(row[0])

异步场景用 asyncpg.create_pool:

import asyncpg

pool = await asyncpg.create_pool(
    "postgresql://postgres@127.0.0.1:5432/postgres", min_size=4, max_size=16
)

async def get_balance(aid: int):
    async with pool.acquire() as conn:
        return await conn.fetchval(
            "select abalance from pgbench_accounts where aid = $1", aid
        )

两个细节值得记住:

  • pool.wait()(psycopg3)或启动时显式建池(asyncpg),能让配置错误在启动阶段暴露,而不是在第一个用户请求上。
  • asyncpg 的参数占位符是 $1,不是 %s。混用是最常见的入门报错。

5. 池要开多大

psycopg 官方文档在"池该多大"这一节的回答很实在:"好问题,谁知道呢。但大概没有你想象的那么大"[1],并建议用 get_stats() 观察 requests_waiting、pool_available、usage_ms 这些指标再调。

从实测反推:psycopg3 单连接顺序就能跑 11184 qps,说明这条查询在服务端的处理时间极短;真正的瓶颈是客户端侧的往返与解释器开销。池开大并不是为了让服务端更忙,而是为了在客户端有并发时不让请求排队。

我的起点是 min_size = CPU 核数、max_size = 2 × CPU 核数,再用 get_stats() 的 requests_waiting 判断要不要加。池过大反而会把连接数推给数据库,每个连接一个进程,内存和上下文切换都要付账。

6. 什么时候还需要 PgBouncer

进程内的池解决的是"单个服务进程内复用连接"。当你有几十个 Pod、每个 Pod 都开 8 个连接时,数据库侧看到的是几百个连接,这时候需要在数据库前面加一层 PgBouncer 做全局收敛。

判断标准很简单:把所有服务实例的 max_size 加起来,如果超过数据库能承受的连接数,就该上外部池化。这个数字可以从 max_connections 反推,留 20% 余量给运维与监控连接。

7. 在 Windows 上复现

docker network create lrnet
docker run -d --name pg --network lrnet -e POSTGRES_PASSWORD=postgres -e POSTGRES_HOST_AUTH_METHOD=trust postgres:18 -c shared_buffers=256MB
docker exec pg pgbench -i -s 10 -U postgres -h 127.0.0.1 -d postgres

docker run -d --name pycli --network lrnet --memory 1g python:3.12-slim sleep 600
docker exec pycli pip install -q "psycopg[binary]" asyncpg
docker cp bench.py pycli:/tmp/bench.py
docker exec pycli python /tmp/bench.py

客户端要放在同一 Docker 网络里,否则要么映射宿主端口(会遇到端口占用/保留段那类问题),要么走宿主机 NAT,测出来的延迟就不是驱动本身的了。

小结

11184 qps 与 360 qps 的差距,全部来自"有没有复用连接"。写完连、用完关的写法,在本地就要付 1.8ms 一次,跨机房更贵。

10 线程比单连接慢 55% 这件事同样值得记住:短查询 + 同步驱动 + GIL,加线程只会把时间花在解释器上。要高并发就换异步驱动,asyncpg 10 连接能到 23601 qps;要简单就单连接加池,psycopg3 足够快。

选驱动别看单连接跑分:psycopg3 单连接比 asyncpg 快 39%,但 asyncpg 并发能反超 2 倍以上。先想清楚你的并发模型,再选驱动。

数据来源与检索时间

检索时间 2026-11-10。全部 qps、延迟、连接成本均为本机实测。

来源URL对应数据
psycopg 3 官方文档《Connection pools》https://www.psycopg.org/psycopg3/docs/advanced/pool.html"建立新连接的时间可能比较长,保持连接打开可以降低延迟";min_size/max_size 语义与排队行为;wait() 的启动检查作用;"池该多大"的官方回答与 get_stats() 指标清单;ConnectionPool 在独立包 psycopg_pool
asyncpg 官方文档《Usage》https://magicstack.github.io/asyncpg/current/usage.html面向"频繁请求、短暂使用连接"的服务推荐连接池;其池"消除了使用 PgBouncer 这类外部池化器的必要";参数占位符为 $1 语法

核验说明

  • 五组吞吐测试与两组连接成本测试均在本机 Docker 内执行;客户端与服务端同一网络,无宿主端口转发。
  • 测试用的查询是最简单的单行主键点查,结果不能代表复杂查询;psycopg3 的线程版测的是"10 线程各持一个连接",没有测连接池版本——池化后排队开销会低一些,但 GIL 的限制不变。
  • 连接成本 1.8ms / 3.4ms 是"同一台机器、容器网络"的数字,跨网络会比这个大一个数量级。
  • 没有测:连接池饱和后的排队延迟、PgBouncer 叠加后的效果、以及 pgbouncer 事务模式下的 prepared statement 开销。这三项都会影响最终选型,但需要另设实验。