Rails Transactional Outbox Pattern: Betrouwbaar Events Publiceren Zonder Dual-Write Fouten
Rails transactional outbox pattern: voorkom dual-write fouten en verloren webhooks door events atomair te publiceren binnen je database-transacties.
Drie weken nadat een nieuwe fulfilment-integratie live ging, belde een klant mij op een vrijdagmiddag. Zeventien bestellingen waren niet verzonden. De bestellingen stonden als status: "fulfilled" in de Rails-database. Het warehousemanagementsysteem had geen enkel record van ze.
Het verschil? Een Rails-proces was gestopt tijdens een rollende deploy — Kamal was containers aan het wisselen en zeven requests waren halverwege. Elk daarvan had de database-transactie succesvol gecommit, de bestelling geüpdatet naar fulfilled, en daarna een Net::ReadTimeout gegenereerd op de HTTP POST naar de warehouse-API. Geen enkel event bereikte het warehouse. Geen retry ingepland. De database en het externe systeem waren stil uit elkaar gedreven, en geen van beide wist het.
Dit is het dual-write probleem. Het zit niet in een fout in je code. Het is een structurele garantieschending die onzichtbaar is totdat het schade veroorzaakt, en het overkomt iedereen die naar een database schrijft én een externe service aanroept in hetzelfde request.
Het Rails transactional outbox pattern elimineert dit probleem.
Het Dual-Write Probleem, Precies
Elke keer dat je dit doet, heb je een dual-write:
def fulfill_order(order)
order.update!(status: :fulfilled, fulfilled_at: Time.current) # Schrijf 1: database
WarehouseClient.post("/shipments", order.to_shipment_payload) # Schrijf 2: extern systeem
end
Er zijn vier mogelijke uitkomsten. Drie zijn prima. Eén is catastrofaal:
- Beide slagen — prima.
- DB-schrijfactie mislukt, HTTP-call vindt nooit plaats — prima, consistente staat, herhaal de hele operatie.
- DB-schrijfactie slaagt, HTTP-call mislukt met een schone fout — theoretisch af te handelen met een rescue en retry, maar je schrijft nu retry-logica in het request-pad, wat de response vertraagt en onder concurrency moeilijk goed te krijgen is.
- DB-schrijfactie slaagt, HTTP-call timed out of het proces sterft — je zult nooit weten dat het event verloren is gegaan, want de exception vond plaats na de commit en de transactie is al weg.
Scenario vier veroorzaakte die zeventien onverzonden bestellingen op die vrijdagmiddag. Het proces stierf. Geen exception werd gegooid vanuit het perspectief van de aanroeper. Geen dead letter queue. Geen alert. Alleen stilte.
De neiging is om de volgorde om te draaien — eerst de externe service aanroepen, dan de database committen. Dat helpt niet. Je krijgt nu de omgekeerde stille fout: de webhook vuurt maar de database-update commit nooit. Je hebt een fundamenteel andere aanpak nodig.
Het Transactional Outbox Pattern
Het patroon is eenvoudig. In plaats van het externe systeem direct aan te roepen, schrijf je de intentie om het aan te roepen in een database-tabel — de outbox — binnen dezelfde transactie als je bedrijfslogicawijziging. Een apart achtergrondproces leest de outbox-tabel en verzorgt de daadwerkelijke levering. Omdat het outbox-record en het bedrijfsrecord atomair worden weggeschreven in één transactie, slagen ze allebei of mislukken ze allebei. De achtergrondprocessor herprobeert levering totdat het slaagt.
Het resultaat is at-least-once delivery: een event kan meer dan eens worden afgeleverd als de relay crasht tussen het markeren als afgeleverd en het persisteren van die status. Je consumers moeten idempotent zijn. In de praktijk is idempotentie aan de consumentenkant gemakkelijker te implementeren dan het klinkt — meestal volstaat een unieke index op een event-ID.
De Outbox-tabel Opzetten
Begin met de migratie:
# db/migrate/20260728000000_create_outbox_events.rb
class CreateOutboxEvents < ActiveRecord::Migration[8.0]
def change
create_table :outbox_events do |t|
t.string :aggregate_type, null: false
t.bigint :aggregate_id, null: false
t.string :event_type, null: false
t.jsonb :payload, null: false, default: {}
t.string :idempotency_key, null: false
t.datetime :published_at
t.integer :attempts, null: false, default: 0
t.datetime :last_attempted_at
t.text :last_error
t.timestamps
end
add_index :outbox_events, :idempotency_key, unique: true
add_index :outbox_events, :created_at,
where: "published_at IS NULL",
name: "idx_outbox_events_pending"
end
end
aggregate_type en aggregate_id identificeren welk record dit event toebehoort — Order en 42, bijvoorbeeld. event_type is een genaamruimde string zoals order.fulfilled. payload is de data die de consumer nodig heeft. idempotency_key is een unieke string per logisch event die double-processing in de consumer voorkomt als de relay hetzelfde event twee keer aflevert. De gedeeltelijke index op created_at WHERE published_at IS NULL houdt de pollingquery snel, zelfs met miljoenen al-afgeleverde events in de tabel.
Het model:
# app/models/outbox_event.rb
class OutboxEvent < ApplicationRecord
MAXIMUM_ATTEMPTS = 10
scope :pending, -> {
where(published_at: nil)
.where("attempts < ?", MAXIMUM_ATTEMPTS)
.order(:created_at)
}
end
Atomair Publiceren naar de Outbox
De volledige waarde van dit patroon komt voort uit het schrijven naar de outbox-tabel binnen hetzelfde transaction-blok als je bedrijfslogica. Rails wikkelt elke save!-aanroep in zijn eigen impliciete transactie, dus je hebt een expliciet blok nodig om de twee schrijfacties samen te bundelen:
class Order < ApplicationRecord
def fulfill!
transaction do
update!(status: :fulfilled, fulfilled_at: Time.current)
OutboxEvent.create!(
aggregate_type: "Order",
aggregate_id: id,
event_type: "order.fulfilled",
payload: {
order_id: id,
customer_id: customer_id,
line_items: line_items.map(&:to_event_payload),
fulfilled_at: fulfilled_at.iso8601
},
idempotency_key: "order.fulfilled.#{id}"
)
end
end
end
Als OutboxEvent.create! gooit — een uniqueness-overtreding omdat het event al in de wachtrij staat, een database-constraint — rolt de hele transactie terug. Als het proces sterft na update! maar voor OutboxEvent.create!, rolt Postgres automatisch terug. Hoe dan ook: de bestelling is ofwel fulfilled met een outbox-record, of geen van beiden is gebeurd. Geen stille divergentie.
Voor hergebruik over meerdere modellen, extraheer je een concern:
# app/models/concerns/outbox_publisher.rb
module OutboxPublisher
extend ActiveSupport::Concern
def publish_outbox_event(event_type, payload = {}, idempotency_key: nil)
OutboxEvent.create!(
aggregate_type: self.class.name,
aggregate_id: id,
event_type: event_type,
payload: payload,
idempotency_key: idempotency_key || "#{self.class.name.underscore}.#{event_type}.#{id}"
)
end
end
Include het en roep het aan binnen je transacties:
class Subscription < ApplicationRecord
include OutboxPublisher
def upgrade!(new_plan)
transaction do
old_plan = plan
update!(plan: new_plan, upgraded_at: Time.current)
publish_outbox_event("subscription.upgraded", {
subscription_id: id,
old_plan: old_plan,
new_plan: new_plan,
upgraded_at: upgraded_at.iso8601
})
end
end
end
Een ontwerpbeslissing die expliciet de moeite waard is: de idempotency_key in het standaard concern is subscription.upgraded.123, wat uniek is per subscription. Als een subscription meerdere keren legitiem kan upgraden, voeg je een versie of timestamp toe in de sleutel om te voorkomen dat de uniqueness-constraint het tweede event blokkeert: "subscription.upgraded.#{id}.#{upgraded_at.to_i}".
De Relay-job
De relay-job pollt de outbox-tabel, levert elk pending event af aan zijn bestemming, en markeert het als afgeleverd. Hij draait elke paar seconden via Solid Queue’s recurring job scheduler — de volledige configuratie beschreef ik in de Solid Queue recurring jobs post.
# app/jobs/outbox_relay_job.rb
class OutboxRelayJob < ApplicationJob
queue_as :outbox
def perform
OutboxEvent.pending.limit(100).lock("FOR UPDATE SKIP LOCKED").each do |event|
relay(event)
end
end
private
def relay(event)
event.update_columns(
attempts: event.attempts + 1,
last_attempted_at: Time.current
)
EventRouter.dispatch(event)
event.update_column(:published_at, Time.current)
rescue => e
event.update_column(:last_error, "#{e.class}: #{e.message}"[0, 2000])
raise
end
end
FOR UPDATE SKIP LOCKED is de sleutel tot veilige parallelle verwerking. Het vergrendelt de geselecteerde rijen en slaat rijen over die al vergrendeld zijn door een andere transactie. Als je twee relay-workers tegelijkertijd draait voor doorvoer, pikt elk een niet-overlappende batch op zonder te blokkeren of te racen. Zonder SKIP LOCKED staan workers in de rij achter elkaars vergrendelingen, waardoor parallelle aflevering wordt geserialiseerd. De Postgres-vergrendelingssemantiek beschreef ik uitgebreid in de advisory locks post.
Plan de relay frequent in via config/recurring.yml:
# config/recurring.yml
outbox_relay:
class: OutboxRelayJob
queue: outbox
schedule: "*/5 * * * * *" # elke 5 seconden (cron met seconden-ondersteuning)
Of inline in config/application.rb als je dat prefereert:
config.solid_queue.recurring_tasks = {
outbox_relay: {
class: "OutboxRelayJob",
schedule: "*/5 * * * * *"
}
}
Events Routeren naar Hun Bestemming
De relay-job delegeert naar een EventRouter. De router mapt event-type patronen naar aflevering-adapters, waardoor de relay-job klein blijft en elke publisher onafhankelijk testbaar is:
# app/services/event_router.rb
class EventRouter
ROUTES = {
/^order\./ => OrderWebhookPublisher,
/^subscription\./ => BillingSystemPublisher,
/^customer\./ => CrmSyncPublisher
}.freeze
def self.dispatch(event)
publisher_class = ROUTES.find { |pattern, _| pattern.match?(event.event_type) }&.last
raise UnroutableEventError, "Geen publisher voor: #{event.event_type}" unless publisher_class
publisher_class.new(event).call
end
end
Een HTTP webhook publisher:
# app/publishers/order_webhook_publisher.rb
class OrderWebhookPublisher
def initialize(event)
@event = event
end
def call
subscribers_for(@event.event_type).each do |subscriber|
payload_json = @event.payload.to_json
response = Faraday.post(
subscriber.endpoint_url,
payload_json,
"Content-Type" => "application/json",
"X-Event-Type" => @event.event_type,
"X-Idempotency-Key" => @event.idempotency_key,
"X-Signature" => sign(payload_json, subscriber.secret)
)
raise WebhookDeliveryError, "HTTP #{response.status}" unless response.success?
end
end
private
def subscribers_for(event_type)
WebhookSubscriber.active.for_event_type(event_type)
end
def sign(payload_json, secret)
OpenSSL::HMAC.hexdigest("SHA256", secret, payload_json)
end
end
Voor interne consumers — Solid Queue-jobs, Sidekiq-workers — sla HTTP volledig over en zet direct in de wachtrij:
# app/publishers/billing_system_publisher.rb
class BillingSystemPublisher
def initialize(event)
@event = event
end
def call
SyncSubscriptionToBillingJob.perform_later(
subscription_id: @event.aggregate_id,
event_type: @event.event_type,
payload: @event.payload,
idempotency_key: @event.idempotency_key
)
end
end
Dit is een punt dat mensen vaak verrast: het Rails transactional outbox pattern werkt even goed of je nu externe HTTP-webhooks aanroept of interne achtergrond-jobs dispatcht. De relay is slechts een dispatcher. Het patroon lost in beide gevallen hetzelfde dual-write probleem op.
Fouten en Dead Letters Afhandelen
De relay-job gooit bij een fout. Het ActiveJob-framework markeert de job als mislukt en de planner zet hem opnieuw in de wachtrij. Bij de volgende relay-run heeft het outbox-record nog steeds published_at: nil met attempts: N en wordt het opnieuw opgepikt — tot MAXIMUM_ATTEMPTS.
Na tien pogingen sluit de pending-scope het record uit. Op dit punt heb je zichtbaarheid nodig. Bouw een eenvoudige health check:
# app/checks/outbox_health_check.rb
class OutboxHealthCheck
def self.call
dead_count = OutboxEvent
.where(published_at: nil)
.where("attempts >= ?", OutboxEvent::MAXIMUM_ATTEMPTS)
.count
{
status: dead_count.zero? ? :ok : :degraded,
dead_letter_count: dead_count
}
end
end
Koppel het aan je monitoring. Op klantopdrachten sla ik dit meestal op als een Sentry cron-check of stel het beschikbaar via /up als een benoemde check. Voor individuele vastgelopen records voeg je een reset-methode toe:
class OutboxEvent < ApplicationRecord
def retry!
update!(attempts: 0, last_error: nil, last_attempted_at: nil)
end
end
Roep OutboxEvent.find(id).retry! aan vanuit een Rails-console om een dead letter event terug in de retry-wachtrij te zetten. Voor batchherstel na het repareren van een kapotte consumer:
OutboxEvent
.where(published_at: nil)
.where("attempts >= ?", OutboxEvent::MAXIMUM_ATTEMPTS)
.update_all(attempts: 0, last_error: nil)
Afgeleverde Events Opruimen
De outbox-tabel verzamelt afgeleverde events voor onbepaalde tijd zonder opruiming. Voeg een prune-job toe:
# app/jobs/outbox_prune_job.rb
class OutboxPruneJob < ApplicationJob
queue_as :maintenance
def perform
OutboxEvent
.where.not(published_at: nil)
.where("published_at < ?", 30.days.ago)
.delete_all
end
end
Gebruik delete_all in plaats van destroy_all. Je hebt geen callbacks bij verwijdering nodig, en delete_all geeft één SQL DELETE uit in plaats van N afzonderlijke roundtrips. Plan het dagelijks naast de relay. Dertig dagen geeft een comfortabel auditvenster zonder onbegrensde tabelgroei.
Wat Te Testen
Het outbox-patroon splitst netjes in twee onafhankelijke concerns, wat het eenvoudig maakt om te testen:
# spec/models/order_spec.rb
RSpec.describe Order do
describe "#fulfill!" do
it "maakt een outbox event aan in dezelfde transactie" do
order = create(:order, :ready_to_fulfill)
expect { order.fulfill! }.to change(OutboxEvent, :count).by(1)
event = OutboxEvent.last
expect(event.event_type).to eq("order.fulfilled")
expect(event.aggregate_id).to eq(order.id)
expect(event.payload["order_id"]).to eq(order.id)
end
it "rolt het outbox event terug als de order update mislukt" do
order = create(:order, :ready_to_fulfill)
allow(order).to receive(:update!).and_raise(ActiveRecord::RecordInvalid.new(order))
expect { order.fulfill! }.to raise_error(ActiveRecord::RecordInvalid)
expect(OutboxEvent.where(event_type: "order.fulfilled").count).to eq(0)
end
end
end
Test de relay-job en elke publisher onafhankelijk met een vooraf gevuld outbox-record. Houd de twee testconcerns gescheiden: één spec verifieert dat het outbox-record correct wordt aangemaakt; een ander verifieert dat de publisher het correct aflevert.
Wanneer Is het Outbox-patroon Overdreven?
Het patroon voegt een polling-lus, een database-tabel en een relay-laag toe. Voor sommige use cases is dat meer dan nodig.
Als je event-volume erg laag is — een paar events per dag — en de gevolgen van een verloren event echt gering zijn, kan een goed geïmplementeerd rescue-blok met een achtergrond-retry voldoende zijn. Het outbox-patroon schittert waar eventverlies zakelijke gevolgen heeft en waar events procesherstarts moeten overleven.
Als je al een berichtenbroker zoals Kafka of RabbitMQ gebruikt met transactionele semantiek, kan de broker zelf de duurzaamheidsgaranties bieden die je nodig hebt. In de praktijk is het betrouwbaar coördineren van een database-transactie én een broker-schrijfactie precies het dual-write probleem dat dit patroon oplost — vaak is de outbox het juiste antwoord, zelfs als er een broker in het spel is.
Voor diepere retry-patronen aan de consumerkant — exponentieel uitstel, circuit breakers, discard-beleid — passen die natuurlijk naast de outbox. Ik behandelde die in de Rails Active Job retries post.
Productie-hardening Checklist
Elke keer dat ik het Rails transactional outbox pattern instelt op een klantsysteem, controleer ik dit voordat ik het als klaar beschouw:
- Unieke index op
idempotency_key— voorkomt dubbele outbox-records en geeft de constraint-overtreding die een rollback triggert als je per ongeluk hetzelfde event twee keer inplant. - Gedeeltelijke index op pending events —
WHERE published_at IS NULLhoudt de pollingquery O(pending) in plaats van O(alles). Zonder dit vertraagt de query naarmate afgeleverde events zich opstapelen. FOR UPDATE SKIP LOCKED— veilige parallelle relay-workers zonder applicatieniveau-coördinatie.MAXIMUM_ATTEMPTSbegrenzing — voorkomt oneindige retry-stormen tegen een permanent kapotte consumer.- Dead letter monitoring — een health check of Sentry-alert schiet af wanneer events hun retries uitputten.
retry!mechanisme — je hebt een veilige manier nodig om dead letter events te resetten nadat een kapotte consumer is gerepareerd.- Payload-groottebeveiliging — valideer tijdens development dat
payload.to_json.bytesizeonder een redelijke limiet blijft (256KB is royaal). Te grote payloads wijzen op ontwerpfouten. - Prune-job — voorkomt onbegrensde tabelgroei.
- Idempotente consumers — documenteer en handhaaf dat alle event-consumers dubbele levering aankunnen.
Veelgestelde Vragen
Wat is het Rails transactional outbox pattern?
Het Rails transactional outbox pattern lost het dual-write probleem op: het risico dat een database-schrijfactie en een externe API-aanroep onafhankelijk slagen of mislukken, waardoor je systeem in een inconsistente staat terechtkomt. Je schrijft het event naar een outbox_events-tabel binnen dezelfde database-transactie als de bedrijfsdatawijziging. Omdat beide schrijfacties atomair zijn in Postgres, slagen ze allebei of mislukken ze allebei. Een afzonderlijke achtergrond-job leest de outbox en verzorgt levering met retries. Het resultaat is at-least-once event-levering zonder risico op stil dataverlies door procesherstarts of netwerkfouten.
Hoe verschilt het outbox-patroon van gewoon een achtergrond-job inplannen?
Een achtergrond-job inplannen is zelf een schrijfactie naar een extern systeem — Redis voor Sidekiq, of de jobs-tabel voor Solid Queue. Als je Rails-proces sterft nadat de database-transactie is gecommit maar voordat de job succesvol is ingepland, gaat het event verloren. De transactional outbox vermijdt dit door de leveringsintentie te schrijven naar dezelfde Postgres-database, binnen dezelfde transactie. Postgres garandeert dat als de transactie commit, het outbox-record bestaat. De relay-job leest dan van Postgres, dat een gecommit record nooit verliest. Zelfs als je Solid Queue gebruikt — dat jobs in Postgres opslaat — is de solid_queue_jobs-tabel een andere databaseverbinding en valt buiten je applicatietransactie.
Hoe voorkomt FOR UPDATE SKIP LOCKED conflicten tussen relay-workers?
SELECT ... FOR UPDATE SKIP LOCKED vergrendelt de geselecteerde rijen en slaat, cruciaal, rijen over die al vergrendeld zijn door een andere transactie. Als twee relay-workers tegelijkertijd draaien, pikt elk een niet-overlappende batch op — ze blokkeren elkaar niet. Zonder SKIP LOCKED staan workers in de rij achter elkaars vergrendelingen, waardoor parallelle aflevering wordt geserialiseerd. Het patroon voorkomt ook dubbele aflevering: de vergrendeling op een rij die door worker A wordt vastgehouden, voorkomt dat worker B hetzelfde event selecteert totdat A commit of teruggooit. Ik bespreek Postgres-vergrendelingssemantiek uitgebreid in de advisory locks post.
Kan ik het outbox-patroon gebruiken met Sidekiq in plaats van Solid Queue?
Ja. De relay-job is een standaard ActiveJob en de planner is losgekoppeld van de outbox-tabel zelf. Vervang OutboxRelayJob door een Sidekiq-worker en plan het met een sidekiq-cron of whenever/cron-invoer. Het OutboxEvent-model en de FOR UPDATE SKIP LOCKED-query werken identiek ongeacht de job-backend. Als je migreert van Sidekiq naar Solid Queue, hoeft je outbox-implementatie niet te veranderen — alleen de plannerconfiguratie.
Stil dataverlies tussen je database en externe systemen is een van de moeilijkste productiefouten om te diagnosticeren, want je weet niet dat het is gebeurd totdat een klant het merkt. Na negentien jaar Rails-productieondersteuning is het transactional outbox pattern standaardinfrastructuur bij elke klantopdracht waarbij webhooks, factureringsintegraties of warehousesystemen betrokken zijn. TTB Software helpt teams betrouwbare event-gedreven Rails-applicaties bouwen die deploys, netwerkfouten en procesherstarts overleven. Als je integraties events droppen onder belasting of tijdens rolling deploys, kunnen wij helpen.
Related Articles
Rails LLM Observability: Prompts, Latentie en Tokengebruik Tracen in Productie met Langfuse
Rails LLM observability-gids: trace prompts, latentie en tokenkosten in productie met Langfuse en ActiveSupport::Noti...
Rails Sentry: Foutbewaking Instellen, Aangepaste Contexten en Performance Tracing voor Rails 8 in Productie
Rails Sentry instellen voor Rails 8: foutopsporing, aangepaste contexten, performance tracing, sampling en het uitfil...
Rails Sorbet: Geleidelijke typeveiligheid toevoegen aan legacy Rails-applicaties met sig, tapioca en CI
Rails Sorbet gids: voeg geleidelijke typeveiligheid toe aan legacy Rails-apps met sig, tapioca RBI-generatie, srb tc,...