iT邦幫忙

2026 iThome 鐵人賽

DAY 13
1
Software Development

一套真實運作中的 Laravel 系統,拆解它的原生機制系列 第 13

Day 13:Queue Job 基礎——用 Generator 安全迭代日期區間去抓外部資料

  • 分享至 

  • xImage
  •  

前言:抓一整年的資料,跟抓一天的資料,用的應該是同一支 Job 嗎?

「這支 Job 要去外部系統抓新聞,抓一天的資料很快,但如果哪天要補抓過去一整年的資料呢?」

這是設計一支「定期去外部系統抓資料」的 Job 時,很容易漏想的問題。如果 Job 的寫法是「把整個日期區間的資料全部抓進記憶體,再一次寫進資料庫」,抓一天沒事,抓一年可能直接把記憶體吃爆。今天要看一支真實的 Job,怎麼用 Generator 把這個問題解決掉,順便處理另一個更現實的問題:同一筆資料,會不會被重複抓兩次。

今日目標

  • 理解為什麼「把整個區間資料一次載入記憶體」在 Job 裡是危險的寫法
  • 看一個真實案例:Generator + LazyCollection 怎麼組合出記憶體安全的迭代
  • 認識用資料庫唯一鍵擋重複寫入,跟「先查詢再新增」比起來的取捨
  • 建立「Job 要處理的資料量沒有上限時,記憶體用量要主動設計」的意識

本文主體

問題:一支要抓「一段時間」資料的 Job,該怎麼寫

這支 Job 的任務是:從外部系統抓新聞資料,寫進站內的 News 資料表。它的建構子接受一個起始日期跟結束日期,代表「幫我抓這段時間的所有新聞」:

public function __construct(?Carbon $start = null, ?Carbon $end = null)
{
    $this->start = ($start ?? new Carbon)->startOfDay();
    $this->end = ($end ?? new Carbon)->endOfDay();
}

多數情況下,這段區間就是「今天一天」,但這支 Job 沒有把「只能抓一天」寫死——它被設計成可以接受任意長度的區間,代表哪天需要補抓過去半年的資料,直接呼叫同一支 Job 就行,不用另外寫一支「補資料專用」的腳本。

問題來了:如果區間是半年,代表這支 Job 要迭代 180 幾天,每天再去外部系統抓一批資料。如果寫法是「把 180 天份的資料全部抓完、存進一個陣列,再一次寫進資料庫」,記憶體用量會隨著區間長度線性成長——區間越長,越容易在還沒寫進資料庫前就把記憶體吃爆。

解法:用 Generator 一天一天吐出時間戳,不預先展開整個區間

private function dateRange(): Generator
{
    $diff = $this->end->getTimestamp() - $this->start->getTimestamp();
    $offset = 0;
    while ($offset < $diff) {
        yield $this->start->copy()->addSeconds($offset);
        $offset += 86400;
    }
}

dateRange() 沒有回傳一個裝滿所有日期的陣列,而是用 yield 一天一天「吐」出時間戳——呼叫端每要一個值,這個方法才往下算一步,不會一次把整個區間的日期全部算出來擺著。

外層再包一層 LazyCollection

LazyCollection::make(function () use ($crawler) {
    foreach ($this->dateRange() as $date) {
        foreach ($crawler->fetchNews($date) as $news) {
            yield $news;
        }
    }
})->each(function (array $data) use ($assignerLookup) {
    // 處理單筆資料、寫進資料庫
});

這裡疊了兩層 Generator:外層迭代日期、內層($crawler->fetchNews())迭代單一天裡的分頁資料。整條鏈路上沒有任何一個地方,把「全部資料」這個概念具體化成一個陣列——each() 拿到的永遠是「下一筆」,處理完就丟掉,不會累積在記憶體裡。這正是 LazyCollection 存在的意義:跟一般的 Collection 用起來語法幾乎一樣,但底層是惰性求值,只有真的走到 each()map() 這一步時才會去要下一筆資料。

另一個問題:同一筆資料會不會抓兩次

如果這支 Job 因為某種原因被重複觸發(例如排程重疊、手動重跑),同一筆新聞資料可能會被抓兩次。一般直覺的寫法是「先查詢這筆資料存不存在,不存在才新增」,但這支 Job 選了另一條路:

try {
    $news = News::create($attributes);
    // ...
} catch (UniqueConstraintViolationException $e) {
}

直接嘗試新增,如果資料庫的唯一鍵擋下了重複寫入,就吃掉這個例外,什麼都不做。這個寫法的好處是少一次查詢——不用先 where(...)->exists() 再決定要不要新增,直接讓資料庫的唯一鍵約束去把關。

但這個寫法也有代價:例外被整個吞掉、連一行 log 都沒留。如果重複寫入的頻率遠高於預期,這支 Job 不會有任何提示——它只會安靜地「什麼都沒發生」。這是一個值得在自己的專案裡多想一步的取捨:用資料庫約束擋重複確實比較省一次查詢,但如果連基本的次數統計都不留,這個約束真正被觸發的頻率、代表的意義,會完全沒有可見度。

對照:兩種處理重複資料的方式

先查詢再決定要不要新增

if (! News::where('foreign_id', $data['url'])->exists()) {
    News::create($attributes);
}
// 多一次查詢,但至少邏輯清楚可追蹤

交給資料庫唯一鍵把關,但至少留下痕跡

try {
    News::create($attributes);
} catch (UniqueConstraintViolationException $e) {
    // 至少加一行:Log::debug('重複資料被擋下', ['url' => $data['url']]);
}

真實程式碼裡用的是前者少一次查詢的寫法,但完全沒有 catch 區塊裡的痕跡記錄——這是可以拿來檢視自己專案的一個具體問題:省一次查詢跟保留可觀測性,中間有沒有必要二選一?

今日思考題

你的專案裡有沒有一支 Job 或指令,處理的資料量理論上沒有上限?回想一下它的寫法,是「一次載入全部再處理」,還是「邊迭代邊處理」?如果换成處理十倍、百倍的資料量,它還撐得住嗎?

今日重點回顧

  • 一支要處理「不確定長度區間」的 Job,記憶體用量要主動設計,不能假設輸入量永遠很小
  • Generator + yield 讓迭代邏輯不用預先把整個結果集展開成陣列
  • LazyCollection 讓這種惰性迭代可以用跟一般 Collection 幾乎一樣的語法操作
  • 用資料庫唯一鍵擋重複寫入,比先查詢再新增少一次查詢,但代價是要自己決定要不要留下可觀測的痕跡

明日預告

明天要對照兩支長度差很多的 Job——一支 16 行、一支超過 120 行,看「一支 Job 該做多少事」這個問題,在真實程式碼裡長什麼樣子。


上一篇
Day 12:一個 Content-Type 的 bug——sitemap.xml 為什麼 Google 解析不了
下一篇
Day 14:一支 Job 該做多少事?從 16 行 vs 120 行的 Job 看職責邊界
系列文
一套真實運作中的 Laravel 系統,拆解它的原生機制14
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言