MySQL & MariaDB
InnoDB, indexes, transactions, users, slow queries, replication and backups in practice.
In 30 seconds
MySQL is one of the most widely used open-source relational databases and the default for most PHP and Laravel hosting. Its InnoDB engine gives you transactions, row-level locking and crash recovery. MariaDB is a community fork that is still largely compatible. Running either well comes down to good indexes, a few key settings, careful user privileges and backups you have actually restored.
Why I use it
- I use MySQL and MariaDB for most of my Laravel projects because they are fast for typical web workloads, supported everywhere, and well understood by hosting panels and teams.
- I rely on InnoDB transactions for checkout, wallet and stock operations, where several rows must change together or not at all.
- I keep databases bound to localhost or a private network, with a separate user per application, because a database open to the internet is one of the most common breaches.
- I value the slow query log and EXPLAIN because most performance problems I have fixed were a missing index, not a lack of hardware.
How it works
01InnoDB: how MySQL stores data
InnoDB is the default storage engine and the only one you should use for application data. It stores each table as a clustered index: rows are physically ordered by the primary key inside a B-tree. Secondary indexes store the indexed columns plus the primary key, and use it to find the full row. Two consequences follow. A short, ever-increasing primary key (bigint) keeps every index small and inserts cheap, while random UUIDs cause page splits and bloat. And a lookup through a secondary index costs two searches unless the index covers all needed columns. InnoDB also keeps a redo log for crash recovery, an undo log for rollbacks and consistent reads, and a buffer pool that caches pages in memory.
- innodb_buffer_pool_size: about 50 to 70% of RAM on a dedicated database server.
- Every table needs an explicit primary key.
- Use utf8mb4 everywhere; the old utf8 cannot store emojis or some scripts.
02Indexes and EXPLAIN
MySQL indexes are B-trees, and the leftmost-prefix rule applies: an index on (status, created_at) helps WHERE status = 'paid' and WHERE status = 'paid' AND created_at > ..., but not created_at alone. A covering index contains every column the query needs, so MySQL never touches the table; EXPLAIN shows 'Using index'. Functions on columns, like WHERE DATE(created_at) = ..., prevent index use; rewrite them as ranges. Leading wildcards (LIKE '%term') cannot use a B-tree, so use FULLTEXT or a search engine. Use EXPLAIN to see the plan and EXPLAIN ANALYZE (MySQL 8) to see real timings. Watch the type column: const and ref are good, ALL means a full table scan.
ALTER TABLE orders ADD INDEX idx_status_created (status, created_at);
EXPLAIN ANALYZE
SELECT id, total_minor
FROM orders
WHERE status = 'paid'
AND created_at >= '2026-10-01'
AND created_at < '2026-11-01';
03Transactions and locking
A transaction groups statements so they all succeed or all fail (ACID: atomic, consistent, isolated, durable). InnoDB's default isolation level is REPEATABLE READ: within a transaction you see a consistent snapshot. Plain SELECTs never block writers, thanks to MVCC (multi-version concurrency control). When you need to read a row and then update it safely, for example stock or a wallet balance, lock it with SELECT ... FOR UPDATE, or update it atomically in one statement with a condition. Keep transactions short: long ones hold locks, block others and grow the undo log. Deadlocks happen when two transactions lock rows in opposite order; InnoDB detects them and rolls one back, so your code should retry.
START TRANSACTION;
SELECT stock FROM variants WHERE id = 42 FOR UPDATE;
UPDATE variants SET stock = stock - 2
WHERE id = 42 AND stock >= 2; -- 0 rows affected = out of stock
INSERT INTO order_items (order_id, variant_id, qty, unit_price_minor)
VALUES (1001, 42, 2, 2490);
COMMIT;
04Users and privileges
MySQL accounts are user plus host: 'app'@'localhost' and 'app'@'%' are different accounts. Follow least privilege. The application user gets only data rights (SELECT, INSERT, UPDATE, DELETE) on its own database. A separate migration user gets schema rights (CREATE, ALTER, INDEX, DROP), and backups get their own read-only account. Never let applications connect as root. Since MySQL 8.4 the old mysql_native_password plugin is disabled by default; caching_sha2_password is the default and works with current drivers. Bind the server to 127.0.0.1 or a private network address with bind-address, and keep port 3306 closed in the firewall. Use TLS if connections cross a network.
CREATE USER 'shop_app'@'localhost' IDENTIFIED BY 'use-a-long-random-secret';
GRANT SELECT, INSERT, UPDATE, DELETE ON shop.* TO 'shop_app'@'localhost';
CREATE USER 'shop_migrate'@'localhost' IDENTIFIED BY 'another-long-secret';
GRANT ALL PRIVILEGES ON shop.* TO 'shop_migrate'@'localhost';
SHOW GRANTS FOR 'shop_app'@'localhost';
05The slow query log
The slow query log records every statement that takes longer than long_query_time seconds. It is the fastest way to find what actually hurts in production, instead of guessing. Turn it on with a threshold of around half a second to one second, optionally log queries that do not use indexes on a staging copy, and then summarise the file. mysqldumpslow ships with MySQL; pt-query-digest from Percona Toolkit gives a better report grouped by query fingerprint, showing which queries consume the most total time. Fix the top few, usually with an index or a rewritten query, then repeat. The performance_schema and sys schema offer the same insight live, for example sys.statements_with_runtimes_in_95th_percentile.
# /etc/mysql/mysql.conf.d/mysqld.cnf
[mysqld]
slow_query_log = ON
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 0.5
# then summarise:
# sudo pt-query-digest /var/log/mysql/slow.log | less
06Replication basics
Replication copies changes from a source server to one or more replicas. The source writes every change to its binary log (binlog); each replica pulls the binlog and replays it. Use row-based format (the default) and GTIDs (global transaction ids), which make failover and re-pointing replicas much simpler. Replication is asynchronous by default, so a replica can lag behind; reads from it may be slightly stale, which matters for 'read your own write' flows like showing an order right after checkout. Replicas are useful for read scaling, reporting and as a warm standby. Replication is not a backup: a DROP TABLE or bad UPDATE is replicated instantly. Since MySQL 8.4 the terms are source and replica, with commands like SHOW REPLICA STATUS.
07Backups, and MySQL vs MariaDB
There are two kinds of backup. Logical backups, made with mysqldump or MySQL Shell's dump utilities, export SQL or data files; they are portable across versions and easy to restore partially, but slow for large databases. Use --single-transaction for a consistent InnoDB snapshot without locking. Physical backups copy the data files themselves, using Percona XtraBackup for MySQL or mariadb-backup for MariaDB; they are much faster to take and restore for big databases and support incremental backups. Keep binlogs to allow point-in-time recovery, store copies off the server, and test restores regularly. MariaDB forked from MySQL in 2009. It is still close for everyday SQL, but the two have diverged: JSON, authentication, replication and optimiser features differ, so treat them as separate products and test before switching.
# consistent logical backup, compressed, off to another location
mysqldump --single-transaction --routines --triggers --events \
-u backup_user -p shop | gzip > shop-$(date +%F).sql.gz
# restore into a test database to prove the backup works
gunzip < shop-2026-11-05.sql.gz | mysql -u root -p shop_restore_test
Use it when
- Typical web and e-commerce workloads with many short reads and writes.
- Laravel and PHP projects, especially on shared or panel-managed hosting.
- Teams that want a mature, well-documented database with simple replication.
- Read-heavy apps that can scale with replicas.
Skip it when
- When you need advanced features like rich JSONB indexing, row-level security, exclusion constraints or many extensions; PostgreSQL is stronger there.
- Heavy analytics over billions of rows, where a columnar database such as ClickHouse fits better.
Alternatives
- PostgreSQL
Richer SQL, JSONB, extensions and row-level security; my choice for complex multi-tenant SaaS.
- MariaDB
Community fork, common on Linux distributions and hosting panels; mostly compatible but diverging.
- Managed MySQL (RDS, Aurora, PlanetScale)
Backups, failover and upgrades handled for you, at a higher price.
Best practices
- Use InnoDB, utf8mb4 and an explicit short primary key on every table.
- Bind to localhost or a private network and close port 3306 publicly.
- Give each application its own least-privilege user; never connect as root.
- Enable the slow query log and review it regularly.
- Keep transactions short and retry on deadlock.
- Combine regular backups with binlogs for point-in-time recovery, and test restores.
- Stay on a supported release line, such as MySQL 8.4 LTS.
Common mistakes
- Using the legacy utf8 charset, which is really a 3-byte subset.
- Wrapping indexed columns in functions so indexes are ignored.
- Treating a replica as a backup.
- Running mysqldump without --single-transaction on a busy server.
- Leaving the server listening on a public interface with weak passwords.
Deep dive
08Memory and durability: buffer pool, redo log and flush settings
Most MySQL tuning comes down to a few InnoDB settings. The buffer pool caches data and index pages; on a dedicated database server it usually gets 50 to 75 percent of RAM, and the goal is that the hot working set fits so reads rarely touch disk. innodb_dedicated_server = ON sizes the buffer pool and redo log automatically from the machine's memory, which is a good default on a host that runs only MySQL. The redo log records changes before data pages are written; since MySQL 8.0.30 its size is one setting, innodb_redo_log_capacity, and a log that is too small forces constant flushing under write load. Durability is a trade-off you choose explicitly. innodb_flush_log_at_trx_commit = 1 and sync_binlog = 1 flush on every commit and survive power loss without losing committed transactions; setting them to 2 and 0 is faster but can lose about a second of commits in a crash. Keep 1 and 1 for anything involving money.
# /etc/mysql/mysql.conf.d/mysqld.cnf (dedicated DB host, 16 GB RAM)
[mysqld]
innodb_buffer_pool_size = 10G
innodb_redo_log_capacity = 4G
innodb_flush_log_at_trx_commit = 1 # full durability
sync_binlog = 1
max_connections = 300 # each connection also uses memory
binlog_expire_logs_seconds = 604800 # keep 7 days for point-in-time recovery
# check how often reads miss the buffer pool:
# SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_read%';
09Online schema changes: INSTANT, INPLACE and metadata locks
ALTER TABLE in MySQL 8 has three algorithms. INSTANT changes only metadata and finishes in milliseconds regardless of table size; since 8.0.29 it can add or drop columns at any position, and it can also rename columns and change defaults. INPLACE rebuilds or modifies the table without blocking reads and writes for many operations, such as adding a secondary index with LOCK=NONE, but it still takes time and I/O, and on replicas the change is replayed afterwards, causing lag. COPY rebuilds the whole table and blocks writes. State the algorithm explicitly; MySQL then refuses instead of silently falling back to something slower. The hidden danger is the metadata lock. Every ALTER needs a brief exclusive metadata lock at start and end. If a long transaction is open on the table, the ALTER waits, and every new query on that table queues behind the ALTER: the site appears frozen. Set a short lock_wait_timeout and retry. For huge tables or risky changes, gh-ost or pt-online-schema-change copy the table in the background in throttled chunks.
SET SESSION lock_wait_timeout = 5; -- give up quickly instead of freezing traffic
ALTER TABLE orders ADD COLUMN gift_note VARCHAR(255) NULL, ALGORITHM=INSTANT;
ALTER TABLE orders ADD INDEX idx_orders_customer (customer_id, created_at),
ALGORITHM=INPLACE, LOCK=NONE;
-- who is holding or waiting for metadata locks?
SELECT * FROM sys.schema_table_lock_waits\G
10Locking internals and deadlock analysis
InnoDB locks index records, not rows in the abstract, and that explains most surprises. A locking read or an UPDATE locks every index record it scans, not just the ones it changes, so an UPDATE whose WHERE clause cannot use an index can lock a large part of the table. Under REPEATABLE READ, InnoDB also takes gap and next-key locks on ranges to prevent phantoms, which means an INSERT can wait on a range another transaction scanned. Practical rules: make sure UPDATE and DELETE statements filter on an index; touch rows in a consistent order, for example sorted by primary key, so two transactions cannot lock in opposite orders; and keep transactions short. When deadlocks happen, InnoDB rolls back the cheaper transaction with error 1213, and the application should simply retry the whole transaction. To understand one, read the LATEST DETECTED DEADLOCK section of SHOW ENGINE INNODB STATUS, or set innodb_print_all_deadlocks = ON to log every deadlock. Live waits are visible in performance_schema.data_locks and sys.innodb_lock_waits.
-- who blocks whom right now
SELECT waiting_pid, waiting_query, blocking_pid, blocking_query, wait_age
FROM sys.innodb_lock_waits;
-- last deadlock, with both transactions and the locks they held
SHOW ENGINE INNODB STATUS\G
-- log every deadlock to the error log
SET PERSIST innodb_print_all_deadlocks = ON;
11High availability: parallel replication, semi-sync and clusters
Basic asynchronous replication can lose the last transactions if the source dies, and a replica can fall behind under heavy writes. Several features address this. Multi-threaded replication applies transactions in parallel on the replica; replica_parallel_workers defaults to 4 since 8.0.27 and replica_preserve_commit_order keeps the commit order safe. Semi-synchronous replication makes the source wait until at least one replica has received each transaction before confirming the commit, trading a little latency for not losing acknowledged writes on failover. For automatic failover, MySQL InnoDB Cluster combines Group Replication, a consensus protocol across three or more nodes, with MySQL Router, which sends connections to the current primary. MariaDB users often choose Galera Cluster instead. Whatever you choose, monitor lag (SHOW REPLICA STATUS, or better a heartbeat table), avoid huge single transactions that replicas must replay serially, and route read-your-own-write requests to the primary.
- Batch big UPDATE or DELETE jobs into chunks of a few thousand rows to keep replicas close.
- A primary key on every table is required for Group Replication and good for row-based replication.
- Rehearse failover; a cluster nobody has failed over is an untested assumption.
Real-world scenarios
A simple ALTER TABLE froze the whole site
Problem
During a deploy, a migration added an index to the orders table. Within seconds, every page touching orders hung, and the process list filled with 'Waiting for table metadata lock'.
How to solve it
First recover: find the blocker with SELECT * FROM sys.schema_table_lock_waits; it is usually a long-running or idle open transaction, such as a forgotten console session or a report query. Kill the blocker, or kill the ALTER to let traffic flow again. Then prevent it. Run migrations with SET SESSION lock_wait_timeout = 5 and retry automatically a few times, so a blocked ALTER gives up instead of queueing everyone behind it. State ALGORITHM=INSTANT or INPLACE, LOCK=NONE explicitly. Alert on transactions open longer than a minute. For large tables or changes that need a copy, use gh-ost or pt-online-schema-change, which throttle on load and replica lag.
Replica lag spikes every night
Problem
Read replicas fall 20 to 30 minutes behind during a nightly cleanup job, so customers see stale order statuses on pages that read from replicas.
How to solve it
Look at the job: one DELETE or UPDATE touching millions of rows is a single transaction that replicas must replay as one unit, and it cannot be parallelised. Rewrite it to work in chunks, for example DELETE ... WHERE created_at < ? ORDER BY id LIMIT 5000 in a loop with a short pause, stopping when replica lag passes a threshold. Make sure the WHERE clause uses an index so each chunk is fast. Check that replica_parallel_workers is above 1 and replicas have the same hardware as the source. For pages that show data right after a change, such as order status after payment, read from the primary or use sticky reads for a few seconds after a write.
MySQL gets killed by the out-of-memory killer under load
Problem
On a server with 8 GB RAM running MySQL and the application, MySQL restarts during traffic peaks. The kernel log shows the OOM killer chose mysqld.
How to solve it
Add up memory honestly. The buffer pool was set to 6 GB as if MySQL were alone, and max_connections was 1,000, while each connection can allocate sort, join and temporary-table buffers. Lower the buffer pool to leave room for the application and the operating system, for example 3 to 4 GB on this shared host. Cap max_connections to what the application really needs and use a connection pool in the app, so peaks queue instead of opening hundreds of sessions. Keep per-session buffers like sort_buffer_size at defaults. Watch memory per component through the sys schema's memory views. If load keeps growing, move MySQL to its own server, where innodb_dedicated_server can size it safely.
Cheat sheet
- Add a column instantly
ALTER TABLE orders ADD COLUMN note VARCHAR(255) NULL, ALGORITHM=INSTANT;- Build an index without blocking writes
ALTER TABLE orders ADD INDEX idx_c (customer_id), ALGORITHM=INPLACE, LOCK=NONE;- Fail fast on metadata locks
SET SESSION lock_wait_timeout = 5;- See metadata lock waits
SELECT * FROM sys.schema_table_lock_waits;- See row lock waits
SELECT * FROM sys.innodb_lock_waits;- Read the last deadlock
SHOW ENGINE INNODB STATUS\G- Check replica health and lag
SHOW REPLICA STATUS\G- Find long-open transactions
SELECT * FROM information_schema.innodb_trx ORDER BY trx_started LIMIT 10;- Auto-size for a dedicated DB host
innodb_dedicated_server = ON- Online change of a huge table
gh-ost --database=shop --table=orders --alter="ADD INDEX idx_s (status)" --execute # plus connection flags
In my real projects
- Several of the production servers I run host Laravel sites backed by MariaDB/MySQL on Plesk-managed stacks.
- My security baseline for every server keeps databases bound to localhost or a private network, never public.
- I checked the panel backups on those servers, and my standard is snapshots plus tested restores, with off-server backups as part of an ongoing consolidation plan.
- On the Laravel backends I lead, such as a restaurant ordering platform with a cashback wallet and a pet-supplies store with warehouse stock movements, checkout and balance changes are exactly the flows where transactions matter most.
Questions & answers
Q1Why does the primary key choice matter so much in InnoDB?
Because InnoDB stores the table as a clustered index ordered by the primary key, and every secondary index stores the primary key value as its pointer to the row. A short, sequential key such as a bigint means new rows append at the end of the B-tree, pages stay full and every secondary index stays small. A random UUID stored as a string makes each insert land in a random page, causing page splits, fragmentation and more I/O, and it bloats every secondary index. If UUIDs are needed, I store them as BINARY(16), prefer time-ordered UUIDv7, or keep a bigint key and a separate unique public id.
Q2How do you find and fix a slow query in MySQL?
I start with data, not guesses: the slow query log or performance_schema, summarised with pt-query-digest to find the queries with the highest total time, not just the slowest single run. For the top query I run EXPLAIN ANALYZE and check the access type, the rows examined versus rows returned, and whether there is a filesort or temporary table. Common fixes are a composite index in the right column order, a covering index, rewriting a function on a column into a range, replacing OFFSET pagination with keyset pagination, or removing an N+1 pattern in the ORM. Then I measure again.
Q3What isolation level does InnoDB use and what does it mean in practice?
REPEATABLE READ by default. Inside a transaction, plain SELECTs read from a consistent snapshot taken at the first read, so repeated reads return the same data even if others commit changes, and readers never block writers thanks to MVCC. For locking reads, such as SELECT ... FOR UPDATE, InnoDB uses next-key locks, which also lock gaps to prevent phantom rows, and that can cause more lock waits and deadlocks than people expect. Many teams switch to READ COMMITTED for less locking. Either way, for read-then-write logic like stock or balances I use explicit locks or atomic conditional updates.
Q4mysqldump or a physical backup?
For small and medium databases, up to a few tens of gigabytes, mysqldump with --single-transaction is simple, portable across versions and lets me restore a single table. Its weakness is speed: dumping is slow and restoring is much slower, because every index is rebuilt. For larger databases I use physical backups with Percona XtraBackup or mariadb-backup, which copy data files while the server runs, restore quickly and support incrementals. In both cases I keep binary logs for point-in-time recovery, encrypt and store copies off the server, and regularly restore into a test instance, because an untested backup is only a hope.
Q5MySQL or MariaDB: are they the same?
They share history and most everyday SQL, so a typical Laravel app runs on either. But since the 2009 fork they have diverged. MySQL, developed by Oracle, has a native binary JSON type, its own optimiser features and InnoDB Cluster; MariaDB stores JSON as text with validation, has its own features such as system-versioned tables, sequences and different storage engines, and its own replication and GTID format. Version numbers no longer line up, and replication or physical backups between them are not supported. I pick one per project, match it in development and CI, and test carefully before migrating between them.
Q6How do you secure a MySQL server?
Network first: bind it to localhost or a private interface and keep port 3306 closed in both the server firewall and the provider firewall, so it is never reachable from the internet. Then accounts: remove anonymous users and remote root, give each application its own user limited to its database and to data statements, use separate users for migrations and backups, and use strong generated passwords stored as secrets. Use TLS when traffic crosses a network. Keep the server on a supported version and patched, and encrypt backups. Finally, limit what the application can do, so a SQL injection cannot drop tables.
Senior-level questions
S1How would you change the schema of a 500-million-row MySQL table with no downtime?
First, check whether the change can be INSTANT: adding or dropping columns, renaming, or changing defaults usually can on MySQL 8.0.29 and later, and then the table size does not matter. If not, an INPLACE change with LOCK=NONE still causes heavy I/O and large replica lag on a table this size, because replicas replay it afterwards. So I use gh-ost or pt-online-schema-change: they create a shadow table with the new schema, copy rows in small chunks, apply ongoing changes (gh-ost reads the binlog, pt-osc uses triggers), throttle on load and replica lag, and finish with a quick atomic rename. Both still need a brief metadata lock at cut-over, so I set lock_wait_timeout, schedule the cut-over at low traffic and make sure no long transactions are open. And the change itself follows expand and contract, so old and new code both work.
S2READ COMMITTED or REPEATABLE READ for a busy OLTP application?
REPEATABLE READ is the default and gives each transaction a stable snapshot, but for locking reads, UPDATEs and DELETEs it takes gap and next-key locks on scanned ranges. Under high concurrency that produces more lock waits and deadlocks, especially on inserts into busy ranges. READ COMMITTED takes only record locks for most operations and releases locks on non-matching rows early, so it has less contention; the cost is non-repeatable reads, meaning two identical SELECTs in one transaction may see different committed data, and it requires row-based binary logging, already the default. Many high-traffic MySQL installations run READ COMMITTED, and it matches PostgreSQL's default. My view: correctness should not depend on the isolation level anyway. Code that reads then writes must use explicit locks or atomic conditional updates, and then READ COMMITTED is usually the better operational choice.
S3How would you design MySQL for high availability on a modest budget?
I match the design to the real requirement for downtime and data loss. For many business apps, one primary with one replica on a separate host, GTID-based replication, semi-sync to avoid losing acknowledged commits, and a scripted, rehearsed manual failover gives minutes of recovery for little cost. Backups with binlogs remain the protection against human error, which replication copies instantly. When minutes are too long, InnoDB Cluster with three nodes and MySQL Router gives automatic failover, at the price of more operational knowledge, a primary key on every table and sensitivity to network latency between nodes. A managed service moves that work to the provider at a higher monthly cost. The trade-off is always money and complexity against recovery time, so I write down the target recovery time and acceptable data loss first, then pick the simplest setup that meets them.
Glossary
- Buffer pool
- InnoDB's main memory cache for data and index pages; the most important MySQL memory setting.
- Redo log
- A write-ahead log of changes InnoDB replays after a crash so committed transactions are not lost.
- Metadata lock
- A lock on a table's definition; DDL needs it exclusively and waits for open transactions on the table.
- ALGORITHM=INSTANT
- An ALTER TABLE mode that changes only metadata, finishing almost immediately regardless of table size.
- Next-key lock
- A lock on an index record plus the gap before it, used to prevent phantom rows.
- Semi-synchronous replication
- The source waits until a replica confirms it received a transaction before acknowledging the commit.
- Group Replication
- MySQL's consensus-based replication among several nodes, the base of InnoDB Cluster's automatic failover.
- MySQL Router
- A lightweight proxy that routes application connections to the current primary or replicas of a cluster.
- gh-ost
- An online schema change tool that copies a table in throttled chunks using the binlog, without triggers.
Keep learning
Have a product in mind?Let's build it.
Tell me about your idea, your product or your team. I usually reply within one working day.