Finding a Postgres Row-Lock Bottleneck with Prometheus + InfluxDB

Photo by alfonso benayas via Openverse (CC BY 2.0)
k6 has native, purpose-built support for streaming per-request load-test metrics to InfluxDB, while container and JVM metrics from cAdvisor and Spring Boot Actuator are designed around Prometheus's pull-based scrape model. Rather than forcing one tool to imitate the other's protocol, each metric source uses the backend it was built for, and both are rendered on a single Grafana dashboard so an operator can correlate them on one time axis.
Running Prometheus, Grafana, InfluxDB, and the load generator on the same host as the service being benchmarked means the observability stack competes with that service for CPU and memory, which corrupts the very measurements it is trying to take. Keeping monitoring on a separate DigitalOcean droplet, federating metrics across a locked-down Nginx reverse proxy, preserved the application host's full resource budget for the service under test.
By repeatedly querying pg_stat_activity joined with pg_locks while load is running, typically every few seconds, to see which sessions are active, which are blocked, and exactly which lock and row they are waiting on. In this investigation that query consistently showed 6 to 8 sessions blocked on an exclusive tuple lock against the same row in a tenant_config table.
Latency, traffic, errors, and saturation tell you a system is unhealthy and roughly point at a layer, but they cannot see inside a database's lock manager. A latency spike with only moderate CPU usage rules out raw compute as the cause, but pinpointing which specific row or query is blocking everything else requires live introspection into the database's own session and lock state, not just dashboard metrics.
The workload published a message to an Artemis queue after every successful write, so a saturated consumer could have looked identical to a database bottleneck from the outside. Sampling the queue's message count every 2 seconds and confirming it drained to zero throughout the test ruled out the broker in minutes, before spending time on a deeper database-level investigation.

Photo by alfonso benayas via Openverse (CC BY 2.0)
Key Takeaway
Golden-signal dashboards (latency, traffic, errors, saturation) can tell you a system is struggling but not where the bottleneck lives. Pinpointing a Postgres concurrency bottleneck requires live-sampling pg_stat_activity joined with pg_locks during the load test itself, which is how a single exclusive tuple lock on one tenant_config row was found blocking 6-8 concurrent sessions.
As part of my SGU thesis work at Commsult Indonesia on Ontego Traces, I spent a good chunk of the project not writing load-test scripts or autoscaler logic, but wiring up the observability layer that made sense of everything those other pieces produced. This post is about that layer: why I split it into two backends instead of one, why it ran on a completely separate host from the service under test, and the actual detective work that took me from "something is slow" to "this exact row in this exact table is the constraint." I already covered the load-test numbers and the autoscaler behavior in other posts, so I will only reference those results briefly here for context and focus on the observability design and the diagnostic method itself.
Early on I tried pushing everything into a single time-series store and quickly ran into an impedance mismatch. k6, the load-testing tool driving the experiments, is built to stream fine-grained per-request metrics like http_req_duration and iteration counts to InfluxDB as a first-class output target. Infrastructure metrics are a different shape entirely: container CPU and memory from cAdvisor, and JVM heap, garbage collection, and HikariCP connection-pool stats from Spring Boot Actuator, both of which speak Prometheus's pull-based scrape model natively. Rather than forcing one tool to imitate the other's protocol, I let each side use the backend it was designed for and rendered both on the same Grafana dashboard, on the same time axis. That single decision turned out to matter more than any dashboard panel I built on top of it.
Every k6 run wrote directly to an InfluxDB instance using k6's native output flag, so response time percentiles, error rates, and throughput were available as soon as the test started, not after it finished. That mattered because Giamattei et al.'s 2024 survey of DevOps monitoring tools found that most pipelines only collect performance metrics after a test run completes, which is exactly the gap a live dashboard closes: an operator watching the run can see p95 climbing in real time instead of discovering it in a post-run report.
cAdvisor exposed container-level CPU and memory as a Prometheus scrape target, and Spring Boot Actuator exposed the same for the JVM heap, garbage collection pauses, and the HikariCP connection pool size and usage. Both were scraped on a short interval and federated into the monitoring host's own Prometheus instance, so an operator staring at the Grafana dashboard during a load test could watch application latency in one panel and container CPU climbing in the panel right next to it, on the same timestamp. That correlation is the entire point: a latency spike with flat CPU tells you something completely different from a latency spike with CPU pegged at 100%.
If you only have budget to add one thing to an existing load-testing setup, add infrastructure metrics on the same time axis as your latency metrics, not a separate dashboard. The value is almost entirely in the ability to eyeball the correlation instantly, not in either metric set alone.
The application under test ran on a Google Cloud Platform instance with 4 vCPUs and 8GB of RAM running the Docker Swarm manager and the service containers. The load generator, InfluxDB, Prometheus, and Grafana all ran on a separate DigitalOcean droplet with 2 vCPUs and 2GB of RAM. This was a deliberate choice, not a cost-saving afterthought: an observability stack that competes with the service under test for the same CPU and memory budget corrupts its own measurements. If cAdvisor, Prometheus, and Grafana are eating CPU cycles on the same box being benchmarked, every latency number they report is partly measuring their own overhead. I verified cAdvisor's footprint on the application host stayed under 1% CPU, but that number is only meaningful because the heavier pieces, the load generator and the time-series databases, were never on that host to begin with.
Since Prometheus needs to scrape the application host's metrics endpoints but the two hosts sit on different cloud providers, I put an Nginx reverse proxy in front of the application host's Prometheus instance and restricted it to accept connections only from the monitoring droplet's IP address. The monitoring host's own Prometheus then federated from that proxy on a regular scrape interval, pulling in the cAdvisor and Actuator series without exposing the application host's monitoring endpoints to the open internet. It is a small piece of configuration, but it is the thing that makes the two-host split actually work end to end instead of just moving the problem.
Golden-signal dashboards are necessary but not sufficient for root-causing a bottleneck below the application tier. If your latency is climbing but your golden signals look ambiguous, the next step is not a fancier dashboard, it is live session and lock sampling against whatever stateful backend sits downstream of your service.
With both backends live on one dashboard, the standard SRE framing of four golden signals, latency, traffic, errors, and saturation, was fully covered: k6 gave latency, traffic, and errors, while cAdvisor and Actuator gave saturation. But watching all four during a load test only told me the system was struggling. p95 climbing past 8 seconds and CPU sitting at a moderate level told me the bottleneck was not raw compute, but nothing in those four signals pointed at which downstream dependency was actually the constraint. That gap is exactly where live database and message-broker introspection had to take over.
With the broker cleared, I turned to Postgres directly. Every 3 seconds during a two-replica load test run, I queried pg_stat_activity joined against pg_locks to see exactly which backend sessions were active, which were waiting, and what they were waiting on. The pattern was immediate and consistent: 6 to 8 sessions were blocked at any given sample, every single one waiting on an exclusive tuple lock against the same row in a tenant_config table. The blocking query was always the same UPDATE statement, issued by every request in the workload against a table that in the staging environment had only a handful of distinct tenant rows, meaning all concurrent traffic funneled through one row's lock. That is not a query performance problem or an indexing problem; it is a concurrency design problem, and no amount of connection-pool tuning or replica count changes it, because the constraint sits at the row level, enforced by Postgres's own MVCC locking, not at the CPU or connection level.
-- Sampled every 3s during load test: who is blocked, and on what row?
SELECT
blocked.pid AS blocked_pid,
blocked.usename AS blocked_user,
blocked.query AS blocked_query,
blocked.wait_event_type,
blocked.wait_event,
blocking.pid AS blocking_pid,
blocking.query AS blocking_query,
blocking.state AS blocking_state,
locks.relation::regclass AS locked_relation,
locks.mode AS lock_mode,
locks.locktype
FROM pg_stat_activity blocked
JOIN pg_locks locks
ON locks.pid = blocked.pid AND NOT locks.granted
JOIN pg_locks blocking_locks
ON blocking_locks.locktype = locks.locktype
AND blocking_locks.database IS NOT DISTINCT FROM locks.database
AND blocking_locks.relation IS NOT DISTINCT FROM locks.relation
AND blocking_locks.page IS NOT DISTINCT FROM locks.page
AND blocking_locks.tuple IS NOT DISTINCT FROM locks.tuple
AND blocking_locks.pid != locks.pid
AND blocking_locks.granted
JOIN pg_stat_activity blocking
ON blocking.pid = blocking_locks.pid
WHERE blocked.wait_event_type = 'Lock'
ORDER BY blocked.query_start;
-- Output during the load test consistently showed 6-8 rows,
-- all pointing at the same tenant_config tuple:
-- blocked_pid | locked_relation | lock_mode | blocking_query
-- -------------+-----------------+-------------------+------------------------------------------
-- 18422 | tenant_config | ExclusiveLock | UPDATE tenant_config SET modified_at=...
-- 18430 | tenant_config | ExclusiveLock | UPDATE tenant_config SET modified_at=...
-- 18441 | tenant_config | ExclusiveLock | UPDATE tenant_config SET modified_at=...
Run this query on a tight interval, every 2 to 3 seconds, for the entire duration of the load test, not once after the fact. pg_locks and pg_stat_activity only report locks that are held or waited on right now, so a contention spike that resolved a second ago leaves nothing to see once you stop the test and query interactively afterward.
Before touching the database, I wanted to eliminate the Artemis message broker as a suspect, since every successful request in the workload published one message to a queue after the write path completed. I sampled the queue's MESSAGE_COUNT every 2 seconds throughout a load test run and watched it stay at zero at essentially every poll, with the publish rate closely matching the consumer's ack rate. A saturated consumer would show a queue backlog growing over the run; there was none. That single, simple check ruled out an entire subsystem in a few minutes and kept me from chasing a broker-tuning rabbit hole that would not have moved the needle.
The general lesson generalizes past this one project: the four golden signals are a triage tool, they tell you a system is unhealthy and roughly which layer, but localizing a concurrency bottleneck to a specific row requires querying the database's own session and lock state while the load is actually running. Static metrics dashboards cannot see a lock wait queue; only live introspection can.
If you are setting up observability for a load-testing project, the two decisions I would repeat without hesitation are: keep the monitoring stack physically separate from the system under test, and treat golden-signal dashboards as the first triage step, not the last diagnostic step. When latency degrades and your CPU, memory, and error-rate panels do not point at an obvious culprit, the next tool you reach for should be a live query against your database's session and lock views, sampled repeatedly during the load, not a new Grafana panel.