Performance · MySQL

One MySQL server, six services, one gateway

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.

01 · The numbers

A fixed cost, not a percentage

Six per-user queries across six databases on one MySQL 8.4 server, one gunicorn worker, 32 callers at once.

+1.0-1.3 msWhat the gateway adds to a call, with the metadata cache, on per-user queries MySQL answers in 0.03-0.42 ms.
662-875 calls/sPer worker, 32 callers, across three per-user queries joining up to three databases. 727-875 with the metadata cache.
+52%What 0.43 serves over 0.42 with MySQL 0.5 ms away: one round trip fewer, and no more stalls.
+21%One worker with gunicorn's access log and the request lines off, where a proxy logs requests already. Errors, warnings and slow queries are still logged.
Per query, one worker, 32 callersCalls a second; median of 5 rounds.
Query (the databases it joins)MySQL directly0.420.430.43, metadata cache
One customer by id (crm)75,420647743875
Order-Status: the customer's last order and its lines (crm, orders)25,063573662727
Stock-Level: low-stock items among a district's last 20 orders (org, orders, inventory)5,343630751833
With the Rust data plane in front (a prototype): 23,619 customer lookups a second on 8 cores, against 2,976 for Python alone →

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.

  • Joins across databases cost no more than joins within one: MySQL resolves crm.customer JOIN orders.orders in a single query.
  • The gateway's Python is the limit, not MySQL. MySQL could answer 8-110 times more, so you scale with workers.
One call on its ownp50
QueryMySQL directlyThrough the gateway (0.43, cache)Added
One customer by id0.034 ms1.07 ms+1.04 ms
Order-Status0.081 ms1.35 ms+1.27 ms
Stock-Level0.417 ms1.65 ms+1.23 ms
02 · The setup

TPC-C's data, split the way microservices split it.

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.

CallersQueryAPIGateone read-only connectionMySQL 8.4, one server
orgwarehouse, district20, 200
crmcustomer600,000 users
ordersorders, new_order, order_line600,000, 180,000, 6,000,000
catalogitem100,000
inventorystock2,000,000
billinghistory (payments)600,000
03 · Threads

Fast queries want fewer threads.

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.

Calls a second, by threads per workerMeasured before 0.43's release, MySQL on the same host
One customer by id0.03 ms in MySQL
1 thread
865/s
2 threads
823/s
4 threads
722/s
8 threads (default)
676/s
Stock-Level0.42 ms in MySQL
1 thread
630/s
2 threads
772/s
4 threads
683/s
8 threads (default)
642/s
Across a network, threads help againorder_status, MySQL 0.5 ms away, one worker, 32 callers
0.420.43Calls a second
1 thread
316
405
2 threads
501
648
4 threads
469
730
8 threads
447
685

On 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.

04 · Busy users

When a few users make most of the calls.

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.

A few very busy usersZipf
Without a response cache
581/s
With cache_ttl: 30
717/s
59% from cache
All equally likely600,000 users
Without a response cache
582/s
With cache_ttl: 30
537/s
5% from cache
05 · Across a network

Two round trips, and the store.

Round trips per call to MySQL3
  1. 1

    the query, with its time limit as a hint

  2. 2

    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).

Where QueryAPIGate keeps its own dataOne call, Order-Status, MySQL 0.5 ms away (0.43)
QueryAPIGate's storeOne 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 on2.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.

Several instances

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.

06 · Keeping services apart

Closed in 0.43.

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.

Closed in 0.43A key allowed customer, on a connection whose database is crm.
RunsResult
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
UpgradingThis was a breaking change. A key that reads another database's table needs that grant written out, 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.

07 · Sizing

How many workers?

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.

sizing · workers · mysql
Workers needed {{ workers }} including one spare
Per worker{{ perWorker }} calls a secondlimited by {{ limit }}
CPU allows {{ cpuCap }}Waiting allows {{ waitCap }}

{{ 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.

Disk

Every call is written to the event log, kept 24 hours by default - about 570 bytes a call.

Sustained calls/sEvent log after 24 h
50about 2.5 GB
500about 25 GB
2,000about 100 GB

Shorten it with QUERYAPIGATE_EVENTS_RETENTION_HOURS.

08 · How it's measured

Measured, not promised.

  • Every part on its own cores: the gateway, MySQL 8.4 and the load generator, on a 20-thread laptop on AC power. A /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.
  • Builds take turns in a rotating order, 5 rounds per comparison. A difference counts only when the min-max ranges don't overlap; by chance, that happens under 1% of the time.
  • Direct baseline: sysbench sent the same SQL straight to MySQL, over TLS like the gateway's driver.
  • Real skew: 600,000 users drawn uniformly, or from a Zipf distribution for the busy-user test.
  • 9.65 million requests, every one returned 200.
What it doesn't cover
  • one machine, not separate hosts;
  • data in memory, so every query is cheap;
  • a simulated network;
  • no TLS termination, load balancer or Redis in front;
  • reads only, no writes;
  • a burst of about 30 ms every few seconds at light load (p99.9), measured but not yet explained.

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.

09 · Reproduce

Run it on your own machine

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.

benchmarks · tpcc_mysql
$ 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