Postgres with QUIC
Every backend engineer running Postgres at scale eventually learns the same painful lesson: connection pooling does not fix network physics. We deploy PgBouncer or PgCat, configure transaction pooling, lock down backend…
以下正文同步自 Hacker News,版权归原站所有,已转换为易读排版。
View Full Visual
Every backend engineer running Postgres at scale eventually learns the same painful lesson: connection pooling does not fix network physics. We deploy PgBouncer or PgCat, configure transaction pooling, lock down backend connections so the database doesn't run out of memory, and celebrate. But if your application or edge services sit tens of milliseconds away from your database cluster, your queries are still choking on a transport bottleneck we rarely talk about: the client-side TCP connection pool.
Recently, I ran an experiment to address this directly: running the Postgres protocol over QUIC (UDP) instead of traditional TCP. We modified PgCat to accept QUIC connections and multiplexed hundreds of virtual database streams over a tiny handful of UDP associations.
The outcome was startling. Under heavy load, our QUIC setup pushed 9,718 queries per second at an average latency of 246ms. The identical workload over traditional TCP collapsed into a catastrophic queue pileup of over 40,000 backlogged queries, spiraling to 7,088ms average latency.
Here is the real engineering breakdown of why this happens, the arithmetic of latency asymmetry, where QUIC genuinely changes the rules, and where it cannot save you.
To understand the problem, you have to look at the PostgreSQL frontend/backend wire protocol (Protocol 3.0). Postgres is fundamentally synchronous at the connection layer.
When a client sends a Query or Execute message down a Postgres TCP connection, that socket is tied up until the server finishes processing and returns the matching CommandComplete and ReadyForQuery message. You cannot interleave two independent queries from different application threads on the same physical connection without strict sequential serialization.
1 Connection = 1 Active In-Flight Query or Transaction. A client socket is completely blocked from the moment bytes leave the network card until the final row batch and ReadyForQuery flag return across the wire.
Because establishing a new Postgres backend process on the database server involves fork-exec overhead, memory allocation (several megabytes per connection for work_mem, cache metadata, and process state), and catalog locks, we can't let 5,000 application threads open 5,000 direct connections to Postgres. The server would instantly melt from context switching and OOM crashes.
So we put connection poolers in the middle. But look closely at where the pooler actually sits.
In modern distributed architectures, your services don't live in the database rack. You have edge nodes, serverless workers, microservices in different availability zones, or regional clusters in us-east-1 querying a centralized database cluster in us-east-2 or eu-central-1.
Let's consider a realistic, standard production deployment:
Each query occupies 1 connection for 30.5ms total waiting on network wire flight
The database engine executes the query in 0.5ms and the internal pooler recycles the connection in 2ms. But the client socket cannot be reused for 30.5ms because the bytes are still flying over the public internet or cross-region fiber!
Here is where basic arithmetic exposes the flaw.
Suppose your application client maintains a pool of 10 TCP connections. Why 10? Because in microservice architectures, you might have 50 pods. If every pod creates 200 connections, you quickly overwhelm the pooler and exceed connection limits.
Now, calculate the maximum possible throughput your client can achieve:
Read that number carefully: 333 queries per second.
It does not matter that your Postgres server is an AWS r6i.16xlarge with 64 vCPUs sitting at 3% CPU utilization. It does not matter that PgCat has 100 warm backend connections ready to serve. Your client physically cannot send more than 10 queries every 30 milliseconds.
Now, what happens if an inbound burst of 500 HTTP requests hits that pod at once, and each request needs one simple query?
The naive response to this math is: "Just bump client pool size! Change max_connections = 1000!"
Every backend engineer who has tried this in production knows the wall you hit immediately:
Every TCP connection consumes a file descriptor. When you scale pods and raise connection pools, you quickly trip EMFILE: too many open files. Even if you tune ulimit -n, the kernel has to track state for tens of thousands of active sockets across epoll queues.
TCP is not free memory. The Linux kernel allocates transmit and receive buffers (tcp_wmem and tcp_rmem) for every socket. 2,000 open TCP connections can quietly chew through hundreds of megabytes of kernel slab memory just waiting on keep-alives.
If idle connections drop or timeout, re-establishing a TCP connection requires a 3-way handshake (1 RTT) plus a TLS handshake (1 to 2 RTTs). Over a 30ms link, creating a new connection takes 60ms to 90ms before a single byte of SQL can be sent.
If any TCP packet in the sequence drops on the public internet, the TCP window freezes. The entire connection halts until that missing packet is retransmitted and acknowledged, even if the application has subsequent data ready to process.
We are caught in a trap: we need thousands of concurrent queries in flight over the 30ms pipe to utilize the database, but we cannot afford the architectural overhead of thousands of raw TCP connections.
QUIC (RFC 9000) changes the transport mechanics entirely. QUIC runs on top of UDP and provides natively multiplexed, independent bidirectional streams over a single connection.
Here is why this shifts the equation for database pooling:
High memory footprint, socket table limits, and kernel buffer overhead per connection.
Zero kernel socket allocation per query. Concurrency is limited only by buffer limits and pooler capacity.
By removing the socket tax, the client can keep thousands of queries in flight across the 30ms pipe simultaneously. The client queue drops to zero, and the pooler on the other end is finally saturated with the queries it was designed to handle!
Any engineer telling you QUIC is a universal silver bullet for database performance is selling snake oil. We need to be completely clear about the boundary between transport multiplexing and database execution.
QUIC works miracles for single-statement queries (auto-commit reads, single writes, key lookups, and transaction units bundled together). But what happens if your application does this?
QUIC solves the transport concurrency bottleneck. It does not re-architect Postgres MVCC or session state. If your workload is dominated by chatty, multi-statement transactions over WAN, your solution is stored procedures, CTEs, or moving compute co-located with the database. But for high-throughput single-statement traffic, QUIC eliminates the transport wall entirely.
One of the most surprising discoveries during this project was how clean the integration was. We did not have to alter the PostgreSQL wire protocol at all.
In the Rust ecosystem, tokio-postgres provides a low-level primitive: connect_raw(stream, tls_mode).
AWS's s2n-quic library exposes bidirectional streams via s2n_quic::stream::BidirectionalStream. Because this struct implements standard Tokio AsyncRead, AsyncWrite, and Unpin traits, tokio-postgres consumes it as a drop-in byte transport!
// Connecting tokio-postgres directly over an s2n-quic bidirectional stream use s2n_quic::Client; use std::net::SocketAddr; use tokio_postgres::NoTls;
// 1. Establish or reuse the underlying QUIC UDP connection (TLS 1.3 is native) let connect = s2n_quic::client::Connect::new(pooler_addr) .with_server_name("pg-pooler.internal"); let mut connection = quic_client.connect(connect).await?;
// 2. Open an independent, lightweight bidirectional stream over the single connection // This requires NO kernel socket creation, NO 3-way handshake, and NO new file descriptor. let stream = connection.open_bidirectional_stream().await?;
// 3. Hand the raw stream to tokio-postgres. // Because BidirectionalStream implements AsyncRead + AsyncWrite + Unpin, // tokio-postgres treats it as a drop-in transport stream! let (pg_client, pg_connection) = pg_config.connect_raw(stream, NoTls).await?;
// Spawn the Postgres connection driver task in background tokio::spawn(async move { if let Err(e) = pg_connection.await { eprintln!("Postgres connection error on stream: {}", e); } });
// Execute regular synchronous query over the isolated QUIC stream let rows = pg_client.query("SELECT id, username FROM users WHERE id = $1", &[&user_id]).await?;
Notice the NoTls passed to connect_raw. This is deliberate: QUIC mandates TLS 1.3 at the transport layer. Every UDP datagram is already encrypted with TLS 1.3 authenticated encryption (AEAD). Running Postgres TLS inside a QUIC stream would be redundant double encryption!
On the pooler side, we modified PgCat. Instead of only binding a TCP listener, PgCat binds a UDP socket with an s2n-quic server. When a client opens a QUIC stream, PgCat accepts the stream, reads the standard Postgres startup packet, and feeds it directly into its existing transaction pooling state machine.
// Multiplexing pool: 50 physical connections, up to 40 concurrent streams each pub struct QuicManager { connections: Vec<QuicConnectionWrapper>, streams_per_connection: u64, }
impl QuicManager { pub async fn acquire_stream(&self) -> Result<s2n_quic::stream::BidirectionalStream> { // Find an established QUIC connection with capacity < streams_per_connection for conn in &self.connections { if conn.active_streams.load(Ordering::Relaxed) < self.streams_per_connection { conn.active_streams.fetch_add(1, Ordering::Relaxed); return conn.connection.open_bidirectional_stream().await; } } // If all existing connections are saturated at 40 streams, open a new QUIC connection self.spawn_new_quic_connection().await } }
The Hard Numbers: 500 TCP vs 50 QUIC
Giving benchmark numbers without detailing the exact conditions under which they ran is meaningless in systems engineering. If you run these tests on a beefy bare-metal server with 64 dedicated cores and heavily tuned kernel buffers, your inflection points will look very different from running them on a modest VM. The environment, host setup, and network topology dictate when and how the transport wall collapses.
Two critical caveats must be kept in mind when interpreting these results:
Here is how the metrics recorded in our telemetry CSVs stacked up at peak saturation:
Figure 3: Real telemetry comparison over 30-second load ramp. Notice the catastrophic divergence in active query backlog and query latency.
Before you rush to rewrite your database drivers, evaluate your infrastructure topology honestly:
Database connection pooling has spent two decades optimizing the server-side relationship between the pooler and the database engine. We perfected transaction pooling, prepared statement caching, and backend connection pinning.
Yet we left the client-to-pooler hop trapped in the 1980s: one heavyweight TCP socket per concurrent query.
By taking advantage of QUIC's lightweight bidirectional streams, we can eliminate the client-side socket bottleneck without touching PostgreSQL's internal engine. It turns connection pooling from a fragile game of managing file descriptors and kernel buffers into a high-throughput, multiplexed data pipe.
For distributed architectures and edge infrastructure, transport multiplexing isn't an experimental novelty: it is the inevitable future of database connectivity.
The modified PgCat pooler supporting QUIC and the Rust benchmark suite are open source.
正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。