MySQL 存儲過程、游標(biāo)、存儲函數(shù)與觸發(fā)器最佳實(shí)踐
本文將全面介紹 MySQL 存儲過程、游標(biāo)、存儲函數(shù)、觸發(fā)器的概念、語法、應(yīng)用場景以及在現(xiàn)代開發(fā)中的定位。這四者是 MySQL 可編程對象的核心,無論你是面試準(zhǔn)備還是項(xiàng)目實(shí)戰(zhàn),讀完這篇文章你將對它們建立起清晰、完整的認(rèn)知。
一、什么是存儲過程
1.1 概念定義
存儲過程(Stored Procedure) 是一組為了完成特定功能而預(yù)先編譯好的 SQL 語句集合,存儲在數(shù)據(jù)庫服務(wù)器中,客戶端通過指定存儲過程的名字并給出參數(shù)(如果有)來調(diào)用執(zhí)行。
用一個(gè)通俗的類比來理解:
- 沒有存儲過程:你每次去餐廳都要一步一步告訴廚師——先放油、再放蒜、然后放肉、翻炒三分鐘……
- 有存儲過程:你只需要說"來一份宮保雞丁",廚師已經(jīng)知道所有步驟了
本質(zhì)上,存儲過程就是數(shù)據(jù)庫端的"函數(shù)",把一段可重復(fù)使用的業(yè)務(wù)邏輯封裝起來,對外暴露一個(gè)調(diào)用接口。
1.2 核心特征
| 特征 | 說明 |
|---|---|
| 預(yù)編譯 | 創(chuàng)建時(shí)編譯一次,后續(xù)調(diào)用直接執(zhí)行編譯后的代碼 |
| 持久化存儲 | 存儲在數(shù)據(jù)庫的系統(tǒng)表中,數(shù)據(jù)庫重啟后依然存在 |
| 參數(shù)化 | 支持輸入(IN)、輸出(OUT)、輸入輸出(INOUT)三種參數(shù)模式 |
| 流程控制 | 支持變量聲明、條件判斷、循環(huán)等編程語言的基本結(jié)構(gòu) |
| 事務(wù)支持 | 內(nèi)部可以使用事務(wù)控制(BEGIN / COMMIT / ROLLBACK) |
1.3 存儲過程 vs 普通 SQL
┌─────────────────────────────────────────────────────────────┐ │ 普通 SQL 執(zhí)行流程 │ ├─────────────────────────────────────────────────────────────┤ │ 客戶端 → 發(fā)送SQL語句 → 數(shù)據(jù)庫解析 → 編譯 → 優(yōu)化 → 執(zhí)行 → 返回 │ │ (每次都要重復(fù)上述全部步驟) │ └─────────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────────┐ │ 存儲過程執(zhí)行流程 │ ├─────────────────────────────────────────────────────────────┤ │ 第一次:客戶端 → CALL → 解析 → 編譯 → 優(yōu)化 → 執(zhí)行 → 返回 │ │ 后續(xù)次:客戶端 → CALL → 直接執(zhí)行已編譯代碼 → 返回 │ │ (省去了解析、編譯、優(yōu)化步驟) │ └─────────────────────────────────────────────────────────────┘
二、存儲過程有什么用
2.1 五大核心作用
① 封裝復(fù)雜業(yè)務(wù)邏輯
將多條 SQL 語句和邏輯判斷封裝為一個(gè)整體,對外只暴露一個(gè)名字和參數(shù)列表,隱藏內(nèi)部實(shí)現(xiàn)細(xì)節(jié)。
-- 不用存儲過程:客戶端需要發(fā)送多條SQL SELECT balance FROM account WHERE id = 1; -- 客戶端判斷余額是否足夠 UPDATE account SET balance = balance - 100 WHERE id = 1; UPDATE account SET balance = balance + 100 WHERE id = 2; INSERT INTO transfer_log VALUES (...); -- 用存儲過程:一條命令搞定 CALL TransferMoney(1, 2, 100.00, @status);
② 提升執(zhí)行效率
- 存儲過程在首次執(zhí)行時(shí)被編譯,后續(xù)調(diào)用跳過解析和編譯階段
- 減少了客戶端與數(shù)據(jù)庫之間的網(wǎng)絡(luò)往返(只傳一個(gè) CALL 命令,而不是一大段 SQL)
③ 增強(qiáng)安全性
- 可以只授予用戶執(zhí)行存儲過程的權(quán)限,而不給予直接操作表的權(quán)限
- 防止 SQL 注入:參數(shù)傳入存儲過程時(shí),不會被當(dāng)作 SQL 代碼執(zhí)行
④ 減少網(wǎng)絡(luò)傳輸
假設(shè)一個(gè)業(yè)務(wù)需要執(zhí)行 10 條 SQL,如果不用存儲過程需要 10 次網(wǎng)絡(luò)往返;用存儲過程只需要 1 次網(wǎng)絡(luò)調(diào)用。
⑤ 保證數(shù)據(jù)一致性
存儲過程內(nèi)部可以包含事務(wù)控制,保證一組操作要么全部成功,要么全部回滾。
2.2 典型應(yīng)用場景
| 場景 | 說明 |
|---|---|
| 銀行轉(zhuǎn)賬 | 扣款 + 加款 + 記流水,必須在事務(wù)中完成 |
| 批量數(shù)據(jù)處理 | 定時(shí)結(jié)算、對賬、數(shù)據(jù)遷移 |
| 復(fù)雜報(bào)表統(tǒng)計(jì) | 多表關(guān)聯(lián) + 聚合計(jì)算 + 分組排序 |
| 權(quán)限控制 | DBA 封裝好存儲過程,開發(fā)人員只能調(diào)用 |
| ETL 數(shù)據(jù)清洗 | 數(shù)據(jù)倉庫中從源表加工到目標(biāo)表 |
三、快速入門
3.1 基礎(chǔ)語法
-- 修改結(jié)束符(因?yàn)榇鎯^程內(nèi)部有分號)
DELIMITER //
CREATE PROCEDURE 過程名([參數(shù)列表])
BEGIN
-- SQL 語句和邏輯
END //
-- 恢復(fù)結(jié)束符
DELIMITER ;為什么需要 DELIMITER?
MySQL 默認(rèn)用;作為語句結(jié)束符。存儲過程體內(nèi)包含多個(gè);,如果不改結(jié)束符,MySQL 會在第一個(gè);處就認(rèn)為語句結(jié)束了。所以我們臨時(shí)將結(jié)束符改為//,定義完后再改回;。
3.2 參數(shù)類型
存儲過程支持三種參數(shù)模式:
CREATE PROCEDURE 過程名(
IN 輸入?yún)?shù)名 數(shù)據(jù)類型, -- 調(diào)用方傳入,過程內(nèi)只讀
OUT 輸出參數(shù)名 數(shù)據(jù)類型, -- 過程內(nèi)寫入,調(diào)用方獲取
INOUT 雙向參數(shù)名 數(shù)據(jù)類型 -- 調(diào)用方傳入,過程內(nèi)修改后調(diào)用方可獲取
)
| 模式 | 方向 | 類比 |
|---|---|---|
| IN | 調(diào)用方 → 存儲過程 | 函數(shù)的入?yún)?/td> |
| OUT | 存儲過程 → 調(diào)用方 | 函數(shù)的返回值 |
| INOUT | 雙向傳遞 | 引用傳遞 |
3.3 快速實(shí)戰(zhàn) 6 例
示例 1:最簡單的存儲過程(無參數(shù))
DELIMITER //
CREATE PROCEDURE HelloWorld()
BEGIN
SELECT 'Hello, Stored Procedure!' AS message;
END //
DELIMITER ;
-- 調(diào)用
CALL HelloWorld();輸出:
+---------------------------+ | message | +---------------------------+ | Hello, Stored Procedure! | +---------------------------+
示例 2:帶輸入?yún)?shù)(IN)—— 根據(jù) ID 查用戶
DELIMITER //
CREATE PROCEDURE GetUserById(IN p_id INT)
BEGIN
SELECT id, username, email, create_time
FROM users
WHERE id = p_id;
END //
DELIMITER ;
-- 調(diào)用
CALL GetUserById(1);
CALL GetUserById(5);示例 3:帶輸出參數(shù)(OUT)—— 統(tǒng)計(jì)用戶數(shù)量
DELIMITER //
CREATE PROCEDURE GetUserCount(OUT p_count INT)
BEGIN
SELECT COUNT(*) INTO p_count FROM users;
END //
DELIMITER ;
-- 調(diào)用
CALL GetUserCount(@total);
SELECT @total AS user_count;注意:
@total是 MySQL 的用戶變量(Session 級別),用@前綴聲明,可以在同一個(gè)會話中跨語句使用。
示例 4:輸入輸出參數(shù)(INOUT)—— 累加計(jì)數(shù)器
DELIMITER //
CREATE PROCEDURE Accumulate(INOUT p_counter INT, IN p_increment INT)
BEGIN
SET p_counter = p_counter + p_increment;
END //
DELIMITER ;
-- 調(diào)用
SET @counter = 100;
CALL Accumulate(@counter, 50);
SELECT @counter; -- 輸出 150
CALL Accumulate(@counter, 30);
SELECT @counter; -- 輸出 180示例 5:條件判斷 + 變量聲明
DELIMITER //
CREATE PROCEDURE EvaluateScore(IN p_score INT, OUT p_level VARCHAR(20))
BEGIN
IF p_score >= 90 THEN
SET p_level = '優(yōu)秀';
ELSEIF p_score >= 80 THEN
SET p_level = '良好';
ELSEIF p_score >= 60 THEN
SET p_level = '及格';
ELSE
SET p_level = '不及格';
END IF;
END //
DELIMITER ;
-- 調(diào)用
CALL EvaluateScore(85, @level);
SELECT @level; -- 輸出:良好示例 6:循環(huán) + 游標(biāo)(遍歷結(jié)果集)
DELIMITER //
CREATE PROCEDURE BatchUpdateStatus()
BEGIN
DECLARE v_id INT;
DECLARE v_done INT DEFAULT 0;
-- 聲明游標(biāo)
DECLARE cur CURSOR FOR
SELECT id FROM orders WHERE status = 'pending' AND create_time < DATE_SUB(NOW(), INTERVAL 30 DAY);
-- 聲明結(jié)束處理器
DECLARE CONTINUE HANDLER FOR NOT FOUND SET v_done = 1;
OPEN cur;
read_loop: LOOP
FETCH cur INTO v_id;
IF v_done = 1 THEN
LEAVE read_loop;
END IF;
-- 將超過30天的待處理訂單自動(dòng)取消
UPDATE orders SET status = 'cancelled' WHERE id = v_id;
END LOOP;
CLOSE cur;
END //
DELIMITER ;
-- 調(diào)用
CALL BatchUpdateStatus();3.4 流程控制語句速查
| 語句 | 作用 | 語法 |
|---|---|---|
| IF | 條件判斷 | IF ... THEN ... ELSEIF ... ELSE ... END IF |
| CASE | 多分支選擇 | CASE WHEN ... THEN ... END CASE |
| WHILE | 前置條件循環(huán) | WHILE 條件 DO ... END WHILE |
| REPEAT | 后置條件循環(huán) | REPEAT ... UNTIL 條件 END REPEAT |
| LOOP | 無條件循環(huán) | LOOP ... END LOOP(配合 LEAVE 退出) |
| LEAVE | 退出循環(huán) | LEAVE 循環(huán)標(biāo)簽 |
| ITERATE | 跳過本次循環(huán) | ITERATE 循環(huán)標(biāo)簽(類似 continue) |
四、存儲過程中的事務(wù)與異常處理
4.1 事務(wù)控制
DELIMITER //
CREATE PROCEDURE TransferMoney(
IN p_from_id INT,
IN p_to_id INT,
IN p_amount DECIMAL(10,2),
OUT p_status VARCHAR(50)
)
BEGIN
-- 聲明異常處理:發(fā)生任何 SQL 異常時(shí)回滾
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
ROLLBACK;
SET p_status = '轉(zhuǎn)賬失?。喊l(fā)生異常';
END;
-- 余額校驗(yàn)
DECLARE v_balance DECIMAL(10,2);
SELECT balance INTO v_balance FROM account WHERE id = p_from_id FOR UPDATE;
IF v_balance < p_amount THEN
SET p_status = '轉(zhuǎn)賬失?。河囝~不足';
ELSE
START TRANSACTION;
UPDATE account SET balance = balance - p_amount WHERE id = p_from_id;
UPDATE account SET balance = balance + p_amount WHERE id = p_to_id;
INSERT INTO transfer_log(from_id, to_id, amount, transfer_time)
VALUES (p_from_id, p_to_id, p_amount, NOW());
COMMIT;
SET p_status = '轉(zhuǎn)賬成功';
END IF;
END //
DELIMITER ;4.2 異常處理器類型
-- 遇到異常后繼續(xù)執(zhí)行后續(xù)語句
DECLARE CONTINUE HANDLER FOR SQLEXCEPTION SET @err = 1;
-- 遇到異常后退出當(dāng)前 BEGIN...END 塊
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
ROLLBACK;
SELECT '執(zhí)行出錯(cuò)' AS error_msg;
END;
-- 處理特定錯(cuò)誤碼
DECLARE CONTINUE HANDLER FOR 1062 -- 重復(fù)鍵錯(cuò)誤
SET @duplicate = 1;五、存儲過程的管理操作
-- ========== 查看 ========== -- 查看當(dāng)前數(shù)據(jù)庫所有存儲過程 SHOW PROCEDURE STATUS WHERE Db = 'your_database'; -- 查看某個(gè)存儲過程的創(chuàng)建語句 SHOW CREATE PROCEDURE TransferMoney; -- 從系統(tǒng)表查詢 SELECT * FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = 'your_database' AND ROUTINE_TYPE = 'PROCEDURE'; -- ========== 修改 ========== -- MySQL 不支持 ALTER PROCEDURE 修改過程體 -- 只能先刪除再重新創(chuàng)建 DROP PROCEDURE IF EXISTS TransferMoney; -- 然后重新 CREATE PROCEDURE ... -- ========== 刪除 ========== DROP PROCEDURE IF EXISTS GetUserById;
六、游標(biāo)(Cursor)詳解
6.1 什么是游標(biāo)
游標(biāo)(Cursor) 是數(shù)據(jù)庫提供的一種機(jī)制,用于對查詢結(jié)果集進(jìn)行逐行處理。普通的 SELECT 語句一次性返回所有滿足條件的行,而游標(biāo)則像一個(gè)"指針",可以在結(jié)果集上一行一行地移動(dòng),逐條讀取并處理數(shù)據(jù)。
通俗類比:
SELECT * FROM orders→ 把所有訂單數(shù)據(jù)一股腦全取出來- 游標(biāo) → 像超市收銀員,一件一件掃描商品,處理完一件再拿下一件
游標(biāo)通常與存儲過程配合使用,在需要對每一行數(shù)據(jù)做差異化處理時(shí)發(fā)揮作用。
6.2 游標(biāo)的使用四步驟
MySQL 中使用游標(biāo)必須嚴(yán)格按照以下四個(gè)步驟進(jìn)行:
① DECLARE → 聲明游標(biāo)(綁定一條 SELECT 語句,此時(shí)不執(zhí)行) ② OPEN → 打開游標(biāo)(執(zhí)行 SELECT,將結(jié)果集加載到內(nèi)存) ③ FETCH → 逐行讀取(將當(dāng)前行數(shù)據(jù)存入變量,指針下移) ④ CLOSE → 關(guān)閉游標(biāo)(釋放結(jié)果集占用的內(nèi)存)
6.3 基礎(chǔ)語法
DELIMITER //
CREATE PROCEDURE cursor_demo()
BEGIN
-- 1. 聲明局部變量(用于存放每行數(shù)據(jù))
DECLARE v_id INT;
DECLARE v_name VARCHAR(50);
DECLARE v_done INT DEFAULT 0; -- 游標(biāo)結(jié)束標(biāo)志
-- 2. 聲明游標(biāo)(綁定 SELECT,注意:此時(shí)并不執(zhí)行查詢)
DECLARE cur CURSOR FOR
SELECT id, username FROM users WHERE status = 1;
-- 3. 聲明結(jié)束處理器(FETCH 到末尾觸發(fā) NOT FOUND,將 v_done 置 1)
-- 注意:HANDLER 必須聲明在游標(biāo)之后
DECLARE CONTINUE HANDLER FOR NOT FOUND SET v_done = 1;
-- 4. 打開游標(biāo)(此時(shí)才真正執(zhí)行 SELECT 查詢)
OPEN cur;
-- 5. 循環(huán)讀取
read_loop: LOOP
FETCH cur INTO v_id, v_name; -- 讀取當(dāng)前行到變量,指針后移
IF v_done = 1 THEN -- 沒有更多行,退出循環(huán)
LEAVE read_loop;
END IF;
-- 處理當(dāng)前行的業(yè)務(wù)邏輯
-- ...
END LOOP read_loop;
-- 6. 關(guān)閉游標(biāo)(釋放資源)
CLOSE cur;
END //
DELIMITER ;?? DECLARE 的順序非常重要:MySQL 規(guī)定 DECLARE 語句必須按以下順序出現(xiàn),順序錯(cuò)誤會報(bào)編譯錯(cuò)誤:
- 局部變量(
DECLARE var TYPE) - 游標(biāo)(
DECLARE cur CURSOR FOR) - 異常處理器(
DECLARE HANDLER)
6.4 游標(biāo)的特性
| 特性 | 說明 |
|---|---|
| 只讀性 | MySQL 游標(biāo)只能讀取數(shù)據(jù),不能通過游標(biāo)直接修改行(需單獨(dú)寫 UPDATE) |
| 單向滾動(dòng) | 只能向前逐行移動(dòng),不能后退或隨機(jī)跳轉(zhuǎn)到某一行 |
| 局部性 | 只能在存儲過程、存儲函數(shù)或觸發(fā)器內(nèi)部使用,不能單獨(dú)執(zhí)行 |
| 內(nèi)存占用 | 打開游標(biāo)會將完整結(jié)果集加載到內(nèi)存,結(jié)果集過大時(shí)存在 OOM 風(fēng)險(xiǎn) |
6.5 實(shí)戰(zhàn)示例
示例 1:遍歷用戶列表自動(dòng)寫通知日志
DELIMITER //
CREATE PROCEDURE sp_send_notification_log()
BEGIN
DECLARE v_user_id INT;
DECLARE v_email VARCHAR(100);
DECLARE v_done INT DEFAULT 0;
DECLARE cur CURSOR FOR
SELECT id, email FROM users WHERE is_active = 1;
DECLARE CONTINUE HANDLER FOR NOT FOUND SET v_done = 1;
OPEN cur;
user_loop: LOOP
FETCH cur INTO v_user_id, v_email;
IF v_done = 1 THEN
LEAVE user_loop;
END IF;
INSERT INTO notification_log(user_id, email, send_time, content)
VALUES (v_user_id, v_email, NOW(), '系統(tǒng)有重要通知,請及時(shí)查看。');
END LOOP user_loop;
CLOSE cur;
END //
DELIMITER ;示例 2:游標(biāo) + 條件判斷 —— 差異化更新用戶積分
DELIMITER //
CREATE PROCEDURE sp_update_user_points()
BEGIN
DECLARE v_id INT;
DECLARE v_orders INT;
DECLARE v_done INT DEFAULT 0;
DECLARE cur CURSOR FOR
SELECT u.id, COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id AND o.status = 'completed'
GROUP BY u.id;
DECLARE CONTINUE HANDLER FOR NOT FOUND SET v_done = 1;
OPEN cur;
point_loop: LOOP
FETCH cur INTO v_id, v_orders;
IF v_done = 1 THEN
LEAVE point_loop;
END IF;
-- 根據(jù)完成訂單數(shù)量差異化增加積分
IF v_orders >= 100 THEN
UPDATE users SET points = points + 500 WHERE id = v_id;
ELSEIF v_orders >= 50 THEN
UPDATE users SET points = points + 200 WHERE id = v_id;
ELSEIF v_orders >= 10 THEN
UPDATE users SET points = points + 50 WHERE id = v_id;
END IF;
END LOOP point_loop;
CLOSE cur;
END //
DELIMITER ;示例 3:遍歷部門同步統(tǒng)計(jì)數(shù)據(jù)(游標(biāo) + 聚合查詢)
DELIMITER //
CREATE PROCEDURE sp_sync_department_stats()
BEGIN
DECLARE v_dept_id INT;
DECLARE v_emp_count INT;
DECLARE v_avg_salary DECIMAL(10,2);
DECLARE v_done INT DEFAULT 0;
DECLARE dept_cur CURSOR FOR
SELECT id FROM departments;
DECLARE CONTINUE HANDLER FOR NOT FOUND SET v_done = 1;
OPEN dept_cur;
dept_loop: LOOP
FETCH dept_cur INTO v_dept_id;
IF v_done = 1 THEN
LEAVE dept_loop;
END IF;
-- 對每個(gè)部門單獨(dú)做聚合查詢
SELECT COUNT(*), IFNULL(AVG(salary), 0)
INTO v_emp_count, v_avg_salary
FROM employees
WHERE department_id = v_dept_id;
-- 更新統(tǒng)計(jì)表(存在則更新,不存在則插入)
INSERT INTO department_stats(dept_id, emp_count, avg_salary, updated_at)
VALUES (v_dept_id, v_emp_count, v_avg_salary, NOW())
ON DUPLICATE KEY UPDATE
emp_count = v_emp_count,
avg_salary = v_avg_salary,
updated_at = NOW();
END LOOP dept_loop;
CLOSE dept_cur;
END //
DELIMITER ;6.6 游標(biāo)的性能問題與替代方案
游標(biāo)的本質(zhì)是逐行處理(Row-by-Row),而關(guān)系型數(shù)據(jù)庫的優(yōu)化器是為**集合操作(Set-based)**設(shè)計(jì)的,兩者背道而馳。
| 方案 | 適用場景 | 性能 |
|---|---|---|
| 游標(biāo)逐行處理 | 每行邏輯復(fù)雜、無法用單條 SQL 表達(dá) | 慢 |
| 集合 SQL(批量 UPDATE/INSERT) | 批量相同操作 | 快 |
| 分批處理(LIMIT + 循環(huán)) | 超大數(shù)據(jù)量的批量操作 | 較快 |
能用集合 SQL 解決的,絕不用游標(biāo):
-- ? 用游標(biāo)逐行更新(慢,不推薦) -- 聲明游標(biāo) → 循環(huán) FETCH → 逐條 UPDATE ... -- ? 用一條集合 SQL 批量更新(快,推薦) UPDATE orders SET status = 'cancelled' WHERE status = 'pending' AND create_time < DATE_SUB(NOW(), INTERVAL 30 DAY);
游標(biāo)合理的使用場景:
- 每行的處理邏輯確實(shí)無法用一條 SQL 表達(dá)(如需要調(diào)用存儲過程、做復(fù)雜條件分支)
- 數(shù)據(jù)量較?。◣浊У綆兹f行以內(nèi))
- ETL / 數(shù)據(jù)遷移中需要逐行加工轉(zhuǎn)換
七、存儲函數(shù)(Function)詳解
7.1 什么是存儲函數(shù)
存儲函數(shù)(Stored Function) 和存儲過程同樣是存儲在數(shù)據(jù)庫中的預(yù)編譯代碼塊。核心區(qū)別在于:存儲函數(shù)必須通過 RETURN 返回一個(gè)值,并且可以像 MySQL 內(nèi)置函數(shù)(NOW()、LENGTH()、IFNULL() 等)一樣,直接嵌入 SQL 語句中使用。
-- 內(nèi)置函數(shù)的使用方式 SELECT LENGTH(username), UPPER(email) FROM users; -- 自定義存儲函數(shù)可以做到完全一樣的事 SELECT fn_mask_phone(phone), fn_get_age_group(age) FROM users; SELECT * FROM users WHERE fn_is_vip(id) = 1;
7.2 基礎(chǔ)語法
DELIMITER //
CREATE FUNCTION 函數(shù)名(參數(shù)1 數(shù)據(jù)類型, 參數(shù)2 數(shù)據(jù)類型, ...)
RETURNS 返回值類型
[特性關(guān)鍵字]
BEGIN
-- 函數(shù)體(只有 IN 參數(shù),沒有 OUT/INOUT)
RETURN 返回值;
END //
DELIMITER ;特性關(guān)鍵字(至少聲明一個(gè),MySQL 要求必須明確聲明函數(shù)特性):
| 關(guān)鍵字 | 含義 |
|---|---|
DETERMINISTIC | 相同輸入總是返回相同輸出(純函數(shù),如字符串處理、數(shù)學(xué)計(jì)算) |
NOT DETERMINISTIC | 相同輸入可能返回不同輸出(默認(rèn)值,如使用了 NOW()、RAND()) |
NO SQL | 函數(shù)體中不包含任何 SQL 語句 |
READS SQL DATA | 函數(shù)體包含 SELECT 讀操作,但不修改數(shù)據(jù) |
MODIFIES SQL DATA | 函數(shù)體包含 INSERT/UPDATE/DELETE 寫操作 |
?? 權(quán)限注意:MySQL 5.x 默認(rèn)禁止創(chuàng)建存儲函數(shù)(因?yàn)樵?binlog 開啟時(shí),不確定性函數(shù)可能引發(fā)主從不一致)。需要 DBA 執(zhí)行:
SET GLOBAL log_bin_trust_function_creators = 1;
MySQL 8.0 對此限制已大幅放寬。
7.3 調(diào)用方式對比
-- 存儲過程:必須用 CALL,不能嵌入 SQL
CALL sp_get_user_by_id(1);
-- 存儲函數(shù):像內(nèi)置函數(shù)一樣隨處可用
SELECT fn_mask_phone('13812345678'); -- 直接查詢
SELECT id, fn_mask_phone(phone) FROM users; -- SELECT 字段中
SELECT * FROM users WHERE fn_get_age_group(age) = '青年'; -- WHERE 條件中
INSERT INTO report SELECT fn_calc_score(id) FROM users; -- INSERT...SELECT 中7.4 實(shí)戰(zhàn)示例
示例 1:字符串處理函數(shù) —— 手機(jī)號中間四位脫敏
DELIMITER //
CREATE FUNCTION fn_mask_phone(p_phone VARCHAR(20))
RETURNS VARCHAR(20)
DETERMINISTIC
BEGIN
-- 13812345678 → 138****5678
IF p_phone IS NULL OR LENGTH(p_phone) != 11 THEN
RETURN p_phone; -- 不符合格式,原樣返回
END IF;
RETURN CONCAT(
LEFT(p_phone, 3),
'****',
RIGHT(p_phone, 4)
);
END //
DELIMITER ;
-- 使用:直接嵌入 SELECT
SELECT id, username, fn_mask_phone(phone) AS masked_phone FROM users;示例 2:業(yè)務(wù)函數(shù) —— 根據(jù)年齡返回用戶年齡段
DELIMITER //
CREATE FUNCTION fn_get_age_group(p_age INT)
RETURNS VARCHAR(10)
DETERMINISTIC
BEGIN
DECLARE v_group VARCHAR(10);
CASE
WHEN p_age < 18 THEN SET v_group = '未成年';
WHEN p_age < 30 THEN SET v_group = '青年';
WHEN p_age < 50 THEN SET v_group = '中年';
ELSE SET v_group = '老年';
END CASE;
RETURN v_group;
END //
DELIMITER ;
-- 使用:嵌入 ORDER BY 和 GROUP BY 也沒問題
SELECT fn_get_age_group(age) AS age_group, COUNT(*) AS cnt
FROM users
GROUP BY fn_get_age_group(age);示例 3:查詢函數(shù) —— 獲取用戶歷史訂單總金額
DELIMITER //
CREATE FUNCTION fn_get_user_total_amount(p_user_id INT)
RETURNS DECIMAL(12,2)
READS SQL DATA
BEGIN
DECLARE v_total DECIMAL(12,2) DEFAULT 0.00;
SELECT IFNULL(SUM(amount), 0.00)
INTO v_total
FROM orders
WHERE user_id = p_user_id
AND status = 'completed';
RETURN v_total;
END //
DELIMITER ;
-- 使用:在 WHERE 中篩選高價(jià)值用戶
SELECT id, username, fn_get_user_total_amount(id) AS total_amount
FROM users
WHERE fn_get_user_total_amount(id) > 10000
ORDER BY total_amount DESC;示例 4:遞歸函數(shù) —— 計(jì)算斐波那契數(shù)列
DELIMITER //
CREATE FUNCTION fn_fibonacci(n INT)
RETURNS BIGINT
DETERMINISTIC
BEGIN
IF n <= 0 THEN RETURN 0; END IF;
IF n = 1 THEN RETURN 1; END IF;
RETURN fn_fibonacci(n - 1) + fn_fibonacci(n - 2);
END //
DELIMITER ;
-- 使用前需設(shè)置最大遞歸深度(默認(rèn)為0,即不允許遞歸)
SET max_sp_recursion_depth = 20;
SELECT fn_fibonacci(10); -- 輸出 557.5 存儲函數(shù)的管理
-- 查看所有存儲函數(shù) SHOW FUNCTION STATUS WHERE Db = 'your_database'; -- 查看函數(shù)定義 SHOW CREATE FUNCTION fn_mask_phone; -- 從系統(tǒng)表查詢 SELECT ROUTINE_NAME, ROUTINE_TYPE, DTD_IDENTIFIER, ROUTINE_DEFINITION FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = 'your_database' AND ROUTINE_TYPE = 'FUNCTION'; -- 刪除函數(shù) DROP FUNCTION IF EXISTS fn_mask_phone;
7.6 存儲過程 vs 存儲函數(shù) 完整對比
| 對比維度 | 存儲過程(Procedure) | 存儲函數(shù)(Function) |
|---|---|---|
| 返回值 | 通過 OUT 參數(shù)返回,可有多個(gè) | 必須有且只有一個(gè) RETURN 返回值 |
| 調(diào)用方式 | CALL procedure_name() | SELECT function_name() |
| 能否嵌入 SQL | ? 不能嵌入 SELECT/WHERE/JOIN | ? 可以像內(nèi)置函數(shù)一樣使用 |
| 參數(shù)類型 | IN / OUT / INOUT | 只有 IN |
| 能否控制事務(wù) | ? 可以使用 COMMIT/ROLLBACK | ? 不能顯式控制事務(wù) |
| 使用游標(biāo) | ? 支持 | ? 支持 |
| 側(cè)重點(diǎn) | 執(zhí)行一系列操作(流程控制) | 計(jì)算并返回單個(gè)值(可復(fù)用計(jì)算邏輯) |
選擇原則:
- 需要做事情(執(zhí)行增刪改、控制事務(wù)、復(fù)雜流程)→ 存儲過程
- 需要算東西(計(jì)算一個(gè)值,嵌入到 SQL 中直接用)→ 存儲函數(shù)
八、觸發(fā)器(Trigger)詳解
8.1 什么是觸發(fā)器
觸發(fā)器(Trigger) 是與數(shù)據(jù)庫表關(guān)聯(lián)的特殊存儲程序。當(dāng)表上發(fā)生特定的數(shù)據(jù)變更操作(INSERT、UPDATE、DELETE)時(shí),觸發(fā)器會自動(dòng)執(zhí)行,無需手動(dòng)調(diào)用,完全由數(shù)據(jù)庫引擎驅(qū)動(dòng)。
通俗類比:
- 存儲過程 → 你主動(dòng)按開關(guān)開燈
- 觸發(fā)器 → 人體感應(yīng)燈,有人進(jìn)來自動(dòng)亮
觸發(fā)器就像數(shù)據(jù)庫層的事件監(jiān)聽器 / 鉤子函數(shù)——就像 Java 中的 @EventListener,觸發(fā)器監(jiān)聽的是數(shù)據(jù)變更事件,一旦發(fā)生就自動(dòng)響應(yīng)。
8.2 觸發(fā)器的類型
MySQL 觸發(fā)器按兩個(gè)維度分類,兩兩組合共 6 種觸發(fā)時(shí)機(jī):
按時(shí)機(jī)(WHEN):
| 時(shí)機(jī) | 說明 | 常見用途 |
|---|---|---|
BEFORE | SQL 操作執(zhí)行之前觸發(fā) | 數(shù)據(jù)校驗(yàn)、自動(dòng)填充、阻止非法操作 |
AFTER | SQL 操作執(zhí)行之后觸發(fā) | 日志記錄、數(shù)據(jù)同步、統(tǒng)計(jì)更新 |
按操作(EVENT):INSERT / UPDATE / DELETE
8.3 基礎(chǔ)語法
DELIMITER //
CREATE TRIGGER trigger_name
{BEFORE | AFTER} {INSERT | UPDATE | DELETE}
ON table_name
FOR EACH ROW
BEGIN
-- 觸發(fā)器邏輯
END //
DELIMITER ;關(guān)鍵說明:
FOR EACH ROW:行級觸發(fā)器,每影響一行就觸發(fā)一次(MySQL 只支持行級,不支持語句級)- 觸發(fā)器名建議使用
trg_表名_時(shí)機(jī)_操作格式,如trg_users_after_insert
8.4 NEW 和 OLD 關(guān)鍵字
觸發(fā)器中通過 NEW 和 OLD 訪問數(shù)據(jù)變更前后的行:
| 觸發(fā)類型 | NEW(新數(shù)據(jù)) | OLD(舊數(shù)據(jù)) |
|---|---|---|
| INSERT | ? 新插入的行 | ? 不可用 |
| UPDATE | ? 更新后的新值 | ? 更新前的舊值 |
| DELETE | ? 不可用 | ? 被刪除的行 |
在 BEFORE 觸發(fā)器中,還可以通過 SET NEW.column = value 修改即將寫入數(shù)據(jù)庫的值:
-- 在 BEFORE INSERT 中預(yù)處理數(shù)據(jù) SET NEW.username = LOWER(NEW.username); -- 用戶名統(tǒng)一轉(zhuǎn)小寫 SET NEW.create_time = NOW(); -- 自動(dòng)填充創(chuàng)建時(shí)間
8.5 實(shí)戰(zhàn)示例
示例 1:AFTER INSERT —— 自動(dòng)記錄新增日志
-- 操作日志表
CREATE TABLE operation_log (
id INT AUTO_INCREMENT PRIMARY KEY,
table_name VARCHAR(50) NOT NULL,
operation VARCHAR(20) NOT NULL,
record_id INT,
operate_time DATETIME NOT NULL,
detail TEXT
);
DELIMITER //
CREATE TRIGGER trg_users_after_insert
AFTER INSERT ON users
FOR EACH ROW
BEGIN
INSERT INTO operation_log(table_name, operation, record_id, operate_time, detail)
VALUES (
'users',
'INSERT',
NEW.id,
NOW(),
CONCAT('新增用戶:', NEW.username, ',郵箱:', NEW.email)
);
END //
DELIMITER ;示例 2:AFTER UPDATE —— 只記錄關(guān)鍵字段的變更
DELIMITER //
CREATE TRIGGER trg_users_after_update
AFTER UPDATE ON users
FOR EACH ROW
BEGIN
-- 只有 status 字段變更時(shí)才寫日志,避免無效日志
IF OLD.status != NEW.status THEN
INSERT INTO operation_log(table_name, operation, record_id, operate_time, detail)
VALUES (
'users',
'UPDATE',
NEW.id,
NOW(),
CONCAT('用戶[', NEW.username, ']狀態(tài)變更:', OLD.status, ' → ', NEW.status)
);
END IF;
END //
DELIMITER ;示例 3:BEFORE INSERT —— 數(shù)據(jù)校驗(yàn) + 自動(dòng)填充
DELIMITER //
CREATE TRIGGER trg_orders_before_insert
BEFORE INSERT ON orders
FOR EACH ROW
BEGIN
-- 校驗(yàn):訂單金額不能為負(fù)數(shù)
IF NEW.amount <= 0 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '訂單金額必須大于0';
END IF;
-- 自動(dòng)填充創(chuàng)建時(shí)間
IF NEW.create_time IS NULL THEN
SET NEW.create_time = NOW();
END IF;
-- 自動(dòng)生成訂單號
IF NEW.order_no IS NULL OR NEW.order_no = '' THEN
SET NEW.order_no = CONCAT(
'ORD',
DATE_FORMAT(NOW(), '%Y%m%d%H%i%s'),
LPAD(NEW.user_id, 6, '0')
);
END IF;
END //
DELIMITER ;
SIGNAL語句:在觸發(fā)器或存儲過程中主動(dòng)拋出錯(cuò)誤,阻止當(dāng)前操作并回滾事務(wù)。SQLSTATE '45000'是用戶自定義錯(cuò)誤的標(biāo)準(zhǔn)狀態(tài)碼。
示例 4:AFTER DELETE —— 刪除前自動(dòng)歸檔備份
DELIMITER //
CREATE TRIGGER trg_orders_after_delete
AFTER DELETE ON orders
FOR EACH ROW
BEGIN
-- 被刪除的數(shù)據(jù)自動(dòng)歸檔到備份表
INSERT INTO orders_archive(
id, user_id, amount, status, create_time, archived_at
)
VALUES (
OLD.id,
OLD.user_id,
OLD.amount,
OLD.status,
OLD.create_time,
NOW()
);
END //
DELIMITER ;示例 5:BEFORE UPDATE —— 阻止非法狀態(tài)流轉(zhuǎn)
DELIMITER //
-- 訂單狀態(tài)只能單向流轉(zhuǎn):pending → paid → shipped → completed
-- 已完成或已取消的訂單不允許再修改狀態(tài)
CREATE TRIGGER trg_orders_before_update
BEFORE UPDATE ON orders
FOR EACH ROW
BEGIN
IF OLD.status = 'completed' AND NEW.status != 'completed' THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '已完成的訂單不允許修改狀態(tài)';
END IF;
IF OLD.status = 'cancelled' THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '已取消的訂單不允許再操作';
END IF;
END //
DELIMITER ;8.6 觸發(fā)器的完整執(zhí)行流程
以執(zhí)行一條 UPDATE orders SET status='paid' WHERE id=1 為例: ┌──────────────────────────────────────────────────────────────────┐ │ 1. BEFORE UPDATE 觸發(fā)器執(zhí)行 │ │ → 可以通過 SET NEW.xxx 修改即將寫入的值 │ │ → 可以通過 SIGNAL 拋錯(cuò)阻止本次操作(整個(gè)語句回滾) │ ├──────────────────────────────────────────────────────────────────┤ │ 2. 實(shí)際執(zhí)行 UPDATE 語句(數(shù)據(jù)寫入磁盤) │ ├──────────────────────────────────────────────────────────────────┤ │ 3. AFTER UPDATE 觸發(fā)器執(zhí)行 │ │ → 數(shù)據(jù)已確定寫入,適合做日志記錄、數(shù)據(jù)同步 │ │ → 若此處拋錯(cuò),整個(gè)事務(wù)(含 UPDATE)一起回滾 │ └──────────────────────────────────────────────────────────────────┘
MySQL 5.7+ 支持同一張表、同一事件有多個(gè)觸發(fā)器,按創(chuàng)建先后順序依次執(zhí)行。
8.7 觸發(fā)器的限制
| 限制 | 說明 |
|---|---|
| 不能調(diào)用有 OUT 參數(shù)的存儲過程 | 觸發(fā)器內(nèi)部無法獲取存儲過程的輸出參數(shù) |
| 不能顯式控制事務(wù) | 不能寫 START TRANSACTION / COMMIT / ROLLBACK,但觸發(fā)器與觸發(fā)它的語句在同一事務(wù)中 |
| 不能對同表做遞歸操作 | users 表的觸發(fā)器中不能再 UPDATE users(會無限遞歸報(bào)錯(cuò)) |
| 不能使用動(dòng)態(tài) SQL | 不能使用 PREPARE / EXECUTE 執(zhí)行動(dòng)態(tài)語句 |
| 錯(cuò)誤影響 | BEFORE 觸發(fā)器拋錯(cuò) → 阻止原操作;AFTER 觸發(fā)器拋錯(cuò) → 整個(gè)事務(wù)回滾 |
8.8 觸發(fā)器的管理
-- 查看當(dāng)前數(shù)據(jù)庫所有觸發(fā)器
SHOW TRIGGERS FROM your_database;
-- 查看特定表的觸發(fā)器
SHOW TRIGGERS FROM your_database LIKE 'orders';
-- 查看觸發(fā)器定義
SHOW CREATE TRIGGER trg_users_after_insert;
-- 從系統(tǒng)表查詢(可獲取更詳細(xì)的信息)
SELECT TRIGGER_NAME, EVENT_MANIPULATION, EVENT_OBJECT_TABLE,
ACTION_TIMING, ACTION_STATEMENT
FROM information_schema.TRIGGERS
WHERE TRIGGER_SCHEMA = 'your_database';
-- 刪除觸發(fā)器
DROP TRIGGER IF EXISTS trg_users_after_insert;8.9 為什么現(xiàn)代開發(fā)不推薦濫用觸發(fā)器
| 問題 | 詳細(xì)說明 |
|---|---|
| 行為隱蔽 | 一條 INSERT 背后可能觸發(fā)多個(gè)觸發(fā)器,調(diào)用方完全不知情,Bug 難以追蹤 |
| 調(diào)試極難 | 觸發(fā)器自動(dòng)執(zhí)行,日志里只顯示原始 SQL,觸發(fā)器內(nèi)部的問題很難定位 |
| 性能陷阱 | 批量操作時(shí)每行都觸發(fā)一次(FOR EACH ROW),百萬行操作 = 百萬次觸發(fā)器調(diào)用 |
| 級聯(lián)風(fēng)險(xiǎn) | 觸發(fā)器 A 修改表 B → 表 B 的觸發(fā)器 B 又觸發(fā)……級聯(lián)鏈路極難維護(hù) |
| 業(yè)務(wù)邏輯分散 | 同一業(yè)務(wù)規(guī)則,一半在 Java Service,一半在數(shù)據(jù)庫觸發(fā)器,職責(zé)不清 |
| 測試?yán)щy | 單元測試時(shí)很難 Mock 觸發(fā)器行為,集成測試成本高 |
推薦的替代方案:
- 審計(jì)日志 → 使用 AOP(
@Aspect)在 Service 層統(tǒng)一攔截記錄 - 數(shù)據(jù)校驗(yàn) → 放在 Java Bean Validation(
@NotNull、@Min等)或 Service 層 - 數(shù)據(jù)同步 → 使用消息隊(duì)列(MQ)做異步解耦,比觸發(fā)器更健壯
九、為什么現(xiàn)代開發(fā)中存儲過程用得越來越少?
這是面試中經(jīng)常會被問到的問題。存儲過程在 2000~2010 年代被大量使用,但如今在互聯(lián)網(wǎng)項(xiàng)目中已經(jīng)很少見了。原因如下:
9.1 微服務(wù)架構(gòu)的興起
2005年的架構(gòu):
┌─────────┐ ┌──────────────────┐
│ 客戶端 │ ──→ │ 單體應(yīng)用 + DB │ ← 業(yè)務(wù)邏輯大量放在存儲過程中
└─────────┘ └──────────────────┘
2020年的架構(gòu):
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ 客戶端 │ ──→ │ 服務(wù)A │ │ 服務(wù)B │ │ 服務(wù)C │
└─────────┘ └────┬────┘ └────┬────┘ └────┬────┘
│ │ │
┌───┴───┐ ┌───┴───┐ ┌───┴───┐
│ DB-A │ │ DB-B │ │ DB-C │
└───────┘ └───────┘ └───────┘微服務(wù)架構(gòu)下,業(yè)務(wù)邏輯必須放在服務(wù)層(Java/Go/Python),而不是數(shù)據(jù)庫中。因?yàn)椋?/p>
- 跨服務(wù)的事務(wù)無法用數(shù)據(jù)庫存儲過程解決
- 每個(gè)服務(wù)獨(dú)立部署、獨(dú)立演進(jìn)
9.2 可維護(hù)性差
| 問題 | 說明 |
|---|---|
| 調(diào)試?yán)щy | 不像 Java 可以打斷點(diǎn)、單步調(diào)試,存儲過程只能通過 SELECT 打印中間變量 |
| 缺乏 IDE 支持 | 沒有代碼補(bǔ)全、沒有重構(gòu)工具、沒有靜態(tài)分析 |
| 可讀性差 | SQL 本身不是面向?qū)ο笳Z言,復(fù)雜邏輯寫起來冗長晦澀 |
| 測試?yán)щy | 不能用 JUnit/Mockito 等框架進(jìn)行單元測試 |
9.3 版本管理困難
- 存儲過程存在數(shù)據(jù)庫里,不在代碼倉庫中
- 無法用 Git 做版本控制、Code Review、分支管理
- 多人協(xié)作時(shí)容易沖突,且沖突難以發(fā)現(xiàn)
雖然可以把存儲過程的 SQL 文件放到 Git 里,但執(zhí)行和部署仍然需要額外的工具支持,遠(yuǎn)不如 Java 代碼的 CI/CD 流程成熟。
9.4 數(shù)據(jù)庫可移植性差
-- MySQL 的存儲過程語法
DELIMITER //
CREATE PROCEDURE ...
BEGIN
DECLARE v INT;
...
END //
-- Oracle 的存儲過程語法(PL/SQL)
CREATE OR REPLACE PROCEDURE ...
IS
v NUMBER;
BEGIN
...
END;
-- SQL Server 的存儲過程語法(T-SQL)
CREATE PROCEDURE ...
AS
BEGIN
DECLARE @v INT;
...
END三種數(shù)據(jù)庫的語法完全不同。如果項(xiàng)目有更換數(shù)據(jù)庫的可能(如從 MySQL 遷移到 PostgreSQL),所有存儲過程都要重寫。
而 Java + MyBatis/JPA 的方案,換數(shù)據(jù)庫只需要改 SQL 方言配置,業(yè)務(wù)代碼基本不動(dòng)。
9.5 水平擴(kuò)展受限
- 應(yīng)用層擴(kuò)展:加幾臺服務(wù)器即可(無狀態(tài),容易擴(kuò)展)
- 數(shù)據(jù)庫層擴(kuò)展:讀寫分離、分庫分表都非常復(fù)雜
把業(yè)務(wù)邏輯放在存儲過程中 = 把壓力集中在數(shù)據(jù)庫上。而數(shù)據(jù)庫是整個(gè)系統(tǒng)中最難水平擴(kuò)展的組件。
9.6 人才和協(xié)作問題
- 精通存儲過程開發(fā)的 DBA 人才稀缺
- 開發(fā)人員和 DBA 之間的協(xié)作成本高
- 出了 Bug,開發(fā)說是存儲過程的問題,DBA 說是調(diào)用方的問題——職責(zé)不清
9.7 ORM 框架的成熟
MyBatis、JPA/Hibernate、Spring Data 等 ORM 框架已經(jīng)非常成熟:
// MyBatis —— 用 XML 或注解寫 SQL,邏輯在 Java 中
@Mapper
public interface UserMapper {
@Select("SELECT * FROM users WHERE id = #{id}")
User findById(int id);
}
// JPA —— 連 SQL 都不用寫
public interface UserRepository extends JpaRepository<User, Long> {
List<User> findByAgeGreaterThan(int age);
}這些框架讓 Java 代碼直接操作數(shù)據(jù)庫變得非常簡單,存儲過程的"封裝"優(yōu)勢不再明顯。
十、那什么時(shí)候還在用存儲過程?
雖然互聯(lián)網(wǎng)項(xiàng)目越來越少用,但存儲過程并沒有消亡。以下場景仍然活躍:
10.1 仍在使用的場景
| 場景 | 原因 |
|---|---|
| 傳統(tǒng)企業(yè)系統(tǒng)(ERP/銀行/保險(xiǎn)) | 歷史遺留,存儲過程已運(yùn)行多年,不敢輕易重構(gòu) |
| 批量數(shù)據(jù)處理 | 百萬/千萬級數(shù)據(jù)的批量更新,存儲過程比應(yīng)用層逐條處理快得多 |
| 數(shù)據(jù)倉庫 / ETL | 數(shù)據(jù)清洗、加工、聚合,天然適合在數(shù)據(jù)庫端完成 |
| 復(fù)雜報(bào)表 | 涉及多表關(guān)聯(lián)、多級匯總的報(bào)表查詢 |
| DBA 運(yùn)維腳本 | 數(shù)據(jù)庫巡檢、空間清理、權(quán)限管理等運(yùn)維操作 |
| 安全性要求極高 | 不允許應(yīng)用層直接訪問表,只能通過存儲過程操作數(shù)據(jù) |
10.2 選擇建議
┌─────────────────────────────────────────────────────────────┐ │ 什么時(shí)候用存儲過程? │ ├─────────────────────────────────────────────────────────────┤ │ │ │ ? 用: │ │ ? 大批量數(shù)據(jù)處理(ETL、定時(shí)任務(wù)) │ │ ? 對性能要求極致的數(shù)據(jù)庫操作 │ │ ? 需要嚴(yán)格權(quán)限控制,不允許直接操作表 │ │ ? 數(shù)據(jù)庫平臺固定,不考慮遷移 │ │ │ │ ? 不用: │ │ ? 常規(guī) CRUD 業(yè)務(wù)邏輯 │ │ ? 微服務(wù)架構(gòu)項(xiàng)目 │ │ ? 需要頻繁迭代的業(yè)務(wù) │ │ ? 需要跨數(shù)據(jù)庫平臺的項(xiàng)目 │ │ ? 團(tuán)隊(duì)沒有熟悉存儲過程的 DBA │ │ │ └─────────────────────────────────────────────────────────────┘
十一、Java 中如何調(diào)用存儲過程
雖然現(xiàn)代項(xiàng)目不推薦大量使用存儲過程,但作為 Java 開發(fā)者,你仍然需要知道怎么調(diào)用它。
11.1 JDBC 原生調(diào)用
// 調(diào)用帶輸入輸出參數(shù)的存儲過程
try (Connection conn = dataSource.getConnection();
CallableStatement cs = conn.prepareCall("{CALL GetUserCount(?)}")) {
// 注冊輸出參數(shù)
cs.registerOutParameter(1, Types.INTEGER);
// 執(zhí)行
cs.execute();
// 獲取輸出參數(shù)
int count = cs.getInt(1);
System.out.println("用戶總數(shù):" + count);
}11.2 MyBatis 調(diào)用
<!-- Mapper XML -->
<select id="getUserCount" statementType="CALLABLE" resultType="map">
{CALL GetUserCount(#{count, mode=OUT, jdbcType=INTEGER})}
</select>// Mapper 接口
@Mapper
public interface UserMapper {
void getUserCount(Map<String, Object> params);
}
// 使用
Map<String, Object> params = new HashMap<>();
userMapper.getUserCount(params);
System.out.println(params.get("count"));11.3 Spring JdbcTemplate 調(diào)用
@Autowired
private JdbcTemplate jdbcTemplate;
public int getUserCount() {
SimpleJdbcCall call = new SimpleJdbcCall(jdbcTemplate)
.withProcedureName("GetUserCount");
Map<String, Object> result = call.execute();
return (Integer) result.get("p_count");
}11.4 JPA / Hibernate 調(diào)用
@Entity
@NamedStoredProcedureQuery(
name = "User.getUserCount",
procedureName = "GetUserCount",
parameters = {
@StoredProcedureParameter(mode = ParameterMode.OUT, name = "p_count", type = Integer.class)
}
)
public class User { ... }
// 調(diào)用
StoredProcedureQuery query = entityManager.createNamedStoredProcedureQuery("User.getUserCount");
query.execute();
Integer count = (Integer) query.getOutputParameterValue("p_count");十二、存儲過程的最佳實(shí)踐(如果你必須使用)
如果你的項(xiàng)目確實(shí)需要用到存儲過程,遵循以下規(guī)范可以減少后續(xù)的維護(hù)痛苦:
12.1 命名規(guī)范
-- 推薦:動(dòng)詞 + 名詞,前綴表明類型 sp_transfer_money -- sp = stored procedure sp_get_user_by_id sp_batch_cancel_orders sp_calculate_monthly_report -- 不推薦 proc1 my_procedure test
12.2 編寫規(guī)范
DELIMITER //
CREATE PROCEDURE sp_example(
IN p_user_id INT, -- 參數(shù)用 p_ 前綴
OUT p_result VARCHAR(50)
)
COMMENT '示例存儲過程:簡要說明功能'
BEGIN
-- 局部變量用 v_ 前綴
DECLARE v_count INT DEFAULT 0;
DECLARE v_status VARCHAR(20);
-- 業(yè)務(wù)邏輯...
-- 1. 每個(gè)步驟加注釋
-- 2. 適當(dāng)?shù)腻e(cuò)誤處理
-- 3. 避免嵌套過深(建議不超過3層)
END //
DELIMITER ;12.3 其他建議
- 保持存儲過程短小:單個(gè)存儲過程不超過 200 行,太長就拆分
- 避免在存儲過程中寫業(yè)務(wù)邏輯:只做數(shù)據(jù)操作,不做業(yè)務(wù)判斷
- 一定要加異常處理:避免事務(wù)不提交也不回滾
- 把 DDL 文件納入版本管理:將所有存儲過程的 CREATE 語句放入項(xiàng)目的
sql/目錄 - 編寫變更日志:每次修改存儲過程,在注釋中記錄修改日期和原因
十三、面試高頻問題
存儲過程相關(guān)
Q1:什么是存儲過程?它的優(yōu)缺點(diǎn)是什么?
存儲過程是存儲在數(shù)據(jù)庫中的預(yù)編譯 SQL 語句集合,通過 CALL 語句調(diào)用。優(yōu)點(diǎn)是執(zhí)行效率高、減少網(wǎng)絡(luò)傳輸、增強(qiáng)安全性;缺點(diǎn)是可移植性差、調(diào)試?yán)щy、不利于版本管理。
Q2:IN、OUT、INOUT 參數(shù)的區(qū)別?
IN 是輸入?yún)?shù)(默認(rèn)),調(diào)用方傳入值,過程內(nèi)只讀;OUT 是輸出參數(shù),過程內(nèi)賦值后調(diào)用方獲??;INOUT 是雙向參數(shù),調(diào)用方傳入、過程內(nèi)修改、調(diào)用方再獲取結(jié)果。
Q3:為什么現(xiàn)在不推薦使用存儲過程?
微服務(wù)架構(gòu)下業(yè)務(wù)邏輯必須在應(yīng)用層;存儲過程難以調(diào)試、測試和版本管理;不同數(shù)據(jù)庫語法不兼容導(dǎo)致遷移成本高;把邏輯放在數(shù)據(jù)庫中不利于水平擴(kuò)展;團(tuán)隊(duì)協(xié)作成本高。
Q4:如何在 Java 中調(diào)用存儲過程?
可以用 JDBC 的
CallableStatement、MyBatis 的statementType="CALLABLE"、Spring 的SimpleJdbcCall、或 JPA 的@NamedStoredProcedureQuery。
游標(biāo)相關(guān)
Q5:什么是游標(biāo)?使用游標(biāo)的四個(gè)步驟是什么?
游標(biāo)是對查詢結(jié)果集進(jìn)行逐行處理的數(shù)據(jù)庫機(jī)制。四個(gè)步驟:① DECLARE 聲明游標(biāo)(綁定 SELECT 語句)② OPEN 打開游標(biāo)(執(zhí)行查詢,加載結(jié)果集)③ FETCH 逐行讀?。▽?dāng)前行存入變量,指針后移)④ CLOSE 關(guān)閉游標(biāo)(釋放內(nèi)存)。
Q6:MySQL 游標(biāo)有哪些特性和限制?
MySQL 游標(biāo)是只讀的,不能通過游標(biāo)直接修改行;是單向滾動(dòng)的,只能向前逐行移動(dòng);只能在存儲過程/函數(shù)/觸發(fā)器內(nèi)使用;打開游標(biāo)會將完整結(jié)果集加載到內(nèi)存,結(jié)果集過大時(shí)存在 OOM 風(fēng)險(xiǎn)。
Q7:游標(biāo)存在性能問題,什么時(shí)候應(yīng)該用游標(biāo),什么時(shí)候不用?
能用集合 SQL(如
UPDATE ... WHERE)解決的絕對不用游標(biāo)。適合用游標(biāo)的場景:每行邏輯復(fù)雜且確實(shí)無法用單條 SQL 表達(dá)、數(shù)據(jù)量較?。◣浊У綆兹f行)、ETL 中需要逐行加工轉(zhuǎn)換的場景。
Q8:MySQL 中 DECLARE 語句的順序有什么要求?
MySQL 規(guī)定
DECLARE語句必須按固定順序出現(xiàn),否則編譯報(bào)錯(cuò):① 先聲明局部變量(DECLARE var TYPE)② 再聲明游標(biāo)(DECLARE cur CURSOR FOR)③ 最后聲明異常處理器(DECLARE HANDLER)。
存儲函數(shù)相關(guān)
Q9:存儲過程和存儲函數(shù)的區(qū)別?
對比項(xiàng) 存儲過程 存儲函數(shù) 調(diào)用方式 CALL sp_name()SELECT fn_name()返回值 OUT 參數(shù),可有多個(gè) 必須有且只有一個(gè) RETURN 能否嵌入 SQL 不能 可以 事務(wù)控制 可以 不能顯式控制 側(cè)重點(diǎn) 執(zhí)行操作 計(jì)算返回值
Q10:創(chuàng)建存儲函數(shù)時(shí) DETERMINISTIC 關(guān)鍵字是什么意思?
DETERMINISTIC表示相同輸入總是產(chǎn)生相同輸出(純函數(shù),如字符串處理、數(shù)學(xué)計(jì)算);NOT DETERMINISTIC(默認(rèn))表示相同輸入可能返回不同結(jié)果(如使用了NOW()、RAND())。在 binlog 開啟時(shí),不確定性函數(shù)可能導(dǎo)致主從不一致,MySQL 5.x 需要設(shè)置log_bin_trust_function_creators = 1。
觸發(fā)器相關(guān)
Q11:什么是觸發(fā)器?MySQL 共有幾種觸發(fā)器?
觸發(fā)器是與表關(guān)聯(lián)的特殊存儲程序,當(dāng)表發(fā)生 INSERT、UPDATE、DELETE 時(shí)自動(dòng)執(zhí)行。按時(shí)機(jī)分
BEFORE和AFTER,按操作分INSERT/UPDATE/DELETE,兩兩組合共 6 種觸發(fā)時(shí)機(jī)。
Q12:觸發(fā)器中 NEW 和 OLD 關(guān)鍵字的區(qū)別?
INSERT 觸發(fā)器中只有
NEW(新插入的行);DELETE 觸發(fā)器中只有OLD(被刪除的行);UPDATE 觸發(fā)器中兩者都有(OLD是更新前,NEW是更新后)。在BEFORE觸發(fā)器中可以用SET NEW.col = val修改即將寫入的值。
Q13:為什么現(xiàn)代開發(fā)不推薦濫用觸發(fā)器?
觸發(fā)器隱式執(zhí)行不透明,Bug 難以追蹤;批量操作時(shí)每行都觸發(fā)一次,性能差;級聯(lián)觸發(fā)鏈路極難維護(hù)和調(diào)試;單元測試時(shí)很難 Mock 觸發(fā)器行為;業(yè)務(wù)邏輯分散在數(shù)據(jù)庫和應(yīng)用層,職責(zé)不清。推薦用 AOP 替代審計(jì)日志,用 Bean Validation 替代數(shù)據(jù)校驗(yàn),用 MQ 替代數(shù)據(jù)同步。
Q14:BEFORE 觸發(fā)器和 AFTER 觸發(fā)器分別適合什么場景?
BEFORE:適合數(shù)據(jù)校驗(yàn)(拋錯(cuò)阻止非法操作)、自動(dòng)填充字段(如創(chuàng)建時(shí)間)、防止非法狀態(tài)流轉(zhuǎn)。AFTER:適合寫操作日志、更新統(tǒng)計(jì)數(shù)據(jù)、實(shí)現(xiàn)刪除歸檔備份。
十四、總結(jié)
| 可編程對象 | 本質(zhì) | 調(diào)用方式 | 當(dāng)前定位 |
|---|---|---|---|
| 存儲過程 | 執(zhí)行一組操作的 SQL 代碼塊 | CALL sp_name() | 批量處理、ETL、傳統(tǒng)企業(yè)系統(tǒng) |
| 游標(biāo) | 逐行遍歷結(jié)果集的指針 | 配合存儲過程/函數(shù)使用 | 每行邏輯差異化處理,優(yōu)先用集合 SQL |
| 存儲函數(shù) | 計(jì)算并返回單個(gè)值的代碼塊 | SELECT fn_name() | 可復(fù)用的計(jì)算邏輯,嵌入 SQL 中直接用 |
| 觸發(fā)器 | 數(shù)據(jù)變更時(shí)自動(dòng)執(zhí)行的事件鉤子 | 自動(dòng)觸發(fā),不能手動(dòng)調(diào)用 | 嚴(yán)格限制使用,推薦用 AOP/MQ 替代 |
共同的歷史定位:這四者都是數(shù)據(jù)庫時(shí)代將業(yè)務(wù)邏輯下沉到數(shù)據(jù)庫的產(chǎn)物。在現(xiàn)代微服務(wù)架構(gòu)中,它們的使用均大幅減少,業(yè)務(wù)邏輯應(yīng)該回歸應(yīng)用層,讓數(shù)據(jù)庫回歸它的本職工作——存儲和檢索數(shù)據(jù)。
| 場景 | 建議方案 |
|---|---|
| 審計(jì)日志 | AOP + 日志表,而非觸發(fā)器 |
| 數(shù)據(jù)校驗(yàn) | Bean Validation + Service 層,而非 BEFORE 觸發(fā)器 |
| 數(shù)據(jù)同步 | MQ 異步解耦,而非觸發(fā)器級聯(lián)操作 |
| 批量處理 | 存儲過程 / 存儲函數(shù) + 游標(biāo) |
| 常規(guī) CRUD | MyBatis / JPA + Service 層 Java 代碼 |
?? 推薦閱讀:如果你對 Java 后端技術(shù)棧感興趣,可以繼續(xù)閱讀本系列其他文章,涵蓋 JVM、Spring、Nacos 等核心知識點(diǎn)。
到此這篇關(guān)于MySQL 存儲過程、游標(biāo)、存儲函數(shù)與觸發(fā)器示例詳解的文章就介紹到這了,更多相關(guān)mysql存儲過程、游標(biāo)、觸發(fā)器內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
MySql中使用INSERT INTO語句更新多條數(shù)據(jù)的例子
這篇文章主要介紹了MySql中使用INSERT INTO語句更新多條數(shù)據(jù)的例子,MySQL的特有語法,需要的朋友可以參考下2014-06-06
MySQL 5.7.14 net start mysql 服務(wù)無法啟動(dòng)-“NET HELPMSG 3534” 的奇怪問題
這篇文章主要介紹了MySQL 5.7.14 net start mysql 服務(wù)無法啟動(dòng)-“NET HELPMSG 3534” 的奇怪問題,需要的朋友可以參考下2016-12-12
CentOS 7.0如何啟動(dòng)多個(gè)MySQL實(shí)例教程(mysql-5.7.21)
這篇文章主要給大家介紹了關(guān)于CentOS 7.0如何啟動(dòng)多個(gè)MySQL實(shí)例(mysql-5.7.21)的相關(guān)資料,文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來一起看看吧。2018-03-03

