上一篇文章: Python--Redis實戰:第五章:使用Redis構建支持程序:第1節:使用Redis來記錄日誌
下一篇文章: Python--Redis實戰:第五章:使用Redis構建支持程序:第3節:查找IP所屬城市以及國家
正如第三章所述,經過記錄各個頁面的被訪問次數,咱們能夠根據基本的訪問計數信息來決定如何緩存頁面。可是第三章只是一個很是簡單的例子,現實狀況不少時候並不是是如此簡單的,特別是涉及實際網站的時候,尤其如此。前端
知道咱們的網站在最近5分鐘內得到了10 000次點擊,或者數據庫在最近5秒內處理了200次寫入和600次讀取,是很是有用的。經過在一段時間內持續地記錄這些信息,咱們能夠注意到流量的突增和漸增狀況,預測什麼時候須要對服務器進行升級,從而預防系統由於負載超載而下線。web
這一節將分別介紹使用Redis來實現計數器的方法以及使用Redis來進行數據統計的方法,並在最後討論如何簡化示例中的數據統計操做。本節展現的例子都是由實際的用例和需求驅動的。首先,讓咱們來看看,如何使用Redis來實現時間序列計數器,以及如何使用這些計數器來記錄和監視應用程序的行文。redis
在監控應用程式的同時,持續的收集信息是一件很是重要的事情。那些影響網站響應速度以及網站所能服務的頁面數量的代碼改動、新的廣告營銷活動或者是剛剛接觸系統的新用戶,都有可能會完全地改變網站載入頁面的數量,並所以而影響網站的各項性能指標。但若是咱們平時不記錄任何指標數據的話,咱們就不可能知道指標發生了變化,也就不知道網站的性能是在提升仍是在降低。數據庫
爲了收集指標數據並進行監視和分析,咱們將構建一個可以持續建立並維護計數器的工具,這個工具建立的每一個計數器都有本身的名字(名字帶有網站點擊量、銷量或者數據庫查詢字樣的計數器都是比較重要的計數器)。這些計數器會以不一樣的精度(如1秒、5秒、1分鐘等)存儲最新的120個數據樣本,用戶也能夠根據本身的須要,對取樣的數量和精度進行修改。segmentfault
實現計數器首先須要考慮的就是如何存儲計數器的信息,接下來將說明咱們是如何將計數器信息存儲在Redis裏面緩存
爲了對計數器進行更新,咱們須要存儲實際的計數器信息,對於每一個計數器以及每種精度,如網站點擊量計數器/5秒,咱們將使用一個散列來存儲網站在每一個5秒時間片以內得到的點擊量,其中,散列的每一個鍵都是某個時間片的開始時間,而鍵對應的值則存儲了網站在該時間片以內得到的點擊量。下表展現了一個點擊量計數器存儲的其中一部分數據,這個計數器以每5秒爲一個時間片記錄着網站的點擊量:服務器
鍵名:count:5:hits | 類型:hash |
---|---|
1336376410 | 45 |
1336376405 | 28 |
1336376395 | 17(本行數據表示:網站在2012年5月7日早晨7:39:55到7:40:00總共得到了17次點擊) |
1336376400 | 29 |
爲了可以清理計數器包含的舊數據,咱們須要在使用計數器的同時,對被使用的計數器進行記錄。爲了作到這一點,咱們須要一個有序序列,這個序列不能包含任何重複元素,而且可以讓咱們一個接一個地遍歷序列中包含的全部元素。雖然同時使用列表和集合能夠實現這種序列,但同時使用兩種數據結構須要編寫更多代碼,而且增長客戶端和Redis之間的通訊往返次數。實際上,實現有序序列更好的方法時使用有序集合,有序集合的各個成員分別由計數器的精度以及計數器的名字組成,而全部成員的分值都是0.由於全部成員的分值都被設置爲0,因此Redis在嘗試按分值對有序集合進行排序的時候,就會發現這一點,並改成使用成員名進行排序,這使得一組給定的成員老是具備固定的排列順序,從而能夠方便地對這些成員進行順序性的掃描。下表展現了一個有序集合,這個有序集合記錄了正在使用的計數器。數據結構
鍵名:known: | 類型:zset(有序集合) |
---|---|
1:hits | 0 |
5:hits | 0 |
60:hits | 0 |
既然咱們已經知道應該使用什麼結構來記錄並表示計數器了,如今是時候來考慮一下如何使用和更新這些計數器了。app
下面代碼展現了程序更新計數器的方法,對於每種時間片精度,程序都會將計數器 的精度和名字做爲引用信息添加都記錄已有計數器的有序集合裏面,並增長散列計數器在指定時間片內的計數值。函數
#以秒爲單位的計數器精度,分別爲1秒/5秒/1分鐘/5分鐘/1小時/5小時/1天 #用戶能夠按需調整這些精度 import time PRECISION=[1,5,60,300,3600,18000,86400] def update_counter(conn,name,count=1,now=None): #經過獲取當前時間來判斷應該對哪一個時間片執行自增操做。 now=now or time.time() #爲了保證以後的清理工做能夠正確的執行,這裏須要建立一個事務性流水線 pipe=conn.pipeline() #爲咱們記錄的每種精度都建立一個計數器 for prec in PRECISION: #取得當前時間片的開始時間 pnow=int(now/prec)*prec #建立負責存儲計數信息的散列 hash='%s:%s'%(prec,name) # 將計數器的引用信息添加到有序集合裏面,並將其分值設爲0,以便在以後執行清理操做 pipe.zadd('known:',hash,0) #對給定名字和精度的計數器進行更新 pipe.hincrby('count:'+hash,pnow,count) pipe.execute()
更新計數器信息的過程並不複雜,程序只須要爲每種時間片精度執行zadd命令和hincrby命令就能夠了。於此相似,從指定精度和名字的計數器裏面獲取計數數據也是一件很是容易地事情。下面代碼展現了用於執行這一操做的代碼:程序首先使用hgetall命令來獲取整個散列,接着將命令返回的時間片和計數器的值從原來的字符串格式轉換成數字格式,根據時間對數據進行排序,最後返回排序後的數據:
def get_counter(conn,name,precision): #取得存儲計數器數據的鍵的名字 hash='%s:%s'%(precision,name) #從Redis裏面取出計數器數據 data=conn.hgetall('count:'+hash) to_return=[] #將計數器數據轉換成指定的格式 for key,value in data.iteritems(): to_return.append((int(key),int(value))) #對數據進行排序,把舊的數據樣本排在前面 to_return.sort() return to_return
get_counter()函數的工做方式就和以前描述的同樣,它獲取計數器數據並將其轉換成整數,而後根據時間前後對轉換後的數據進行排序。
在弄懂了獲取計數器存儲的數據以後,接下來咱們要考慮的是如何防止這些計數器存儲過多的數據。
通過前面的介紹,咱們已經知道了怎樣將計數器存儲到Redis裏面,已經怎樣從計數器裏面取出數據。可是,若是咱們只是一味地對計數器進行更新而不執行任何清理操做的話,那麼程序最終將會由於存儲了過多的數據而致使內存不足。好在咱們事先已將全部已知的計數器記錄到了一個有序集合裏面,因此對計數器進行清理只須要遍歷有序集合並刪除其中的舊計數器舊能夠了。
爲何不使用expire?expire命令的其中一個限制就是它只能應用整個鍵,而不能只對鍵的某一部分數據進行過時處理。而且由於咱們將同一個計數器在不一樣精度下的全部計數器數據都存放到了同一個鍵裏面,因此咱們必須按期地對計數器進行清理。若是讀者感興趣的話,也能夠試試改變計數器組織數據的方式,使用Redis的過時鍵功能來代替手工的清理操做。
在處理和清理舊數據的時候,有幾件事情是須要咱們格外留心的,其中包括如下幾件:
咱們接下來要構建一個守護進程函數,這個函數的工做方式和第三章中展現的守護進程函數相似,而且會嚴格遵照上面列出的各個注意事項。和以前展現的守護進程函數同樣,這個守護進程函數會不斷地重複循環知道系統終止這個進程爲止。爲了儘量地下降清理操做的執行負載,守護進程會以每分鐘一次的頻率清理那些每分鐘更新一次或者每分鐘更新屢次的計數器,而對於那些更新頻率低於每分鐘一次的計數器,守護進程則會根據計數器自身的更新頻率來決定對他們進行清理的頻率。好比說,對於每秒更新一次或者每5秒更新一次的計數器,守護進程將以每分鐘一次的頻率清理這些計數器;而對於每5分鐘更新一次的計數器,守護進程將以每5分鐘一次的頻率清理這些計數器。
清理程序經過對記錄已知計數器的有序集合執行zrange命令來一個接一個的遍歷全部已知的計數器。在對計數器執行清理操做的時候,程序會取出計數器記錄的全部計數樣本的開始時間,並移除那些開始時間位於指定截止時間以前的樣本,清理以後的計數器最多隻會保留最新的120個樣本。若是一個計數器在執行清理操做以後再也不包含任何樣本,那麼程序將從記錄已知計數器的有序集合裏面移除這個計數器的引用信息。以上給出的描述大體地說明了計數器清理函數的運做原理,至於程序的一些邊界狀況最好仍是經過代碼來講明,要了解該函數的全部細節,請看下面代碼:
import bisect import time import redis QUIT=True SAMPLE_COUNT=1 def clean_counters(conn): pipe=conn.pipeline(True) #爲了平等的處理更新頻率各不相同的多個計數器,程序須要記錄清理操做執行的次數 passes=0 #持續地對計數器進行清理,知道退出爲止 while not QUIT: #記錄清理操做開始執行的時間,這個值將被用於計算清理操做的執行時長 start=time.time() index=0 #漸進的遍歷全部已知計數器 while index<conn.zcard('known:'): #取得被檢查的計數器的數據 hash=conn.zrange('known:',index,index) index+=1 if not hash: break hash=hash[0] #取得計數器的精度 prec=int(hash.partition(':')[0]) #由於清理程序每60秒就會循環一次,因此這裏須要根據計數器的更新頻率來判斷是否真的有必要對計數器進行清理 bprec=int(prec//60) or 1 #若是這個計數器在此次循環裏不須要進行清理,那麼檢查下一個計數器。 #舉個例子:若是清理程序只循環了3次,而計數器的更新頻率是5分鐘一次,那麼程序暫時還不須要對這個計數器進行清理 if passes % bprec: continue hkey='count:'+hash #根據給定的精度以及須要保留的樣本數量,計算出咱們須要保留什麼時間以前的樣本。 cutoff=time.time()-SAMPLE_COUNT*prec #將conn.hkeys(hkey)獲得的數據都轉換成int類型 samples=map(int,conn.hkeys(hkey)) samples.sort() #計算出須要移除的樣本數量。 remove=bisect.bisect_right(samples,cutoff) #按須要移除技術樣本 if remove: conn.hdel(hkey,*samples[:remove]) #這個散列可能以及被清空 if remove==len(samples): try: #在嘗試修改計數器散列以前,對其進行監視 pipe.watch(hkey) #驗證計數器散列是否爲空,若是是的話,那麼從記錄已知計數器的有序集合裏面移除它。 if not pipe.hlen(hkey): pipe.multi() pipe.zrem('known:',hash) pipe.execute() #在刪除了一個計數器的狀況下,下次循環可使用與本次循環相同的索引 index-=1 else: #計數器散列並不爲空,繼續讓它留在記錄已知計數器的有序集合裏面 pipe.unwatch() except redis.exceptions.WatchError: #有其餘程序向這個計算器散列添加了新的數據,它已經再也不是空的了, # 繼續讓它留在記錄已知計數器的有序集合裏面。 pass passes+=1 # 爲了讓清理操做的執行頻率與計數器更新的頻率保持一致 # 對記錄循環次數的變量以及記錄執行時長的變量進行更新。 duration=min(int(time.time()-start)+1,60) #若是此次循環未耗盡60秒,那麼在餘下的時間內進行休眠,若是60秒已經耗盡,那麼休眠1秒以便稍做休息 time.sleep(max(60-duration,1))
正如以前所說,clean_counters()函數會一個接一個地遍歷有序集合裏面記錄的計數器,查找須要進行清理的計數器。程序在每次遍歷時都會對計數器進行檢查,確保只清理應該清理的計數器。當程序嘗試清理一個計數器的時候,它會取出計數器記錄的全部數據樣本,並判斷哪些樣本是須要被刪除的。若是程序在對一個計數器執行清理操做以後,而後這個計數器已經再也不包含任何數據,那麼程序會檢查這個計數器是否已經被清空,並在確認了它已經被清空以後,將它從記錄已知計數器的有序集合中移除。最後,在遍歷完全部計數器以後,程序會計算這次遍歷耗費的時長,若是爲了執行清理操做而預留的一分鐘時間沒有徹底耗盡,那麼程序將休眠直到這一分鐘過去爲止,而後繼續進行下次遍歷。
如今咱們已經知道怎樣記錄、獲取和清理計數器數據了,接下來要作的視乎就是構建一個界面來展現這些數據了。遺憾的是,這些內容設計到前端,並不在本內容介紹範圍內,若是感興趣,能夠試試jqplot、Highcharts、dygraphs已經D3,這幾個JavaScript繪圖庫不管是我的使用仍是專業使用都很是合適。
在和一個真實的網站打交道的時候,知道頁面天天的點擊能夠幫助咱們判斷是否須要對頁面進行緩存。可是,若是被頻繁訪問的頁面只須要花費2毫秒來進行渲染,而其餘流量只要十分之一的頁面卻須要花費2秒來進行渲染,那麼在緩存被頻繁訪問的頁面以前,咱們能夠先將注意力放到優化渲染速度較慢的頁面上去。在接下來的一節中,咱們將再也不使用計數器來記錄頁面的點擊量,而是經過記錄聚合統計數據來更準確地判斷哪些地方須要進行優化。
首先須要說明的一點是,爲了統計數據存儲到Redis裏面,筆者曾經實現過5種不一樣的方法,本節介紹的方法綜合了這5種方法裏面的衆多優勢,具備很是大的靈活性和可擴展性。
本節所展現的存儲統計數據的方法,在工做方式上與上節介紹的log_common()函數相似:這二者存儲的數據記錄的都是當前這一小時以及前一小時所產生的事情。另外,本節介紹的方法會記錄最小值、最大值、平均值、標準差、樣本數量以及全部被記錄值之和等衆多信息,以便不時之需。
對於一種給定的上下文和類型,程序將使用一個有序集合來記錄這個上下文以及這個類型的最小值、最大值、樣本數量、值的和、值的平方之和等信息,並經過這些信息來計算平均值以及標準差。程序將值存儲在有序集合裏面並不是是爲了按照分值對成員進行排序、而是爲了對存儲着統計信息的有序集合和其餘有序集合進行並集計算,並經過min和max這兩個聚合函數來篩選相交的元素。下表展現了一個存儲統計數據的有序集合實例,它記錄了ProfilePage(我的簡歷)上下文的AccessTime(訪問時間)統計數據。
表名:starts:ProfilePage:AccessTime | 類型:zset |
---|---|
min | 0.035 |
max | 4.958 |
sunsq | 194.268 |
sum | 258.973 |
count | 2323 |
既然咱們已經知道了程序要存儲的是什麼類型的數據,那麼接下來要考慮的就是如何將這些數據寫到數據結構裏面了。
下面代碼展現了負責更新統計數據的代碼。和以前介紹過的常見日誌程序同樣,統計程序在寫入數據以前會進行檢查,確保被記錄的是當前這小時的統計數據,並將不屬於當前這一小時的舊數據進行歸檔。在此以後,程序會構建兩個臨時有序集合,其中一個用於保存最小值,而另外一個則用於保存最大值而後使用zunionstore命令以及它的兩個聚合函數min和max,分別計算兩個臨時有序集合與記錄當前統計數據的有序集合以前的並集結果。經過使用zunionstore命令,程序能夠快速的更新統計數據,而無須使用watch去監視可能會頻繁進行更新的存儲統計數據的鍵,由於這個鍵可能會頻繁地進行更新。程序在並集計算完畢以後就會刪除那些臨時有序集合,並使用zincrby命令對統計數據有序集合裏面的count、sum、sumsq這3個成員進更新。
import datetime import time import uuid import redis def update_status(conn,context,type,value,timeout=5): #負責存儲統計數據的鍵 destination='stats:%s:%s'%(context,type) #像common_log()函數同樣,處理當前這一個小時的數據和上一個小時的數據 start_key=destination+':start' pipe=conn.pipeline(True) end=time.time()+timeout while time.time()<=end: try: pipe.watch(start_key) now=datetime.utcnow().timetuple() # 像common_log()函數同樣,處理當前這一個小時的數據和上一個小時的數據 hour_start=datetime(*now[:4]).isoformat() existing=pipe.get(start_key) pipe.multi() if existing and existing<hour_start: # 像common_log()函數同樣,處理當前這一個小時的數據和上一個小時的數據 pipe.rename(destination,destination+':last') pipe.rename(start_key,destination+':pstart') pipe.set(start_key,hour_start) tkey1=str(uuid.uuid4()) tkey2=str(uuid.uuid4()) #將值添加到臨時鍵裏面 pipe.zadd(tkey1,'min','value') pipe.zadd(tkey2,'max','value') #使用聚合函數min和max,對存儲統計數據的鍵以及兩個臨時鍵進行並集計算 pipe.zunionstore(destination,[destination,tkey1],aggregate='min') pipe.zunionstore(destination,[destination,tkey2],aggregate='max') #刪除臨時鍵 pipe.delete(tkey1,tkey2) #對有序集合中的樣本數量、值的和、值的平方之和3個成員進行更新。 pipe.zincrby(destination,'count') pipe.zincrby(destination,'sum',value) pipe.zincrby(destination,'sumsq',value*value) #返回基本的計數信息,以便函數調用者在有須要時作進一步的處理 return pipe.execute()[-3:] except redis.exceptions.WatchError: #若是新的一個小時已經開始,而且舊的數據已經被歸檔,那麼進行重試 continue
update__status()函數的前半部分代碼基本上能夠忽略不看,由於它們和上節介紹的log_common()函數用來輪換數據的代碼幾乎如出一轍,而update__status()函數的後半部分則作了咱們前面描述過的事情:程序首先建立兩個臨時有序集合,而後使用適當的聚合函數,對存儲統計數據的有序集合以及兩個臨時有序集合分別執行zunionstore命令;最後,刪除臨時有序集合,並將並集計算所得的統計數據更新到存儲統計數據的有序集合裏面。update__status()函數展現了將統計數據存儲到有序集合裏面的方法,但若是想要獲取統計數據的話,又應該怎麼作呢?
下面代碼展現了程序取出統計數據的方法:程序會從記錄統計數據的有序集合裏面取出全部被存儲的值,並計算出平均值和標準差。其中,平均值能夠經過值的和(sum)除以取樣數量(count)來計算得出;而標準差的計算則更復雜一些,程序須要多作一些工做才能根據已有的統計信息計算出標註差,可是爲了簡潔起見,這裏不會解釋計算標準差時用到的數學知識。
import datetime import time import uuid import redis def get_stats(conn,context,type): #程序將從這個鍵裏面取出統計數據 key='stats:%s:%s'%(context,type) #獲取基本的統計數據,並將它們都放到一個字典裏面 data=dict(conn.zrange(key,0,-1,withscores=True)) #計算平均值 data['average']=data['sum']/data['count'] #計算標準差的第一個步驟 numerator=data['sumsq']-data['sun']**2/data['count'] #完成標準差的計算工做 data['stddev']=(numerator/data['count']-1 or 1)** .5 return data
除了用於計算標準差的代碼以外,get_stats()函數並無什麼難懂的地方,若是讀者願意花些時間在網上了解什麼叫標準差的話,那麼讀懂這些標準差的代碼應該也不是什麼難事。儘管有了那麼多統計數據,但咱們可能還不太清楚本身應該觀察哪些數據,而接下來的一節就會解答這個問題。
在將統計數據存儲到Redis裏面以後,接下來咱們該作些什麼呢?說的更詳細一點,在知道了訪問每一個頁面所需的時間以後,咱們要怎樣才能找到那些生成速度較慢的網頁?或者說,當某個頁面的生成速度變得比以往要慢的時候,咱們如何才能知悉這一狀況?簡單的說,爲了發現以上提到的這些狀況,咱們須要存儲更多信息,而具體的方法將這一節裏面介紹。
要記錄頁面的訪問時長,程序就必須在頁面被訪問時進行計時。爲了作到這一點,咱們能夠在各個不一樣的頁面設置計時器,並添加代碼來記錄計時的結果,但最好的辦法是直接實現一個可以進行計時並將計時結果存儲起來的東西,讓它將平均訪問速度最慢的頁面都記錄到一個有序集合裏面,並向咱們報告哪些頁面的載入時間變得比之前更長了。
爲了計算和記錄訪問時長,咱們會編寫一個Python上下文管理器,並使用這個上下文管理器來包裹那些須要計算並記錄訪問時長的代碼。
在Python裏面,一個上下文管理器就是一個專門定義的函數或者類,這個函數或者類的不一樣部分能夠在一段代碼執行以前以及執行以後分別執行。上下文管理器使得用戶能夠很容易地實現相似【自動關閉已打開的文件】這樣的功能。
下面代碼展現了用於計算和記錄訪問時長的上下文管理器:程序首先會取得當前時間,接着執行被包裹的代碼,而後計算這些代碼的執行時長,並將結果記錄到
Redis裏面;除此以外,程序還會對記錄當前上下文最大訪問的時間的有序集合進行更新。
import contextlib import time #將這個Python生成器用做上下文管理器 @contextlib.contextmanager def access__time(conn,context): #記錄代碼塊執行前的時間 start=time.time() #運行被包裹的代碼塊 yield #計算代碼塊的執行時長 data=time.time()-start #更新這一上下文的統計數據 stats=update_stats(conn,context,'AccessTime',data) #計算頁面的平局訪問時長 average=stats[1]/stats[0] pipe=conn.pipeline(True) #將頁面的平均訪問時長添加到記錄最長訪問時間的有序集合裏面 pipe.zadd('slowest:AccessTime',context,average) #AccessTime有序集合只會保留最慢的100條記錄 pipe.zremrangebyrank('slowessTime',0,-101) pipe.execute()
由於access__time()上下文管理器裏面有一些沒辦法只用三言兩語來解釋的概念,因此咱們最好仍是直接經過使用這個管理器來了解它是如何運做的。接下來的這段代碼展現了使用access__time()上下文管理器記錄web頁面訪問時長的方法,負責處理被記錄頁面的是一個回調函數:
#這個視圖接收一個Redis鏈接以及一個生成內容的回調函數做爲參數 def process_view(conn,callback): #計算並記錄訪問時長的上下文管理器就是這一包裹代碼塊的 with access_time(conn,request.path): #當上下文管理器中的yield語句被執行時,這個語句就會被執行 return callback()
若是還不理解,看下面簡單的實例:
import contextlib @contextlib.contextmanager def mark(): print("1") yield print(2) def test(callback): with mark(): return callback() def xxx(): print('xxx') if __name__ == '__main__': test(xxx)
運行結果:
1 xxx 2
在看過這個例子以後,即便讀者沒有學過上下文管理器的建立方法,可是至少也已經知道該如何去使用它了。這個例子使用了訪問時間上下文管理器來計算生成一個頁面須要花費時多長時間,此外,一樣的上下文管理器還能夠用於計算數據庫查詢花費的時長,或者用來計算渲染一個模板所需的時長。做爲練習,你可否構思一些其餘種類的上下文管理器,並使用它們來記錄有用的統計信息呢?另外,你可否讓程序在頁面的訪問時長比平均狀況要高出兩個標註差或以上時,在recent_log()函數裏面記錄這一狀況呢?
對現實世界中的統計數據進行收集和計數儘管本書已經花費了好幾頁篇幅來說述該如何收集生產系統運做時產生的至關重要的統計信息,可是別忘了已經有不少現成的軟件包能夠用於收集並繪製計數器以及統計數據,我我的最喜歡的是Graphite,在時間嘗試構建本身的數據繪圖庫以前,不妨先試試這個。
在學會了如何將應用程序相關的各類重要信息存儲到Redis以後,在接下來一節中,咱們將瞭解更多與訪客有關的信息,這些信息能夠幫助咱們處理其餘問題。
上一篇文章: Python--Redis實戰:第五章:使用Redis構建支持程序:第1節:使用Redis來記錄日誌
下一篇文章: Python--Redis實戰:第五章:使用Redis構建支持程序:第3節:查找IP所屬城市以及國家