使用Redis构建应用程序组件
自动补全
-
自动补全最近联系人,构建最近联系人自动补全列表
def add_update_contact(conn, user, contact): ac_list = 'recent:' + user # 准备执行原子操作。 pipeline = conn.pipeline(True) # 如果联系人已经存在,那么移除他。 pipeline.lrem(ac_list, contact) # 将联系人推入到列表的最前端 pipeline.lpush(ac_list, contact) # 只保留列表里面的前100个联系人。 pipeline.ltrim(ac_list, 0, 99) # 实际地执行以上操作。 pipeline.execute()
-
从列表中移除指定的联系人
def remove_contact(conn, user, contact): conn.lrem('recent:' + user, contact)
-
自动补全列表并查找匹配的用户
def fetch_autocomplete_list(conn, user, prefix): # 获取自动补完列表。 candidates = conn.lrange('recent:' + user, 0, -1) matches = [] # 检查每个候选联系人。 for candidate in candidates: if candidate.lower().startswith(prefix): # 发现一个匹配的联系人。 matches.append(candidate) # 返回所有匹配的联系人。 return matches
-
通讯录自动补全
# 准备一个由已知字符组成的列表。 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) 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 for item in items if '{' 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)
分布式锁
下图是一个游戏的商品交易市场的数据结构
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-E5kSKXHB-1585137410466)(C:\Users\zhousheng42\AppData\Roaming\Typora\typora-user-images\image-20200128094043757.png)]
-
当玩家将商品放到市场上面进行销售的时候,为了确保被出售的商品的确存在于玩家的包裹里面,程序会使用watch命令来监视玩家的包裹,然后将被出售的商品添加到代表市场的有序集合里面,最后从玩家的包裹里移除被出售的商品
def list_item(conn, itemid, sellerid, price): #... # 监视卖家包裹发生的变动。 pipe.watch(inv) # 确保被出售的物品仍然存在于卖家的包裹里面。 if not pipe.sismember(inv, itemid): pipe.unwatch() return None # 将物品添加到市场里面。 pipe.multi() pipe.zadd("market:", item, price) pipe.srem(inv, itemid) pipe.execute() return True #...
-
购买商品
def purchase_item(conn, buyerid, itemid, sellerid, lprice): #... # 监视市场以及买家个人信息发生的变化。 pipe.watch("market:", buyer) # 检查物品是否已经售出、物品的价格是否已经发生了变化, # 以及买家是否有足够的金钱来购买这件物品。 price = pipe.zscore("market:", item) funds = int(pipe.hget(buyer, 'funds')) if price != lprice or price > funds: pipe.unwatch() return None # 将买家支付的货款转移给卖家,并将被卖出的物品转移给买家。 pipe.multi() pipe.hincrby(seller, 'funds', int(price)) pipe.hincrby(buyerid, 'funds', int(-price)) pipe.sadd(inventory, itemid) pipe.zrem("market:", item) pipe.execute() return True #...
-
锁的故障描述及原因
序号 | 描述 | 原因 |
---|---|---|
1 | 无限等待 | 客户端下线,未释放锁资源 |
2 | 进程未结束即释放锁 | 持有锁的进程因操作时间过长而提前释放锁,甚至还有可能错误的释放掉其他进程持有的锁 |
3 | 等待锁释放 | 一个持有锁的进程已经崩溃,但是锁未释放,因此其他进程必须等待到锁超时释放才可以重新获取锁 |
4 | 多个进程获取了锁 | 在一个进程过期后多个进程均试图获取锁并都获得了锁 |
5 | 多个进程获取了锁并均以为自己是唯一一个获得锁的进程 | 1和3同时出现 |
-
使用redis构建锁
-
获取锁
def acquire_lock(conn, lockname, acquire_timeout=10): # 128位随机标识符。 identifier = str(uuid.uuid4()) end = time.time() + acquire_timeout while time.time() < end: # 尝试取得锁。 if conn.setnx('lock:' + lockname, identifier): return identifier time.sleep(.001) return False
-
加锁后的购物操作
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
-
获取锁并设置超时时间
def acquire_lock_with_timeout( conn, lockname, acquire_timeout=10, lock_timeout=10): # 128位随机标识符。 identifier = str(uuid.uuid4()) lockname = 'lock:' + lockname # 确保传给EXPIRE的都是整数。 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 not conn.ttl(lockname): conn.expire(lockname, lock_timeout) time.sleep(.001) return False
-
-
计数信号量锁
计数信号量锁可以让用户限制一项资源最多能够同时被多少个进程访问,通常用于限定能够同时使用的资源数量。
-
获取信号量
def acquire_semaphore(conn, semname, limit, timeout=10): # 128位随机标识符。 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): # 如果信号量已经被正确地释放,那么返回True; # 返回False则表示该信号量已经因为过期而被删除了。 return conn.zrem(semname, identifier)
-
获取优化的公平信号量
def acquire_fair_semaphore(conn, semname, limit, timeout=10): # 128位随机标识符。 identifier = str(uuid.uuid4()) czset = semname + ':owner' ctr = semname + ':counter' now = time.time() pipeline = conn.pipeline(True) # 删除超时的信号量。 pipeline.zremrangebyscore(semname, '-inf', now - timeout) pipeline.zinterstore(czset, {czset: 1, semname: 0}) # 对计数器执行自增操作,并获取操作执行之后的值。 pipeline.incr(ctr) counter = pipeline.execute()[-1] # 尝试获取信号量。 pipeline.zadd(semname, identifier, now) pipeline.zadd(czset, identifier, counter) # 通过检查排名来判断客户端是否取得了信号量。 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) # 返回True表示信号量已被正确地释放, # 返回False则表示想要释放的信号量已经因为超时而被删除了。 return pipeline.execute()[0]
-
刷新信号量
def refresh_fair_semaphore(conn, semname, identifier): # 更新客户端持有的信号量。 if conn.zadd(semname, identifier, time.time()): # 告知调用者,客户端已经失去了信号量。 release_fair_semaphore(conn, semname, identifier) return False # 客户端仍然持有信号量。 return True
-
-
消除竞争条件
举例如,两个进程A和B都在尝试获取剩余的一个信号量时,首先A对计数器进行了自增操作,但只要B能够抢先将自己的标识添加到有序集合里,并检查标识符在有序集合中的排名,那么B就可以成功地取得信号量。之后A会“偷走”B地信号量,而B只有在尝试释放信号量或者尝试刷新信号量的时候才会察觉这一点。
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)
-
任务队列
用户可以推迟执行那些需要一段时间才能完成的操作,这种将工作交给任务处理器来执行的做法成为任务队列。
-
先进先出队列——先到先服务
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 # 从JSON对象中解码出邮件信息。 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]) # 没有找到任务指定的回调函数,用日志记录错误并重试。 if name not in callbacks: log_error("Unknown callback %s"%name) continue # 执行任务。 callbacks[name](*args)
-
任务优先级
def worker_watch_queues(conn, queues, callbacks): # 实现优先级特性要修改的第一行代码。 while not QUIT: packed = conn.blpop(queues, 30) # 实现优先级特性要修改的第二行代码。 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)
-
-
延迟任务
-
在任务信息中包含任务的执行时间,如果工作进程发现任务的执行时间尚未来临,那么它将在短期等待后,把任务重新推入队列里面。
-
工作进程使用一个本地的等待列表记录所有需要在未来执行的任务,并在每次while循环的时候检查等待列表并执行那些已经到期的任务。
-
把所有需要在未来执行的任务都添加到有序集合里,并将任务的执行时间设置为分值,另外再使用一个进程查找有序集合里是否存在可以立即被执行的任务,如果有的话就从有序集合中移除那个任务,并将它添加到适当的任务队列里面。
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) # 队列没有包含任何任务,或者任务的执行时间未到。 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)
-
-
消息拉取
消息推送:由发送者来确保所有接收者已经成功接收到了消息。
消息拉取:要求接收者自己去获取存储在某种邮箱里面的消息。
-
单接收者消息的发送与订阅替代品
不同种类的客户端去监听他们独有的频道并作为频道消息的唯一接收者,从频道哪里接收传来的消息。然而当我们在遇到连接故障的情况下需要不丢失任何消息的时候,publish命令和subscribe命令就派不上用场了。
-
多接收者消息的发送与订阅替代品
-
创建群组聊天会话
def create_chat(conn, sender, recipients, message, chat_id=None): # 获得新的群组ID。 chat_id = chat_id or str(conn.incr('ids:chat:')) # 创建一个由用户和分值组成的字典,字典里面的信息将被添加到有序集合里面。 recipients.append(sender) recipientsd = dict((r, 0) for r in recipients) pipeline = conn.pipeline(True) # 将所有参与群聊的用户添加到有序集合里面。 pipeline.zadd('chat:' + chat_id, **recipientsd) # 初始化已读有序集合。 for rec in recipients: pipeline.zadd('seen:' + rec, chat_id, 0) pipeline.execute() # 发送消息。 return send_message(conn, chat_id, sender, message)
-
发送消息
def send_message(conn, chat_id, sender, message): identifier = acquire_lock(conn, 'chat:' + chat_id) if not identifier: raise Exception("Couldn't get the lock") try: # 筹备待发送的消息 mid = conn.incr('ids:' + chat_id) ts = time.time() packed = json.dumps({ 'id': mid, 'ts': ts, 'sender': sender, 'message': message, }) # 将消息发送至群组。 conn.zadd('msgs:' + chat_id, packed, mid) finally: release_lock(conn, 'chat:' + chat_id, identifier) return chat_id
-
获取消息
def fetch_pending_messages(conn, recipient): # 获取最后接收到的消息的ID。 seen = conn.zrange('seen:' + recipient, 0, -1, withscores=True) pipeline = conn.pipeline(True) # 获取所有未读消息。 for chat_id, seen_id in seen: pipeline.zrangebyscore( 'msgs:' + chat_id, seen_id+1, 'inf') # 这些数据将被返回给函数调用者。 chat_info = zip(seen, pipeline.execute()) for i, ((chat_id, seen_id), messages) in enumerate(chat_info): if not messages: continue messages[:] = map(json.loads, messages) # 使用最新收到的消息来更新群组有序集合。 seen_id = messages[-1]['id'] conn.zadd('chat:' + chat_id, recipient, seen_id) # 找出那些所有人都已经阅读过的消息。 min_id = conn.zrange( 'chat:' + chat_id, 0, 0, withscores=True) # 更新已读消息有序集合。 pipeline.zadd('seen:' + recipient, chat_id, seen_id) if min_id: # 清除那些已经被所有人阅读过的消息。 pipeline.zremrangebyscore( 'msgs:' + chat_id, 0, min_id[0][1]) chat_info[i] = (chat_id, messages) pipeline.execute() return chat_info
-
加入群组
def join_chat(conn, chat_id, user): # 取得最新群组消息的ID。 message_id = int(conn.get('ids:' + chat_id)) pipeline = conn.pipeline(True) # 将用户添加到群组成员列表里面。 pipeline.zadd('chat:' + chat_id, user, message_id) # 将群组添加到用户的已读列表里面。 pipeline.zadd('seen:' + user, chat_id, message_id) pipeline.execute()
-
离开群组
def leave_chat(conn, chat_id, user): pipeline = conn.pipeline(True) # 从群组里面移除给定的用户。 pipeline.zrem('chat:' + chat_id, user) pipeline.zrem('seen:' + user, chat_id) # 查看群组剩余成员的数量。 pipeline.zcard('chat:' + chat_id) if not pipeline.execute()[-1]: # 删除群组。 pipeline.delete('msgs:' + chat_id) pipeline.delete('ids:' + chat_id) pipeline.execute() else: # 查找那些已经被所有成员阅读过的消息。 oldest = conn.zrange( 'chat:' + chat_id, 0, 0, withscores=True) # 删除那些已经被所有成员阅读过的消息。 conn.zremrangebyscore('msgs:' + chat_id, 0, oldest[0][1])
-
-
-
使用redis进行文件分发
分布式系统中常常需要在多台机器上复制分发或者处理数据文件,我们使用redis也可以简单实现一个文件分发系统
-
根据地理位置聚合用户数据
aggregates = defaultdict(lambda: defaultdict(int)) def daily_country_aggregate(conn, line): if line: line = line.split() # 提取日志行中的信息。 ip = line[0] day = line[1] # 根据IP地址判断用户所在国家。 country = find_city_by_ip_local(ip)[2] # 对本地聚合数据执行自增操作。 aggregates[day][country] += 1 return # 当天的日志文件已经处理完毕,将聚合计算的结果写入到Redis里面。 for day, aggregate in aggregates.items(): conn.zadd('daily:country:' + day, **aggregate) del aggregates[day]
-
发送日志文件
def copy_logs_to_redis(conn, path, channel, count=10, limit=2**30, quit_when_done=True): bytes_in_redis = 0 waiting = deque() # 创建用于向客户端发送消息的群组。 create_chat(conn, 'source', map(str, range(count)), '', channel) count = str(count) # 遍历所有日志文件。 for logfile in sorted(os.listdir(path)): full_path = os.path.join(path, logfile) fsize = os.stat(full_path).st_size # 如果程序需要更多空间,那么清除已经处理完毕的文件。 while bytes_in_redis + fsize > limit: cleaned = _clean(conn, channel, waiting, count) if cleaned: bytes_in_redis -= cleaned else: time.sleep(.25) # 将文件上传至Redis。 with open(full_path, 'rb') as inp: block = ' ' while block: block = inp.read(2**17) conn.append(channel+logfile, block) # 提醒监听者,文件已经准备就绪。 send_message(conn, channel, 'source', logfile) # 对本地记录的Redis内存占用量相关信息进行更新。 bytes_in_redis += fsize waiting.append((logfile, fsize)) # 所有日志文件已经处理完毕,向监听者报告此事。 if quit_when_done: send_message(conn, channel, 'source', ':done') # 在工作完成之后,清理无用的日志文件。 while waiting: cleaned = _clean(conn, channel, waiting, count) if cleaned: bytes_in_redis -= cleaned else: time.sleep(.25) # 对Redis进行清理的详细步骤。 def _clean(conn, channel, waiting, count): if not waiting: return 0 w0 = waiting[0][0] if conn.get(channel + w0 + ':done') == count: conn.delete(channel + w0, channel + w0 + ':done') return waiting.popleft()[1] return 0
-
接受日志文件
def process_logs_from_redis(conn, id, callback): while 1: # 获取文件列表。 fdata = fetch_pending_messages(conn, id) for ch, mdata in fdata: for message in mdata: logfile = message['message'] # 所有日志行已经处理完毕。 if logfile == ':done': return elif not logfile: continue # 选择一个块读取器(block reader)。 block_reader = readblocks if logfile.endswith('.gz'): block_reader = readblocks_gz # 遍历日志行。 for line in readlines(conn, ch+logfile, block_reader): # 将日志行传递给回调函数。 callback(conn, line) # 强制地刷新聚合数据缓存。 callback(conn, None) # 报告日志已经处理完毕。 conn.incr(ch + logfile + ':done') if not fdata: time.sleep(.1)
-
处理日志文件
def readlines(conn, key, rblocks): out = '' for block in rblocks(conn, key): out += block # 查找位于文本最右端的断行符;如果断行符不存在,那么rfind()返回-1。 posn = out.rfind('\n') # 找到一个断行符。 if posn >= 0: # 根据断行符来分割日志行。 for line in out[:posn].split('\n'): # 向调用者返回每个行。 yield line + '\n' # 保留余下的数据。 out = out[posn+1:] # 所有数据块已经处理完毕。 if not block: yield out break
def readblocks(conn, key, blocksize=2**17): lb = blocksize pos = 0 # 尽可能地读取更多数据,直到出现不完整读操作(partial read)为止。 while lb == blocksize: # 获取数据块。 block = conn.substr(key, pos, pos + blocksize - 1) # 准备进行下一次遍历。 yield block lb = len(block) pos += lb yield ''
def readblocks_gz(conn, key): inp = '' decoder = None # 从Redis里面读入原始数据。 for block in readblocks(conn, key, 2**17): if not decoder: inp += block try: # 分析头信息以便取得被压缩数据。 if inp[:3] != "\x1f\x8b\x08": raise IOError("invalid gzip data") i = 10 flag = ord(inp[3]) if flag & 4: i += 2 + ord(inp[i]) + 256*ord(inp[i+1]) if flag & 8: i = inp.index('\0', i) + 1 if flag & 16: i = inp.index('\0', i) + 1 if flag & 2: i += 2 # 程序读取的头信息并不完整。 if i > len(inp): raise IndexError("not enough data") except (IndexError, ValueError): continue else: # 已经找到头信息,准备好相应的解压程序。 block = inp[i:] inp = None decoder = zlib.decompressobj(-zlib.MAX_WBITS) if not block: continue # 所有数据已经处理完毕,向调用者返回最后剩下的数据块。 if not block: yield decoder.flush() break # 向调用者返回解压后的数据块。 yield decoder.decompress(block)
-
-
小结
- 尽管watch是个有用的命令,但是分布式锁可以让针对redis的并发编程变得简单得多。
- 通过锁住粒度更小的部件而不是整个数据库键可以大大减少冲突的出现几率。