通過Internet獲取資源既緩慢,成本又高。為此,Http協議裡包含了控制快取的部分,以使Http客戶端可以快取和重用以前獲取的資源,從而優化效能,提升體驗。雖然Http中關於快取控制的部分,隨著協議演進,有一些變化。但我覺著,作為後端程式設計師,在開發Web服務時,只需要關注請求頭If-None-Match、響應頭ETag、響應頭Cache-Control就足夠了。因為這三個Http頭就可以滿足你的需求,並且,當今絕大多數的瀏覽器,都支援這三個Http頭。我們所要做的就是,確保每個伺服器響應都提供正確的 HTTP 頭指令,以指導瀏覽器何時可以快取響應以及可以快取多久。
快取在哪兒?
上圖中有三個角色,瀏覽器、Web代理和伺服器,如圖所示HTTP快取存在於瀏覽器和Web代理中。當然在伺服器內部,也存在著各種快取,但這已經不是本文要討論的Http快取了。所謂的Http快取控制,就是一種約定,通過設定不同的響應頭Cache-Control來控制瀏覽器和Web代理對快取的使用策略,通過設定請求頭If-None-Match和響應頭ETag,來對快取的有效性進行驗證。
響應頭ETag
ETag全稱Entity Tag,用來標識一個資源。在具體的實現中,ETag可以是資源的hash值,也可以是一個內部維護的版本號。但不管怎樣,ETag應該能反映出資源內容的變化,這是Http快取可以正常工作的基礎。
如上例中所展示的,伺服器在返回響應時,通常會在Http頭中包含一些關於響應的後設資料資訊,其中,ETag就是其中一個,本例中返回了值為x1323ddx的ETag。當資源/file的內容發生變化時,伺服器應當返回不同的ETag。
請求頭If-None-Match
對於同一個資源,比如上一例中的/file,在進行了一次請求之後,瀏覽器就已經有了/file的一個版本的內容,和這個版本的ETag,當下次使用者再需要這個資源,瀏覽器再次向伺服器請求的時候,可以利用請求頭If-None-Match來告訴伺服器自己已經有個ETag為x1323ddx的/file,這樣,如果伺服器上的/file沒有變化,也就是說伺服器上的/file的ETag也是x1323ddx的話,伺服器就不會再返回/file的內容,而是返回一個304的響應,告訴瀏覽器該資源沒有變化,快取有效。
如上例中所示,在使用了If-None-Match之後,伺服器只需要很小的響應就可以達到相同的結果,從而優化了效能。
響應頭Cache-Control
每個資源都可以通過Http頭Cache-Control來定義自己的快取策略,Cache-Control控制誰在什麼條件下可以快取響應以及可以快取多久。 最快的請求是不必與伺服器進行通訊的請求:通過響應的本地副本,我們可以避免所有的網路延遲以及資料傳輸的資料成本。為此,HTTP 規範允許伺服器返回一系列不同的 Cache-Control 指令,控制瀏覽器或者其他中繼快取如何快取某個響應以及快取多長時間。
Cache-Control 頭在 HTTP/1.1 規範中定義,取代了之前用來定義響應快取策略的頭(例如 Expires)。當前的所有瀏覽器都支援 Cache-Control,因此,使用它就夠了。
以下我來介紹可以再Cache-Control中設定的常用指令。
max-age
該指令指定從當前請求開始,允許獲取的響應被重用的最長時間(單位為秒。例如:Cache-Control:max-age=60表示響應可以再快取和重用 60 秒。需要注意的是,在max-age指定的時間之內,瀏覽器不會向伺服器傳送任何請求,包括驗證快取是否有效的請求,也就是說,如果在這段時間之內,伺服器上的資源發生了變化,那麼瀏覽器將不能得到通知,而使用老版本的資源。所以在設定快取時間的長度時,需要慎重。
public和private
如果設定了public,表示該響應可以再瀏覽器或者任何中繼的Web代理中快取,public是預設值,即Cache-Control:max-age=60等同於Cache-Control:public, max-age=60。
在伺服器設定了private比如Cache-Control:private, max-age=60的情況下,表示只有使用者的瀏覽器可以快取private響應,不允許任何中繼Web代理對其進行快取 – 例如,使用者瀏覽器可以快取包含使用者私人資訊的 HTML 網頁,但是 CDN 不能快取。
no-cache
如果伺服器在響應中設定了no-cache即Cache-Control:no-cache,那麼瀏覽器在使用快取的資源之前,必須先與伺服器確認返回的響應是否被更改,如果資源未被更改,可以避免下載。這個驗證之前的響應是否被修改,就是通過上面介紹的請求頭If-None-match和響應頭ETag來實現的。
需要注意的是,no-cache這個名字有一點誤導。設定了no-cache之後,並不是說瀏覽器就不再快取資料,只是瀏覽器在使用快取資料時,需要先確認一下資料是否還跟伺服器保持一致。如果設定了no-cache,而ETag的實現沒有反應出資源的變化,那就會導致瀏覽器的快取資料一直得不到更新的情況。
no-store
如果伺服器在響應中設定了no-store即Cache-Control:no-store,那麼瀏覽器和任何中繼的Web代理,都不會儲存這次相應的資料。當下次請求該資源時,瀏覽器只能重新請求伺服器,重新從伺服器讀取資源。
怎樣決定一個資源的Cache-Control策略呢?
下面這個流程圖,可以幫到你。
常見錯誤
啟動時快取
有時候,我們會發現應用程式啟動很慢,最終發現是其中一個依賴的服務響應時間很長,這時該怎麼辦?
通常來說,遇到這類問題,說明這個依賴服務無法滿足需求。如果這是一個第三方服務,控制權不在自己手上,這時我們可能會引入快取。
此時引入快取的問題,是快取失效策略難以生效,因為快取設計的本意就是儘可能少的請求依賴的服務。
過早快取
這裡提到“早”,不是應用程式的生命週期,而是開發的週期。有的時候我們會看見,一些開發者在開發初期就已經估算出系統瓶頸,並引入快取。
事實上,這樣的做法掩蓋了可能進行效能優化的點。反正到時候這個服務的返回值會被快取住,我幹嘛還要花時間去優化這部分程式碼呢?
整合快取
SOLID原則中的“S”代表——單一功能原則(Single responsibility principle)。當應用程式整合快取模組之後,快取模組和服務層就有了強耦合,無法在沒有快取模組的參與下單獨執行。
快取所有內容
有的時候為了降低響應延遲,可能會盲目的對外部呼叫都加上快取。事實上,這樣的行為很容易讓開發者和維護者無法意識到快取模組的存在,最終對底層依賴模組的可靠性做出了錯誤的評估。
級聯快取
快取所有內容,或者只是快取了大部分內容,可能會導致快取資料中包含其他快取資料。
如果應用程式中包含這種級聯的快取結構,可能導致的情況是快取失效時間不可控。最上層的快取需要等每一級快取都失效更新之後,最終返回的資料才會徹底更新。
不可重新整理快取
通常情況下,快取中介軟體會提供一個重新整理快取的工具。例如Redis,維護人員可以通過其提供的工具,刪除部分資料,甚至重新整理整個快取。
但是,一些臨時快取,可能不會包含這樣的工具。例如簡單的將資料儲存在內容中的快取,通常不會允許外部工具來修改或者刪除快取內容。這時,如果發現快取資料異常,維護人員只能採取重啟服務的方式,這將大大增加運維成本和響應時間。更有甚者,一些快取可能會將快取內容寫在檔案系統中進行備份。此時除了重啟服務,還需要確保應用程式啟動之前刪除檔案系統上的快取備份。
快取帶來的影響
上面提到了引入快取可能導致的常見錯誤,這些問題在無快取系統中通過不會考慮。
部署一個重度依賴快取的系統,可能會因為等待快取失效而花費大量時間。例如通過CDN快取內容,系統釋出之後去重新整理CDN配置、CDN快取的內容,可能需要幾個小時。
另外,出現效能瓶頸優先考慮快取,會導致效能問題被掩蓋,得不到真正的解決。事實上,很多時候調優程式碼花費的時間,和引入快取元件不會相差太多。
最後,對於包含快取元件的系統,除錯成本會大大增加。經常會發生追蹤半天程式碼,結果資料來自快取,和實際邏輯上應該依賴的元件沒有任何關係。同樣的問題也可能出現在執行了所有相關測試用例之後,修改到的程式碼實際沒有被測試到。
如何用好快取?
放棄快取!
好吧,很多時候快取是無法避免的。基於網際網路的系統,很難完全避免使用快取,甚至連http協議頭,都包含快取配置:Cache-Control: max-age=xxx。
瞭解資料
如果要將資料訪問快取,首先需要了解資料更新策略。只有明確瞭解資料何時需要更新,才能通過If-Modified-Since頭來判斷客戶端請求的資料是否需要更新,是簡單返回304 Not Modified響應讓客戶端複用之前的本地快取資料,還是返回最新資料。另外,為了更好利用http協議中的快取,建議給資料區分版本,或者利用eTag來標記快取資料的版本。
優化效能而不是使用快取
前文提到過,使用快取往往會將潛在效能問題掩蓋。儘可能利用效能分析工具,找到應用程式響應緩慢的真實原因並且修復它。例如減少無效程式碼呼叫,根據SQL執行計劃優化SQL等。
下面是清除應用程式所有快取的程式碼
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
|
/* * 文 件 名: DataCleanManager.java * 描 述: 主要功能有清除內/外快取,清除資料庫,清除sharedPreference,清除files和清除自定義目錄 */ package com.test.DataClean; import java.io.File; import android.content.Context; import android.os.Environment; /** * 本應用資料清除管理器 */ public class DataCleanManager { /** * 清除本應用內部快取(/data/data/com.xxx.xxx/cache) * * @param context */ public static void cleanInternalCache(Context context) { deleteFilesByDirectory(context.getCacheDir()); } /** * 清除本應用所有資料庫(/data/data/com.xxx.xxx/databases) * * @param context */ public static void cleanDatabases(Context context) { deleteFilesByDirectory(new File("/data/data/" + context.getPackageName() + "/databases")); } /** * 清除本應用SharedPreference(/data/data/com.xxx.xxx/shared_prefs) * * @param context */ public static void cleanSharedPreference(Context context) { deleteFilesByDirectory(new File("/data/data/" + context.getPackageName() + "/shared_prefs")); } /** * 按名字清除本應用資料庫 * * @param context * @param dbName */ public static void cleanDatabaseByName(Context context, String dbName) { context.deleteDatabase(dbName); } /** * 清除/data/data/com.xxx.xxx/files下的內容 * * @param context */ public static void cleanFiles(Context context) { deleteFilesByDirectory(context.getFilesDir()); } /** * 清除外部cache下的內容(/mnt/sdcard/android/data/com.xxx.xxx/cache) * * @param context */ public static void cleanExternalCache(Context context) { if (Environment.getExternalStorageState().equals( Environment.MEDIA_MOUNTED)) { deleteFilesByDirectory(context.getExternalCacheDir()); } } /** * 清除自定義路徑下的檔案,使用需小心,請不要誤刪。而且只支援目錄下的檔案刪除 * * @param filePath */ public static void cleanCustomCache(String filePath) { deleteFilesByDirectory(new File(filePath)); } /** * 清除本應用所有的資料 * * @param context * @param filepath */ public static void cleanApplicationData(Context context, String... filepath) { cleanInternalCache(context); cleanExternalCache(context); cleanDatabases(context); cleanSharedPreference(context); cleanFiles(context); for (String filePath : filepath) { cleanCustomCache(filePath); } } /** * 刪除方法 這裡只會刪除某個資料夾下的檔案,如果傳入的directory是個檔案,將不做處理 * * @param directory */ private static void deleteFilesByDirectory(File directory) { if (directory != null && directory.exists() && directory.isDirectory()) { for (File item : directory.listFiles()) { item.delete(); } } } } |
總結
快取是非常有用的工具,但極易被濫用。不到最後一刻不要使用快取,優先考慮使用其他方式優化應用程式效能