本書若不講解一章關于連接到MySQL的應用程序優化的內容,那就不能算完整,因為人們常常把一些性能方面的問題都歸咎到MySQL身上。書里面我們更多地是講到MySQL的優化,但是,我們不想讓你錯過這個更大的圖景。一個糟糕的應用設計會使你無論怎么優化MySQL也彌補不了它帶來的損失。實際上,有時候對于這類問題的答案是把它們從MySQL上脫離開來,讓應用自己或其他工具來做這些事情,這樣或許會有較好的性能表現。
本章不是構建高性能應用的參考書,我們只是希望通過閱讀這一章讓你避免那些常見的會傷及MySQL性能的小錯誤。下文中我們以Web應用為主要講解對象,因為MySQL主要是用在Web應用上的。
1 應用程序性能概述
對于更快性能的追求開始時很簡單:應用響應請求花費了太長的時間,你總要為此做點什么吧。然而,真正的問題是什么呢?通常的瓶頸是緩慢的查詢、鎖、CPU飽和、網絡延時和文件I/O。如果應用配置錯誤,或者不恰當地使用資源,以上任何一個因素都會引出一個大問題。
1.1找出問題的根源
第一個任務是找出"肇事者"。如果你的應用具備了顯示系統運行概況的功能,這做起來就簡單了。如果你已經做到了這一步,但還是沒法找出引起性能低下的原因,那你就要增加更多的概況信息的調用,去找出那些要么緩慢要么被多次調用的資源。
如果你的應用因為CPU高占用率而一直等待,并且應用里有高并發性,那我們提到過的"丟失的時間"可能就成問題了。鑒于此,有些時候在有限的并發條件下生成應用的概況信息是很有用的。
網絡延時會占用大塊的時間,哪怕是在局域網里。應用層面的概況信息已經包括了網絡延時,因此,你應該在概況系統里看到網絡往返延時帶來的影響了。舉例來說,如果一個頁面執行了1 000個查詢,即使每次只有1毫秒的延時,那累加起來也有0.5秒的響應時間,這對高性能應用來說已經是個很大的數目了。
如果應用層面概況信息收集得很充分,那就不難找出問題的根源。如果還沒有內置概況功能,那就盡可能地加上它。如果你無法添加這個功能,那也可以試試第76頁的"當你無法加入概況信息代碼時"里提供的那些建議。這個總比鉆研像"什么引起應用變慢"那樣沒頭緒的理論設想要更快更容易。
1.2尋找常見問題
同樣的問題我在應用里一次又一次地遇到,其原因往往是人們使用了設計糟糕的原有系統,或者采用了簡化開發的通用框架。雖然這在某些時候能讓你在開發一些功能時變得方便又快速,但它們也給應用增加了風險,因為你不知道它們底下是怎么工作的。這里有一張清單你應該逐個檢查一下:
在各個機器上的CPU、磁盤、網絡和內存資源的使用情況如何?使用率對你而言是否合理?如果不合理,就檢查那些影響資源使用的應用的程序基礎。配置文件有時就是解決問題的最簡單方法,舉例來說,如果Apache耗光了內存,那是因為它創建1 000個工作者進程,每個工作進程需要50MB內存,這樣,你就可通過配置文件配置這個應用能申請的Apache工作者進程數。你也可以配置系統,使之創建進程時少用些內存。
應用是否真正使用了它所取得的數據?一個常見的錯誤是:讀取了1 000行數據,卻只要顯示10行就夠了,其他990行就丟棄了(然而,如果應用緩存了余下的990條記錄供以后使用,那么這可能是特意做的優化)。
應用里是否做了本該由數據庫來做的處理?反之亦然。有個對應的例子是:讀取了所有行的數據,然后在應用里計算它們的總數;以及在數據庫里做復雜的字符串處理。數據庫擅長于計數,而應用的編程語言擅長于正則表達式。你該使用正確的工具去干正確的活。
應用里執行了太多的查詢?那些號稱能"把程序員從SQL代碼里解救出來"的ORM(Object-Relational Mapping)就因此常被人們責備。數據庫服務器是被設計用來匹配多表數據的,因為要移除那些嵌套循環,代之以聯接(Join)來做同樣的查詢。
應用里執行的查詢太少了?我們只知道執行了太多的查詢會成為問題。但是,有時"手工的聯接"和與其相似的查詢是個好主意,因為它們可以更加有效地利用緩存,更少的鎖(尤其是MyISAM),有時當你在應用的代碼里使用一個散列聯接時(MySQL的嵌套循環的聯接方法往往是低效的),查詢的執行速度會更快。
應用是不是在毫無必要的時候還連到MySQL上去了?如果你能從緩存里讀取數據,就不要去連數據庫了。
應用連接到同一個MySQL實例的次數是不是太多了?這可能是因為應用的各個部分都各自開啟了自己的數據庫連接。有個建議在通常情況下都很對:從頭到尾都重用同一個數據庫連接。
應用是不是做了太多的"垃圾"查詢?一個常見的例子是在做查詢前才去選擇需要的數據庫。一個較好的做法是連接到名稱明確的數據庫,并使用表的全名做查詢。(這樣做,也便于通過日志或SHOW PROCESSLIST去查詢情況,因為你可以直接執行這些查詢語句,無需再更改數據庫)。"準備"數據庫連接又是另一個常見的問題,特別是Java寫的數據庫驅動程序,它在準備連接時會做大量的工作,它們中的大多數你都可以關閉。另一種垃圾查詢是SET NAMES UTF8,這純粹是多此一舉(它無法改變客戶端連接庫的字符集,它只對服務器有影響)。如果你的應用已確定在多數任務下使用的是某一個字符集,那你就可以避免這樣無謂的字符集設置命令。
應用使用連接池了嗎?這既是好事情也是壞事情。它限制了連接的數量,這在連接上查詢數不多的情況下(Ajax應用就是個典型的例子)是有利的;然而,它的不好的一面是,應用會受限于使用事務、臨時表、連接指定的設置和定義用戶變量。
應用使用了持久性連接嗎?這樣做的直接結果是會產生太多的數據庫連接連到MySQL上。通常情況下,這是個壞主意,除了一種情形:由于慢速的網絡導致MySQL的連接成本很高,如果每條連接上只執行一兩個快速的查詢,或者頻繁地連接到MySQL,那樣你會很快用完客戶端的所有本地端口(更多內容請查看第328頁的"網絡配置")。如果你正確地配置了MySQL,根本不需要持久性連接,可以使用"跳過名稱解析"來防止DNS的查找,并確認該線程的優先級足夠高。
即使沒有使用,應用是不是還打開著連接?如果是,特別是當這些連接連向多臺服務器時,它們可能占用了其他進程需要的連接。舉例來說,假設你連接到10臺MySQL服務器。由一個Apache進程占用10個連接數,這不是個問題,但是它們中只有一條連接是在任何指定時間里做著一些操作,而其他9條連接絕大多數時間都處于睡眠狀態。如果有一臺服務器響應變得遲緩,或者網絡延時變長,那其他幾臺服務器就遭殃了,因為它們根本沒連接可用。對于這個問題的解決辦法是控制應用使用數據庫連接的方式。
舉例來說,你可以在各個MySQL實例中依次做批量操作,在向下一個MySQL發起查詢前,關閉當前的所有連接。如果你要的是時間消耗很大的操作,比如調用一個Web Service,可以先關閉與MySQL的連接,等這個耗時的調用完成后,再打開MySQL的連接,完成剩余的需要在數據庫上操作的任務。
持久性連接與連接池的不同點比較模糊。持久性連接有與連接池相同的副作用,因為在各種情況下重新使用的連接往往都帶有狀態。
然而,連接池并不總是導致許多連接到服務器的聯接,因為它們是隊列化的,并在各進程間共享這些連接。在另一方面,持久化連接是基于每個進程來創建的,無法被其他進程所使用。 與持久性連接相比,連接池在連接策略上有更多的控制。你可以把一個連接池配置成自動擴充的,但是通常的做法還是當連接池滿的時候,新的連接請求都被放在隊列里等待。這使得這些請求都在應用服務器上等待,總好過MySQL因為連接太多而超載。 有太多的方法使查詢和連接更加快速,一般性準則是避免把它們放在一起,勝于試著把它們加速。
2 Web服務器的議題
Apache是Web應用中使用最廣泛的服務器軟件。在各種用途下,它都能運行良好,但如果使用得不恰當,它也會占用大量的資源。最常見的一個情況是讓它的進程活動了太長的時間,并把它用在各種不同類型的任務下卻沒有做相應的優化。
Apache經常在prefork配置項里使用mod_php、mod_perl、mod_python。預分叉(Prefork)是為每個請求分配一個進程。因為PHP、Perl和Python等腳本語言運行起來很費資源,每個進程占用50MB或100MB內存的情形也不罕見。當一個請求處理完后,它會把絕大多數內存歸還給操作系統,但不會是全部。Apache會讓這個進程保持在運行狀態,以處理將要到來的請求。這就意味著如果這個新來的請求只是為了獲得一個靜態文件,比如一個CSS文件或一張圖片,你都需要重新啟用那個又"肥"又"大"的進程來處理這個簡單請求。這也是為什么把Apache用作多用途Web服務器是件危險的事情。它是多用途的,若你對它進行了有針對性的配置,它才會有更好的性能表現。
另外有個主要的問題是如果你打開了Keep-Alive參數項,進程就會長時間地保持忙碌狀態。即使你不這么做, 有些進程也會這樣。如果內容是像"填鴨"一樣傳給客戶端的,那這個讀取數據的過程也會很漫長。
人們也經常犯這樣的錯誤:按Apache默認開啟的模塊來運行。你可以按照Apache使用手冊里的說明,把你不需要的模塊都關閉掉,做法也很簡單:查看Apache的配置文件,把不需要的模塊都注釋掉,然后重啟Apache。你可以從php.ini文件中把不需要的PHP模塊都移除。
如果你創建了一個多用途Apache才需要的配置當作Web服務器來用,你最后可能會被眾多繁重的Apache進程所拖垮,這些進程純粹浪費你的Web服務器上的資源。而且,它們會占用大量與MySQL的連接,以至于也浪費了MySQL的資源。這里有一些方法能給你的服務器"減負":
不要把Apache用作靜態內容的服務,如果一定要用,那也至少要換個另外的Apache實例來處理這些事情。常見的替代品有lighttpd和nginx。
使用一個緩存代理服務器,比如Squid或Varnish,使用戶請求無須抵達Web服務器后才能被響應。即使在這個層面上你無法緩存所有的頁面,你也能緩存大部分頁面,并通過Edge Side Includes(ESL,http://www.esi.org)技術把頁面上的小塊動態部分放到緩存的靜態部分里。
對動態內容和靜態內容都設置過期策略。你可以使用緩存代理軟件,像Squid,去驗證內容的明確性。Wikipedia就是用這樣的技術在緩存里移除內容已發生變化的文檔。
有時你可能需要改變一下應用,使它能使用更長的超期時間。舉例來說,如果你告訴瀏覽器要永久緩存CSS和JavaScript文件,然后又對這個網站靜態HTML文件做了一些修改,這樣這些頁面的顯示效果可能會變得很糟。對此,你需要使用一個唯一的文件名對每次修訂后的頁面文件都作一個明確的版本標記。舉例來說,你可以自定義你的網站發布腳本,把CSS文件復制到/css/123_frontpage.css目錄下,這里的123就是Subversion里的修訂號。你也可以用同樣的方法來處理圖片文件-- 不要重用原來的文件名,否則,即使你更新了文件內容,頁面不會再被更新,不管瀏覽器要將原來的頁面緩存多久。
不要讓Apache與客戶端做"填鴨"式通信。這不僅僅是慢,而且很容易招致拒絕性服務攻擊。典型地,硬件化的負載平衡器會處理好緩存,Apache就能很快地結束響應,然后讓負載平衡器從緩存里讀出數據去"喂"客戶端。你也可以使用lighttpd、Squid,或者設為事件驅動模式下的Apache作為應用的前端。
開啟gzip壓縮。現在的CPU很廉價,它可以用來節省大量的網絡流量。如果你想節省CPU周期,那可以使用輕量級的Web服務器,比如lighttpd,來緩存和提供壓縮過的頁面。
不要將Apache上的長距離連接配置為"保活"(Keep-Alive)模式,因為它會使Apache上臃腫的進程長時間處于運行狀態。代替的方案是,用一個服務端的代理來處理"保活"的連接,使服務器免受這類客戶端的傷害。如果將Apache與代理之間的連接方式設為"保活",那是不錯的主意,因為代理僅使用幾個連接從服務器上讀取數據。下圖說明了以上兩者的差異。
以上這些策略應該可以幫助Apache減少進程的使用數,使你的服務器不會因為太多的進程而崩潰。然而,有些具體的操作仍然會引起Apache的進程長時間地運行,吞掉大量的系統資源。有一個例子就是查詢外部資源時具有很高的延遲,比如訪問一個遠程Web服務器。這樣的問題還是無法用上述那些方法來解決。
2.1找出最佳并發數
每個Web服務器都有它的一個最佳并發數--它的含義是服務器能同時處理的并發連接數目,它們既能盡可能快地處理客戶端請求,又不會使服務器過載。這個"神奇的數目"需要做多次的嘗試-失敗的反復才能得到,相比于它能帶來的好處,這還是值得一做。
對于大流量的網站而言,Web服務器同時處理幾千個連接是件很平常的事情。然而,這些連接中只有很少的一部分需要主動地去處理請求,而其他那些都是讀取請求、文件上傳、"喂"內容,或者僅僅等待客戶端的下一步請求。
并發數增加時,服務器會在某一點上達到它的吞吐量頂峰,在此之后,吞吐量會變得平穩,往往還會開始下降。更重要的是,系統的響應時間(延遲)開始增加。
想要知道究竟,就要設想如果你只有一顆CPU,而服務器同時接收到100個請求,接下來會發生什么?假如一個CPU秒只能處理一個請求,而且你使用了一個完美的操作系統,沒有任務調度的開銷,也沒有上下文切換的開銷,那么這些請求總共需要100個CPU秒才能完成。
那么,怎樣去做才是處理這些請求的最好辦法?你可以把它們一個接一個放進隊列里,或者對它們進行并行處理,每個請求在每一個輪回中都獲得一樣多的處理時間。這兩種方式里,吞吐量都是每一秒一個請求。然而,如果使用隊列,平均延遲有50秒(并發數=1),如果并行處理,那延遲有100秒(并發數=100)。在實際環境下,并發處理方法的平均延遲還會更高,因為其中還有個切換開銷。
對于高CPU占有率的工作負載而言,其最佳并發數就是CPU(或者是CPU里的核)的數目。然而,進程不總是可以運行的,因為它們會執行阻塞式調用,比如I/O、數據庫查詢和網絡請求等。因此,最佳并發數往往會多于CPU數目。
你可以估計最佳并發數,但是這需要精確的分析模型。通常情況下,還是通過實驗的方法比較容易,你嘗試著不同的并發數,然后觀察系統在降低響應時間前,能達到多大的頂峰吞吐量。
3 緩存
緩存對于高負載的應用而言極其重要。一個典型Web應用里,直接提供服務要比使用緩存(包括緩存校驗、作廢)多生成很多內容,所以,緩存能夠將應用的性能提高好幾個數量級。這個技巧的關鍵在于找出緩存粒度和作廢策略的最佳結合點。同時,你需要決定緩存哪些內容,在哪里緩存。
一個典型的高負載應用有許多層的緩存。緩存不僅僅發生在你的服務器上:它出現在整個流程的每一個步驟上,包括用戶的Web瀏覽器里(這就是網頁頭部的有關作廢設置內容的用途)。通常而言,緩存越靠近客戶端,就越能節省更多的資源,更加高效。一副圖片從瀏覽器緩存里讀出要好于從Web服務器的內存里讀取,而后者又好于從服務器的磁盤上讀取。每一種緩存都其獨有的特性,比如尺寸、延時等,在接下來的章節里我們將對它們逐一進行敘述。
你可以把緩存想象成兩大類:被動緩存和主動緩存。被動緩存除了保存和返回數據不做其他事情。當你從被動緩存那里請求一些內容時,它要么給你需要的結果,要么告訴你"你要的數據不存在"。一個被動緩存的例子就是memcached。
相反地,主動緩存在找不到請求的數據時,它會做點別的事情。一般就是把你的請求傳遞給應用的某一部分--它能生成請求所需要的內容,然后主動緩存就會存儲這部分內容,并返回給客戶端。Squid緩存代理服務器就是一個主動緩存。
當設計應用時,你總希望你的緩存是主動型(也叫透明型)的,因為對于應用,它們可以隱藏"檢查-生成-存儲"這個邏輯。你可以在被動緩存之上構建你的主動緩存。
緩存并不總是有用, 你需要確定緩存是不是真地提高了系統的性能,因為它可能一點用處也沒有。舉例來說,在實際應用中,從lighttpd的內存中讀取內容要比從緩存代理那里讀取快一些。如果那個代理的緩存是建于磁盤上的,那結論會更明顯。 這個原因很簡單:緩存也有自己的運行開銷,它們主要檢查緩存的開銷和提供被命中緩存內容的開銷,另外還有將緩存內容作廢和保存數據的開銷。只有當這些開銷的總和小于服務器生成和提供數據所要的開銷時,緩存才有用。
如果你知道所有這些操作的總開銷,你就能計算緩存能起多大的作用。沒有緩存時的開銷就是服務器為每個請求生成數據所需要的總開銷。有緩存時的開銷就是檢查緩存的開銷,加上緩存沒命中的可能性乘以生成這些數據的開銷,再加上緩存命中的可能性乘以從緩存里取出這些數據的開銷。
如果有緩存時的開銷小于沒緩存的時候的開銷,那使用緩存就可以提高系統性能,但是也不能保證肯定是這樣。記在腦子里的一個例子就是從lighttpd內存里讀取內容的開銷要比代理從磁盤緩存上讀取的開銷要小,一些緩存總會比另外一些便宜。
3.1在應用之下的緩存
MySQL服務器有它自己的內部緩存,你也可以構建你自己的緩存和匯總表。你可以自定義緩存表,以便于更好地將它用于過濾、排序、與其他表做聯接、計數,以及其他用途。緩存表比其他應用層的緩存更加持久,因為它們在服務器重啟后還會繼續存在。
3.2應用層面的緩存
典型的應用層面的緩存一般都是將數據放在本機內存里,或者放在網絡上的另外一臺機器的內存里。
應用層面的緩存一般要比更低層面的緩存有更高的效率,因為應用可以把部分計算結果存放在緩存里。因而,緩存對兩類工作很有幫助:讀取數據和在這些讀取數據之上做計算。一個很好的例子是HTML文本的各個分塊。應用能夠產生HTML段落,比如頭條新聞,然后將它們緩存起來。隨后打開的頁面里就能將這些被緩存起來的頭條新聞直接放到頁面上。通常來講,緩存之前處理的數據越多,使用緩存之后能節省的工作量也越多。
這里有個不足之處就是緩存的命中率越多,要提高它而花費的錢就越多。假如你需要50個不同版本的頭條新聞,能根據用戶所在的不同地域來顯示不同的頭條。你需要有足夠的內存來保存這全部50個版本的頭條新聞,任何一個給定版本的頭條被請求得越少,那它的作廢操作也會越復雜。
應用緩存有許多種類型,以下是其中的一部分:
本地緩存
這種緩存一般都比較小,只存在于請求處理時的進程內存空間里。它們可用于避免對同一資源的多次請求。因此,它也沒什么精彩之處:它往往只是應用程序代碼里的一個變量或一個散列表。舉例來說,如果需要顯示用戶名,而你只知道用戶ID,于是就設計一個函數叫get_name_from_id,把緩存功能放在這個函數里,具體代碼如下:
如果你使用的是Perl,那么Memoize模塊就是緩存函數調用結果的標準辦法:
本地共享內存式緩存
這種緩存大小中等(幾個GB)、訪問快速,同時,難于在各機器間同步。它們適用于小型的、半靜態的數據存儲。舉例來說,像每個州的城市列表、共享數據存儲里的分塊函數(使用映射表),或者應用了存活時間(Time-to-live,TTL)策略的數據。共享內存的最大好處是訪問時非常快速--一般要比任何一種遠程緩存要快很多。
分布存內存式緩存
分布式內存緩存的最著名的例子是memcached。分布式緩存比本地共享緩存要大,增長也容易。每一份緩存的數據只被創建一次,因為不會浪費你的內存,當同一份數據在各處緩存時也不會引起數據一致性問題。分布式內存擅長于對共享對象的排序,比如用戶信息文件、評論和HTML片段。
這種緩存比本地共享緩存有更高的延遲,因此最有效的使用它們的方法是"多取"操作(比如在一次往返時,讀取多個對象數據)。它們也要事先規劃好怎么加入更多的節點,以及當一個節點崩潰時該怎么做。在這兩種情形下,應用都要決定如何在各節點間分布或重新分布緩存對象。
磁盤緩存
磁盤是慢速的,所以,持久性對象最適合做磁盤緩存。對象往往不適合放在內存里,靜態內容也是(比如預生成的自定義圖片)。
非常有效地使用磁盤緩存和Web服務器的技巧是用404錯誤處理過程來捕捉沒命中的緩存。加入你的Web應用要在頁面的頭部顯示一個用戶自定義的圖片,暫且將這個圖片命名為/images/welcomeback/john.jpg。如果這個圖片不存在,它就會產生一個404錯誤,同時觸發錯誤處理過程。接著,錯誤處理過程就生成這個圖片,并存放在磁盤上,然后再啟動一個重定向,或者僅僅把這個圖片"回填"到瀏覽器里,那么,以后的訪問都可以直接從文件里返回這個圖片了。 你可以將這項技巧用于許多類型的內容,舉例來說,你用不著再緩存那塊用來顯示最新頭條新聞的HTML代碼了,而把它們放入一個JavaScript文件里,然后在頁面的頭部插入指向這個js文件的引用。
緩存失效的操作也很簡單:刪除這個文件就可以了。你可以通過運行一個周期性的任務,將N分鐘前創建的文件都刪除掉,來實現TTL失效策略。 如果想對緩存的尺寸做限制,那你可以實現一個最近最少使用(Least Recently Used,LRU)的失效策略,根據緩存內容的創建時間來刪除內容。 這個失效策略需要你在文件系統的掛接(Mount)選項上開啟"訪問時間"這個開關項。(實際操作時忽略noatime掛接選項來達到這個目的)。如果這么做了,你就應該使用內存文件系統來避免大量的磁盤操作。更多內容請查看第331頁的"選擇文件系統"。
3.3緩存控制策略
緩存引出的問題跟你數據庫設計時違背了基本范式一樣:它們包含了重復數據,這意味更新數據時要更新多個地方,還要避免讀到過期的"壞"數據。以下是幾個常用的緩存控制策略:
存活時間
每個緩存的對象都帶有一個作廢日期,用一個刪除進程定時檢查該數據的作廢時間是否到達,如果是就立即刪除它,你也可以暫時不理會它,直到下一次訪問它時,如果已經超過作廢時間,那才用一個更新的版本來替換它。這種作廢策略最適用于很少變動或幾乎不用刷新的數據。
顯式作廢
如果緩存里的數據過于"陳舊"而無法被接受,那么更新緩存數據的進程就立即將該舊版本的數據作廢。這個策略里有兩個變體類型:寫-作廢和寫-更新。寫-作廢策略非常簡單:直接將該數據標志為作廢(也可以有從緩存里把它刪除掉的選擇)。寫-更新策略就有更多的工作要做,因為你還要用最新的數據來替換舊緩存數據。但是,這個策略非常有用。特別是當生成緩存數據的代價很昂貴時(這個功能在寫的進程里可能已經具備)。更新了緩存之后,將來的請求就用不著再等應用來生成這份數據了。如果你是在后臺執行作廢過程的,比如是基于TTL的作廢過程,你可以在一個獨立于任何用戶請求的進程里生成最新版本的數據去替換緩存里已作廢的數據。
讀時作廢
相對于在改變源數據時使緩存里對應的舊數據作廢,有一個替代性的方法是保存一些信息來幫你判斷從緩存里讀出的數據是否已經作廢。它有個比顯式作廢更顯著的優點:隨著時間的增長,它開銷是固定的。假設你要將一個對象作廢,而緩存里有100萬個對象依賴于它。如果在寫時將它作廢,你就不得不將緩存里的相關100萬個對象都作廢。而100萬次讀的延遲是相當小的,這樣就可以攤薄作廢操作的時間成本,避免了加載時的長時間延遲。
采用寫時作廢策略的最簡單的方法是實行對象版本化管理。在這個方法里,當把對象保存到緩存里時,你同時要保存該數據所依賴的版本號或時間戳。舉例來說,假設你將一個用戶在博客發表的文章的統計信息保存到緩存里,這些信息包括了發表文章的數量。當將它作為blog_stats對象緩存時,你同時也要把該用戶當前的版本號也保存起來,因為這個統計信息依賴于具體某個用戶。
無論什么時候你更新了依賴于用戶的數據,也要隨之改變用戶的版本號。假設用戶版本初始為0,你生成并緩存這些統計信息。當用戶發表了一篇文章后,你就將用戶版本號改為1(最好將這個版本號與文章存放在一起,盡管這個例子我們不必這么做)。那么,當你需要顯示統計信息時,就先比較緩存的blog_stats對象的版本和緩存的用戶版本,因為這時用戶的版本比這個對象的版本要高,這樣你就知道這份統計信息里的數據已經陳舊,須要更新了。 這種用于內容作廢的方法相當粗糙,因為它預先假設了緩存里的依賴于用戶的數據也跟其他數據進行互動。這個條件并不總是成立。舉例來說,如果用戶編輯了一篇文章,你也會去增加用戶的版本號,這使得緩存里的統計數據都要作廢了,哪怕真正的統計信息(文章的數目)實際上根本沒發生變化。折中的方案是樸素的,一個簡單的緩存作廢策略不僅僅要易于實現,還要有更高的效率。
對象版本化管理是標簽式緩存的一個簡化形式,后者可以處理更復雜的依賴關系。一個標簽化緩存了解不同類別的依賴關系,并能單獨追蹤每一個對象的版本號。在上一章的圖書俱樂部的例子里,你可以這樣給評論做緩存:用用戶版本號和書本版本號一起給評論做標簽,具體像user_ver=1234和book_ver=5678這樣。如果其中一個版本發生了變化,你就要刷新緩存。
3.4緩存對象的層次
把對象按層次結構存放在緩存中,有助于讀取、作廢和內存使用的操作。你不僅要將對象本身緩存起來,還要緩存它們的ID和對象分組的ID,這樣就能方便成組地讀取它們。
電子商務網站上的搜索結果就是這種技術很好的例子。一次搜索可能返回一個匹配的產品清單,清單里包含了產品的名稱、描述、縮略圖和價格。如果把整個列表存放到緩存里,那讀取時的效率是低下的,因為其他的搜索可能也會包含了同樣的某幾個產品,這樣做的結果就是數據重復、浪費內存。這個策略也難以在產品價格發生變化時到緩存里找到對應的產品并使其作廢,因為必須逐個清單地去查看是否存在這個價格變化了的產品。
一個可以代替緩存整個清單的方法是把搜索結果里盡量少的信息緩存起來,比如搜索的結果數目和結果清單里的產品ID,這樣你就可以單獨緩存每一個產品資料了。這個方法解決了兩個問題:一是消除了重復數據;二是更容易在單獨產品的粒度上將緩存數據作廢。
這個方法的缺點是你不得不從緩存里讀取多個對象數據,而不是立即讀取到整個搜索結果。然而,另一方面這也讓你能更快地按照產品ID對搜索結果進行排序。現在,一次緩存命中就返回一個ID列表,如果緩存允許一次調用返回多個對象(Memcached有一個mget()調用支持這個功能),你就可以用這些ID再到緩存里去讀取對應的產品資料。
如果你使用不當,這個方法也會產生古怪的結果。假設你使用TTL策略來作廢搜索結果,當產品資料發生變化時,明確地將緩存里對應的單個產品資料作廢。現在試著想象一個產品的描述發生了變化,它不再包含跟緩存里搜索結果匹配的關鍵字,而搜索結果還沒到作廢時間。于是,你的用戶就會看到"陳舊"的搜索結果,因為緩存里的這個搜索結果仍然引用了那個描述已經發生變化的產品。
于多數應用來說,這一般不成為問題。如果你的應用無法容忍這個問題,那么就可以使用以版本為基礎的緩存策略,在搜索之后,把產品版本號和搜索結果放在一起。在緩存里找到一個搜索結果后,把結果里的每個產品的版本號跟當前產品的版本號(也是在緩存里的)進行比較,如果發現有版本不符的,就通過重新搜索來獲取新的搜索結果。
3.5內容的預生成
除了在應用層面上緩存數據之外,你還可以使用后臺進程向服務器預先請求一些頁面,然后將它們轉換為靜態頁面保存在服務器上。如果頁面是動態變化的,那你可以預生成頁面中的一部分,然后使用一種技術,比如服務端整合,來生成最終頁面。這樣有助于減少預生成內容的大小和開銷,因為本來你要為了各個最終頁面上的細微差別而不得不重復存儲大量的內容。
緩存預生成的內容會占用大量空間,也不可能總是去預生成所有東西。無論哪種形式的緩存,預生成內容里的最重要部分就是請求最多的那些內容。因此,像我們在本章的前面提到過的那樣,你可以通過404錯誤處理程序來對內容作"按需生成"。這些預生成的內容一般都放在內存文件系統里,避免放在磁盤上。
4 擴展Mysql
如果MySQL完不成你所需要的任務,有一種可能性就是擴展它的能力。在這里,我們不是打算告訴你怎么去做擴展,而是要提一下這個可能性里的一些具體途徑。如果你有興趣去深究其中的任何一條途徑,那么網上有很多資源可供使用,也有很多關于這個主題的書可以參考。 當我們說 "MySQL完不成你所需要的任務"時,其中包含了兩個含義:一是MySQL根本做不到,二是MySQL能做到,但是使用的辦法不夠好。無論哪個含義都是我們要擴展MySQL的理由。一個好消息是MySQL現在變得越來越模塊化、多用途了。舉例來說,MySQL 5.1 有大量可用的功能插件,它甚至允許存儲引擎也是插件形式的,這樣你就用不著把它們編譯到MySQL服務器里了。
使用存儲引擎將MySQL擴展為特定用途的數據庫服務器是個偉大的想法。Brian Aker已經編寫了一個存儲引擎的框架和一系列的文章、幻燈片來指導用戶如何開發自己的存儲引擎。這已經構成了一些主要的第三方存儲引擎的基礎。如果跟蹤MySQL的內部郵件列表,你會發現現在有許多公司正在編寫他們自己的內置存儲引擎。舉例來說,Friendster使用一個特別的存儲引擎來做社交圖操作,另外,我們還知道有一家公司正在做一個用來做模糊搜索的引擎。編寫一個簡單的自定義引擎一點也不難。
你也可以把存儲引擎直接用作軟件某一部分的接口。Sphinx就是個很好的例子,它直接與Sphinx全文檢索軟件通信。
MySQL 5.1 也允許全文檢索解析器插件,如果你能編寫UDF(請查看第5章),它擅長處理CPU密集的任務,這些任務必須在服務器線程環境下運行,對于SQL而言又太慢太笨重。因此,你可以用它們完成系統管理、服務集成、讀取操作系統信息、調用Web服務、同步數據,以及其他更多相類似的任務。
MySQL代理另外有一個很棒的選項,可以讓你向MySQL協議增加你自己的功能。Paul McCullagh的可擴展大二進制流框架項目(http://www.blobstreaming.org)為你打通了在MySQL里存儲大型對象的道路。 因為MySQL是免費的、開源的軟件,所以當你感覺它功能不夠用時,你還可以去查看服務器代碼。我們知道一些公司已經擴展了MySQL內部解析器的語法。近年來,還有第三方提交的許多有趣的MySQL擴展,涵蓋了性能概要、擴展及其他新奇的應用。當人們想擴展MySQL,MySQL的開發者們總是反應積極,并樂于提供幫助。你可以通過郵件列表internals@lists.mysql.com(注冊用戶請訪問http://lists.mysql.com)、MySQL論壇和IRC頻道#mysql-dev跟他們取得聯系。
5 可替代的Mysql
MySQL不是一個能適用于所有需要的萬能解決方案。有些工作全部放到MySQL之外會更好,即使MySQL在理論上也能做到。
一個很明顯的例子是在傳統的文件系統里對數據進行排序而不是在表里。圖像文件是又一個經典的案例:你可以把它們都放在BLOB字段里,但是這在多數時候都不是個好主意(注3)。通常的做法是把圖像文件或其他大型二進制文件存在文件系統里,然后把文件名放在MySQL里。這樣,應用就可以在MySQL之外讀取文件了。在Web應用里,你可以把文件名放在<img>元素的src屬性里。
全文檢索也是應該放在MySQL之外處理的任務之一--MySQL不像Lucene或Sphinx那樣擅長于這類檢索。
NDB API 可以被用于某一類型的任務。比如,雖然MySQL的NDB Cluster存儲引擎不適合在高性能要求的Web應用中作排序操作,但是可以通過直接使用NDB API 來存儲網站的session數據或用戶注冊信息。關于 NDB API,你可以訪問http://dev.mysql.com/doc/ndbapi/ en/index.html來獲取更多信息。Apache上也有相應的NDB模塊,你可以從http://code.google. com/p/mod-ndb/下載。
最后,對于有些操作,比如圖形化的關系、樹的遍歷,關系數據庫并不擅長做這些。MySQL也不擅長分布式數據處理,因為它缺少并行查詢的執行能力。你可能需要使用別的工具(與MySQL一起使用)來達到這一目的。