環境

  • Laravel 8.34.0
  • mysql 8.0.23 (docker)

前置作業

建立 1 個資料表後
想辦法塞假資料
我自己是透過 factory 方式實作 artisan cli 命令
然後開多個 cli 背景程序來產生大量資料 本次測試數據量

  • users : 1,079,751
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
CREATE TABLE `users` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT,
  `name` varchar(255) NOT NULL,
  `email` varchar(255)  NOT NULL,
  `email_verified_at` timestamp NULL DEFAULT NULL,
  `password` varchar(255) NOT NULL,
  `remember_token` varchar(100) DEFAULT NULL,
  `created_at` timestamp NULL DEFAULT NULL,
  `updated_at` timestamp NULL DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `users_email_unique` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci

簡單的 csv 下載功能

1
Route::get('download', [ExampleController::class, 'download']);
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
public function download()
{
    $headers = [
        'Content-Encoding: UTF-8',
        'Content-Type' => 'text/csv',
    ]; // 可有可無

    return response()->streamDownload(function () {
        echo "\xEF\xBB\xBF"; // UTF8 BOM

        // Header
        echo "欄位1,欄位2,欄位3\n";

        // Data
        echo "11,22,33\n";
        echo "11,22,33\n";
    }, 'example.csv', $headers);
}
  • streamDownload 方法參考
  • line 9 : csv 的 utf-8 bom ,可以讓你使用 excel 開起 csv 時,中文不會出現亂碼
  • Header 和 Data 應該不用多作說明吧,聰明的你還會知道這邊可以做成 Service,之後可以重複用此下載小功能

撈取 DB 大量 User 資料並匯出

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public function download()
{
    return response()->streamDownload(function () {
        // Data
        $this->getData();
    }, 'example.csv');
}

protected function getData()
{
    // 方法一
    User::query()
    // ->select('id', 'name', 'email')
    ->chunk(100000, function ($users) {
        foreach ($users as $user) {
            echo implode(',', $user->toArray()) . "\n";
        }
    });

    // 方法二
    DB::table('users')
    // ->select('id', 'name', 'email')
    ->orderBy('id', 'desc')
    ->chunk(100000, function ($rows) {
        foreach ($rows as $row) {
            echo implode(',', (array) $row) . "\n";
        }
    });
}

上面兩種方法都可以
但是!!! 下面的 DB::table(‘users’) 速度比較快
可能 User::chunk 因為處理 Model 物件轉換的關係浪費了多餘的時間
下面 chunk 後直接得到 collection 後可以馬上進行處理
上面執行有可能會出現 php timeout

  • 這邊就直接做 User 全部欄位一次匯出,來測試極限,當然你也可以透過 select(...) 限制撈取欄位,每次撈取的欄位變少了,記憶體負擔單也跟著會變小
  • 使用 chunk 分次執行 sql 查詢,避免超過 php 記憶體限制 (128M)
  • 為什麼 chunk 會使用 100,000 這個數值 ? 這是不斷 Try 出來的最合適值,你可能也會依據每個不同撈取狀況來做調整,chunk 越大表示 sql 執行回傳的資料量越大,你就需要確保當前有足夠的記憶體承載,否則就會直接接噴 PHP Fatal error: Allowed memory size of 134217728 bytes 的錯誤,當然我們也可以改 php.ini 來調整記憶體使用上限,但不建議改太大,如果有其他程序這時也要使用大記憶體,可能會發生未預期的系統錯誤。
  • 如果 chunk 使用太小比如 500,這會導致你需要花更多時間去執行 sql 指令撈回資料,最後會因為撈資料撈太久 php 直接噴 timeout 錯誤
  • 最後成功匯出 User 100 萬筆的全部欄位資料,檔案大小為 282M,處理平均時間約 3 ~ 5 秒內。

總結

可以發現在大資料查詢匯出時
php-fpm 程序預設記憶體為 128M 的狀況下
分次查詢的 chunk 功能扮演非常重要的角色
開發人員需要確認當下撈取的資料量來決定 chunk 大小
在有限的記憶體和處理時間下得到最大效益

不過接下來有趣問題來了…
你某一天收到客戶說想要匯出超大查詢資料 (GB級)
這時你發現即便你怎麼 chunk 都會超過伺服器回應時間



聰明的你一定想到了還有其他解法
就讓我們下期待續

延伸問題

  1. 如果換成匯入,該怎麼實做 ?
  2. 能不能知道每次 chunk 執行查詢,撈出的資料大小多少,是否在 128M 以內 ?
  3. 還有哪邊可以優化 ? 節省記憶體,加快處理速度 ?
  4. 如果不使用 laravel,改用純 php,該怎麼實作 ?

有任何想法
歡迎在下方留言討論