MySQLACID Properties

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.

SQL
-- 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:

  1. Transaction begins.
  2. For each row to be changed, the original values are written to the undo log.
  3. The actual row in the data file is modified.
  4. On ROLLBACK (or crash): InnoDB reads the undo log entries and re-applies the before-images, restoring every row to its pre-transaction state.
  5. On COMMIT: the undo log entries become eligible for purge (cleaned up by the purge thread).

SQL
-- 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';
Note
Long-running transactions accumulate undo log entries that cannot be purged until the transaction ends. This causes undo log growth, performance degradation, and bloat. Always keep transactions short.
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.

SQL
-- 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 violated
Tip
Define all business rules as database constraints (FK, NOT NULL, UNIQUE, CHECK). Do not rely solely on application code — a different application, a DBA script, or a bug can bypass application-level checks but cannot bypass database constraints.
Isolation — 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:

  1. InnoDB checks the row's DB_TRX_ID.
  2. If that transaction ID is newer than your read view, the row was modified after your snapshot started.
  3. 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.
  4. This continues back through the undo log chain until a version visible to your read view is found.

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

SQL
-- 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

SQL
-- 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;
Warning
Setting 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:

  1. Multiple transactions prepare their redo log records.
  2. The "leader" transaction of a batch calls fsync.
  3. All transactions in the batch are committed together.

This dramatically reduces I/O at high concurrency. You can tune it:

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

SQL
-- 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

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)

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

SQL
-- 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:

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

SQL
-- 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