PostgreSQL

Engineering PostgreSQL 18 Asynchronous I/O for Predictable Storage Performance

Pinterest LinkedIn Tumblr

Asynchronous I/O changes the storage bottleneck

PostgreSQL 18 introduces an asynchronous I/O subsystem that allows a backend to submit multiple eligible reads without waiting for every operation to finish sequentially. Initial consumers include sequential scans, bitmap heap scans, and vacuum, so the feature matters most when execution repeatedly reaches physical storage rather than finding pages in shared buffers or the operating-system cache. It does not make every PostgreSQL I/O operation asynchronous, nor does it repair poor plans, undersized memory, or overloaded storage. Its practical value is that PostgreSQL can keep a capable storage path busy while hiding part of the latency of individual requests.

This changes where engineers should expect saturation. A synchronous scan may underutilize a high-IOPS NVMe device because the backend cannot maintain sufficient queue depth. The same workload under asynchronous I/O can drive more outstanding requests, increase throughput, and shorten scan time, but it can also expose SAN controller limits, NFS latency tails, cloud volume throttling, or noisy-neighbor behavior that was previously hidden by PostgreSQL’s serialized demand. A faster scan may therefore produce worse latency for transactional sessions sharing the same device. AIO deployment is a capacity-management exercise, not a parameter-only upgrade.

Selecting an I/O method

The server-start parameter io_method supports worker, io_uring, and sync. The default worker implementation delegates operations to PostgreSQL I/O worker processes and is the conservative production starting point because it does not depend on Linux io_uring support. io_workers, whose default is three, controls the worker pool and only affects the worker method. The io_uring method avoids that worker handoff, but PostgreSQL must have been built with liburing and the operating system, kernel, security policy, and runtime environment must support it correctly.

# postgresql.conf
io_method = 'worker'
io_workers = 6
io_max_concurrency = 64
effective_io_concurrency = 32
maintenance_io_concurrency = 16
io_combine_limit = '128kB'
track_io_timing = on
track_wal_io_timing = on

Changing io_method or io_max_concurrency requires a server restart, so method selection belongs in the upgrade and rollback plan. Confirm the running values rather than trusting configuration-management output, especially when packages, containers, or managed services use generated configuration files. A node that silently falls back to a different build or configuration invalidates performance comparisons.

SELECT name, setting, unit, source, pending_restart
FROM pg_settings
WHERE name IN ('io_method', 'io_workers', 'io_max_concurrency',
               'effective_io_concurrency', 'maintenance_io_concurrency',
               'io_combine_limit', 'io_max_combine_limit');

SELECT version();
SELECT * FROM pg_config WHERE name IN ('CONFIGURE', 'LIBS');

Do not assume io_uring is automatically superior. Kernel revisions, container seccomp profiles, enterprise hardening controls, and vendor support policies all matter. Test it on the exact operating-system image used in production, including the same filesystem, multipathing stack, encryption layer, and cgroup limits. If io_uring initialization or stability is uncertain, the worker method provides real asynchronous behavior with a simpler compatibility envelope. Keep sync as a controlled comparison and operational fallback, not as evidence that the new subsystem has been tested.

Tuning concurrency without destabilizing the platform

effective_io_concurrency describes how many concurrent operations an individual session may attempt and defaults to 16 in PostgreSQL 18. maintenance_io_concurrency applies to maintenance activity performed on behalf of many sessions. Meanwhile, io_max_concurrency places a per-process ceiling on simultaneous operations; its default automatic calculation is capped at 64. These controls interact, so raising only one parameter may have little effect, while raising all of them aggressively can multiply demand across parallel query workers, autovacuum workers, and concurrent reporting sessions.

The correct value is derived from the complete storage path. Local NVMe can often sustain deeper queues than a latency-sensitive shared array, while an NFS service may advertise high aggregate throughput but enforce per-client or per-volume limits. On provisioned cloud storage, queue depth cannot overcome an IOPS or bandwidth cap; it merely creates longer queues. Measure device utilization, average and tail latency, throughput, and queue depth at the host and storage service while increasing PostgreSQL concurrency gradually. Stop when throughput flattens or transactional latency begins rising, even if PostgreSQL can submit more work.

Different tablespaces may require different concurrency. A reporting tablespace on dedicated NVMe should not be constrained by the safe value chosen for an OLTP tablespace on shared SAN, and PostgreSQL permits tablespace overrides for the effective and maintenance concurrency settings. This is useful only when the placement is real: logical volumes on the same underlying array do not create independent capacity.

ALTER TABLESPACE reporting_nvme
  SET (effective_io_concurrency = 64,
       maintenance_io_concurrency = 32);

ALTER TABLESPACE oltp_san
  SET (effective_io_concurrency = 8,
       maintenance_io_concurrency = 4);

io_combine_limit controls the largest combined I/O request, subject to the startup-time ceiling io_max_combine_limit. Larger requests can improve sequential bandwidth and reduce operation overhead, but may increase latency for unrelated small reads and provide no benefit when the storage or filesystem splits them. PostgreSQL silently uses the lower ceiling if the requested combine limit is too high. Validate the effective values and test several representative request sizes rather than copying the advertised maximum of a storage device.

Building a credible validation test

A useful AIO test must distinguish physical reads from cache hits. Repeatedly scanning a relation that fits in RAM primarily benchmarks memory bandwidth, tuple processing, and query execution. Conversely, dropping operating-system caches on a production host is unsafe and produces an artificial state. Use an isolated replica or staging system with production-equivalent storage, a dataset larger than the effective cache, and concurrent OLTP traffic that represents the latency-sensitive workload.

Capture a baseline with io_method=sync, restart, warm the system according to a documented procedure, and execute the same workload under worker and, where supported, io_uring. Test sequential scans, bitmap heap scans, and vacuum separately because they have different demand patterns. A simple demonstration table can verify that the executor reaches the intended paths, although it is not a substitute for replaying production query shapes.

CREATE TABLE aio_demo AS
SELECT g AS id, repeat(md5(g::text), 8) AS payload
FROM generate_series(1, 20000000) AS g;

CREATE INDEX aio_demo_id_brin ON aio_demo USING brin (id);
ANALYZE aio_demo;

EXPLAIN (ANALYZE, BUFFERS, SETTINGS, WAL)
SELECT count(*), sum(length(payload))
FROM aio_demo
WHERE id BETWEEN 4000000 AND 16000000;

VACUUM (VERBOSE, ANALYZE) aio_demo;

Compare elapsed time together with buffer reads, storage latency, CPU consumption, and collateral application latency. A result is not successful merely because the reporting query becomes faster. It is successful when the target workload improves without violating foreground latency objectives, causing autovacuum starvation, exhausting CPU, or pushing a replicated standby behind. Repeat the test at peak concurrency because a single session rarely reveals array contention or cloud throttling.

Observability and failure modes

PostgreSQL 18 extends pg_stat_io with byte counters and provides per-backend I/O statistics through pg_stat_get_backend_io(). The pg_aios view exposes asynchronous I/O handles currently in use. Enable track_io_timing and track_wal_io_timing only after measuring their clock-call overhead with pg_test_timing; timing is more diagnostic than operation counts, but it is disabled by default for a reason. Export rates rather than graphing cumulative counters directly, and preserve reset timestamps so a restart is not mistaken for an I/O collapse.

SELECT backend_type, object, context,
       reads, read_bytes, read_time,
       writes, write_bytes, write_time,
       fsyncs, fsync_time
FROM pg_stat_io
ORDER BY backend_type, object, context;

SELECT * FROM pg_aios;

SELECT pid, backend_type, wait_event_type, wait_event,
       state, query_id
FROM pg_stat_activity
WHERE wait_event_type = 'IO';

Correlate these views with iostat -x, multipath health, NFS client metrics, filesystem errors, and the storage provider’s latency and throttling counters. PostgreSQL can show which backend class is waiting, but it cannot determine whether a latency spike originates in a guest kernel, virtual disk, fabric port, array pool, or remote file server. Monitor WAL separately as well: accelerating heap reads does not imply that commit-path WAL writes have improved. In fact, additional query or maintenance throughput can increase checkpoint and WAL pressure.

The most likely failure is not data corruption but resource unfairness. A few analytical backends can fill the storage queue, causing index probes and transaction commits to experience long tail latency. Vacuum may complete faster in isolation yet compete more aggressively with application reads. On synchronous replication architectures, storage pressure on a standby can surface as commit latency on the primary; on asynchronous standbys, it appears as replay lag and reduced recovery-point confidence. Alert on application latency, physical I/O latency percentiles, replication lag, checkpoint behavior, and autovacuum progress together rather than treating AIO metrics as an isolated subsystem.

Production rollout

Deploy PostgreSQL 18 AIO as a staged infrastructure change. Establish synchronous baselines before the major-version upgrade, begin with the default worker method, and canary one replica or low-risk instance using conservative concurrency. Exercise failover so the promoted node is proven with the same binaries, kernel capability, configuration, and storage limits. Configuration automation should validate pg_settings after restart and reject unexpected pending_restart values.

The final tuning target is predictable service behavior, not maximum scan throughput. Preserve enough storage headroom for checkpoints, WAL, failover recovery, backups, and maintenance bursts; then increase concurrency only where measurements show unused parallel capacity. PostgreSQL 18 gives the database better control over read submission, but it also removes a bottleneck that may have been protecting the rest of the platform. Treat that newly exposed demand as part of the architecture, and AIO becomes a controllable production capability rather than another source of latency surprises.

Write A Comment