Routing Reads and Writes with ProxySQL Query Rules

This guide is part of ProxySQL & MySQL Connection Multiplexing. Read/write splitting is the reason many teams put ProxySQL in front of MySQL, and it is also where the first correctness incident usually comes from. A rule set written as “send SELECT to the readers” is one regular expression away from sending SELECT ... FOR UPDATE to a replica, or from routing the read that follows a write to a replica that has not received it yet:

ERROR 1290 (HY000): The MySQL server is running with the --read-only option so it cannot execute this statement
# or, worse, no error at all:
INSERT INTO orders ... ;                      -- writer
SELECT * FROM orders WHERE id = LAST_ID;      -- replica, 40 ms behind: zero rows

The objective is a rule set that routes by intent rather than by prefix, keeps transactions and read-your-writes on the writer, degrades safely when a replica lags, and can be verified from ProxySQL’s own statistics rather than by hope.

Rapid incident diagnosis

Read the rules in effect. Runtime rules are what matter, not the on-disk table:

SELECT rule_id, active, match_digest, match_pattern, destination_hostgroup, apply, flagIN, flagOUT
FROM runtime_mysql_query_rules ORDER BY rule_id;

Rules are evaluated in rule_id order; apply = 1 stops evaluation at the first match. A broad ^SELECT rule with apply = 1 at a low rule_id shadows every more specific rule below it, which is the most common cause of mis-routing.

See where queries actually went. The digest table records the hostgroup each statement was sent to:

SELECT hostgroup, count_star, SUBSTR(digest_text, 1, 70) AS query
FROM stats_mysql_query_digest
ORDER BY count_star DESC LIMIT 20;

Writes appearing against a reader hostgroup, or locking reads against a reader, identify the rule that needs narrowing.

Check the transaction setting. transaction_persistent = 1 on the user keeps every statement of a transaction on the hostgroup where it started, which is what prevents a read inside a write transaction from being routed away:

SELECT username, default_hostgroup, transaction_persistent, max_connections FROM runtime_mysql_users;

Check replica lag handling. A replica beyond max_replication_lag is shunned, so lag shows as traffic concentrating on the remaining readers rather than as stale data — provided monitoring is configured:

SELECT hostgroup, srv_host, status, max_replication_lag FROM runtime_mysql_servers;
SELECT * FROM monitor.mysql_server_replication_lag_log ORDER BY time_start_us DESC LIMIT 10;
ProxySQL query rule evaluation order A statement is matched against rules in rule_id order. A locking read matches the specific rule at rule_id 100 and goes to the writer; a plain select falls through to rule 200 and goes to the readers; anything unmatched uses the user's default hostgroup. Statement arrives rule 100 SELECT ... FOR UPDATE rule 200 ^SELECT no match default hostgroup hostgroup 10 · writer hostgroup 20 · readers hostgroup 10 · writer (default) put the narrow rules first; apply = 1 stops evaluation at the first match
Rules are evaluated in order and the first match with apply wins. A broad SELECT rule placed above the specific ones silently captures everything.

Mathematical sizing or parameter formula

Routing changes how load divides, so the pools on each side must be sized from the split rather than from the total.

writer_pool  = ceil( write_tps × mean_write_seconds ÷ 0.7 )
reader_pool  = ceil( read_tps  × mean_read_seconds  ÷ 0.7 ) ÷ live_replicas

A service at 2,400 statements per second with an 85 % read share, writes averaging 9 ms and reads 3 ms:

writes: 360/s × 0.009 = 3.2 busy   → writer default_pool_size = 5
reads: 2,040/s × 0.003 = 6.1 busy  → 9 across readers, so 5 per replica with two replicas

Size the readers for one replica fewer than you have. When a replica is shunned for lag or maintenance, its share moves to the survivors:

reader_pool_per_replica ≥ ceil( read_busy ÷ (live_replicas − 1) ÷ 0.7 )

With two replicas that means each pool must be able to carry the whole read load: ceil(6.1 ÷ 1 ÷ 0.7) = 9.

Lag threshold. max_replication_lag is measured in seconds and should be set from what the application can tolerate, not from what the replicas usually achieve:

max_replication_lag ≈ acceptable_staleness_seconds

A value of 1 or 2 seconds is typical; setting it to 0 disables the check entirely, which is the opposite of what the name suggests.

Weights. backend_weight divides read traffic proportionally. Equal weights suit identical replicas; a replica also serving analytics should get a lower weight so interactive reads are not queued behind long scans.

Reader pool sizing with a replica removed With two replicas each carrying about three busy connections, losing one moves the whole six-connection read load onto the survivor, which its pool of nine can absorb. Busy backend connections for reads both replicas replica A · 3 busy replica B · 3 busy one shunned survivor · 6 busy, pool of 9 a pool sized for the normal case queues the moment a replica is removed
Sizing each reader pool for the loss of one replica means a shunned node moves load without also producing a queue.

Exact remediation & configuration

Order rules from specific to general, and keep the set small enough to read:

DELETE FROM mysql_query_rules;

-- 1. Anything that must see its own writes, or takes locks, goes to the writer.
INSERT INTO mysql_query_rules (rule_id, active, match_digest, destination_hostgroup, apply, comment)
VALUES
  (100, 1, '^SELECT .* FOR UPDATE$',        10, 1, 'locking read'),
  (101, 1, '^SELECT .* LOCK IN SHARE MODE$',10, 1, 'locking read'),
  (102, 1, '^SELECT +GET_LOCK',             10, 1, 'named lock'),
  (103, 1, '^SELECT .* FROM +outbox',       10, 1, 'read-your-writes table');

-- 2. Driver housekeeping that is safe anywhere and should not pin multiplexing.
INSERT INTO mysql_query_rules (rule_id, active, match_digest, multiplex, apply, comment)
VALUES
  (150, 1, '^SELECT @@session\\.transaction_read_only', 1, 0, 'driver probe');

-- 3. Everything else that reads goes to the replicas.
INSERT INTO mysql_query_rules (rule_id, active, match_digest, destination_hostgroup, apply, comment)
VALUES
  (200, 1, '^SELECT',  20, 1, 'plain reads'),
  (201, 1, '^SHOW ',   20, 1, 'metadata reads');

LOAD MYSQL QUERY RULES TO RUNTIME;
SAVE MYSQL QUERY RULES TO DISK;

Keep transactions whole. transaction_persistent = 1 means a transaction that began on the writer stays there even when rule 200 would send its reads elsewhere:

UPDATE mysql_users SET transaction_persistent = 1 WHERE username = 'orders_app';
LOAD MYSQL USERS TO RUNTIME;
SAVE MYSQL USERS TO DISK;

Set the lag guard and weights on the servers:

UPDATE mysql_servers SET max_replication_lag = 2 WHERE hostgroup_id = 20;
UPDATE mysql_servers SET weight = 500 WHERE hostname = 'mysql-replica-b.internal';  -- also runs analytics
LOAD MYSQL SERVERS TO RUNTIME;
SAVE MYSQL SERVERS TO DISK;

Give the application an explicit escape hatch. Some reads must be fresh even outside a transaction — a redirect after a write, a confirmation page. Rather than trying to detect them with patterns, let the application say so with a query comment and match on it:

INSERT INTO mysql_query_rules (rule_id, active, match_pattern, destination_hostgroup, apply, comment)
VALUES (90, 1, '/\\*\\s*route:writer\\s*\\*/', 10, 1, 'explicit writer hint');
LOAD MYSQL QUERY RULES TO RUNTIME;
cur.execute("/* route:writer */ SELECT status FROM orders WHERE id = %s", (order_id,))

Note that this rule matches match_pattern — the raw query text — rather than match_digest, because ProxySQL strips comments when computing the digest.

Use mysql_replication_hostgroups so ProxySQL moves servers between the writer and reader hostgroups automatically based on read_only, instead of a rule set that assumes a fixed topology:

INSERT INTO mysql_replication_hostgroups (writer_hostgroup, reader_hostgroup, check_type, comment)
VALUES (10, 20, 'read_only', 'orders');
LOAD MYSQL SERVERS TO RUNTIME;

Verifying routing before and after release

Routing rules are code, and they deserve the same treatment: a test that proves intent, and a check that catches drift.

Assert the destination for representative statements. ProxySQL can explain its own decision, which makes a rule set testable without sending real traffic:

mysql -h 127.0.0.1 -P 6032 -u admin -p -e "
  SELECT rule_id, destination_hostgroup, match_digest
  FROM runtime_mysql_query_rules
  WHERE active = 1 ORDER BY rule_id;"

For end-to-end proof, run each representative statement through the proxy and read the digest afterwards:

for q in "SELECT id FROM orders WHERE id=1" \
         "SELECT id FROM orders WHERE id=1 FOR UPDATE" \
         "/* route:writer */ SELECT status FROM orders WHERE id=1" \
         "UPDATE orders SET status='x' WHERE id=0"; do
  mysql -h proxysql.internal -P 6033 -u orders_app -p"$PW" orders -e "$q" >/dev/null 2>&1
done

mysql -h 127.0.0.1 -P 6032 -u admin -p -e "
  SELECT hostgroup, SUBSTR(digest_text,1,60) FROM stats_mysql_query_digest
  ORDER BY last_seen DESC LIMIT 10;"

The locking read, the hinted read and the update must all show hostgroup 10; the plain read must show 20.

Prove read-your-writes. Inside a transaction, write and then read back, several hundred times:

BEGIN;
INSERT INTO probe(id, at) VALUES (UUID(), NOW());
SELECT COUNT(*) FROM probe WHERE id = LAST_INSERT_ID();   -- must never be 0
COMMIT;

Prove the lag guard. On a staging replica, stop replication and generate writes:

STOP REPLICA;   -- on the replica

Within a couple of monitor intervals the replica’s status must become SHUNNED in runtime_mysql_servers, and its select_cnt in stats_mysql_connection_pool must stop increasing. START REPLICA restores it.

Catch drift in CI. Export the runtime rules and compare them with the committed definition on every deploy; a rule added by hand during an incident and never written down is the usual source of surprise routing months later.

Check Frequency Failure means
Representative statements land in the expected hostgroup every release A rule is shadowed or too broad
Read-your-writes probe returns 1 every release transaction_persistent off, or a rule splits the transaction
Shunned replica stops receiving reads quarterly Monitoring or max_replication_lag unset
Runtime rules match the committed set every deploy Manual changes have drifted
Routing verification checks Four checks cover statement destination, read-your-writes correctness, behaviour when a replica lags, and drift between runtime and committed rules. Destination per statement Read-your-writes probe Lag guard shunning works Drift runtime vs committed Four checks, four failure modes the first two belong in every release; the third is a quarterly rehearsal and the fourth runs on every deploy
The four checks map to the four ways routing goes wrong: shadowed rules, split transactions, ignored lag, and undocumented manual changes.

Frequently Asked Questions

Should routing live in the application instead?
Application-level routing — a reader data source and a writer data source — gives the application full control and no proxy to operate, at the cost of touching every code path. ProxySQL rules keep the application unchanged but move a correctness decision into infrastructure. Many teams do both: the proxy handles the general case, and explicit hints handle the exceptions.
Why does match_digest not see my comment?
The digest is the normalised statement with comments and literals removed, which is what makes it stable. Match comments with match_pattern, which sees the raw text.
Can a rule send a statement to a specific server rather than a hostgroup?
No — rules target hostgroups. To reach one server, put it in its own hostgroup. That is the usual pattern for an analytics replica that should receive only tagged queries.
Does apply = 0 mean the rule does nothing?
It means evaluation continues to the next rule after this one matches, which is how a rule that only sets multiplex or a flag can run without deciding the destination. Rules that choose a hostgroup normally use apply = 1.