Thinkphp 模型和數據庫:性能和安全

本章的內容主要講解了如何給數據庫的CURD查詢添加回調事件,以及如何在最底層的SQL層面進行監聽和做出性能分析及對查詢性能做出優化建議,最後給出了一些安全方面的建議,學習內容主要從性能分析和優化,以及安全三個方面進行講解:

  • 性能分析
  • 數據庫調試模式
  • 獲取查詢次數
  • 獲取SQL
  • 開啟性能分析
  • SQL監聽
  • 性能優化
  • SQL優化
  • 字段緩存
  • 數據緩存
  • 模型緩存
  • 查詢事件
  • 數據安全
  • 底層防護
  • 寫入過濾
  • 安全建議
  • 總結

性能分析

除了一些糟糕的業務邏輯,框架的性能瓶頸一般都是在數據庫(其它方面的性能沒什麼好糾結的)。業務邏輯的優化暫時不在本書的討論範疇,我們首先來學習如何進行數據庫的性能分析。

數據庫調試模式

和應用的調試模式不同,數據庫有自己獨立的調試模式開關,在第一章我們已經提過,數據庫配置參數中的debug參數就是數據庫調試模式的開關。

<code>// 數據庫調試模式
'debug' => true,/<code>

數據庫調試模式開啟後,可以支持下列行為:

  • 記錄SQL日誌;
  • 分析SQL性能;
  • 支持SQL監聽;

由於上述行為不可避免會產生額外的開銷,因此對性能存在一定的影響,但並不大,因為所有的日誌是最終統一一次性寫入,而且可以設置為某個用戶才寫入日誌。

在生產模式下面,必須關閉應用調試模式(app_debug),否則會暴露你的服務器敏感信息。和應用調試模式不同,開啟數據庫調試模式並不會對外暴露任何安全信息,因此是否開啟數據庫調試模式,看自己的需求。

獲取查詢次數

使用Db::getQueryTimes()方法可以獲取當前的數據庫查詢次數,如果使用true作為參數的話可以獲取包括寫操作在內的查詢次數。

<code>// 獲取讀操作次數
$read = Db::getQueryTimes();
// 獲取所有的查詢次數
$count = Db::getQueryTimes(true);/<code>

如果開啟了頁面Trace顯示的話,可以直觀的看到當前請求的查詢信息。

Thinkphp 模型和數據庫:性能和安全

調用存儲過程會被認為是執行一次查詢操作而非寫操作,儘管存儲過程內部可能會有寫入操作。

獲取SQL

可以用getLastsql方法獲取最後一次執行的SQL語句,無論是使用Db類還是模型類,所以下面的方式都是有效的:

<code>Db::name('user')->where('id', '>', 0)->select();
echo Db::getLastSql();
$user = User::get(1);
echo $user->getLastsql();/<code>

getLastSql方法即使關閉數據庫調試模式一樣有效

如果使用了文件類型記錄日誌,並且開啟了數據庫調試模式的話,在日誌文件中可以看到所有的SQL歷史記錄。

開啟性能分析

框架不但能記錄SQL日誌,而且可以對查詢的SQL語句作出性能分析,幫助你快速找出數據庫性能瓶頸。

確保在數據庫配置文件中開啟下面兩個參數:

<code>// 開啟數據庫調試模式
'debug' => true,
// 開啟SQL性能分析
'sql_explain' => true,/<code>

開啟sql_explain參數後,會對查詢的SQL做EXPLAIN解析(由每個連接器類的getExplain方法完成查詢SQL分析),並把解析結果合併記錄到SQL日誌中(注意:目前僅對Mysql數據庫有效)。

下面是一個查詢的分析日誌例子:

<code>[ 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, ) ]/<code>

SQL日誌中會記錄每個SQL的執行時間以及EXPLAIN分析結果,框架只是記錄分析結果,至於如何查出問題和解決則需要你具備一定的SQL性能分析和優化知識。

當EXPLAIN分析結果中的extra中使用了filesort或者temporary的話,系統會額外記錄一個警告錯誤告訴我們某條SQL存在性能問題需要處理。

SQL監聽

如果覺得內置的性能分析不夠全面,完全可以對執行的SQL進行監聽並且對接第三方的SQL分析類庫。使用listen方法註冊SQL監聽,例如可以在應用公共文件或者某個行為擴展中添加如下代碼:

<code>Db::listen(function ($sql, $time, $explain) {
// 記錄SQL
Log::record($sql . ' [' . $time . 's]', 'sql');
// 查看性能分析結果
dump($explain);

});/<code>

如果關閉了sql_explain參數,explain參數就是一個空數組,你可以在監聽方法中自行分析SQL性能問題。

監聽的閉包方法支持傳入三個參數,分別是:SQL語句、執行時間(秒)和性能分析結果(數組),並注意如下事項:

  • 如果註冊了多個SQL監聽方法,則會依次調用;
  • 一旦註冊了SQL監聽,則SQL日誌和分析日誌自動無效,由監聽方法接管;

性能優化

現在我們已經基本掌握了性能分析的手段,那麼如何進行性能優化(本書中的優化範疇主要是數據庫操作層面的)就是擺在開發人員面前的一件棘手大事,如果是一般的應用可能主要做好數據表的索引就基本上沒什麼大的性能問題,對於大流量及高併發的應用,優化的手段和空間就比較多,因為這個情況下任何一個細小的優化都能帶來可觀的性能改進。

SQL優化

這裡說的SQL優化主要針對數據庫層面的優化,對於Mysql數據庫來說,下面是一些比較常規的建議:

  • 儘量少用SQL函數(會減少數據庫自身查詢緩存的命中率)而是用PHP變量傳入;
  • 給常用的查詢字段建立索引或者聯合索引;
  • 對JOIN的條件字段建立索引,並且採用相同的數據類型(包括字符集);
  • 避免使用ORDER BY RAND();
  • 儘量調用field方法顯式列出查詢的字段,即使用field(true) ;
  • 養成給數據表設置自增主鍵的習慣;
  • 合理設計你的數據表字段類型;
  • 對於大數據表使用垂直分表把數據表分為固定長度和不定長的兩個表;

更深層次的優化可以對Mysql的配置參數進行優化配置(沒有一勞永逸的配置優化,一定是針對應用場景的),相信大部分應用暫時還不需要到優化配置的地步,首先考慮的還是架構設計的優化,數據庫配置的優化策略對應用的部署遷移會造成額外的成本以及不可預知的問題,如果你不是一個DBA角色不建議頻繁調整配置參數。

字段緩存

說完了數據庫層面的優化,我們後面著重來說下框架和應用層面的優化。

為了更安全的進行數據庫操作,框架底層在查詢數據表數據的時候,會首先獲取該數據表的字段信息,包括字段名稱、字段類型以及主鍵名,對於不在字段列表中的字段則會進行忽略處理甚至拋出異常,字段類型則用於進行寫入和查詢的自動參數綁定,雖然說每個數據表只會獲取一次字段信息,但每次請求都要重新獲取一次不免覺得有點性能浪費。不過在開發階段,如果經常會涉及到字段信息的變化,還是無所謂,但如果已經部署上線了的話,還是建議使用字段緩存,也可以有效提高查詢性能,我們會在頁面Trace的SQL欄中看到類似的信息

<code>[ SQL ] SHOW COLUMNS FROM `user` [ RunTime:0.001582s ]/<code>

其實就是查詢數據表user的字段信息的SQL語句(不同的數據庫查詢字段信息的SQL語句是不同的,由連接器類的getFields方法完成查詢)。

部署上線後,可以在命令行下執行以下指令生成字段緩存,在命令行切換到應用的根目錄(think文件所在目錄),輸入:

<code>php think optimize:schema/<code>

會自動生成當前數據庫配置文件中定義的數據表字段緩存,執行後會自動在runtime/schema目錄下面按照數據表生成字段緩存文件,緩存文件的命名格式為:

數據庫名.數據表名.php

如果你的應用有多個數據庫的操作,也可以指定數據庫生成字段緩存(必須有用戶權限),例如,下面用--db參數指定生成demo數據庫下面的所有數據表的字段緩存信息。

<code>php think optimize:schema --db demo/<code>

如果你的應用不同的模塊使用了不同的數據庫連接,還可以根據模塊來生成,用--module參數指定模塊如下:

<code>php think optimize:schema --module index/<code>

會讀取index模塊的模型來生成數據表字段緩存,沒有繼承think\\Model類的模型和抽象類不會生成。

每次執行指令都會重新生成數據表字段緩存文件,如果只是更改了數據表的某個字段或者增加了新的字段,重新部署上線的時候,支持單獨更新某個數據表的緩存。

使用 --table參數指定需要更新的數據表:

<code>php think optimize:schema --table user/<code>

支持指定數據庫名稱

<code>php think optimize:schema --table demo.think_user/<code>

生成字段緩存後,你會發現數據庫的查詢性能提升明顯,尤其是在請求中操作大量數據表的情況下。

數據緩存

數據庫的優化手段有時候比不過架構和緩存的設計優化,而架構的優化是一個綜合的範疇,需要針對具體的邏輯和場景,並且優化的手段通常多元化,模型關聯的設計也是底層提供的架構設計的優化手段之一(使用預載入查詢可以有效減少數據庫查詢次數),現在我們要講的是如何利用數據緩存策略來減少數據庫的查詢開銷,這是一個不依賴數據庫的普適優化策略。

數據庫的數據緩存並不是你理解的直接使用Cache類進行操作,那樣太麻煩了,每次都要手動設置及額外讀取,也許像下面這樣:

<code>$user = Cache::get('user_cache');
if (!$user) {
$user = Db::table('user')
->where('id', 10)
->find();
Cache::set('user_cache', $user);
}/<code>

查詢類封裝了一個數據緩存的鏈式方法cache,可以很方便的進行查詢數據的自動緩存和讀取,以及緩存數據的自動更新。數據庫的緩存策略主要就是掌握cache鏈式方法的使用,下面我們仔細給你講解下用法。

先給出一個最簡單的用法:

<code>Db::table('user')
->cache(600)
->where('id', 10)
->find();

Db::table('user')
->where('status', 1)
->cache(600)
->count();/<code>

可以對find、select、value和column方法及其衍生方法使用數據緩存功能,不支持原生查詢query方法。

cache方法如果傳入數字,表示查詢數據的緩存時間(秒),所以上面的查詢在10分鐘以內多次調用的話不會重複查詢數據庫,而是直接讀取緩存數據(使用當前配置的緩存類型和緩存參數)。

如果需要在外部調用緩存數據(儘管並不常見,但在跨模塊的時候可能會需要),可以指定緩存標識,例如:

<code>Db::table('user')
->cache('user_cache_key', 600)
->where('id', 10)
->find();/<code>

cache方法的第一個參數使用字符串表示緩存標識,這個時候第二個參數就表示緩存有效期,然後可以在外部調用緩存的用戶數據:

<code>// 緩存數據有效期為10分鐘 

$userData = Cache::get('user_cache_key');/<code>

內置的數據緩存策略對原生查詢不起作用(只能單獨使用緩存方法來進行緩存),相比緩存的優勢用原生查詢的那點性能優越感這個時候已經蕩然無存了,查詢構造器的優勢就很明顯了。

數據緩存策略的關鍵是如何及時更新緩存數據,我們來看下如何做到自動更新緩存,下面的內容才是數據緩存要講的關鍵。

只需要在調用更新或者刪除方法之前調用cache方法(見證奇蹟的時刻到了):

<code>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]);
/<code>

在更新數據的時候調用cache手動清除緩存,所以最後查詢的數據不會受第一條查詢緩存的影響,查詢出來的數據依然是同步更新後的數據。

同樣,如果進行了刪除操作,也會自動清除緩存數據。

<code>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]);/<code>

確保查詢和更新或者刪除使用相同的緩存標識才能自動清除緩存。

比較常用的數據緩存是以主鍵為查詢條件的單個數據的緩存,所以如果使用find方法並且使用主鍵查詢的情況,緩存更新更智能。update或者delete方法可以不需要調用cache方法,也會自動清理緩存,例如:

<code>Db::table('user')
->cache(true)
->find(1);

Db::table('user')
->update(['id' => 1, 'name' => 'topthink']);

Db::table('user')
->cache(true)
->find(1);/<code>

根據主鍵查詢的話,緩存更新是自動的,因此上面的例子最後查詢的數據會是更新後的數據。

使用where方法查詢主鍵條件的話,效果一樣:

<code>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();/<code>

模型緩存

除了使用Db類,模型類還提供了更方便的方法進行數據緩存。如果是緩存讀取單個數據,可以使用:

<code>// 查詢數據並緩存讀取
$user = User::get(1, [], true);
// 設置緩存有效期
$user = User::get(1, [], 600);/<code>

由於第二個參數是預載入查詢,所以查詢緩存屈居二線了_,不過如果你的版本在5.0.6以上的話,可以直接寫成:

<code>// 查詢數據並緩存讀取
$user = User::get(1, true);
// 設置緩存有效期
$user = User::get(1, 600);/<code>

當使用主鍵查詢、更新和刪除模型數據的時候,會自動更新模型數據緩存。如果你的查詢條件不是主鍵,可以指定緩存標識,並在刪除的時候帶上緩存標識,例如:

<code>模型數據緩存標識不能直接在外部讀取,因為緩存的數據都是數組而不是對象,所以下面才是正確的姿勢。// 查詢name為thinkphp的用戶數據並緩存讀取
$user = User::cache('user_key_thinkphp')
->getByName('thinkphp');
// 刪除數據並更新緩存數據
$user->cache('user_key_thinkphp')
->delete();/<code>

模型數據緩存標識不能直接在外部讀取,因為緩存的數據都是數組而不是對象,所以下面才是正確的姿勢。

<code>// 查詢name為thinkphp的用戶數據並緩存讀取
$user = User::cache('user_key_thinkphp')
->getByName('thinkphp');
// 外部讀取模型數據緩存
$data = new User(Cache::get('user_key_thinkphp'));/<code>

同樣的用法,如果要緩存讀取多個數據,使用下面的方式:

<code>// 查詢多個數據並緩存讀取
$users = User::all([1, 2, 3], [], true);
// 設置緩存有效期
$users = User::all([1, 2, 3], [], 3600);/<code>

5.0.6版本以上同樣可以使用

<code>// 查詢多個數據並緩存讀取
$users = User::all([1, 2, 3], true);
// 設置緩存有效期
$users = User::all([1, 2, 3], 3600);/<code>

模型的數據緩存配合關聯預載入查詢的話效果更佳,關於如何使用關聯預載入查詢請參考上一章的內容。

查詢事件

使用查詢事件可以在不改變原有數據查詢代碼的前提下制定獨立的緩存策略,先來了解下什麼是查詢事件。

查詢事件是針對數據庫的CURD操作而設計的回調方法,主要包括:

事件 描述 before_select select查詢前回調 before_find find查詢前回調 after_insert insert操作成功後回調 after_update update操作成功後回調 after_delete delete操作成功後回調

使用下面的方式註冊一個查詢事件

<code>Db::event('before_select', function ($options, $query) {
// 事件處理
});/<code>

如果before_select或者before_find回調方法有返回數據,則表示提前返回查詢結果,不會繼續執行查詢操作。

<code>Db::event('before_find', function ($options, $query) {
// 事件處理
if ('user' == $options['table']) {
$result = ['id' => 1, 'name' => 'thinkphp'];
return $result;
}
});

$user = Db::table('user')->find();
/<code>

user變量最終的結果是['id'=>1,'name'=>'thinkphp']。

下面的例子我們沒有使用cache方法進行數據緩存,而是利用查詢事件來定製自己的數據緩存策略。

<code>// 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;
}
});/<code>

註冊完查詢回調方法後,下面的查詢除了寫操作會執行數據庫操作,其它的查詢方法都直接讀取緩存數據,而且始終保持最新的數據。

<code>$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); /<code>

數據安全

安全和優化就如同魚和熊掌一般,很難兼得。從某種程度上說,數據安全比性能優化更重要,因此為了更加安全和穩健運行,犧牲一定的性能都是值得的,下面我們來學習下基本的安全策略。

底層防護

5.0版本提供了更高的底層安全策略,雖然不至於因此而高枕無憂,但也完全不必杞人憂天,主要體現在:

  • WEB訪問目錄和應用目錄隔離;
  • 內置使用PDO預處理和自動參數綁定機制;
  • 默認用戶提交數據不支持數組;
  • 支持數據自動過濾機制;

只要善於運用系統提供的安全手段和做好一些配置,可確保你的應用安全無虞,聽我給你細細道來。

寫入過濾

由於系統的安全機制,任何非數據表的字段如果要寫入數據庫都會導致異常,如果你不希望非數據表字段寫入數據庫的時候拋出異常,而只是忽略就行,那麼可以使用下面兩種方式。

如果是僅僅當前操作忽略,則可以使用strict方法,例如:

<code>Db::table('user')
->strict(false)
->insert([
'name' => 'thinkphp',
'nickname' => '流年',
'test' => '測試數據',
]);
/<code>

由於user表中並不存在test字段,因此test數據會被直接忽略,但由於使用了strict(false)方法,而不會拋出異常。

如果希望全局不拋出異常,可以在數據庫配置文件中設置

<code>        // 是否嚴格檢查字段是否存在
'fields_strict' => false,/<code>

但有些時候我們還需要限制寫入數據庫的字段,避免被用戶提交更新一些敏感數據,並非只有查詢的時候可以使用field方法指定字段列表,我們還可以在寫入數據的時候使用field方法限制字段寫入。

<code>Db::table('user')
->field('name,nickname')
->where('id', 1)
->update([
'name' => 'thinkphp',
'nickname' => '流年',
'email' => '[email protected]',
]);/<code>

上面的例子中,由於我們用field方法限制了寫入的字段列表,因此email數據不會被更新,而是直接忽略。

同樣,field方法也支持排除某些字段

<code>Db::table('user')
->field('email,score', true)
->where('id', 1)
->update([
'name' => 'thinkphp',
'nickname' => '流年',
'email' => '[email protected]',
]);/<code>

如果使用模型操作的話,我們還可以使用allowField方法提前對數據進行字段過濾

<code>$user           = User::get(1);
$user->name = 'thinkphp';
$user->nickname = '流年';
$user->email = '[email protected]';
$user->allowField('name,nickname')
->save();/<code>

allowField過濾數據並不會導致異常,和field方法不同,allowField方法並不支持字段排除,如果調用allowField(true) 表示過濾數據表字段之外的數據

模型還額外提供了一個只讀字段的功能,針對某些字段只提供寫入功能而不提供更新功能,具體可以參考模型高級用法一章的內容。

安全建議

為了讓你的應用更安全,綜合之前提到的各種安全因素,在數據庫的層面我們給出如下安全建議:

  • 對用戶輸入的數據做盡可能的驗證;
  • 對寫入的數據做好過濾,避免異常;
  • 避免直接使用用戶提交數據作為查詢條件;
  • 查詢字段名不應該由表單或者用戶決定;
  • 對於get和find方法的參數建議做好Null判斷;
  • 數據輸出的時候注意做好XSS安全過濾;
  • 對於模型數據儘量隱藏敏感數據後輸出;
  • 對於業務數據的寫入操作應當做好權限檢查;
  • 寫入數據嚴格使用field方法限制寫入字段;

舉個例子,如果你開放查詢字段名給用戶提交而未作判斷直接作為查詢條件,例如下面的代碼:

<code>$where = request()->param();
// 查詢用戶是否存在
$user = Db::table('user')
->where($where)
->find();/<code>

假設你的表單裡面有一個name字段,那麼,用戶就可以在瀏覽器構造一個name|email字段完成OR查詢,查詢的結果可能完全不同了,極有可能造成邏輯漏洞。

正確的查詢方式應該是:

<code>// 查詢用戶是否存在
$user = Db::table('user')
->where('name',request()->param('name'))
->find();/<code>


作者:寒冬夜行人_51a4
鏈接:https://www.jianshu.com/p/04853e463c81
來源:簡書
著作權歸作者所有。商業轉載請聯繫作者獲得授權,非商業轉載請註明出處。


分享到:


相關文章: