PHPlaravelphpsymfony

Mô hình bộ nhớ mà mọi lập trình viên PHP nên nắm khi làm việc với tiến trình dài hạn

Nếu bạn chuyển từ mô hình PHP-FPM truyền thống sang các worker chạy liên tục như RoadRunner, Laravel Queue worker, Symfony Messenger consumer, hoặc các daemon tùy chỉnh, bạn sẽ sớm nhận ra một hiện tượng quen thuộc:

Dong Nguyen

February 26, 2026

Mô hình bộ nhớ mà mọi lập trình viên PHP nên nắm khi làm việc với tiến trình dài hạn

Nếu bạn chuyển từ mô hình PHP-FPM truyền thống sang các worker chạy liên tục như RoadRunner, Laravel Queue worker, Symfony Messenger consumer, hoặc các daemon tùy chỉnh, bạn sẽ sớm nhận ra một hiện tượng quen thuộc:

Bộ nhớ chỉ tăng, không bao giờ giảm.

Worker khởi động với ~40–60 MB. Xử lý một job nặng → nhảy vọt lên 180 MB. Xử lý batch lớn tiếp theo → lên 320 MB. Sau đó… nó cứ ở mức 320 MB mãi mãi, cho đến khi restart.

Bạn kiểm tra leak, không thấy. Chạy gc_collect_cycles(), vô ích. Thêm unset() khắp nơi, vẫn không thay đổi. Đây không phải lỗi — đây là đặc trưng của PHP trong môi trường dài hạn. Hiểu rõ nó sẽ thay đổi hoàn toàn cách bạn thiết kế ứng dụng.

Tại sao hiện tượng này xảy ra? Bản chất của PHP

PHP được sinh ra để xử lý request ngắn: nhận yêu cầu → xử lý → trả response → chết. Toàn bộ bộ nhớ được giải phóng tự động khi process kết thúc. Không cần lo lắng gì nhiều.

Zend Memory Manager (ZMM) — bộ quản lý bộ nhớ của PHP — được tối ưu cho kịch bản này:

  • Phân bổ nhanh trong lúc xử lý.
  • Không tốn chi phí dọn dẹp khi process chết.
  • Thời gian sống của biến rất ngắn (millisecond).

Nhưng khi dùng PHP cho worker dài hạn (hàng giờ, hàng ngày), đặc trưng này lộ rõ.

Cách ZMM hoạt động thực tế: Hệ thống chunk

PHP không gọi malloc()/free() trực tiếp từ OS mỗi lần. Thay vào đó, ZMM xin OS cấp các khối lớn gọi là chunk (thường 2–4 MB).

Khi bạn tạo string, array, object… PHP lấy không gian từ chunk đã có, chứ không xin OS liên tục.

Hình dung:

  • OS: kho lớn, cho thuê nguyên tầng.
  • Chunk: tầng đã thuê.
  • Biến của bạn: bàn ghế trên tầng đó.

Khi bạn unset() hoặc biến hết scope:

  1. PHP đánh dấu vùng nhớ đó là trống trong cấu trúc nội bộ.
  2. Vùng nhớ quay về pool miễn phí của PHP.
  3. OS vẫn thấy process đang giữ nguyên chunk đó.
  4. Chunk không được trả lại OS (trừ trường hợp cực kỳ hiếm).

Lý do không trả:

  • Xin/trả bộ nhớ OS tốn kém về CPU.
  • Fragmentation (phân mảnh) khiến chunk khó trống 100%.
  • PHP không có cơ chế tự động thu hẹp chunk.
  • Giả định: bạn sẽ cần dùng lại bộ nhớ đó sớm.

Kết quả: mỗi lần spike bộ nhớ → trở thành baseline mới mãi mãi.

Đồ thị bộ nhớ điển hình (staircase pattern)

Bộ nhớ
500MB ┤                   ┌──────────────
      │                   │
400MB ┤             ┌─────┘
      │             │
300MB ┤       ┌─────┘
      │       │
100MB ┤───────┘
      └───────────────────────────────────> Thời gian

Mỗi lần xử lý nặng → nhảy bậc thang lên, và ở đó vĩnh viễn.

Garbage collection của PHP không giải quyết được vấn đề này

PHP dùng reference counting (đếm tham chiếu):

$data = tạoMảngLớn();     // refcount = 1
$copy = $data;            // refcount = 2
unset($data);             // refcount = 1
unset($copy);             // refcount = 0 → giải phóng ngay

Rất hiệu quả cho hầu hết trường hợp.

Còn cycle collector xử lý vòng tham chiếu:

$a = []; $b = [];
$a['link'] = &$b; $b['link'] = &$a;
unset($a, $b); // Cần cycle collector

Bạn có thể gọi gc_collect_cycles() hoặc gc_mem_caches() — nhưng chúng chỉ dọn trong PHP, hiếm khi trả chunk về OS vì phân mảnh.

Các vùng code dễ gây spike vĩnh viễn

  • Load toàn bộ collection từ ORM:
    $orders = Order::with(['customer', 'items.product'])->get(); // 50k bản ghi → spike lớn
  • Đọc file lớn một lần:
    $content = file_get_contents('big_report.csv');
  • Tích lũy mảng kết quả:
    $reports = [];
    foreach ($items as $item) {
        $reports[] = xửLýNặng($item);
    }

Mỗi lần như vậy → spike → baseline mới. Lặp lại vài lần → worker từ 60 MB thành 400–500 MB.

Cách thiết kế để sống chung với mô hình này

  1. Ưu tiên stream / xử lý theo lô thay vì load hết

    Laravel:

    User::where('status', 'active')->lazy()->each(function ($user) {
        xửLýUser($user); // Bộ nhớ gần như phẳng
    });

    Hoặc chunk:

    Order::pending()->chunk(200, function ($orders) {
        foreach ($orders as $order) {
            xửLý($order);
        }
    });

    Doctrine:

    $query = $em->createQuery('SELECT o FROM Order o');
    foreach ($query->toIterable() as $order) {
        xửLý($order);
        $em->detach($order); // Rất quan trọng!
    }
  2. Tận dụng scope hàm để tự động dọn

    public function xửJob($job)
    {
        $this->xửLýDữLiệuNặng($job);
        // Khi hàm kết thúc → tất cả biến cục bộ tự động giải phóng
    }
    
    private function xửLýDữLiệuNặng($job)
    {
        $data = lấyDữLiệuTo(); // Spike 200 MB
        biếnĐổi($data);
        lưu($data);
        // Ra khỏi hàm → spike biến mất (trong PHP), chunk vẫn ở đó nhưng ít nhất không tích lũy thêm
    }
  3. Chấp nhận và dùng worker rotation

    Đây không phải “vá víu”, mà là giải pháp chuẩn.

    • RoadRunner: giới hạn số job trước khi restart worker.
      pool:
        max_jobs: 2000   # Worker mới sau 2000 job
    • Laravel Queue:
      php artisan queue:work --max-jobs=1500 --memory=512
    • Symfony Messenger:
      php bin/console messenger:consume async --limit=1500

    Rotation reset bộ nhớ về mức khởi đầu, giữ worker ổn định.

  4. Tránh leak thật sự (khác với pattern)

    Leak thật: trạng thái tĩnh/global tăng vô hạn.

    PHP

    class Logger
    {
        private static array $logs = [];
        public static function add($msg) { self::$logs[] = $msg; } // Tích lũy mãi
    }

    Tốt:

    class BoundedLogger
    {
        private array $logs = [];
        private int $max = 500;
    
        public function add($msg)
        {
            if (count($this->logs) >= $this->max) {
                array_shift($this->logs);
            }
            $this->logs[] = $msg;
        }
    }

Bài học cốt lõi

  • Chấp nhận pattern: Spike bộ nhớ là vĩnh viễn cho đến khi restart.
  • Thiết kế theo peak memory: Worker sẽ dùng mức cao nhất nó từng đạt.
  • Giữ spike nhỏ: Stream, batch, scope isolation.
  • Rotation là bạn: Không phải lỗi, mà là cách PHP hoạt động.
  • Theo dõi chặt: Phát hiện spike bất thường sớm.
  • Phân biệt pattern vs leak thật: Pattern là bình thường, leak là trạng thái tăng không kiểm soát.

Hiểu và làm chủ pattern này giúp bạn viết worker PHP dài hạn ổn định, tiết kiệm tài nguyên và ít debug đau đầu hơn. Từ “sao bộ nhớ cao thế?” chuyển sang “peak của job này là bao nhiêu? → rotate khi nào?”.

Support My Work

If you found this article helpful, consider supporting my work. It helps me keep creating free content for the developer community.

Buy Me a Coffee

Need Help With Your Project?

I'm available for freelance work. Whether you need a full-stack application, API development, or technical consulting, I'd love to help.

View My Services