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
Ruby Lazy Enumerators: Verwerk Miljoenen Rijen Zonder Geheugenexplosie

Ruby Lazy Enumerators: Verwerk Miljoenen Rijen Zonder Geheugenexplosie

TTB Software
ruby
Leer hoe Ruby's Lazy Enumerators enorme datasets regel voor regel verwerken met stabiel geheugengebruik. Inclusief benchmarks, productiepatronen en veelgemaakte fouten.

Ruby’s Lazy enumerator laat je bewerkingen op collecties koppelen zonder alles tegelijk in het geheugen te laden. In plaats van bij elke stap tussenliggende arrays op te bouwen, verwerkt lazy evaluatie elementen één voor één door de hele keten.

Als je ooit een Sidekiq worker hebt laten crashen door .map.select.map aan te roepen op een CSV van 2 miljoen rijen, dan is dit de oplossing.

Het Probleem met Eager Evaluatie

Een typische dataverwerkingspipeline:

File.readlines("transactions.csv")    # Array van 2M strings in geheugen
  .map { |line| line.split(",") }      # Tweede array van 2M arrays
  .select { |row| row[3].to_f > 100 } # Derde array (subset)
  .first(50)                           # Je had er maar 50 nodig

Dit creëert drie volledige tussenliggende arrays voordat bijna alles wordt weggegooid. Bij een bestand met 2 miljoen regels kijk je naar zo’n 800MB+ heapgebruik voor iets dat kilobytes zou moeten kosten.

Hoe Lazy Dit Oplost

File.foreach("transactions.csv")  # Geeft een Enumerator terug (geen array)
  .lazy                            # Wraps in Enumerator::Lazy
  .map { |line| line.split(",") }  # Uitgesteld — er gebeurt nog niks
  .select { |row| row[3].to_f > 100 }
  .first(50)                       # NU verwerkt het, regel voor regel

Met .lazy verwerkt Ruby elke regel door de gehele keten voordat het naar de volgende gaat. Zodra first(50) 50 overeenkomende rijen heeft verzameld, stopt het met het lezen van het bestand. Het geheugengebruik blijft stabiel, ongeacht de bestandsgrootte.

Echte Benchmarks

Getest op Ruby 3.3.0 met een 500MB CSV-bestand (4,2 miljoen rijen) op een machine met 2GB beschikbaar RAM:

Eager (.readlines.map.select):
  Geheugenpiek: 1.847 MB
  Tijd: 38,2 seconden
  Resultaat: Gekilld door OOM op kleinere instances

Lazy (File.foreach.lazy.map.select):
  Geheugenpiek: 12 MB
  Tijd: 14,7 seconden (stopte vroeg na het vinden van matches)

De lazy versie gebruikte 150x minder geheugen en was sneller klaar omdat het niet het hele bestand hoefde te verwerken.

Custom Enumerators Bouwen

Enumerator.new laat je lazy-compatibele streams maken van elke databron:

def paginated_api_results(endpoint)
  Enumerator.new do |yielder|
    page = 1
    loop do
      response = HTTP.get("#{endpoint}?page=#{page}&per_page=100")
      results = JSON.parse(response.body)
      break if results.empty?

      results.each { |record| yielder.yield(record) }
      page += 1
    end
  end
end

# Chain lazy bewerkingen op API-resultaten
paginated_api_results("https://api.example.com/users")
  .lazy
  .select { |user| user["active"] }
  .map { |user| user["email"] }
  .first(200)

Dit haalt pagina’s op aanvraag op. Als de eerste twee pagina’s 200 actieve gebruikers bevatten, wordt pagina 3 nooit opgevraagd.

Wanneer Lazy Enumerators Prestaties Verslechteren

Lazy is niet altijd sneller. Voor kleine collecties kost de overhead van de lazy wrapper meer dan het bespaart:

# Kleine array — eager is sneller
(1..100).map { |n| n * 2 }.select(&:even?).first(10)
# ~0,003ms

# Lazy voegt hier overhead toe
(1..100).lazy.map { |n| n * 2 }.select(&:even?).first(10)
# ~0,008ms

Gebruik lazy wanneer:

  • De bron groot of onbegrensd is (bestanden, API-paginering, database cursors)
  • Je slechts een subset van resultaten nodig hebt (first, take, find)
  • Je keten dure tussenliggende collecties creëert
  • Geheugen belangrijker is dan ruwe snelheid

Sla lazy over wanneer:

  • De collectie minder dan ~10.000 elementen heeft
  • Je toch alle resultaten nodig hebt
  • Je .to_a aan het einde aanroept (maakt het doel ongedaan)

Lazy Combineren met each_slice voor Batchverwerking

Een veelgebruikt productiepatroon voor het verwerken van grote datasets in batches:

File.foreach("imports/products.jsonl")
  .lazy
  .map { |line| JSON.parse(line) }
  .select { |product| product["price"].positive? }
  .each_slice(500) do |batch|
    Product.upsert_all(
      batch.map { |p| { sku: p["sku"], name: p["name"], price: p["price"] } },
      unique_by: :sku
    )
  end

De each_slice aanroep is het enige punt waar data zich ophoopt, en het is begrensd tot 500 records.

Let Op: Lazy en Bijwerkingen

Omdat lazy enumerators de uitvoering uitstellen, gebeuren bijwerkingen in je keten pas wanneer iets evaluatie afdwingt:

results = (1..10).lazy.map { |n|
  puts "Verwerken #{n}"  # Dit print nog niet
  n * 2
}

# Er is niets geprint. results is een niet-geëvalueerde Enumerator::Lazy.

results.first(3)
# NU print het "Verwerken 1", "Verwerken 2", "Verwerken 3"

Productiepatroon: Streaming CSV Rapporten in Rails

Een patroon dat goed werkt in Rails applicaties met achtergrondtaken:

class LargeReportJob < ApplicationJob
  def perform(report_id)
    report = Report.find(report_id)

    Tempfile.create(["report", ".csv"]) do |tmp|
      csv = CSV.new(tmp)
      csv << ["ID", "Name", "Amount", "Date"]

      report.line_items_query
        .find_each(batch_size: 2000)
        .lazy
        .map { |item| [item.id, item.name, item.amount, item.created_at.iso8601] }
        .each { |row| csv << row }

      tmp.rewind
      report.file.attach(io: tmp, filename: "report-#{report.id}.csv")
    end

    report.update!(status: :completed)
  end
end

find_each verwerkt records al in batches vanuit de database. Door er .lazy bovenop te zetten, bouwt de .map transformatie geen tussenliggende array van alle getransformeerde rijen. Voor een rapport met 500K rijen blijft het geheugengebruik onder de 50MB.

Ruby 3.3+ Verbeteringen

Ruby 3.3 introduceerde optimalisaties voor Enumerator::Lazy die de overhead per element met zo’n 15-20% verminderden ten opzichte van Ruby 3.1. De YJIT compiler verwerkt lazy enumerator dispatch ook beter in Ruby 3.3.

FAQ

Hoe werkt Lazy samen met Enumerable#chunk en chunk_while?

Beide werken met lazy enumerators in Ruby 3.x. Ze verwerken elementen één voor één en leveren chunks op zodra die compleet zijn. Een kanttekening: chunk moet opeenvolgende elementen zien om groepsgrenzen te bepalen, dus het buffert de huidige chunk in het geheugen.

Kan ik lazy enumerators gebruiken met ActiveRecord relaties?

Niet direct — ActiveRecord relaties zijn al lazy in de SQL-zin. Maar je kunt find_each of in_batches combineren met .lazy voor de Ruby-kant van de verwerkingsketen. Roep .lazy niet aan op een relatie zelf; roep het aan op de enumerator die find_each teruggeeft.

Wat is het verschil tussen Enumerator::Lazy en Fiber?

Beide maken verwerking op aanvraag mogelijk, maar ze lossen verschillende problemen op. Lazy is voor het transformeren van collectie-pipelines zonder tussenliggende arrays. Fiber is voor coöperatieve concurrency. Je kunt lazy-achtig gedrag bouwen met Fibers (en intern gebruikt Ruby’s Enumerator Fiber), maar Lazy biedt een schonere API voor data-pipeline use cases. Voor async I/O, kijk naar Ruby’s Fiber Scheduler.

Werkt .lazy met oneindige reeksen?

Ja — dit is een van de beste use cases. (1..Float::INFINITY).lazy.select(&:odd?).first(100) werkt perfect en retourneert direct. Zonder .lazy zou Ruby proberen een oneindige array te bouwen.

Moet ik lazy gebruiken in Rails controller acties?

Over het algemeen niet. Controller acties moeten snel responses retourneren, en lazy enumerators voegen per-element overhead toe die niet de moeite waard is voor de kleine datasets die typisch zijn bij webresponses. Gebruik lazy in achtergrondtaken, rake tasks en data import/export scripts.

#ruby #performance #enumerators #lazy-evaluation #geheugen-optimalisatie
T

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