2026年9月14日 星期一

高流量媒體系統的 Laravel 實戰課題:從 Legacy 重構到金流、快取與 AI 開發

 高流量媒體系統的 Laravel 實戰課題:從 Legacy 重構到金流、快取與 AI 開發(進階架構版)

在內容媒體與訂閱平台的開發中,後端團隊通常面對的是「在營運不中斷的前提下,邊換輪胎邊開車」的架構演進課題。

以媒體事業群的數位系統為例,常見的技術痛點包含:新舊系統並存時的 Session 與驗證同步、流量爆發時的快取防線、訂閱金流的絕不重扣與狀態衝突、千萬級巨量資料庫效能瓶頸、多節點零停機部署,以及導入 AI 工具時的程式碼資安防線。

本文拆解這六大核心場景,提供實務上的系統架構設計、程式碼實作與落地解法。

一、 Legacy Code 重構:新舊系統共存與 SSO / Session 一致性

1. 舊系統痛點與架構解耦

舊版 CodeIgniter (CI3) 系統常見的問題在於 Fat Controller原生 SQL 散落同步 Blocking 第三方呼叫。重構的第一步是透過 Service / Repository Pattern 將業務邏輯與資料存取抽離,並以抽象層封裝外部 HTTP 呼叫。

2. CI 與 Laravel 雙系統共存的 SSO / Session 挑戰

在逐步重構(Strangler Fig Pattern)的過渡期,使用者會在舊 CI 頁面與新 Laravel 頁面之間無縫切換。此時最大的挑戰是:如何維持單一登入(SSO)與 Session 狀態一致性?

┌────────────────────────────────────────────────────────┐
│                     Client Browser                     │
└───────────┬────────────────────────────────┬───────────┘
            │ Request (Cookie: app_session)  │ Request (Cookie: app_session)
            ▼                                ▼
┌───────────────────────┐        ┌───────────────────────┐
│ CodeIgniter 3 (舊系統) │        │   Laravel 11 (新系統) │
└───────────┬───────────┘        └───────────┬───────────┘
            │                                │
            │   ┌────────────────────────┐   │
            └──►│ Central Redis Session  │◄──┘
                │ Store (JSON / Custom)  │
                └────────────────────────┘

實務解法:中央化 Redis Session 與客製化 Handler

  1. 共享 Cookie 網域:將 Session Cookie 設定在頂層網域(如 .yourdomain.com),確保兩邊都能讀取相同的 Cookie Key。

  2. Session 序列化格式統一:CI3 預設使用 PHP 原生 serialize,而 Laravel 預設使用自己的 Encrypter 與 Serializer。解法是建立統一的 Redis Session Handler

    • 方案 A(推薦):在 Laravel 端建立客製化的 Session Driver,讓它解讀 CI3 儲存於 Redis 中的 JSON 格式 Session 資料。

    • 方案 B(JWT / Central SSO):引入輕量級 JWT 簽章機制。當使用者在舊系統登入後,核發包含 user_id 的 Signed Cookie,Laravel 與 CI3 分別建立 Middleware 驗證該 Token。

PHP
// Laravel 端的 Custom Session Handler 示意
namespace App\Extensions;

use Illuminate\Support\Facades\Redis;

class SharedCiSessionHandler implements \SessionHandlerInterface
{
    public function read($sessionId): string|false
    {
        // 讀取 CI3 寫入 Redis 的 Session 格式
        $data = Redis::connection('session')->get("ci_session:" . $sessionId);
        if (!$data) return '';
        
        $unserialized = unserialize($data);
        // 轉換為 Laravel 可識別的 Session 結構
        return json_encode([
            'auth_user_id' => $unserialized['user_id'] ?? null,
            'user_role'    => $unserialized['role'] ?? 'guest',
        ]);
    }
    // ... 實作其餘 SessionHandler 介面方法
}
🧠 思考題 1.1
當使用者在舊 CI 系統點擊「登出」時,如何確保 Laravel 系統的 Session 即時失效,且不會產生競態條件(Race Condition)?

二、 高流量文章頁的多層快取與 Edge 防禦

當重大新聞爆發時,資料庫最大的敵人不是常態流量,而是 Cache Miss 瞬間的驚群效應(Cache Stampede)。完整防線應延伸至邊緣節點(CDN Edge)。

[ User Request ]
       │
       ▼
┌─────────────────────────────────┐
│ CDN Edge (Cloudflare / Fastly)  ├─► Hit: 回傳 Stale / Cached Page (0ms)
│ Cache-Control + SWR Header      │
└────────────────┬────────────────┘
                 │ Miss / Stale Revalidate
                 ▼
┌─────────────────────────────────┐
│ Redis Cluster (L2 Cache)        ├─► Hit: 回傳 JSON Data
│ (帶 Redis Lock 防驚群機制)       │
└────────────────┬────────────────┘
                 │ Miss (搶到 Lock 者)
                 ▼
┌─────────────────────────────────┐
│ MySQL Database                  │
└─────────────────────────────────┘

1. CDN Edge 與 Stale-While-Revalidate (SWR) 模式

在文章 Response Header 中注入 Cache-Control 指令,啟用 SWR 機制:

HTTP
Cache-Control: public, max-age=60, stale-while-revalidate=300, stale-if-error=86400
  • max-age=60:60 秒內直接由 CDN 邊緣快取回應。

  • stale-while-revalidate=300:60 秒~360 秒之間,CDN 會先回傳舊頁面給使用者,並在背景非同步發送一個 Request 回 Laravel 重新整理快取

  • stale-if-error=86400:若源站(Laravel/MySQL)崩潰,CDN 可繼續提供長達 24 小時的過期快取頁面。

2. L2 Redis 快取與 Lock 防禦實作

PHP
public function getArticleDetail(int $articleId): array 
{
    $cacheKey = "article:v1:detail:{$articleId}";
    
    // 1. 讀取快取
    $data = Cache::get($cacheKey);
    if ($data) return $data;

    // 2. 使用 Atomic Lock 防禦 Cache Stampede
    $lock = Cache::lock("lock:{$cacheKey}", 5);

    if ($lock->get()) {
        try {
            $data = Article::with(['category', 'tags'])->findOrFail($articleId)->toArray();
            Cache::put($cacheKey, $data, now()->addMinutes(10));
        } finally {
            $lock->release();
        }
        return $data;
    }

    // 3. 未搶到鎖者:微幅等待後讀取,或走 Fallback
    usleep(100000); // 100ms
    return Cache::get($cacheKey) ?? $this->getFallbackArticleData($articleId);
}
🧠 思考題 2.1
如果 Redis Cluster 發生整體故障(Outage),系統應該如何實施優雅降級(Graceful Degradation),避免全站請求直接刷爆 MySQL?

三、 訂閱制與金流 Webhook 的可靠性與狀態衝突處理

訂閱制的核心原則為:絕對不重複開通、不遺漏權限、能處理非同步狀態不一致

[ 金流 Webhook ]
       │
       ▼
[ Signature 驗證 ] ──► (失敗則 400 Abort)
       │
       ▼
[ DB Unique Index 鎖定 ] ──► (重複則 200 OK 跳過)
       │
       ▼
[ 狀態機 State Machine 檢查 ]
       │
       ├─► (Webhook 狀態 == API 查詢狀態) ──► 執行權限開通 DB Transaction
       │
       └─► (狀態衝突 / 延遲) ───────────────► 觸發二次查詢與衝突事件監聽

1. 處理 Webhook 延遲與 API 查詢不一致的實務案例

在真實場景中,金流平台重送的 Webhook 狀態(如 PendingPaid)可能與我們主動透過 API 查詢的結果(如 FailedExpired)發生衝突。

解法:有限狀態機(Finite State Machine, FSM)與雙向校驗

  1. 嚴格控制訂單狀態流轉:訂單狀態只允許單向演進:Pending $\rightarrow$ Paid / Failed $\rightarrow$ Refunded。不允許已進入 Paid 的訂單受延遲到的 Pending Webhook 覆蓋。

  2. 強制主動主動主查 (Active Query Verification):當收到 Webhook 通知扣款成功時,不完全信任 Payload,後端主動發起一次 TLS 對向 API 查詢金流端,雙重驗證為 Success 後才發放權限。

PHP
public function handleSpGatewayWebhook(Request $request)
{
    if (!$this->verifySignature($request)) {
        return response()->json(['message' => 'Invalid signature'], 400);
    }

    $tradeNo = $request->input('TradeNo');
    $orderId = $request->input('MerchantOrderNo');

    return DB::transaction(function () use ($tradeNo, $orderId, $request) {
        // 1. 利用 DB 排他鎖 (Pessimistic Lock) 鎖定訂單
        $order = Order::where('id', $orderId)->lockForUpdate()->firstOrFail();

        // 2. 狀態機防禦:已完成的訂單直接回傳,防止重複處理
        if ($order->status === OrderStatus::PAID) {
            return response()->json(['message' => 'Already processed'], 200);
        }

        // 3. 雙重校驗:向金流 API 發起 Secondary Check
        $apiResult = $this->paymentGatewayClient->queryOrder($tradeNo);
        if ($apiResult->isPaid() && $request->input('Status') === 'SUCCESS') {
            $order->update(['status' => OrderStatus::PAID, 'paid_at' => now()]);
            UserSubscription::grantAccess($order->user_id, $order->plan_id);
            return response()->json(['message' => 'Success'], 200);
        }

        // 情況:狀態衝突 (Webhook 成功但 API 查無結果或失敗)
        Log::critical("Payment mismatch for Order {$orderId}", ['webhook' => $request->all(), 'api' => $apiResult]);
        $order->update(['status' => OrderStatus::FLAGGED_FOR_REVIEW]);
        return response()->json(['message' => 'State mismatch recorded'], 200);
    });
}
🧠 思考題 3.1
如果金流平台系統異常,延遲了 30 分鐘才回傳交易結果,而使用者在前端頁面不斷重整並提示「付款處理中」,後端應如何設計權限預發放與過期補償機制?

四、 千萬級閱讀紀錄的大表優化與替代架構

user_article_views 資料表達到數千萬層級時,在 OLTP 資料庫(MySQL)執行 GROUP BYORDER BY 會嚴重消耗 CPU 資源。

1. 替代方案選型分析

方案適用情境優點缺點 / 成本
Redis ZSET即時熱門排行榜 (前 100 名)極速 ($O(\log N)$)、實時更新記憶體成本高,資料持久化需謹慎
ClickHouse巨量行為 Log 分析、多維度統計欄位式儲存,百億級資料毫秒響應需維護獨立 OLAP 叢集,不支援高頻單筆更新
TimescaleDB時間序列分析 (按時間區段統計)相容 PostgreSQL,具自動分區 (Hypertables)需由 MySQL 轉移至 PostgreSQL 生態系

2. Redis ZSET 與 ClickHouse 雙軌架構

[ User Article View Event ]
             │
             ├──► (即時) ──► Redis ZSET (ZINCRBY leaderboard:24h 1 article_id)
             │
             └──► (異步/Log) ─► Vector / Kafka ──► ClickHouse (OLAP 巨量分析)

ClickHouse 數據導流設計

對於千萬級點擊日誌,利用 Laravel Event 非同步發送至 Kafka/S3,再批次匯入 ClickHouse 做多維度統計(例如:按地區、裝置、作者統計 30 天熱門度)。

🧠 思考題 4.1
當採用 ClickHouse 作為分析資料庫時,為了避免每筆閱讀都直接寫入 ClickHouse 導致小檔案過多 (Too many parts),Laravel 應如何設計雙發 (Dual Write) 或 Buffer 批次寫入機制?

五、 CI/CD 零停機部署與多節點同步

在多節點(Multi-Node / ECS Cluster)架構下,滾動更新(Rolling Update)期間會同時存在舊版本程式碼新版本程式碼的 Container。

                       [ Load Balancer ]
                               │
            ┌──────────────────┴──────────────────┐
            ▼                                     ▼
┌──────────────────────┐               ┌──────────────────────┐
│  Node A (Old Code)   │               │  Node B (New Code)   │
└───────────┬──────────┘               └───────────┬──────────┘
            │                                     │
            └──────────────────┬──────────────────┘
                               ▼
                   ┌──────────────────────┐
                   │   Shared MySQL DB    │
                   └──────────────────────┘

1. 解決多節點競態 Migration

若 5 個 Node 同時啟動並執行 php artisan migrate,會造成 DB Table Lock 競爭甚至部署失敗。

  • 解法:使用 Laravel 11 的 migrate --isolated 旗標(底層使用 Atomic Lock 機制),確保整個 Cluster 同一時間只有一個 Node 能執行 Migration。

Bash
php artisan migrate --force --isolated

2. 多節點 Cache 不一致防禦

部署時若執行 php artisan config:cachecache:clear,可能導致 Node A 刷除了 Redis 快取,但 Node B 仍在使用舊版序列化類別,造成 UnserializeException

  • 版本化快取 Key (Versioned Cache Prefix):在 config/app.php.env 中定義 APP_VERSION(例如 Git Commit Hash)。

  • 快取 Key 統一帶上版本字串:article:v_{APP_VERSION}:detail:{id}。部署升級後,新舊版本的 Code 各自讀寫獨立版本號的 Cache,升級完成後再讓舊 Cache 自然過期。

🧠 思考題 5.1
在藍綠部署(Blue-Green Deployment)過程中,若新的 Migration 包含刪除舊欄位(DROP COLUMN),應該在部署的哪一個階段執行?為什麼?

六、 AI 開發工作流與 CI/CD 安全防線

團隊導入 AI(如 Cursor, GitHub Copilot)能大幅提升開發效率,但 AI 生成的程式碼常帶有 N+1 SQL 查詢Mass Assignment 風險弱型別漏洞

1. 安全自動化檢查工具鏈 (Security Pipeline)

必須在 GitHub Actions CI Pipeline 中設置自動化靜態分析閘門,攔截不合規的 AI 程式碼:

YAML
# .github/workflows/ai-code-quality.yml 範例片段
name: AI Code Guardrails

on: [push, pull_request]

jobs:
  static-analysis:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          tools: phpstan, composer-normalize

      # 1. 靜態分析:設定嚴格層級 (Level 8) 捕捉型別與潛在 Bug
      - name: Run PHPStan / Larastan
        run: vendor/bin/phpstan analyse --level=8

      # 2. 資安掃描:檢查第三方套件 CVE 漏洞
      - name: Security Vulnerability Audit
        run: composer audit

      # 3. 程式碼風格與架構規則約束
      - name: Check Code Architecture
        run: vendor/bin/deptrac analyse

2. 定義 .cursorrules 限制 AI 產出

在專案根目錄設定 .cursorrules,強制要求 AI 遵循團隊的最佳實踐:

Plaintext
- 所有的 DB 查詢必須使用 Eloquent 或 Query Builder,嚴禁產生 RAW SQL 拼接。
- 在產生 Controller 時,必須搭配 FormRequest 處理驗證,嚴禁在 Controller 內撰寫 $request->validate()。
- 查詢包含關聯模型時,必須預先加載 (Eager Loading with()),嚴禁在 foreach 內觸發 N+1 查詢。
- 所有敏感欄位寫入必須經過 $fillable 白名單保護。
🧠 思考題 6.1
如何在 CI 流程中整合 Larastan 與 Static Analysis 工具,自動偵測 AI 程式碼中未被 with() 預載入的潛在 N+1 查詢?

結語

維運與設計高流量媒體系統,關鍵在於深諳系統的防禦邊界與降級機制

從 Legacy 重構的 Session 共存,到邊緣快取的 SWR 模式、金流狀態機的雙向校驗、巨量資料的 OLAP 轉型,以及 CI/CD 與 AI 工作流的自動化防線——將複雜的業務情境拆解成具備「可觀測」、「可恢復」與「高容錯」的架構步驟,才是系統長期穩健演進的真正基石。

沒有留言:

張貼留言

高流量媒體系統的 Laravel 實戰課題:從 Legacy 重構到金流、快取與 AI 開發

  高流量媒體系統的 Laravel 實戰課題:從 Legacy 重構到金流、快取與 AI 開發(進階架構版) 在內容媒體與訂閱平台的開發中,後端團隊通常面對的是「在營運不中斷的前提下,邊換輪胎邊開車」的架構演進課題。 以媒體事業群的數位系統為例,常見的技術痛點包含:新舊系統並存...