RUBY ON RAILS · 16 MIN READ ·

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 je het inzet in plaats van Redis of Kafka.

Rails Postgres LISTEN/NOTIFY: Real-Time Events Zonder Redis of Message Broker

Vorig voorjaar stuurde een founder mij een specifieke vraag: hij had voor precies één feature een Redis-instance van vijftig dollar per maand op Upstash draaien — een “nieuwe reactie”-toast die in de browser verscheen zodra iemand anders op hetzelfde document een reactie plaatste. De rest van de applicatie was een normale Rails 8 monoliet op Postgres bij RDS. Hij wilde weten of die Redis-instance het operationele oppervlak waard was voor die ene feature. Dat was het niet. De Postgres-database waar hij toch al voor betaalde kon die events zelf pushen, in minder dan een milliseconde, met een Ruby-vriendelijke API die de meeste Rails-developers nog nooit hebben aangeraakt. Dit is de post die ik hem had willen sturen: een fatsoenlijke uiteenzetting van Rails Postgres LISTEN/NOTIFY in productie.

Na negentien jaar Rails heb ik veel teams gezien die naar een message broker grepen zodra ze “iets real-time” nodig hadden. Soms is dat de juiste keuze. Vaak niet — het volume is laag, de ordening-garanties zijn zwak genoeg dat één database round-trip prima is, en de operationele kosten van weer een bewegend deel wegen niet op tegen de engineering-kosten van het leren van de tool die je al bezit. Rails Postgres LISTEN/NOTIFY is het patroon voor dat middengebied, en de valkuilen zijn de moeite waard om te kennen voor je live gaat.

Wat Rails Postgres LISTEN/NOTIFY Eigenlijk Is

Postgres heeft sinds 2010 een lichtgewicht pub/sub-mechanisme. Een sessie die LISTEN kanaal_naam uitvoert wordt gewekt zodra een andere sessie op dezelfde database NOTIFY kanaal_naam, 'payload' uitvoert. De payload is een string van maximaal acht kilobytes, delivery is at-most-once binnen de huidige transactie, en berichten worden weggegooid als er geen listener verbonden is. Het is geen Kafka. Het is geen RabbitMQ. Het is een deurbel die klinkt zolang je webserver een database-verbinding open heeft.

Voor Rails betekent dat één specifieke superkracht: je kunt een real-time UI-update triggeren vanuit elke plek die al naar de database schrijft — een controller, een background job, een database-trigger — zonder een tweede broker te introduceren. De subscriber-kant is een Ruby-thread die een ruwe PG::Connection in een wait-loop vasthoudt, en de brug naar de rest van je app is wat jij wilt: een ActionCable-broadcast, een Rails.event.publish, of een gewone method call.

De Trigger-Kant: NOTIFY Vanuit Rails

De schrijfkant is triviaal. Je kunt het inline vanuit een controller of een after_commit callback doen:

class Comment < ApplicationRecord
  after_commit :publish_created_event, on: :create

  private

  def publish_created_event
    payload = {
      id: id,
      document_id: document_id,
      author_id: author_id,
      created_at: created_at.iso8601
    }.to_json

    self.class.connection.execute(
      ActiveRecord::Base.sanitize_sql([
        "NOTIFY comments_created, ?", payload
      ])
    )
  end
end

Twee dingen om op te merken. Ten eerste vuurt dit pas nadat de transactie gecommit is, wat is wat je wilt — je wilt niet dat subscribers een rij lezen die een latere ROLLBACK zou verwijderen. Ten tweede is sanitize_sql niet optioneel. NOTIFY accepteert een string-literal, en als je een payload interpoleert die je uit user content hebt opgebouwd zonder single quotes te escapen, heb je een SQL-injectie.

Je kunt Rails ook helemaal overslaan en Postgres zelf laten notificeren via een trigger. Dit is het patroon dat ik aanraad als het schrijfpad niet altijd via Rails loopt — denk aan jobs in Go, bulk-imports via COPY, of een tweede app die dezelfde database deelt:

CREATE OR REPLACE FUNCTION notify_comment_created() RETURNS trigger AS $$
BEGIN
  PERFORM pg_notify(
    'comments_created',
    json_build_object(
      'id', NEW.id,
      'document_id', NEW.document_id,
      'author_id', NEW.author_id,
      'created_at', NEW.created_at
    )::text
  );
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER comments_notify_after_insert
AFTER INSERT ON comments
FOR EACH ROW EXECUTE FUNCTION notify_comment_created();

pg_notify is de functievariant van het NOTIFY-commando, en dat is wat je binnen PL/pgSQL wilt gebruiken omdat het de payload als bind-parameter accepteert. De trigger vuurt binnen de transactie — subscribers zien het pas als de transactie commit, omdat NOTIFY-berichten queuen en pas bij commit worden geflushed.

De Listen-Kant: Een Threaded Loop Die Productie Overleeft

De listener is waar het echte werk zit. Je hebt een Ruby-thread nodig die een dedicated Postgres-verbinding vasthoudt — niet één uit de ActiveRecord-pool — en blocking wacht op wait_for_notify. Dit is de vorm die ik in productie zet:

# app/services/pg_notifier_listener.rb
class PgNotifierListener
  CHANNELS = %w[comments_created documents_updated].freeze

  def self.start!
    Thread.new { new.run }
  end

  def run
    loop do
      connect_and_listen
    rescue PG::ConnectionBad, PG::UnableToSend => e
      Rails.logger.warn("pg_listener: reconnecting after #{e.class}: #{e.message}")
      sleep 1
      retry
    end
  end

  private

  def connect_and_listen
    conn = raw_connection
    CHANNELS.each { |c| conn.exec("LISTEN #{c}") }

    loop do
      conn.wait_for_notify(30) do |channel, _pid, payload|
        dispatch(channel, payload)
      end
      # ping zodat de verbinding NAT/firewall idle timeouts overleeft
      conn.exec("SELECT 1")
    end
  ensure
    conn&.close
  end

  def raw_connection
    config = ActiveRecord::Base.connection_db_config.configuration_hash
    PG::Connection.new(
      host:     config[:host],
      port:     config[:port],
      dbname:   config[:database],
      user:     config[:username],
      password: config[:password],
      sslmode:  config[:sslmode] || "prefer"
    )
  end

  def dispatch(channel, payload)
    parsed = JSON.parse(payload)
    ActiveSupport::Notifications.instrument("pg_notify.#{channel}", parsed)
    PgNotifierRouter.route(channel, parsed)
  rescue JSON::ParserError => e
    Rails.logger.error("pg_listener: bad payload on #{channel}: #{e.message}")
  end
end

Een paar productie-hardening opmerkingen. De dedicated PG::Connection is essentieel — je kunt er geen lenen uit ActiveRecord::Base.connection_pool want een verbinding die op wait_for_notify blokkeert kan geen queries meer bedienen. De timeout van dertig seconden op wait_for_notify met een SELECT 1 ping houdt de verbinding open door cloud load balancers heen die anders na vijf minuten stilte de TCP-verbinding stilletjes weggooien. De ActiveSupport::Notifications.instrument-aanroep zorgt dat je bestaande OpenTelemetry-setup — zie mijn post over OpenTelemetry in Rails — deze events gratis oppikt.

Bekabel de listener vanuit een initializer, maar alleen in server-processen:

# config/initializers/pg_notifier.rb
Rails.application.config.after_initialize do
  next unless defined?(Puma) || defined?(Rails::Server)
  next if Rails.env.test?

  PgNotifierListener.start!
end

Je wilt geen listener-thread in elke Sidekiq-worker, in rails console, of in rake db:migrate. Guard op het proces-type.

Het PgBouncer-Probleem Waarvoor Je Moet Plannen

Hier komt de valkuil waar elk team over struikelt dat dit in productie probeert: LISTEN/NOTIFY overleeft PgBouncer in transaction pooling mode niet. LISTEN bindt de subscriptie aan één specifieke backend-verbinding; de transaction pooler van PgBouncer geeft je bij de volgende query een andere backend, dus je LISTEN stopt stilzwijgend met ontvangen. Ik schreef hier uitvoerig over in mijn post over PgBouncer transaction pooling en prepared statements — dezelfde architectonische beperking geldt hier.

Er zijn drie werkbare opties. De eerste is PgBouncer omzeilen voor alleen de listener-verbinding, waarbij de listener-thread direct met Postgres verbindt op poort 5432 terwijl de rest van de app via de pooler op 6432 gaat. Dit is wat ik meestal doe — je accepteert één long-lived verbinding met de database in ruil voor een strakke reguliere pool. De tweede is PgBouncer in session pooling mode draaien, wat LISTEN behoudt maar de meeste multiplexing-voordelen tenietdoet. De derde is PgBouncer helemaal overslaan en leunen op Rails’ eigen connection pool.

Wat je ook kiest, documenteer het in de runbook. Over zes maanden verhuist iemand de app achter een nieuwe pooler en vraagt zich af waarom de toasts zijn gestopt.

Bridging Naar ActionCable

De listener-thread draait in hetzelfde proces als ActionCable, dus de brug is één method call:

# app/services/pg_notifier_router.rb
class PgNotifierRouter
  def self.route(channel, payload)
    case channel
    when "comments_created"
      DocumentChannel.broadcast_to(
        Document.find(payload["document_id"]),
        type: "comment_created",
        comment: payload
      )
    when "documents_updated"
      DocumentChannel.broadcast_to(
        Document.find(payload["id"]),
        type: "document_updated",
        document: payload
      )
    end
  end
end

Hier verdient het patroon zijn geld terug. Je had geen Redis nodig, je had geen Solid Cable nodig, en de round-trip van INSERT naar browser-toast is een paar milliseconden. Als je al bent overgestapt op Solid Cable voor het transport, dan compose’t dit netjes — het LISTEN/NOTIFY-kanaal triggert de broadcast, en Solid Cable regelt de bezorging tussen webprocessen.

Payload-Grootte, Ordening en Delivery-Garanties

Postgres cap’t de NOTIFY-payload op acht kilobytes — dit is een harde limiet, en pogingen om meer te sturen leveren een error op. In de praktijk moet je de payload behandelen als pointer, niet als document. Stuur het record-id en een paar velden waarmee de subscriber kan beslissen of hij iets moet doen; als de subscriber de volledige rij nodig heeft, moet die maar queryen.

def publish_created_event
  payload = { id: id, document_id: document_id }.to_json
  self.class.connection.execute(
    ActiveRecord::Base.sanitize_sql(["NOTIFY comments_created, ?", payload])
  )
end

Delivery is at-most-once. Als de listener-thread niet verbonden is — omdat het proces herstart, omdat het netwerk hikte, omdat je deploy’t — dan zijn berichten die tijdens dat venster afgevuurd worden weg. Dat is prima voor UI-notificaties, presence-indicators en cache-invalidaties. Het is niet prima voor iets dat gereconcilieerd moet worden — order fulfillment, billing events, audit trails. Gebruik daarvoor een durable queue zoals Solid Queue en behandel NOTIFY als een low-latency zetje, met een durable job als vangnet.

Ordening is per-kanaal, in volgorde van transactie-commit. Twee notificaties gepubliceerd binnen dezelfde transactie komen samen aan, in de volgorde waarin ze in de queue kwamen. Notificaties uit twee gelijktijdige transacties zijn geordend op commit-tijd, wat bijna altijd is wat je wilt.

Debouncing en Deduplicatie

Een veelvoorkomend patroon is een document dat tien updates per seconde krijgt — je wilt dat de browser één refresh ziet, niet tien. Debounce aan de subscriber-kant in plaats van te proberen writes te reduceren. De listener is een gewone Ruby-thread; een kleine in-memory coalescing map is prima:

class DebouncedRouter
  DEBOUNCE_MS = 200

  def initialize
    @pending = {}
    @mutex = Mutex.new
    start_flusher
  end

  def route(channel, payload)
    key = "#{channel}:#{payload['id']}"
    @mutex.synchronize { @pending[key] = [channel, payload, monotonic_ms] }
  end

  private

  def start_flusher
    Thread.new do
      loop do
        sleep 0.05
        flush
      end
    end
  end

  def flush
    now = monotonic_ms
    ready = @mutex.synchronize do
      due, kept = @pending.partition { |_, (_c, _p, t)| now - t >= DEBOUNCE_MS }
      @pending = kept.to_h
      due
    end
    ready.each { |_key, (channel, payload, _)| PgNotifierRouter.route(channel, payload) }
  end

  def monotonic_ms
    (Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000).to_i
  end
end

Dit dropt bewust tussentijdse updates voor dezelfde id — de laatste write wint na 200ms stilte. Als je wél elke update wilt behouden, gebruik dan een durable queue.

Wanneer Je LISTEN/NOTIFY Overslaat En Grijpt Naar Redis Of Kafka

LISTEN/NOTIFY is de juiste tool als events efemeer zijn, het volume onder een paar honderd per seconde blijft, subscribers gemiste berichten tijdens restarts kunnen tolereren, en je niet nog een stuk infrastructuur wilt toevoegen. Als je durable delivery over restarts heen nodig hebt, geordende fan-out naar honderden workers, replay van het laatste uur aan events, of cross-region distributie, dan zit je buiten de comfortzone. Redis Streams is een logische volgende stap; Kafka woont nog een tier verder.

Het andere geval waarin ik het overslaat is bij hoog write-volume tegen tabellen die al contention hebben. Elke NOTIFY neemt een lichtgewicht lock op een Postgres-interne queue; bij enkele duizenden per seconde zie je dat verschijnen in pg_stat_activity. In de praktijk heb ik dat plafond nog nooit geraakt in een Rails-app, maar als je een event bus draait voor een vloot microservices is het reëel.

FAQ

Werkt Postgres LISTEN/NOTIFY met connection pooling?

Niet met transaction pooling. LISTEN bindt aan één specifieke backend-verbinding, en een transaction pooler zoals PgBouncer in transaction mode geeft je bij de volgende query een andere backend, dus je subscriptie ontvangt stilzwijgend niets meer. Verbind de listener direct met Postgres (pooler omzeilen) of draai de pooler in session mode.

Hoe groot mag een NOTIFY-payload zijn?

Acht kilobytes. Dat is een harde Postgres-limiet. Behandel de payload als pointer — stuur het id en een paar velden waarop de subscriber kan filteren, en laat de subscriber de volledige rij ophalen als hij meer nodig heeft.

Is LISTEN/NOTIFY een vervanging voor ActionCable?

Nee. LISTEN/NOTIFY is server-naar-server, binnen je Rails-proces. ActionCable is server-naar-browser, over WebSockets. Je gebruikt LISTEN/NOTIFY om een event uit te waaieren naar elk Rails-proces, en dan ActionCable om het naar de verbonden browsers te pushen. Op Rails 8 met Solid Cable compose’n de twee netjes — Postgres doet in beide gevallen de pub/sub.

Wat gebeurt er met notificaties tijdens een deploy?

Ze worden weggegooid. LISTEN/NOTIFY is at-most-once; elke notificatie die vuurt terwijl het listener-proces herstart is verloren. Dat is prima voor UI-toasts en cache-invalidatie. Voor alles wat niet verloren mag gaan — orders, betalingen, audit events — gebruik je een durable queue zoals Solid Queue, en behandel je NOTIFY als het low-latency zetje eroverheen.


Hulp nodig bij de beslissing of jouw Rails-app Redis kan overslaan voor real-time features, of wil je een review van je Postgres- en ActionCable-topologie? TTB Software shipt sinds 2007 Rails in productie en werkt als fractional CTO met founders en engineering-teams. Negentien jaar verder zijn de saaie antwoorden nog steeds meestal de juiste.

#rails-postgres-listen-notify #postgres-pub-sub-rails #rails-real-time-events #actioncable-postgres #rails-database-triggers #postgres-notify-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