Redis 延迟队列
1. 基本实现
我们平常习惯使用Kafka和RabbitMQ作为消息队列中间件,在应用程序之间增加异步消息传递功能。这两个中间件都是专业的消息队列
但是使用RabbitMQ比较复杂,发消息之前要创建Exchange, 再创建Queue, 还要将Queue和Exchange通过某种规则绑定起来,发消息之前要指定routing-key,还需要控制头部信息。消费者在消费消息之前也要进行上面的繁琐过程。在大多数情况下,我们的消息队列只有一组消费者,但是使用RabbitMQ也需要经历上述过程。
对于那些只有一组消费者的消息队列,可以使用Redis的list来实现,但是需要注意的是,Redis的list不是专业的消息队列,没有非常多的高级特性,没有ACK保证。如果对消息的可靠性要求高,则不推荐使用Redis的list作为消息队列。
2. 队列为空问题
客户通过队列的pop()操作来获取消息,处理完数据后,再接着获取消息,如此循环。可是如果队列空了,客户端就会陷入循环等待,没有数据,就要不停的空轮询。空轮询不仅拉高了客户端的CPU消耗,Redis的QPS(每秒查询率, Queries-per-second)性能会降低。
通常来说,使用sleep来解决这个问题。让线程睡一会。通常1s.
阻塞读取
用上面的方法能解决空轮询的问题,但是睡眠会导致延迟增大。为了解决这个问题,就需要使用blpop()/brpop()方法。即blocking阻塞读取。
阻塞度在队列没有数据的时候,会立即进入休眠状态,一旦有数据到来,则立即被唤醒,消息的延迟几乎为0.
空闲连接断开问题
如果线程一直阻塞在等待队列数据中,Redis的客户端连接就成为了闲置连接,闲置过久,服务器就会断开连接,减少闲置资源的占用。这个时候使用blpop()/brpop()方法会抛出异常。所以编写客户端的消费者要小心,如果捕获到异常,还需要重试。
3. 锁冲突处理
在分布式锁中,如果客户端在处理请求时加锁失败应该怎么办,一般使用3中策略来处理加锁失败:
-
直接抛出异常,通知用户稍后重试
这种方式比较适合由用户直接发起的请求,用户看到错误的对话框后,会点击重试,这样就起到人工延迟的效果。如果考虑到用户体验,可以由前端代码代替用户进行延迟重试控制。本质上是对当前请求的放弃,由客户端决定是否发起新的请求。
-
sleep一段时间,然后在重试
sleep会阻塞当前的消息处理线程,导致队列后续消息处理出现延迟,如果锁冲突的比较频繁或者队列的消息较多,sleep可能并不合适。如果因为个别死锁的Key导致加锁不成功,线程会彻底堵死。后续的消息永远得不到及时处理。
-
将请求转移至延迟队列,过一会再重试。延迟队列
这种方式适合异步消息处理,将当前冲突的请求放入到另一个队列延后处理,以避免冲突
4. 延迟队列的实现
延迟消息队列可以通过zset来实现,将消息序列化成一个字符串作为zset的value,这个消息的到期处理时间为score,然后用多线程轮询zset获取到期的任务进行处理。多线程是为了保证可用性,一旦某一个线程挂掉,还有其他线程可以处理。但是因为有多线程,所以需要考虑并发竞争问题,确保任务不会被多次执行。
[Python版本]
class Message:
__slots__ = "idx", "data"
def __init__(self, idx=None, data=None):
self.idx = idx
self.data = data
class RedisDemo:
def __init__(self):
self.conn_pool = redis.ConnectionPool(host="localhost", port=6379, max_connections=8)
self.conn = redis.Redis(connection_pool=self.conn_pool, socket_timeout=3000, password="admin")
def delay(self, msg: Message):
msg.idx = str(uuid.uuid4()) # 保证value的唯一性
value = json.dumps(msg)
retry_ts = time.time() + 5 # 5秒后重试
self.conn.zadd("delay-queue", retry_ts, value)
def loop(self):
while True:
values = self.conn.zrangebyscore("delay-queue", 0, time.time(), start=0, num=1)
if not values:
time.sleep(1) # 延迟队列为空,sleep一秒后重试
continue
value = values[0]
# 从消息队列中移除消息
success = self.conn.zrem("delay-queue", value)
if success:
msg = json.loads(value)
print(msg) # handle msg
Redis的zrem方法是多线程争抢任务的关键,它的返回值决定了当前的实列有没有抢到任务。因为loop()方法可能会被多线程调用,要通过zerm来绝ing唯一的属主。同时要注意对handle_msg()进行异常捕获,避免因为个别任务处理出现问题导致循环异常退出。
进一步优化
同一个任务可能会被多个进程取到之后,在使用zrem进行争抢,那些没抢到的进行就浪费了一次执行,因此可以考虑使用Lua脚本,将zrangebyscore和zrem一同挪到服务器端进行原子化操作。

286

被折叠的 条评论
为什么被折叠?



