如果 Web 應用程式中的一個特性需要超過 1 秒或 2 秒才能完成,那麼應該怎麼辦?需要某種離線處理解決方案。學習幾種對 PHP 應用程式中長時間啟動並執行作業進行離線服務的方法。
大型的連鎖店有一個大問題。每天,在每家商店會發生數千次交易。公司執行官希望對這些資料進行挖掘。哪些產品賣得好?哪些不好?有機產品在哪裡賣得好?冰淇淋的銷售情況怎麼樣?
為了捕捉這些資料,組織必須將所有事務性資料裝載進一個資料模型,以便更適合產生公司所需的報告類型。但是,這很花費時間,而且隨著連鎖規模的增長,處理一天的資料可能要花費一天以上的時間。因此,這是個大問題。
現在,您的 Web 應用程式可能不需要處理這麼多資料,但是任何網站的處理時間都有可能超過客戶願意等待的時間。一般來說,客戶願意等待的時間是 200 毫秒,如果超過這個時間,客戶就會覺得過程 “緩慢”。這個數字基於傳統型應用程式,而 Web 使我們更有耐心了。但無論如何,不應該讓客戶等待的時間超過幾秒。所以,要採用一些策略來處理 PHP 中的批次工作。
分散的方式與 cron
在 UNIX® 機器上,執行批處理的核心程式是 cron 守護進程。這個守護進程讀取一個設定檔,這個檔案會告訴它要運行哪些命令列以及啟動並執行頻率。然後,這個守護進程就按照配置執行它們。在遇到錯誤時,它甚至能夠向指定的電子郵件地址發送錯誤輸出,從而協助對問題進行調試。
我知道一些工程師強烈主張使用線程技術。“線程!線程才是進行幕後處理的真正方法。cron 守護進程太過時了。”
我不這麼認為。
這兩種方法我都用過,我認為 cron 具備 “Keep It Simple, Stupid(KISS,簡單就是美)” 原則的優點。它使幕後處理保持簡單。不需要編寫一直啟動並執行多線程的作業處理應用程式(因此不會有記憶體流失),而是由 cron 啟動一個簡單的批處理指令碼。這個指令碼判斷是否有作業要處理,執行作業,然後退出。不需要擔心記憶體流失。也不需要擔心線程停止或陷入無限迴圈。
那麼,cron 是如何工作的?這依賴於您所處的系統內容。我只討論老式簡單的 cron 的 UNIX 命令列版本,您可以向系統管理員諮詢如何在自己的 Web 應用程式中實現它。
下面是一個簡單的 cron 配置,它在每天晚上 11 點運行一個 PHP 指令碼:
0 23 * * * jack /usr/bin/php /users/home/jack/myscript.php
前 5 個欄位定義應該啟動指令碼的時間。然後是應該用來運行這個指令碼的使用者名稱。其餘的命令是要執行的命令列。時間欄位分別是分、小時、月中的日、月和周中的日。下面是幾個樣本。
命令:
15 * * * * jack /usr/bin/php /users/home/jack/myscript.php
在每個小時的第 15 分鐘運行指令碼。
命令:
15,45 * * * * jack /usr/bin/php /users/home/jack/myscript.php
在每個小時的第 15 和第 45 分鐘運行指令碼。
命令:
*/1 3-23 * * * jack /usr/bin/php /users/home/jack/myscript.php
在早上 3 點到晚上 11 點之間的每分鐘運行指令碼。
命令
30 23 * * 6 jack /usr/bin/php /users/home/jack/myscript.php
在每星期六的晚上 11:30 運行指令碼(星期六由 6 指定)。
可以看到,組合的數量是無限的。可以根據需要控制運行指令碼的時間。還可以指定多個要啟動並執行指令碼,這樣的話,一些指令碼可以每分鐘都運行,而其他指令碼(比如備份指令碼)可以每天只運行一次。
為了指定將報告的錯誤發送到哪個電子郵件地址,可以使用 MAILTO 指令,如下所示:
MAILTO=jherr@pobox.com
注意:對於 Microsoft® Windows® 使用者,有一個等效的 Scheduled Tasks 系統可以用來定期啟動命令列進程(比如 PHP 指令碼)。
回頁首
批處理體繫結構的基礎知識
批處理是相當簡單的。在大多數情況下,採用兩個工作流程之一。第一個工作流程用於進行報告;指令碼每天運行一次,它產生報告並將報告發送給一組使用者。第二個工作流程是在響應某種請求時建立的批作業。例如,我登入進 Web 應用程式中,並要求它向系統中註冊的所有使用者發送一個訊息,將一個新的特性告訴他們。這個操作必須進行批處理,因為系統中有 10,000 個使用者。PHP 要花費一段時間才能完成這樣的任務,所以它必須由瀏覽器之外的一個作業來執行。
在第二個工作流程中,Web 應用程式只需將資訊放在某個位置,讓批處理應用程式共用它。這些資訊指定作業的性質(例如,“Send this e-mail to all the people on the system”。)批次程式運行這個作業,然後刪除作業。另一種方法是,處理常式將作業標為已完成。無論用哪種方法,作業都應該識別為已完成,這樣就不會再次運行它。
本文的其餘部分示範在 Web 應用程式前端和批處理後端之間共用資料的各種方法。
回頁首
郵件隊列
第一種方法是使用專用的郵件隊列系統。在這種模型中,資料庫中的一個表包含應該發送給各個使用者的電子郵件訊息。Web 介面使用 mailouts 類將電子郵件添加到隊列中。電子郵件處理常式使用 mailouts 類檢索未處理的電子郵件,然後再次使用它從隊列中刪除未處理的電子郵件。
這個模型首先需要 MySQL 模式。
清單 1. mailout.sql
DROP TABLE IF EXISTS mailouts;CREATE TABLE mailouts ( id MEDIUMINT NOT NULL AUTO_INCREMENT, from_address TEXT NOT NULL, to_address TEXT NOT NULL, subject TEXT NOT NULL, content TEXT NOT NULL, PRIMARY KEY ( id ));
這個模式非常簡單。每行中有一個 from 和一個 to 地址,以及電子郵件的主題和內容。
對資料庫中的 mailouts 表進行處理的是 PHP mailouts 類。
清單 2. mailouts.php
<?phprequire_once('DB.php');class Mailouts{ public static function get_db() { $dsn = 'mysql://root:@localhost/mailout'; $db =& DB::Connect( $dsn, array() ); if (PEAR::isError($db)) { die($db->getMessage()); } return $db; } public static function delete( $id ) { $db = Mailouts::get_db(); $sth = $db->prepare( 'DELETE FROM mailouts WHERE id=?' ); $db->execute( $sth, $id ); return true; } public static function add( $from, $to, $subject, $content ) { $db = Mailouts::get_db(); $sth = $db->prepare( 'INSERT INTO mailouts VALUES (null,?,?,?,?)' ); $db->execute( $sth, array( $from, $to, $subject, $content ) ); return true; } public static function get_all() { $db = Mailouts::get_db(); $res = $db->query( "SELECT * FROM mailouts" ); $rows = array(); while( $res->fetchInto( $row ) ) { $rows []= $row; } return $rows; }}?>
這個指令碼包含 Pear::DB 資料庫訪問類。然後定義 mailouts 類,其中包含三個主要的靜態函數:add、delete 和 get_all。add() 方法向隊列中添加一個電子郵件,這個方法由前端使用。get_all() 方法從表中返回所有資料。delete() 方法刪除一個電子郵件。
您可能會問,我為什麼不只在指令碼末尾調用 delete_all() 方法。不這麼做有兩個原因:如果在發送每個訊息之後刪除它,那麼即使指令碼在出現問題之後重新運行,訊息也不可能發送兩次;在批作業的啟動和完成之間可能會添加新的訊息。
下一步是編寫一個簡單的測試指令碼,這個指令碼將一個條目添加到隊列中。
清單 3. mailout_test_add.php
<?phprequire 'mailout.php';Mailouts::add( 'donotreply@mydomain.com', 'molly@nocompany.com.org', 'Test Subject', 'This is a test of the batch mail sendout' );?>
在這個樣本中,我添加一個 mailout,這個訊息要發送給某公司的 Molly,其中包括主題 “Test Subject” 和電子郵件主體。可以在命令列上運行這個指令碼:php mailout_test_add.php。
為了寄送電子郵件,需要另一個指令碼,這個指令碼作為作業處理常式。
清單 4. mailout_send.php
<?phprequire_once 'mailout.php';function process( $from, $to, $subject, $email ) { mail( $to, $subject, $email, "From: $from" );}$messages = Mailouts::get_all();foreach( $messages as $msg ) { process( $msg[1], $msg[2], $msg[3], $msg[4] ); Mailouts::delete( $msg[0] );}?>
這個指令碼使用 get_all() 方法檢索所有電子郵件訊息,然後使用 PHP 的 mail() 方法逐一發送訊息。在每次成功寄送電子郵件之後,調用 delete() 方法從隊列中刪除對應的記錄。
使用 cron 守護進程定期運行這個指令碼。運行這個指令碼的頻率取決於您的應用程式的需要。
注意:PHP Extension and Application Repository(PEAR)存放庫包含一個出色的 郵件隊列系統 實現,可以免費下載。
回頁首
更通用的方法
專門用來寄送電子郵件的解決方案是很不錯,但是是否有更通用的方法?我們需要能夠寄送電子郵件、產生報告或者執行其他耗費時間的處理,而不必在瀏覽器中等待處理完成。
為此,可以利用一個事實:PHP 是一種解釋型語言。可以將 PHP 代碼儲存在資料庫中的隊列中,以後再執行它。這需要兩個表,見清單 5。
清單 5. generic.sql
DROP TABLE IF EXISTS processing_items;CREATE TABLE processing_items ( id MEDIUMINT NOT NULL AUTO_INCREMENT, function TEXT NOT NULL, PRIMARY KEY ( id ));DROP TABLE IF EXISTS processing_args;CREATE TABLE processing_args ( id MEDIUMINT NOT NULL AUTO_INCREMENT, item_id MEDIUMINT NOT NULL, key_name TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY ( id ));
第一個表 processing_items 包含作業處理常式調用的函數。第二個表 processing_args 包含要發送給函數的參數,採用的形式是由鍵/值對組成的 hash 表。
與 mailouts 表一樣,這兩個表也由 PHP 類封裝,這個類稱為 ProcessingItems。
清單 6. generic.php
<?phprequire_once('DB.php');class ProcessingItems{ public static function get_db() { ... } public static function delete( $id ) { $db = ProcessingItems::get_db(); $sth = $db->prepare( 'DELETE FROM processing_args WHERE item_id=?' ); $db->execute( $sth, $id ); $sth = $db->prepare( 'DELETE FROM processing_items WHERE id=?' ); $db->execute( $sth, $id ); return true; } public static function add( $function, $args ) { $db = ProcessingItems::get_db(); $sth = $db->prepare( 'INSERT INTO processing_items VALUES (null,?)' ); $db->execute( $sth, array( $function ) ); $res = $db->query( "SELECT last_insert_id()" ); $id = null; while( $res->fetchInto( $row ) ) { $id = $row[0]; } foreach( $args as $key => $value ) { $sth = $db->prepare( 'INSERT INTO processing_args VALUES (null,?,?,?)' ); $db->execute( $sth, array( $id, $key, $value ) ); } return true; } public static function get_all() { $db = ProcessingItems::get_db(); $res = $db->query( "SELECT * FROM processing_items" ); $rows = array(); while( $res->fetchInto( $row ) ) { $item = array(); $item['id'] = $row[0]; $item['function'] = $row[1]; $item['args'] = array(); $ares = $db->query( "SELECT key_name, value FROM processing_args WHERE item_id=?", $item['id'] ); while( $ares->fetchInto( $arow ) ) $item['args'][ $arow[0] ] = $arow[1]; $rows []= $item; } return $rows; }}?>
這個類包含三個重要的方法:add()、get_all() 和 delete()。與 mailouts 系統一樣,前端使用 add(),處理引擎使用 get_all() 和 delete()。
清單 7 所示的測試指令碼將一個條目添加到處理隊列中。
清單 7. generic_test_add.php
<?phprequire_once 'generic.php';ProcessingItems::add( 'printvalue', array( 'value' => 'foo' ) );?>
在這個樣本中,添加了一個對 printvalue 函數的調用,並將 value 參數設定為 foo。我使用 PHP 命令列解譯器運行這個指令碼,並將這個方法調用放進隊列中。然後使用以下處理指令碼運行這個方法。
清單 8. generic_process.php
<?phprequire_once 'generic.php';function printvalue( $args ) { echo 'Printing: '.$args['value']."\n";}foreach( ProcessingItems::get_all() as $item ) { call_user_func_array( $item['function'], array( $item['args'] ) ); ProcessingItems::delete( $item['id'] );}?>
這個指令碼非常簡單。它獲得 get_all() 返回的處理條目,然後使用 call_user_func_array(一個 PHP 內建函式)用給定的參數動態地調用這個方法。在這個樣本中,調用本地的 printvalue 函數。
為了示範這種功能,我們看看在命令列上發生了什麼:
% php generic_test_add.php % php generic_process.php Printing: foo%
輸出並不多,但是您能夠看出要點。通過這種機制,可以將任何 PHP 函數的處理延遲。
現在,如果您不喜歡將 PHP 函數名和參數放進資料庫中,那麼另一種方法是在 PHP 代碼中建立資料庫中的 “處理作業類型” 名稱和實際 PHP 處理函數之間的映射。按照這種方式,如果以後決定修改 PHP 後端,那麼只要 “處理作業類型” 字串匹配,系統就仍然可以工作。
回頁首
放棄資料庫
最後,我示範另一種稍有不同的解決方案,它使用一個目錄中的檔案來儲存批作業,而不是使用資料庫。在這裡提供這個思路並不是建議您 “採用這種方式,而不使用資料庫”,這隻是一種可供選擇的方式,是否採用它由您決定。
顯然,這個解決方案中沒有模式,因為我們不使用資料庫。所以先編寫一個類,它包含與前面樣本中相似的 add()、get_all() 和 delete() 方法。
清單 9. batch_by_file.php
<?phpdefine( 'BATCH_DIRECTORY', 'batch_items/' );class BatchFiles{ public static function delete( $id ) { unlink( $id ); return true; } public static function add( $function, $args ) { $path = ''; while( true ) { $path = BATCH_DIRECTORY.time(); if ( file_exists( $path ) == false ) break; } $fh = fopen( $path, "w" ); fprintf( $fh, $function."\n" ); foreach( $args as $k => $v ) { fprintf( $fh, $k.":".$v."\n" ); } fclose( $fh ); return true; } public static function get_all() { $rows = array(); if (is_dir(BATCH_DIRECTORY)) { if ($dh = opendir(BATCH_DIRECTORY)) { while (($file = readdir($dh)) !== false) { $path = BATCH_DIRECTORY.$file; if ( is_dir( $path ) == false ) { $item = array(); $item['id'] = $path; $fh = fopen( $path, 'r' ); if ( $fh ) { $item['function'] = trim(fgets( $fh )); $item['args'] = array(); while( ( $line = fgets( $fh ) ) != null ) { $args = split( ':', trim($line) ); $item['args'][$args[0]] = $args[1]; } $rows []= $item; fclose( $fh ); } } } closedir($dh); } } return $rows; }}?>
BatchFiles 類有三個主要方法:add()、get_all() 和 delete()。這個類不訪問資料庫,而是讀寫 batch_items 目錄中的檔案。
使用以下測試代碼添加新的批處理條目。
清單 10. batch_by_file_test_add.php
<?phprequire_once 'batch_by_file.php';BatchFiles::add( "printvalue", array( 'value' => 'foo' ) );?>
有一點需要注意:除了類名(BatchFiles)之外,實際上沒有任何跡象能夠說明作業是如何儲存的。所以,以後很容易將它改為資料庫風格的儲存方式,而不需要修改介面。
最後是處理常式的代碼。
清單 11. batch_by_file_processor.php
<?phprequire_once 'batch_by_file.php';function printvalue( $args ) { echo 'Printing: '.$args['value']."\n";}foreach( BatchFiles::get_all() as $item ) { call_user_func_array( $item['function'], array( $item['args'] ) ); BatchFiles::delete( $item['id'] );}?>
這段代碼幾乎與資料庫版本完全相同,只是修改了檔案名稱和類名。
回頁首
結束語
正如前面提到的,伺服器對線程提供了許多支援,可以進行後台批處理。在某些情況下,使用輔助線程處理小作業肯定比較容易。但是,也可以使用傳統工具(cron、MySQL、標準的物件導向的 PHP 和 Pear::DB)在 PHP 應用程式中建立批作業,這很容易實現、部署和維護。
參考資料
學習
您可以參閱本文在 developerWorks 全球網站上的 英文原文 。
閱讀 IBM developerWorks 的 PHP 項目資源中心,進一步瞭解 PHP。
PHP.net 是面向 PHP 開發人員的優秀資源。
PEAR Mail_Queue 包 是一個健壯的郵件隊列實現,其中包括資料庫後端。
crontab 手冊 提供了 cron 配置的細節,但是不容易理解。
PHP 手冊中關於 Using PHP from the command line 的一節可以協助您瞭解如何從 cron 運行指令碼。
隨時關注 developerWorks 技術事件和 webcast。
瞭解世界各地即將進行的會議、展覽、網路廣播和其他 活動,IBM 開放源碼開發人員可以通過這些活動瞭解最新的技術發展。
訪問 developerWorks 開源技術專區,獲得廣泛的 how-to 資訊、工具和項目更新,可以協助您利用開放源碼技術進行開發並將其與 IBM 產品結合使用。
developerWorks podcasts 中包括很多適合於軟體開發人員的有趣的訪談和討論。
獲得產品和技術
查閱 PEAR -- PHP Extension and Application Repository,其中包含 Pear::DB。
使用 IBM 試用軟體 改進您的下一個開放源碼開發項目,這些軟體可以下載或者通過 DVD 獲得。
討論
developerWorks PHP Developer Forum 為所有 PHP 開發人員提供了討論技術問題的場所。如果您有關於 PHP 指令碼、函數、文法、變數、調試和其他主題的問題,可以在這裡提出。
通過參與 developerWorks blog 加入 developerWorks 社區。
關於作者
Jack D. Herrington 是一名進階軟體工程師,具有 20 多年的工作經驗。他撰寫過三本書: Code Generation in Action 、 Podcasting Hacks 和 PHP Hacks,還撰寫了 30 多篇文章。