← 回 knowledge index

Knowledge Mirror

大量資料處理效能優化:從 1100 萬筆事件 Replay 到每秒 45000 筆

來源筆記:大量資料處理效能優化:從 1100 萬筆事件 Replay 到每秒 45000 筆.md

這份筆記整理自 Brent Roose 的文章〈Processing 11 million rows〉:作者在搬遷部落格 analytics 系統時,需要重新 replay 超過 1,100 萬筆事件,並逐步把處理速度從約 30 events/sec 優化到約 45,000 events/sec。 原文:<外部連結

大量資料處理效能優化:從 1100 萬筆事件 Replay 到每秒 45000 筆

#database #performance #event-sourcing #batch-processing #php #mysql #optimization

這是什麼

這份筆記整理自 Brent Roose 的文章〈Processing 11 million rows〉:作者在搬遷部落格 analytics 系統時,需要重新 replay 超過 1,100 萬筆事件,並逐步把處理速度從約 30 events/sec 優化到約 45,000 events/sec

原文:Processing 11 million rows 原文

這份文件不是逐字翻譯,而是把文章中的效能優化知識點整理成可回查、可套用的工程筆記。

核心結論

大量資料處理的效能優化,核心不是單一技巧,而是持續減少幾類成本:

文章中的效能變化大致如下:

| 步驟 | 優化內容 | 效能 |

|---|---|---:|

| Baseline | 原始版本 | 30/sec |

| 1 | 移除 createdAt 排序 | 6,700/sec |

| 2 | 反轉 projector/event loop | 6,800/sec |

| 3 | 避開 ORM | 7,800/sec |

| 4 | 改 while loop、調整 batch size | 8,400/sec |

| 5 | 修正 framework scalar binding overhead | 14,000/sec |

| 6 | Buffered inserts | 19,000/sec |

| 7 | Transaction | 45,000/sec |

背景 / 問題

作者的網站使用 server-side anonymous analytics。每次有人造訪網站時,系統會記錄事件,之後由不同 projectors 產生統計資料,例如:

這是典型 event sourcing 場景:歷史事件完整保存,未來若新增統計方式,只要 replay 舊事件即可重建資料。

問題是五年下來累積超過 1,100 萬筆 visit events。當作者要把舊 Laravel 系統搬到 Tempest 並重建 projectors 時,原始 replay command 每秒只能處理約 30 筆事件。若有多個 projectors,總耗時可能達數十小時。

優化流程

1. 先建立 baseline

效能優化前,先量測原始速度。

原始流程大致是:

foreach ($projectors as $projector) {
    $projector->clear();

    StoredEvent::select()
        ->orderBy('createdAt ASC')
        ->chunk(500, function ($events) use ($projector) {
            foreach ($events as $event) {
                $projector->replay($event);
            }
        });
}

問題包括:

初始吞吐量約:

30 events/sec

重點:不要靠感覺猜瓶頸。先取得 baseline,後續每一步才知道是否真的有效。

2. 移除不必要排序

原本 query 依 createdAt ASC 排序:

->orderBy('createdAt ASC')

但事件本來就是依序寫入資料庫,若沒有額外需求,這個排序可能不必要。尤其當排序欄位沒有 index 時,對大量資料排序非常昂貴。

移除後速度大幅提升:

30/sec → 6,700/sec

經驗:

3. 反轉 loop,避免重複讀資料

原始流程是:

for 每個 projector:
    讀全部 events
    for 每個 event:
        projector replay event

如果有 12 個 projectors,就會把 1,100 萬筆事件讀 12 次。

改成:

讀一批 events
for 每個 projector:
    for 每個 event:
        projector replay event

概念範例:

StoredEvent::select()
    ->chunk(500, function ($events) use ($projectors) {
        foreach ($projectors as $projector) {
            foreach ($events as $event) {
                $projector->replay($event);
            }
        }
    });

單一 projector 的速度提升有限:

6,700/sec → 6,800/sec

但在多 projector 場景,這個改法可以避免大量重複 I/O。

經驗:如果資料量遠大於處理器數量,應優先讓大量資料只被讀一次。

4. 避開 ORM

ORM 對一般開發很方便,但大量批次處理時,每一筆 row 都被轉成 model 會產生明顯成本。

可把 ORM query:

StoredEvent::select()

改為更接近資料庫的 query builder / raw query:

query('stored_events')->select()

概念範例:

query('stored_events')
    ->select()
    ->chunk(1500, function (array $rows) use ($projectors) {
        $events = array_map(function ($row) {
            return $row['eventClass']::unserialize($row['payload']);
        }, $rows);

        foreach ($projectors as $projector) {
            foreach ($events as $event) {
                $projector->replay($event);
            }
        }
    });

速度提升:

6,800/sec → 7,800/sec

經驗:ORM 是方便性工具,不是免費工具。對數百萬筆以上的批次處理,應評估是否改用 raw query / query builder。

5. 自己控制 batch loop,並實測 batch size

Framework 的 chunk() helper 也可能有額外成本。作者改用 while loop 自己控制批次讀取,並測試不同 batch size,最後找到在他的環境中較好的數值。

概念範例:

$offset = 0;
$limit = 1500;

while (true) {
    $rows = query('stored_events')
        ->select()
        ->limit($limit)
        ->offset($offset)
        ->all();

    if ($rows === []) {
        break;
    }

    // process rows...

    $offset += $limit;
}

速度提升:

7,800/sec → 8,400/sec

經驗:

6. 不要迷信直覺優化:serialization 嘗試反而更慢

事件 payload 需要 unserialize。作者猜測若跳過 unserialize、改從原始 visits table 手動建立 event 物件,可能會更快。

實測結果反而更慢。

經驗:

7. 用 profiler 找出真正瓶頸

作者使用 Xdebug profiler 後發現:每處理一批 events,Tempest 的 reflection 相關工具被呼叫大量次數。

原因是 framework 的 database layer 在處理 query binding 時,會檢查每個值是否需要 serializer。這對 object 可能合理,但對 string / number 這類 scalar value 沒必要。

優化方向是先短路處理 scalar value:

if ($value instanceof Query) {
    $value = $value->execute();
} elseif (is_string($value) || is_numeric($value)) {
    // scalar value,直接保留,不進 serializer factory
} elseif ($serializer = $serializerFactory->forValue($value)) {
    $value = $serializer->serialize($value);
}

速度提升:

8,400/sec → 14,000/sec

經驗:

8. 避免大 offset pagination

長時間 replay 時,作者發現一開始速度快,但跑越久越慢。記憶體沒有持續增加,因此不像 memory leak。

問題可能出在 offset pagination:

LIMIT 1500 OFFSET 9000000

資料庫可能仍需掃過前面大量 rows,只是最後丟掉。offset 越大,後面越慢。

改成 keyset pagination / seek pagination:

$lastId = 0;
$limit = 1500;

while (true) {
    $rows = query('stored_events')
        ->select()
        ->where('id > ?', $lastId)
        ->orderBy('id ASC')
        ->limit($limit)
        ->all();

    if ($rows === []) {
        break;
    }

    // process rows...

    $lastId = end($rows)['id'];
}

經驗:

9. Buffered inserts:減少 database round trip

如果 projector 每處理一筆 event 就寫一次資料庫,會產生大量 round trips。

可把寫入先 buffer 起來,等一批處理完再一起送出。

概念 interface:

interface BufferedProjector extends Projector
{
    public function persist(): void;
}

概念 trait:

trait BuffersQueries
{
    private array $queries = [];

    protected function buffer(string $sql): void
    {
        $this->queries[] = $sql;
    }

    public function persist(): void
    {
        if ($this->queries === []) {
            return;
        }

        query()->executeMany($this->queries);

        $this->queries = [];
    }
}

Projector 不立即寫 DB,而是先 buffer:

public function onPageVisited(PageVisited $event): void
{
    $date = $event->visitedAt->format('Y-m-d');

    $this->buffer("
        INSERT INTO visits_per_day (`date`, `count`)
        VALUES ('$date', 1)
        ON DUPLICATE KEY UPDATE `count` = `count` + 1
    ");
}

Replay loop 每批 flush:

foreach ($projectors as $projector) {
    foreach ($events as $event) {
        $projector->replay($event);
    }

    if ($projector instanceof BufferedProjector) {
        $projector->persist();
    }
}

速度提升:

14,000/sec → 19,000/sec

注意:上面的 SQL 是概念範例。實務上應使用 prepared statements、bulk insert builder 或安全的 escaping,避免 SQL injection。

10. Transaction:減少 commit / fsync 成本

最後的巨大提升來自 transaction。

如果沒有明確 transaction,每次 insert/update 都可能成為 implicit transaction。對 InnoDB 這類資料庫來說,頻繁 commit 會導致大量 fsync 成本。

概念上:

20,000 inserts = 20,000 commits = 很多次 fsync

包進 transaction 後:

20,000 inserts = 1 transaction = 1 commit

概念範例:

$database->withinTransaction(function () use ($projectors, $events) {
    foreach ($projectors as $projector) {
        foreach ($events as $event) {
            $projector->replay($event);
        }

        if ($projector instanceof BufferedProjector) {
            $projector->persist();
        }
    }
});

速度提升:

19,000/sec → 45,000/sec

經驗:大量寫入時,transaction 常常是最有力的優化之一。

可套用的優化 checklist

大量資料處理前後,可以用這份 checklist 檢查:

[ ] 有沒有建立 baseline?
[ ] 有沒有每一步記錄吞吐量?
[ ] Query 是否有不必要 ORDER BY?
[ ] ORDER BY 欄位是否有 index?
[ ] 是否用了大 OFFSET?
[ ] 能不能改成 id > last_id?
[ ] 是否重複撈同一批資料?
[ ] 能不能資料讀一次,多個處理器共用?
[ ] ORM hydration 是否是瓶頸?
[ ] Batch size 是否實測過?
[ ] 是否一筆一筆寫 DB?
[ ] 能不能 buffer writes?
[ ] 能不能 bulk insert / bulk update?
[ ] 是否包 transaction?
[ ] 有沒有用 profiler 找真正瓶頸?

常見踩坑

不必要排序

大量資料上的未索引排序,可能是最昂貴的操作之一。若資料本身已按寫入順序排列,先確認排序是否真的必要。

Offset pagination

OFFSET 在後期會越來越慢。大量掃描應優先用 keyset pagination:

WHERE id > :last_id
ORDER BY id ASC
LIMIT :limit

ORM per-row overhead

ORM 會做 hydration、mapping、型別處理、可能還有 event hooks。對一般 CRUD 很方便,但在千萬級資料處理中可能成為熱路徑成本。

小 query 太多

即使單次 query 很快,幾百萬次 round trip 也會非常慢。應考慮 buffer、bulk execute、bulk insert、bulk update。

Commit 太頻繁

大量 insert/update 若沒有 transaction,可能會被 implicit commit 拖垮。明確 transaction 可以大幅降低 fsync 成本。

沒有 profiler

直覺只能找到一部分問題。Framework 內部的 reflection、serializer lookup、binding handling 等熱路徑瓶頸,通常需要 profiler 才看得到。

工程思維

每次只改一件事

效能優化要能歸因。一次改太多,速度變快也不知道是哪個改動有效;速度變慢也不知道是哪個改動造成問題。

先解最大瓶頸

移除排序從 30/sec 到 6,700/sec,比許多微優化有效得多。先找高成本操作,再處理細節。

不要過度抽象

大量資料處理通常是熱路徑。熱路徑上要盡量簡單、直接、可預測。

對方便性工具保持警覺

ORM、chunk helper、serializer factory、reflection 都可能在大量資料處理中變成成本。不是不能用,而是要知道何時該繞過。

寫入優化常比讀取優化更關鍵

讀取可以透過 index、分批、減少排序改善;寫入則要注意 round trip、bulk operation、transaction、lock contention、index 更新成本。

延伸應用

這些原則不只適用 PHP / MySQL,也適用於多數大量資料處理場景:

可概括為一句話:

大量資料處理要減少排序、減少抽象層、減少 round trip、減少 commit、避免大 offset,並用 profiler 驗證每一步。