Rails Data Migrations: Veilige Backfills met data-migrate, Maintenance Tasks en Batched Updates
Rails data migrations correct uitvoeren: gebruik data-migrate of maintenance_tasks voor veilige, hervatbare backfills die geen tabellen locken of deploys blokkeren.
De deploy liep al zes minuten toen de DBA naar mijn bureau liep. Ze zei niets. Ze draaide haar laptop simpelweg om. De pg_locks-tabel stond vol van boven naar beneden: een enkele UPDATE orders SET status = 'shipped' WHERE ... hield een ACCESS EXCLUSIVE lock op de orders-tabel en de hele applicatie had opgehouden requests te accepteren.
De ontwikkelaar die de migratie had geschreven had twee fundamenteel verschillende dingen door elkaar gehaald: een schema-migratie en een data-migratie. De schema-migratie voegde een status-kolom toe, correct, een DDL-statement dat in milliseconden uitvoert. Vlak daarna hadden ze twee duizend regels Ruby geplakt die door elke order itereerde om de kolom te vullen. De migratie draaide in een transactie. Die transactie hield een ACCESS EXCLUSIVE lock gedurende de twaalf minuten die het kostte om vier miljoen rijen aan te raken. We rolden het terug, maar de applicatie was al zes minuten dood.
Na negentien jaar Rails heb ik deze fout in de een of andere vorm zeker een dozijn keer gezien. Het is geen domheid — de logica is intuïtief. Je voegt een kolom toe, je vult hem, je deployt. Het probleem is dat Rails data migrations en Rails schema-migraties verschillende operaties zijn met verschillende veiligheidsprofilen, en de een in de ander uitvoeren is hoe je productie neerhaalt.
Dit artikel is de gids die ik aan elk team geef waarmee ik werk: wat het verschil is, de drie tools die je kunt gebruiken om data-migraties veilig af te handelen, en wanneer je welke pakt.
Schema-migraties vs. Data-migraties: Het Kernverschil
Een schema-migratie wijzigt de databasestructuur: add_column, create_index, drop_table. Dit zijn DDL-statements. In PostgreSQL is de meeste DDL transactioneel — als je ze verpakt in een BEGIN/COMMIT-blok en de migratie mislukt, wordt de schemawijziging netjes teruggedraaid. Rails verpakt elke migratie standaard in een transactie voor precies deze reden.
Een data-migratie wijzigt de inhoud van bestaande rijen: een nieuwe kolom vullen, een gedenormaliseerd veld opsplitsen in twee kolommen, berekende waarden herberekenen, records verplaatsen tussen tabellen. Dit zijn DML-statements. Ze zijn ook transactioneel, wat goed klinkt — maar de transactie die een data-migratie omhult houdt locks op elke rij die het aanraakt gedurende de volledige duur van de operatie. Op een tabel met vier miljoen rijen wordt die lockduur gemeten in minuten, niet milliseconden.
De correcte regel is simpel: voer nooit data-migraties uit binnen schema-migraties. De schema-migratie voegt de kolom toe of hernoemt hem. Een apart proces, dat buiten een lange transactie draait, vult de data. De twee operaties worden onafhankelijk van elkaar gedeployt, mogelijk dagen apart, en je applicatie verwerkt beide toestanden.
Dit is geen nieuw idee, maar het is er een waar de conventie van Rails’s migratiebestanden actief tegenin werkt. De map db/migrate/ traint ontwikkelaars om alle database-aanrakende wijzigingen op één plek te zetten. De oplossing is de juiste tool te kiezen voor data-specifieke wijzigingen en die ergens anders naartoe te sturen.
De data-migrate Gem: Versioned Data Migrations
De data-migrate gem voegt een tweede migratiedirectory toe — db/data/ — voor alleen-data-wijzigingen. Hij onderhoudt zijn eigen versiebeheer-tabel (data_migrations) los van schema_migrations, en biedt Rake-taken die data-migraties onafhankelijk van of naast schema-migraties draaien.
Installatie en Configuratie
# Gemfile
gem "data-migrate"
bundle install
bundle exec rails data_migrate:install:migrations
bundle exec rails db:migrate
De installatiestap maakt de data_migrations-trackingtabel aan. Vanaf dit punt bestaan er twee directories:
db/migrate/— alleen schemawijzigingen (DDL)db/data/— alleen datawijzigingen (DML)
Genereer een data-migratie net zoals je een schema-migratie genereert:
bundle exec rails generate data_migration BackfillOrderStatus
Dit maakt db/data/20260915100000_backfill_order_status.rb aan.
Een Data-migratie Schrijven
class BackfillOrderStatus < ActiveRecord::Migration[7.1]
def up
Order.in_batches(of: 1000) do |batch|
batch.where(status: nil, shipped_at: ..Time.current).update_all(status: "shipped")
batch.where(status: nil).update_all(status: "pending")
end
end
def down
Order.where(status: ["shipped", "pending"]).update_all(status: nil)
end
end
Twee dingen om op te letten. Eerste: in_batches — update nooit alle rijen in één statement binnen een data-migratie. Een enkel UPDATE orders SET status = 'shipped' WHERE status IS NULL op vier miljoen rijen houdt een rij-level lock op alle vier miljoen rijen tegelijk gedurende de volledige updateduur. Batches van 500 tot 2000 rijen houden elke individuele lock kort en geven andere queries de kans te draaien tussen batches.
Tweede: de down-methode. De meeste data-migraties zijn moeilijk netjes terug te draaien, maar lever er een als je dat kunt. Als de rollback echt destructief is — de migratie verwijdert bijvoorbeeld records die niet gereconstrueerd kunnen worden — gebruik dan disable_ddl_transaction! plus een expliciete opmerking:
class DeduplicateSubscriptions < ActiveRecord::Migration[7.1]
disable_ddl_transaction!
def up
# Vind dubbele abonnementen (zelfde user_id + plan_id) en bewaar alleen de nieuwste.
# Dit is onomkeerbaar: eenmaal verwijderd zijn de duplicaten weg.
duplicate_scope = Subscription
.select("user_id, plan_id, COUNT(*) as count")
.group(:user_id, :plan_id)
.having("COUNT(*) > 1")
duplicate_scope.each do |dupe|
Subscription
.where(user_id: dupe.user_id, plan_id: dupe.plan_id)
.order(created_at: :desc)
.offset(1)
.delete_all
end
end
def down
raise ActiveRecord::IrreversibleMigration
end
end
disable_ddl_transaction! laat deze migratie afzien van Rails’s verpakkende transactie. Lang-lopende DML in een transactie houdt locks vast voor de volledige duur van de transactie; zonder de transactie-wrapper commit elke update_all onmiddellijk en geeft zijn locks vrij.
Data Migrations Uitvoeren
De gem geeft drie Rake-taken die er toe doen in productie:
# Alleen schema-migraties draaien (de DDL-deploy):
bundle exec rails db:migrate
# Alleen data-migraties draaien (de DML-deploy):
bundle exec rails data:migrate
# Schema-migraties draaien, daarna data-migraties:
bundle exec rails db:migrate:with_data
Een typische deployreeks voor een schema-plus-data-wijziging:
- Deploy de schema-migratie (
db:migrate). De applicatie draait met de nieuwe kolom;statusisnilvoor bestaande records. - Deploy applicatiecode die
status: nilverwerkt als backward-compatible standaard. - Draai de data-migratie (
data:migrate) — dit is een aparte stap, vaak een paar minuten of uren na de applicatiedeploy. - Zodra alle rijen zijn bijgevuld, verwijder de nil-verwerkingscode in een laatste opruimdeploy.
Deze reeks betekent dat je applicatie live is en traffic verwerkt terwijl de backfill draait. Geen lock, geen downtime, geen gecombineerde schema-en-data-deploy die twaalf minuten blokkeert.
Maintenance Tasks: Shopify’s Aanpak voor Grootschalige Backfills
data-migrate verwerkt versioned, eenmalige data-migraties goed. Voor lang-lopende backfills — de backfills die uren duren, honderden miljoenen rijen aanraken en een procesherststart moeten overleven — is Shopify’s maintenance_tasks gem het betere gereedschap.
maintenance_tasks biedt een Rails-engine met een web-UI voor het draaien, pauzeren, hervatten en monitoren van datataken. Taken zijn gewone Ruby-klassen. Voortgangsregistratie, throttling en cursor-gebaseerde hervatbaarheid zijn ingebouwd.
Installatie
# Gemfile
gem "maintenance_tasks"
bundle install
bundle exec rails maintenance_tasks:install:migrations
bundle exec rails db:migrate
Koppel de engine in config/routes.rb:
mount MaintenanceTasks::Engine => "/maintenance_tasks"
Beperk de toegang in productie — je wilt deze UI niet blootstellen aan het internet:
# config/routes.rb
authenticate :user, ->(user) { user.admin? } do
mount MaintenanceTasks::Engine => "/maintenance_tasks"
end
Een Maintenance Task Schrijven
bundle exec rails generate maintenance_tasks:task BackfillUserFullName
Dit maakt app/tasks/maintenance_tasks/backfill_user_full_name_task.rb aan:
module MaintenanceTasks
class BackfillUserFullNameTask < MaintenanceTasks::Task
# Verwerk 500 records per iteratie
def collection
User.where(full_name: nil).select(:id, :first_name, :last_name)
end
def count
collection.count
end
def process(user)
user.update_columns(full_name: "#{user.first_name} #{user.last_name}".strip)
end
end
end
collection geeft een ActiveRecord-relatie terug. De engine itereert er in batches overheen en roept process aan voor elk record. Tussen elke batch controleert hij of de taak is gepauzeerd of geannuleerd, schrijft een voortgangscontrolepunt naar de database, en slaapt optioneel op basis van een throttle-conditie.
Throttling om Productie te Beschermen
De belangrijkste feature in maintenance_tasks voor grote backfills is de throttle-callback. Zonder throttling itereert een backfill zo snel als de database aankan — wat op een write-intensief productiesysteem betekent dat hij direct concurreert met live traffic om I/O.
module MaintenanceTasks
class BackfillUserFullNameTask < MaintenanceTasks::Task
throttle_on(backoff: 30.seconds) do
# Pauzeer als de database primair zwaar belast is.
ApplicationRecord.connection
.execute("SELECT count(*) FROM pg_stat_activity WHERE state = 'active'")
.first["count"]
.to_i > 50
end
def collection
User.where(full_name: nil).select(:id, :first_name, :last_name)
end
def count
collection.count
end
def process(user)
user.update_columns(full_name: "#{user.first_name} #{user.last_name}".strip)
end
end
end
Wanneer het throttle-blok true teruggeeft, pauzeert de taak gedurende backoff seconden voor hij hervat. Je kunt ook throttlen op Sidekiq-wachtrij-latentie, Redis-geheugen, CPU-belasting van een monitoring-API — alles wat vanuit Ruby meetbaar is.
Cursor-gebaseerde Hervatbaarheid
Als een maintenance task crasht of opzettelijk wordt gepauzeerd, hervat hij vanaf het laatste controlepunt. Het controlepunt wordt per batch opgeslagen in de maintenance_tasks_task_runs-tabel. Bij hervatting reconstrueert maintenance_tasks de collectiequery vanaf de laatste verwerkte cursorpositie.
Voor ActiveRecord-gebaseerde taken is de cursor de primaire sleutel van het laatste verwerkte record. Voor CSV- of enumerabletaken geef je het cursortype expliciet door:
module MaintenanceTasks
class ReprocessInvoicesTask < MaintenanceTasks::Task
# Verwerk facturen vanuit een CSV-export — cursor is het rijnummer
csv_collection(has_header: true)
def process(row)
invoice = Invoice.find_by(external_id: row["external_id"])
invoice&.reprocess!
end
end
end
Een acht uur durende backfill ‘s nachts draaien en ‘s ochtends hervatten wanneer het traffic toeneemt is een normale maintenance_tasks-workflow. De taaklogboeken laten exact zien hoeveel records zijn verwerkt en met welke snelheid, zodat je de voltooiingstijd kunt schatten.
Het Batched Update Patroon: Wanneer Je Geen Gem Nodig Hebt
Voor eenmalige backfills die te klein zijn voor maintenance_tasks en te riskant om in een schema-migratie te draaien, is het gewone in_batches-patroon voldoende:
# Voer dit uit vanuit de rails console, een Rake-taak of een eenmalige job
Order.where(tax_rate: nil).in_batches(of: 500) do |batch|
batch.update_all(tax_rate: 0.21)
# Optioneel: throttle tussen batches
sleep 0.1
end
Voor grotere tabellen waar je een voortgangsindicator wilt:
total = Order.where(tax_rate: nil).count
done = 0
start = Time.now
Order.where(tax_rate: nil).in_batches(of: 500) do |batch|
batch.update_all(tax_rate: 0.21)
done += batch.count
elapsed = Time.now - start
rate = done / elapsed
eta = (total - done) / rate
Rails.logger.info "Backfill: #{done}/#{total} (#{(done * 100.0 / total).round(1)}%) — ETA: #{eta.round}s"
sleep 0.05
end
in_batches gebruikt keyset-paginering onder de motorkap — het genereert WHERE id > last_id LIMIT batch_size-queries in plaats van LIMIT ... OFFSET ...-queries. Dit is belangrijk voor grote tabellen: OFFSET-queries vereisen het scannen en weggooien van de voorafgaande rijen, wat duurder wordt bij elke batch. Keyset-paginering blijft bij een ruwweg constante kosten ongeacht hoe diep je in de tabel zit. Dit is hetzelfde idee achter Rails cursor-paginering.
Eén valkuil: roep update_all niet aan binnen in_batches op de kolom waarop je filtert. Als je batchscope where(status: nil) is en update_all stelt status: 'active' in, bewegen de records buiten de scope en kan de batchcursor rijen overslaan. Scope de batch altijd op een stabiel attribuut — typisch de primaire sleutel of een created_at-bereik.
Wanneer Gebruik Je Welke Aanpak?
De beslissingsboom is kort.
Gebruik data-migrate wanneer:
- De datawijziging een eenmalige operatie is die gekoppeld is aan een schemawijziging (kolom toevoegen, hernoemen, splitsen).
- De tabel enkele miljoenen rijen heeft en de migratie in onder de vijf minuten per batchronde uitvoert.
- Je wilt dat de datawijziging wordt bijgehouden in versiebeheer naast schema-migraties.
- Je wilt dat
db:migrate:with_datazowel schema als data in CI automatisch afhandelt.
Gebruik maintenance_tasks wanneer:
- De backfill langer dan vijftien minuten duurt.
- Je halverwege moet kunnen pauzeren, hervatten of annuleren.
- Je voortgangsregistratie en een web-UI voor operationele zichtbaarheid wilt.
- De backfill een tabel met honderden miljoenen rijen aanraakt.
- Je throttling wilt die is gekoppeld aan productiebelastingssignalen.
- De operatie niet gebonden is aan een specifieke deploy — het is een doorlopende of herhaalde dataoperatie.
Gebruik gewoon in_batches wanneer:
- De backfill een eenmalige onderzoeks- of correctieoperatie is die nooit opnieuw hoeft te draaien.
- De tabel klein genoeg is dat hij in onder een minuut voltooid is.
- Je hem interactief vanuit de Rails-console wilt draaien en de voortgang wilt volgen.
In de praktijk gebruiken de meeste teams data-migrate voor de vijfennegentig procent van de gevallen en maintenance_tasks voor de grote, terugkerende operaties. Een gewone console in_batches verwerkt soms ‘s nachts om drie uur hotfixes.
Data Migrations Testen
Data-migraties hebben tests nodig. Een migratie die data corrumpeert op een manier die je niet had voorzien is aanzienlijk erger dan een migratie die een fout gooit en terugdraait.
Voor data-migrate, test je de migratieclass direct in RSpec of Minitest:
# spec/db/data/20260915100000_backfill_order_status_spec.rb
require "rails_helper"
describe BackfillOrderStatus do
let(:migration) { described_class.new }
describe "#up" do
it "markeert verzonden orders met een shipped_at-datum" do
order = create(:order, status: nil, shipped_at: 2.days.ago)
migration.up
expect(order.reload.status).to eq("shipped")
end
it "markeert niet-verzonden orders als pending" do
order = create(:order, status: nil, shipped_at: nil)
migration.up
expect(order.reload.status).to eq("pending")
end
it "overschrijft een al-ingestelde status niet" do
order = create(:order, status: "cancelled", shipped_at: 2.days.ago)
migration.up
expect(order.reload.status).to eq("cancelled")
end
end
end
Voor maintenance_tasks is de taakclass gewone Ruby en afzonderlijk testbaar:
# spec/tasks/maintenance_tasks/backfill_user_full_name_task_spec.rb
require "rails_helper"
describe MaintenanceTasks::BackfillUserFullNameTask do
describe "#process" do
it "voegt first_name en last_name samen tot full_name" do
user = create(:user, first_name: "Alice", last_name: "Jong", full_name: nil)
task = described_class.new
task.process(user)
expect(user.reload.full_name).to eq("Alice Jong")
end
it "verwijdert overtollige spaties wanneer last_name leeg is" do
user = create(:user, first_name: "Cher", last_name: "", full_name: nil)
task = described_class.new
task.process(user)
expect(user.reload.full_name).to eq("Cher")
end
end
end
Test de randgevallen: nil-waarden, lege strings, records die overgeslagen moeten worden, records waarbij de transformatie idempotent is. De daadwerkelijke batching- en throttlinglogica zit in de gem zelf en hoeft niet in je testsuite getest te worden — test de process-methode geïsoleerd en vertrouw op het framework.
Als de migratie een tabel aanraakt die wordt bestreken door strong_migrations, controleer dan of de data-migratie geen van de onveilige-operaties-waarschuwingen van strong_migrations triggert. Een veelvoorkomend mismatch: strong_migrations vangt kolomverwijderingen op in schema-migraties, maar een data-migratie die destroy_all aanroept op een grote tabel kan een lang-lopende tabellock houden die strong_migrations niet ziet. Controleer dit handmatig.
Veelgestelde Vragen
Wat is het verschil tussen een data-migratie en een schema-migratie in Rails?
Een schema-migratie wijzigt de databasestructuur — kolommen, tabellen, indexen, constraints. Een data-migratie wijzigt de inhoud van bestaande rijen — kolommen vullen, waarden transformeren, records verplaatsen. Schema-migraties in Rails worden verpakt in een transactie en passen DDL toe; data-migraties passen DML toe en mogen niet draaien in een lange transactie op grote tabellen om te voorkomen dat rijen langdurig worden vergrendeld.
Kan ik data-migrate data-migraties automatisch uitvoeren in Rails CI?
Ja. Vervang rails db:migrate door rails db:migrate:with_data in je CI-pipeline. Dit voert eerst schema-migraties uit, daarna data-migraties, en registreert beide in hun respectieve versietabellen. Voor rails db:schema:load (gebruikt bij testomgevingsinstellingen) draai je onmiddellijk daarna rails data:migrate om eventuele openstaande data-migraties toe te passen.
Hoe verwerkt maintenance_tasks een taakcrash of serverherstart?
maintenance_tasks persisteert een cursor naar de database na elke batch. Als het proces crasht of wordt herstart, toont het taakuitvoeringsrecord de status interrupted. Je hervat hem vanuit de web-UI of via MaintenanceTasks::Task.named("BackfillUserFullNameTask").resume!. De taak gaat verder vanaf de laatste persistente cursor en verwerkt geen rijen opnieuw en slaat geen rijen over.
Moet ik een data-migratie in een transactie verpakken?
Nee, niet voor grote tabellen. Een transactie op een update_all van vijf miljoen rijen houdt rij-level locks op alle vijf miljoen rijen vast voor de volledige updateduur — wat gelijktijdige schrijfacties op die rijen blokkeert voor zo lang als de update duurt. Laat elke in_batches-batch onafhankelijk committen. Elke batch voltooit en geeft zijn locks vrij voordat de volgende batch begint. De afweging is dat als de migratie halverwege mislukt, sommige rijen zijn gemigreerd en sommige niet — wat prima is, omdat een hervattende migratie verdergaat vanaf de laatste succesvolle batch.
Hoe verwerk ik afhankelijke associaties in een data-migratie?
Gebruik find_each of in_batches in plaats van includes + iteratie bij het aanraken van meerdere modellen. Eager loading met includes kan enorme resultaatsets in het geheugen laden op grote tabellen. Voor multi-model backfills, verwerk je in batches gekoppeld aan de primaire tabel en query je afhankelijke records binnen elke batch:
Order.in_batches(of: 500) do |batch|
orders = batch.includes(:line_items).to_a
orders.each do |order|
total = order.line_items.sum(&:amount)
order.update_columns(cached_total: total)
end
end
includes laadt hier regelitems voor vijfhonderd orders in twee queries — geen N+1 — en de batch commit voordat hij verdergaat naar de volgende vijfhonderd.
Een backfill die vastloopt op “we kunnen ons de downtime niet veroorloven”? Of een schema-migratie aan het ontrafelen die door de jaren heen een data-migratie is geworden? TTB Software doet dit werk voor teams die het goed gedaan willen hebben. Negentien jaar Rails-productie-ervaring, inclusief een paar twaalf-minuten-uitval die we hebben helpen oplossen.
Related Articles
Rails Timeouts: Statement, Rack, HTTP Client en Job Timeouts die Productiecascades Voorkomen
Rails timeouts goed geregeld: configureer statement_timeout, rack-timeout, HTTP client en background job limieten om ...
Rails Postgres-back-ups met pgBackRest: point-in-time recovery, S3-opslag en restore drills
Rails Postgres-back-ups met pgBackRest: volledige en differentiële back-ups naar S3, WAL-archivering, point-in-time r...
Rails Searchkick: Productie Full-Text Zoeken met Elasticsearch en OpenSearch
Rails Searchkick integreert Elasticsearch en OpenSearch met ActiveRecord via synoniemen, boosting, facetten, autocomp...