Previously:
zil.kucinskas@gmail.com
class RemoveSomeColumnFromUsers < ActiveRecord::Migration[6.0]
def change
remove_column :users, :some_column
end
end
Developer writes a file:
rails db:migrate
Run (for example at deployment time):
ALTER TABLE "users" DROP COLUMN "some_column";
Rails will generate and execute the following DDL:
Schema changes are challenging. It's like changing a car tire while it barrels down the motorway.
Case #1 - column is added in the middle of a SELECT statement's execution
Expectation - we don’t want some rows in the result set to have the new column and others not.
Case #2 - column is dropped between the issuance of an UPDATE that writes to it and the actual execution of that UPDATE
Expectation - we certainly don’t want to write that obsolete value to some undefined location in memory or on disk!
Generally in Postgres, we have 3 main mechanisms of locking:
* Table-level locks
* Row-level locks
* Advisory locks
For most DDL changes, an ACCESS EXCLUSIVE lock will be required, a very strict lock that prevents all reads and all writes for the duration of the query.
Queries blocked for a long time by slow transaction and migration
When I realize that my simple migration caused a major production outage
class RemoveSomeColumnFromUsers < ActiveRecord::Migration[6.0]
def change
remove_column :users, :some_column
end
end
Problems in production
PGError: ERROR: column "some_column" does not exist
class User < ApplicationRecord
self.ignored_columns = ["some_column"]
end
class RemoveSomeColumnFromUsers < ActiveRecord::Migration[6.0]
def change
remove_column :users, :some_column
end
end
1. Ignore from the cache
2. Deploy the code
3. Write a migration to remove the column
4. Deploy and run migration
Note: Don't forget to clean up the code
class AddSomeColumnToUsers < ActiveRecord::Migration[6.0]
def change
add_column :users, :some_column, :text, default: "default_value"
end
end
Problems
* Adding a column with a default value to an existing table causes the entire table to be rewritten.
Note: This operation is safe in Postgres 11+
class AddSomeColumnToUsers < ActiveRecord::Migration[6.0]
def up
add_column :users, :some_column, :text
change_column_default :users, :some_column, "default_value"
end
def down
remove_column :users, :some_column
end
end
class AddSomeColumnToUsers < ActiveRecord::Migration[6.0]
def change
add_column :users, :some_column, :text
User.update_all some_column: "default_value"
end
end
Problems:
* Backfilling in the same transaction that alters a table locks the table for the duration of the backfill.
* Running a single query to update data can cause issues for large tables
class BackfillSomeColumn < ActiveRecord::Migration[6.0]
disable_ddl_transaction!
def up
User.unscoped.in_batches do |relation|
relation.update_all some_column: "default_value"
sleep(0.01) # throttle
end
end
end
Three key elements of backfilling data safely:
* Batching
* Throttling
* Running outside of a transaction (Rails console, background jobs or disable_ddl_transaction!)
class RenameOrChangeSomeColumn < ActiveRecord::Migration[6.0]
def change
# Example A. Renaming column
rename_column :users, :some_column, :new_name
# Example B. Changing column type
change_column :users, :some_column, :new_type
end
end
Caveats - safe changes:
class RenameUsersToCustomers < ActiveRecord::Migration[6.0]
def change
rename_table :users, :customers
end
end
class CreateUsers < ActiveRecord::Migration[6.0]
def change
create_table :users, force: true do |t|
# ...
end
end
end
Problems in production:
* The force option can drop an existing table.
class CreateUsers < ActiveRecord::Migration[6.0]
def change
create_table :users do |t|
# ...
end
end
end
class ChangeSomeColumnNull < ActiveRecord::Migration[6.0]
def change
change_column_null :users, :some_column, false, "default_value"
end
end
Problems in production:
* This generates a single UPDATE statement to set the default value.
class ChangeSomeColumnNull < ActiveRecord::Migration[6.0]
def change
change_column_null :users, :some_column, false
end
end
1. Backfill the column safely.
2. Execute migration:
Note: In Postgres, change_column_null is still not safe with this method.
class SetSomeColumnNotNull < ActiveRecord::Migration[6.0]
def change
change_column_null :users, :some_column, false
end
end
Problems in production:
In Postgres, setting NOT NULL on an existing column requires an AccessExclusiveLock, which is expensive on large tables.
class SetSomeColumnNotNull < ActiveRecord::Migration[6.0]
def change
execute 'ALTER TABLE "users" ADD CONSTRAINT "users_some_column_null"'\
'CHECK ("some_column" IS NOT NULL) NOT VALID'
end
end
class ValidateSomeColumnNotNull < ActiveRecord::Migration[6.0]
def change
execute 'ALTER TABLE "users" VALIDATE CONSTRAINT "users_some_column_null"'
end
end
Note: This is not 100% the same as NOT NULL column constraint. Here’s a good explanation.
1. Add a constraint
2. Validate it in a separate transaction
class AddSomeIndexToUsers < ActiveRecord::Migration[6.0]
def change
add_index :users, :some_column
end
end
Problems in production
* In Postgres, adding an index non-concurrently locks the table.
class AddSomeIndexToUsers < ActiveRecord::Migration[6.0]
disable_ddl_transaction!
def change
add_index :users, :some_column, algorithm: :concurrently
end
end
Add indexes concurrently:
* If you forget disable_ddl_transaction!, the migration will fail.
* Also, note that indexes on new tables (those created in the same migration) don’t require this.
class RemoveSomeIndexFromUsers < ActiveRecord::Migration[6.0]
def change
remove_index :users, :some_column
end
end
Problems in production
* In Postgres, removing an index non-concurrently locks the table for a brief period.
class AddSomeIndexToUsers < ActiveRecord::Migration[6.0]
disable_ddl_transaction!
def change
remove_index :users, :some_column, algorithm: :concurrently
end
end
Remove indexes concurrently:
class AddReferenceToUsers < ActiveRecord::Migration[6.0]
def change
add_reference :users, :city
end
end
Problems in production
* Rails adds an index non-concurrently to references by default, which is problematic for Postgres.
class AddReferenceToUsers < ActiveRecord::Migration[6.0]
disable_ddl_transaction!
def change
add_reference :users, :city, index: { algorithm: :concurrently }
end
end
Make sure the index is added concurrently.
class AddForeignKeyOnUsers < ActiveRecord::Migration[6.0]
def change
# Case A.
add_foreign_key :users, :orders
# Case B.
add_reference :users, :order, foreign_key: true
end
end
Problems in production
* In Postgres, new foreign keys are validated by default, which acquires a ShareRowExclusiveLock that can be expensive on large tables.
class AddForeignKeyOnUsers < ActiveRecord::Migration[6.0]
def change
add_foreign_key :users, :orders, validate: false
end
end
Instead, validate it in a separate migration with a more agreeable RowShareLock. This approach is documented by Postgres to have “the least impact on other work.”
class ValidateForeignKeyOnUsers < ActiveRecord::Migration[6.0]
def change
validate_foreign_key :users, :orders
end
end
Then validate it in a separate migration.
class AddPropertiesToUsers < ActiveRecord::Migration[6.0]
def change
add_column :users, :properties, :json
end
end
Problems in production
* In Postgres, there’s no equality operator for the json column type, which can cause errors for existing SELECT DISTINCT queries.
class AddPropertiesToUsers < ActiveRecord::Migration[6.0]
def change
add_column :users, :properties, :jsonb
end
end
Use jsonb instead:
ALTER ROLE myuser SET statement_timeout = '1h';
ALTER ROLE myuser SET lock_timeout = '10s';
A good practice is to set:
* long statement timeout
* short lock timeout
Impact:
* migrations can run for a while, and if a migration can’t acquire a lock in a timely manner, other statements won’t be stuck behind it.
Operation | Safety | Exceptions / Solutions |
---|---|---|
Creating tables | ✅ Safe | |
Removing tables | ✅ Safe | |
Adding columns | ⚠️ Warning |
✅ No constraints on the column ⚠️ Prefer jsonb over json |
Removing columns | ⚠️ Warning | ✅ When telling AR to ignore column first |
Renaming columns or renaming tables | 🚫 Unsafe | ✅ Add new column/table, write to both, remove the old one |
Operation | Safety | Exceptions / Solutions |
---|---|---|
Creating indexes | ⚠️ Warning | ✅ Create concurrently |
Removing indexes | ⚠️ Warning | ✅ Create concurrently |
Renaming index | 🚫 Unsafe | ⚠️ Postgres 12 lowers barrier |
Adding a reference | ⚠️ Warning | ✅ Create concurrently |
Removing reference | ✅ Safe | |
Adding a foreign key | ⚠️ Warning | ✅ Add foreign key without validation and validate in a separate migration |
Removing a foreign key | ✅ Safe |
* strong_migrations (ankane from Instacart) - Catch unsafe migrations in development
* nandi (GoCardless) - Fear free PostgreSQL migrations for Rails
* zero_downtime_migrations (LendingHome) - Zero downtime migrations with ActiveRecord 3+ and PostgreSQL
* pg_ha_migrations (Braintree) - Enforces DDL/migration safety in Ruby on Rails project with an emphasis on explicitly choosing trade-offs and avoiding unnecessary magic.
* safe-pg-migrations (Doctolib) - ActiveRecord migrations for Postgres made safe.
1. Gitlab - What requires no downtime?
2. Gitlab::Database::MigrationHelper code
3. Gitlab Migrations Style Guide
4. PostgreSQL at Scale: Database Schema Changes Without Downtime
5. Rollout - Rails Migrations with Zero Downtime
6. When Postgres blocks: 7 tips for dealing with locks
7. Safe Operations For High Volume PostgreSQL
Scan me to get presentation link