排查常见RabbitMQ配置问题
无需盲目追查,快速定位并修复RabbitMQ交换器、队列、绑定、确认和权限错误。
排查常见RabbitMQ配置问题
大多数RabbitMQ配置问题起初看起来都像是应用程序的bug。发布者说它发送了消息。消费者说它从未看到过消息。队列图是空的,或者更糟,它是满的,却没人知道原因。最快的解决方法是停止猜测,沿着消息路径追踪:发布者、交换器、绑定、队列、消费者、确认。
RabbitMQ对拓扑结构要求严格。直连交换器不会“大致”匹配路由键。声明为独占的队列不会像共享工作队列那样工作。标记为强制性的消息可以被退回,而同样的无法路由的消息如果没有mandatory标记,则可能直接被交换器丢弃。这些细节虽小,但足以让你浪费一个下午。
从实际路由开始
对于正常的AMQP发布,生产者向交换器发送一条带有路由键的消息。交换器根据其类型和绑定决定哪些队列应接收该消息。然后消费者从队列中拉取投递,并在处理后确认。
当消息消失时,问四个问题:
- 生产者是否发布到了你认为的交换器和虚拟主机?
- 那个交换器是否存在,并且是你认为的类型吗?
- 从那个交换器到目标队列是否有绑定?
- 对于该交换器类型,路由键是否匹配该绑定?
这听起来很基础,但它能抓住很多真实的事故。预发布环境和生产环境通常使用不同的虚拟主机。部署脚本可能在一个环境中声明了orders.created,而在另一个环境中声明了order.created。一个队列可能绑定到了一个主题模式,但该模式缺少了一个额外的单词。
使用管理UI或CLI检查实时的代理,而不是你希望正在运行的代码:
rabbitmqctl list_exchanges name type durable auto_delete
rabbitmqctl list_bindings source_name source_kind destination_name destination_kind routing_key
rabbitmqctl list_queues name durable auto_delete exclusive messages_ready messages_unacknowledged consumers
如果你使用多个虚拟主机,请包含-p:
rabbitmqctl -p production list_bindings source_name destination_name routing_key
路由键不匹配
直连交换器要求精确的绑定键匹配。如果队列绑定的是invoice.created,那么使用invoices.created发布的消息将不会到达。RabbitMQ不会纠正复数、大小写、点或破折号。
主题交换器使用*表示一个单词,使用#表示零个或多个单词。单词分隔符是点。绑定logs.*匹配logs.info,但不匹配logs.app.info。绑定logs.#匹配两者。
一个有用的排查技巧是添加一个具有宽泛绑定的临时诊断队列,然后发布一条已知的测试消息:
rabbitmqadmin declare queue name=debug.routing durable=false auto_delete=true
rabbitmqadmin declare binding source=events destination=debug.routing destination_type=queue routing_key='#'
在生产环境中谨慎操作,并在完成后移除诊断绑定。目标是证明消息是否到达了交换器。
对于重要的发布者,启用带有mandatory标志的发布者返回,以便无法路由的消息对发布者可见。发布者确认告诉你代理接受了发布;返回告诉你交换器无法将其路由到任何队列。它们回答不同的问题。
交换器类型错误
交换器类型更改是常见的混淆来源,因为用不同属性声明现有交换器会失败。如果一个服务将events声明为topic,而另一个服务将其声明为direct,那么第二个声明应该会得到前置条件失败。
这个失败是好事。它防止两个应用程序在路由问题上默默产生分歧。解决方法不是捕获并忽略异常。解决方法是明确拓扑所有权。通常,一个部署步骤或基础设施模块应该声明共享的交换器和队列,而应用程序只声明私有的回复队列或幂等地断言预期的拓扑。
扇出交换器忽略路由键。标头交换器根据标头而不是路由键进行路由。如果你的测试消息有正确的路由键但没有队列接收它,请在编辑每个绑定之前检查交换器类型。
令人惊讶的队列属性
持久化意味着队列定义在代理重启后仍然存在。它并不保证队列中的每条消息都能存活。为了让消息在重启后存活,队列必须是持久化的,并且消息必须以持久化方式发布。即便如此,如果发布者需要知道RabbitMQ何时安全地接受了消息,他们应该使用确认。
自动删除队列在其最后一个消费者消失后被移除。它们适用于临时订阅,但不适合共享工作队列。独占队列的作用域限定于声明它的连接,并在该连接关闭时消失。它们适用于回复队列和私有消费者,但不适用于多个工作实例。
如果队列似乎“随机消失”,请检查这些标志:
rabbitmqctl list_queues name durable auto_delete exclusive consumers
还要检查应用程序代码是否在启动时使用与现有队列不同的参数声明了队列。RabbitMQ将队列参数(如队列类型、死信交换器、最大长度以及一些与持久性相关的设置)视为声明契约的一部分。不匹配可能会导致通道关闭并出现前置条件失败。
消息已就绪,但消费者无所作为
如果messages_ready很高而consumers为零,则RabbitMQ在等待。消费者应用程序可能已关闭、连接到了错误的虚拟主机、使用了错误的队列名称,或被权限阻止。
如果消费者已连接但未进行投递,请检查预取和消费者容量:
rabbitmqctl list_consumers queue_name channel_pid consumer_tag ack_required prefetch_count active
一个使用手动确认且预取窗口已满的消费者将不会接收更多消息,直到它确认或拒绝一些它已经拥有的消息。这通常看起来像是RabbitMQ停止了投递,而实际上消费者正持有未确认的工作。
如果messages_unacknowledged很高,请查看消费者日志和下游系统。慢速数据库、卡住的HTTP依赖项或捕获异常但不确认的处理程序都可能导致大量未确认消息。
确认错误
手动确认是可靠处理的正常选择。消费者应该在工作完成后才确认。如果失败,它应该拒绝或否定确认,并做出有意的重新入队决定。
危险模式是对可能失败的工作使用auto_ack=true。使用自动确认时,RabbitMQ在消息投递后立即认为消息已处理。如果消费者在接收后崩溃,消息将从队列中消失。
相反的错误是永远不确认。消费者成功处理了消息,甚至可能写入了数据库,但忘记了basic_ack。RabbitMQ会保持投递未确认,直到通道关闭,然后重新投递。这会产生重复工作和不断增长的未确认计数。
一个简单的处理程序形状更容易审计:
def handle(ch, method, properties, body):
try:
process(body)
except RetryableError:
ch.basic_nack(method.delivery_tag, requeue=True)
except Exception:
ch.basic_nack(method.delivery_tag, requeue=False)
else:
ch.basic_ack(method.delivery_tag)
如果你永远重新入队所有失败,一条坏消息可能会无限循环。对于毒药消息,请使用死信交换器或重试设计。
权限和虚拟主机
RabbitMQ权限按虚拟主机划分。用户可能能够连接,但仍然缺乏对队列或交换器的配置、写入或读取权限。这可能在客户端日志中显示为通道异常,而不总是友好的应用程序错误。
直接检查权限:
rabbitmqctl list_permissions -p production
rabbitmqctl list_user_permissions app_user
对于只发布的服务,授予其所需交换器模式的写入权限,并避免广泛的配置权限。对于消费者,授予对队列的读取权限,如果它需要将否定确认发布到死信路径或使用回复模式,则还需要写入权限。过于宽泛的权限让今天的排查更容易,但让明天的安全审查更困难。
死信配置错误
死信交换器本应使失败可见。配置错误的死信化则相反:消息失败,被拒绝,然后消失在一个没有绑定的交换器中。
检查队列参数,而不仅仅是队列名称:
rabbitmqctl list_queues name arguments
对于应该死信失败作业的队列,你应该看到诸如x-dead-letter-exchange以及有时x-dead-letter-routing-key之类的参数。然后像检查主路由一样检查该交换器及其绑定。
一个常见的错误是配置一个名为jobs.dlx的死信交换器,但将死信队列绑定到另一个交换器上的jobs.failed。另一个错误是将x-dead-letter-routing-key设置为没有绑定匹配的值。RabbitMQ会像处理任何其他发布一样,通过死信交换器路由死信消息。如果没有匹配,消息就没有有用的去处。
重试队列也需要同样的关注。如果你使用TTL加死信化构建重试,请在纸上画出路由:
主队列 -> 拒绝 -> 重试交换器 -> 重试队列 -> TTL过期 -> 主交换器 -> 主队列
然后验证每个交换器、队列、绑定和路由键。重试循环很容易意外创建。在消息头或应用程序状态中设置尝试次数上限,这样一条损坏的负载就不会永远旋转。
策略带来的意外
策略可以在不提及应用程序代码的情况下更改队列行为。策略可以设置队列类型、最大长度、TTL、死信交换器或其他可选参数。这对运维很有用,但当队列行为与代码声明不同时,它可能会使调试变得混乱。
在排查问题时列出策略:
rabbitmqctl list_policies
查看模式和优先级。像.*这样的宽泛策略可能会影响不相关团队稍后创建的队列。如果队列正在丢弃较旧的消息,请检查max-length或溢出设置。如果消息比预期更早过期,请检查队列级TTL和每条消息的过期时间。
当应用程序声明一组参数而策略应用另一组时,RabbitMQ的规则取决于设置。一些可选参数可以由策略控制;其他参数必须与声明匹配。安全的运维习惯是将队列行为放在一个明显的位置,并记录任何有意覆盖应用程序默认值的策略。
当生产者和消费者声明拓扑时
许多客户端库使得每个服务在启动时声明交换器、队列和绑定变得容易。这在开发中可能很方便。在生产中,它可能造成所有权问题。
如果生产者和消费者都声明同一个队列,他们必须在每个重要属性上达成一致。如果一个部署将队列从自动删除更改为持久化,或者更改了死信参数,那么下一个启动的服务可能会因前置条件错误而失败。这比静默漂移要好,但它仍然可能破坏部署。
对于共享拓扑,最好有一个所有者:Terraform、Ansible、迁移作业或一个明确负责的服务。应用程序启动时仍然可以断言预期的拓扑存在,但它不应该随意使用未经审查的默认值创建共享队列。
私有拓扑则不同。创建临时回复队列或独占订阅队列的服务可以直接拥有该队列。区别在于其他服务是否依赖于该队列名称和行为。
保留一条已知良好的发布路径
对于重要系统,保留一个微小的诊断发布者或运行手册命令,通过预期的交换器、路由键和虚拟主机发送一条无害消息。它应该使用与真实应用程序相同的凭据类,或者至少使用足够接近的权限集以捕获路由和访问问题。
这条已知良好的路径在部署期间很有用。如果诊断消息路由成功但应用程序消息没有,请比较应用程序中的实际路由键、标头和虚拟主机。如果诊断消息也失败,那么问题很可能是拓扑、权限或代理状态。
实用的事故检查清单
当消息丢失或卡住时,在重启所有内容之前收集实时状态:
rabbitmqctl -p production list_queues name messages_ready messages_unacknowledged consumers state
rabbitmqctl -p production list_bindings source_name destination_name routing_key
rabbitmqctl -p production list_exchanges name type durable
rabbitmqctl -p production list_connections name user vhost state
然后发送一条带有唯一ID的已知测试消息,并通过日志追踪它。不要使用路径已经不清楚的随机生产消息进行测试。
一旦你将声明的拓扑与发布者和消费者行为对齐,大多数RabbitMQ配置问题就不再神秘。代理通常完全按照它被告知的方式行事。工作在于找到它被告知的内容与团队意图不同的地方。