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?
answer
- TCP connect proves nothing — query the role
- pg_is_in_recovery / super_read_only / cluster-manager endpoint
- Timeout < interval; rise/fall thresholds vs flapping
- Reserved slot: saturation must not eject a healthy node
- Unknown role → fail closed for writes
basics
~20 sCheck 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 sA 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-- 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
Say that the check must run a real query and confirm the node is writable, not just that the port is open.
Name the concrete role probes per engine and explain timeouts and consecutive-failure thresholds.
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.
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