Redis In Action 筆記(五):使用 Redis 支援應用程式

tsin發表於2019-06-29

記錄日誌

記錄最近日誌

# 思路:將日誌加入list,然後修剪到規定大小
def log_recent(conn, name, message, severity=logging.INFO, pipe=None):
    severity = str(SEVERITY.get(severity, severity)).lower()    

    # 日誌的KEY,構成:recent:日誌名稱:日誌級別            
    destination = 'recent:%s:%s'%(name, severity)   
    message = time.asctime() + ' ' + message  # 日誌資訊前面新增時間資訊 

    pipe = pipe or conn.pipeline()                              
    pipe.lpush(destination, message)   # 1. 加入list                         
    pipe.ltrim(destination, 0, 99)     # 2. 擷取最近100條記錄                        
    pipe.execute()     # 執行以上兩步

# 執行:log_recent(conn, 'test', 'test_msg')
# 結果:(key)recent:test:info  (value)Sun Jun 23 11:57:38 2019 test_msg      

記錄常見日誌

記錄出現頻率較高的日誌,每小時進行一次輪換

# 思路:訊息作為成員記錄到有序集合,訊息出現頻率作為成員的分值(score)
# 記錄的時間範圍為1小時,記錄的時候發現已經過了一小時,
# 則把已有的記錄歸檔到上一小時(通過把KEY重新命名來實現)
# 則新的一小時訊息頻率有從0開始記錄
# 用於記錄的KEY:[common:日誌名稱:日誌級別]
def log_common(conn, name, message, severity=logging.INFO, timeout=5):
    severity = str(SEVERITY.get(severity, severity)).lower()    
    destination = 'common:%s:%s'%(name, severity) 
    #當前所處小時數              
    start_key = destination + ':start'    # common:日誌名稱:日誌級別:start                     
    pipe = conn.pipeline()

    end = time.time() + timeout
    while time.time() < end:
        try:
            pipe.watch(start_key)  
            # datetime(*now[:4]).isoformat() --> '2019-06-23T06:00:00'
            now = datetime.utcnow().timetuple()  
            # 簡單獲取小時數(原書方法行不通,這裡加以修改)               
            hour_start = now.tm_hour         

            # 獲取[common:日誌名稱:日誌級別:start]的值
            # 這裡返回字串型別,注意轉為整型
            existing = pipe.get(start_key)
            pipe.multi()   

            # 如果值存在 且 小於當前小時數                                  
            if existing and int(existing) < hour_start:
                # 進行歸檔
                # KEY [common:日誌名稱:日誌級別] 重新命名為 [common:日誌名稱:日誌級別:last]         
                pipe.rename(destination, destination + ':last') 
                # KEY [common:日誌名稱:日誌級別:start] 重新命名為 [common:日誌名稱:日誌級別:pstart]
                pipe.rename(start_key, destination + ':pstart') 
                # 之前的KEY已經歸檔,這裡是新的
                pipe.set(start_key, hour_start) 

            # 不存在則新增該日誌開始時間記錄                
            elif not existing:
                # KEY [common:日誌名稱:日誌級別:start] 的值設定為當前小時數                                  
                pipe.set(start_key, hour_start)

            # 對有序集合destination的成員message自增1
            # 注意:zincrby在redis-py3.0+的用法  
            pipe.zincrby(destination, 1, message)        
            # 記錄到最新日誌
            log_recent(pipe, name, message, severity, pipe)     
            return

        # 如果其他客戶端剛好有操作,修改了watch的key,進行重試
        except redis.exceptions.WatchError:
            continue   
# 執行
log_common(conn, 'test', 'msg')
#結果
#(zset)common:test:info   msg --> 1
# -->1小時後再次記錄日誌的話,該KEY就會變成common:test:info:last

#(string)common:test:info:start   14(小時數)
# -->1小時後再次記錄日誌的話,該KEY就會變成common:test:info:pstart

#(list)recent:test:info   Wed Jun 26 22:53:17 2019 msg

計數器

記錄,比如,1秒鐘、5秒鐘……頁面點選數,下圖為5秒鐘點選計數器,型別為hash,各鍵值對為 [ 時間戳:點選數 ]。

Redis In Action 筆記(五):使用 Redis 支援應用程式

[know:]有序聚合用於清理舊資料時,按精度大小順序逐個迭代計數器。
Redis In Action 筆記(五):使用 Redis 支援應用程式

更新計數器

# 以秒為單位的計數器精度
PRECISION = [1, 5, 60, 300, 3600, 18000, 86400]     
QUIT = False
SAMPLE_COUNT = 100

# 實現:獲取每個時間片段的開始時間,將次數統計到每個時間段的開始時間
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)    # 建立儲存計數資訊的hash

        # zadd在redis-py 3.0之後更改了,第二個引數應該傳入一個字典 √                
        # pipe.zadd('known:', hash, 0) ×
        # 有序集合,用於後期可以按順序迭代清理計數器(並不使用expire,因為expire只能對hash整個鍵過期)
        # 這裡可以組合使用set和list,但用zset,可以排序,又可以避免重複元素
        # **這個zset的score值都是0,所以最後排序的時候按member字串的二進位制來排序**
        pipe.zadd('known:', {hash: 0})   # 這個記錄在後面的清理程式有用  

        pipe.hincrby('count:' + hash, pnow, count) # 更新對應精度時間片的計數   
    pipe.execute()

獲取計數器

def get_counter(conn, name, precision):
    hash = '%s:%s'%(precision, name)  # 要獲取的hash的key              
    data = conn.hgetall('count:' + hash)            
    to_return = []                                  
    for key, value in data.items():             
        to_return.append((int(key), int(value)))    
    to_return.sort()                                
    return to_return

清理計數器

計數器中的元素隨著時間的推移越來越多,需要對其定期清理,保持合理的數量,以減少記憶體消耗。

# 清理規則: 1s,5s計數器,1min清理一次
# 後面的,5min計數器5min清理一次,以此類推
def clean_counters(conn):
    pipe = conn.pipeline(True)
    passes = 0 
    # 按時間片段從小到大迭代已知的計數器                                                 
    while not QUIT:                                             
        start = time.time()                                     
        index = 0

        while index < conn.zcard('known:'):
            # 取出有序集合的一個元素(列印hash,發現返回的是一個byte型別)                    
            hash = conn.zrange('known:', index, index)

            index += 1
            if not hash:
                break
            hash = hash[0]
            # 得到時間精度
            prec = int(hash.partition(b':')[0])
            # 按上面說明的清理規則計算時間間隔
            # 小於60s的計數器至少1min清理一次                  
            bprec = int(prec // 60) or 1    # '//'操作是取整除法 

            # 實現幾分鐘清理一次的邏輯
            # 不整除的時候continue --> 重新while迴圈
            # 比如,1分鐘,每次都整除,所以每次判斷後後執行continue下面的語句
            # 10分鐘,要等10次到passes=10才整除                   
            if passes % bprec: #                                  
                continue

            # 清理邏輯開始
            hkey = 'count:' + hash.decode('utf-8') # 注意將byte轉換成str,書中沒有轉換

            # 根據要保留的個數*精度,計算要擷取的時間點
            cutoff = time.time() - SAMPLE_COUNT * prec

            # python3的map返回可迭代物件而不是list,原書的這句需要加上list轉換          
            samples = list(map(int, conn.hkeys(hkey)))
            samples.sort()

            # 二分法找出cutoff右邊的位置(index)                                      
            remove = bisect.bisect_right(samples, cutoff)       

            # 如果有需要移除的
            if remove: 
                # 刪除0-remove位置的元素                                         
                conn.hdel(hkey, *samples[:remove])

                # 判斷是否全部被移除              
                if remove == len(samples):                      
                    try:
                        pipe.watch(hkey) 
                        # 再次確保hash中已經沒有元素                       
                        if not pipe.hlen(hkey):                 
                            pipe.multi() 
                            # 同時將known:中的相應元素移除                       
                            pipe.zrem('known:', hash)           
                            pipe.execute() 
                            # 減少了一個計數器                     
                            index -= 1                          
                        else:
                            pipe.unwatch()                      
                    except redis.exceptions.WatchError:         
                        pass                                    
        # 累計次數,直到整除,才開始清理程式
        passes += 1   

        # 以下兩句保證一次while迴圈時間是1min   

        # 計算程式執行時間,且保證至少1s,最多是1min                                       
        duration = min(int(time.time() - start) + 1, 60)  

        # 休息,時間為:1min減去程式執行時間,也即1min中剩餘的時間,且保證至少是1s
        time.sleep(max(60 - duration, 1))                       

統計資料

Redis In Action 筆記(五):使用 Redis 支援應用程式

# 將所有統計量放在一個有序集合裡
def update_stats(conn, context, type, value, timeout=5):
    destination = 'stats:%s:%s'%(context, type)                 
    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()                 
            hour_start = now.tm_hour       

            existing = pipe.get(start_key)
            pipe.multi()
            if existing and existing < hour_start:
                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())

            # 這裡比較巧妙地統計到最大值和最小值
            # 構造兩個有序集合,成員為min、max,score為要統計的值
            # 然後跟目標集合求並集,聚合方式為min、max
            pipe.zadd(tkey1, {'min': value})                      
            pipe.zadd(tkey2, {'max': value})                      
            pipe.zunionstore(destination,                       
                [destination, tkey1], aggregate='min')          
            pipe.zunionstore(destination,                       
                [destination, tkey2], aggregate='max')          

            pipe.delete(tkey1, tkey2)                           
            pipe.zincrby(destination, 1, 'count')  # +1                
            pipe.zincrby(destination, value, 'sum') # 總和            
            pipe.zincrby(destination, value*value,'sumsq') #平方和    

            return pipe.execute()[-3:]                          
        except redis.exceptions.WatchError:
            continue 

# 計算平均值、方差
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['sum'] ** 2 / data['count']       
    data['stddev'] = (numerator / (data['count'] - 1 or 1)) ** .5      
    return data    

完整程式碼:

本作品採用《CC 協議》,轉載必須註明作者和本文連結

Was mich nicht umbringt, macht mich stärker

相關文章