Rails Strong Migrations: Vang onveilige databasewijzigingen voordat ze productie lockken
Rails Strong Migrations: vang onveilige Postgres-wijzigingen — NOT NULL toevoegen, hernoemen, indexen zonder CONCURRENTLY — voordat ze productie blokkeren.
De migratie zag er onschuldig uit. Twaalf regels, één nieuwe kolom, een default-waarde false, een NOT NULL-constraint. De auteur draaide hem lokaal op een dev-database met honderd rijen in minder dan een seconde en mergde. De CI-pipeline draaide hem tegen een verse schema in minder dan een seconde. De staging-deploy draaide hem tegen een staging-database met een miljoen rijen in twee seconden. Iedereen ging naar huis.
Productie had tachtig miljoen rijen. De migratie pakte een ACCESS EXCLUSIVE-lock op de orders-tabel en hield die negen minuten vast terwijl elke rij werd herschreven. Elke checkout in de EU liep in een timeout. Customer support kreeg honderdveertig tickets in elf minuten. De CTO kreeg een telefoontje. De migratie liep af, de locks gingen los, de site herstelde, en de post-mortem eindigde met de zin “we hebben betere review op migraties nodig.”
Na negentien jaar Rails kan ik je precies vertellen wat “betere review op migraties” eruitziet. Het is een gem genaamd Strong Migrations, drie regels in je Gemfile, en een CI-check die de build laat falen voordat de gevaarlijke migratie ooit een reviewer bereikt. Deze post gaat over waarom die tooling ertoe doet, wat hij vangt, wat hij niet vangt, en hoe je hem netjes in een Rails-app integreert.
Wat Rails Strong Migrations daadwerkelijk doet
Strong Migrations is een Rails-gem die de inhoud van je migratiebestanden op migratietijd inspecteert en weigert patronen te draaien waarvan bekend is dat ze downtime veroorzaken op een drukke productiedatabase. Het is geen linter en geen runtime-monitor. Het is een vangrail die een klasse van “werkt lokaal maar sloopt productie”-bugs omzet in een harde, luide, actiegerichte fout op de machine van de ontwikkelaar en in CI.
De onveilige patronen die hij vangt zijn niet theoretisch. Het zijn de patronen die elke week echte Rails-apps neerhalen. Een NOT NULL-kolom toevoegen met een default op Postgres 10. Een kolom verwijderen die nog live readers heeft. Een kolom hernoemen die de applicatie nog selecteert. Een index aanmaken zonder CONCURRENTLY op een tabel die groot genoeg is dat de bijbehorende ACCESS EXCLUSIVE-lock elke write blokkeert voor de duur van de build. Elk van deze is een migratie van één regel. Elk van deze kan een site minuten tot uren neerhalen, afhankelijk van tabelgrootte en verkeerspatroon.
Strong Migrations repareert de migratie niet. Hij weigert hem te draaien en print een bericht dat een veilig alternatief bevat, vaak met de exacte code die je in plaats daarvan moet gebruiken. Het foutbericht is onderdeel van de waarde. Een junior engineer die het probleem nog nooit heeft gezien leest het bericht, leert het patroon, en schrijft de veiligere versie meteen de eerste keer.
Rails Strong Migrations installeren in een Rails 8-app
Voeg de gem en een kleine initializer toe.
# Gemfile
gem "strong_migrations"
bundle install
bin/rails generate strong_migrations:install
De generator maakt config/initializers/strong_migrations.rb en update db/migrate zodat het de check kent. Je ziet zoiets in de initializer:
StrongMigrations.start_after = 20260619000000
StrongMigrations.target_version = 17 # Postgres major version
StrongMigrations.lock_timeout = 10.seconds
StrongMigrations.statement_timeout = 1.hour
StrongMigrations.auto_analyze = true
start_after is cruciaal en bijna altijd standaard verkeerd. Het vertelt Strong Migrations om migraties te negeren waarvan de timestamp ouder is dan die waarde. Zet het op de timestamp van de meest recente migratie voordat je de gem invoerde, zodat historische migraties niet met terugwerkende kracht falen. Nieuwe migraties worden gecontroleerd; oude worden ingerekend.
target_version moet overeenkomen met de major-versie van Postgres die je daadwerkelijk in productie draait. Strong Migrations past zijn regels op basis hiervan aan. Een NOT NULL-kolom toevoegen met een default is onveilig op Postgres 10 en veilig op Postgres 11+. Als je de gem de verkeerde versie geeft, krijg je false positives of false negatives.
lock_timeout en statement_timeout zijn de twee belangrijkste instellingen in het bestand en de instellingen die teams overslaan. Ze vertellen Postgres om een migratie op te geven die langer dan lock_timeout op een lock heeft gewacht, of langer dan statement_timeout heeft gedraaid. Zonder deze blijft een migratie die vastloopt achter een lange transactie daar voor eeuwig zitten, met een wachtrij van geblokkeerde queries erachter. Met deze instellingen faalt de migratie snel, krijgt het team een duidelijke fout, en blijft productie overeind.
De onveilige patronen die je echt gaat tegenkomen
Strong Migrations vangt een lange lijst patronen. In de praktijk zijn vijf ervan goed voor bijna elk productie-incident dat ik heb gezien. Die uit het hoofd kennen is nuttiger dan de hele README van de gem lezen.
Een NOT NULL-kolom toevoegen met een default op oudere Postgres
add_column :users, :receives_marketing, :boolean, default: false, null: false
Op Postgres 10 en eerder herschrijft dit elke rij in de tabel om de default in te vullen. Op een tabel met honderd miljoen rijen zijn dat uren, en de tabel is de hele tijd gelocked. Op Postgres 11+ wordt de default opgeslagen als een constante in catalog-metadata en virtueel toegepast bij het lezen, dus de operatie is instant. Strong Migrations kent je Postgres-versie en klaagt alleen als het ertoe doet.
Het veilige patroon op oudere Postgres is drie migraties:
# 1. Voeg de kolom nullable toe, zonder default.
add_column :users, :receives_marketing, :boolean
# 2. Backfill in batches in een aparte migratie of rake-task.
User.in_batches.update_all(receives_marketing: false)
# 3. Voeg de constraint en default toe, deploy eerst de applicatiecode.
change_column_null :users, :receives_marketing, false
change_column_default :users, :receives_marketing, false
Een kolom verwijderen die de app nog leest
remove_column :orders, :legacy_status
ActiveRecord cachet de kolomlijst bij de eerste query. Een draaiend webproces dat is gestart vóór de migratie gelooft nog steeds dat legacy_status bestaat, neemt hem op in zijn SELECT, en crasht bij elke request zodra de kolom weg is. Strong Migrations dwingt je dit te erkennen door eerst een ignored_columns-declaratie in het model toe te voegen, dat te deployen, en pas daarna de verwijdering in een vervolg-deploy te draaien.
# Deploy 1
class Order < ApplicationRecord
self.ignored_columns = [:legacy_status]
end
# Deploy 2 (nadat Deploy 1 volledig is uitgerold)
class RemoveLegacyStatusFromOrders < ActiveRecord::Migration[8.0]
def change
safety_assured { remove_column :orders, :legacy_status, :string }
end
end
safety_assured is de expliciete override. Gebruik hem als je het risico begrijpt en het voorwerk hebt gedaan. Gebruik hem nooit omdat de gem “vervelend” is.
Een kolom hernoemen
rename_column :products, :stock, :inventory_count
Dit is hetzelfde probleem als remove_column, plus een write-kant. Oude code leest uit stock, nieuwe code schrijft naar inventory_count, geen van beide ziet elkaars data. Het veilige patroon is: voeg de nieuwe kolom toe, sync writes via een callback of trigger, backfill, switch reads, switch writes, en laat dan de oude kolom vallen. Vijf deploys. Strong Migrations laat je geen stappen overslaan.
Voor de meeste apps is het juiste antwoord op “ik wil deze kolom hernoemen” gewoon “doe het niet.” Voeg een method alias toe in het model, laat de kolom met rust, en kijk er over drie jaar nog eens naar als de tabel de helft van zijn huidige grootte is en de hernoeming zich daadwerkelijk uitbetaalt.
Een index aanmaken zonder CONCURRENTLY
add_index :events, :user_id
Een standaard CREATE INDEX pakt een ACCESS EXCLUSIVE-achtige lock die writes blokkeert voor de duur van de build. Op een kleine tabel is dit onzichtbaar. Op een tabel met vijftig miljoen rijen op een drukke productiedatabase is het een outage van vijf minuten op elke write naar die tabel.
De fix is één keyword:
class AddIndexOnEventsUserId < ActiveRecord::Migration[8.0]
disable_ddl_transaction!
def change
add_index :events, :user_id, algorithm: :concurrently
end
end
disable_ddl_transaction! is verplicht omdat CREATE INDEX CONCURRENTLY niet binnen een transactie kan draaien. Strong Migrations weigert de migratie tenzij je zowel het algoritme als de transactie-disable opneemt, wat precies de discipline is die je wil.
Combineer dit met pg_stat_statements zodra de index er staat — meestal zie je dan dat de query die de index motiveerde een fractie van zijn vorige kosten heeft, en af en toe ontdek je dat de index niets hielp en je een andere nodig hebt.
Data backfillen binnen een schema-migratie
def change
add_column :invoices, :tax_cents, :integer
Invoice.find_each { |i| i.update!(tax_cents: i.subtotal_cents * 0.21) }
end
Dit combineert twee anti-patterns in één migratie. De schema-wijziging houdt de hele tijd een lock vast terwijl de backfill loopt, en de backfill is traag omdat hij elke rij door ActiveRecord instantieert. Op een tabel met een miljoen rijen lockt de migratie de tabel vijftien minuten terwijl de backfill voortploetert. Strong Migrations weigert het.
Het veilige patroon is: splits de schema-wijziging van de backfill, doe de backfill in een aparte Rake-task of background job die buiten de migratierunner loopt, en gebruik update_all in batches in plaats van objecten te laden:
# Migratie: alleen schema, instant.
class AddTaxCentsToInvoices < ActiveRecord::Migration[8.0]
def change
add_column :invoices, :tax_cents, :integer
end
end
# Backfill: aparte rake task, draai na de deploy.
namespace :backfill do
task tax_cents: :environment do
Invoice.in_batches(of: 1_000) do |batch|
batch.update_all("tax_cents = (subtotal_cents * 21 / 100)")
end
end
end
Dit is voor de meeste teams hét “Strong Migrations heeft veranderd hoe ik code schrijf”-moment. Schema en data één keer scheiden en je verwart ze daarna nooit meer.
Strong Migrations integreren in CI
Strong Migrations draait op migratietijd, wat betekent dat hij het probleem vangt wanneer de ontwikkelaar bin/rails db:migrate draait. Dat is goed, maar niet genoeg. Je wil ook dat CI luid faalt, zodat een migratie die aan lokale review is ontsnapt nog steeds productie niet bereikt.
Draai de migraties als onderdeel van de testsetup. De meeste Rails-apps doen dit al impliciet via bin/rails db:test:prepare. Zorg dat de testdatabase op dezelfde major Postgres-versie als productie zit — anders kan Strong Migrations de versie-specifieke regels niet correct afdwingen.
Voor pull request reviews: voeg een job toe die alleen de nieuwe migraties draait tegen een kopie van het productie-schema:
# .github/workflows/migrations.yml
name: Migration safety
on: [pull_request]
jobs:
check:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
env:
POSTGRES_PASSWORD: postgres
ports: ['5432:5432']
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- run: bin/rails db:create db:schema:load
env:
RAILS_ENV: test
- run: bin/rails db:migrate
env:
RAILS_ENV: test
De schema-load spiegelt het huidige productie-schema (of komt er dichtbij), de migrate draait de nieuwe migraties bovenop, en Strong Migrations weigert elk onveilig patroon. De build faalt, de PR kan niet mergen, het probleem wordt gevangen voordat de review überhaupt is begonnen.
Statement timeout en lock timeout in de praktijk
De twee timeouts in de initializer verdienen hun eigen sectie omdat ze de manier zijn waarop Strong Migrations integreert met de rest van Postgres’ veiligheidsverhaal.
lock_timeout is de maximale tijd dat Postgres op een lock zal wachten. Standaard wacht Postgres voor eeuwig. Met lock_timeout = 10.seconds faalt een migratie die binnen tien seconden geen ACCESS EXCLUSIVE-lock kan krijgen met PG::LockNotAvailable en rolt terug. Het team probeert het opnieuw tijdens een rustiger venster. Cruciaal: dit voorkomt het wachtrij-achter-een-geblokkeerde-DDL-patroon, waarbij een vastgelopen migratie ook elke volgende write naar de tabel blokkeert — zo verandert een trage migratie in een volledige site-outage.
statement_timeout is de maximale tijd dat één statement mag draaien. Een CREATE INDEX CONCURRENTLY op een enorme tabel kan legitiem uren duren, dus zet dit hoger dan je slechtste verwachte migratie. Een uur is een redelijke default; verlaag het tijdelijk voor migraties waarvan je zeker weet dat ze snel zijn, als je extra veiligheid wil.
Deze combineren goed met advisory locks voor het coördineren van lange data-backfills over meerdere runners, en met logical replication voor de echt grote schema-wijzigingen waar zelfs een veilige migratie te risicovol is op de primary.
Wanneer Strong Migrations ongelijk heeft
Strong Migrations is bewust conservatief. Hij weigert af en toe een migratie waarvan je weet dat hij veilig is — bijvoorbeeld een remove_column op een tabel waarvan het model volledig is verwijderd, of een add_index zonder CONCURRENTLY op een tabel die je in dezelfde deploy hebt aangemaakt. Het safety_assured-blok is de ontsnapping:
class DropAbandonedExperimentsTable < ActiveRecord::Migration[8.0]
def change
safety_assured { drop_table :abandoned_experiments }
end
end
Gebruik het bewust. De verkeerde reactie op “Strong Migrations weigert mijn migratie” is alles in safety_assured wikkelen. De juiste reactie is het foutbericht lezen, begrijpen welk patroon het triggerde, beslissen of het productierisico echt is voor jouw tabelgrootte en verkeerspatroon, en de migratie ofwel herschrijven ofwel annoteren met de expliciete erkenning dat je het risico accepteert.
Combineer dit met een code review-regel: elke PR die safety_assured introduceert vereist een commentaarregel die uitlegt waarom. Dat is genoeg frictie om teams eerlijk te houden zonder ze te vertragen.
Veelgestelde vragen
Hoe verschilt Rails Strong Migrations van handmatige code review van migraties?
Code review vangt wat de reviewer toevallig zoekt. Strong Migrations vangt elke instantie van elk bekend onveilig patroon, elke keer, zonder uitzondering, in de lokale omgeving van de ontwikkelaar voordat de PR überhaupt geopend is. Het encodeert ook Postgres-versie-specifieke kennis die de meeste reviewers niet uit hun hoofd weten — bijvoorbeeld dat add_column met een default veilig werd in Postgres 11 maar niet eerder. De gem vult review aan; hij vervangt het niet. De reviewer is nog steeds de juiste plek om logica-bugs en architecturale problemen te vangen. De gem is de juiste plek om “dit blokkeert productie negen minuten” te vangen.
Werkt Rails Strong Migrations met Rails 8 en Solid Queue?
Ja. Strong Migrations werkt op het niveau van ActiveRecord-migraties en is agnostisch voor welke Rails-versie of welk background job-framework je gebruikt. Het werkt identiek op Rails 6.1, 7.x en 8.0, inclusief met de nieuwe Solid Queue- en Solid Cache-schema-migraties. Als je Rails 8 met meegeleverde Solid Queue draait, inspecteert de gem die migraties net als elke andere en past dezelfde regels toe.
Kan ik Rails Strong Migrations met MySQL gebruiken of alleen Postgres?
Strong Migrations ondersteunt zowel Postgres als MySQL, maar de diepte van de analyse is significant beter op Postgres. MySQL heeft een andere set onveilige patronen en een ander model voor online DDL (sommige MySQL-versies kunnen bepaalde schema-wijzigingen uitvoeren zonder volledige lock via ALGORITHM=INPLACE), dus de regels en aanbevolen fixes van de gem zijn per adapter afgestemd. Zet StrongMigrations.target_version op je MySQL major-versie in een MySQL-app en de gem past zijn regels dienovereenkomstig aan.
Wat is de juiste waarde voor StrongMigrations.start_after bij adoptie op een legacy app?
Zet het op de timestamp van de meest recente migratie in je repository op het moment dat je de gem adopteert. Vind hem met ls -1 db/migrate | tail -1 en gebruik de numerieke prefix. Dit rekent elke historische migratie in — die draaiden al in productie, dus die nu controleren is zinloos — en zorgt dat elke nieuwe migratie vanaf nu wordt geïnspecteerd. De verkeerde waarde is 0 (waardoor de gem duizenden irrelevante historische migraties checkt) of een toekomstige timestamp (waardoor de gem stilletjes wordt uitgeschakeld voor alles wat momenteel in de pipeline zit).
Hulp nodig bij het inbouwen van veilige migraties in je Rails deployment-pipeline, of bij herstel na een migratie die je site neerhaalde? TTB Software is gespecialiseerd in Rails-productieveiligheid en Postgres-operations. Wij doen dit al negentien jaar.
Related Articles
Rails Delegated Types: Single Table Inheritance Vervangen Zonder de Rommel
Rails delegated types uitgelegd: vervang STI met per-type tabellen. Migratiestrategie, querypatronen, N+1-valkuilen e...
Rails Postgres LISTEN/NOTIFY: Real-Time Events Zonder Redis of Message Broker
Rails Postgres LISTEN/NOTIFY gids: real-time events met ActionCable, connectiepatronen, productie-valkuilen. Wanneer ...
Rails Materialized Views met Scenic: Snelle Postgres Read Models Zonder Writes Om Zeep Te Helpen
Rails materialized views met de Scenic gem: bouw snelle read models, versioneer SQL in migraties en refresh concurren...