- Tác giả

- Name
- Nguyễn Đức Xinh
- Ngày xuất bản
- Ngày xuất bản
Generators Và Lazy Evaluation Trong PHP: Xử Lý Dataset Lớn Tiết Kiệm RAM
Ở bài trước về cấu hình PHP-FPM và PHP CLI, chúng ta đã biết memory_limit giới hạn RAM tối đa mà một request/script được phép cấp phát, và việc vượt ngưỡng này sẽ khiến PHP ném lỗi Fatal error: Allowed memory size exhausted. Cách xử lý thông thường của nhiều người khi gặp lỗi này là... tăng memory_limit lên. Nhưng đó chỉ là chữa triệu chứng, không chữa nguyên nhân.
Nguyên nhân thật sự thường nằm ở cách code load dữ liệu: đọc một file CSV 2GB bằng file(), load toàn bộ 500,000 dòng từ database vào một mảng bằng fetchAll(), hay build một mảng kết quả khổng lồ trước khi trả về — tất cả đều buộc PHP phải giữ toàn bộ dữ liệu trong RAM cùng một lúc, bất kể bạn có thực sự cần dùng tất cả cùng lúc hay không.
Generator và lazy evaluation giải quyết đúng vấn đề này: thay vì tính toán/tải hết dữ liệu trước rồi mới xử lý (eager evaluation), generator chỉ tạo ra từng phần tử một, đúng lúc cần dùng, giữ mức sử dụng RAM gần như không đổi bất kể dataset lớn cỡ nào.
Nội dung bài viết:
- Vấn đề của eager evaluation với array truyền thống
yieldhoạt động như thế nào bên dưới- So sánh RAM giữa array và Generator qua ví dụ đo thực tế
yield key => value,yield from,send(),return()- Ứng dụng thực tế: đọc file lớn, stream kết quả database, pipeline xử lý dữ liệu
- Giới hạn của Generator và khi nào không nên dùng
- Kết hợp Generator với
memory_limitđể xử lý dataset lớn an toàn
Vấn Đề Của Eager Evaluation
Xét ví dụ đọc một file log 3GB để đếm số dòng chứa từ khóa "ERROR":
<?php
// Cách làm SAI — load toàn bộ file vào RAM trước khi xử lý
function countErrors(string $path): int
{
$lines = file($path); // toàn bộ 3GB file nằm trong RAM cùng lúc
$count = 0;
foreach ($lines as $line) {
if (str_contains($line, 'ERROR')) {
$count++;
}
}
return $count;
}
Hàm file() đọc toàn bộ nội dung file thành một mảng các dòng, nghĩa là để xử lý file 3GB, PHP cần tối thiểu 3GB+ RAM chỉ để giữ dữ liệu thô, chưa kể overhead của cấu trúc mảng PHP (mỗi phần tử mảng tốn nhiều hơn kích thước chuỗi gốc do cấu trúc zval bên trong). Với memory_limit = 256M như đã cấu hình ở bài trước, script này chết ngay lập tức.
Vấn đề cốt lõi: chúng ta chỉ cần đếm dòng, không cần giữ tất cả dòng trong bộ nhớ cùng lúc. Đây chính là lúc Generator phát huy tác dụng.
Generator Và yield Hoạt Động Như Thế Nào
<?php
function readLines(string $path): Generator
{
$handle = fopen($path, 'r');
while (($line = fgets($handle)) !== false) {
yield $line;
}
fclose($handle);
}
function countErrors(string $path): int
{
$count = 0;
foreach (readLines($path) as $line) {
if (str_contains($line, 'ERROR')) {
$count++;
}
}
return $count;
}
Giải thích cơ chế:
- Khi hàm chứa từ khóa
yield, PHP không thực thi hàm ngay khi gọi như hàm bình thường. Thay vào đó, PHP trả về ngay một đối tượngGenerator(implement interfaceIterator), và code bên trong hàm chỉ chạy khi được yêu cầu (khi vòngforeachgọinext()). - Mỗi lần
yieldđược thực thi, hàm tạm dừng hoàn toàn tại đúng vị trí đó, trả giá trị ra ngoài cho vòng lặp xử lý, và giữ nguyên toàn bộ state (biến cục bộ, vị trí con trỏ file$handle...) cho đến khi được gọi tiếp (next()), lúc đó nó chạy tiếp từ ngay sau điểmyieldvừa rồi — chứ không chạy lại từ đầu hàm. - Kết quả: tại bất kỳ thời điểm nào, RAM chỉ giữ một dòng hiện tại đang xử lý, không phải toàn bộ file. Dù file 3GB hay 300GB, mức RAM sử dụng gần như không đổi.
Đây chính là ý nghĩa của lazy evaluation: giá trị chỉ được tính toán/tải đúng lúc cần dùng (on demand), thay vì tính hết trước (eager).
Đo RAM Thực Tế: Array vs Generator
<?php
function numbersArray(int $count): array
{
$result = [];
for ($i = 0; $i < $count; $i++) {
$result[] = $i * $i;
}
return $result;
}
function numbersGenerator(int $count): Generator
{
for ($i = 0; $i < $count; $i++) {
yield $i * $i;
}
}
// Đo với array
$before = memory_get_usage();
$data = numbersArray(1_000_000);
echo 'Array: ' . round((memory_get_usage() - $before) / 1024 / 1024, 2) . " MB\n";
unset($data);
// Đo với generator
$before = memory_get_usage();
$data = numbersGenerator(1_000_000);
foreach ($data as $n) {
// chỉ giữ 1 giá trị tại một thời điểm
}
echo 'Generator: ' . round((memory_get_usage() - $before) / 1024 / 1024, 2) . " MB\n";
Kết quả thực tế trên PHP 8.3 (con số có thể dao động nhẹ theo phiên bản/hệ điều hành):
Array: ~40.00 MB
Generator: ~0.01 MB
Chênh lệch không phải vài phần trăm mà là hàng nghìn lần. Đây là lý do Generator là công cụ bắt buộc phải biết khi làm việc với dataset lớn trong PHP, thay vì chỉ tăng memory_limit mỗi khi gặp lỗi.
yield key => value
Generator có thể yield cả key lẫn value, tương tự associative array:
<?php
function readCsvAsAssoc(string $path): Generator
{
$handle = fopen($path, 'r');
$header = fgetcsv($handle);
while (($row = fgetcsv($handle)) !== false) {
yield array_combine($header, $row);
}
fclose($handle);
}
foreach (readCsvAsAssoc('users_1_million_rows.csv') as $rowNumber => $user) {
echo "Dòng {$rowNumber}: {$user['email']}\n";
}
foreach tự động dùng key mà Generator yield ra (ở đây PHP tự đánh số 0, 1, 2... theo thứ tự vì code không tự chỉ định key), cho phép giữ nguyên cú pháp quen thuộc trong khi vẫn tiết kiệm RAM tối đa.
yield from: Kết Hợp Nhiều Generator
yield from cho phép một generator "ủy quyền" việc sinh giá trị cho một generator hoặc iterable khác, hữu ích khi build pipeline xử lý nhiều bước:
<?php
function readFile(string $path): Generator
{
$handle = fopen($path, 'r');
while (($line = fgets($handle)) !== false) {
yield rtrim($line);
}
fclose($handle);
}
function filterErrors(iterable $lines): Generator
{
foreach ($lines as $line) {
if (str_contains($line, 'ERROR')) {
yield $line;
}
}
}
function readMultipleLogFiles(array $paths): Generator
{
foreach ($paths as $path) {
yield from readFile($path); // uỷ quyền, không load hết file này trước khi sang file khác
}
}
$errors = filterErrors(readMultipleLogFiles([
'/var/log/app-2026-09-10.log',
'/var/log/app-2026-09-11.log',
'/var/log/app-2026-09-12.log',
]));
foreach ($errors as $errorLine) {
echo $errorLine . "\n";
}
Mỗi hàm (readFile, filterErrors, readMultipleLogFiles) đều lazy — không có bước nào load toàn bộ nhiều file log vào RAM. Dữ liệu chảy qua từng bước như một pipeline: đọc 1 dòng → lọc → trả ra, rồi lặp lại cho dòng tiếp theo. Đây chính là mô hình streaming pipeline, rất phổ biến khi xử lý ETL (Extract-Transform-Load) dữ liệu lớn.
send() Và return(): Giao Tiếp Hai Chiều Với Generator
Generator không chỉ sinh giá trị ra ngoài, mà còn có thể nhận giá trị từ bên ngoài vào ngay tại điểm đang tạm dừng, thông qua send():
<?php
function logger(): Generator
{
while (true) {
$message = yield; // tạm dừng, chờ nhận giá trị qua send()
echo '[LOG] ' . $message . "\n";
}
}
$log = logger();
$log->current(); // bắt buộc gọi 1 lần để generator chạy đến yield đầu tiên
$log->send('Bắt đầu xử lý đơn hàng #1024');
$log->send('Đơn hàng #1024 thanh toán thành công');
Và return() (khác với yield) cho phép generator trả về một giá trị cuối cùng sau khi vòng lặp kết thúc, lấy được qua getReturn():
<?php
function processRows(iterable $rows): Generator
{
$processed = 0;
foreach ($rows as $row) {
yield $row;
$processed++;
}
return $processed; // giá trị trả về sau khi generator "cạn" (exhausted)
}
$gen = processRows($someRows);
foreach ($gen as $row) {
// xử lý từng row
}
echo 'Đã xử lý: ' . $gen->getReturn() . ' dòng';
Ứng Dụng Thực Tế
1. Đọc File Log/CSV Khổng Lồ
Đã minh hoạ ở trên — đây là use case phổ biến nhất, đặc biệt với các job import dữ liệu chạy qua PHP CLI (nơi mà theo bài trước, memory_limit mặc định của CLI tuy là -1 nhưng không nên ỷ lại vào đó cho dataset không kiểm soát được kích thước).
2. Stream Kết Quả Query Database Thay Vì fetchAll()
<?php
function streamUsers(PDO $pdo): Generator
{
$stmt = $pdo->query('SELECT id, email FROM users');
// PDO::FETCH_ASSOC vẫn cần cursor phía driver hỗ trợ unbuffered query
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
yield $row;
}
}
// So với: $pdo->query(...)->fetchAll() sẽ load TOÀN BỘ bảng vào RAM
foreach (streamUsers($pdo) as $user) {
// xử lý từng user, RAM ổn định dù bảng có 10 triệu dòng
}
Lưu ý quan trọng: mặc định PDO với MySQL dùng buffered query, tức là driver MySQL vẫn tải toàn bộ result set về client trước (RAM phía MySQL client library), rồi PHP mới fetch từng dòng. Để thực sự tiết kiệm RAM ở tầng network/driver, cần bật unbuffered query:
<?php
$pdo->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, false);
Đánh đổi: unbuffered query giữ connection "bận" cho đến khi đọc hết result set, không thể chạy query khác trên cùng connection song song — cần cân nhắc theo từng use case.
3. Pipeline Xử Lý Dữ Liệu (ETL Nhẹ)
Kết hợp yield from để xây pipeline nhiều bước (đọc → lọc → transform → ghi) mà không cần dùng đến queue hay framework ETL riêng, phù hợp cho các script xử lý dữ liệu batch chạy qua CLI.
4. Sinh Dữ Liệu Vô Hạn Hoặc Không Xác Định Trước Kích Thước
<?php
function fibonacci(): Generator
{
[$a, $b] = [0, 1];
while (true) {
yield $a;
[$a, $b] = [$b, $a + $b];
}
}
foreach (fibonacci() as $i => $number) {
if ($i >= 10) {
break;
}
echo $number . ' ';
}
Vì Generator chỉ tính giá trị khi được yêu cầu, ta có thể biểu diễn một dãy số vô hạn mà không gây tràn bộ nhớ — điều không thể làm được với array (không thể tạo một mảng có độ dài vô hạn).
Giới Hạn Của Generator Và Khi Nào Không Nên Dùng
Generator không phải giải pháp vạn năng, có những đánh đổi cần biết:
- Chỉ duyệt được một lần: sau khi đã lặp hết (exhausted), không thể
foreachlại từ đầu như array. Muốn lặp lại phải gọi lại hàm tạo generator mới. - Không random access: không thể lấy trực tiếp phần tử thứ 500 như
$array[500], phải lặp tuần tự đến đó. - Không dùng được các hàm array trực tiếp:
array_map(),array_filter(),count()không áp dụng được choGenerator(phải tự viết phiên bản lazy tương đương hoặc dùng thư viện nhưnikic/iter). - Không phù hợp khi cần biết trước tổng số phần tử mà không muốn duyệt hết (ví dụ hiển thị tổng số bản ghi cho phân trang) — trường hợp này vẫn cần một query
COUNT(*)riêng. - Overhead nhỏ cho mỗi lần resume: với dataset nhỏ (vài trăm phần tử) mà không có vấn đề RAM, dùng array thông thường vẫn đơn giản và nhanh hơn do không có chi phí chuyển đổi context của generator.
Quy tắc chọn lựa: dùng Generator khi dataset lớn, không xác định trước kích thước, hoặc đọc từ nguồn I/O (file, DB, API); dùng array khi dataset nhỏ, cần truy cập ngẫu nhiên, hoặc cần dùng nhiều lần.
Kết Hợp Generator Với memory_limit Để Xử Lý An Toàn
Quay lại bài học về memory_limit ở bài trước: Generator không thay thế việc cấu hình memory_limit hợp lý, mà bổ trợ cho nó theo hai hướng:
- Với PHP-FPM (mỗi request có
memory_limitgiới hạn cụ thể, ví dụ256M): dùng Generator giúp một request xử lý export/report với dataset lớn không bao giờ chạm đến ngưỡng đó, thay vì phải tăngmemory_limitlên hàng GB chỉ cho một vài endpoint đặc biệt — điều vừa lãng phí RAM tổng thể của pool FPM, vừa có nguy cơ OOM Killer nếu nhiều request cùng lúc đều nặng. - Với PHP CLI (queue worker, import job chạy nền, thường có
memory_limit = -1hoặc rất cao): Generator giúp job chạy ổn định về RAM theo thời gian, tránh trường hợp job "tưởng chạy được vì không giới hạn RAM" nhưng thực chất đang âm thầm ăn hết RAM server, ảnh hưởng đến các service khác (MySQL, FPM) đang chạy chung máy.
Nói cách khác: memory_limit là lưới an toàn cuối cùng, còn Generator là cách viết code để không bao giờ cần chạm đến lưới an toàn đó.
Best Practices Checklist
- Luôn dùng Generator khi đọc file có kích thước không kiểm soát được (log, CSV export, import dữ liệu người dùng upload).
- Bật
PDO::MYSQL_ATTR_USE_BUFFERED_QUERY = falsekhi cần stream thật sự từ database, nhưng nhớ đánh đổi về việc giữ connection bận. - Dùng
yield fromđể build pipeline nhiều bước thay vì gộp logic vào một hàm khổng lồ. - Không cố dùng Generator cho dataset nhỏ hoặc cần truy cập ngẫu nhiên — array vẫn là lựa chọn đúng trong trường hợp đó.
- Nhớ rằng Generator chỉ duyệt được một lần; nếu cần dùng lại dữ liệu nhiều lần, cân nhắc cache kết quả hoặc tạo lại generator mới.
- Đo RAM thực tế bằng
memory_get_usage()/memory_get_peak_usage()trước và sau khi refactor sang Generator để xác nhận cải thiện, đừng chỉ tin vào lý thuyết.
Kết Luận
Generator và lazy evaluation là một trong những công cụ mạnh nhất nhưng lại ít được tận dụng nhất trong PHP để xử lý dữ liệu lớn. Thay vì phản xạ tăng memory_limit mỗi khi gặp lỗi hết bộ nhớ, hãy tự hỏi: đoạn code này có đang load nhiều dữ liệu hơn mức cần dùng tại một thời điểm hay không? Nếu có, Generator gần như luôn là câu trả lời đúng — giữ RAM ổn định, cho phép xử lý dataset lớn gấp hàng nghìn lần so với giới hạn RAM thực tế của server, và giúp hệ thống PHP-FPM/CLI đã được cấu hình cẩn thận ở bài trước phát huy đúng hiệu quả của nó.
