RUBY ON RAILS · 18 MIN READ ·

Postgres Logical Replication voor Rails: Zero-Downtime Major Version Upgrades en Database-Migraties

Postgres logical replication voor Rails: zero-downtime major version upgrades, provider-migraties en database-splitsingen met publisher, subscriber, slot-monitoring.

Postgres Logical Replication voor Rails: Zero-Downtime Major Version Upgrades en Database-Migraties

Een fintech-klant belde me op een woensdag: hun RDS Postgres 13-cluster bereikte over elf weken end-of-life, het standaard pg_upgrade-pad vereiste een schrijfdowntime van meerdere uren die ze niet konden opvangen, en hun vorige poging tot een blue-green database-swap was geëindigd met drie minuten verloren writes en een lange postmortem. Ze wilden weten of Postgres logical replication ze zonder downtime naar Postgres 17 kon brengen — niet “vijf seconden read-only mode”, maar echt nul. Vier weken later was de cutover klaar. Totale schrijfpauze tijdens de switchover: 1,6 seconden, ruim binnen hun connection retry envelope. Geen dataverlies, geen supporttickets.

Na negentien jaar Rails heb ik veel Postgres-upgrades gedaan, en het eerlijke antwoord is dat pg_upgrade prima is voor hobbyprojecten en het verkeerde gereedschap voor productieverkeer boven de duizend writes per seconde. Postgres logical replication voor Rails is de saaie, betrouwbare, licht omslachtige manier om major version upgrades, provider-migraties, regiomigraties en database-splitsingen uit te voeren zonder je app offline te halen. Deze post is het draaiboek dat ik bij klanten gebruik: hoe het werkt, de Rails-specifieke valkuilen (sequences, large objects, bytea-kolommen, replica identity), en de daadwerkelijke cutover-volgorde die writes minder dan twee seconden ophoudt.

Hoe Postgres Logical Replication Verschilt van Streaming Replication

Streaming replication kopieert de write-ahead log byte-voor-byte van een primary naar een standby. De standby moet exact dezelfde Postgres-versie draaien, dezelfde architectuur hebben, en is read-only tot je hem promoot. Het is het juiste gereedschap voor high-availability replica’s — en het verkeerde gereedschap voor een major version upgrade, omdat je geen WAL tussen major versies kunt repliceren.

Postgres logical replication verzendt gedecodeerde wijzigingen: “voeg deze rij toe aan deze tabel, hier zijn de kolomwaarden.” Een subscriber op Postgres 17 kan wijzigingen consumeren van een publisher op Postgres 13 omdat beide kanten het logical-protocol spreken. De subscriber is een volledig schrijfbare database die toevallig een live feed van wijzigingen ergens vandaan ontvangt. Dat is de hele truc.

Dit is ook wat het geschikt maakt voor migraties tussen providers (RDS naar Aurora, Aurora naar self-hosted, Heroku naar RDS), tussen regio’s, tussen Postgres-versies, en voor chirurgische operaties zoals “splits het audit_logs-schema af op een eigen database.” Als je mijn eerdere stuk over Postgres connection pooling met PgBouncer hebt gelezen, ken je al een van de beperkingen waar we tegenaan zullen lopen tijdens de cutover.

De Pre-Flight Audit Die Elke Rails-App Nodig Heeft

Voordat je een publicatie aanmaakt, neem een dag voor de audit. Postgres logical replication heeft scherpe randen die specifiek Rails-apps bijten.

Inventariseer tabellen zonder primary key. Logical replication heeft een replica identity nodig om te weten welke rij geüpdatet of verwijderd moet worden op de subscriber. Standaard is dat de primary key. Rails geeft elk model een primary key, dus dit is meestal in orde — maar join tables aangemaakt met create_join_table en de legacy schema_migrations-tabel hebben er geen. Elke tabel zonder primary key heeft of een toegevoegde primary key nodig (voorkeur) of REPLICA IDENTITY FULL (wat werkt maar bruut is voor UPDATE/DELETE-performance omdat het elke kolom van elke gewijzigde rij logt).

SELECT n.nspname, c.relname, c.relreplident
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'r'
  AND n.nspname NOT IN ('pg_catalog', 'information_schema')
ORDER BY n.nspname, c.relname;

relreplident is d (default — gebruikt primary key), n (niets — tabel kan niet gerepliceerd worden voor updates of deletes), f (full) of i (via een specifieke index). Alles wat n is, is een blokkade.

Inventariseer sequences. Logical replication repliceert sequence-waarden niet. Na de cutover staat elke sequence op de subscriber op de waarde waarmee hij geïnitialiseerd is, en je volgende INSERT op de nieuwe database zal botsen met bestaande ID’s. Je moet sequences handmatig vooruitschuiven tijdens het cutover-window. Hieronder laat ik dat script zien.

Inventariseer large objects, unlogged tables en materialized views. Large objects (het lo-type, niet bytea) worden niet gerepliceerd. Unlogged tables worden niet gerepliceerd. Materialized view-inhoud wordt niet gerepliceerd — alleen de definitie. Als je deze hebt, accepteer je het verlies, repliceer je ze apart, of refresh je ze op de subscriber na de cutover.

Inventariseer DDL. Postgres logical replication repliceert geen schemawijzigingen. Elke ALTER TABLE die je tijdens het migratie-window draait moet aan beide kanten draaien. De meeste teams freezen migraties tijdens de cutover, wat op een Rails-app betekent: deploys met migraties bevriezen — makkelijker gezegd dan gedaan.

Draai dit script voor de audit-samenvatting:

# bin/audit_logical_replication
require_relative "../config/environment"

conn = ActiveRecord::Base.connection

puts "=== Tables without primary key ==="
puts conn.exec_query(<<~SQL).rows.map(&:first)
  SELECT c.relname FROM pg_class c
  JOIN pg_namespace n ON n.oid = c.relnamespace
  WHERE c.relkind = 'r' AND n.nspname = 'public'
    AND c.relreplident = 'd'
    AND NOT EXISTS (
      SELECT 1 FROM pg_index i WHERE i.indrelid = c.oid AND i.indisprimary
    )
SQL

puts "\n=== Unlogged tables ==="
puts conn.exec_query(<<~SQL).rows.map(&:first)
  SELECT relname FROM pg_class
  WHERE relkind = 'r' AND relpersistence = 'u'
SQL

puts "\n=== Materialized views ==="
puts conn.exec_query("SELECT matviewname FROM pg_matviews").rows.map(&:first)

puts "\n=== Sequence count ==="
puts conn.exec_query("SELECT count(*) FROM pg_sequences").rows.first.first

Draai dit op productie. Los alles in de output op voordat je een enkele publicatie aanmaakt.

De Publisher en Subscriber Opzetten

Zodra de audit schoon is, is de daadwerkelijke logical replication-setup mechanisch. Op de publisher (de oude database) zet je wal_level = logical aan. Dit vereist een restart — plan dit tijdens een rustig window of gebruik de tooling van je provider. Op RDS is dit een parameter group-wijziging en een reboot; op Aurora is het rds.logical_replication = 1.

-- On the publisher (Postgres 13)
ALTER SYSTEM SET wal_level = logical;
-- restart Postgres

CREATE PUBLICATION rails_app_pub FOR ALL TABLES;

-- Replication user
CREATE ROLE replicator WITH REPLICATION LOGIN PASSWORD 'redacted';
GRANT CONNECT ON DATABASE rails_app_production TO replicator;
GRANT USAGE ON SCHEMA public TO replicator;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO replicator;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO replicator;

Op de subscriber (de nieuwe Postgres 17-database) herstel je eerst alleen het schema van de publisher. Je wilt elke tabel, index, sequence en constraint, maar geen data — de data komt via replicatie binnen.

# Schema only dump from old database
pg_dump --host=old-db --schema-only --no-owner --no-privileges \
  --no-publications --no-subscriptions \
  rails_app_production > schema.sql

# Restore on new database
psql --host=new-db rails_app_production < schema.sql

Maak nu de subscription aan op de nieuwe database:

-- On the subscriber (Postgres 17)
CREATE SUBSCRIPTION rails_app_sub
  CONNECTION 'host=old-db port=5432 dbname=rails_app_production user=replicator password=redacted'
  PUBLICATION rails_app_pub
  WITH (copy_data = true, create_slot = true, slot_name = 'rails_app_slot');

Dat ene commando doet enorm veel werk. Het opent een connectie terug naar de publisher, maakt een replication slot aan, voert een initiële COPY van elke tabel naar de subscriber uit, en begint dan wijzigingen te streamen vanaf de start-LSN van het slot. Voor een database van 200 GB kan de initiële kopie meerdere uren duren. Gedurende die tijd wordt elke rij die op de publisher wordt ingevoegd, geüpdatet of verwijderd, gebufferd in het replication slot, wat betekent dat WAL op de publisher niet kan worden opgeruimd totdat de subscriber bijgewerkt is. Houd schijfruimte in de gaten.

Replicatie-Lag en Slot-Gezondheid Monitoren

De gevaarlijkste failure mode van Postgres logical replication is een ongedetecteerd slot dat blijft groeien. Het slot houdt WAL vast op de publisher tot de subscriber het bevestigt. Als je subscriber crasht, hopeloos achterop raakt, of je iets verkeerd configureert, groeit het slot, vreet je schijf op, en uiteindelijk weigert je publisher writes. Ik heb dit productie zien plat leggen. Monitor dit vanaf dag één.

Op de publisher:

SELECT
  slot_name,
  active,
  pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn)) AS lag,
  pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained_wal
FROM pg_replication_slots;

retained_wal is je gevaarmetric. Als het boven een paar gigabytes groeit en niet krimpt, houdt de subscriber het niet bij.

Op de subscriber:

SELECT
  subname,
  pid,
  received_lsn,
  last_msg_receipt_time,
  latest_end_time
FROM pg_stat_subscription;

Als last_msg_receipt_time ouder is dan tien seconden, dan staat de replicatie stil. Ik hang beide aan Datadog met alerts op 2 GB retained WAL als warning en 8 GB als critical.

Een simpele Rails-check die je vanaf een health endpoint of scheduled job kunt aanroepen:

class ReplicationHealthCheck
  THRESHOLD_BYTES = 2.gigabytes

  def self.publisher_lag
    sql = <<~SQL
      SELECT slot_name,
             pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn) AS lag_bytes
      FROM pg_replication_slots
      WHERE slot_name = 'rails_app_slot'
    SQL

    result = ActiveRecord::Base.connection.exec_query(sql).first
    return unless result

    if result["lag_bytes"] > THRESHOLD_BYTES
      Rails.error.report(
        ReplicationLagError.new("Slot lag #{result['lag_bytes']} bytes"),
        severity: :warning
      )
    end
  end
end

Plan dit elke minuut. Als je geen slot-lag-alert hebt aangesloten, ben je niet klaar om over te schakelen.

De Cutover: Writes 1,6 Seconden Ophouden

De cutover is het hart van de migratie. Goed gedaan kan de applicatie kort niet schrijven, gaat hij daarna verder tegen een nieuwe database, zonder dataverlies. Verkeerd gedaan split-brain je je data tussen twee databases en spendeer je het weekend met rijen reconciliëren.

De volgorde die ik gebruik, op volgorde:

Stap 1: Bevestig dat replicatie-lag in feite nul is. pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn) moet onder een paar kilobytes zitten. Zo niet, wacht.

Stap 2: Zet de applicatie in write-pause modus. De schoonste manier die ik heb gevonden is een Rack middleware die 503 Service Unavailable retourneert voor elke non-GET request wanneer een Redis-flag gezet is. Reads gaan door, de homepage werkt, maar geen writes raken Postgres. Clients zien een korte herhaalbare fout. Dit is het enige window waarin je downtime hebt — en het moet gemeten worden in seconden, niet minuten.

class WritePauseMiddleware
  def initialize(app)
    @app = app
  end

  def call(env)
    request = ActionDispatch::Request.new(env)
    if !request.get? && !request.head? && write_paused?
      [503, {"Content-Type" => "application/json", "Retry-After" => "5"},
       [{error: "Maintenance in progress, retry shortly"}.to_json]]
    else
      @app.call(env)
    end
  end

  private

  def write_paused?
    Rails.cache.fetch("write_pause", expires_in: 1.minute) { false }
  rescue
    false
  end
end

Stap 3: Wacht twee seconden tot eventuele in-flight writes voltooid zijn of teruggerold worden.

Stap 4: Schuif elke sequence op de subscriber voorbij de waardes van de publisher. Dit is de meest vergeten stap in elk Postgres logical replication-draaiboek, en het veroorzaakt constraint-violations zodra je writes weer openzet.

namespace :replication do
  desc "Advance sequences on subscriber past publisher values"
  task advance_sequences: :environment do
    publisher = ActiveRecord::Base.establish_connection(
      ENV.fetch("PUBLISHER_DATABASE_URL")
    ).connection

    subscriber = ActiveRecord::Base.establish_connection(
      ENV.fetch("SUBSCRIBER_DATABASE_URL")
    ).connection

    sequences = publisher.exec_query(<<~SQL).rows.flatten
      SELECT schemaname || '.' || sequencename FROM pg_sequences
      WHERE schemaname NOT IN ('pg_catalog')
    SQL

    sequences.each do |seq|
      pub_val = publisher.exec_query(
        "SELECT last_value FROM #{seq}"
      ).first["last_value"]

      next_val = pub_val + 1000
      subscriber.exec("SELECT setval('#{seq}', #{next_val}, false)")
      puts "#{seq}: advanced to #{next_val}"
    end
  end
end

De + 1000 buffer is paranoia — hij kost je duizend ID’s per sequence, wat niets is, en geeft je een veiligheidsmarge tegen de zeldzame race waarbij de publisher een ID alloceert tijdens het pauzewindow.

Stap 5: Wissel de DATABASE_URL van de applicatie naar de nieuwe database. Op de meeste moderne hosting (Kamal, Heroku, ECS, Fly) is dit een enkele environment variable-wijziging gevolgd door een snelle app-restart. Met Kamal proxy of een vergelijkbare zero-downtime restart duurt dit een paar seconden en is onzichtbaar voor gebruikers die nog steeds de maintenance-response zien.

Stap 6: Verwijder de write-pause flag. Writes stromen naar de nieuwe database.

Stap 7: Verifieer met een known-good read-and-write probe. Voeg een rij in, lees hem terug, log het.

De hele sequence — pauzeren, sequences, swap, unpause — draait met oefening in minder dan drie seconden. De fintech-klant die ik eerder noemde klokte 1,6 seconden. Hun P99-latency tijdens de cutover zag eruit als een korte piek, niets meer.

Ik behandelde de algemene filosofie van zero-downtime database-migraties in een eerdere post — dit is de meest extreme versie daarvan.

Na de Cutover: Drop de Publisher Nog Niet

De nieuwe database neemt writes aan. Weersta de neiging om de oude minstens een week te verwijderen. Twee redenen.

Ten eerste heeft je rollback-plan de oude database intact nodig. Als er iets mis is met het nieuwe cluster — een parameter group-misconfiguratie, een extensie die niet meegemigreerd is, een query plan-regressie op Postgres 17 — wil je de optie hebben om terug te schakelen. Zodra je de publicatie en het slot dropt, is die optie weg.

Ten tweede is de publisher een gratis read-only audit log van het migratie-window. Als iemand ontbrekende data meldt, kun je row counts vergelijken. Als een webhook-handler iets aan de verkeerde kant heeft gelogd, kun je diffen.

Volg na een week monitoring deze drop-volgorde: drop de subscription op de subscriber, drop de publicatie op de publisher, drop het replication slot op de publisher, decommission het oude cluster, snapshot eerst. De decommissioning moet je backup-beleid en eventuele retentievereisten respecteren — praat met compliance als je in een gereguleerde sector zit.

Wanneer Postgres Logical Replication het Verkeerde Gereedschap Is

Voor de balans: pak hier niet naar als je upgrade van Postgres 15 naar 16 op een low-traffic app. pg_upgrade met link mode is binnen een minuut klaar en een zestig-seconden maintenance-window is goedkoper dan een vier-weken project. De complexiteit van Postgres logical replication voor Rails verdient zijn plek wanneer downtime echt onacceptabel is, wanneer je van provider verandert, wanneer je meerdere major versies overslaat, of wanneer je een database in tweeën splitst.

De complexiteit is ook niet gratis als je zware bytea-kolommen hebt, regelmatige schemawijzigingen die je niet kunt freezen, of tabellen zonder primary keys die je niet kunt repareren. Los die eerst op of kies een andere migratiestrategie.

FAQ

Kan Postgres logical replication in één stap over meerdere major versies upgraden?

Ja. De publisher en subscriber kunnen elke combinatie zijn van versies vanaf Postgres 10. Je kunt direct van 13 naar 17 gaan in een enkele replicatie-setup — geen tussenstops nodig. Dit is een van de hoofdvoordelen ten opzichte van pg_upgrade, dat over het algemeen wil dat je één major versie per keer hopt.

Hoe lang duurt de initiële COPY-fase voor een grote Rails-database?

Voor een goed afgestelde subscriber met snelle SSD en de publisher onder gematigde load, verwacht ruwweg 30 tot 60 GB per uur voor de initiële tabel-kopiefase. Een Rails-database van 500 GB neemt doorgaans 10 tot 15 uur initiële sync, waarna streaming binnen minuten bijwerkt. Draai deze fase ruim voor je cutover-window — het slot accumuleert al die tijd WAL op de publisher, dus dimensioneer de publisher-schijf navenant.

Wat is het verschil tussen logical replication en pg_dump / pg_restore?

pg_dump is een point-in-time snapshot — writes die na de dump plaatsvinden worden gemist. Logical replication is continu, dus writes die tijdens en na de initiële kopie binnenkomen worden vastgelegd. Je gebruikt pg_dump --schema-only om de subscriber te seeden, maar de daadwerkelijke data-sync gaat via de replicatie-stream, en dat is wat de zero-downtime cutover mogelijk maakt.

Moet ik mijn Rails-applicatiecode aanpassen om logical replication te laten werken?

Nee. Logical replication is onzichtbaar voor de applicatie — zowel publisher als subscriber zijn normale Postgres-databases die normale queries accepteren. De enige Rails-wijziging is de DATABASE_URL-swap bij de cutover, en optioneel de write-pause middleware. Als je Rails multi-database support gebruikt, kun je tijdelijk beide databases geconfigureerd willen houden, maar geen modelcode-wijzigingen.


Een Postgres major version upgrade of database-migratie aan het plannen en wil je een tweede paar ogen op het draaiboek? TTB Software helpt Rails-teams met het ontwerpen en uitvoeren van zero-downtime database-migraties met logical replication. We doen dit al negentien jaar.

#postgres-logical-replication #postgres-logical-replication-rails #postgres-major-version-upgrade #rails-database-migration #zero-downtime-deploys #postgresql #ruby-on-rails

Related Articles

Laatste sectie. Bel dan alsjeblieft.

Het is een telefoongesprek. Erger dan dat kan het niet worden.

Geen discovery-deck. Geen 45-minuten "kwalificatiegesprek." 30 minuten, jouw probleem, mijn mening. Als we een fit zijn weet je dat in minuut 12.

Directe lijn — Roger neemt zelf op
+31 6 5123 6132
Ma–vr, 09:00–18:00 CET · Nu beschikbaar

OF
info@ttb.software