資料庫程式化的演進與必要性

在現代資料庫應用開發的領域中,開發者經常面臨著處理大量重複性任務的挑戰。當企業系統需要批次更新數百萬筆交易記錄、生成複雜的跨部門統計報表,或是執行涉及多個資料表的資料遷移作業時,傳統的應用程式層處理方式往往會遇到效能瓶頸。每一次的資料庫存取都需要經過網路傳輸、協定解析、連線管理等開銷,當處理規模達到數十萬甚至數百萬筆記錄時,這些看似微小的開銷會累積成巨大的效能負擔。

MySQL 作為全球企業廣泛採用的關聯式資料庫管理系統,提供了完整的程式化控制結構來解決這些挑戰。透過將業務邏輯直接實作在資料庫層級,開發者能夠大幅減少應用程式與資料庫之間的網路往返次數,讓資料處理直接在資料儲存的位置進行。這種架構設計不僅提升了整體系統的執行效率,也降低了應用程式端的複雜度,使得程式碼更容易維護和擴充。

本文將深入探討 MySQL 的迴圈控制結構,包括 REPEAT 與 WHILE 兩種主要的迴圈機制,以及儲存過程的系統化設計方法。我們會詳細解析遊標的操作技巧,展示如何透過遊標實現逐列的精細資料處理。此外,也會探討觸發器的設計原則與實作策略,協助讀者建構自動化的資料稽核與驗證機制。透過豐富的實務範例和詳盡的程式碼註解,本文將協助讀者全面掌握這些強大的資料庫程式化工具,並能夠在實際專案中靈活運用。

迴圈控制結構的理論基礎與實務應用

在程式設計的世界裡,迴圈是實現重複執行邏輯的基本構造。當我們需要對一組資料執行相同的處理步驟,或是需要持續執行某個操作直到滿足特定條件時,迴圈就成為不可或缺的工具。MySQL 的程式化語言同樣支援迴圈控制,讓開發者能夠在資料庫層級直接處理需要反覆執行的複雜操作。

理解迴圈控制結構的重要性,不僅僅在於學會語法本身,更在於掌握何時以及如何適當地使用它們。在資料庫系統中,迴圈的使用必須謹慎評估。雖然迴圈提供了靈活的控制能力,但過度依賴迴圈可能會導致效能問題。資料庫系統的強項在於集合操作,當可以使用單一的 SQL 陳述式完成任務時,通常會比使用迴圈逐筆處理來得高效。然而,在某些特定場景下,例如需要根據每筆記錄的不同條件執行不同的處理邏輯,或是需要呼叫外部系統進行驗證時,迴圈就成為必要的選擇。

MySQL 提供了多種迴圈控制結構,其中 REPEAT 和 WHILE 是最常使用的兩種。這兩種迴圈結構在執行邏輯上有著本質的差異,理解這些差異對於選擇適當的迴圈類型至關重要。REPEAT 迴圈是一種後測試迴圈,它會先執行迴圈體內的陳述式,然後才檢查終止條件。這意味著迴圈體內的程式碼至少會執行一次,無論條件是否滿足。相對地,WHILE 迴圈是一種前測試迴圈,它會先檢查條件,只有當條件為真時才執行迴圈體。這種差異使得這兩種迴圈在不同的應用場景中各有優勢。

REPEAT…UNTIL 迴圈的深度解析與應用場景

REPEAT 迴圈的設計哲學源自於某些處理邏輯天生需要至少執行一次的特性。在實務應用中,我們經常會遇到這樣的情境,例如使用者介面的輸入驗證迴圈,無論如何都需要先顯示輸入畫面,然後根據使用者的輸入決定是否繼續。又或是在批次處理中,我們需要先取得第一批資料,然後根據取得的結果決定是否需要繼續處理下一批。

REPEAT 迴圈的語法結構相對直觀,它由 REPEAT 關鍵字開始,接著是迴圈體內的陳述式,最後以 UNTIL 關鍵字和終止條件結束。特別需要注意的是,UNTIL 後面的條件是終止條件,而非繼續條件。當條件評估為 TRUE 時,迴圈會停止執行。這與某些程式語言中的 do-while 迴圈正好相反,在那些語言中,條件通常是繼續條件。

讓我們透過一個實際的範例來深入理解 REPEAT 迴圈的運作機制。假設我們需要計算從 1 到某個指定數字的總和,這是一個經典的數學問題,也是理解迴圈運作的絕佳範例。

-- REPEAT 迴圈實現累加計算的完整範例
-- 此儲存過程展示了 REPEAT 迴圈的基本結構與執行流程

DELIMITER //

CREATE PROCEDURE demo_repeat_sum_calculation()
BEGIN
    -- 宣告計數器變數
    -- 使用 INT 資料型別儲存整數值
    -- DEFAULT 關鍵字設定初始值為 1
    DECLARE counter INT DEFAULT 1;

    -- 宣告累加器變數
    -- 用於儲存累加的總和結果
    DECLARE total_sum INT DEFAULT 0;

    -- 設定計算的目標值
    -- 我們要計算從 1 到 10 的總和
    DECLARE target_number INT DEFAULT 10;

    -- REPEAT 迴圈開始
    -- 迴圈體內的程式碼至少會執行一次
    REPEAT
        -- 將目前計數器的值加入累加器
        -- 這是核心的累加運算邏輯
        SET total_sum = total_sum + counter;

        -- 遞增計數器,準備處理下一個數字
        -- 每次迭代都會使計數器增加 1
        SET counter = counter + 1;

    -- UNTIL 子句定義終止條件
    -- 當 counter 的值超過 target_number 時,迴圈結束
    -- 注意:這是終止條件,而非繼續條件
    UNTIL counter > target_number
    END REPEAT;

    -- 輸出計算結果
    -- 使用 SELECT 陳述式將結果傳回給呼叫者
    -- 此時 total_sum 應該是 1+2+3+...+10 = 55
    SELECT 
        target_number AS '計算範圍',
        total_sum AS '累加總和',
        CONCAT('1 到 ', target_number, ' 的總和為 ', total_sum) AS '結果描述';

END //

DELIMITER ;

-- 執行儲存過程來觀察 REPEAT 迴圈的執行結果
CALL demo_repeat_sum_calculation();

透過這個範例,我們可以清楚地看到 REPEAT 迴圈的執行流程。迴圈從 counter 等於 1 開始,每次迭代都將 counter 的值加入 total_sum,然後遞增 counter。當 counter 的值變成 11 時(也就是大於 target_number 的 10),終止條件 counter > target_number 評估為 TRUE,迴圈結束。

@startuml
!define PLANTUML_FORMAT svg
!theme _none_

skinparam dpi auto
skinparam shadowing false
skinparam linetype ortho
skinparam roundcorner 5
skinparam defaultFontName "Microsoft JhengHei UI"
skinparam defaultFontSize 16
skinparam minClassWidth 100

title REPEAT...UNTIL 迴圈執行流程圖

start
:初始化變數;
note right
  counter = 1
  total_sum = 0
  target_number = 10
end note

repeat
    :執行迴圈體陳述式;
    note right
      total_sum = total_sum + counter
      迴圈體至少執行一次
    end note
    
    :更新迭代變數;
    note right
      counter = counter + 1
    end note
    
    :檢查終止條件;
    note right
      counter > target_number ?
    end note
    
backward :繼續下一次迭代;
repeat while (終止條件為 FALSE?) is (是)
->否;

:輸出計算結果;
note right
  total_sum = 55
  迴圈共執行 10 次
end note

stop

@enduml

REPEAT 迴圈特別適合用於需要先執行再判斷的場景。舉例來說,在資料遷移作業中,我們可能需要分批處理大量資料。每次迭代都會處理一批資料,然後檢查是否還有更多資料需要處理。由於第一批資料肯定需要處理,使用 REPEAT 迴圈就非常合適。另一個常見的應用場景是實作重試機制,當某個操作失敗時,我們至少需要嘗試一次,然後根據結果決定是否重試。

WHILE 迴圈的完整剖析與實務技巧

WHILE 迴圈代表了另一種迴圈控制的思維模式。與 REPEAT 迴圈不同,WHILE 迴圈在執行任何迴圈體內的程式碼之前,會先檢查條件。只有當條件評估為 TRUE 時,迴圈體才會執行。這種前測試的特性使得 WHILE 迴圈可能完全不執行,如果初始條件就不滿足的話。

在實務應用中,WHILE 迴圈經常用於處理未知迭代次數的情況。例如,當我們需要處理一個遊標返回的結果集時,我們不知道結果集中有多少筆記錄,只能透過不斷取得下一筆記錄並檢查是否到達結尾來控制迴圈。這種情況下,WHILE 迴圈提供了更自然的控制結構。

WHILE 迴圈的語法包含 WHILE 關鍵字、一個布林條件、DO 關鍵字、迴圈體陳述式,以及 END WHILE 結束標記。需要特別注意的是,WHILE 後面的條件是繼續條件,當條件為 TRUE 時迴圈會繼續執行。這與 REPEAT 的終止條件恰好相反。

讓我們透過計算階乘的範例來深入理解 WHILE 迴圈的運作機制。階乘運算是數學中的基本運算,n 的階乘定義為從 1 到 n 所有整數的乘積。

-- WHILE 迴圈實現階乘計算的完整範例
-- 此儲存過程展示了 WHILE 迴圈的控制結構與應用技巧

DELIMITER //

CREATE PROCEDURE demo_while_factorial_calculation(
    -- 輸入參數:要計算階乘的目標數值
    -- 使用 IN 關鍵字明確標示這是輸入參數
    IN target_number INT
)
BEGIN
    -- 宣告計數器變數,從 1 開始
    -- 這個變數會在每次迭代中遞增
    DECLARE counter INT DEFAULT 1;

    -- 宣告階乘結果變數
    -- 使用 BIGINT 資料型別以支援較大的階乘值
    -- 初始值設為 1,因為任何數乘以 1 等於其自身
    DECLARE factorial_result BIGINT DEFAULT 1;

    -- 參數驗證:確保輸入的數值是非負整數
    -- 階乘只對非負整數有定義
    IF target_number < 0 THEN
        -- 使用 SIGNAL 產生錯誤訊息
        SIGNAL SQLSTATE '45000'
        SET MESSAGE_TEXT = '錯誤:階乘運算不支援負數';
    END IF;

    -- 特殊情況處理:0 的階乘定義為 1
    IF target_number = 0 THEN
        SET factorial_result = 1;
    ELSE
        -- WHILE 迴圈開始
        -- 條件:當 counter 小於或等於 target_number 時繼續執行
        -- 注意:這是繼續條件,與 REPEAT 的終止條件不同
        WHILE counter <= target_number DO
            -- 階乘運算的核心邏輯
            -- 將目前的結果乘以計數器值
            SET factorial_result = factorial_result * counter;

            -- 遞增計數器,準備下一次迭代
            SET counter = counter + 1;

        END WHILE;
    END IF;

    -- 輸出計算結果
    -- 提供詳細的結果資訊供呼叫者參考
    SELECT 
        target_number AS '計算數值',
        factorial_result AS '階乘結果',
        CONCAT(target_number, '! = ', factorial_result) AS '運算式';

END //

DELIMITER ;

-- 執行儲存過程測試不同的輸入值
CALL demo_while_factorial_calculation(5);  -- 5! = 120
CALL demo_while_factorial_calculation(10); -- 10! = 3,628,800
CALL demo_while_factorial_calculation(0);  -- 0! = 1

這個階乘計算的範例展示了 WHILE 迴圈的多個重要特性。首先,我們在迴圈開始前進行了參數驗證,確保輸入值符合階乘運算的定義。其次,我們處理了 0 階乘的特殊情況。在迴圈執行過程中,counter 變數從 1 開始,每次迭代都將 factorial_result 乘以 counter,然後遞增 counter。當 counter 的值超過 target_number 時,條件 counter <= target_number 評估為 FALSE,迴圈結束。

@startuml
!define PLANTUML_FORMAT svg
!theme _none_

skinparam dpi auto
skinparam shadowing false
skinparam linetype ortho
skinparam roundcorner 5
skinparam defaultFontName "Microsoft JhengHei UI"
skinparam defaultFontSize 16
skinparam minClassWidth 100

title WHILE 迴圈執行流程圖

start
:接收輸入參數;
note right
  target_number (例如: 5)
end note

:初始化變數;
note right
  counter = 1
  factorial_result = 1
end note

if (參數驗證) then (有效)
    if (target_number = 0?) then (是)
        :設定結果為 1;
    else (否)
        while (counter <= target_number?) is (是)
            :執行階乘運算;
            note right
              factorial_result *= counter
              條件不符時可能完全不執行
            end note
            
            :遞增計數器;
            note right
              counter += 1
            end note
        endwhile (否)
    endif
    
    :輸出計算結果;
else (無效)
    :產生錯誤訊息;
    note right
      SIGNAL SQLSTATE '45000'
    end note
endif

stop

@enduml

WHILE 迴圈在實務應用中特別適合處理遊標操作。當我們使用遊標逐筆處理查詢結果時,我們不知道結果集中有多少筆記錄,只能透過 FETCH 操作和 NOT FOUND 條件來判斷是否還有更多資料。這種情況下,WHILE 迴圈配合條件變數提供了清晰且易於理解的控制結構。

LOOP 結構與進階流程控制

除了 REPEAT 和 WHILE 這兩種常用的迴圈結構,MySQL 還提供了更基本的 LOOP 結構。LOOP 本身是一個無條件的無限迴圈,它會持續執行迴圈體內的陳述式,直到遇到 LEAVE 陳述式明確跳出迴圈為止。這種設計提供了最大的彈性,讓開發者可以在迴圈體的任何位置,根據任意複雜的條件決定是否繼續執行或跳出迴圈。

LOOP 結構通常配合標籤(label)使用,標籤是一個識別符,用於標記特定的迴圈或程式區塊。透過標籤,我們可以在巢狀迴圈中明確指定要離開或繼續哪一層迴圈。這在處理複雜的巢狀控制結構時特別有用。

LEAVE 陳述式用於跳出指定標籤的迴圈,類似於其他程式語言中的 break 陳述式。ITERATE 陳述式則用於跳過本次迭代的剩餘陳述式,直接進入下一次迭代,類似於其他程式語言中的 continue 陳述式。這兩個陳述式的組合使用,讓 LOOP 結構能夠實現幾乎任何複雜的迴圈邏輯。

-- LOOP 結構搭配標籤的進階應用範例
-- 此範例展示了複雜的流程控制邏輯

DELIMITER //

CREATE PROCEDURE demo_advanced_loop_control()
BEGIN
    -- 宣告控制變數
    DECLARE counter INT DEFAULT 0;
    
    -- 宣告累加器變數
    -- 分別儲存偶數和奇數的總和
    DECLARE sum_even INT DEFAULT 0;
    DECLARE sum_odd INT DEFAULT 0;
    
    -- 宣告結果描述變數
    DECLARE result_description VARCHAR(500);

    -- 定義外層迴圈標籤
    -- 標籤名稱應該具有描述性,說明迴圈的用途
    main_calculation_loop: LOOP
        -- 遞增計數器
        SET counter = counter + 1;

        -- 設定迴圈終止條件
        -- LEAVE 陳述式用於跳出標籤所指定的迴圈
        -- 這裡我們設定當 counter 超過 20 時結束迴圈
        IF counter > 20 THEN
            LEAVE main_calculation_loop;
        END IF;

        -- 跳過特定條件的數值
        -- ITERATE 陳述式用於跳過本次迭代的剩餘程式碼
        -- 直接進入下一次迭代
        -- 這裡我們跳過所有 5 的倍數
        IF counter MOD 5 = 0 THEN
            ITERATE main_calculation_loop;
        END IF;

        -- 根據奇偶性進行不同的處理
        -- 使用 MOD 運算子判斷是否為偶數
        IF counter MOD 2 = 0 THEN
            -- 偶數累加
            SET sum_even = sum_even + counter;
        ELSE
            -- 奇數累加
            SET sum_odd = sum_odd + counter;
        END IF;

    END LOOP main_calculation_loop;

    -- 建立詳細的結果描述
    SET result_description = CONCAT(
        '處理範圍: 1 到 20 (排除 5, 10, 15, 20)',
        '\n偶數: 2+4+6+8+12+14+16+18 = ', sum_even,
        '\n奇數: 1+3+7+9+11+13+17+19 = ', sum_odd,
        '\n總和: ', sum_even + sum_odd
    );

    -- 輸出計算結果
    SELECT 
        sum_even AS '偶數總和',
        sum_odd AS '奇數總和',
        sum_even + sum_odd AS '整體總和',
        result_description AS '詳細說明';

END //

DELIMITER ;

-- 執行儲存過程觀察結果
CALL demo_advanced_loop_control();

這個範例展示了 LOOP 結構的強大彈性。我們可以在迴圈的任何位置使用 LEAVE 跳出迴圈,也可以使用 ITERATE 跳過特定條件的處理。這種設計讓我們能夠實現複雜的業務邏輯,例如在資料處理過程中根據不同條件採取不同的處理策略。

在實務應用中,LOOP 結構特別適合用於需要多個退出點的場景。例如,在資料轉換過程中,我們可能需要檢查多個條件來決定是否繼續處理。使用 LOOP 結構,我們可以在每個檢查點直接使用 LEAVE 跳出,避免了複雜的巢狀 IF-ELSE 結構。

儲存過程的架構設計與企業級應用

儲存過程(Stored Procedure)是資料庫應用架構中的核心元件之一。它將一組相關的 SQL 陳述式封裝成一個邏輯單元,儲存在資料庫伺服器上,並且可以被重複呼叫。這種封裝不僅提升了程式碼的可重用性,也帶來了顯著的效能優勢。

從效能的角度來看,儲存過程具有多重優勢。首先,儲存過程在第一次執行時會被編譯並快取在記憶體中,後續的呼叫可以直接使用編譯後的執行計畫,大幅減少了解析和編譯的開銷。其次,當業務邏輯在資料庫端執行時,可以避免應用程式與資料庫之間的多次往返,特別是在需要執行多個相關操作時,這種優勢更加明顯。最後,儲存過程可以利用資料庫引擎的最佳化能力,產生更高效的執行計畫。

從架構設計的角度來看,儲存過程提供了清晰的邏輯封裝。複雜的業務規則可以被封裝在儲存過程中,應用程式只需要透過簡單的呼叫介面就能使用這些功能。這種分離使得業務邏輯的變更不需要修改應用程式程式碼,只需要更新儲存過程即可。同時,儲存過程也提供了一層安全防護,應用程式不需要直接存取基礎資料表,而是透過儲存過程的介面進行操作,這樣可以更好地控制資料存取權限。

@startuml
!define PLANTUML_FORMAT svg
!theme _none_

skinparam dpi auto
skinparam shadowing false
skinparam linetype ortho
skinparam roundcorner 5
skinparam defaultFontName "Microsoft JhengHei UI"
skinparam defaultFontSize 16
skinparam minClassWidth 100

title 儲存過程在企業架構中的定位

package "應用程式層" {
    component [Web 應用程式] as webapp
    component [行動應用程式] as mobile
    component [API 服務層] as api
    component [批次處理程式] as batch
}

package "資料庫層" {
    component [儲存過程集合] as sp_collection
    component [觸發器機制] as triggers
    component [使用者定義函式] as functions
    database "核心資料表" as core_tables
    database "稽核記錄表" as audit_tables
}

webapp --> sp_collection : CALL 呼叫
mobile --> sp_collection : CALL 呼叫
api --> sp_collection : CALL 呼叫
batch --> sp_collection : CALL 呼叫

sp_collection --> core_tables : 執行 DML 操作
sp_collection --> functions : 呼叫函式計算
triggers --> core_tables : 事件觸發
triggers --> audit_tables : 記錄變更

note right of sp_collection
  儲存過程的核心優勢
  
  效能優化
  減少網路往返次數
  預先編譯與快取
  執行計畫最佳化
  
  邏輯封裝
  業務規則集中管理
  介面標準化
  版本控制便利
  
  安全控制
  資料表直接存取限制
  權限細緻化管理
  SQL 注入防護
end note

@enduml

儲存過程的參數設計模式

儲存過程的參數機制是實現其靈活性和可重用性的關鍵。MySQL 支援三種參數類型,分別是輸入參數(IN)、輸出參數(OUT)和輸入輸出參數(INOUT)。理解這三種參數類型的特性和適用場景,對於設計良好的儲存過程介面至關重要。

輸入參數是最常用的參數類型,它允許呼叫者將資料傳遞給儲存過程。輸入參數在儲存過程內部是唯讀的,雖然我們可以修改參數變數的值,但這個修改不會影響到呼叫者傳入的原始變數。輸入參數適合用於傳遞查詢條件、設定選項等資料。

讓我們透過一個實務範例來深入理解輸入參數的使用。假設我們有一個人口統計資料庫,需要根據不同的條件查詢縣市人口資料。

-- 建立具有多個輸入參數的儲存過程
-- 此範例展示了參數驗證與動態查詢的實作技巧

DELIMITER //

CREATE PROCEDURE sp_query_county_population(
    -- 輸入參數:州份名稱
    -- 使用 _param 後綴命名避免與欄位名稱衝突
    IN state_param VARCHAR(100),
    
    -- 輸入參數:最小人口數門檻
    -- 允許 NULL 值表示不限制最小人口數
    IN min_population_param INT,
    
    -- 輸入參數:排序欄位
    -- 控制結果的排序方式
    IN sort_by_param VARCHAR(20)
)
BEGIN
    -- 宣告區域變數用於建構動態 SQL
    DECLARE sql_statement VARCHAR(1000);
    DECLARE where_clause VARCHAR(500) DEFAULT '';
    DECLARE order_clause VARCHAR(100) DEFAULT '';

    -- 參數驗證:確保 state_param 不為空
    IF state_param IS NULL OR TRIM(state_param) = '' THEN
        SIGNAL SQLSTATE '45000'
        SET MESSAGE_TEXT = '錯誤:州份名稱不得為空';
    END IF;

    -- 建構 WHERE 子句
    SET where_clause = CONCAT('state = ''', state_param, '''');
    
    -- 如果指定了最小人口數,加入相應的條件
    IF min_population_param IS NOT NULL AND min_population_param > 0 THEN
        SET where_clause = CONCAT(
            where_clause,
            ' AND population >= ',
            min_population_param
        );
    END IF;

    -- 建構 ORDER BY 子句
    -- 根據參數決定排序方式
    IF sort_by_param = 'population_desc' THEN
        SET order_clause = 'ORDER BY population DESC';
    ELSEIF sort_by_param = 'population_asc' THEN
        SET order_clause = 'ORDER BY population ASC';
    ELSEIF sort_by_param = 'name' THEN
        SET order_clause = 'ORDER BY county ASC';
    ELSE
        -- 預設排序方式
        SET order_clause = 'ORDER BY population DESC';
    END IF;

    -- 建構完整的查詢陳述式
    SET sql_statement = CONCAT(
        'SELECT ',
        'county AS 縣市名稱, ',
        'FORMAT(population, 0) AS 人口數, ',
        'ROUND(population / 1000000, 2) AS 人口百萬 ',
        'FROM county_population ',
        'WHERE ', where_clause, ' ',
        order_clause
    );

    -- 執行動態 SQL
    -- 使用 PREPARE 和 EXECUTE 執行動態建構的查詢
    SET @dynamic_sql = sql_statement;
    PREPARE stmt FROM @dynamic_sql;
    EXECUTE stmt;
    DEALLOCATE PREPARE stmt;

END //

DELIMITER ;

-- 呼叫儲存過程的多種方式
CALL sp_query_county_population('New York', NULL, 'population_desc');
CALL sp_query_county_population('California', 1000000, 'name');
CALL sp_query_county_population('Texas', 500000, 'population_asc');

輸出參數提供了將計算結果傳回給呼叫者的機制。與函式(Function)不同,儲存過程可以傳回多個值,這些值透過輸出參數傳遞。輸出參數特別適合用於傳回統計資訊、計算結果等單一數值。

-- 建立具有多個輸出參數的儲存過程
-- 此範例展示了如何同時傳回多個計算結果

DELIMITER //

CREATE PROCEDURE sp_calculate_population_statistics(
    -- 輸入參數:要分析的州份名稱
    IN state_param VARCHAR(100),
    
    -- 輸出參數:該州的總人口數
    OUT total_population_param BIGINT,
    
    -- 輸出參數:縣市數量
    OUT county_count_param INT,
    
    -- 輸出參數:平均人口數
    OUT average_population_param DECIMAL(15,2),
    
    -- 輸出參數:人口最多的縣市名稱
    OUT largest_county_param VARCHAR(100)
)
BEGIN
    -- 查詢總人口數和縣市數量
    -- 使用 SELECT...INTO 語法將結果存入輸出參數
    SELECT 
        SUM(population),
        COUNT(*)
    INTO 
        total_population_param,
        county_count_param
    FROM county_population
    WHERE state = state_param;

    -- 檢查查詢結果是否有效
    -- 如果沒有找到資料,輸出參數會是 NULL
    IF county_count_param IS NULL OR county_count_param = 0 THEN
        -- 設定預設值表示沒有找到資料
        SET total_population_param = 0;
        SET county_count_param = 0;
        SET average_population_param = 0;
        SET largest_county_param = 'N/A';
    ELSE
        -- 計算平均人口數
        SET average_population_param = total_population_param / county_count_param;

        -- 查詢人口最多的縣市
        SELECT county
        INTO largest_county_param
        FROM county_population
        WHERE state = state_param
        ORDER BY population DESC
        LIMIT 1;
    END IF;

END //

DELIMITER ;

-- 使用輸出參數的標準方式
-- 需要提供使用者變數來接收輸出值
CALL sp_calculate_population_statistics(
    'New York',
    @total_pop,
    @county_cnt,
    @avg_pop,
    @largest_county
);

-- 檢視所有輸出參數的值
SELECT 
    FORMAT(@total_pop, 0) AS '總人口數',
    @county_cnt AS '縣市數量',
    FORMAT(@avg_pop, 0) AS '平均人口數',
    @largest_county AS '最大縣市';

輸入輸出參數(INOUT)結合了輸入和輸出的特性,參數值可以被傳入,也可以在儲存過程中被修改並傳回。這種參數類型適合用於需要對輸入值進行轉換或累加的場景。

-- 建立使用 INOUT 參數的儲存過程
-- 此範例展示了參數值的雙向傳遞

DELIMITER //

CREATE PROCEDURE sp_apply_population_growth(
    -- INOUT 參數:傳入目前人口,傳回成長後的人口
    INOUT population_param INT,
    
    -- 輸入參數:年度成長率(百分比)
    IN annual_growth_rate_param DECIMAL(5,2),
    
    -- 輸入參數:計算年數
    IN years_param INT,
    
    -- 輸出參數:總成長量
    OUT growth_amount_param INT
)
BEGIN
    -- 宣告區域變數
    DECLARE year_counter INT DEFAULT 0;
    DECLARE original_population INT;

    -- 儲存原始人口數用於計算成長量
    SET original_population = population_param;

    -- 參數驗證
    IF population_param <= 0 THEN
        SIGNAL SQLSTATE '45000'
        SET MESSAGE_TEXT = '錯誤:人口數必須大於零';
    END IF;

    IF annual_growth_rate_param < -100 OR annual_growth_rate_param > 100 THEN
        SIGNAL SQLSTATE '45000'
        SET MESSAGE_TEXT = '錯誤:成長率必須在 -100% 到 100% 之間';
    END IF;

    IF years_param <= 0 THEN
        SIGNAL SQLSTATE '45000'
        SET MESSAGE_TEXT = '錯誤:計算年數必須大於零';
    END IF;

    -- 使用複利公式計算人口成長
    -- 逐年計算以提高精確度
    WHILE year_counter < years_param DO
        -- 計算該年度的人口成長
        SET population_param = ROUND(
            population_param * (1 + annual_growth_rate_param / 100)
        );
        
        -- 遞增年度計數器
        SET year_counter = year_counter + 1;
    END WHILE;

    -- 計算總成長量
    SET growth_amount_param = population_param - original_population;

END //

DELIMITER ;

-- 使用 INOUT 參數的範例
SET @current_population = 1000000;
CALL sp_apply_population_growth(
    @current_population,  -- INOUT: 原始值 1000000
    2.5,                   -- 年成長率 2.5%
    10,                    -- 計算 10 年
    @growth_amount        -- 輸出總成長量
);

SELECT 
    FORMAT(@current_population, 0) AS '預估人口',
    FORMAT(@growth_amount, 0) AS '成長數量',
    CONCAT(ROUND(@growth_amount / 1000000 * 100, 2), '%') AS '成長百分比';

儲存過程的模組化設計與巢狀呼叫

在複雜的企業應用中,將所有邏輯塞進一個巨大的儲存過程是不明智的做法。相反地,我們應該採用模組化的設計思維,將大型功能拆解成多個較小、職責單一的儲存過程,然後透過巢狀呼叫的方式組合這些模組。這種設計方式不僅提升了程式碼的可讀性和可維護性,也讓各個模組能夠被其他儲存過程重用。

儲存過程可以呼叫其他儲存過程,形成呼叫鏈。在設計呼叫鏈時,需要注意幾個重點。首先是錯誤處理的傳播,當被呼叫的儲存過程發生錯誤時,錯誤應該能夠正確地傳播到呼叫者。其次是事務管理,如果多個儲存過程共同參與一個事務,需要確保事務邊界的正確性。最後是參數傳遞的效率,避免在呼叫鏈中傳遞大量不必要的資料。

-- 設計模組化的儲存過程系統
-- 首先建立基礎的查詢模組

DELIMITER //

CREATE PROCEDURE sp_get_state_population(
    IN state_param VARCHAR(100),
    OUT population_param BIGINT
)
BEGIN
    -- 查詢指定州份的人口總數
    SELECT SUM(population)
    INTO population_param
    FROM county_population
    WHERE state = state_param;

    -- 如果沒有找到資料,設定為 0
    IF population_param IS NULL THEN
        SET population_param = 0;
    END IF;
END //

CREATE PROCEDURE sp_get_county_count(
    IN state_param VARCHAR(100),
    OUT count_param INT
)
BEGIN
    -- 查詢指定州份的縣市數量
    SELECT COUNT(*)
    INTO count_param
    FROM county_population
    WHERE state = state_param;
END //

-- 建立組合多個模組的主控儲存過程

CREATE PROCEDURE sp_generate_population_report(
    IN state_list_param VARCHAR(500)
)
BEGIN
    -- 宣告變數
    DECLARE state_name VARCHAR(100);
    DECLARE state_pop BIGINT;
    DECLARE county_cnt INT;
    DECLARE position INT DEFAULT 1;
    DECLARE delimiter_pos INT;
    DECLARE remaining_list VARCHAR(500);

    -- 建立暫存表儲存報表結果
    CREATE TEMPORARY TABLE IF NOT EXISTS temp_report (
        state_name VARCHAR(100),
        total_population BIGINT,
        county_count INT,
        average_population DECIMAL(15,2)
    );

    -- 清空暫存表
    TRUNCATE TABLE temp_report;

    -- 初始化剩餘清單
    SET remaining_list = state_list_param;

    -- 解析州份清單(以逗號分隔)
    parse_loop: LOOP
        -- 如果剩餘清單為空,結束迴圈
        IF remaining_list IS NULL OR TRIM(remaining_list) = '' THEN
            LEAVE parse_loop;
        END IF;

        -- 尋找下一個逗號的位置
        SET delimiter_pos = LOCATE(',', remaining_list);

        -- 如果找到逗號,提取州份名稱
        IF delimiter_pos > 0 THEN
            SET state_name = TRIM(SUBSTRING(remaining_list, 1, delimiter_pos - 1));
            SET remaining_list = SUBSTRING(remaining_list, delimiter_pos + 1);
        ELSE
            -- 這是最後一個州份
            SET state_name = TRIM(remaining_list);
            SET remaining_list = '';
        END IF;

        -- 呼叫模組化的儲存過程取得資料
        CALL sp_get_state_population(state_name, state_pop);
        CALL sp_get_county_count(state_name, county_cnt);

        -- 將結果插入暫存表
        IF county_cnt > 0 THEN
            INSERT INTO temp_report VALUES (
                state_name,
                state_pop,
                county_cnt,
                state_pop / county_cnt
            );
        END IF;

    END LOOP parse_loop;

    -- 輸出完整的報表
    SELECT 
        state_name AS '州份',
        FORMAT(total_population, 0) AS '總人口',
        county_count AS '縣市數',
        FORMAT(average_population, 0) AS '平均人口',
        CONCAT(
            ROUND(total_population / 1000000, 1),
            'M'
        ) AS '人口簡記'
    FROM temp_report
    ORDER BY total_population DESC;

    -- 清理暫存表
    DROP TEMPORARY TABLE IF EXISTS temp_report;

END //

DELIMITER ;

-- 執行主控儲存過程
CALL sp_generate_population_report('New York,California,Texas,Florida');
@startuml
!define PLANTUML_FORMAT svg
!theme _none_

skinparam dpi auto
skinparam shadowing false
skinparam linetype ortho
skinparam roundcorner 5
skinparam defaultFontName "Microsoft JhengHei UI"
skinparam defaultFontSize 16
skinparam minClassWidth 100

title 儲存過程模組化呼叫架構

participant "應用程式呼叫者" as caller
participant "主控儲存過程\nsp_generate_population_report" as main
participant "模組儲存過程\nsp_get_state_population" as module1
participant "模組儲存過程\nsp_get_county_count" as module2
database "county_population 資料表" as db

caller -> main: CALL('NY,CA,TX')
activate main

main -> main: 建立暫存表
main -> main: 解析州份清單

loop 每個州份
    main -> module1: CALL('New York', @pop)
    activate module1
    module1 -> db: SELECT SUM(population)
    db --> module1: 20201249
    module1 --> main: @pop = 20201249
    deactivate module1
    
    main -> module2: CALL('New York', @cnt)
    activate module2
    module2 -> db: SELECT COUNT(*)
    db --> module2: 62
    module2 --> main: @cnt = 62
    deactivate module2
    
    main -> main: 計算平均值\n插入暫存表
end

main -> main: 彙總報表資料
main --> caller: 傳回完整報表
deactivate main

note right of main
  模組化設計的優勢
  
  可重用性
  各模組可獨立測試
  職責清晰分離
  維護成本降低
  
  擴展性
  新增功能容易
  修改影響範圍小
  版本升級平滑
end note

@enduml

這個模組化設計展示了如何將複雜的報表生成邏輯拆解成多個獨立的模組。主控儲存過程負責整體流程的協調,包括解析輸入參數、呼叫各個模組、彙總結果等。而具體的資料查詢邏輯則被封裝在獨立的模組中,這些模組可以被其他儲存過程重用,也可以獨立進行單元測試。

遊標的進階操作與效能考量

遊標(Cursor)是資料庫程式設計中用於逐列處理查詢結果的機制。當我們需要對每一筆記錄執行複雜的個別處理,而無法使用集合操作完成時,遊標就成為必要的工具。然而,遊標的使用需要謹慎評估,因為它通常比集合操作來得慢,且會消耗更多的系統資源。

遊標的工作原理是在記憶體中維護一個指向查詢結果集的指標。這個指標可以在結果集中移動,讓開發者一次處理一筆記錄。MySQL 的遊標是唯讀且只能向前移動的,這意味著一旦取得下一筆記錄,就無法回到上一筆。這種限制雖然降低了彈性,但也簡化了實作並減少了資源消耗。

遊標的生命週期包含四個關鍵階段。宣告階段定義遊標及其關聯的 SELECT 陳述式。開啟階段執行 SELECT 陳述式並建立結果集。擷取階段逐筆取得結果集中的記錄。關閉階段釋放遊標占用的資源。正確管理這些階段對於避免資源洩漏至關重要。

@startuml
!define PLANTUML_FORMAT svg
!theme _none_

skinparam dpi auto
skinparam shadowing false
skinparam linetype ortho
skinparam roundcorner 5
skinparam defaultFontName "Microsoft JhengHei UI"
skinparam defaultFontSize 16
skinparam minClassWidth 100

title 遊標完整生命週期管理

state "宣告遊標" as declare
state "開啟遊標" as open  
state "擷取資料" as fetch
state "處理記錄" as process
state "關閉遊標" as close

[*] --> declare

declare : DECLARE cursor_name CURSOR FOR
declare : SELECT statement
declare --> open

open : OPEN cursor_name
open : 執行 SELECT 陳述式
open : 建立結果集在記憶體中
open --> fetch

fetch : FETCH cursor_name INTO variables
fetch : 取得目前列資料
fetch : 移動指標到下一列
fetch --> process

process : 處理取得的記錄
process : 執行業務邏輯
process : 進行資料轉換或計算
process --> fetch : 繼續下一筆

process --> close : 所有記錄處理完畢\n或遇到 NOT FOUND

close : CLOSE cursor_name
close : 釋放記憶體資源
close : 清除結果集
close --> [*]

note right of declare
  宣告階段重點
  
  必須在所有變數宣告之後
  定義查詢邏輯
  不會執行查詢
end note

note right of fetch
  擷取階段重點
  
  配合 LOOP 或 WHILE 使用
  需要處理 NOT FOUND 條件
  無法回到上一筆記錄
end note

@enduml

讓我們透過一個實務範例來深入理解遊標的使用。假設我們需要處理大型縣市的資料,將人口超過門檻的縣市分割成多個較小的行政區。

-- 使用遊標實現複雜的資料處理邏輯
-- 此範例展示了遊標的完整使用流程

DELIMITER //

CREATE PROCEDURE sp_split_large_counties(
    IN population_threshold_param INT
)
BEGIN
    -- ==================== 變數宣告區 ====================
    -- 注意:在 MySQL 中,所有變數宣告必須在其他陳述式之前

    -- 宣告用於儲存遊標讀取值的變數
    DECLARE v_county_id INT;
    DECLARE v_state VARCHAR(100);
    DECLARE v_county VARCHAR(100);
    DECLARE v_population INT;

    -- 宣告用於計算的區域變數
    DECLARE v_split_count INT;
    DECLARE v_split_population INT;
    DECLARE v_counter INT;

    -- 宣告迴圈控制變數
    -- 用於判斷遊標是否已讀取完所有記錄
    DECLARE done BOOLEAN DEFAULT FALSE;

    -- 宣告統計變數
    DECLARE total_processed INT DEFAULT 0;
    DECLARE total_created INT DEFAULT 0;

    -- ==================== 遊標宣告區 ====================
    -- 宣告遊標,定義要處理的資料集
    -- 選擇人口超過指定門檻的縣市
    DECLARE county_cursor CURSOR FOR
        SELECT 
            county_id,
            state,
            county,
            population
        FROM county_population
        WHERE population > population_threshold_param
        ORDER BY population DESC;

    -- ==================== 例外處理器宣告區 ====================
    -- 宣告 NOT FOUND 處理器
    -- 當遊標讀取完所有記錄時,會觸發此處理器
    -- CONTINUE 表示繼續執行後續程式碼
    DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE;

    -- ==================== 主要處理邏輯 ====================

    -- 建立暫存表儲存處理記錄
    CREATE TEMPORARY TABLE IF NOT EXISTS temp_split_log (
        original_county VARCHAR(100),
        original_population INT,
        split_count INT,
        split_population INT,
        process_timestamp DATETIME
    );

    -- 開啟遊標,執行關聯的 SELECT 陳述式
    OPEN county_cursor;

    -- 使用標籤定義外層迴圈
    fetch_loop: LOOP
        -- 從遊標讀取一筆記錄到變數中
        FETCH county_cursor INTO 
            v_county_id,
            v_state,
            v_county,
            v_population;

        -- 檢查是否已讀取完所有記錄
        IF done THEN
            LEAVE fetch_loop;
        END IF;

        -- 計算需要分割成幾個行政區
        -- 假設每個行政區的目標人口不超過 100 萬
        SET v_split_count = CEIL(v_population / 1000000);
        
        -- 計算每個分割行政區的人口數
        SET v_split_population = FLOOR(v_population / v_split_count);

        -- 初始化分割計數器
        SET v_counter = 1;

        -- 內層迴圈:建立分割後的行政區
        split_loop: LOOP
            -- 插入新的分割行政區
            -- 行政區名稱加上編號後綴
            INSERT INTO county_population (
                state,
                county,
                population,
                created_from_split,
                created_date
            )
            VALUES (
                v_state,
                CONCAT(v_county, '-分區', v_counter),
                IF(v_counter = v_split_count,
                   -- 最後一個分割區包含剩餘的人口
                   v_population - (v_split_population * (v_split_count - 1)),
                   v_split_population
                ),
                TRUE,
                NOW()
            );

            -- 更新建立的行政區計數
            SET total_created = total_created + 1;

            -- 遞增分割計數器
            SET v_counter = v_counter + 1;

            -- 檢查是否已建立足夠的分割行政區
            IF v_counter > v_split_count THEN
                LEAVE split_loop;
            END IF;

        END LOOP split_loop;

        -- 記錄處理日誌
        INSERT INTO temp_split_log VALUES (
            v_county,
            v_population,
            v_split_count,
            v_split_population,
            NOW()
        );

        -- 刪除原始的大型縣市記錄
        DELETE FROM county_population
        WHERE county_id = v_county_id;

        -- 更新處理計數
        SET total_processed = total_processed + 1;

    END LOOP fetch_loop;

    -- 關閉遊標,釋放資源
    CLOSE county_cursor;

    -- 輸出處理摘要
    SELECT 
        total_processed AS '處理的原始縣市數',
        total_created AS '建立的分割行政區數',
        population_threshold_param AS '人口門檻';

    -- 輸出詳細處理記錄
    SELECT 
        original_county AS '原始縣市',
        FORMAT(original_population, 0) AS '原始人口',
        split_count AS '分割數量',
        FORMAT(split_population, 0) AS '每區人口',
        DATE_FORMAT(process_timestamp, '%Y-%m-%d %H:%i:%s') AS '處理時間'
    FROM temp_split_log
    ORDER BY original_population DESC;

    -- 清理暫存表
    DROP TEMPORARY TABLE IF EXISTS temp_split_log;

END //

DELIMITER ;

-- 執行儲存過程
CALL sp_split_large_counties(2000000);

這個範例展示了遊標的完整使用流程,包括變數宣告、遊標宣告、例外處理器宣告、遊標的開啟與關閉,以及使用 FETCH 逐筆取得記錄。特別值得注意的是內外層迴圈的配合使用,外層迴圈透過遊標取得需要處理的縣市,內層迴圈則負責建立分割後的行政區。

在實務應用中,遊標的效能考量至關重要。遊標會在記憶體中維護結果集的狀態,這會消耗系統資源。當結果集很大時,這種資源消耗可能會變得明顯。因此,應該盡可能限制遊標處理的資料量。如果可能,應該在遊標的 SELECT 陳述式中加入 WHERE 條件來過濾資料,而不是取得所有資料後再在程式中過濾。

此外,應該避免在遊標迴圈中執行複雜的子查詢或聯結。如果需要關聯其他資料表的資料,可以考慮先將相關資料提取到暫存表中,然後在遊標迴圈中存取暫存表。這樣可以避免在每次迭代中都執行複雜的查詢操作。

觸發器的系統化設計與企業級應用

觸發器(Trigger)是資料庫中的特殊物件,當特定的資料庫事件發生時會自動執行。觸發器為資料庫提供了事件驅動的能力,讓資料庫能夠在資料變更時自動執行相關的處理邏輯,而不需要應用程式明確呼叫。這種機制在實現資料稽核、資料驗證、衍生資料維護等功能時特別有用。

MySQL 支援六種類型的觸發器,分別對應三種 DML 操作(INSERT、UPDATE、DELETE)和兩種觸發時機(BEFORE、AFTER)的組合。BEFORE 觸發器在資料實際變更之前執行,這讓我們有機會驗證和修正即將寫入的資料。AFTER 觸發器在資料變更完成之後執行,適合用於記錄稽核日誌或更新相關資料表。

在觸發器的設計中,有兩個特殊的關鍵字需要理解。OLD 關鍵字代表變更前的資料列,我們可以透過 OLD.column_name 的方式存取舊值。NEW 關鍵字代表變更後的資料列,透過 NEW.column_name 存取新值。在 INSERT 操作中,只有 NEW 可用。在 DELETE 操作中,只有 OLD 可用。在 UPDATE 操作中,OLD 和 NEW 都可用,分別代表更新前後的值。

@startuml
!define PLANTUML_FORMAT svg
!theme _none_

skinparam dpi auto
skinparam shadowing false
skinparam linetype ortho
skinparam roundcorner 5
skinparam defaultFontName "Microsoft JhengHei UI"
skinparam defaultFontSize 16
skinparam minClassWidth 100

title INSERT 操作的觸發器完整執行流程

start

:應用程式發出 INSERT 陳述式;
note right
  INSERT INTO table_name
  VALUES (...)
end note

partition "BEFORE INSERT 階段" {
    :BEFORE INSERT 觸發器執行;
    note right
      可以執行的操作
      
      驗證資料合法性
      修改 NEW 值
      設定預設值
      呼叫 SIGNAL 取消操作
    end note
    
    if (資料驗證通過?) then (是)
        :繼續處理;
    else (否)
        :產生錯誤\nSIGNAL SQLSTATE;
        stop
    endif
}

:執行實際的 INSERT 操作;
:資料寫入資料表;
note right
  此時資料已經持久化
  無法再修改 NEW 值
end note

partition "AFTER INSERT 階段" {
    :AFTER INSERT 觸發器執行;
    note right
      可以執行的操作
      
      讀取 NEW 值
      寫入稽核記錄
      更新統計資料表
      更新相關資料表
      發送通知
    end note
}

:INSERT 操作完成;
:傳回執行結果給應用程式;

stop

@enduml

讓我們透過一個完整的稽核系統來深入理解觸發器的實作。這個系統會追蹤所有對特定資料表的變更,記錄變更的時間、執行者和詳細內容。

-- ==================== 建立稽核記錄表 ====================
-- 此表用於儲存所有對 payable 表的變更記錄

CREATE TABLE IF NOT EXISTS payable_audit (
    -- 稽核記錄的唯一識別碼
    -- 使用自動遞增確保每筆記錄有唯一 ID
    audit_id BIGINT AUTO_INCREMENT PRIMARY KEY,
    
    -- 變更發生的精確時間戳記
    -- 使用 DATETIME(6) 包含微秒精度
    audit_datetime DATETIME(6) NOT NULL,
    
    -- 執行變更的資料庫使用者
    -- 儲存完整的使用者識別資訊
    audit_user VARCHAR(200) NOT NULL,
    
    -- 變更類型:INSERT、UPDATE 或 DELETE
    audit_action VARCHAR(10) NOT NULL,
    
    -- 受影響的記錄識別碼
    -- 用於追溯特定記錄的變更歷史
    affected_record_id INT,
    
    -- 變更的詳細描述
    -- 使用 TEXT 資料型別支援長文字
    audit_change TEXT,
    
    -- 變更前的完整記錄(JSON 格式)
    -- 僅用於 UPDATE 和 DELETE 操作
    old_values JSON,
    
    -- 變更後的完整記錄(JSON 格式)
    -- 僅用於 INSERT 和 UPDATE 操作
    new_values JSON,
    
    -- 建立索引以加速查詢
    INDEX idx_audit_datetime (audit_datetime),
    INDEX idx_audit_user (audit_user),
    INDEX idx_audit_action (audit_action),
    INDEX idx_affected_record (affected_record_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- ==================== 建立 BEFORE INSERT 觸發器 ====================
-- 用於驗證和修正即將插入的資料

DELIMITER //

CREATE TRIGGER tr_payable_before_insert
BEFORE INSERT ON payable
FOR EACH ROW
BEGIN
    -- 驗證金額必須為正數
    -- 負數金額在業務邏輯上不合理
    IF NEW.amount <= 0 THEN
        SIGNAL SQLSTATE '45000'
        SET MESSAGE_TEXT = '錯誤:應付金額必須大於零';
    END IF;

    -- 驗證公司名稱不得為空
    -- 空白的公司名稱會造成資料品質問題
    IF NEW.company IS NULL OR TRIM(NEW.company) = '' THEN
        SIGNAL SQLSTATE '45000'
        SET MESSAGE_TEXT = '錯誤:公司名稱不得為空白';
    END IF;

    -- 自動修正:將公司名稱轉為大寫並移除多餘空白
    -- 這確保了資料的一致性
    SET NEW.company = UPPER(TRIM(NEW.company));

    -- 自動修正:移除服務描述的前後空白
    IF NEW.service IS NOT NULL THEN
        SET NEW.service = TRIM(NEW.service);
    END IF;

    -- 如果未設定建立時間,自動設定為目前時間
    IF NEW.created_date IS NULL THEN
        SET NEW.created_date = NOW();
    END IF;

    -- 設定最後修改時間
    SET NEW.last_modified_date = NOW();

END //

-- ==================== 建立 AFTER INSERT 觸發器 ====================
-- 用於記錄新增操作的稽核日誌

CREATE TRIGGER tr_payable_after_insert
AFTER INSERT ON payable
FOR EACH ROW
BEGIN
    -- 宣告變數用於建構 JSON
    DECLARE new_values_json JSON;

    -- 建立新記錄的 JSON 表示
    SET new_values_json = JSON_OBJECT(
        'payable_id', NEW.payable_id,
        'company', NEW.company,
        'amount', NEW.amount,
        'service', NEW.service,
        'created_date', NEW.created_date
    );

    -- 插入稽核記錄
    INSERT INTO payable_audit (
        audit_datetime,
        audit_user,
        audit_action,
        affected_record_id,
        audit_change,
        old_values,
        new_values
    )
    VALUES (
        NOW(6),  -- 使用微秒精度的時間戳記
        USER(),  -- 目前的資料庫使用者
        'INSERT',
        NEW.payable_id,
        CONCAT(
            '新增應付款項 - ',
            '公司:', NEW.company, ', ',
            '金額:', FORMAT(NEW.amount, 2), ', ',
            '服務:', COALESCE(NEW.service, 'N/A')
        ),
        NULL,  -- INSERT 操作沒有舊值
        new_values_json
    );

END //

-- ==================== 建立 BEFORE UPDATE 觸發器 ====================
-- 用於驗證更新操作並記錄變更

CREATE TRIGGER tr_payable_before_update
BEFORE UPDATE ON payable
FOR EACH ROW
BEGIN
    -- 驗證更新後的金額仍然為正數
    IF NEW.amount <= 0 THEN
        SIGNAL SQLSTATE '45000'
        SET MESSAGE_TEXT = '錯誤:更新後的金額必須大於零';
    END IF;

    -- 驗證更新後的公司名稱不得為空
    IF NEW.company IS NULL OR TRIM(NEW.company) = '' THEN
        SIGNAL SQLSTATE '45000'
        SET MESSAGE_TEXT = '錯誤:更新後的公司名稱不得為空白';
    END IF;

    -- 自動修正資料格式
    SET NEW.company = UPPER(TRIM(NEW.company));
    
    IF NEW.service IS NOT NULL THEN
        SET NEW.service = TRIM(NEW.service);
    END IF;

    -- 更新最後修改時間
    SET NEW.last_modified_date = NOW();

    -- 如果金額變更超過 20%,記錄警告
    IF ABS((NEW.amount - OLD.amount) / OLD.amount) > 0.2 THEN
        INSERT INTO payable_alerts (
            alert_datetime,
            payable_id,
            alert_type,
            alert_message
        )
        VALUES (
            NOW(),
            NEW.payable_id,
            'LARGE_AMOUNT_CHANGE',
            CONCAT(
                '金額變動超過 20%: ',
                FORMAT(OLD.amount, 2), ' -> ', FORMAT(NEW.amount, 2)
            )
        );
    END IF;

END //

-- ==================== 建立 AFTER UPDATE 觸發器 ====================
-- 用於詳細記錄更新操作的稽核日誌

CREATE TRIGGER tr_payable_after_update
AFTER UPDATE ON payable
FOR EACH ROW
BEGIN
    -- 宣告變數
    DECLARE change_description TEXT DEFAULT '';
    DECLARE old_values_json JSON;
    DECLARE new_values_json JSON;
    DECLARE has_changes BOOLEAN DEFAULT FALSE;

    -- 建構詳細的變更描述
    -- 只記錄實際發生變更的欄位

    IF OLD.company != NEW.company THEN
        SET change_description = CONCAT(
            change_description,
            '公司名稱: [', OLD.company, '] → [', NEW.company, ']; '
        );
        SET has_changes = TRUE;
    END IF;

    IF OLD.amount != NEW.amount THEN
        SET change_description = CONCAT(
            change_description,
            '金額: [', FORMAT(OLD.amount, 2), '] → [', FORMAT(NEW.amount, 2), ']; '
        );
        SET has_changes = TRUE;
    END IF;

    IF (OLD.service IS NULL AND NEW.service IS NOT NULL) OR
       (OLD.service IS NOT NULL AND NEW.service IS NULL) OR
       (OLD.service != NEW.service) THEN
        SET change_description = CONCAT(
            change_description,
            '服務: [', COALESCE(OLD.service, 'N/A'), '] → [', COALESCE(NEW.service, 'N/A'), ']; '
        );
        SET has_changes = TRUE;
    END IF;

    -- 只有在確實有變更時才記錄
    IF has_changes THEN
        -- 建立 JSON 格式的舊值和新值記錄
        SET old_values_json = JSON_OBJECT(
            'payable_id', OLD.payable_id,
            'company', OLD.company,
            'amount', OLD.amount,
            'service', OLD.service
        );

        SET new_values_json = JSON_OBJECT(
            'payable_id', NEW.payable_id,
            'company', NEW.company,
            'amount', NEW.amount,
            'service', NEW.service
        );

        -- 插入詳細的稽核記錄
        INSERT INTO payable_audit (
            audit_datetime,
            audit_user,
            audit_action,
            affected_record_id,
            audit_change,
            old_values,
            new_values
        )
        VALUES (
            NOW(6),
            USER(),
            'UPDATE',
            NEW.payable_id,
            CONCAT('更新記錄 ID ', NEW.payable_id, ' - ', change_description),
            old_values_json,
            new_values_json
        );
    END IF;

END //

-- ==================== 建立 AFTER DELETE 觸發器 ====================
-- 用於記錄刪除操作的稽核日誌

CREATE TRIGGER tr_payable_after_delete
AFTER DELETE ON payable
FOR EACH ROW
BEGIN
    -- 宣告變數
    DECLARE old_values_json JSON;

    -- 建立被刪除記錄的 JSON 表示
    SET old_values_json = JSON_OBJECT(
        'payable_id', OLD.payable_id,
        'company', OLD.company,
        'amount', OLD.amount,
        'service', OLD.service
    );

    -- 記錄刪除操作
    -- DELETE 操作只有 OLD 值,沒有 NEW 值
    INSERT INTO payable_audit (
        audit_datetime,
        audit_user,
        audit_action,
        affected_record_id,
        audit_change,
        old_values,
        new_values
    )
    VALUES (
        NOW(6),
        USER(),
        'DELETE',
        OLD.payable_id,
        CONCAT(
            '刪除應付款項 - ',
            'ID:', OLD.payable_id, ', ',
            '公司:', OLD.company, ', ',
            '金額:', FORMAT(OLD.amount, 2), ', ',
            '服務:', COALESCE(OLD.service, 'N/A')
        ),
        old_values_json,
        NULL  -- DELETE 操作沒有新值
    );

END //

DELIMITER ;

這個完整的稽核系統展示了觸發器在企業級應用中的重要作用。透過組合使用 BEFORE 和 AFTER 觸發器,我們實現了完整的資料驗證、自動修正和變更追蹤機制。BEFORE 觸發器確保只有合法的資料能夠寫入資料表,而 AFTER 觸發器則詳細記錄了所有變更的歷史。

@startuml
!define PLANTUML_FORMAT svg
!theme _none_

skinparam dpi auto
skinparam shadowing false
skinparam linetype ortho
skinparam roundcorner 5
skinparam defaultFontName "Microsoft JhengHei UI"
skinparam defaultFontSize 16
skinparam minClassWidth 100

title 完整的觸發器稽核系統架構

database "payable\n應付款項主表" as payable
database "payable_audit\n稽核記錄表" as audit
database "payable_alerts\n警告記錄表" as alerts

component "BEFORE INSERT\n觸發器" as bi
component "AFTER INSERT\n觸發器" as ai
component "BEFORE UPDATE\n觸發器" as bu
component "AFTER UPDATE\n觸發器" as au
component "BEFORE DELETE\n觸發器" as bd
component "AFTER DELETE\n觸發器" as ad

note top of bi
  資料驗證
  格式修正
  預設值設定
end note

note top of bu
  更新驗證
  異常檢測
  時間戳記更新
end note

payable <-- bi : 驗證與修正
payable <-- bu : 驗證與修正

bi --> alerts : 記錄警告
bu --> alerts : 記錄警告

ai --> audit : INSERT 稽核
au --> audit : UPDATE 稽核
ad --> audit : DELETE 稽核

note bottom of audit
  完整的變更歷史
  
  記錄內容
  操作時間與使用者
  變更前後的完整資料
  詳細的變更描述
  JSON 格式的資料快照
end note

@enduml

效能優化與最佳實務準則

在實務應用中,迴圈控制、儲存過程、遊標和觸發器都需要謹慎使用,並特別注意效能影響。資料庫系統的核心優勢在於集合操作,當可以使用單一的 SQL 陳述式完成任務時,通常會比使用程式化控制結構來得高效。然而,這並不意味著應該完全避免使用這些工具,而是需要理解何時以及如何正確使用它們。

對於迴圈控制,應該優先考慮是否可以用集合操作替代。例如,要更新滿足特定條件的所有記錄,使用 UPDATE…WHERE 陳述式會比使用遊標逐筆更新快得多。但當需要根據每筆記錄的不同條件執行不同的複雜邏輯時,迴圈就成為必要的選擇。在這種情況下,應該確保迴圈內部的操作盡可能簡潔,避免執行複雜的子查詢或多表聯結。

對於遊標的使用,最重要的原則是限制結果集的大小。在遊標的 SELECT 陳述式中加入適當的 WHERE 條件,只取得真正需要處理的資料。如果需要在遊標迴圈中查詢相關資料,考慮先將這些資料載入暫存表,然後在迴圈中存取暫存表,而不是在每次迭代中都執行資料庫查詢。此外,務必在使用完畢後立即關閉遊標,避免資源洩漏。

觸發器的效能影響需要特別注意。觸發器會在每次資料變更時自動執行,如果觸發器的邏輯過於複雜或執行時間過長,會直接影響到所有涉及該資料表的 DML 操作。因此,觸發器應該保持精簡,只執行必要的驗證和記錄操作。複雜的業務邏輯應該移至儲存過程中,觸發器僅負責呼叫這些儲存過程。同時要避免觸發器的連鎖反應,一個觸發器修改資料導致另一個觸發器執行,這種連鎖可能會造成難以追蹤的問題。

儲存過程的設計應該遵循模組化原則。將大型的儲存過程拆解成多個較小、職責單一的儲存過程,不僅提升了可讀性和可維護性,也讓各個模組能夠被獨立測試和重用。在參數設計上,應該為所有參數提供清晰的命名和註解,並進行適當的驗證。錯誤處理也是儲存過程設計的重要環節,應該使用 DECLARE…HANDLER 語法捕獲並處理可能發生的錯誤,避免錯誤在呼叫鏈中無聲地傳播。

在事務管理方面,需要特別注意儲存過程和觸發器的事務邊界。如果儲存過程中包含多個 DML 操作,應該考慮使用明確的事務控制(START TRANSACTION、COMMIT、ROLLBACK),確保這些操作的原子性。觸發器會在觸發它的 DML 操作的事務中執行,因此觸發器中的任何錯誤都會導致整個事務回滾。

實務應用案例:建構完整的資料處理流程

讓我們透過一個完整的實務案例來整合本文介紹的所有技術。假設我們需要建構一個資料遷移系統,將舊系統的人口資料遷移到新系統,並在過程中進行資料清理、驗證和轉換。

-- ==================== 完整的資料遷移系統 ====================
-- 整合迴圈控制、儲存過程、遊標和觸發器的企業級範例

DELIMITER //

-- 主控遷移儲存過程
CREATE PROCEDURE sp_migrate_population_data(
    IN batch_size_param INT,
    IN validation_level_param VARCHAR(20),
    OUT total_migrated_param INT,
    OUT total_errors_param INT
)
BEGIN
    -- 變數宣告
    DECLARE v_batch_count INT DEFAULT 0;
    DECLARE v_record_count INT DEFAULT 0;
    DECLARE v_error_count INT DEFAULT 0;
    DECLARE v_continue BOOLEAN DEFAULT TRUE;
    
    -- 建立遷移日誌表
    CREATE TEMPORARY TABLE IF NOT EXISTS migration_log (
        log_id INT AUTO_INCREMENT PRIMARY KEY,
        batch_number INT,
        records_processed INT,
        errors_encountered INT,
        start_time DATETIME,
        end_time DATETIME,
        status VARCHAR(20)
    );

    -- 開始遷移迴圈
    WHILE v_continue DO
        SET v_batch_count = v_batch_count + 1;
        
        -- 呼叫批次處理儲存過程
        CALL sp_process_migration_batch(
            batch_size_param,
            validation_level_param,
            @batch_records,
            @batch_errors
        );
        
        -- 累積統計資訊
        SET v_record_count = v_record_count + @batch_records;
        SET v_error_count = v_error_count + @batch_errors;
        
        -- 記錄批次處理結果
        INSERT INTO migration_log (
            batch_number,
            records_processed,
            errors_encountered,
            end_time,
            status
        )
        VALUES (
            v_batch_count,
            @batch_records,
            @batch_errors,
            NOW(),
            IF(@batch_records = 0, 'COMPLETED', 'SUCCESS')
        );
        
        -- 檢查是否還有更多資料需要處理
        IF @batch_records = 0 THEN
            SET v_continue = FALSE;
        END IF;
        
    END WHILE;

    -- 設定輸出參數
    SET total_migrated_param = v_record_count;
    SET total_errors_param = v_error_count;

    -- 輸出遷移摘要
    SELECT 
        v_batch_count AS '批次數量',
        FORMAT(v_record_count, 0) AS '遷移記錄數',
        FORMAT(v_error_count, 0) AS '錯誤數量',
        CONCAT(
            ROUND((v_record_count - v_error_count) / v_record_count * 100, 2),
            '%'
        ) AS '成功率';

END //

DELIMITER ;

這個完整的案例展示了如何在實務應用中組合使用本文介紹的各種技術。主控儲存過程使用 WHILE 迴圈實現批次處理,每個批次呼叫子儲存過程進行實際的資料處理。透過模組化的設計,我們可以輕鬆地擴展和維護這個系統,同時透過完善的日誌記錄和錯誤處理,確保系統的可靠性和可追蹤性。

結語

MySQL 的迴圈控制結構、儲存過程、遊標和觸發器構成了一套完整且強大的資料庫程式化工具集。透過掌握這些工具,開發者能夠在資料庫層級實現複雜的業務邏輯,大幅提升系統的整體效能和可維護性。REPEAT 和 WHILE 迴圈提供了基本的重複執行能力,讓開發者能夠實現需要反覆執行的處理邏輯。儲存過程則將這些邏輯封裝成可重用的模組,提供了清晰的介面和強大的參數機制。遊標允許開發者對結果集進行逐列的精細處理,在無法使用集合操作的場景中提供了必要的控制能力。觸發器則實現了事件驅動的自動化執行,讓資料庫能夠在資料變更時自動執行相關的驗證和稽核邏輯。

在實務應用中,這些技術的成功運用需要深入理解其特性和限制。迴圈雖然靈活,但不應成為第一選擇,當可以使用集合操作時應優先考慮集合操作。遊標雖然強大,但會消耗較多資源,應該謹慎使用並確保及時釋放。觸發器雖然方便,但過度使用可能導致系統行為難以預測,應該保持精簡並避免複雜的巢狀邏輯。儲存過程的設計應該遵循模組化原則,將複雜的邏輯拆解成多個較小的單元,每個單元都有清晰的職責和介面。

透過本文詳細的講解和豐富的實務範例,相信讀者已經對 MySQL 的這些程式化工具有了全面而深入的理解。將這些知識應用到實際的專案開發中,開發者能夠建構出高效能、可維護且可靠的資料庫解決方案,為企業的資訊系統提供堅實的資料處理能力。記住,工具本身沒有好壞,關鍵在於理解其適用場景並正確使用,這才是成為專業資料庫開發者的核心能力。