RonDB training · module 09 of 25 · 18 min
Transactions and concurrency
Locks, isolation, the commit protocol that both commits and replicates, and what happens when nodes fail or transactions deadlock.
In one slide · RonDB's concurrency model
RonDB concurrency: read committed, row locks, and a commit protocol that replicates
| Choice | RonDB | Why |
|---|---|---|
| Isolation | Read committed by default | Readers never wait; no memory for snapshots |
| Concurrency control | Pessimistic: strict two-phase row locking | Predictable latency under conflicts, simpler recovery |
| Range locks | None | Would need locks on every node of a hash-partitioned table |
| Commit | RonDB's own two-phase commit | Atomicity and replication in one protocol, non-blocking |
| Deadlocks | Timeout; proactive detection optional | Safe and simple; also catches stalls from node failures |
docs: rondb_concurrency_control.md (TLDR; RonDB Specs, RonDB's Choice) · rondb_nonblocking_2pc.md · code: ConfigInfo.cpp (EnableProactiveDeadlockDetection)
Isolation · what each level allows
Isolation: read committed by default; stronger reads by asking for locks
| Level | Prevents | In RonDB |
|---|---|---|
| Read uncommitted | nothing | Not supported: no dirty reads |
| Read committed | dirty reads | The default: always the latest committed value |
| Repeatable read | + non-repeatable reads | With shared or exclusive row locks, as the application asks |
| Serializable | + phantoms | Not for ranges: no range locks. Lock a row that stands for the range |
A long scan under read committed is not a snapshot: each row is the latest committed version when the scan reaches it.
docs: rondb_concurrency_control.md (Isolation Levels, Range Locks, Range Lock Workarounds)
Lock modes · SQL and the NDB API
Lock modes: each read chooses its lock; every write takes an exclusive one
| SQL | NDB API | Lock | Behaviour |
|---|---|---|---|
| SELECT … (default) | LM_CommittedRead | none | Reads the committed row in DataMemory, even while it is being written |
| SELECT … FOR SHARE | LM_Read | shared | Many readers or one writer; held to the end of the transaction |
| SELECT … FOR UPDATE | LM_Exclusive | exclusive | One reader or writer; no lock upgrade later |
| — | LM_SimpleRead | shared, briefly | Released as soon as the row is read |
| INSERT · UPDATE · DELETE | — | exclusive | Held until the transaction completes |
Read committed with writes in flight is WORM locking, write once read many: the writer works on its copy in TransactionMemory (module 07).
code: include/ndbapi/NdbOperation.hpp (enum LockMode) · sql/sql_yacc.yy (locking_clause) · docs: rondb_concurrency_control.md (Supporting Read Committed)
Strict 2PL · locks grow, then shrink
Strict two-phase locking: locks are taken per operation, released at commit
The MySQL server sends one operation at a time; each takes the lock it needs. All reads and writes happen in the prepare phase.
Shared locks go in the commit phase.
Exclusive locks go at commit on the primary, at complete on the backups: strict two-phase locking. Gradual locking leaves room for application deadlocks.
docs: rondb_nonblocking_2pc.md (RonDB's 2PC, Complete Stage) · code: dblqh/DblqhMain.cpp (commitReqLab: primary at COMMIT; execCOMPLETE: backups) · the timeline is an illustration
A classic deadlock · two shared locks, two upgrades
Upgrade deadlock: two readers that both decide to write wait for each other
Both transactions hold a shared lock on the same row. That is fine for reading.
Both then update it. Neither shared lock can become exclusive while the other exists: a deadlock. If you will write, read with FOR UPDATE.
docs: rondb_concurrency_control.md (Use-case Exclusive Locks)
Special cases · where read committed takes a lock
Read committed exceptions: unique key lookups and BLOB reads lock briefly
Lookup by unique key
- Takes a shared lock on the unique index row until the base row is read, so a delete and re-insert cannot fool it.
- With locks or writes, it locks index row then base row; a write by primary key locks the other way: deadlock risk.
- Use unique keys as constraints, not as lookup paths in write transactions.
Reading a BLOB
- Its parts are several rows, so a lockless read could mix versions.
- A shared lock on the base row is held while the parts are read, then released.
- BLOB reads wait for writers, and get read-after-write on any replica.
docs: rondb_concurrency_control.md (Locking Unique Indexes, Locking BLOBs)
PART 1
The commit protocol
One protocol for atomicity and replication.
Two-phase commit · also the replication protocol
RonDB replicates through its commit protocol, not through a consensus log
Elsewhere
Many databases use Raft or Paxos per partition for replication, and 2PC on top for atomicity. Quorums want 3 replicas or more.
RonDB
The commit protocol writes every replica of every row it touches. No quorum: a fixed node order and an arbitrator decide after failures.
So
Highly available with two replicas. Module 15 covers arbitration.
An in-memory database can also avoid blocking: a failed node's uncommitted work is simply gone, so the survivors decide without it.
No log and no backup coordinator: transaction state lives in memory in every data node, rebuilt by a takeover after a failure. So RonDB can run millions of concurrent transactions.
docs: rondb_nonblocking_2pc.md (Atomicity vs. Replication, Blocking Protocols, Takeover Protocol)
Design · messages and waits
Two-phase commit variants: RonDB mixes the original and the linear protocol
| Protocol | Messages | Messages to wait for | 2 rows × 2 replicas |
|---|---|---|---|
| Original 2PC: TC talks to every replica | 4 × rows × replicas | 4 | 16 · wait 4 |
| Linear: one chain through all replicas | 2 × rows × replicas | 2 × rows × replicas | 8 · wait 8 |
| RonDB: rows in parallel, a chain per row | 2 × (replicas + 1) × rows | 2 × (replicas + 1) | 12 · wait 6 |
Rows in parallel keeps latency predictable however many rows; a chain within a row saves messages and allows primary copy locking: the primary is always locked first, so replicas of one row never deadlock.
docs: rondb_nonblocking_2pc.md (Original 2PC, Linear 2PC, RonDB's 2PC; formulas as given there)
Signals · one row, two replicas, Read Backup on
A committed write, signal by signal
| Phase | Signal | From → to | What happens |
|---|---|---|---|
| prepare | TCKEYREQ | API → TC | Operation, with start and commit bits |
| prepare | LQHKEYREQ | TC → primary → backup | Each locks the row and writes its copy |
| prepare | LQHKEYCONF | backup → TC | All replicas prepared |
| commit | COMMIT | TC → backup → primary | GCI from DBDIH; backups record the commit, the primary installs the row and releases its lock |
| commit | COMMITTED | primary → TC | Without Read Backup, TC answers the API now |
| complete | COMPLETE · COMPLETED | TC → backup → primary → TC | Backups install and unlock; then TCKEYCONF to the API |
| after | TC_COMMIT_ACK | API → TC | REMOVE_MARKER_ORD clears the commit markers, which let the API learn the commit decision in case the TC fails |
docs: rondb_nonblocking_2pc.md (Two vs. Three Phase Commit, RonDB's 2PC) · code: dbtc/DbtcMain.cpp (TR_DELAY_COMMIT, TF_LATE_COMMIT "wait sending apiCommit until complete phase done") · dblqh/DblqhMain.cpp (commitReqLab: the primary commits and unlocks at COMMIT, with or without Read Backup)
Signals · key reads
Key reads: the row goes to the API straight from the ldm thread or a query worker
Read committed
- TCKEYREQ → TC → LQHKEYREQ to any replica (Read Backup).
- The ldm thread or a query worker sends the row to the API in TRANSID_AI, directly.
- No reply through TC: two hops less.
Locked read
- Always to the primary replica.
- The row goes directly too, from the ldm thread or a query worker, in TRANSID_AI; LQHKEYCONF to TC, which sends TCKEYCONF.
- TCKEYCONF carries no data; it confirms one or more operations, even mid-transaction.
docs: rondb_signal_arch.md (Basic read transaction) · code: dbtc/DbtcMain.cpp (V_QUERY for reads without disk columns; c_use_query_thread_for_locked_reads, on by default) · module 11 covers query workers
Global checkpoints · every commit gets a GCI
Each commit is stamped with a global checkpoint id, the unit of durability
At commit
DBTC takes the current GCI from DBDIH and sends it with COMMIT. It is stored in the row header (module 07).
A pause
When the GCI moves on, every TC holds new commits for a moment, so each GCI is a clean cut through all transactions.
Durability
The REDO log is flushed per GCI. After a cluster crash, RonDB restarts from the last complete GCI: transaction-consistent.
A commit is durable at once in the memory of several nodes (network durability) and on disk at the next GCI. Module 14 covers the details.
docs: rondb_nonblocking_2pc.md (Writing and Persisting of Data) · rondb_signal_arch.md
PART 2
Failures and deadlocks
Nothing blocks for long.
TC takeover · when the coordinator fails
TC takeover: a surviving TC rebuilds the lost transactions from the ldm threads
Coordinators can fail mid-transaction. A backup coordinator would not survive two failures, so RonDB rebuilds the state instead.
The first TC on the master node asks every LQH in the cluster for the operations of transactions the failed node coordinated.
Each LQH reports back in LQH_TRANSCONF. Any trace of a commit decision means commit; otherwise abort. Then the API nodes hear the outcome, and nothing stays blocked.
docs: rondb_nonblocking_2pc.md (Takeover Protocol)
Commit markers · the API always learns the outcome
Commit markers: data nodes remember a commit until the API confirms it heard
- A writing transaction leaves a commit marker in TC and in the LQHs it touched.
- If the TC node fails before the API hears the result, the takeover TC finds the markers and tells the API.
- With Read Backup, the final complete message rides on the "node failure handled" report: if no abort came, it committed.
- When the API gets TCKEYCONF it sends
TC_COMMIT_ACK; TC sendsREMOVE_MARKER_ORDand the markers go.
docs: rondb_nonblocking_2pc.md (Takeover Protocol) · rondb_signal_arch.md (Basic write transaction) · code: DbtcMain.cpp (commit ack marker pools, REMOVE_MARKERS)
Deadlocks · the default handling
Deadlock timeout: a transaction stuck for 1.2 s is aborted with error 266 (296 in a scan), retryable
| config.ini | Default | What it bounds |
|---|---|---|
| TransactionDeadlockDetectionTimeout | 1200 ms | How long a data node may take on one request of a transaction: lock waits included |
| TransactionInactiveTimeout | 3 600 000 ms | How long TC waits for the application's next statement |
- DBTC checks its transactions periodically and adds random jitter to each timeout, so of two deadlocked transactions usually only one dies.
- The abort carries error 266, or 296 for a scan: temporary errors, so the application retries the transaction.
- Timeouts also catch transactions stalled by node failures.
code: ConfigInfo.cpp (TransactionDeadlockDetectionTimeout 1200, TransactionInactiveTimeout 3600000; its comment still says "around 40 days") · dbtc/DbtcMain.cpp (timeOutLoopStartLab: ndb_rand() jitter) · Dbtc.hpp (ZTIME_OUT_ERROR 266, ZSCANTIME_OUT_ERROR 296)
New in RonDB 26.10 · RONDB-1062, off by default
Proactive detection finds two transactions waiting for each other and aborts one at once
- When DBACC queues a lock request, it knows the waiter and the transactions blocking it: wait-for edges.
- Each edge goes to one collector, chosen by a hash of the transaction id, so every node agrees and no node is favoured.
- A collector holding both edges of a pair, A waits for B and B for A, aborts one of them at once. Longer cycles still wait for the timeout.
- Same errors as a timeout, 266 for key operations and 296 for scans, so applications need no change.
EnableProactiveDeadlockDetection=true in config.ini turns it on.
code: ConfigInfo.cpp (EnableProactiveDeadlockDetection, default false) · dbtc/DbtcMain.cpp (2-cycle: victim aborted with ZTIME_OUT_ERROR or ZSCANTIME_OUT_ERROR) · storage/ndb/claude_files/deadlock/design.md (RONDB-1062, phase 1: 2-cycles)
Checklist · writing transactions for RonDB
Checklist: eight habits for transactions
- Retry on temporary errors such as 266: they are normal.
- Read with FOR UPDATE when you will write the row.
- Lock rows in the same order in every transaction.
- Do not look rows up by unique key inside write transactions.
- Keep transactions small; watch CASCADE and large BLOBs.
- Lock a row that stands for a range when you need range isolation.
- Prefer read committed; ask for locks only where needed.
- No XA, no savepoints: design around them.
docs: rondb_concurrency_control.md · basic_sql.md (Transactions)
Recap · module 09
What to take away
- Read committed readers never wait; locks are per row, strict two-phase.
- RonDB's 2PC: rows in parallel, a chain per row, primary locked first.
- Commit runs backup to primary; complete unlocks the backups; Read Backup answers after complete.
- A failed TC is replaced by a takeover that commits whatever any node heard commit.
- Deadlocks end by a 1.2 s timeout (error 266, retry), or proactively if enabled.
RonDB training · 25 modules · you are at 09