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