This is the finale in a three-part series on how Django transactions work. I cover everything from the app and framework code all the way to the database layer:
In the last part of this series, we explored which DB operations and code to avoid putting in transactions to prevent slow responses and outages, plus what you can put in the transaction to maintain its atomic behavior.
Reversible operations on the DB Reversible and related business logic
Slow queries
Operations on multiple tables
Data migrations
Changes to schema (table/column structure)
By managing the code within transactions, we control which locks are held in transactions and for how long. This lets us minimize the amount of time transactions take, lower the DB’s latency (locks aren’t held for too long), and ensure other critical DB operations are not blocked. But how do DB operations block each other? Which ones are conflicting?
Locks exist because DBs can have multiple users executing commands simultaneously, e.g., one user performs a DROP TABLE command while another user reads from that table. This would cause an error or, worse, data corruption. To prevent this, every DB operation acquires and holds a lock specific to the permissions that the operation needs, e.g., DROP TABLE acquires an ACCESS EXCLUSIVE lock on the table being dropped because it requires exclusive access to drop that table safely. In contrast, SELECT acquires ACCESS SHARE lock, which allows it to read the table data but does not block other operations from reading or writing to that table. If neither operation can attain those locks on the table or another operation is holding a conflicting lock, the operation waits for the locks to be released.
There are two categories of PSQL locks — table and row.
Row level locks affect a single row/record, these locks prevent operations on that particular record and are low risk but if we put a table migration in a transaction we can inadvertently hold all the locks on every row of that table thereby locking the entire table. With these locks, we want to minimize how many of them we hold. The more row locks we hold, the more records on the table are inaccessible.
Here are the PSQL commands (operations) broken down by the severity of what they block.
Everything (reads/writes)
Writes (inserting, updating, and deleting data)
Continue reading for more details.
+++,++,** Check DB Operations That Block Table Access for the subcommand
Locks exist to handle the problems that arise from concurrent connections. When we allow our databases to be operated (read/written) by multiple connections simultaneously, we run into conflicts. What if one connection tries to delete a row while another is querying? Or worse, what if one connection adds a new column to a table while another inserts a new row using the old schema? Locks prevent the errors and data corruption that can occur with simultaneous operations on the DB. The locks are held by the transactions initiated by the connection — every PSQL command is wrapped automatically in a transaction.
Each lock blocks operations that might cause an error or data corruption if executed simultaneously with the current operation. Some locks are very permissive and let almost all other operations occur simultaneously, e.g., the locks held by SELECT queries allow most other commands to be executed. These promiscuous locks can be held by multiple transactions at a time because these locks don’t conflict with each other (they don’t block each other’s operations). While other locks are exclusive and block multiple or all other operations, e.g., the locks held by certain ALTER TABLE/COLUMN sub-commands. These locks can only be held by one transaction at a time because they block all other operations and hence block themselves — we can’t execute two DROP TABLE commands on the same table simultaneously.
The pg_locks table tracks all of this, along with which transaction is holding the locks. We can use this table to display outages and timeouts caused by lockouts and deadlocks.
Every operation on the DB asks for and acquires a table lock before executing it. Most of these locks are loose and allow other operations — doing a SELECT query (filter
in Django) will allow all other operations except ALTER INDEX/TABLE (Django migrations that change a column or table).
This table makes it obvious that migrations (changes to the schema) are the riskiest types of changes — CREATE INDEX and ALTER INDEX/TABLE are run in migrations.
Run:
./manage.py sqlmigrate APP_NAME MIGRATION_NUM
To find out which operations a migration executes and if they are risky. If they are, make sure the migration is quick and run it after hours (when no other long-running transactions are running).
DROP TABLE is not included in this because it is obvious that it blocks all other commands (it acquires the same access exclusive lock as ALTER INDEX/TABLE). Paradoxically DROP TABLE wont cause your app to crash due to a lockout because it does not usually take a long time to execute — unless there are a many constraints on the table and references to the table.
Besides table locks, there are row or data locks that block write access to a specific row in a table. Unlike table locks, row locks don’t block reading data, so they are less risky, but they can cause the same damage a table lock does in specific scenarios.
Suppose we are migrating data in a table — going through all the rows in it and updating a deprecated constant or denormalizing a JSON object, for example. We will lock the entire table if this migration happens in a transaction, i.e., if we wrap the function that is doing that migration in an atomic() decorator or context. This is due to the operations on each record locking each row on the table and the transaction holding onto the row locks until the entire migration is finished. This will block writes to the table until every row is migrated.
A better solution is to NOT wrap the entire migration in a transaction block. If we need to alter multiple columns for each row, then wrap only the row operations in the migration. This way, after every row is changed, the lock is released.
./manage.py sqlmigrate APP_NAME MIGRATION_NUMBER/NAME
This will print the operations that will be run during the migration. A comment in the query will explain what each operation does.
For a no-op migration — a migration that does not change the DB, like updates to TextChoice and similar columns — the result will look like this:
BEGIN;
-
- Alter field transaction_status on transactions
-
COMMIT;
An empty transaction with a comment inside of it describing what the transaction represents.
This migration does nothing—hence the SQL comments in the transaction block. The django_migrations table will be updated to track which migrations have been run, but that shouldn’t impact the app.
For migrations that actually change the DB, the output will look like this:
BEGIN;
-
- Add field reverted to transactions
-
ALTER TABLE "transactions" ADD COLUMN "reverted" timestamp with time zone NULL;
COMMIT;
This alters a table by adding a column that blocks all reads and writes.