抱歉用了這么渣的標題,其實是一個很簡單而且很常見的需求:假設我們有一個學生表,它有一個狀態字段:
create table T_STU ( STU_ID VARCHAR2(36) not null, NAME VARCHAR2(255), CODE VARCHAR2(255), STATE NUMBER(10), START_YEAR NUMBER(10) ); alter table T_STU2 add constraint PK_STU2 primary key (STU_ID); create index IX_STU21 on T_STU2 (STATE);
由一個數字代表學生的各種狀態,例如 1 表示“在校”,2 表示“休學”,3 表示“肄業”,5表示“畢業”。
現在想要創建一個查詢學生表的存儲過程,我們希望它能靈活點兒,可以查詢出某幾個狀態下的學生。例如,假設存儲過程叫做 SP_QUERY_STU_BY_STATES,
SP_QUERY_STU_BY_STATES('2,3')
將獲得所有休學和肄業的學生。我們從最簡單的方法開始,尋求一種相對較好的解決方案。
【方法1】將參數直接放在 in 表達式里面(不可用)
create or replace procedure SP_QUERY_STU_BY_STATES1( cur_OUT out SYS_REFCURSOR, p_states varchar2 ) is begin open cur_OUT for select t.* from t_stu t where t.state in (p_states); end SP_QUERY_STU_BY_STATES1;
調用 SP_QUERY_STU_BY_STATES1('2') 沒有問題,但是調用 SP_QUERY_STU_BY_STATES1('2,3') 會報“ORA-01722:無效數字”的錯誤。因為 Oracle 不能把字符串直接轉換為列表。
【方法2】使用動態SQL語句(可用,但是有許多缺點)
將 p_states 作為SQL語句的一部分拼接起來之后再執行,就不會報 ORA-01722 錯誤了。
create or replace procedure SP_QUERY_STU_BY_STATES2( cur_OUT out SYS_REFCURSOR, p_states varchar2 ) is query_string VARCHAR2(4000); begin query_string := 'select /*+RULE*/ t.* from t_stu t where t.state in (' || p_states || ')'; dbms_output.put_line(query_string); -- for debug open cur_OUT for query_string; end SP_QUERY_STU_BY_STATES2;
注意這里使用增加 /*+RULE*/ 標記的方式強制使用RBO優化器,可以讓Oracle利用STATE字段上的索引而提高效率。實測查詢狀態為2,3的學生(從3,000,000條里取出154條數據),不加 /*+RULE*/ 標記時CBO會使用全表掃描,耗時2.3秒(即使剛剛做完表分析也要耗時0.6秒);加上 /*+RULE*/ 標記利用STATE字段上的索引,耗時0.172秒。所以后面的所有查詢都會加上/*+RULE*/ 標記。
雖然方法2可以得到正確的結果,但是它有好幾個讓人抓狂的缺點。
1. 動態SQL的效率要比靜態SQL稍低。
2. 可讀性差。SQL語句本身可讀性就不好。想象一下,讀一個上百行的復雜查詢本身就很讓人頭大了,如果里面還有大量判斷語句和字符串拼接,整個代碼會丑得讓人想吐。
3. 語法錯誤要到運行時才會暴露出來。
4. 想看執行計划挺不方便的,要使用 dbms_output.put_line() 把生成的SQL輸出才能查看執行計划。
總之,動態SQL是非常靈活同時又是非常惡心的方法。記得有一天吃完午飯,突然感覺實在受不了動態SQL了,就憋了半小時,想到了下面這個方法。
【方法3】使用反着的LIKE語句(可用,但效率低下)
create or replace procedure SP_QUERY_STU_BY_STATES3( cur_OUT out SYS_REFCURSOR, p_states varchar2 ) is begin open cur_OUT for select /*+RULE*/ * from t_stu t where ',' || p_states || ',' like '%,' || t.state || ',%'; end SP_QUERY_STU_BY_STATES3;
上面這段代碼可以這么理解:假設 p_states 參數為 '2,3',對於 STATE 字段為 2 或 3 的數據, '2,3' like '%2%' 和 '2,3' like '%3%' 都會被判定為真;對於STATE字段為5的數據,'2,3' like '%5%' 會被判定為假,這樣自然就篩選出了狀態為2和3的數據。之所以拼接了許多 “,” ,是因為如果有一個狀態是12的話,'12,3' like '%2%'也會被判定為真,這樣就錯誤地把不需要的數據也包含進來了。
這個方法除了寫法有些奇怪之外,最大的缺點是性能很差——這種寫法會造成全表掃描,類型轉換和字符串匹配也要耗費不少的時間。實測獲取狀態為2,3的數據耗時2.3秒。
【方法4】將字符串轉換為數組(可用,而且性能好)
先來看一下最終結果
create or replace procedure SP_QUERY_STU_BY_STATES4( cur_OUT out SYS_REFCURSOR, p_states varchar2 ) is begin open cur_OUT for select /*+RULE*/ * from t_stu t where t.state in (select column_value from TABLE(f_cstr_to_list(p_states))); end SP_QUERY_STU_BY_STATES4;
這種方法可以利用STATE上的索引,性能很好。實測獲取2,3狀態的數據耗時0.171秒。
這個方法的重點在於如何把逗號分隔的字符串狀態列表轉換為數組。首先,需要定義一個內容為字符串的數組類型 t_strlist
CREATE OR REPLACE Type t_strlist as Table of Varchar2(4000);
由於Oracle沒有分割字符串的 split 函數,下面這個將逗號分隔的字符串轉換為數組的函數稍稍有些雜亂,我盡量寫得可讀性好一點,相信並不難看懂。
CREATE OR REPLACE Function f_cstr_to_list -- 將逗號分隔的字符串分解為列表 ( cstr In Varchar2 ) Return t_strlist is v_start number := 1; -- 迭代搜索開始位置 v_i number := 1; -- 迭代次數 v_position number := 0; -- 每次迭代找到的逗號字符的位置 v_str varchar2(4000); -- 源字符串 Result t_strlist; Begin Result := t_strlist(); v_str := cstr || ','; loop v_position := instr(v_str, ',', 1, v_i); if(v_position > 0) then Result.EXTEND; Result(v_i) := substr(v_str, v_start, v_position-v_start); v_start := v_position + 1; v_i := v_i + 1; else exit; end if; end loop; return Result; End;
這種方法除了需要自己寫一個自定義函數有些麻煩(當然只需麻煩一次),而且需要冒函數寫得不對而引發BUG的風險之外,可以說是非常完美。當然,實戰中往往還需要按姓名等字段進行模糊查詢,這時的效率如何呢?我們來試試下面這個更為實用一點的存儲過程。
create or replace procedure SP_QUERY_STU_BY_NAME_STATES( cur_OUT out SYS_REFCURSOR, p_name varchar2, p_states varchar2 ) is begin open cur_OUT for select /*+RULE*/ * from t_stu t where t.name like '%' || p_name || '%' and t.state in (select column_value from TABLE(f_cstr_to_list(p_states))); end SP_QUERY_STU_BY_NAME_STATES;
看一下執行計划:
Oracle會先使用STATE上的索引將檢索范圍縮小然后再模糊匹配,效率自然會比較高。實測從3,000,000條數據里獲取77條耗時0.11秒。