本章的內容主要講解了如何給數據庫的CURD查詢添加回調事件,以及如何在最底層的SQL層面進行監聽和做出性能分析及對查詢性能做出優化建議,最后給出了一些安全方面的建議,學習內容主要從性能分析和優化,以及安全三個方面進行講解:
性能分析
除了一些糟糕的業務邏輯,框架的性能瓶頸一般都是在數據庫(其它方面的性能沒什么好糾結的)。業務邏輯的優化暫時不在本書的討論范疇,我們首先來學習如何進行數據庫的性能分析。
數據庫調試模式
和應用的調試模式不同,數據庫有自己獨立的調試模式開關,在第一章我們已經提過,數據庫配置參數中的debug
參數就是數據庫調試模式的開關。
// 數據庫調試模式
'debug' => true,
數據庫調試模式開啟后,可以支持下列行為:
- 記錄SQL日志;
- 分析SQL性能;
- 支持SQL監聽;
由于上述行為不可避免會產生額外的開銷,因此對性能存在一定的影響,但并不大,因為所有的日志是最終統一一次性寫入,而且可以設置為某個用戶才寫入日志。
在生產模式下面,必須關閉應用調試模式(
app_debug
),否則會暴露你的服務器敏感信息。和應用調試模式不同,開啟數據庫調試模式并不會對外暴露任何安全信息,因此是否開啟數據庫調試模式,看自己的需求。
獲取查詢次數
使用Db::getQueryTimes()
方法可以獲取當前的數據庫查詢次數,如果使用true作為參數的話可以獲取包括寫操作在內的查詢次數。
// 獲取讀操作次數
$read = Db::getQueryTimes();
// 獲取所有的查詢次數
$count = Db::getQueryTimes(true);
如果開啟了頁面Trace顯示的話,可以直觀的看到當前請求的查詢信息。
調用存儲過程會被認為是執行一次查詢操作而非寫操作,盡管存儲過程內部可能會有寫入操作。
獲取SQL
可以用getLastsql
方法獲取最后一次執行的SQL語句,無論是使用Db
類還是模型類,所以下面的方式都是有效的:
Db::name('user')->where('id', '>', 0)->select();
echo Db::getLastSql();
$user = User::get(1);
echo $user->getLastsql();
getLastSql
方法即使關閉數據庫調試模式一樣有效
如果使用了文件類型記錄日志,并且開啟了數據庫調試模式的話,在日志文件中可以看到所有的SQL歷史記錄。
開啟性能分析
框架不但能記錄SQL日志,而且可以對查詢的SQL語句作出性能分析,幫助你快速找出數據庫性能瓶頸。
確保在數據庫配置文件中開啟下面兩個參數:
// 開啟數據庫調試模式
'debug' => true,
// 開啟SQL性能分析
'sql_explain' => true,
開啟sql_explain
參數后,會對查詢的SQL做EXPLAIN
解析(由每個連接器類的getExplain
方法完成查詢SQL分析),并把解析結果合并記錄到SQL日志中(注意:目前僅對Mysql
數據庫有效)。
下面是一個查詢的分析日志例子:
[ SQL ] SELECT * FROM `user` WHERE `id` IN (2) [ RunTime:0.000703s ]
[ EXPLAIN : array ( 'id' => 1, 'select_type' => 'SIMPLE', 'table' => 'think_user', 'partitions' => NULL, 'type' => 'system', 'possible_keys' => 'PRIMARY', 'key' => NULL, 'key_len' => NULL, 'ref' => NULL, 'rows' => 1, 'filtered' => 100.0, 'extra' => NULL, ) ]
SQL日志中會記錄每個SQL的執行時間以及EXPLAIN
分析結果,框架只是記錄分析結果,至于如何查出問題和解決則需要你具備一定的SQL性能分析和優化知識。
當
EXPLAIN
分析結果中的extra
中使用了filesort
或者temporary
的話,系統會額外記錄一個警告錯誤告訴我們某條SQL存在性能問題需要處理。
SQL監聽
如果覺得內置的性能分析不夠全面,完全可以對執行的SQL進行監聽并且對接第三方的SQL分析類庫。使用listen
方法注冊SQL監聽,例如可以在應用公共文件或者某個行為擴展中添加如下代碼:
Db::listen(function ($sql, $time, $explain) {
// 記錄SQL
Log::record($sql . ' [' . $time . 's]', 'sql');
// 查看性能分析結果
dump($explain);
});
如果關閉了
sql_explain
參數,explain
參數就是一個空數組,你可以在監聽方法中自行分析SQL性能問題。
監聽的閉包方法支持傳入三個參數,分別是:SQL語句、執行時間(秒)和性能分析結果(數組),并注意如下事項:
- 如果注冊了多個SQL監聽方法,則會依次調用;
- 一旦注冊了SQL監聽,則SQL日志和分析日志自動無效,由監聽方法接管;
性能優化
現在我們已經基本掌握了性能分析的手段,那么如何進行性能優化(本書中的優化范疇主要是數據庫操作層面的)就是擺在開發人員面前的一件棘手大事,如果是一般的應用可能主要做好數據表的索引就基本上沒什么大的性能問題,對于大流量及高并發的應用,優化的手段和空間就比較多,因為這個情況下任何一個細小的優化都能帶來可觀的性能改進。
SQL優化
這里說的SQL優化主要針對數據庫層面的優化,對于Mysql
數據庫來說,下面是一些比較常規的建議:
- 盡量少用SQL函數(會減少數據庫自身查詢緩存的命中率)而是用PHP變量傳入;
- 給常用的查詢字段建立索引或者聯合索引;
- 對JOIN的條件字段建立索引,并且采用相同的數據類型(包括字符集);
- 避免使用
ORDER BY RAND()
; - 盡量調用
field
方法顯式列出查詢的字段,即使用field(true)
; - 養成給數據表設置自增主鍵的習慣;
- 合理設計你的數據表字段類型;
- 對于大數據表使用垂直分表把數據表分為固定長度和不定長的兩個表;
更深層次的優化可以對Mysql的配置參數進行優化配置(沒有一勞永逸的配置優化,一定是針對應用場景的),相信大部分應用暫時還不需要到優化配置的地步,首先考慮的還是架構設計的優化,數據庫配置的優化策略對應用的部署遷移會造成額外的成本以及不可預知的問題,如果你不是一個
DBA
角色不建議頻繁調整配置參數。
字段緩存
說完了數據庫層面的優化,我們后面著重來說下框架和應用層面的優化。
為了更安全的進行數據庫操作,框架底層在查詢數據表數據的時候,會首先獲取該數據表的字段信息,包括字段名稱、字段類型以及主鍵名,對于不在字段列表中的字段則會進行忽略處理甚至拋出異常,字段類型則用于進行寫入和查詢的自動參數綁定,雖然說每個數據表只會獲取一次字段信息,但每次請求都要重新獲取一次不免覺得有點性能浪費。不過在開發階段,如果經常會涉及到字段信息的變化,還是無所謂,但如果已經部署上線了的話,還是建議使用字段緩存,也可以有效提高查詢性能,我們會在頁面Trace的SQL欄中看到類似的信息
[ SQL ] SHOW COLUMNS FROM `user` [ RunTime:0.001582s ]
其實就是查詢數據表user
的字段信息的SQL語句(不同的數據庫查詢字段信息的SQL語句是不同的,由連接器類的getFields
方法完成查詢)。
部署上線后,可以在命令行下執行以下指令生成字段緩存,在命令行切換到應用的根目錄(think
文件所在目錄),輸入:
php think optimize:schema
會自動生成當前數據庫配置文件中定義的數據表字段緩存,執行后會自動在runtime/schema
目錄下面按照數據表生成字段緩存文件,緩存文件的命名格式為:
數據庫名.數據表名.php
如果你的應用有多個數據庫的操作,也可以指定數據庫生成字段緩存(必須有用戶權限),例如,下面用--db
參數指定生成demo
數據庫下面的所有數據表的字段緩存信息。
php think optimize:schema --db demo
如果你的應用不同的模塊使用了不同的數據庫連接,還可以根據模塊來生成,用--module
參數指定模塊如下:
php think optimize:schema --module index
會讀取index
模塊的模型來生成數據表字段緩存,沒有繼承think\Model類的模型和抽象類不會生成。
每次執行指令都會重新生成數據表字段緩存文件,如果只是更改了數據表的某個字段或者增加了新的字段,重新部署上線的時候,支持單獨更新某個數據表的緩存。
使用 --table
參數指定需要更新的數據表:
php think optimize:schema --table user
支持指定數據庫名稱
php think optimize:schema --table demo.think_user
生成字段緩存后,你會發現數據庫的查詢性能提升明顯,尤其是在請求中操作大量數據表的情況下。
數據緩存
數據庫的優化手段有時候比不過架構和緩存的設計優化,而架構的優化是一個綜合的范疇,需要針對具體的邏輯和場景,并且優化的手段通常多元化,模型關聯的設計也是底層提供的架構設計的優化手段之一(使用預載入查詢可以有效減少數據庫查詢次數),現在我們要講的是如何利用數據緩存策略來減少數據庫的查詢開銷,這是一個不依賴數據庫的普適優化策略。
數據庫的數據緩存并不是你理解的直接使用Cache
類進行操作,那樣太麻煩了,每次都要手動設置及額外讀取,也許像下面這樣:
$user = Cache::get('user_cache');
if (!$user) {
$user = Db::table('user')
->where('id', 10)
->find();
Cache::set('user_cache', $user);
}
查詢類封裝了一個數據緩存的鏈式方法cache
,可以很方便的進行查詢數據的自動緩存和讀取,以及緩存數據的自動更新。數據庫的緩存策略主要就是掌握cache
鏈式方法的使用,下面我們仔細給你講解下用法。
先給出一個最簡單的用法:
Db::table('user')
->cache(600)
->where('id', 10)
->find();
Db::table('user')
->where('status', 1)
->cache(600)
->count();
可以對
find
、select
、value
和column
方法及其衍生方法使用數據緩存功能,不支持原生查詢query
方法。
cache
方法如果傳入數字,表示查詢數據的緩存時間(秒),所以上面的查詢在10
分鐘以內多次調用的話不會重復查詢數據庫,而是直接讀取緩存數據(使用當前配置的緩存類型和緩存參數)。
如果需要在外部調用緩存數據(盡管并不常見,但在跨模塊的時候可能會需要),可以指定緩存標識,例如:
Db::table('user')
->cache('user_cache_key', 600)
->where('id', 10)
->find();
cache
方法的第一個參數使用字符串表示緩存標識,這個時候第二個參數就表示緩存有效期,然后可以在外部調用緩存的用戶數據:
// 緩存數據有效期為10分鐘
$userData = Cache::get('user_cache_key');
內置的數據緩存策略對原生查詢不起作用(只能單獨使用緩存方法來進行緩存),相比緩存的優勢用原生查詢的那點性能優越感這個時候已經蕩然無存了,查詢構造器的優勢就很明顯了。
數據緩存策略的關鍵是如何及時更新緩存數據,我們來看下如何做到自動更新緩存,下面的內容才是數據緩存要講的關鍵。
只需要在調用更新或者刪除方法之前調用cache
方法(見證奇跡的時刻到了):
Db::table('user')
->cache('user_data')
->select([1, 3, 5]);
Db::table('user')
->cache('user_data')
->update(['id' => 1, 'name' => 'thinkphp']);
Db::table('user')
->cache('user_data')
->select([1, 3, 5]);
在更新數據的時候調用cache
手動清除緩存,所以最后查詢的數據不會受第一條查詢緩存的影響,查詢出來的數據依然是同步更新后的數據。
同樣,如果進行了刪除操作,也會自動清除緩存數據。
Db::table('user')
->cache('user_data')
->select([1, 3, 5]);
Db::table('user')
->cache('user_data')
->delete(1);
Db::table('user')
->cache('user_data')
->select([1, 3, 5]);
確保查詢和更新或者刪除使用相同的緩存標識才能自動清除緩存。
比較常用的數據緩存是以主鍵為查詢條件的單個數據的緩存,所以如果使用find
方法并且使用主鍵查詢的情況,緩存更新更智能。update
或者delete
方法可以不需要調用cache
方法,也會自動清理緩存,例如:
Db::table('user')
->cache(true)
->find(1);
Db::table('user')
->update(['id' => 1, 'name' => 'topthink']);
Db::table('user')
->cache(true)
->find(1);
根據主鍵查詢的話,緩存更新是自動的,因此上面的例子最后查詢的數據會是更新后的數據。
使用where
方法查詢主鍵條件的話,效果一樣:
Db::table('user')
->cache(true)
->where('id', 1)
->find();
Db::table('user')
->where('id', 1)
->update(['name' => 'topthink']);
Db::table('user')
->cache(true)
->where('id', 1)
->find();
模型緩存
除了使用Db類,模型類還提供了更方便的方法進行數據緩存。如果是緩存讀取單個數據,可以使用:
// 查詢數據并緩存讀取
$user = User::get(1, [], true);
// 設置緩存有效期
$user = User::get(1, [], 600);
由于第二個參數是預載入查詢,所以查詢緩存屈居二線了_,不過如果你的版本在5.0.6以上的話,可以直接寫成:
// 查詢數據并緩存讀取
$user = User::get(1, true);
// 設置緩存有效期
$user = User::get(1, 600);
當使用主鍵查詢、更新和刪除模型數據的時候,會自動更新模型數據緩存。如果你的查詢條件不是主鍵,可以指定緩存標識,并在刪除的時候帶上緩存標識,例如:
// 查詢name為thinkphp的用戶數據并緩存讀取
$user = User::cache('user_key_thinkphp')
->getByName('thinkphp');
// 刪除數據并更新緩存數據
$user->cache('user_key_thinkphp')
->delete();
模型數據緩存標識不能直接在外部讀取,因為緩存的數據都是數組而不是對象,所以下面才是正確的姿勢。
// 查詢name為thinkphp的用戶數據并緩存讀取
$user = User::cache('user_key_thinkphp')
->getByName('thinkphp');
// 外部讀取模型數據緩存
$data = new User(Cache::get('user_key_thinkphp'));
同樣的用法,如果要緩存讀取多個數據,使用下面的方式:
// 查詢多個數據并緩存讀取
$users = User::all([1, 2, 3], [], true);
// 設置緩存有效期
$users = User::all([1, 2, 3], [], 3600);
5.0.6版本以上同樣可以使用
// 查詢多個數據并緩存讀取
$users = User::all([1, 2, 3], true);
// 設置緩存有效期
$users = User::all([1, 2, 3], 3600);
模型的數據緩存配合關聯預載入查詢的話效果更佳,關于如何使用關聯預載入查詢請參考上一章的內容。
查詢事件
使用查詢事件可以在不改變原有數據查詢代碼的前提下制定獨立的緩存策略,先來了解下什么是查詢事件。
查詢事件是針對數據庫的CURD操作而設計的回調方法,主要包括:
事件 | 描述 |
---|---|
before_select |
select 查詢前回調 |
before_find |
find 查詢前回調 |
after_insert |
insert 操作成功后回調 |
after_update |
update 操作成功后回調 |
after_delete |
delete 操作成功后回調 |
使用下面的方式注冊一個查詢事件
Db::event('before_select', function ($options, $query) {
// 事件處理
});
如果before_select
或者before_find
回調方法有返回數據,則表示提前返回查詢結果,不會繼續執行查詢操作。
Db::event('before_find', function ($options, $query) {
// 事件處理
if ('user' == $options['table']) {
$result = ['id' => 1, 'name' => 'thinkphp'];
return $result;
}
});
$user = Db::table('user')->find();
user
變量最終的結果是['id'=>1,'name'=>'thinkphp']
。
下面的例子我們沒有使用cache
方法進行數據緩存,而是利用查詢事件來定制自己的數據緩存策略。
// after_insert回調方法
Db::event('after_insert', function ($options, $query) {
$pk = $query->getPk($options);
$guid = $options['table'] . '_' . $options['data'][$pk];
Cache::set($guid, $options['data'], 0);
});
// after_update回調方法
Db::event('after_update', function ($options, $query) {
$pk = $query->getPk($options);
$guid = $options['table'] . '_' . $options['data'][$pk];
$data = Cache::get($guid);
$data = array_merge($data, $options['data']);
Cache::set($guid, $data, 0);
});
// after_delete回調方法
Db::event('after_delete', function ($options, $query) {
$pk = $query->getPk($options);
$guid = $options['table'] . '_' . $options['data'][$pk];
Cache::set($guid, null, 0);
});
// before_find回調方法
Db::event('before_find', function ($options, $query) {
$pk = $query->getPk($options);
$guid = $options['table'] . '_' . $options['data'][$pk];
$data = Cache::get($guid);
if ($data) {
return $data;
}
});
注冊完查詢回調方法后,下面的查詢除了寫操作會執行數據庫操作,其它的查詢方法都直接讀取緩存數據,而且始終保持最新的數據。
$id = Db::table('user')
->insert(['name'=>'thinkphp']);
Db::table('user')->find($id);
Db::table('user')
->where('id',$id)
->update(['name'=>'topthink']);
Db::table('user')->find($id);
Db::table('user')
->delete($id);
Db::table('user')->find($id);
數據安全
安全和優化就如同魚和熊掌一般,很難兼得。從某種程度上說,數據安全比性能優化更重要,因此為了更加安全和穩健運行,犧牲一定的性能都是值得的,下面我們來學習下基本的安全策略。
底層防護
5.0
版本提供了更高的底層安全策略,雖然不至于因此而高枕無憂,但也完全不必杞人憂天,主要體現在:
- WEB訪問目錄和應用目錄隔離;
- 內置使用PDO預處理和自動參數綁定機制;
- 默認用戶提交數據不支持數組;
- 支持數據自動過濾機制;
只要善于運用系統提供的安全手段和做好一些配置,可確保你的應用安全無虞,聽我給你細細道來。
寫入過濾
由于系統的安全機制,任何非數據表的字段如果要寫入數據庫都會導致異常,如果你不希望非數據表字段寫入數據庫的時候拋出異常,而只是忽略就行,那么可以使用下面兩種方式。
如果是僅僅當前操作忽略,則可以使用strict
方法,例如:
Db::table('user')
->strict(false)
->insert([
'name' => 'thinkphp',
'nickname' => '流年',
'test' => '測試數據',
]);
由于user
表中并不存在test
字段,因此test數據會被直接忽略,但由于使用了strict(false)
方法,而不會拋出異常。
如果希望全局不拋出異常,可以在數據庫配置文件中設置
// 是否嚴格檢查字段是否存在
'fields_strict' => false,
但有些時候我們還需要限制寫入數據庫的字段,避免被用戶提交更新一些敏感數據,并非只有查詢的時候可以使用field
方法指定字段列表,我們還可以在寫入數據的時候使用field
方法限制字段寫入。
Db::table('user')
->field('name,nickname')
->where('id', 1)
->update([
'name' => 'thinkphp',
'nickname' => '流年',
'email' => 'thinkphp@qq.com',
]);
上面的例子中,由于我們用field
方法限制了寫入的字段列表,因此email
數據不會被更新,而是直接忽略。
同樣,field
方法也支持排除某些字段
Db::table('user')
->field('email,score', true)
->where('id', 1)
->update([
'name' => 'thinkphp',
'nickname' => '流年',
'email' => 'thinkphp@qq.com',
]);
如果使用模型操作的話,我們還可以使用allowField
方法提前對數據進行字段過濾
$user = User::get(1);
$user->name = 'thinkphp';
$user->nickname = '流年';
$user->email = 'thinkphp@qq.com';
$user->allowField('name,nickname')
->save();
allowField
過濾數據并不會導致異常,和field
方法不同,allowField
方法并不支持字段排除,如果調用allowField(true)
表示過濾數據表字段之外的數據
模型還額外提供了一個只讀字段的功能,針對某些字段只提供寫入功能而不提供更新功能,具體可以參考模型高級用法一章的內容。
安全建議
為了讓你的應用更安全,綜合之前提到的各種安全因素,在數據庫的層面我們給出如下安全建議:
- 對用戶輸入的數據做盡可能的驗證;
- 對寫入的數據做好過濾,避免異常;
- 避免直接使用用戶提交數據作為查詢條件;
- 查詢字段名不應該由表單或者用戶決定;
- 對于
get
和find
方法的參數建議做好Null
判斷; - 數據輸出的時候注意做好
XSS
安全過濾; - 對于模型數據盡量隱藏敏感數據后輸出;
- 對于業務數據的寫入操作應當做好權限檢查;
- 寫入數據嚴格使用
field
方法限制寫入字段;
舉個例子,如果你開放查詢字段名給用戶提交而未作判斷直接作為查詢條件,例如下面的代碼:
$where = request()->param();
// 查詢用戶是否存在
$user = Db::table('user')
->where($where)
->find();
假設你的表單里面有一個name
字段,那么,用戶就可以在瀏覽器構造一個name|email
字段完成OR查詢,查詢的結果可能完全不同了,極有可能造成邏輯漏洞。
正確的查詢方式應該是:
// 查詢用戶是否存在
$user = Db::table('user')
->where('name',request()->param('name'))
->find();
總結
到目前為止,我們已經完成了5.0的數據庫和模型的學習,最好的老師是實踐并把掌握的知識點融會貫通,在后面的附錄中我們會給大家匯總整理一些常見問題,并保持不斷更新。
感謝你堅持看完了本書的內容,您的建議是我們努力完善的動力,希望不吝賜教并隨時在本書的評論區或者
github
上給我們留言,最后祝你在新的開發征程中所向披靡,因為我們的愿景就是讓開發變得更簡單!