Transactions and ACID
A transaction is all or nothing: atomicity, consistency, isolation and durability, commit and rollback, and redundancy.
Do this lesson in the simulatorA database change is often several statements that only make sense together. When BugBot carries two balls from the red zone to the blue zone, the database must add 2 to blue and take 2 from red. If the power fails between the two, two balls have appeared from nowhere. This lesson is about transactions, the way a DBMS makes sure that never happens, and the four properties, ACID, that a reliable transaction must have.
What a transaction is
A transaction is a single logical unit of work made of one or more operations on the database, which must either all happen or all not happen. In SQL:
BEGIN TRANSACTION
UPDATE zones SET balls = balls + 2 WHERE name = 'blue'
UPDATE zones SET balls = balls - 2 WHERE name = 'red'
COMMIT
COMMITmakes the changes permanent and visible to other users.ROLLBACKundoes every change made since the transaction began, putting the database back exactly as it was.
Transaction processing is the handling of every change as a transaction: a DBMS that does it keeps a log of the changes each transaction makes, so it can undo an unfinished transaction or redo a committed one after a crash.
Half a change
First, what goes wrong without a transaction. This database commits every statement as soon as it runs (isolation_level=None), and the program fails between the two updates:
import sqlite3
db = sqlite3.connect(":memory:", isolation_level=None) # every statement commits at once
db.execute("CREATE TABLE zones (name TEXT PRIMARY KEY, balls INTEGER NOT NULL)")
db.executemany("INSERT INTO zones VALUES (?, ?)", [("red", 3), ("blue", 2)])
try:
db.execute("UPDATE zones SET balls = balls + 2 WHERE name = 'blue'")
raise RuntimeError("power cut") # the failure, between the two updates
db.execute("UPDATE zones SET balls = balls - 2 WHERE name = 'red'")
except RuntimeError as e:
print("failed:", e)
print(db.execute("SELECT * FROM zones").fetchall())
print("total:", db.execute("SELECT SUM(balls) FROM zones").fetchone()[0])
There were 5 balls; now the database says 7. Now the same with a transaction. In Python's sqlite3, with db: wraps the statements in a transaction: it commits if the block finishes and rolls back if an exception escapes it.
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE zones (name TEXT PRIMARY KEY, balls INTEGER NOT NULL)")
db.executemany("INSERT INTO zones VALUES (?, ?)", [("red", 3), ("blue", 2)])
db.commit()
try:
with db:
db.execute("UPDATE zones SET balls = balls + 2 WHERE name = 'blue'")
raise RuntimeError("power cut")
db.execute("UPDATE zones SET balls = balls - 2 WHERE name = 'red'")
except RuntimeError as e:
print("failed:", e)
print(db.execute("SELECT * FROM zones").fetchall())
print("total:", db.execute("SELECT SUM(balls) FROM zones").fetchone()[0])
The half-done update was rolled back, and the total is still 5.
ACID
A DBMS guarantees four properties for every transaction:
| Property | Meaning | In the ball example |
|---|---|---|
| Atomicity | a transaction is all or nothing: either every change in it is made, or none is | both updates happen, or neither does |
| Consistency | a transaction takes the database from one valid state to another; every rule (keys, referential integrity, constraints) holds before and after | a zone never has fewer than 0 balls, and the total is unchanged |
| Isolation | transactions running at the same time do not see each other's unfinished changes; the result is the same as if they ran one after another | a teacher reading the scores mid-move sees the old totals or the new ones, never 7 balls |
| Durability | once committed, a change survives any later failure, such as a power cut or crash | after COMMIT, the move is saved in non-volatile storage and the log |
Atomicity is provided by rollback, using the log. Durability comes from writing the change and the log to non-volatile storage before reporting the commit as done. Isolation is provided by the concurrency controls of the next lesson, such as record locking.
Consistency: rules the database enforces
A CHECK constraint is a rule the DBMS tests on every change. A statement that would break it fails, and with a transaction around it, the whole transaction can be rolled back:
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE zones (name TEXT PRIMARY KEY, balls INTEGER NOT NULL CHECK (balls >= 0))")
db.executemany("INSERT INTO zones VALUES (?, ?)", [("red", 3), ("blue", 2)])
db.commit()
try:
db.execute("UPDATE zones SET balls = balls - 4 WHERE name = 'blue'")
except sqlite3.IntegrityError as e:
print("refused:", e)
print(db.execute("SELECT * FROM zones").fetchall())
A transaction around a physical action
A transaction can span the robot's own work. The database records the carry only if the robot really arrived; if it did not, the change is rolled back.
# the two lines every program starts with: the commands, then the robot
from bugbot import *
connect()
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE zones (name TEXT PRIMARY KEY, balls INTEGER NOT NULL CHECK (balls >= 0))")
db.executemany("INSERT INTO zones VALUES (?, ?)", [("red", 3), ("blue", 2)])
db.commit()
try:
with db:
db.execute("UPDATE zones SET balls = balls - 1 WHERE name = 'red'")
forward(50, distance=30) # carry the ball to the blue zone
x, y = position()
if y < 28:
raise RuntimeError("did not reach blue")
db.execute("UPDATE zones SET balls = balls + 1 WHERE name = 'blue'")
print("committed")
except RuntimeError as e:
print("rolled back:", e)
print(db.execute("SELECT * FROM zones ORDER BY name").fetchall())
Had the robot been blocked short of the blue zone, the exception would have rolled back the first update, and the red zone would still have 3 balls.
Record locking and redundancy
Two more ideas complete transaction processing.
Record locking stops two transactions changing the same record at once. While one transaction is updating a record, the record is locked and any other transaction that wants it must wait until the first commits or rolls back. The next lesson looks at why this is needed and at the problem it can cause.
Redundancy here means keeping extra copies on purpose: mirrored disks, replicated database servers, and backups, so that a failed disk or server loses neither data nor service. A replica can take over if the main server fails, which protects durability. Do not confuse it with data redundancy inside a database (lesson A11.2), the unwanted repetition of the same fact that normalisation removes. In an answer, say which one you mean.
Task: all or nothing
The table zones holds each zone's name (text, the primary key) and its number of balls (a whole number), with the rule CHECK (balls >= 0). It starts as red 3, blue 2, green 0. moves is a list of (src, dst, n): two zone names and a whole number of balls.
- Write
move(src, dst, n)that, as one transaction, first addsntodstwithUPDATE zones SET balls = balls + ?and then takesnfromsrc. If the database refuses the change withsqlite3.IntegrityError, the whole move must be undone: neither zone changes. Printmoved <n> <src> -> <dst>if it worked, orrefused <n> <src> -> <dst>if not. Do not test the numbers with anifof your own: let theCHECKrule refuse. - Call
movefor each item ofmoves, in order. - Print every zone in name order as
<name> <balls>, thentotal <sum>usingSUM.
Eight lines in all. The robot does not move.
# the two lines every program starts with: the commands, then the robot
from bugbot import *
connect()
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE zones (name TEXT PRIMARY KEY, balls INTEGER NOT NULL CHECK (balls >= 0))")
db.executemany("INSERT INTO zones VALUES (?, ?)", [("red", 3), ("blue", 2), ("green", 0)])
db.commit()
moves = [("red", "blue", 2), ("blue", "green", 5), ("blue", "green", 3), ("red", "green", 2)]
Challenges
- Take the
with db:out of yourmove. Which line of output changes, and which ACID property has been lost? - A bank moves £50 from one account to another. Describe what each ACID property guarantees.
- Why must a DBMS write to its log before it tells the user a transaction is committed?