「消息队列」Python使用pika的优化: 多消费者复用一个连接

本文探讨了使用Python的pika库与RabbitMQ进行消息队列通信时遇到的连接稳定性问题,包括连接丢失和资源消耗过大的解决方案。介绍了如何通过调整心跳检测机制和使用本地队列优化多消费者场景,降低服务器资源消耗。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

转载请注明出处: https://blog.youkuaiyun.com/jinixin/article/details/84202958

 

 

通过消息队列可以非常方便的实现分布式, 上篇文章使用Python的pika搭建的"生产者-消费者"模型就是很好的例子, 但经过一段时间的运行, 有两个问题令我疑惑, 下面拿出来简单谈谈.

 

 

问题

 

问题一: 运行一段时间后, pika会丢失与RabbitMQ的连接.

问题二: 连接建立过多, 资源消耗较严重.

 

 

解决

 

1. 问题1的原因与解决

AMQP协议规定消息队列有心跳检测机制, 即消息队列的消息代理会设置一个心跳超时时间.

当客户端与消息队列的消息代理建立连接后, 客户端隔一定时间就会发送一个心跳检测包, 如果消息代理在心跳超时时间内没有收到心跳检测包, 该连接就会被断开. 对于消息队列来说这是好事情, 因为不用维持一个不常使用的连接, 但对我们编写的客户端(生产者/消费者)来说可不友好. 怎么办呢? 有两种解决的方法.

1) 方法一

使用pika提供的process_data_events方法, 该方法定时自动向RabbitMQ的消息代理发送心跳包来维持这段感情(啊, 是这个连接^_^).

connection.process_data_events()  # 保持连接, 程序会阻塞在此处

该方法可选填time_limit参数表示最长的阻塞时间. 参数默认为0, 即有消息需要发送或接收时会立即结束阻塞并进行处理. 阻塞期间连接不会被消息代理断开, 该方法起到了保活作用.

 

2) 方法二

关闭RabbitMQ的心跳检测机制

pika.ConnectionParameters(host=self.host, port=self.port, heartbeat=0)  # 该方法用于生成连接参数

heartbeat表示心跳超时时间, 如果设置的是大于0的数, 则该数会被作为消息代理与该客户端间连接的心跳超时时间. 如果设置的是None, 则会使用消息代理的默认心跳超时时间. 如果是0, 则关闭对该连接的心跳超时检测.

 

2. 问题2的解释与解决

关于问题二, 你可能没有get到我的意思, 我先解释下哈~

AMQP协议指导客户端与消息队列服务器建立的连接是基于TCP的, 一个连接下可以有多条信道, 每条信道间互不干扰. 对此我们立即就会想到多个消费者(线程)共享一个连接, 每个消费者(线程)独享一条信道, 而AMQP也正是这样建议的, 但现实却是pika是线程不安全的, 尤其是pika所操作的连接与信道不支持多个消费者(线程)共享. 所以我们只能为每个消费者(线程)单独分配一个连接和一个信道.

这样虽然程序能正常运行, 但还是有弊端的, 因为客户端与RabbitMQ服务器的连接是基于TCP的. 试想集群下有多台计算机, 每台计算机上有多个消费者, 每个消费者都会创建一个消息队列客户端, 也就是每个消费者都会与RabbitMQ服务器保持一个连接. 这对消费者所在的计算机来说十几个连接问题不大, 但对RabbitMQ所在服务器来说就是另番光景了. 其需要应对那么多台部署有消费者的计算机, 其所需建立的连接数量会非常大. 而普遍认为一台计算机理论TCP连接数量取决于内存, 在10K-100K间.

不仅TCP连接的建立需要消耗大量资源, 而且连接过多也加大了宕机的风险, 如何解决呢?

 

惭愧分享下我的想法做个抛砖引玉: 在消费者所在的计算机上维护一个本地队列, 暂存从消息队列处获得的消息, 之后该计算机上的所有消费者都从本地队列中取得消息. 这样即使有众多消费者, 该计算机也仅和消息队列服务器保持一个长连接. Enmmm, 你可能会有下面疑惑:

 

疑惑一: 增加本地队列, 代码会不会变得很复杂?

的确有些麻烦, 但借助Queue模块中的Queue队列可以简单不少. Queue模块是线程安全的, 且可以设定本地队列的最大存储值, 以防一下子接收消息队列中全部消息.

疑惑二: 如果没开启消息手动确认, 这还到是一个办法. 但如果开启了消息手动确认, 该怎么办呢? 消费者处理完了, 是不是还要写个回调来通知消息代理呢?

不需要这么麻烦, pika提供了add_callback_threadsafe方法来异步通知RabbitMQ消息处理成功. 该方法是pika库中唯一线程安全的, 可被多个消费者(线程)同时调用.

 

下面是优化后的代码, 和上篇文章中的主体结构是一致的, 主要关闭了心跳检测机制, 增加了本地队列和异步的消息确认.

# coding=utf-8

import logging
import time
from Queue import Queue

import pika


class MQBase(object):
    """ 消息队列基类, 该类线程不安全的 """

    def __init__(self, host, port, exchange, exchange_type='direct', ack=True, persist=True, **kwargs):  # 默认开启手动消息确认, 交换机\队列\消息持久化
        """ 当开启手动消息确认, 要考虑消息重入的情况 """

        self._conn = None
        self._channel = None
        self._properties = None

        self.host = host
        self.port = port
        self.exchange = exchange
        self.exchange_type = exchange_type
        self.ack = ack
        self.persist = persist

    def _get_channel(self):
        """ 创建连接与信道, 声明交换机 """

        if self._check_alive():
            return
        else:
            self._clear()

        if not self._conn:
            self._conn = pika.BlockingConnection(pika.ConnectionParameters(host=self.host,
                                                                           port=self.port,
                                                                           heartbeat=0))  # 建立连接并关闭心跳检测
        if not self._channel:
            self._channel = self._conn.channel()  # 建立信道
            self._channel.exchange_declare(exchange=self.exchange,
                                           exchange_type=self.exchange_type,
                                           durable=self.persist)  # 声明交换机
            if self.ack:
                self._channel.confirm_delivery()  # 在该信道中开启消息确认
            self._properties = pika.BasicProperties(delivery_mode=(2 if self.persist else 0))  # 消息持久化

    def _clear(self):
        """ 清理连接与信道 """

        def clear_conn():
            if self._conn and self._conn.is_open:
                self._conn.close()
            self._conn = None

        def clear_channel():
            if self._channel and self._channel.is_open:
                self._channel.close()
            self._channel = None

        if not (self._conn and self._conn.is_open):
            clear_conn()  # 清理连接
        clear_channel()  # 清理信道

    def _check_alive(self):
        """ 检查连接与信道是否存活 """

        return self._channel and self._channel.is_open and self._conn and self._conn.is_open


class MQSender(MQBase):
    """ 生产者, 该类是线程不安全的 """

    def send(self, route, msg):
        def try_send():
            try:
                self._get_channel()
                success = self._channel.basic_publish(exchange=self.exchange,
                                                      routing_key=route,
                                                      body=msg,
                                                      properties=self._properties)
            except Exception, e:
                success = False

            return success

        ret = try_send() or try_send()
        if not ret:
            self._clear()

        return ret


class MQReceiver(MQBase):
    """
    消费者, 该类是线程安全的
    通过receive_queue支持多线程, 不直接创建多实例是想减少底层TCP连接
    """

    def __init__(self, *args, **kwargs):
        self._has_start = False
        self.prefetch_count = kwargs.get('prefetch_count', 1)  # 每次只接受prefetch_count条消息, 处理完再接收新的
        self.receive_queue = Queue(self.prefetch_count)  # 接收缓冲队列, 线程安全的
        super(self.__class__, self).__init__(*args, **kwargs)

    def _declare_queue(self, queue_name):
        """ 声明队列 """

        self._channel.queue_declare(queue=queue_name, durable=self.persist)  # 声明队列
        self._channel.queue_bind(queue=queue_name, exchange=self.exchange, routing_key=queue_name)  # 绑定队列到交换机

    def _subscribe_queue(self, queue_route):
        """ 订阅队列  """

        try:
            self._declare_queue(queue_route)
            self._channel.basic_qos(prefetch_count=self.prefetch_count)  # 负载均衡
            self._channel.basic_consume(self._handler, queue_route, no_ack=not self.ack)  # 订阅队列, 并分配消息处理器

        except Exception, e:
            return False

        return True

    def _handler(self, channel, method_frame, header_frame, body):  # 消息处理器
        """ 收到消息后的回调 """

        print ('wait message num is {num}'.format(num=channel.get_waiting_message_count()))
        self.receive_queue.put((body, self._conn, channel, method_frame.delivery_tag))  # 压入队列

    def start(self, queue_route):
        """ 开始消费 """

        if self._has_start:
            return
        try:
            self._get_channel()
            if self._subscribe_queue(queue_route):
                self._channel.start_consuming()  # 开始消费, 一个连接下仅能调用该方法一次, 否则抛出RecursionError

        except Exception, e:
            pass

    def _clear(self):

        self.receive_queue = Queue(self.prefetch_count)  # 避免消息重入缓冲队列
        super(self.__class__, self)._clear()


def main():
    from threading import Thread, current_thread
    from functools import partial

    def producer_func():
        for index in range(50):
            print ('sender send {content}'.format(content=index))
            sender.send('test_queue', index)

    def consumer_func():
        receiver.start('test_queue')

    def deal_message(r):
        while True:
            msg, conn, channel, ack_tag = r.receive_queue.get()
            interval = int(msg) / 5.0
            print ('{thread_name}: get {msg}, wait {interval}s, ack_tag is {delivery_tag}'.format(
                msg=msg, thread_name=current_thread().getName(), interval=interval, delivery_tag=ack_tag))
            if current_thread().getName() == 'handler_1':  # 测试负载均衡
                time.sleep(60)
            else:
                time.sleep(interval)
            conn.add_callback_threadsafe(partial(channel.basic_ack, ack_tag))  # 确认消息成功处理

    handler_num = 5
    sender = MQSender(host='localhost', port=5680, exchange='test_exchange')
    receiver = MQReceiver(host='localhost', port=5680, exchange='test_exchange', prefetch_count=handler_num)

    Thread(target=consumer_func).start()  # 消费者
    Thread(target=producer_func).start()  # 生产者

    for i in range(handler_num):
        Thread(target=deal_message, args=(receiver,), name='handler_%d' % i).start()  # 消息处理器


if __name__ == '__main__':
    main()

 

注意如果开启了消息手动确认机制, 一定要记得考虑消息重入的情况.

通过上述的小优化, 现在的"生产者-消费者"模型支持长连接, 不自动断开. 多消费者复用一个连接, 避免消耗过多资源. 多消费者间负载均衡.

 

 

文中如有不当之处, 还望包容和指出, 感谢.

 

评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值