HorizonDB reduces WAL overhead with smarter FPI (full-page image) than traditional PostgreSQL
Azure HorizonDB exposes the familiar PostgreSQL statistics views because it is fully compatible with PostgreSQL. However, its compute and storage architecture differs from traditional PostgreSQL. I was curious whether these architectural differences appear in standard PostgreSQL statistics. I performed the same pgbench initialization steps and transactional workload on:
- Azure Database for PostgreSQL Flexible Server, which runs the communtity PostgreSQL and extensions
- Azure HorizonDB (Preview), which is PostgreSQL with disaggregated storage
This is not a performance or cost comparison. The instances are not equivalent in compute capacity. The objective is to compare what PostgreSQL itself reports through the cumulative statistics views:
-
pg_stat_io, which groups I/O operations by backend type, object, and context -
pg_stat_checkpointer, which reports checkpoint requests, buffers written, and synchronization time -
pg_stat_wal, which reports WAL records, full-page images, bytes, writes, and synchronizations
Each experiment below follows the same structure: the raw output, a table of the counters that matter, and the architectural signal that can reasonably be inferred from them.
The experiments follow the natural pgbench initialization order and are state-dependent: table generation, primary keys, foreign keys, and VACUUM each operate on the result of the preceding phase. The central question is why the same wal_fpi counter records full-page images for two reasons: checkpoint-based torn-page protection in conventional PostgreSQL and delivery of a base page image to HorizonDB storage.
Experimental method
I initialized the same pgbench scale factor on both systems: -s 800. I first recreated the empty pgbench tables with pgbench -iIdt -s 800, then ran the initialization phases separately. The scale factor creates 80 million rows in pgbench_accounts and approximately 10 GB of heap data.
For the initialization phases, I:
- Issued
CHECKPOINTand reset all shared statistics before table generation. - Ran one
pgbenchinitialization step. - Issued
CHECKPOINT, so that the statistics included the processing of dirty buffers created by that step. - Read
pg_stat_wal,pg_stat_checkpointer, andpg_stat_io. - Reset the shared statistics before continuing to the next dependent step.
The final transactional workload differs slightly: the statistics had just been reset after the VACUUM phase. I then issued a checkpoint and ran pgbench without a final checkpoint.
I prepared the following query to read the IO statistics:
prepare delta_stat_io as
select
pg_size_pretty(reads * op_bytes) as read,
pg_size_pretty(writes * op_bytes) as write,
pg_size_pretty(extends * op_bytes) as extend,
pg_size_pretty(hits * op_bytes) as hits,
*
from pg_stat_io
where row(
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
) <> row( reads, read_time, writes, write_time, writebacks, writeback_time, extends, extend_time, hits, evictions, reuses, fsyncs, fsync_time )
order by coalesce(reads, 0) + coalesce(writes, 0) desc
;
I did not enable track_io_timing and track_wal_io_timing, so timing was not collected. I am interested in the number of calls, blocks, and bytes.
Configuration
I set up the two instances with shared buffer allocations that are intentionally close so that the I/O patterns can be compared: 12 GB for PostgreSQL and 11 GB for HorizonDB.
Conventional PostgreSQL buffers pages in two memory pools: userspace in PostgreSQL shared buffers and kernel space in the operating-system filesystem cache. HorizonDB avoids this double caching and provides compute replicas with a local NVMe page cache, allowing a larger share of RAM to be allocated to shared buffers. I provisioned Azure HorizonDB (Preview) with 2 vCores and 16 GiB RAM. Its 11241MB setting represents approximately 70% of that memory. Because PostgreSQL relies on the filesystem cache and allocates 25% of RAM to shared buffers, I provisioned Azure Database for PostgreSQL Flexible Server with 12 vCores and 48 GiB RAM.
| Parameter | PostgreSQL | HorizonDB |
|---|---|---|
shared_buffers |
12GB | 11241MB |
effective_cache_size |
36GB | 11241MB |
Note that effective_cache_size does not allocate memory. It is an estimate used by the planner. On conventional PostgreSQL, it typically includes the expected contribution of the operating-system filesystem cache, in addition to shared_buffers.
Among the other parameters, the most important difference is full_page_writes. In PostgreSQL, it is on, so the first modification of a page after a checkpoint can log a full-page image rather than just the change vector. This allows recovery to restore a page affected by a partial write. HorizonDB protects against torn pages in the distributed storage layer, so it is set to off to reduce the WAL generated.
The WAL-file-management parameters also differ:
| Parameter | PostgreSQL | HorizonDB |
|---|---|---|
wal_init_zero |
on | off |
wal_recycle |
on | off |
max_wal_size |
2GB | 12GB |
checkpoint_timeout |
10min | 200s |
data_checksums |
on | off |
restart_after_crash |
on | off |
fsync |
on | on |
wal_sync_method |
fdatasync | fdatasync |
These settings do not fully describe the storage implementation, but they show that HorizonDB does not manage WAL files and checkpoint scheduling exactly as conventional PostgreSQL does.
Experiment 1: Generate the table data
This first experiment establishes the baseline: the same logical work, the same buffer manager, and the first visible divergence in WAL composition.
I generated the heap data without indexes:
checkpoint;
select pg_stat_reset_shared();
\! pgbench -iIG -s 800
checkpoint;
select * from pg_stat_wal;
select * from pg_stat_checkpointer;
execute delta_stat_io;
select pg_stat_reset_shared();
PostgreSQL Flexible Server output:
generating data (server-side)...
done in 203.84 s (server-side generate 203.84 s).
CHECKPOINT
wal_records | wal_fpi | wal_bytes | wal_buffers_full | wal_write | wal_sync | wal_write_time | wal_sync_time
-------------+---------+-------------+------------------+-----------+----------+----------------+---------------
80009422 | 361 | 12160725691 | 560783 | 561412 | 778 | 0 | 0
(1 row)
num_timed | num_requested | restartpoints_timed | restartpoints_req | restartpoints_done | write_time | sync_time | buffers_written
-----------+---------------+---------------------+-------------------+--------------------+------------+-----------+----------------
0 | 11 | 0 | 0 | 0 | 121159 | 49123 | 1311879
(1 row)
read | write | extend | hits | backend_type | object | context | reads | writes | writebacks | extends | op_bytes | hits | evictions | reuses | fsyncs
---------+---------+------------+--------+-------------------+----------+---------+-------+---------+------------+---------+----------+----------+-----------+--------+-------
| 10 GB | | | checkpointer | relation | normal | | 1311879 | 1311879 | | 8192 | | | | 63
296 kB | 0 bytes | 10 GB | 631 GB | client backend | relation | normal | 37 | 0 | 0 | 1311855 | 8192 | 82658063 | 0 | | 0
64 kB | 0 bytes | 8192 bytes | 19 MB | autovacuum worker | relation | normal | 8 | 0 | 0 | 1 | 8192 | 2384 | 0 | | 0
0 bytes | 0 bytes | 0 bytes | 136 kB | autovacuum worker | relation | vacuum | 0 | 0 | 0 | 0 | 8192 | 17 | 0 | 0 |
(4 rows)
HorizonDB output:
generating data (server-side)...
done in 144.40 s (server-side generate 144.40 s).
CHECKPOINT
wal_records | wal_fpi | wal_bytes | wal_buffers_full | wal_write | wal_sync | wal_write_time | wal_sync_time
-------------+---------+-------------+------------------+-----------+----------+----------------+---------------
81321067 | 1312186 | 12544965939 | 0 | 2220 | 2125 | 0 | 0
(1 row)
num_timed | num_requested | restartpoints_timed | restartpoints_req | restartpoints_done | write_time | sync_time | buffers_written
-----------+---------------+---------------------+-------------------+--------------------+------------+-----------+----------------
0 | 2 | 0 | 0 | 0 | 109454 | 2 | 1311878
(1 row)
read | write | extend | hits | backend_type | object | context | reads | writes | writebacks | extends | op_bytes | hits | evictions | reuses | fsyncs
---------+---------+----------+--------+-------------------+----------+---------+-------+---------+------------+---------+----------+----------+-----------+--------+-------
| 10 GB | | | checkpointer | relation | normal | | 1311878 | 1311879 | | 8192 | | | | 0
72 kB | 0 bytes | 32 kB | 17 MB | autovacuum worker | relation | normal | 9 | 0 | 0 | 4 | 8192 | 2212 | 0 | | 0
32 kB | 0 bytes | 10 GB | 631 GB | client backend | relation | normal | 4 | 0 | 0 | 1311855 | 8192 | 82656973 | 0 | | 0
0 bytes | 0 bytes | 0 bytes | 40 kB | background worker | relation | normal | 0 | 0 | 0 | 0 | 8192 | 5 | 0 | | 0
(4 rows)
At the PostgreSQL buffer-manager level, these executions are nearly identical:
| Metric | PostgreSQL | HorizonDB |
|---|---|---|
| Relation blocks extended | 1,311,855 | 1,311,855 |
| Relation size extended | 10 GB | 10 GB |
| Shared-buffer hits | 82,658,063 | 82,656,973 |
| Hit volume | 631 GB | 631 GB |
| Checkpointer buffers written | 1,311,879 (~10 GB) | 1,311,878 (~10 GB) |
The PostgreSQL query layer created the same number of relation pages, performed nearly the same number of shared-buffer accesses, and passed essentially the same number of dirty buffers to the checkpointer.
HorizonDB retains PostgreSQL's checkpointer process and buffer-management accounting. The statistics show an active checkpointer processing the same volume of dirty buffers. On HorizonDB, those writes maintain the compute replica's local SSD page cache but do not make relation pages durable. Durability and high availability are offloaded to the storage layer.
The volume processed by the checkpointer is the same, but the synchronization is not:
| Metric | PostgreSQL | HorizonDB |
|---|---|---|
Checkpointer sync_time
|
49,123 ms | 2 ms |
Relation fsyncs
|
63 | 0 |
Only PostgreSQL Flexible Server exposes conventional relation-file synchronization activity. Therefore, the writes counter cannot be interpreted the same way on both systems. It records a buffer write operation visible to PostgreSQL. On HorizonDB, that operation can populate or update the local cache without participating in durability.
The WAL remains at the core of durability. Both systems generated approximately 12 GB of WAL:
pg_stat_wal column |
PostgreSQL Flexible Server | HorizonDB |
|---|---|---|
wal_bytes |
12,160,725,691 | 12,544,965,939 |
wal_records |
80,009,422 | 81,321,067 |
wal_fpi |
361 | 1,312,186 |
wal_buffers_full |
560,783 | 0 |
wal_write |
561,412 | 2,220 |
wal_sync |
778 | 2,125 |
WAL plays a broader role in HorizonDB than in conventional PostgreSQL, and its generation is adapted to that role:
- PostgreSQL writes permanent relation pages at checkpoint or eviction. WAL protects changes until those page writes become durable and provides the change stream for crash recovery and replication.
- In HorizonDB's database-as-a-log architecture, compute sends WAL to durable storage instead of sending data pages. Storage can apply records asynchronously, or apply them to an earlier page version when that page is read. WAL is therefore part of both the write path and the page-read path.
Why wal_fpi is low on PostgreSQL and high on HorizonDB?
In PostgreSQL, a large heap load creates many new pages but logs very few full-page images in the WAL. The pages are created directly in shared buffers, and the WAL records describing their creation are sufficient to reconstruct them during recovery. Full-page images are generated when an existing page is read into shared buffers and modified after a checkpoint, because recovery then needs a reliable base image to apply incremental changes. In this case, the base is an empty page.
The high wal_fpi on HorizonDB may be surprising, especially since full_page_writes = off. These images were not produced by the "first modification after checkpoint" rule. Because pages from shared buffers are not written to storage as relation files, brand-new pages never reach the storage layer. HorizonDB therefore sends the full image rather than an incremental change vector. The 1,312,186 FPIs are close to, but not exactly equal to, the 1,311,855 blocks extended. Other activity in the interval accounts for the aggregate counters not being one-to-one. The resulting WAL volume is essentially the same on both systems.
Experiment 2: Create primary keys
This phase shows the first clear divergence in WAL generation. Building the B-tree indexes creates new index pages and can modify those pages again as the build proceeds.
\! pgbench -iIp -s 800
checkpoint;
select * from pg_stat_wal;
select * from pg_stat_checkpointer;
execute delta_stat_io;
select pg_stat_reset_shared();
Because the preceding statistics reset occurred after the data-generation checkpoint, this interval excludes the heap-loading phase.
PostgreSQL Flexible Server output
creating primary keys...
done in 123.93 s (primary keys 123.93 s).
CHECKPOINT
wal_records | wal_fpi | wal_bytes | wal_buffers_full | wal_write | wal_sync | wal_write_time | wal_sync_time
-------------+---------+------------+------------------+-----------+----------+----------------+---------------
1975171 | 2187495 | 2506687103 | 0 | 459 | 459 | 0 | 0
(1 row)
num_timed | num_requested | restartpoints_timed | restartpoints_req | restartpoints_done | write_time | sync_time | buffers_written
-----------+---------------+---------------------+-------------------+--------------------+------------+-----------+----------------
0 | 2 | 0 | 0 | 0 | 84374 | 588 | 1311559
(1 row)
read | write | extend | hits | backend_type | object | context | reads | writes | writebacks | extends | op_bytes | hits | evictions | reuses | fsyncs
------------+---------+------------+---------+-------------------+----------+----------+-------+---------+------------+---------+----------+--------+-----------+--------+-------
| 10 GB | | | checkpointer | relation | normal | | 1311559 | 1311559 | | 8192 | | | | 46
136 kB | 0 bytes | 0 bytes | 124 MB | client backend | relation | normal | 17 | 0 | 0 | 0 | 8192 | 15897 | 0 | | 0
40 kB | 0 bytes | 0 bytes | 1928 kB | background worker | relation | normal | 5 | 0 | 0 | 0 | 8192 | 241 | 0 | | 0
8192 bytes | 0 bytes | 8192 bytes | 7720 kB | autovacuum worker | relation | normal | 1 | 0 | 0 | 1 | 8192 | 965 | 0 | | 0
0 bytes | 0 bytes | 0 bytes | 736 kB | autovacuum worker | relation | vacuum | 0 | 0 | 0 | 0 | 8192 | 92 | 0 | 0 |
0 bytes | 0 bytes | | 5117 MB | client backend | relation | bulkread | 0 | 0 | 0 | | 8192 | 654924 | 0 | 0 |
0 bytes | 0 bytes | | 5129 MB | background worker | relation | bulkread | 0 | 0 | 0 | | 8192 | 656552 | 0 | 0 |
(7 rows)
HorizonDB output
creating primary keys...
done in 76.90 s (primary keys 76.90 s).
CHECKPOINT
wal_records | wal_fpi | wal_bytes | wal_buffers_full | wal_write | wal_sync | wal_write_time | wal_sync_time
-------------+---------+-----------+------------------+-----------+----------+----------------+---------------
78812 | 219396 | 844376348 | 0 | 594 | 527 | 0 | 0
(1 row)
num_timed | num_requested | restartpoints_timed | restartpoints_req | restartpoints_done | write_time | sync_time | buffers_written
-----------+---------------+---------------------+-------------------+--------------------+------------+-----------+----------------
0 | 1 | 0 | 0 | 0 | 61364 | 1 | 1091695
(1 row)
read | write | extend | hits | backend_type | object | context | reads | writes | writebacks | extends | op_bytes | hits | evictions | reuses | fsyncs
------------+---------+---------+---------+-------------------+----------+---------+-------+---------+------------+---------+----------+--------+-----------+--------+-------
| 8529 MB | | | checkpointer | relation | normal | | 1091695 | 1311555 | | 8192 | | | | 0
96 kB | 0 bytes | 0 bytes | 3762 MB | client backend | relation | normal | 12 | 0 | 0 | 0 | 8192 | 481524 | 0 | | 0
8192 bytes | 0 bytes | 32 kB | 1132 MB | autovacuum worker | relation | normal | 1 | 0 | 0 | 4 | 8192 | 144890 | 0 | | 0
0 bytes | 0 bytes | 0 bytes | 6628 MB | background worker | relation | normal | 0 | 0 | 0 | 0 | 8192 | 848358 | 0 | | 0
(4 rows)
The logical work here is different from the heap load. Building a B-tree requires scanning the existing table, sorting the keys, and writing index pages. The statistics reflect another cache difference. On PostgreSQL Flexible Server, the table scan appears in the bulkread context. PostgreSQL uses a small ring of shared buffers for large scans so it does not displace useful pages from both shared buffers and the filesystem cache. HorizonDB reports the scan in the normal context. With one compute page-cache hierarchy rather than PostgreSQL plus the filesystem cache, it does not need the same protection against polluting two caches.
| Metric | PostgreSQL | HorizonDB |
|---|---|---|
| shared-buffer hits | 10 GB bulkread
|
11 GB normal
|
| WAL records | 1,975,171 | 78,812 |
| Full-page images | 2,187,495 | 219,396 |
| WAL volume | 2.51 GB | 844 MB |
| Checkpointer writes | 1,311,559 | 1,091,695 |
Relation fsyncs
|
46 | 0 |
The whole table was cached on both systems, so the indexes were built essentially from memory.
Both systems created the same indexes, yet HorizonDB generated about one third of the WAL volume and one tenth of the full-page images. The gap is wider than during heap generation. B-tree construction creates new index pages and subsequently modifies pages created during the build. On conventional PostgreSQL, modifications after the checkpoint can trigger full-page images because full_page_writes = on. On HorizonDB, new pages require base images, while later modifications can be represented by incremental WAL once storage has a valid base. HorizonDB still shows substantial checkpointer activity — more than one million dirty buffers processed — but avoids most checkpoint-driven FPI overhead.
One accounting detail is worth noting before it is misread. In the HorizonDB output, writes = 1,091,695 and writebacks = 1,311,555. These are different PostgreSQL accounting events and must not be added together to estimate physical storage traffic. Neither is necessarily a unique durable page write, particularly with a distributed storage layer underneath.
In HorizonDB, the PostgreSQL instance on compute still manages buffers, WAL, and checkpoints. Durability and recovery protection are offloaded from the traditional compute-side combination of relation-file writes, fsync operations, and checkpoint-driven full-page images to the storage layer.
Experiment 3: Create foreign keys
I created the foreign keys as the next natural pgbench initialization step:
\! pgbench -iIf -s 800
checkpoint;
select * from pg_stat_wal;
select * from pg_stat_checkpointer;
execute delta_stat_io;
select pg_stat_reset_shared();
Foreign-key creation primarily validates existing data. Because this article focuses on writes and full-page images, this read-oriented phase adds no useful architectural signal. I keep it in the sequence because the following VACUUM operates on the database state it produced, but omit its statistics.
Experiment 4: VACUUM — the key experiment
This is the most revealing experiment of the article.
The preceding checkpoint establishes a clean recovery boundary, and VACUUM then revisits nearly every page in the database. If a checkpoint-related page-protection mechanism exists, it must appear in the WAL statistics here.
VACUUM is also one of PostgreSQL's most disliked operational costs because its work can generate substantial I/O and WAL activity and is difficult to predict. Offloading durability work and avoiding checkpoint-driven FPIs make that maintenance path lighter and more predictable, even though VACUUM remains part of PostgreSQL itself.
\! pgbench -iIv -s 800
checkpoint;
select * from pg_stat_wal;
select * from pg_stat_checkpointer;
execute delta_stat_io;
select pg_stat_reset_shared();
PostgreSQL Flexible Server output
vacuuming...
done in 91.63 s (vacuum 91.63 s).
CHECKPOINT
wal_records | wal_fpi | wal_bytes | wal_buffers_full | wal_write | wal_sync | wal_write_time | wal_sync_time
-------------+---------+-------------+------------------+-----------+----------+----------------+---------------
1311609 | 1311543 | 1128836191 | 0 | 466 | 466 | 0 | 0
(1 row)
num_timed | num_requested | restartpoints_timed | restartpoints_req | restartpoints_done | write_time | sync_time | buffers_written
-----------+---------------+---------------------+-------------------+--------------------+------------+-----------+----------------
0 | 2 | 0 | 0 | 0 | 61867 | 707 | 1311543
(1 row)
read | write | extend | hits | backend_type | object | context | reads | writes | writebacks | extends | op_bytes | hits | evictions | reuses | fsyncs
---------+---------+---------+------------+-------------------+----------+---------+-------+---------+------------+---------+----------+---------+-----------+--------+-------
| 10 GB | | | checkpointer | relation | normal | | 1311543 | 1311543 | | 8192 | | | | 31
0 bytes | 0 bytes | 344 kB | 10 GB | client backend | relation | normal | 0 | 0 | 0 | 43 | 8192 | 1330219 | 0 | | 0
0 bytes | 0 bytes | 0 bytes | 10 GB | client backend | relation | vacuum | 0 | 0 | 0 | 0 | 8192 | 1341529 | 0 | 0 |
0 bytes | 0 bytes | 0 bytes | 8376 kB | autovacuum worker | relation | normal | 0 | 0 | 0 | 0 | 8192 | 1047 | 0 | | 0
0 bytes | 0 bytes | 0 bytes | 8192 bytes | autovacuum worker | relation | vacuum | 0 | 0 | 0 | 0 | 8192 | 1 | 0 | 0 |
(5 rows)
HorizonDB output
vacuuming...
done in 4.97 s (vacuum 4.97 s).
CHECKPOINT
wal_records | wal_fpi | wal_bytes | wal_buffers_full | wal_write | wal_sync | wal_write_time | wal_sync_time
-------------+---------+-----------+------------------+-----------+----------+----------------+---------------
994350 | 74 | 58687203 | 0 | 65 | 60 | 0 | 0
(1 row)
num_timed | num_requested | restartpoints_timed | restartpoints_req | restartpoints_done | write_time | sync_time | buffers_written
-----------+---------------+---------------------+-------------------+--------------------+------------+-----------+----------------
0 | 1 | 0 | 0 | 0 | 56522 | 1 | 994230
(1 row)
read | write | extend | hits | backend_type | object | context | reads | writes | writebacks | extends | op_bytes | hits | evictions | reuses | fsyncs
---------+---------+------------+--------+-------------------+----------+---------+-------+--------+------------+---------+----------+---------+-----------+--------+-------
| 7767 MB | | | checkpointer | relation | normal | | 994230 | 994230 | | 8192 | | | | 0
88 kB | 0 bytes | 8192 bytes | 667 MB | autovacuum worker | relation | normal | 11 | 0 | 0 | 1 | 8192 | 85341 | 0 | | 0
0 bytes | 0 bytes | 248 kB | 15 GB | client backend | relation | normal | 0 | 0 | 0 | 31 | 8192 | 1947191 | 0 | | 0
(3 rows)
Here are the interesting statistics:
| Metric | PostgreSQL | HorizonDB |
|---|---|---|
| WAL records | 1,311,609 | 994,350 |
| Full-page images (FPI) | 1,311,543 | 74 |
| WAL bytes | 1.13 GB | 58.7 MB |
| Checkpointer buffers written | 1,311,543 | 994,230 |
In PostgreSQL, WAL FPI corresponds to the number of buffers written. While this strong correlation suggests that checkpoint-triggered full-page images are in use, these aggregate counters do not confirm page-by-page accuracy: wal_fpi tracks images in WAL, whereas buffers_written reflects checkpointer write operations. This pattern aligns exactly with what full_page_writes = on aims to achieve after a checkpoint: the initial modification of a page logs a complete image, ensuring recovery does not depend on potentially partial relation-page writes.
HorizonDB shows the opposite pattern, with nearly one million dirty buffers handled by the PostgreSQL checkpointer, yet only 74 full-page images were produced. The checkpointer remains operational, and its writes primarily update the cache state instead of following PostgreSQL's conventional durable relation-file recovery method.
The WAL volume makes the effect concrete: the same maintenance operation produced roughly twenty times less WAL on HorizonDB (1,128,836,191 bytes versus 58,687,203 bytes).
This single experiment explains most of the WAL differences observed in the other phases. It isolates the recovery semantics from the workload itself: both systems modified a large number of existing pages after a checkpoint, but only conventional PostgreSQL had to protect them with full-page images.
The checkpointer counters confirm that the buffer processing is real on both sides:
| Metric | PostgreSQL | HorizonDB |
|---|---|---|
| Buffers written | 1,311,543 | 994,230 |
Checkpointer sync_time
|
707 ms | 1 ms |
Relation fsyncs
|
31 | 0 |
HorizonDB offloads the filesystem-oriented durability work traditionally associated with checkpoints: relation-file synchronization and checkpoint-driven full-page-image logging. Compute-side writes can still be useful for the local SSD cache, while page durability and crash recovery are handled in the storage layer.
Experiment 5: Transactional workload
Initialization exercises involve bulk operations for a specific purpose. This phase verifies if the VACUUM observation applies also to regular OLTP activity. I executed the built-in pgbench transaction for 15 minutes:
checkpoint;
\! pgbench -n -c 10 -T 900
select * from pg_stat_wal;
select * from pg_stat_checkpointer;
execute delta_stat_io;
select pg_stat_reset_shared();
The statistics had already been reset by the final statement of Experiment 4. The explicit checkpoint shown here is included in this interval, which matches num_requested = 1 in both outputs.
Unlike the initialization phases, I did not issue a final checkpoint before reading the statistics. The counters therefore show work performed during the 900-second interval, but not necessarily the eventual processing of every page dirtied by it.
PostgreSQL Flexible Server output
pgbench (16.2, server 17.10)
transaction type: <builtin: TPC-B (sort of)>
scaling factor: 800
query mode: simple
number of clients: 10
number of threads: 1
maximum number of tries: 1
duration: 900 s
number of transactions actually processed: 44053
number of failed transactions: 0 (0.000%)
latency average = 203.822 ms
initial connection time = 2294.257 ms
tps = 49.062339 (without initial connection time)
wal_records | wal_fpi | wal_bytes | wal_buffers_full | wal_write | wal_sync | wal_write_time | wal_sync_time
-------------+---------+-----------+------------------+-----------+----------+----------------+---------------
472835 | 85311 | 228228681 | 0 | 46112 | 46112 | 0 | 0
(1 row)
num_timed | num_requested | restartpoints_timed | restartpoints_req | restartpoints_done | write_time | sync_time | buffers_written
-----------+---------------+---------------------+-------------------+--------------------+------------+-----------+----------------
1 | 1 | 0 | 0 | 0 | 25 | 1 | 31920
(1 row)
read | write | extend | hits | backend_type | object | context | reads | writes | writebacks | extends | op_bytes | hits | evictions | reuses | fsyncs
------------+---------+---------+-------+-------------------+----------+---------+-------+--------+------------+---------+----------+---------+-----------+--------+-------
318 MB | 0 bytes | 8312 kB | 18 GB | client backend | relation | normal | 40714 | 0 | 0 | 1039 | 8192 | 2386621 | 0 | | 0
| 249 MB | | | checkpointer | relation | normal | | 31920 | 31904 | | 8192 | | | | 0
8192 bytes | 0 bytes | 72 kB | 99 MB | autovacuum worker | relation | normal | 1 | 0 | 0 | 9 | 8192 | 12702 | 0 | | 0
0 bytes | 0 bytes | 0 bytes | 31 MB | autovacuum worker | relation | vacuum | 0 | 0 | 0 | 0 | 8192 | 3904 | 0 | 0 |
(4 rows)
HorizonDB output
pgbench (16.2, server 17.9 (Azure HorizonDB (1b3bcd789c4)(release)))
transaction type: <builtin: TPC-B (sort of)>
scaling factor: 800
query mode: simple
number of clients: 10
number of threads: 1
maximum number of tries: 1
duration: 900 s
number of transactions actually processed: 44530
number of failed transactions: 0 (0.000%)
latency average = 201.639 ms
initial connection time = 2302.203 ms
tps = 49.593642 (without initial connection time)
wal_records | wal_fpi | wal_bytes | wal_buffers_full | wal_write | wal_sync | wal_write_time | wal_sync_time
-------------+---------+-----------+------------------+-----------+----------+----------------+---------------
466156 | 1273 | 35362656 | 0 | 44789 | 44789 | 0 | 0
(1 row)
num_timed | num_requested | restartpoints_timed | restartpoints_req | restartpoints_done | write_time | sync_time | buffers_written
-----------+---------------+---------------------+-------------------+--------------------+------------+-----------+----------------
4 | 1 | 0 | 0 | 0 | 890 | 12 | 79389
(1 row)
read | write | extend | hits | backend_type | object | context | reads | writes | writebacks | extends | op_bytes | hits | evictions | reuses | fsyncs
---------+---------+------------+--------+-------------------+----------+---------+-------+--------+------------+---------+----------+---------+-----------+--------+-------
| 620 MB | | | checkpointer | relation | normal | | 79389 | 79448 | | 8192 | | | | 0
321 MB | 0 bytes | 8448 kB | 18 GB | client backend | relation | normal | 41101 | 0 | 0 | 1056 | 8192 | 2421167 | 0 | | 0
0 bytes | 0 bytes | 8192 bytes | 141 MB | autovacuum worker | relation | normal | 0 | 0 | 0 | 1 | 8192 | 18044 | 0 | | 0
(3 rows)
The logical workload executed by both systems was almost identical:
| Metric | PostgreSQL | HorizonDB |
|---|---|---|
| Transactions | 44,053 | 44,530 |
| Average latency | 203.8 ms | 201.6 ms |
| TPS | 49.1 | 49.6 |
| Client reads | 318 MB | 321 MB |
| Shared-buffer hits | 18 GB | 18 GB |
| WAL records | 472,835 | 466,156 |
I am not using this as a performance result. It matters because it establishes that the two systems executed comparable transactional work and produced comparable buffer activity.
The difference is entirely in WAL composition:
| Metric | PostgreSQL | HorizonDB |
|---|---|---|
| Full-page images | 85,311 | 1,273 |
| WAL volume | 228 MB | 35 MB |
The WAL-record counts are similar, while PostgreSQL generated about 67 times more full-page images and 6.45 times the WAL volume. The similar transaction, buffer-access, and WAL-record counts indicate comparable logical work. They do not make the environments identical. The server versions, compute capacity, and checkpoint schedules differ, with one timed checkpoint on PostgreSQL Flexible Server and four on HorizonDB. Within those limits, the result is consistent with the different page-durability mechanisms observed in the preceding phases.
The PostgreSQL execution path stays recognizable on both systems:
| Observation | PostgreSQL | HorizonDB |
|---|---|---|
| Relation reads visible | Yes | Yes |
| Shared-buffer hits visible | Yes | Yes |
| WAL writes visible | Yes | Yes |
| Checkpointer writes visible | Yes | Yes |
Relation fsyncs
|
None in interval | None |
This confirms that the behavior observed during VACUUM is not limited to bulk or maintenance operations. During normal transactional processing, HorizonDB still relies on PostgreSQL WAL generation and checkpoints but avoids full-page-image logging after checkpoints.
What the counters ultimately mean
The names and views of PostgreSQL processes remain familiar, but their actual meanings have shifted. A write showing up in the HorizonDB checkpointer indicates that PostgreSQL processed a dirty buffer and might have updated the local SSD cache on the compute replica. However, this does not mean a typical local relation-file write made the page durable.
This distinction aligns with the ARIES recovery algorithm, which allows the database to use a no-force policy—meaning commit doesn't require all pages to be written to their durable locations as long as the WAL is durable. HorizonDB extends this separation: compute relation writes serve as cache updates, while the durable WAL and page states are stored in the storage layer.
The role of WAL also evolves. In traditional PostgreSQL, WAL protects changes until relation pages are durable, supporting crash recovery and replication. In HorizonDB's database-as-a-log architecture, WAL becomes the primary write stream and can directly participate in reading a page by applying changes to an earlier stored version.
Conclusion
The experiments reveal two storage architectures beneath the same PostgreSQL database engine, with the same SQL processing and transaction semantics.
In PostgreSQL Flexible Server, dirty relation pages flow from shared buffers to durable relation files. Checkpoints coordinate those writes and full-page images protect recovery from partial page writes. WAL accompanies the data path mainly for crash recovery and replication.
In HorizonDB's compute instance, relation writes maintain a local SSD cache and are not the durability path. WAL is made durable in the storage layer, where it is also used to materialize pages read by the compute layer. A read can start from an earlier page image and apply WAL up to the requested LSN. Full-page images establish base versions for pages that storage has not seen before, rather than protecting local relation-file writes at every checkpoint boundary.
The familiar checkpointer and WAL counters therefore remain useful, but they describe logical PostgreSQL work at an architectural boundary. In HorizonDB, durability is offloaded, compute writes serve the cache, and WAL is part of both writing and reading the database.