skip to content

How would you design the health check that a database proxy uses to decide which backend node is the writable primary, and what makes a naive check dangerous?

level: seniorimportance: should knowfreq 40%

answer

  1. TCP connect proves nothing — query the role
  2. pg_is_in_recovery / super_read_only / cluster-manager endpoint
  3. Timeout < interval; rise/fall thresholds vs flapping
  4. Reserved slot: saturation must not eject a healthy node
  5. Unknown role → fail closed for writes

basics

~20 s

Check role, not just liveness: open a real connection as a real user and run a query that reports whether the node is writable. TCP-connect checks pass on a demoted or wedged node. Add timeouts, rise/fall thresholds against flapping, a reserved connection slot, and fail closed for writes.

solid answer

~60 s

A TCP handshake proves a port is open, nothing more: a demoted standby, a node in recovery, or one whose connections are exhausted will all accept the socket. The check must therefore be a **real query on a real connection as an unprivileged user against the real database**, and it must report **role**, not just aliveness — `pg_is_in_recovery()` on PostgreSQL, `@@read_only`/`@@super_read_only` on MySQL, or the HTTP endpoint of a cluster manager that already knows the authoritative role. Then the operational details that decide whether it helps or hurts: - Timeout shorter than the interval, so checks cannot pile up. - Separate rise and fall thresholds — several consecutive failures to eject, several successes to return — so one slow response does not flap the whole fleet. - A reserved connection so the check does not fail exactly when the node is saturated; otherwise the check ejects a healthy node under load and turns a spike into an outage. - Fail closed: if role is unknown, do not send writes there. And routing decisions never substitute for fencing the old primary.

code

sql · 5 lines
sql
-- PostgreSQL: false on the primary, true on a standby
SELECT NOT pg_is_in_recovery() AS is_primary;

-- MySQL: primary reports 0 for both
SELECT @@global.read_only, @@global.super_read_only;

go deeper

for a junior

Say that the check must run a real query and confirm the node is writable, not just that the port is open.

for a middle

Name the concrete role probes per engine and explain timeouts and consecutive-failure thresholds.

for a senior

Cover the saturation feedback loop, reserved connections, flap damping, failing closed for writes, and preferring a cluster manager's authoritative role over an independent opinion.

for a principal

Set the detection budget from the availability target, weigh false-positive cost against detection speed, and make clear that routing health is an input to availability but never a substitute for fencing.

## Why the naive check fails The default health check in most load balancers is a TCP connect. It answers "is something listening", which is nearly useless here. Every one of the following passes it: a demoted node that is now a read-only standby; a node still in crash recovery; a node whose disk is full so every write errors; a node at `max_connections` so no client can actually get in. Routing writes to any of them produces errors or, if the node is wrongly writable, divergent data. ## What a good check asks 1. **Can I connect and authenticate?** Use a dedicated low-privilege account against the actual application database, not the default one — a full disk or a broken database shows up here, and a check that connects as a superuser hides authorisation problems. 2. **Is the node writable and is it the primary?** This is the decisive question. Query the engine's role indicator, or ask a cluster manager (Patroni, Orchestrator, an operator) whose REST endpoint already reflects the authoritative decision. Deriving role from a cluster manager is preferable when one exists, because two independent opinions about who is primary is precisely the situation to avoid. 3. **Is it usable, not merely alive?** Optionally check that the query returned within a latency bound and, for replicas, that lag is under a threshold. For read backends the check is different — a standby should be *rejected* from the write pool for exactly the reason it is *accepted* into the read pool, so proxies normally run two checks with different predicates against the same node. ## The dangerous details **Saturation feedback loop.** When a node hits its connection limit, the health check cannot connect and the node is ejected. Its traffic moves to the remaining nodes, which saturate too. A load spike becomes a full outage. Defences: reserve connection slots for the check user (most engines support reserved superuser or per-user connection allowances), give the check its own low-cost path, and treat connection saturation as degradation rather than death. **Flapping.** With a single failed check as the ejection criterion, one GC pause or IO stall reshuffles routing, breaking every connection on that backend. Require N consecutive failures to eject and M consecutive successes to restore, with the check interval and timeout chosen so total detection time still meets the availability target. Asymmetry is deliberate: quick to eject on repeated failure, slow to trust again. **Timeouts.** The timeout must be shorter than the interval, or slow checks overlap and queue. A check that hangs forever on a wedged node is worse than no check, because the proxy keeps the node in rotation while it waits. **Cost.** Checks run per proxy instance per backend per interval. Twenty proxies checking five backends every second is a hundred connections a second; if the check opens a new connection each time rather than reusing one, that alone can be significant load. **Fail closed for writes.** If the check cannot determine role, the correct behaviour is to withhold write traffic, not to guess. Reads may degrade gracefully; writes must not be sent to a node that might be a standby or a stale former primary. **Routing is not fencing.** A proxy that stops sending traffic to the old primary has not stopped anything else from reaching it — other clients, cron jobs, or a second proxy with a different opinion. Ensuring only one node can accept writes is a separate mechanism; the health check merely reflects it. ## Observability Export check results as metrics — per backend state, transition timestamps, check latency, failure reason. The most useful post-incident question is "when did the proxy think each node changed role, and why", and that is only answerable if every transition was recorded with its cause.

  • Your proxy's health check uses a superuser account and the default maintenance database. Why change both?
    A superuser connection is exempt from the normal connection limit and from many permission problems, so the check succeeds in situations where real application traffic would fail — it measures the wrong thing. Connecting to the default maintenance database misses failures specific to the application database, such as a full tablespace or a database-level restriction. Use a dedicated least-privilege account against the real database, and give it a reserved connection allowance so saturation still does not blind the check.
  • How do you choose the check interval and failure threshold?
    Work backwards from the detection budget: total time to eject is roughly interval times the failure threshold, plus one timeout. If a failed primary must be detected within a few seconds, that might be a one-second interval with a threshold of three. Then sanity-check the cost — interval times number of proxies times number of backends — and confirm the threshold is high enough that a routine GC pause or IO stall does not trip it, otherwise you have traded a rare failure for frequent self-inflicted churn.

saying these in an interview costs you the question

  • Using a TCP-connect or ping check to identify the primary
  • Ejecting a backend after a single failed check
  • Running checks as a superuser so saturation and permission failures are invisible
  • Assuming that removing a node from the proxy prevents it from receiving any writes
  • Setting the check timeout longer than the check interval

context