A recent case in our Percona Support team started with a familiar complaint. A PostgreSQL standby lagging behind its primary. Although the problem was simple, it brought a specific flavor that’s worth sharing.
The customer had already reached out to AWS Support about the storage layer behind the database, an Amazon FSx filesystem mounted over NFS on the standby’s EC2 instance. Let’s not stigmatize the infrastructure choice. It met the customer’s requirements, and they paid for provisioned resources to guarantee a performance baseline, a better starting point than many similar cases we see.
AWS confirmed that both the EC2 instance and the FSx filesystem looked healthy, with the filesystem sitting at roughly 50% of its provisioned capacity. From that angle, storage looked like a non-issue, and the investigation could easily have moved elsewhere in PostgreSQL.
We see this pattern often enough to call it out. A component reporting comfortable headroom relative to its provisioned limit does not mean the component isn’t the bottleneck. This post walks through what we found and why the standard utilization numbers didn’t show it.
Provisioned IOPS and throughput numbers describe what a storage backend can deliver, regardless of what the path between the database host and that backend can actually carry.
An EC2 instance talking to FSx over NFS goes through several layers, including the instance’s own network bandwidth, the network path to the filesystem, and the filesystem’s own limits. Usually, monitoring dashboards only consider the last of those. If any of the other layers cap out below the provisioned limit, the system can become saturated even as every capacity dashboard still shows green.
That’s exactly what we found here.
The first signal came from vmstat. The “b” column reports the number of processes blocked waiting for I/O to complete. In a healthy system, “b” sits at zero most of the time, with occasional small blips, but not this time. The “wa” column was also consistently high across all samples, pointing to storage/network wait, while si/so stayed low, an early sign that memory wasn’t the initial suspect.
|
1 2 3 4 5 6 7 8 9 10 11 12 |
procs -------------memory------------ ---swap-- -----io---- ---system---- ------cpu----- r b swpd free buff cache si so bi bo in cs us sy id wa st 15 10 839168 1411800 0 199145760 7 18 14 25 23 21 7 15 61 17 0 3 10 839424 1399408 0 199137696 12 84 12 84 198108 221859 11 18 50 20 0 9 5 839424 1453656 0 199109952 12 4 12 8 206128 237222 10 17 51 22 0 10 7 839424 1412500 0 199134752 20 8 44 8 221809 252061 11 18 50 21 0 8 7 839168 1439736 0 199120608 20 56 20 56 212175 236773 11 17 52 20 0 12 8 839424 1436540 0 199113056 28 72 28 544 212031 238994 11 18 51 19 0 5 4 839680 1829036 0 198694080 32 96 32 96 176937 185345 10 16 53 21 0 6 8 839680 1632944 0 198926912 0 0 0 28 213123 253983 10 16 55 19 0 14 15 843008 1677392 0 198866720 8 3620 8 3620 193275 227061 11 16 52 21 0 5 14 843264 1639992 0 198931488 0 288 0 288 237257 273167 11 18 50 21 0 |
That was not an occasional blip. Across all the samples, we classified every reading:
|
1 2 3 4 5 6 |
awk '$1 ~ /^[0-9]+$/ && $2 ~ /^[0-9]+$/ { b=$2+0; n++; if (b==0) a++; else if (b<=5) c++; else if (b<=10) d++; else e++ } END { printf "b = 0 : %6d (%5.1f%%)\nb = 1-5 : %6d (%5.1f%%)\nb = 6-10 : %6d (%5.1f%%)\nb > 10 : %6d (%5.1f%%)\ntotal : %6d\n", a,100*a/n, c,100*c/n, d,100*d/n, e,100*e/n, n }' vmstat.log b = 0 : 1 ( 0.6%) b = 1-5 : 86 ( 47.8%) b = 6-10 : 79 ( 43.9%) b > 10 : 14 ( 7.8%) total : 180 |
“b” was above zero in 99.4% of samples, and above 5 more than half the time. The CPU columns in the same output showed a consistent I/O wait (wa) value across all lines. Both point to a storage subsystem bottleneck, somewhere between the disk, the network, or the NFS server.
nfsiostat displays NFS traffic by read and write operations, including latency. Looking at averages:
|
1 2 3 4 5 6 7 8 9 |
fs-xxxxxxxxxxxxxxxxx.fsx.us-east-1.amazonaws.com:/fsx mounted on /postgres/data1: ops/s rpc bklog 15927.396 0.000 read: ops/s kB/s kB/op retrans avg RTT (ms) avg exe (ms) avg queue (ms) errors 7144.840 303386.959 42.462 0 (0.0%) 1.304 12.281 10.960 72 (0.0%) write: ops/s kB/s kB/op retrans avg RTT (ms) avg exe (ms) avg queue (ms) errors 3040.323 94332.387 31.027 0 (0.0%) 1.976 136.517 134.531 0 (0.0%) |
Average write exe time is 136.52 ms, most of which (134.53 ms) is queue time. That’s already a red flag, as 136.52 ms is long enough for any modern storage system. Since most of the time is spent in the queue rather than in the actual round trip, the bottleneck doesn’t seem to be the underlying disks themselves.
Comparing peak and p95 usage against the provisioned limits looked fine at first:
|
1 2 3 4 5 6 7 8 9 |
grep -A1 '^read:' nfsiostat.log | awk '/^ +[0-9]/{print $2}' | tail -n +2 | sort -rn | \ awk -v prov=1280 'NR==1{max=$1}{v[NR]=$1} END{p95=v[int(NR*0.05)]; printf "NFS reads (%d x 1s samples) vs %d MB/s provisioned\n PEAK : %7.0f MB/s (%.2f Gbit/s) = %2.0f%% of provisioned\n p95 : %7.0f MB/s (%.2f Gbit/s) = %2.0f%% of provisioned\n CEILING: no sample exceeds %.0f MB/s (p95 is within %.1f%% of peak)\n", NR, prov, max*1024/1e6, max*1024*8/1e9, 100*(max*1024/1e6)/prov, p95*1024/1e6, p95*1024*8/1e9, 100*(p95*1024/1e6)/prov, max*1024/1e6, 100*(max-p95)/max}' NFS reads (179 x 1s samples) vs 1280 MB/s provisioned PEAK : 626 MB/s (5.01 Gbit/s) = 49% of provisioned p95 : 623 MB/s (4.98 Gbit/s) = 49% of provisioned CEILING: no sample exceeds 626 MB/s (p95 is within 0.5% of peak) |
|
1 2 3 4 5 6 7 8 |
grep -A1 'rpc bklog' nfsiostat.log | awk '/^ +[0-9]/{print $1}' | tail -n +2 | sort -rn | \ awk -v prov=80000 'NR==1{max=$1}{v[NR]=$1} END{ printf "NFS IOPS (%d x 1s samples) vs %d provisioned\n PEAK : %6.0f ops/s = %2.0f%% of provisioned\n p50 : %6.0f ops/s = %2.0f%% of provisioned\n", NR, prov, max, 100*max/prov, v[int(NR*0.5)], 100*v[int(NR*0.5)]/prov}' NFS IOPS (179 x 1s samples) vs 80000 provisioned PEAK : 33676 ops/s = 42% of provisioned p50 : 16170 ops/s = 20% of provisioned |
49% of provisioned throughput and 42% of provisioned IOPS at peak. On paper, this storage subsystem has plenty of room left. This is the same conclusion AWS Support reached, and it’s a reasonable one to draw from these two numbers alone.
Peak and p95 only describe the top of the range. They don’t say how often the system sits there. So we built a distribution of every 1-second read throughput sample instead of collapsing it into a single peak or percentile:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
grep -A1 '^read:' nfsiostat.log | awk '/^ +[0-9]/{print $2}' | tail -n +2 | \ awk '{g=$1*1024*8/1e9; b=int(g*4); c[b]++; n++; if(c[b]>mx)mx=c[b]} END{printf "NFS read throughput distribution (%d x 1s samples)\n\n",n; for(i=0;i<=21;i++) if(i in c){bar=""; w=int(c[i]*50/mx); for(j=0;j<w;j++) bar=bar "#"; printf "%4.2f-%4.2f Gbit/s |%-50s| %3d (%4.1f%%)\n", i/4, i/4+0.25, bar, c[i], 100*c[i]/n}}' NFS read throughput distribution (179 x 1s samples) 1.25-1.50 Gbit/s |### | 2 ( 1.1%) 1.50-1.75 Gbit/s |####### | 4 ( 2.2%) 1.75-2.00 Gbit/s |######### | 5 ( 2.8%) 2.00-2.25 Gbit/s |# | 1 ( 0.6%) 2.25-2.50 Gbit/s |######### | 5 ( 2.8%) 2.50-2.75 Gbit/s |###################### | 12 ( 6.7%) 2.75-3.00 Gbit/s |############## | 8 ( 4.5%) 3.00-3.25 Gbit/s |########################### | 15 ( 8.4%) 3.25-3.50 Gbit/s |######################## | 13 ( 7.3%) 3.50-3.75 Gbit/s |##################################### | 20 (11.2%) 3.75-4.00 Gbit/s |############################################## | 25 (14.0%) 4.00-4.25 Gbit/s |################################### | 19 (10.6%) 4.25-4.50 Gbit/s |######################### | 14 ( 7.8%) 4.50-4.75 Gbit/s |############## | 8 ( 4.5%) 4.75-5.00 Gbit/s |##################################################| 27 (15.1%) 5.00-5.25 Gbit/s |# | 1 ( 0.6%) |
The single most common bucket among all samples is 4.75-5.00 Gbit/s, and no sample surpassed 5.01 Gbit/s, not a random peak but a ceiling. Every time the workload tried to push beyond 5 Gbit/s, it was capped at that rate instead of climbing further, which explains the queue time we saw in nfsiostat. The requests were piling up behind a virtual wall unrelated to the FSx filesystem’s provisioned throughput.
A number that consistently caps out at 5 Gbit/s pointed us to networking limits rather than disk performance issues. According to AWS documentation, an r5n.8xlarge instance provides 25 Gbps of bandwidth. However, an EC2 instance can become a victim of what AWS calls “single-flow traffic” (a single TCP connection). This limitation caps network transfer bandwidth at 5 Gbps regardless of the EC2 instance’s total aggregate network bandwidth, and it applies to most of today’s EC2 instances.
One look at the mount confirmed a single flow was carrying everything:
|
1 |
fs-xxxxxxxxxxxxxxxxx.fsx.us-east-1.amazonaws.com:/fsx on /postgres/data1 type nfs4 (rw,relatime,vers=4.2,rsize=1048576,wsize=1048576,namlen=255,hard,fatal_neterrors=none,proto=tcp,timeo=600,retrans=2,sec=sys,clientaddr=10.0.0.10,local_lock=none,addr=10.0.0.20) |
No nconnect option, which means a single TCP connection was carrying all NFS traffic between the EC2 instance and FSx. That single connection was subject to EC2’s single-flow bandwidth limit, not the instance’s aggregate limit, and definitely not the throughput FSx itself was provisioned for. The 25 Gbps the EC2 instance could theoretically use, and the 10.2 Gbps throughput FSx could theoretically deliver, were both irrelevant as long as everything moved through a single flow capped at roughly 5 Gbps.
This is why the AWS Support answer and the customer’s own checks weren’t wrong, but were just looking at the wrong layer.
NFS supports the nconnect mount option, which allows a client to multiplex operations across multiple TCP connections to the same NFS export rather than a single one. It’s supported on NFS v3, v4.0, v4.1, and v4.2, and available by default on modern Linux kernels (5.3+), with backports available on some enterprise distributions. AWS itself documents and suggests using nconnect when possible:
|
1 |
sudo mount -t nfs -o nconnect=16 filesystem_dns_name:/vol_path /localpath |
We recommended reviewing the nconnect configuration with their own infrastructure team or AWS Support, since this is a network and infrastructure configuration decision, not a PostgreSQL one. We did not commit to a specific nconnect value, not because we didn’t want to, but because properly benchmarking it would require resources and access our team didn’t have.
The customer came back after setting nconnect=16 and reported that the lag immediately started to close. That’s a good outcome, but the value itself probably deserves a second look. Any value greater than the default of a single connection would have shown improvements right away in this capped environment. However, sixteen connections (16*5 Gbps each) is a theoretical maximum of 80 Gbps, far beyond both endpoints in this setup, the EC2 instance’s own 25 Gbps aggregate limit, and the 10.2 Gbps FSx filesystem’s own provisioned throughput.
Two connections would theoretically already reach close to the FSx throughput ceiling, and three would cover it with a comfortable buffer. Going straight to sixteen removed the bottleneck, but it also means the OS is maintaining far more resources with no measurable benefit.
Parallelism doesn’t scale as a clean multiplication of a single connection’s throughput. Other resources, on both the client and server sides, become part of the equation as concurrency increases. As with any other piece of infrastructure or software, finding the right number requires proper benchmarking.
As a database support team, our role was to identify and prove the cause behind the lag, provide the evidence, and propose a possible path forward.
A component reporting low utilization against its provisioned limit doesn’t rule out an I/O bottleneck elsewhere in the path, it only rules out that specific limit. In this case, FSx itself was never the problem. The actual constraint was the single-flow network bandwidth cap between the EC2 instance and its FSx filesystem, throttling every request that went through it.
Two things are worth carrying into the next investigation, like this one. First, vmstat’s “b” column and a sustained “wa” value are cheap and fast signals that something downstream of PostgreSQL is the bottleneck, before diving into storage-specific tooling. Second, when checking a metric against a provisioned limit, consider the full distribution of samples, not just the peak or a single percentile. A value that repeatedly hits the same ceiling (even one well below the provisioned maximum) is a stronger signal than a single peak value.
Resources
RELATED POSTS