Background Workers: Die stillen Helden die deine Anwendung am Leben halten

Lass mich dir von dem Mal erzählen als ein einzelner Background Job eine komplette E-Commerce Plattform für sechs Stunden am Black Friday lahmgelegt hat. Ein Job. Sechs Stunden. Millionen an verlorenem Umsatz. Der Job verarbeitete Bestellbestätigungen, traf auf eine fehlerhafte Email Adresse, warf eine unbehandelte Exception, und weil er so konfiguriert war dass er unendlich oft mit keinem Circuit Breaker wiederholt, blockierte er die gesamte Queue. Jeder andere Job, Versandbenachrichtigungen, Inventar Updates, Zahlungsbestätigungen, alles saß einfach da und wartete. Das ist kein Hypothetikum. Das ist passiert. Und genau deshalb schreibe ich diesen Post.

Lass mich dir von dem Mal erzählen als ein einzelner Background Job eine komplette E-Commerce Plattform für sechs Stunden am Black Friday lahmgelegt hat. Ein Job. Sechs Stunden. Millionen an verlorenem Umsatz. Der Job verarbeitete Bestellbestätigungen, traf auf eine fehlerhafte Email Adresse, warf eine unbehandelte Exception, und weil er so konfiguriert war dass er unendlich oft mit keinem Circuit Breaker wiederholt, blockierte er die gesamte Queue. Jeder andere Job, Versandbenachrichtigungen, Inventar Updates, Zahlungsbestätigungen, alles saß einfach da und wartete. Das ist kein Hypothetikum. Das ist passiert. Und genau deshalb schreibe ich diesen Post.

Background Workers sind die stillen Helden moderner Webanwendungen. Sie erledigen all das Zeug das deine User sonst auf Ladespinner starren lassen würde: Emails senden, Bilder verarbeiten, Daten synchronisieren, Reports generieren, Kreditkarten belasten. Aber sie sind auch eine massive Quelle von Produktions Incidents wenn sie nicht richtig designt sind.

Ich betreibe Sidekiq in Produktion seit über einem Jahrzehnt quer durch dutzende Anwendungen. Von kleinen Startups bis zu Enterprise Systemen die Millionen von Jobs pro Tag verarbeiten. Die Patterns in diesem Post sind kampferprobt und repräsentieren hart erarbeitete Lektionen aus echten Ausfällen.

Warum Background Workers existieren

Lass uns mit den Basics anfangen. Wenn ein User auf einen Button auf deiner Website klickt, erwartet er dass etwas schnell passiert. Forschung zeigt dass User nach etwa 100 Millisekunden Verzögerung frustriert werden, und sie werden eine Seite verlassen die länger als 3 Sekunden zum Laden braucht.

Aber manche Operationen brauchen Zeit. Eine Email zu senden könnte 500ms dauern weil du dich mit einem SMTP Server verbinden musst. Ein hochgeladenes Bild zu verarbeiten könnte 2 Sekunden dauern. Einen PDF Report zu generieren könnte 10 Sekunden dauern. Daten mit einer Third Party API zu synchronisieren könnte wer weiß wie lange dauern abhängig von deren Servern.

Wenn du diese Operationen synchron machst (also der User wartet während sie abgeschlossen werden), fühlt sich deine Anwendung träge an. Schlimmer noch, wenn du sie innerhalb eines Web Requests machst, blockierst du einen Serverprozess der andere Requests bearbeiten könnte. Unter Last führt das zu Request Queuing, Timeouts, und schließlich zum Stillstand deiner gesamten Anwendung.

Background Workers lösen das indem sie langsame Operationen aus dem Request/Response Zyklus verschieben. Statt die Arbeit sofort zu erledigen, reihst du sie ein und gibst eine Antwort an den User zurück. Ein separater Prozess holt die eingereihte Arbeit ab und erledigt sie asynchron.

Der User klickt "Bestellung aufgeben" und sieht sofort "Bestellung bestätigt!" Währenddessen, im Hintergrund, senden Worker Bestätigungs Emails, aktualisieren Inventar, benachrichtigen das Lager, und belasten die Kreditkarte. Der User wartet auf nichts davon.

Der Stack: Sidekiq, Redis und Valkey

In der Ruby Welt ist Sidekiq der unbestrittene König der Background Job Verarbeitung. Es ist schnell, zuverlässig, und wurde über ein Jahrzehnt in Produktion von tausenden Firmen kampferprobt.

Sidekiq nutzt Redis als seine Job Queue. Redis ist ein In Memory Data Store der unglaublich schnell ist für die Art von Operationen die Sidekiq braucht: Jobs auf Queues schieben, Jobs von Queues holen, und Job Status tracken.

Eine kurze Anmerkung zu Valkey

In 2024 hat Redis seine Lizenzierung von der permissiven BSD Lizenz zu einer Dual Lizenz geändert die einschränkt wie Cloud Anbieter Redis als Service anbieten können. Als Reaktion darauf hat die Linux Foundation Redis geforkt und Valkey erstellt, das unter der ursprünglichen BSD Lizenz weiterläuft.

Für unsere Zwecke ist Valkey ein Drop in Ersatz für Redis. Alles was ich in diesem Post über Redis sage gilt genauso für Valkey. Wenn du ein neues Projekt startest oder deine Organisation Bedenken wegen Redis Lizenzierung hat, nutze Valkey. Die Commands sind identisch, das Protokoll ist identisch, und Sidekiq funktioniert mit beiden.

# Gemfile
gem 'sidekiq', '~> 7.2'

# Für Redis
gem 'redis', '~> 5.0'

# Oder für Valkey (gleiches Gem, anderer Server)
# gem 'redis', '~> 5.0'  # Valkey spricht das Redis Protokoll
# config/initializers/sidekiq.rb
# Funktioniert mit Redis und Valkey

Sidekiq.configure_server do |config|
  config.redis = {
    url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0'),
    network_timeout: 5,
    pool_timeout: 5
  }
end

Sidekiq.configure_client do |config|
  config.redis = {
    url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0'),
    network_timeout: 5,
    pool_timeout: 5
  }
end

Dein erster Background Job

Lass uns einen einfachen Job schreiben der eine Willkommens Email sendet:

# app/jobs/send_welcome_email_job.rb
# Simplerster Job um Willkommens Emails zu senden
# Nix Fancy, erledigt einfach den Job

class SendWelcomeEmailJob
  include Sidekiq::Job

  def perform(user_id)
    user = User.find(user_id)
    UserMailer.welcome(user).deliver_now
  end
end

Und reihe ihn von deinem Controller ein:

# app/controllers/registrations_controller.rb

class RegistrationsController < ApplicationController
  def create
    @user = User.new(user_params)
    
    if @user.save
      # Job einreihen - kehrt sofort zurück
      # User wartet nicht bis Email tatsächlich gesendet wird
      SendWelcomeEmailJob.perform_async(@user.id)
      
      redirect_to dashboard_path, notice: 'Willkommen an Bord!'
    else
      render :new
    end
  end
end

Der Schlüssel hier ist perform_async. Das sendet nicht die Email. Es serialisiert den Job (den Klassennamen und die Argumente) nach Redis und kehrt sofort zurück. Der User bekommt seine Antwort in Millisekunden.

Währenddessen holt ein Sidekiq Worker Prozess der separat läuft den Job aus Redis und führt die perform Methode aus. Wenn die Email 500ms zum Senden braucht, ist das okay. Der User schaut sich bereits sein Dashboard an.

Das Problem: Jobs sind keine Inseln

Hier wird es gefährlich. Der einfache Job den ich gerade gezeigt habe? Er hat mehrere kritische Fehler die deine gesamte Anwendung lahmlegen könnten.

Fehler 1: Was wenn der User nicht existiert?

def perform(user_id)
  user = User.find(user_id)  # BUMM! RecordNotFound wenn User gelöscht wurde
  UserMailer.welcome(user).deliver_now
end

Zwischen dem Zeitpunkt als der Job eingereiht wurde und wann er läuft, könnte der User gelöscht worden sein. Vielleicht hat er Kontolöschung angefordert. Vielleicht hat ein Admin ihn entfernt. Vielleicht gab es einen Datenbank Rollback.

User.find wird ActiveRecord::RecordNotFound werfen, was Sidekiq fängt und wiederholt. Und wiederholt. Und wiederholt. Standardmäßig wiederholt Sidekiq fehlgeschlagene Jobs 25 mal über etwa 21 Tage. Das sind 25 Exceptions in deinem Error Tracker, 25 verschwendete Verarbeitungszyklen, und potenziell 25 Alert Benachrichtigungen.

Fehler 2: Was wenn die Email fehlschlägt?

def perform(user_id)
  user = User.find(user_id)
  UserMailer.welcome(user).deliver_now  # BUMM! SMTP Fehler, Timeout, whatever
end

SMTP Server gehen down. Netzwerkverbindungen haben Timeouts. Rate Limits werden erreicht. Email Adressen sind fehlerhaft. Irgendwas davon wird eine Exception werfen.

Wieder, Sidekiq wiederholt. Aber hier ist der tückische Teil: wenn dein Email Provider einen Ausfall hat, wird jeder einzelne Email Job fehlschlagen. Sie gehen alle in die Retry Queue. Wenn der Provider wieder da ist, hast du plötzlich tausende Jobs die alle gleichzeitig wiederholen und den Provider potenziell wieder überlasten.

Die goldene Regel: Jobs müssen unabhängig sein

Das ist die wichtigste Lektion in diesem gesamten Post. Tätowiere es auf deinen Arm wenn nötig:

Jeder Job muss unabhängig sein. Jeder Job muss idempotent sein. Jeder Job muss annehmen dass er fehlschlagen und wiederholt werden wird.

Unabhängigkeit

Ein Job sollte nicht vom Zustand abhängen den ein anderer Job hinterlassen hat. Er sollte nicht annehmen dass Jobs in einer bestimmten Reihenfolge laufen. Er sollte alle Daten die er braucht zur Ausführungszeit holen, nicht auf Daten von als er eingereiht wurde vertrauen.

# SCHLECHT: Hängt davon ab das vorheriger Job ein Flag gesetzt hat
class SendShippingNotificationJob
  include Sidekiq::Job

  def perform(order_id)
    order = Order.find(order_id)
    # Nimmt an das ProcessOrderJob schon lief und shipped_at gesetzt hat
    # Was wenn nicht? Was wenn er fehlgeschlagen ist?
    raise 'Noch nicht versandt!' unless order.shipped_at
    OrderMailer.shipped(order).deliver_now
  end
end

# GUT: Prüft Status und handhabt gnädig
class SendShippingNotificationJob
  include Sidekiq::Job

  def perform(order_id)
    order = Order.find_by(id: order_id)
    
    # User oder Order existiert vielleicht nicht mehr
    return unless order
    
    # Noch nicht versandt? Das ist okay, schick halt keine Benachrichtigung
    # Vielleicht reiht der Shipment Job uns später nochmal ein
    return unless order.shipped_at
    
    # Schon benachrichtigt? Spam den Kunden nicht zu
    return if order.shipping_notification_sent_at
    
    OrderMailer.shipped(order).deliver_now
    order.update!(shipping_notification_sent_at: Time.current)
  end
end

Idempotenz

Ein Job ist idempotent wenn ihn mehrmals auszuführen denselben Effekt hat wie ihn einmal auszuführen. Das ist entscheidend weil Sidekiq deinen Job mehrmals ausführen könnte wegen Retries, Netzwerkproblemen oder Worker Abstürzen.

# SCHLECHT: Nicht idempotent - wird mehrmals belasten bei Retry
class ChargeOrderJob
  include Sidekiq::Job

  def perform(order_id)
    order = Order.find(order_id)
    Stripe::Charge.create(
      amount: order.total_cents,
      customer: order.user.stripe_customer_id
    )
    order.update!(paid: true)
  end
end

# GUT: Idempotent - prüft ob schon belastet wurde
class ChargeOrderJob
  include Sidekiq::Job

  def perform(order_id)
    order = Order.find_by(id: order_id)
    return unless order
    
    # Schon bezahlt? Nichts zu tun
    return if order.paid?
    
    # Nutze Idempotency Key damit Stripe nicht doppelt belastet
    # Selbst wenn wir nach dem Belasten aber vor dem Update abstürzen
    charge = Stripe::Charge.create(
      amount: order.total_cents,
      customer: order.user.stripe_customer_id,
      idempotency_key: "order_#{order.id}_charge"
    )
    
    order.update!(
      paid: true,
      stripe_charge_id: charge.id,
      paid_at: Time.current
    )
  end
end

Monolithische Jobs aufbrechen

Erinnerst du dich an diesen furchtbaren ProcessOrderJob von vorhin? Lass uns ihn fixen indem wir ihn in unabhängige Jobs aufbrechen:

# app/jobs/process_order_job.rb
# Orchestrator Job - koordiniert nur die anderen
# Jeder Schritt ist sein eigener unabhängiger Job

class ProcessOrderJob
  include Sidekiq::Job

  def perform(order_id)
    order = Order.find_by(id: order_id)
    return unless order
    return if order.processing_started?
    
    # Markieren dass wir gestartet haben - verhindert doppelte Verarbeitung
    order.update!(processing_started_at: Time.current)
    
    # Jeden Schritt als separaten Job einreihen
    # Sie laufen unabhängig und können unabhängig fehlschlagen
    ChargeOrderJob.perform_async(order_id)
    UpdateInventoryJob.perform_async(order_id)
    NotifyWarehouseJob.perform_async(order_id)
    SendOrderConfirmationJob.perform_async(order_id)
  end
end
# app/jobs/charge_order_job.rb
# Behandelt nur Zahlung - nix anderes

class ChargeOrderJob
  include Sidekiq::Job
  sidekiq_options queue: 'critical', retry: 10

  def perform(order_id)
    order = Order.find_by(id: order_id)
    return unless order
    return if order.paid?
    
    charge = Stripe::Charge.create(
      amount: order.total_cents,
      customer: order.user.stripe_customer_id,
      idempotency_key: "order_#{order.id}_v1"
    )
    
    order.update!(
      paid: true,
      stripe_charge_id: charge.id,
      paid_at: Time.current
    )
  rescue Stripe::CardError => e
    # Karte abgelehnt - nicht wiederholen, User benachrichtigen
    order.update!(payment_failed: true, payment_error: e.message)
    PaymentFailedMailer.notify(order).deliver_later
  end
end

Jetzt wenn die Warehouse API down ist, schlägt nur NotifyWarehouseJob fehl und wiederholt. Die Zahlung geht durch, Inventar aktualisiert, und der Kunde bekommt seine Bestätigungs Email. Die Warehouse Benachrichtigung wird schließlich erfolgreich sein wenn deren API sich erholt.

Das ist die Power von unabhängigen Jobs. Fehler sind isoliert. Eine kaputte Sache kaskadiert nicht zu allem ist kaputt.

Queue Design: Kritische Jobs in Bewegung halten

Nicht alle Jobs sind gleich erschaffen. Eine Passwort Reset Email ist dringender als ein wöchentlicher Analytics Rollup. Eine Zahlungsbestätigung ist kritischer als das Synchronisieren von Daten zu einem CRM.

Sidekiq lässt dich mehrere Queues mit unterschiedlichen Prioritäten definieren:

# config/sidekiq.yml
# Queue Konfiguration - Reihenfolge ist wichtig!
# Sidekiq verarbeitet Queues in der aufgelisteten Reihenfolge

:concurrency: 10
:queues:
  - [critical, 6]      # 6 Threads für critical dediziert
  - [default, 3]       # 3 Threads für default
  - [mailers, 2]       # 2 Threads für Emails
  - [low, 1]           # 1 Thread für niedrige Priorität
  - [external_apis, 2] # 2 Threads für externe API Calls

Für wirklich kritische Jobs bevorzuge ich separate Sidekiq Prozesse:

# Prozess 1: Nur kritische Jobs
bundle exec sidekiq -q critical -c 5

# Prozess 2: Default und Mailer
bundle exec sidekiq -q default -q mailers -c 10

# Prozess 3: Niedrige Priorität und externe APIs
bundle exec sidekiq -q low -q external_apis -c 5

So, selbst wenn die external_apis Queue sich aufstaut weil ein Drittanbieter langsam ist, fließen deine kritischen Jobs weiter.

Monitoring und Alerting

Du kannst nicht fixen was du nicht sehen kannst. Sidekiq bietet ein Web UI das du unbedingt deployen solltest:

# config/routes.rb
require 'sidekiq/web'

Rails.application.routes.draw do
  # Mit Authentifizierung schützen
  authenticate :user, ->(user) { user.admin? } do
    mount Sidekiq::Web => '/sidekiq'
  end
end

Lektionen aus der Produktion

Lass mich einige spezifische Lektionen aus echten Ausfällen teilen:

Lektion 1: Argumente müssen serialisierbar sein

# SCHLECHT: ActiveRecord Objekte übergeben
SendEmailJob.perform_async(@user)  # Serialisiert das ganze Objekt!

# GUT: IDs übergeben
SendEmailJob.perform_async(@user.id)

Sidekiq serialisiert Job Argumente nach JSON. ActiveRecord Objekte werden riesige Datenklumpen. Schlimmer, bis der Job läuft, könnte das Objekt veraltet sein. Übergebe immer IDs und hole frische Daten im Job.

Lektion 2: Jobs sollten schnell einzureihen sein

# SCHLECHT: Langsam einzureihen
def create
  @order = Order.create!(order_params)
  
  # Das trifft die Datenbank 1000 mal beim Einreihen!
  @order.line_items.each do |item|
    UpdateInventoryJob.perform_async(item.id)
  end
end

# GUT: Bulk einreihen
def create
  @order = Order.create!(order_params)
  
  # Einzelner Job der alle Items behandelt
  UpdateOrderInventoryJob.perform_async(@order.id)
end

Lektion 3: Teste deine Jobs

# spec/jobs/send_welcome_email_job_spec.rb
require 'rails_helper'

RSpec.describe SendWelcomeEmailJob, type: :job do
  describe '#perform' do
    let(:user) { create(:user) }

    it 'sendet Willkommens Email' do
      expect {
        described_class.new.perform(user.id)
      }.to change { ActionMailer::Base.deliveries.count }.by(1)
    end

    it 'handhabt fehlenden User gnädig' do
      expect {
        described_class.new.perform(999999)
      }.not_to raise_error
    end

    it 'ist idempotent' do
      described_class.new.perform(user.id)
      
      expect {
        described_class.new.perform(user.id)
      }.not_to change { ActionMailer::Base.deliveries.count }
    end
  end
end

Fazit

Background Workers sind nicht optional für moderne Webanwendungen. Aber sie sind auch eine signifikante Quelle von Komplexität und potenziellen Fehlern. Der Unterschied zwischen einem robusten System und einem Kartenhaus kommt darauf an wie gut du deine Jobs designst.

Denk dran:

Jobs müssen unabhängig sein. Verlasse dich nicht darauf dass andere Jobs vorher gelaufen sind.

Jobs müssen idempotent sein. Zweimal laufen sollte denselben Effekt haben wie einmal laufen.

Jobs müssen Fehler gnädig behandeln. Weil Fehler keine Frage des Ob sind, sondern des Wann.

Queue Design ist wichtig. Trenne kritische Jobs von Massenverarbeitung.

Monitore alles. Du kannst nicht fixen was du nicht sehen kannst.

Ich habe zu viele Anwendungen gesehen die von schlecht designten Background Jobs lahmgelegt wurden. Der Black Friday Ausfall den ich am Anfang erwähnt habe? Er war komplett vermeidbar. Ein paar Guards, einige Retry Limits, und ordentliche Queue Isolation hätten das System am Laufen gehalten.

Lerne diese Lektionen nicht auf die harte Tour. Designe deine Jobs von Anfang an richtig.

Ich betreibe Sidekiq in Produktion seit über einem Jahrzehnt quer durch Anwendungen die Millionen von Jobs pro Tag verarbeiten. Die Patterns in diesem Post sind kampferprobt. Die Horror Stories sind echt.

Brauchst du Hilfe beim Designen einer robusten Background Job Architektur? Oder beim Debuggen mysteriöser Job Fehler? Ich habe alles gesehen und kann dir helfen Systeme zu bauen die oben bleiben wenn Dinge schief gehen. Lass uns quatschen.

Kämpfst du mit Background Job Zuverlässigkeit? Ich kann dir helfen Queues zu designen die gesund bleiben, Jobs die gnädig fehlschlagen, und Monitoring das Probleme fängt bevor deine User es merken. Meld dich.