35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English 35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English
Rails Postgres Advisory Locks: Stop Cron-Overlap en Race Conditions in Productie

Rails Postgres Advisory Locks: Stop Cron-Overlap en Race Conditions in Productie

Roger Heykoop
Ruby on Rails, Postgres
Rails Postgres advisory locks: stop cron-overlap, race conditions en dubbele verwerking. Productiepatronen en valkuilen na negentien jaar Rails.

Een logistieke klant piepte me op een dinsdagochtend op omdat hun voorraad duizenden eenheden van slag was. Niet een beetje af — substantieel af, het soort af waarbij een CFO naar je bureau komt lopen. We groeven een uur en vonden de oorzaak: hun cron van vijf minuten “verzoen voorraad met leveranciersfeed” liep, ergens tijdens een Kamal-rollout, op twee app-pods tegelijk. Beide pods lazen dezelfde leveranciersbatch. Beide verlaagden dezelfde orders. Beide schreven resultaten terug. Telkens als de vensters overlapten, werd elke regel dubbel geteld.

De fix kostte acht regels. We omhulden de hele job met een Postgres advisory lock. Na negentien jaar Rails heb ik meer storingen gezien door jobs die dubbel liepen dan door jobs die niet liepen, en Rails Postgres advisory locks zijn nog steeds het schoonste wapen tegen die hele klasse bugs. Dit is het productie-draaiboek.

Wat Rails Postgres Advisory Locks Eigenlijk Zijn

Een Postgres advisory lock is een named lock die jij, als applicatieontwikkelaar, definieert en verkrijgt volgens je eigen conventie. Postgres houdt bij of de lock vastgehouden wordt, blokkeert iedereen die hem ook probeert te krijgen, en geeft hem vrij wanneer jij dat vraagt — of wanneer je sessie verdwijnt.

Cruciaal: advisory locks vergrendelen geen rijen of tabellen. Ze vergrendelen een getal van jouw keuze. Dat is de hele truc. Je kiest een 64-bit integer (meestal afgeleid van een string als "reconcile-stock"), en Postgres gebruikt die als coördinatiepunt over elke verbinding die naar de database wijst. Dat is alles wat je nodig hebt om Rails-processen te coördineren over pods, regio’s of deploys heen.

Ten opzichte van pg_locks op een rij hebben advisory locks drie voordelen die het waard zijn om te onthouden:

  • Ze zijn niet afhankelijk van een bestaande record — je kunt het concept “de onboarding-flow van deze gebruiker” vergrendelen voordat er ook maar één rij is geschreven.
  • Ze zijn goedkoop. Geen rij-reads, geen autovacuum-interactie, geen lock-table bloat.
  • Ze hebben een session-level modus die over transacties heen blijft bestaan, precies wat je nodig hebt voor langlopende jobs.
SELECT pg_try_advisory_lock(42);
-- t  (lock acquired)

SELECT pg_try_advisory_lock(42);
-- f  (someone else holds it)

SELECT pg_advisory_unlock(42);
-- t  (released)

Dat is het hele primitief. Alles hieronder is alleen maar patronen die daarop gebouwd zijn.

Session-Level vs Transaction-Level Rails Postgres Advisory Locks

Postgres geeft je twee smaken en Rails-ontwikkelaars halen ze constant door elkaar.

Transaction-level locks worden verkregen met pg_advisory_xact_lock en automatisch vrijgegeven wanneer de transactie commit of rollback. Je kunt ze niet handmatig vrijgeven. Ze zijn perfect voor “maak dit codepad binnen één transactie wederzijds uitsluitend.”

Session-level locks worden verkregen met pg_advisory_lock en vastgehouden tot je ze expliciet vrijgeeft met pg_advisory_unlock of je databasesessie eindigt. Dat is wat je wilt voor langlopende jobs die meerdere transacties beslaan.

De valkuil: met PgBouncer in transaction-pooling-modus zijn session-level advisory locks gevaarlijk. Je “sessie” is in werkelijkheid een geleende backend-verbinding die PgBouncer doorgeeft aan de volgende client zodra je transactie eindigt — lock en al. Gebruik dus transaction-level locks achter PgBouncer, of stuur lock-vasthoudend werk via een directe verbinding. Ik schreef hier uitgebreider over in de Postgres connection pooling-gids.

De with_advisory_lock Gem Is De Dependency Waard

Je kunt het zelf bouwen, en voor een of twee call-sites moet je dat ook. Daarna: gebruik with_advisory_lock. Hij hasht een string naar een 64-bit integer voor je, regelt transaction-vs-session correct, ondersteunt timeouts, en leest prachtig.

# Gemfile
gem "with_advisory_lock", "~> 5.2"
class Inventory::ReconcileJob < ApplicationJob
  queue_as :default

  def perform(account_id)
    Account.with_advisory_lock("reconcile-stock-#{account_id}", timeout_seconds: 0) do
      Inventory::Reconciler.new(account_id).run
    end
  rescue WithAdvisoryLock::FailedToAcquireLock
    Rails.logger.info("reconcile-stock-#{account_id} already running, skipping")
  end
end

timeout_seconds: 0 is de regel die dit verandert van “wacht voor altijd” naar “sla over als hij al draait.” Dat is de juiste default voor cron-achtige jobs waar de volgende tick toch oppakt waar je was. Voor workflows waar je moet draaien, verhoog je de timeout naar een paar seconden, maar nooit naar oneindig. Een geblokkeerde job die een worker bezet houdt is erger dan een overgeslagen job.

Use Case 1: Stop Cron-Overlap Over Pods en Deploys Heen

Dit is de meestvoorkomende reden om Rails Postgres advisory locks te adopteren, en het was de inventory-bug waarmee dit artikel begon.

class DailyDigestJob < ApplicationJob
  def perform
    ApplicationRecord.with_advisory_lock("daily-digest", timeout_seconds: 0) do
      Account.find_each do |account|
        DigestMailer.with(account: account).daily.deliver_later
      end
    end
  end
end

Of je scheduler nu whenever, GoodJob recurring jobs, Solid Queue cron of een Kubernetes CronJob is, je moet aannemen dat hij twee keer kan vuren. Een pod-restart, een deploy, klokverloop, een “handmatige run vanuit de console” door een junior — het enige dat tussen jou en een dubbele run staat is een lock. Als je achtergrondwerk specifiek met Solid Queue coördineert, doorloopt de Solid Queue background jobs-gids hoe je dit netjes in recurring tasks bedraadt.

Use Case 2: Race Conditions in Checkout en Voorraad

De andere klassieker. Twee requests komen op exact hetzelfde moment binnen, beide checken stock >= 1, beide slagen, beide maken een order aan. Nu heb je één van iets twee keer verkocht.

SELECT FOR UPDATE werkt hier, maar vereist dat de rij bestaat en vergrendelt de rij voor het hele transactievenster. Een advisory lock met de sleutel "checkout-#{sku}" is vaak helderder en laat je werk afschermen dat meerdere tabellen aanraakt.

class CheckoutService
  def initialize(sku:, user:)
    @sku, @user = sku, user
  end

  def call
    Product.with_advisory_lock("checkout-#{@sku}", transaction: true, timeout_seconds: 5) do
      product = Product.find_by!(sku: @sku)
      raise OutOfStock unless product.stock_available >= 1

      product.decrement!(:stock_available)
      Order.create!(user: @user, product: product, status: :placed)
    end
  end
end

Let op transaction: true. Dat schakelt with_advisory_lock om naar pg_advisory_xact_lock, wat betekent dat de lock automatisch vrijkomt wanneer de omhullende transactie eindigt — zelfs als je code raised. Dat is precies wat je hier wilt: geen kans op een lock-leak als Order.create! opblaast.

Use Case 3: Leader Election voor Eenmalig Werk

Je hebt N app-servers, allemaal dezelfde code, en je wilt dat precies één van hen een stuk werk uitvoert. Misschien warm je een cache. Misschien draai je een backfill bij boot. Misschien start je één websocket-subscriber op.

# config/initializers/leader_tasks.rb
Rails.application.config.after_initialize do
  next if Rails.env.test?

  Thread.new do
    ApplicationRecord.with_advisory_lock("backfill-leader", timeout_seconds: 0) do
      Backfill::ImportancesRecalculator.new.run
    end
  rescue WithAdvisoryLock::FailedToAcquireLock
    # someone else is the leader, fine
  end
end

Welke pod als eerste boot, pakt de lock en draait het werk. De anderen falen om de lock te krijgen en lopen door. Geen Redis, geen Zookeeper, geen leader-election-library. Eén Postgres-call.

Use Case 4: Idempotente Webhooks en Externe Callbacks

Webhook-providers retryen. Stripe, GitHub, Shopify — ze gaan er allemaal van uit dat je endpoint twee keer geraakt kan worden voor dezelfde event. Een advisory lock combineren met een idempotency-record geeft je de twee lagen die je nodig hebt.

class StripeWebhooksController < ApplicationController
  def create
    event_id = params[:id]

    ApplicationRecord.with_advisory_lock("stripe-event-#{event_id}", transaction: true) do
      return head :ok if WebhookEvent.exists?(provider: "stripe", external_id: event_id)

      Stripe::EventProcessor.new(params).call
      WebhookEvent.create!(provider: "stripe", external_id: event_id)
    end

    head :ok
  end
end

De lock voorkomt dat twee gelijktijdige retries beide “nog geen record” zien en beide verwerken. De WebhookEvent-rij is het duurzame bewijs voor retries die binnenkomen nadat de lock al is vrijgegeven. Ik behandelde de bredere webhook-security en idempotency-patronen in de Rails webhook processing-gids.

Productie-Valkuilen Waar Niemand Je Voor Waarschuwt

Dit zijn de vier die mij of mijn klanten in de afgelopen twee jaar hebben gebeten.

Lock-leak met session-level locks onder PgBouncer. Al genoemd. Als je session-level locks móét gebruiken, zorg dan dat die verbindingen PgBouncer omzeilen of session pooling gebruiken.

Een lock verkrijgen binnen een transactie en dan langzaam werk doen. Een transaction-level advisory lock houdt de transactie open, wat idle in transaction-verbindingen levend houdt, wat autovacuum verdrietig maakt. Houd het werk binnen de lock kort, of gebruik een session-level lock met een korte transactie daarbinnen. De autovacuum tuning-gids legt uit waarom lange transacties zo corrosief zijn.

Hash-collisies door je codebase heen. with_advisory_lock hasht je string naar 64 bits, wat ruim genoeg is, maar het blijft een hash. Als je locks bekent op directe gebruikersinput ("checkout-#{params[:sku]}"), wees je er dan van bewust dat twee verschillende SKU’s kunnen botsen. Prefix je lock-namen met het domein (“checkout-“, “reconcile-stock-“) zodat collisies begrensd zijn.

timeout_seconds vergeten. De default is voor altijd wachten. In een job-systeem betekent dat workers stapelen achter een vastzittende lock en je queue-depth ontploft. Stel altijd een timeout in. Voor “skip if running”-workflows is die timeout 0. Voor “wacht kort en geef dan op” is hij twee tot vijf seconden.

Rails Postgres Advisory Locks Monitoren

De pg_locks-view laat je precies zien wat er vastgehouden wordt en door wie. Deze query is het waard om vast te pinnen op je monitoring-dashboard:

SELECT pid,
       locktype,
       classid,
       objid,
       mode,
       granted,
       now() - state_change AS held_for,
       query
FROM pg_locks
JOIN pg_stat_activity USING (pid)
WHERE locktype = 'advisory'
ORDER BY held_for DESC;

Als je dezelfde advisory lock urenlang ziet vastzitten, heeft iemand er één gelekt. Zie je rijen wachters, dan verkrijgt iemand zonder timeout. Beide zijn binnen seconden te vinden met deze query en effectief onzichtbaar zonder.

Wanneer Niet Naar Advisory Locks Grijpen

Advisory locks zijn het juiste gereedschap voor coördinatie. Ze zijn het verkeerde gereedschap voor duurzame serialisatie. Als je nodig hebt “exact één van deze jobs heeft ooit gedraaid, ook over deploys heen,” wil je een uniqueness constraint op een job-runs-tabel, geen lock. Als je nodig hebt “deze gebruiker mag maar één open sessie hebben,” wil je een rij in een sessions-tabel met een unieke index. Als je nodig hebt “deze creditcard kan niet twee keer belast worden voor hetzelfde intent,” wil je een idempotency-key persistent in een rij, met de lock als dunne laag concurrency-control daarbovenop.

Het mentale model: advisory locks coördineren gelijktijdige runs. Database-constraints voorkomen dubbele runs over de tijd heen. Meestal wil je beide.

Veelgestelde Vragen

Zijn Rails Postgres advisory locks veilig met PgBouncer?

Transaction-level advisory locks (transaction: true) zijn veilig met PgBouncer in elke pooling-modus omdat ze worden vrijgegeven aan het einde van de transactie voordat de verbinding terug naar de pool gaat. Session-level advisory locks zijn alleen veilig met session pooling, nooit met transaction pooling — de verbinding wordt aan een andere client doorgegeven terwijl jouw lock nog vasthoudt.

Hoe voorkom ik dat twee cronjobs tegelijk draaien in Rails?

Omhul de body van de job met with_advisory_lock("job-name", timeout_seconds: 0) en rescue WithAdvisoryLock::FailedToAcquireLock. De eerste invocatie pakt de lock, elke overlappende invocatie wordt overgeslagen. Dit werkt hetzelfde of je scheduler nu whenever, Solid Queue cron, GoodJob recurring of een Kubernetes CronJob is.

Wat is het verschil tussen pg_advisory_lock en pg_try_advisory_lock?

pg_advisory_lock blokkeert tot de lock is verkregen. pg_try_advisory_lock keert direct terug met true of false. Voor achtergrondjobs wil je bijna altijd de try-variant of een begrensde timeout — voor altijd blokkeren binnen een worker is hoe queue-depth ontploft.

Kunnen advisory locks deadlocken?

Ja, als je twee locks in inconsistente volgorde verkrijgt over processen heen. De fix is dezelfde als bij row locks: kies een canonieke volgorde (alfabetische lock-naam, oplopende ID) en verkrijg altijd in die volgorde. Postgres detecteert de deadlock en aborteert één transactie, maar je wilt liever niet zo ver komen.


Na negentien jaar Rails blijft de les zich herhalen: de meeste productie-correctheidsbugs zijn vermomde concurrency-bugs, en de meeste concurrency-bugs zijn op te lossen met één goedgeplaatste lock. Rails Postgres advisory locks zijn de lichtste, goedkoopste tool in die gereedschapskist. Gebruik ze.

Hulp nodig bij het hardenen van een Rails-systeem tegen race conditions, cron-overlap of dubbele verwerking? TTB Software levert productiewaardige Rails-infrastructuur. We doen dit al negentien jaar.

#rails-postgres-advisory-locks #postgres-advisory-locks #rails-race-conditions #prevent-cron-overlap #with-advisory-lock-gem #rails-locking-patterns #ruby-on-rails
R

About the Author

Roger Heykoop is een senior Ruby on Rails ontwikkelaar met 19+ jaar Rails ervaring en 35+ jaar ervaring in softwareontwikkeling. Hij is gespecialiseerd in Rails modernisering, performance optimalisatie, en AI-ondersteunde ontwikkeling.

Get in Touch

Share this article

Need Expert Rails Development?

Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise

Schedule a Free Consultation