ACID Properties in MySQL
ACID is the set of four properties that guarantee database transactions process reliably even in the face of errors, power failures, and concurrent access. The acronym stands for Atomicity, Consistency, Isolation, and Durability. MySQL's InnoDB storage engine is built from the ground up to satisfy all four.
Overview of ACID
Property | Guarantee | InnoDB Mechanism |
|---|---|---|
Atomicity | All operations in a transaction succeed, or none do | Undo log — stores before-images for rollback |
Consistency | Transaction brings database from one valid state to another | Constraints, triggers, cascades, CHECK |
Isolation | Concurrent transactions do not interfere with each other | MVCC (Multi-Version Concurrency Control) + gap locks |
Durability | Committed data survives crashes and restarts | Redo log + doublewrite buffer + fsync |
Atomicity — All or Nothing
Atomicity means a transaction is an indivisible unit. If any part fails — whether from an error, a constraint violation, or a power failure — the entire transaction is rolled back as if nothing happened.
-- Transfer $500 from Account A to Account B START TRANSACTION; UPDATE accounts SET balance = balance - 500 WHERE account_id = 1; -- Suppose a power failure or application crash occurs here UPDATE accounts SET balance = balance + 500 WHERE account_id = 2; COMMIT; -- Without atomicity: Account A loses $500 but Account B never receives it (partial failure) -- With atomicity: on crash/error, the entire transaction rolls back to original state
Undo Log — How InnoDB Rolls Back Changes
Before modifying any row, InnoDB writes the before-image of that row to the undo log (stored in dedicated undo tablespaces). This is how rollback works:
- Transaction begins.
- For each row to be changed, the original values are written to the undo log.
- The actual row in the data file is modified.
- On ROLLBACK (or crash): InnoDB reads the undo log entries and re-applies the before-images, restoring every row to its pre-transaction state.
- On COMMIT: the undo log entries become eligible for purge (cleaned up by the purge thread).
-- Inspect active transactions and undo log usage SELECT trx_id, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS age_seconds, trx_rows_modified, trx_undo_log_size_bytes, trx_query FROM information_schema.INNODB_TRX ORDER BY trx_started; -- Undo log size is bounded by innodb_max_undo_log_size SHOW VARIABLES LIKE 'innodb_max_undo_log_size'; -- Default: 1GB — long transactions can fill this, causing errors -- Check undo tablespace status SELECT TABLESPACE_NAME, FILE_NAME, STATE FROM information_schema.FILES WHERE FILE_TYPE = 'UNDO LOG';
Consistency — Valid State to Valid State
Consistency guarantees that a transaction can only bring the database from one valid state to another valid state. "Valid" means all constraints, rules, and cascades are satisfied. If a transaction would violate them, MySQL rejects it and rolls back the offending statement.
-- NOT NULL constraint enforces consistency
START TRANSACTION;
INSERT INTO customers (first_name, last_name, email)
VALUES ('John', 'Doe', NULL); -- email is NOT NULL
-- ERROR 1048: Column 'email' cannot be null
-- INSERT is rejected; database remains consistent
-- FOREIGN KEY constraint enforces referential integrity
INSERT INTO orders (customer_id, total)
VALUES (99999, 100); -- customer 99999 does not exist
-- ERROR 1452: Cannot add or update a child row: foreign key constraint fails
-- UNIQUE constraint prevents duplicates
INSERT INTO users (email) VALUES ('alice@example.com');
INSERT INTO users (email) VALUES ('alice@example.com');
-- ERROR 1062: Duplicate entry for key 'email'
-- CHECK constraint (MySQL 8.0.16+) enforces business rules at the DB level
ALTER TABLE products
ADD CONSTRAINT chk_price_positive CHECK (price > 0);
UPDATE products SET price = -5 WHERE product_id = 1;
-- ERROR 3819: Check constraint 'chk_price_positive' is violatedIsolation — MVCC Deep-Dive
Isolation controls which changes made by one transaction are visible to other concurrent transactions. Without isolation, concurrent transactions produce these classic problems:
Problem | Description | Prevented by REPEATABLE READ? |
|---|---|---|
Dirty read | Read uncommitted data from another transaction that later rolls back | Yes |
Non-repeatable read | Same row read twice returns different values (another tx committed between reads) | Yes |
Phantom read | Same range query returns different rows (another tx inserted/deleted in the range) | Yes (via gap locks in InnoDB) |
Lost update | Two txns read-modify-write the same row; one overwrites the other | Prevented by locking (SELECT ... FOR UPDATE) |
MVCC — How InnoDB Implements Isolation
InnoDB uses Multi-Version Concurrency Control (MVCC) to provide non-locking reads. The key insight: readers do not block writers, and writers do not block readers.
Every row has two hidden system columns:
- DB_TRX_ID: the transaction ID that last modified this row.
- DB_ROLL_PTR: a pointer into the undo log for the previous version of this row.
When a transaction starts, InnoDB creates a read view — a snapshot of which transactions were active or committed at that moment. When you read a row:
- InnoDB checks the row's DB_TRX_ID.
- If that transaction ID is newer than your read view, the row was modified after your snapshot started.
- InnoDB follows the DB_ROLL_PTR pointer into the undo log to reconstruct the version of the row that was current at your snapshot time.
- This continues back through the undo log chain until a version visible to your read view is found.
-- MySQL default isolation level: REPEATABLE READ SHOW VARIABLES LIKE 'transaction_isolation'; -- Value: REPEATABLE-READ -- Session A: start a transaction and read START TRANSACTION; SELECT balance FROM accounts WHERE account_id = 1; -- reads 1000 -- Session B (concurrent): update and commit UPDATE accounts SET balance = 900 WHERE account_id = 1; COMMIT; -- Session A: reads again — still sees 1000 (MVCC snapshot from START TRANSACTION) SELECT balance FROM accounts WHERE account_id = 1; -- still 1000 COMMIT; -- snapshot released; next read would show 900 -- To get the latest committed value within a transaction, use READ COMMITTED: SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; START TRANSACTION; SELECT balance FROM accounts WHERE account_id = 1; -- 900 (latest committed)
Gap Locks — Preventing Phantom Reads
Under REPEATABLE READ, InnoDB uses gap locks to prevent phantom reads in range queries. A gap lock prevents other transactions from inserting rows into the gap between indexed values that your query touched.
-- Session A: range query with a lock START TRANSACTION; SELECT * FROM orders WHERE total BETWEEN 100 AND 200 FOR UPDATE; -- InnoDB places gap locks on the range [100, 200] -- Session B: tries to INSERT a row in the locked range INSERT INTO orders (customer_id, total) VALUES (1, 150); -- BLOCKS until Session A commits or rolls back -- Session A: commits — Session B's INSERT can now proceed COMMIT;
Durability — Redo Log and Write-Ahead Logging
Durability guarantees that once a transaction is committed, its changes are permanent — even if the server crashes immediately after COMMIT returns.
Redo Log (Write-Ahead Logging): Before InnoDB writes changed data pages back to the tablespace files, it writes a redo log record to the redo log files. The redo log is a sequential write — much faster than random I/O to data pages. On crash recovery, MySQL replays the redo log to reapply any committed changes that had not yet been flushed to the main data files.
Doublewrite Buffer: InnoDB pages are 16KB. OS file writes may not be atomic — a crash mid-write can produce a "torn page" (partial write). InnoDB writes pages to the doublewrite buffer area first, then to the actual data file locations. On crash recovery, if a torn page is detected, the complete copy from the doublewrite buffer is used instead.
innodb_flush_log_at_trx_commit — Durability vs Performance
Value | Write to log buffer | Flush to disk | Durability | Performance |
|---|---|---|---|---|
0 | Every ~1 second | Every ~1 second | Lose up to 1 sec on power failure | Fastest writes |
1 (default) | On every COMMIT | On every COMMIT (fsync) | Full ACID durability | Slowest writes |
2 | On every COMMIT | Every ~1 second | Survive MySQL crash but not OS/power failure | Middle ground |
-- Check current setting SHOW VARIABLES LIKE 'innodb_flush_log_at_trx_commit'; -- For financial/critical data: always use 1 (default) SET GLOBAL innodb_flush_log_at_trx_commit = 1; -- For less critical high-write workloads (e.g. analytics ingestion): -- value 2 is acceptable with a battery-backed RAID controller SET GLOBAL innodb_flush_log_at_trx_commit = 2;
innodb_flush_log_at_trx_commit = 0 or 2 dramatically improves write throughput but risks losing up to 1 second of committed transactions on a power failure or OS crash. Use the default value of 1 for any financial or critical data.Group Commit — Batching fsync Calls
Under high write loads, MySQL uses group commit to batch multiple transactions' redo log writes into a single fsync call to disk. Instead of one fsync per transaction (extremely expensive), MySQL staggers the process:
- Multiple transactions prepare their redo log records.
- The "leader" transaction of a batch calls fsync.
- All transactions in the batch are committed together.
This dramatically reduces I/O at high concurrency. You can tune it:
-- Group commit tuning variables SHOW VARIABLES LIKE 'binlog_group_commit_sync_delay'; -- microseconds to wait (default 0) SHOW VARIABLES LIKE 'binlog_group_commit_sync_no_delay_count'; -- max transactions per batch -- Monitor group commit efficiency SHOW GLOBAL STATUS LIKE 'Binlog_commits'; SHOW GLOBAL STATUS LIKE 'Binlog_group_commits'; -- If Binlog_group_commits << Binlog_commits, batching is working well
Crash Recovery Walkthrough
When MySQL restarts after a crash, InnoDB performs automatic recovery in phases:
Phase 1 — Redo Log Scan: InnoDB reads the redo log from the last checkpoint forward to find all committed changes that were not yet written to data files.
Phase 2 — Redo (Roll Forward): All committed changes from the redo log are reapplied to the data files, bringing them up to date.
Phase 3 — Undo (Roll Back): InnoDB checks for transactions that were active (uncommitted) at the time of crash. Their changes are reversed using the undo log, restoring the database to a consistent state.
Phase 4 — Doublewrite Buffer Check: If any data page is corrupt (torn page from partial write), InnoDB copies the intact version from the doublewrite buffer.
Recovery completes automatically — no manual intervention needed in normal cases.
-- Check redo log configuration SHOW VARIABLES LIKE 'innodb_redo_log_capacity'; -- MySQL 8.0.30+ (in bytes) SHOW VARIABLES LIKE 'innodb_log_file_size'; -- Older MySQL SHOW VARIABLES LIKE 'innodb_log_files_in_group'; -- Number of redo log files -- Monitor redo log usage (how full is the redo log?) SELECT (VARIABLE_VALUE / 1024 / 1024) AS redo_log_mb_used FROM performance_schema.global_status WHERE VARIABLE_NAME = 'Innodb_redo_log_current_lsn';
Demonstrating Each ACID Property with SQL
-- Setup CREATE TABLE wallets ( wallet_id INT PRIMARY KEY, owner VARCHAR(100) NOT NULL, balance DECIMAL(10,2) NOT NULL DEFAULT 0, CONSTRAINT chk_balance_non_negative CHECK (balance >= 0) ) ENGINE = InnoDB; INSERT INTO wallets VALUES (1, 'Alice', 1000.00), (2, 'Bob', 500.00); -- ATOMICITY: partial failure rolls back everything START TRANSACTION; UPDATE wallets SET balance = balance - 200 WHERE wallet_id = 1; -- OK: 1000 -> 800 UPDATE wallets SET balance = balance - 9999 WHERE wallet_id = 2; -- FAILS: CHECK violation -- Both updates are rolled back — Alice still has 1000, Bob still has 500 ROLLBACK; SELECT * FROM wallets; -- Both at original values -- CONSISTENCY: constraint prevents invalid state START TRANSACTION; UPDATE wallets SET balance = -100 WHERE wallet_id = 1; -- ERROR 3819: Check constraint 'chk_balance_non_negative' is violated ROLLBACK; -- ISOLATION (MVCC): Session A sees snapshot, not Session B's uncommitted change -- [Session A] START TRANSACTION; -- [Session A] SELECT balance FROM wallets WHERE wallet_id = 1; -- 1000 -- [Session B] UPDATE wallets SET balance = 800 WHERE wallet_id = 1; (not committed) -- [Session A] SELECT balance FROM wallets WHERE wallet_id = 1; -- still 1000 -- [Session B] COMMIT; -- [Session A] SELECT balance FROM wallets WHERE wallet_id = 1; -- still 1000 (snapshot) -- [Session A] COMMIT; -- DURABILITY: after COMMIT, data survives a server restart START TRANSACTION; UPDATE wallets SET balance = balance - 100 WHERE wallet_id = 1; UPDATE wallets SET balance = balance + 100 WHERE wallet_id = 2; COMMIT; -- Even after: sudo systemctl restart mysql -- SELECT * FROM wallets; -- Alice 900, Bob 600 — data is permanent
ACID vs BASE
Some distributed NoSQL databases favor BASE (Basically Available, Soft state, Eventually consistent) over ACID. MySQL InnoDB is a fully ACID-compliant system. For most business applications — financial records, orders, user accounts — ACID is the correct choice.
Factor | ACID (MySQL InnoDB) | BASE (Cassandra, DynamoDB) |
|---|---|---|
Consistency | Immediate, guaranteed | Eventual (may lag seconds/minutes) |
Availability | May refuse writes to maintain consistency | Highly available (always accepts writes) |
Use case | Banking, orders, inventory | Social feeds, telemetry, recommendation data |
Transactions | Full multi-row, multi-table | Limited or none across partitions |
Best Practices
Keep innodb_flush_log_at_trx_commit = 1 for financial or critical data (full durability)
Define all consistency rules (FK, NOT NULL, CHECK, UNIQUE) at the database level
Use START TRANSACTION explicitly for any operation involving multiple related changes
Monitor undo log growth with information_schema.INNODB_TRX for long-running transactions
Size the InnoDB redo log large enough for peak write bursts (aim for 1-4GB)
Test crash recovery in staging by force-killing the MySQL process mid-transaction
Use SELECT ... FOR UPDATE or SELECT ... FOR SHARE when you need read-then-modify consistency
Isolation Levels Compared
Isolation Level | Dirty Read | Non-repeatable Read | Phantom Read | Performance |
|---|---|---|---|---|
READ UNCOMMITTED | Possible | Possible | Possible | Highest (no locks) |
READ COMMITTED | Prevented | Possible | Possible | High (re-reads committed data) |
REPEATABLE READ (default) | Prevented | Prevented | Prevented in InnoDB | Good (MVCC snapshot) |
SERIALIZABLE | Prevented | Prevented | Prevented | Lowest (all reads lock rows) |
-- Change isolation level for the current session SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; -- Change isolation level for a single transaction SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; START TRANSACTION; -- ... all reads here use serializable isolation ... COMMIT; -- Check current isolation level SELECT @@transaction_isolation; -- or @@tx_isolation on older MySQL
ACID and Autocommit
By default, MySQL runs in autocommit mode — every single statement is automatically wrapped in its own transaction and immediately committed. To group multiple statements into one atomic unit, you must explicitly start a transaction.
-- Check autocommit status
SELECT @@autocommit; -- 1 = enabled (default)
-- With autocommit ON, each statement is its own transaction
INSERT INTO logs (msg) VALUES ('event 1'); -- immediately committed
INSERT INTO logs (msg) VALUES ('event 2'); -- immediately committed
-- To make them atomic together, use an explicit transaction
START TRANSACTION;
INSERT INTO logs (msg) VALUES ('event 1');
INSERT INTO logs (msg) VALUES ('event 2');
COMMIT; -- both committed atomically
-- Disable autocommit for the session (BEGIN still works when autocommit is ON)
SET SESSION autocommit = 0;InnoDB Crash Recovery Deep-Dive
When MySQL restarts after an unexpected shutdown, InnoDB performs automatic recovery before accepting connections. Understanding the phases helps when diagnosing recovery issues:
-- After crash recovery completes, check InnoDB status SHOW ENGINE INNODB STATUSG -- Look for: "Log sequence number", "Last checkpoint at" -- These confirm the redo log was replayed successfully -- Monitor recovery progress in the MySQL error log -- tail -f /var/log/mysql/error.log -- You will see lines like: -- [Note] InnoDB: Starting crash recovery. -- [Note] InnoDB: Doing recovery: scanned up to log sequence number ... -- [Note] InnoDB: Database was not shut down normally! -- [Note] InnoDB: Starting crash recovery... -- [Note] InnoDB: Recovered in X seconds. -- Verify redo log is properly sized for your write workload SHOW VARIABLES LIKE 'innodb_redo_log_capacity'; -- MySQL 8.0.30+ -- Recommended: set to cover at least 1 hour of peak write I/O -- Too small: frequent checkpoints reduce write throughput -- Too large: longer recovery time after crash
ACID Quick Reference
Property | Guarantee | Broken by | InnoDB mechanism |
|---|---|---|---|
Atomicity | All or nothing | Partial write without transaction | Undo log — stores before-images for rollback |
Consistency | Valid state to valid state | Missing constraints or skipped validation | FK, NOT NULL, CHECK, triggers |
Isolation | Concurrent txns do not interfere | Shared mutable state without locking | MVCC read views + gap locks |
Durability | Committed data survives crashes | innodb_flush_log_at_trx_commit != 1 | Redo log + doublewrite buffer + fsync |
Selecting the Right Isolation Level
Most applications should use the default REPEATABLE READ isolation level. Here are the cases where changing it makes sense:
REPEATABLE READ (default): correct for most transactional applications. Provides consistent snapshots within a transaction. Use this unless you have a specific reason to change it.
READ COMMITTED: use when your application does short transactions and can tolerate seeing the latest committed data on each read. Slightly better concurrency than REPEATABLE READ on high-write workloads.
READ UNCOMMITTED: almost never use this. The only valid use case is approximate reporting on a highly concurrent system where dirty reads are acceptable and performance is critical.
SERIALIZABLE: use only when absolute correctness is required and performance is secondary. All reads implicitly acquire share locks. Useful for financial double-entry systems where phantom reads would be catastrophic.
-- Set isolation level for a single transaction SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; START TRANSACTION; -- Confirm current level within a transaction SELECT @@transaction_isolation; -- Set for current session (all future transactions in this session) SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; -- Set globally (affects all new connections) SET GLOBAL TRANSACTION ISOLATION LEVEL REPEATABLE READ; -- Persist in my.cnf for all connections: -- transaction_isolation = REPEATABLE-READ