A database per microservice on one MySQL server, and queries that read across them - a customer, their orders, their payments. Measured with TPC-C's data split into six databases, five rounds per comparison, and the harness published so you can rerun it.
Six per-user queries across six databases on one MySQL 8.4 server, one gunicorn worker, 32 callers at once.
| Query (the databases it joins) | MySQL directly | 0.42 | 0.43 | 0.43, metadata cache |
|---|---|---|---|---|
| One customer by id (crm) | 75,420 | 647 | 743 | 875 |
| Order-Status: the customer's last order and its lines (crm, orders) | 25,063 | 573 | 662 | 727 |
| Stock-Level: low-stock items among a district's last 20 orders (org, orders, inventory) | 5,343 | 630 | 751 | 833 |
The other three per-user queries from the first run (last orders with items, payments, the customer with district and warehouse) weren't rerun; on 0.42 they fell between the customer lookup and Order-Status.
crm.customer JOIN orders.orders in a single query.| Query | MySQL directly | Through the gateway (0.43, cache) | Added |
|---|---|---|---|
| One customer by id | 0.034 ms | 1.07 ms | +1.04 ms |
| Order-Status | 0.081 ms | 1.35 ms | +1.27 ms |
| Stock-Level | 0.417 ms | 1.65 ms | +1.23 ms |
MySQL 8.4, 1.9 GB of data in memory, one QueryAPIGate connection as a read-only user with SELECT on all six databases. No column masking. Every query takes one customer, by id.
Extra threads compete for Python's interpreter lock. A 0.03 ms lookup is fastest with one thread, 28% faster than eight. A 0.42 ms query wants two, so one call can run while another waits on MySQL. Slow reports and exports are the opposite case, and that's what the eight are for.
order_status, MySQL 0.5 ms away, one worker, 32 callersOn 0.42, threads beyond two cost throughput: each added thread meant more pings, and each ping stopped every thread. On 0.43 the ping is gone and threads overlap the waiting: four serve 730 calls a second, 46% more than 0.42's best. With MySQL across a network, use 4.
With skewed traffic, a 30 s response cache answered 59% of calls, raised throughput 23%, and sent 58% fewer statements to MySQL. With uniform users it cost 8%: almost nothing repeats within 30 seconds, and storing it buys nothing. Put cache_ttl on what your users repeat: dashboards, popular accounts.
cache_ttl: 30cache_ttl: 30the query, with its time limit as a hint
ROLLBACK AND CHAIN when the connection returns to the pool
Counted with MySQL's statement log, and measured by timing calls at increasing network delay. 0.42 made a third: a ping mysql-connector sent before every query, while holding Python's interpreter lock, so every thread waited for it. 0.43 reuses the cursor, and the ping is gone. With 32 callers and MySQL 0.5 ms away, that's 52% more calls a second (680 against 446).
| QueryAPIGate's store | One call |
|---|---|
| SQLite, local (one instance) | 2.86 ms |
| PostgreSQL, 0.5 ms away (several instances) | 5.70 ms |
| PostgreSQL, 0.5 ms away, metadata cache on | 2.87 ms |
Every call reads three of QueryAPIGate's own rows - the API key, the saved query, the connection. With several instances on a shared PostgreSQL store, those reads cross the network too. The metadata cache keeps them in memory, and takes the store off the network entirely.
It caches rows, never decisions: whether a key is active, expired or allowed from this address is checked on every call. A change made on another instance takes up to the TTL to arrive.
On a shared PostgreSQL store, workers scale as they do on SQLite: 625 to 1,862 calls a second from 1 to 8 workers, or 720 to 2,270 with the metadata cache, which also cuts the store's load to a third. A core's second hyperthread is worth about 0.6 of a core here, so run one worker per physical core.
A key's table allow-list matches database.table on MySQL now, as it does on every SQL connection: a bare grant means the connection's own database.
customer, on a connection whose database is crm.| Runs | Result |
|---|---|
SELECT … FROM crm.customer | ✓allowed, as intended |
SELECT … FROM billing.customer (another service's table) | ✓refused (allowed before 0.43) |
SELECT … FROM billing.history | ✓refused, as intended |
billing.customer. Statements whose tables can't be checked (SHOW, SET, USE and the like) are refused for table-restricted keys. The changelog →Still good practice: a MySQL user per service, with SELECT on its own databases only, so MySQL enforces it too.
A worker runs out of CPU or of threads to wait with, whichever comes first. Put in your own load and this works out which, and how many workers to run.
{{ hint }}
Planning figures from one machine, for queries MySQL answers in under half a millisecond. Single-table lookups run about 15% above the CPU figure; heavier queries below it. Measure your own at 75% of what you plan to serve.
Every call is written to the event log, kept 24 hours by default - about 570 bytes a call.
| Sustained calls/s | Event log after 24 h |
|---|---|
| 50 | about 2.5 GB |
| 500 | about 25 GB |
| 2,000 | about 100 GB |
Shorten it with QUERYAPIGATE_EVENTS_RETENTION_HOURS.
/health check around every block showed whether the machine changed speed. One session, 2026-10-26 18:32 to 21:59, on the same laptop and data as the first run.TPC-C-derived: the data and two queries (Order-Status, Stock-Level) are TPC-C's, but this is not the TPC-C benchmark, and these are not TPC-C results.
It starts its own MySQL, loads the data with go-tpc, splits it into the six databases, pins each part to its own cores, and compares releases side by side. About 3½ hours.
$ pip install -e ".[server,mysql,postgres]" py-spy $ python benchmarks/tpcc_mysql.py --out /tmp/qag-tpcc --go-tpc ./go-tpc --refs v0.42.0,origin/main \ --m2-queries customer,order_status,stock_level setup C teardown $ python benchmarks/tpcc_mysql_report.py /tmp/qag-tpcc/results.jsonl