log in
consulting hosting industries the daily tools about contact

MariaDB thread pools: what max_connections can't save you from

Raising max_connections feels like the right fix for connection exhaustion. It isn't. Here's what actually works under burst traffic.

Raising max_connections is the first thing everyone does when MariaDB starts rejecting connections under load. It's also mostly wrong, and I've watched it make things worse on more than one client server. The real fix is the thread pool, and understanding why changes how you think about database capacity entirely.

The situation that taught me this

A few years back I was running managed hosting for an e-commerce client — mid-sized, WooCommerce on the front end, custom Laravel API handling fulfillment on the back. Black Friday. Traffic spiked roughly 6x in about four minutes (they'd landed a deal aggregator mention they didn't tell me about). Within two minutes I had alerts: Too many connections. The site started returning 500s.

My first move, like everyone's first move, was to SSH in and bump max_connections from 150 to 500. I reloaded MariaDB. Things got worse. The server load average climbed to 40 on an 8-core box, queries that normally took 12ms were timing out at 30 seconds, and we were now just failing slower with more connections.

I rolled it back, set max_connections to 200, enabled the thread pool, and the server stabilized in under 90 seconds. That's the post.

What max_connections actually does

Each connection to MariaDB without a thread pool gets its own OS thread. That means when you have 300 active connections, you have 300 threads competing for CPU time, memory, mutex locks on the InnoDB buffer pool, and I/O bandwidth.

The OS scheduler doesn't know that 280 of those threads are blocked waiting on a disk read or a row lock. It context-switches between all of them anyway. You get thrashing. The more connections you allow, the more threads, the more thrashing, the slower every single query becomes — which keeps threads busy longer — which causes more connections to pile up. It's a feedback loop, and raising max_connections pours fuel on it.

MariaDB's thread pool breaks this by decoupling connections from threads. You can have 1,000 open connections handled by 32 worker threads. Connections that aren't actively executing wait in a queue; threads pick up work as they finish. The OS is scheduling 32 threads, not 1,000. Cache stays warm, context switching stays manageable, the server stays responsive.

The configuration that actually works

MariaDB ships the thread pool as a plugin. On most Linux installs it's already available; you just need to enable it. Here's what I put in /etc/mysql/mariadb.conf.d/99-threadpool.cnf for that client (8-core box, 32GB RAM):

[mysqld]
# Thread pool
thread_handling                = pool-of-threads
thread_pool_size               = 8
thread_pool_max_threads        = 1000
thread_pool_idle_timeout       = 60
thread_pool_stall_limit        = 500

# Connection limits — keep this reasonable now
max_connections                = 300
back_log                       = 150

# Keep per-thread memory sane
thread_stack                   = 256K
read_buffer_size               = 128K
read_rnd_buffer_size           = 256K
join_buffer_size               = 256K
sort_buffer_size               = 512K

thread_pool_size is the one setting that matters most. The rule of thumb is to set it to the number of physical CPU cores. Not logical, not hyperthreaded — physical. My 8-core box gets thread_pool_size = 8. The idea is that you want one thread per core doing real work, with zero wasted cycles on context switching.

thread_pool_stall_limit (in milliseconds) controls when the pool considers a query "stalled" and adds a thread to cover it. If a query has been running for longer than this without yielding, MariaDB spins up an extra thread so the pool doesn't deadlock behind one slow query. 500ms is a reasonable default; tune it down if you have lots of fast OLTP queries and occasional long-running reports that shouldn't block them.

thread_pool_max_threads is the safety valve. The pool will spin up threads beyond thread_pool_size to handle stalled queries, but it won't go past this number. Keep it well above max_connections.

What this looks like in Laravel

Nothing changes in your application code, which is the point. Laravel's database config stays the same. But your connection pool settings in config/database.php matter more now because you want to avoid holding connections open unnecessarily:

// config/database.php
'mysql' => [
    'driver'         => 'mysql',
    'host'           => env('DB_HOST', '127.0.0.1'),
    'port'           => env('DB_PORT', '3306'),
    'database'       => env('DB_DATABASE', 'forge'),
    'username'       => env('DB_USERNAME', 'forge'),
    'password'       => env('DB_PASSWORD', ''),
    'charset'        => 'utf8mb4',
    'collation'      => 'utf8mb4_unicode_ci',
    'prefix'         => '',
    'strict'         => true,
    'engine'         => null,
    'options'        => [
        PDO::ATTR_TIMEOUT          => 5,      // fail fast, don't hang
        PDO::MYSQL_ATTR_COMPRESS   => false,
    ],
    // If you're using a connection pooler like ProxySQL:
    // 'options' => [PDO::ATTR_PERSISTENT => false], // let the pooler manage it
],

The PDO::ATTR_TIMEOUT is important. Under burst load, you want requests that can't get a connection to fail quickly and return a 503, not hang for 30 seconds and stack up PHP-FPM workers until the web server falls over too. Fast failure keeps the blast radius small.

If you're running Laravel queue workers, make sure your QUEUE_CONNECTION is set up so workers reconnect after forks rather than holding a connection idle. Horizon handles this well; vanilla queue workers with --sleep are fine too. The thing to avoid is a worker that opens a connection at boot and holds it for hours across hundreds of job cycles.

The gotchas that will bite you

The thread pool is MariaDB-only. MySQL's thread pool is an Enterprise feature. If you're on RDS MySQL or Aurora MySQL, you don't have this. PlanetScale doesn't expose it either. This is one reason I've standardized on MariaDB for managed hosting — the thread pool is included, no enterprise license required.

thread_pool_size too high is its own problem. I've seen people set thread_pool_size = 64 on a 4-core box because "more threads = more throughput" feels intuitive. It doesn't work that way here. You're back to context-switching hell, just with the pool's overhead added on top. Match core count.

Long-running queries will eat your stall threads. If you have a reporting query that runs for 45 seconds, thread_pool_stall_limit = 500 means MariaDB will have popped an extra thread for it after half a second. If you have 20 reports running simultaneously, you've got 20 extra threads plus your base pool. Know your query mix. Consider routing long analytical queries to a read replica.

max_connections still matters. The thread pool doesn't mean you can set max_connections = 10000 and forget it. Each open connection still consumes memory for its connection descriptor, authentication state, and per-session variables. On a 32GB server I typically keep max_connections at 300-500. The thread pool handles the burst; max_connections is still the hard ceiling on connection memory.

ProxySQL stacks well with this. For clients with multiple app servers hitting one database, I put ProxySQL in front. ProxySQL maintains a persistent pool of backend connections to MariaDB (say, 50) while accepting thousands of frontend connections from the application tier. The thread pool then manages those 50 backend connections efficiently. That combination — ProxySQL + MariaDB thread pool — handles a lot of traffic on hardware that would buckle otherwise.

When I'd reach for this

Any MariaDB server handling bursty web traffic. E-commerce is the obvious one — flash sales, aggregator mentions, anything where traffic can spike 5-10x in under a minute. Healthcare portals that are quiet overnight and slammed at 8am when staff log in. Any SaaS app with a shared database tier.

If your traffic is perfectly smooth and predictable, the thread pool still helps, but you'll feel it less. The benefit is most dramatic when connection count variance is high.

I wouldn't reach for the thread pool as a substitute for query optimization. I've seen servers where the real problem was a missing index causing full table scans on a 50M-row table. No amount of thread pooling fixes that — you just get more threads efficiently waiting on the same bad query. Fix your queries first, then tune the thread pool.

I also wouldn't use this as a reason to skip read replicas on a busy OLAP workload. The thread pool optimizes CPU scheduling; it doesn't add I/O bandwidth or buffer pool capacity.

Monitoring it

Once the thread pool is running, these status variables tell you what's happening:

SHOW STATUS LIKE 'Threadpool%';
-- Threadpool_threads       : current worker threads (should stay near thread_pool_size)
-- Threadpool_idle_threads  : threads with no work
-- Threadpool_queued_requests: connection requests waiting (watch this under load)

SHOW STATUS LIKE 'Connections';
SHOW STATUS LIKE 'Max_used_connections';
SHOW STATUS LIKE 'Threads_running';

Threads_running is the most useful single number. On a healthy server with the thread pool, this should stay close to thread_pool_size even under load. If it's climbing toward thread_pool_max_threads, your stall_limit is too aggressive or you've got slow queries.

Closing

The instinct to raise max_connections when you're seeing connection errors is completely understandable — it's a number with the word "max" in it, and you want more. But more connections without thread pool management just means more things failing at the same time. Enable the thread pool, set thread_pool_size to your core count, and keep max_connections honest. That's the configuration that's actually held up for me at 3am on a spike.

Related

Need help shipping something like this? Get in touch.