Redis In Action 筆記(六):使用 Redis 作為應用程式元件

tsin發表於2019-06-30

自動補全

程式說明

實現給一個組別中的成員發郵件時,輸入字首能找出匹配的收件人;程式假設所有名字是由字母組成。

設計思路

比如,查詢abc字首的名稱:

  • → 查詢範圍是(abbz, abd)
  • → zrange取出該範圍
  • → 但問題是這兩個元素的可能不存在
  • → 向有序集合插入兩個特殊元素,一個在abbz之後,一個在abd之前
  • → 根據這兩個特殊元素取排名→根據排名取範圍
  • → 已知:ASCII編碼裡面,z後面的第一個字元是 '{', a前面的第一個是反引號 ' ` '
  • →得到 abd 之前的且所有以 abc 為字首的合法名字之後的元素「 abc{ 」 →作為查詢的結束元素
  • →得到 abbz 之後的第一個元素是 abb{,這個元素位置 abc 之前→用作起始元素
  • →特殊情況:如果是查詢的字首是aba,則起始元素是ab`
  • → 由此得到查詢的範圍,即開始元素是字首的最後一個字元後退一個ASCII字元 + ' { '(如果是a則變為' ` '), 結束元素是字首直接加' { '

程式碼實現

import uuid
import bisect
import redis

conn = redis.Redis(host='127.0.0.1', port=6379)

valid_characters = '`abcdefghijklmnopqrstuvwxyz{'             

# 獲得起始和結束範圍
def find_prefix_range(prefix):
    posn = bisect.bisect_left(valid_characters, prefix[-1:])  
    suffix = valid_characters[(posn or 1) - 1]                
    return prefix[:-1] + suffix + '{', prefix + '{'     

# 自動補全程式
def autocomplete_on_prefix(conn, guild, prefix):
    # 獲取起始和結束字元
    start, end = find_prefix_range(prefix)                 
    identifier = str(uuid.uuid4()) 
    # 將開始和結束加上一個唯一識別符號,避免其他使用者同時操作時相互干擾                        
    start += identifier                                    
    end += identifier
    # 要查詢的組                                      
    zset_name = 'members:' + guild

    # 向有序集合中插入起始和結束字元
    conn.zadd(zset_name, {start:0, end:0})                 
    pipeline = conn.pipeline(True)
    while 1:
        try:
            pipeline.watch(zset_name) 
            # 獲得起始和結束元素的位置
            sindex = pipeline.zrank(zset_name, start)      
            eindex = pipeline.zrank(zset_name, end) 

            # 這裡用於讓取出的元素不超過10個       
            erange = min(sindex + 9, eindex - 2)           
            pipeline.multi()

            # 移除插入的兩個元素
            pipeline.zrem(zset_name, start, end) 
            # 獲得搜尋結果          
            pipeline.zrange(zset_name, sindex, erange)     
            items = pipeline.execute()[-1]                 
            break
        except redis.exceptions.WatchError:                
            continue                                       
    # 移除可能由其他客戶端插入的用於搜尋的元素(含有{)
    return [item.decode('utf-8') for item in items if b'{' not in item]   

# 加入小組
def join_guild(conn, guild, user):
    conn.zadd('members:' + guild, {user:0})

# 退出小組
def leave_guild(conn, guild, user):
    conn.zrem('members:' + guild, user)   

# 測試執行
join_guild(conn, 'aa', 'abc')    
join_guild(conn, 'aa', 'abcd')    
join_guild(conn, 'aa', 'abcz')    
join_guild(conn, 'aa', 'abcx')    
join_guild(conn, 'aa', 'abcy')    
join_guild(conn, 'aa', 'abci')    
join_guild(conn, 'aa', 'abcj')    
join_guild(conn, 'aa', 'abd')  
items = autocomplete_on_prefix(conn, 'aa', 'abc')  
print(items)
# 輸出: ['abc', 'abcd', 'abci', 'abcj', 'abcx', 'abcy', 'abcz']

有序集合常規用途

  • 快速判斷某個元素是否存在於集合裡面(ZSCORE key member,不存在這返回nil)
  • 檢視某個成員在有序集合中的位置或索引(ZRANK key-name member)
  • 取出某個範圍的元素(ZRANGE key-name start offset [WITHSCORES])

這裡的用法

所有score設為0,當所有成員分值都相同時,按照成員的名字來進行排序; 而當所有成員分值都是0的時候,成員按照字串的二進位制進行排序。

分散式鎖

實現一個分散式鎖,核心的命令是:setnx(SET if Not eXists),它的作用是當設定一個KEY時,如果該KEY不存在,才能設定成功。那麼當一個客戶端設定一個KEY成功,並將其值設定為一個唯一識別符號,其他客戶端使用同樣的命令則會設定失敗,也就是說,其他客戶端在該KEY存在的週期內,是無法成功設定該KEY的值的(程式上返回false),這相當於設定該KEY的值成功的客戶端獲得了鎖,當該客戶端使用完畢並將其刪除,其他客戶端才能夠設定該KEY。

當系統負載較大的時候,WATCH鎖導致程式進行重試的次數劇增,分散式鎖無此問題。

程式實現

 # 獲取鎖
def acquire_lock(conn, lockname, acquire_timeout=10):
    identifier = str(uuid.uuid4())                      
    end = time.time() + acquire_timeout
    # 在超時時間內不停重試,直到設定KEY的值成功,也即獲得了「鎖」
    while time.time() < end:
        if conn.setnx('lock:' + lockname, identifier):
            # 加鎖成功返回識別符號  
            return identifier
        time.sleep(.001)
    return False

# 4.4.3節中的函式改寫
def purchase_item_with_lock(conn, buyerid, itemid, sellerid):
    buyer = "users:%s" % buyerid
    seller = "users:%s" % sellerid
    item = "%s.%s" % (itemid, sellerid)
    inventory = "inventory:%s" % buyerid

    # 加鎖(這裡對整個市場加鎖)
    # 也可以改變鎖的粒度,只對操作到的商品加鎖
    locked = acquire_lock(conn, 'market:')     
    if not locked:
        return False

    pipe = conn.pipeline(True)
    try:
        pipe.zscore("market:", item)           
        pipe.hget(buyer, 'funds')              
        price, funds = pipe.execute()          
        if price is None or price > funds:     
            return None                        

        pipe.hincrby(seller, 'funds', int(price))  
        pipe.hincrby(buyer, 'funds', int(-price))  
        pipe.sadd(inventory, itemid)               
        pipe.zrem("market:", item)                 
        pipe.execute()                             
        return True
    finally:
        # 釋放鎖
        release_lock(conn, 'market:', locked) 

# 釋放鎖
def release_lock(conn, lockname, identifier):
    pipe = conn.pipeline(True)
    lockname = 'lock:' + lockname

    while True:
        try:
            pipe.watch(lockname)                
            if pipe.get(lockname) == identifier:
                pipe.multi()                    
                pipe.delete(lockname)           
                pipe.execute()                  
                return True                     

            pipe.unwatch()
            break

        except redis.exceptions.WatchError:     
            pass                                

    return False

帶超時的鎖

可以取得鎖之後,給鎖加一個超時時間,可以避免因為不可預料的情況,持有者一直持有鎖。

acquire_lock_with_timeout(conn, lockname, acquire_timeout=10, lock_timeout=10):
    identifier = str(uuid.uuid4())                      
    lockname = 'lock:' + lockname
    lock_timeout = int(math.ceil(lock_timeout))         

    end = time.time() + acquire_timeout
    while time.time() < end:
        if conn.setnx(lockname, identifier): 
            # 給鎖設定超時時間           
            conn.expire(lockname, lock_timeout)         
            return identifier
        # 如果鎖已存在,順便設定下超時時間
        elif conn.ttl(lockname) < 0:                    
            conn.expire(lockname, lock_timeout)         

        time.sleep(.001)

    return False 

計數訊號量

  • 計數訊號量是一種鎖,用於限制訪問同一資源的程式數

  • 可以把上一節建立的鎖看成只能被一個程式訪問的計數訊號量

  • 同樣有獲取鎖、釋放鎖的操作,但獲取鎖失敗時,傾向於立即返回失敗結果,而不是重試

  • 實現超時限制特性:1.使用EXPIRE    2.使用有序集合

    例子

    儲存訊號量資訊的有序集合:
    Redis In Action 筆記(六):使用 Redis 作為應用程式元件

  • 佔坑

    def acquire_semaphore(conn, semname, limit, timeout=10):
    identifier = str(uuid.uuid4())                             
    now = time.time()
    
    pipeline = conn.pipeline(True)
    # 清理超時的坑位
    pipeline.zremrangebyscore(semname, '-inf', now - timeout)  
    pipeline.zadd(semname, {identifier::now}) # 佔個坑先                   
    pipeline.zrank(semname, identifier)  # 獲取位置                      
    if pipeline.execute()[-1] < limit:   # 如果坑位還足夠,就搶佔成功                     
        return identifier 
    
    # 坑位不足,佔不到坑,就只能灰溜溜地走了
    conn.zrem(semname, identifier)                             
    return None  
  • 釋放坑位

    def release_semaphore(conn, semname, identifier):
    return conn.zrem(semname, identifier)  

    公平訊號量

    解決系統時間可能不一致,導致系統時間較慢的客戶端能搶先佔有訊號量的問題。
    實現思路:新增一個計數器,再新增一個儲存計數訊號量資訊的有序集合(A),這個有序集合以最新的計數器的值為score,還需要另一個儲存計數訊號量的有序集(B),score值為時間戳,用來清理超時的訊號量。B清理調超時訊號量後,與A求交集(zinterstore),B的聚合權重設為0,最終結果儲存到A,這樣一來,A通過B間接地清理掉超時訊號量。實現程式碼如下:

  • 取得計數訊號量

    def acquire_fair_semaphore(conn, semname, limit, timeout=10):
    identifier = str(uuid.uuid4())                             
    czset = semname + ':owner'  # 訊號量集合(B)
    ctr = semname + ':counter'  # 計數器
    
    now = time.time()
    pipeline = conn.pipeline(True)
    # 刪除(A)中超時的,然後與czset(B)求交集來間接刪除czset中超時的
    pipeline.zremrangebyscore(semname, '-inf', now - timeout) 
    # 求交集,semname聚合權重為0,這樣不影響(A)的score值 
    pipeline.zinterstore(czset, {czset: 1, semname: 0})        
    
    pipeline.incr(ctr)                                         
    counter = pipeline.execute()[-1]                           
    
    pipeline.zadd(semname, identifier, now) # 以時間戳為score的zset                   
    pipeline.zadd(czset, identifier, counter)  # 以計數為score的zset                 
    
    pipeline.zrank(czset, identifier)  # 獲取位置                        
    if pipeline.execute()[-1] < limit:                         
        return identifier                                      
    
    pipeline.zrem(semname, identifier)                         
    pipeline.zrem(czset, identifier)                           
    pipeline.execute()
    return None 
  • 釋放鎖

    比非公平訊號量多了一個集合要刪除。

    def release_fair_semaphore(conn, semname, identifier):
    pipeline = conn.pipeline(True)
    pipeline.zrem(semname, identifier)
    pipeline.zrem(semname + ':owner', identifier)
    return pipeline.execute()[0] 
  • 重新整理訊號量

    前面設定的計數訊號量10s就超時,需要進行重新整理,防止過期

    # 重新整理訊號量            
    def refresh_fair_semaphore(conn, semname, identifier):
    # zadd操作,元素不存在時執行新增操作,返回1;存在時則更新元素,返回0
    if conn.zadd(semname, identifier, time.time()): 
        # 如果是新增,將判斷語句新增的刪除,返回False,表示已過期           
        release_fair_semaphore(conn, semname, identifier)      
        return False 
    
    # 如果是更新,直接跳到這裡,返回True,表示重新整理成功                                         
    return True 
  • 獲取訊號量之加鎖版

    用於消除競爭條件

    def acquire_semaphore_with_lock(conn, semname, limit, timeout=10):
    # 設定一個超短時間過期的鎖
    identifier = acquire_lock(conn, semname, acquire_timeout=.01)
    # 如果設定成功
    if identifier:
        try:
            # 獲取公平訊號量
            return acquire_fair_semaphore(conn, semname, limit, timeout)
        finally:
            # 將獲得的鎖刪除
            release_lock(conn, semname, identifier)
  • 計數訊號量總結

    • 如果對系統的時鐘差異可以接受,可以使用第一種
    • 不能接受系統的時鐘差異,可以使用公平訊號量
    • 希望訊號量一直執行正確,使用第三種,加鎖獲取訊號量(推薦)

任務佇列

import redis

conn = redis.Redis(host='127.0.0.1', port=6379)

# 已售出商品郵件佇列
def send_sold_email_via_queue(conn, seller, item, price, buyer):
    data = {
        'seller_id': seller,                    
        'item_id': item,                        
        'price': price,                         
        'buyer_id': buyer,                      
        'time': time.time()                     
    }
    conn.rpush('queue:email', json.dumps(data)) 

# 讀取列表中的發郵件任務,傳送郵件
def process_sold_email_queue(conn):
    while not QUIT:
        packed = conn.blpop(['queue:email'], 30)                  
        if not packed:                                            
            continue                                              

        to_send = json.loads(packed[1])                           
        try:
            fetch_data_and_send_sold_email(to_send)               
        except EmailSendError as err:
            log_error("Failed to send sold email", err, to_send)
        else:
            log_success("Sent sold email", to_send)

# 可以執行多種任務的佇列
def worker_watch_queue(conn, queue, callbacks):
    while not QUIT:
        packed = conn.blpop([queue], 30)                    
        if not packed:                                      
            continue                                        

        name, args = json.loads(packed[1]) 
        # 如果任務沒有在callbacks中註冊                 
        if name not in callbacks:                           
            log_error("Unknown callback %s"%name)           
            continue 
        # 呼叫在callbacks中已註冊的函式                                       
        callbacks[name](*args)   

# 任務優先順序 
# 實現原理:blpop可傳入多個list,排在前面的list優先處理
def worker_watch_queues(conn, queues, callbacks):   
    while not QUIT:
        packed = conn.blpop(queues, 30)  # queues是由多個list組成的list           
        if not packed:
            continue

        name, args = json.loads(packed[1])
        if name not in callbacks:
            log_error("Unknown callback %s"%name)
            continue
        callbacks[name](*args)   

# 延遲任務
# 實現思路:把所有要延遲的任務加到有序集合,執行時間作為score
# 另外的一個程式用來查詢有序集合裡面是否有要立即執行的任務
# 如果有的話,就從有序集合移除,新增到佇列中
def execute_later(conn, queue, name, args, delay=0):
    identifier = str(uuid.uuid4())                          
    item = json.dumps([identifier, queue, name, args])      
    if delay > 0: # 新增到有序集合 延遲任務執行
        conn.zadd('delayed:', item, time.time() + delay)    
    else: # 立即執行的任務
        conn.rpush('queue:' + queue, item)                  
    return identifier    

# 執行延遲任務
def poll_queue(conn):
    while not QUIT:
        # 取出第一個元素
        item = conn.zrange('delayed:', 0, 0, withscores=True) 
        # 如果沒有元素 休眠0.01s  
        if not item or item[0][1] > time.time():                
            time.sleep(.01)                                     
            continue                                            

        # 取出要執行的任務
        item = item[0][0]                                       
        identifier, queue, function, args = json.loads(item)    

        locked = acquire_lock(conn, identifier)                 
        if not locked:                                          
            continue                                            
        # 從有序集合移除該任務 並 新增到任務佇列
        if conn.zrem('delayed:', item):                         
            conn.rpush('queue:' + queue, item)                  

        release_lock(conn, identifier, locked)                                                                              

參考資料:https://redislabs.com/ebook/part-2-core-co...

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

Was mich nicht umbringt, macht mich stärker

相關文章