跨机房同步消息这件事,我前前后后踩过不少坑。最早的做法是自己写一个客户端,从 A 机房的 RabbitMQ 消费,再往 B 机房的 RabbitMQ 投递,中间加个 while 循环和一张"进度表"。这套东西在测试环境跑得挺好,一上线就原形毕露:消费端进程挂了没人知道,ACK 时机没算对导致重启后重复投递几万条,扩容还得改代码重新发版。后来我把这套自研搬运工彻底删掉,换成了 RabbitMQ 自带的 Shovel 插件,配置量不到原来的十分之一,稳定性反而高了一个档次。这篇就把 RabbitMQ 扩展组件里的第 9 号角色——Shovel——从场景、参数、实操到排查完整讲一遍。
先说清楚它是什么。Shovel 是 RabbitMQ 官方提供的一个扩展插件,本质上是运行在 Broker 内部的一个"消息搬运工":它从一个源(可以是队列,也可以是交换机)拉取消息,再投递到另一个目标(同样可以是队列或交换机),源和目标是两个完全独立的连接,可以跨 vhost、跨集群、跨机房,甚至跨产品(比如从 AMQP 1.0 的中间件搬进 RabbitMQ)。它解决的核心问题是消息的可靠跨域搬运,包括机房迁移、多活数据汇聚、灰度分流、灾备同步这些典型场景面。
这篇文章适合三类人看:一是正在做机房迁移或者多活架构、需要把存量消息搬走的运维和后端同学;二是已经用上了 Shovel 但被"消息怎么少了""链路怎么断了"折磨过、想搞清楚内部机制的人;三是面试里被问到"跨集群消息同步怎么做"、只知道 Federation 不知道 Shovel 的求职者。文中所有配置我都会给出可直接抄的版本,同时把"为什么要这么配"讲透,避免抄完出问题还不知道从哪查。
1. 先把场景想清楚:Shovel 解决的到底是什么问题
1.1 自研搬运进程为什么会翻车
绝大多数团队第一次做跨集群同步,第一反应都是"写个消费者不就行了"。这个思路本身没错,Shovel 干的也是这件事,区别在于它把那些容易出错的细节全部内化了。我列一下自研方案最常见的四个翻车点,你对照看看有没有中招。
第一是 ACK 时机。很多人写代码是"先投递到目标端,成功后再 ACK 源端",这看起来是对的,但目标端的"成功"到底指什么?是basicPublish没抛异常,还是 Broker 确认落盘?AMQP 0-9-1 的 publish 默认是异步的,basicPublish返回时消息可能还在客户端缓冲区里,这时候 ACK 掉源端,中间进程一崩,这条消息就永久丢了。Shovel 的ack-mode参数就是专门解决这个问题的,后面会详细拆。
第二是进程存活。自研程序是一个独立进程,它挂了之后消息会在源队列里堆积,但业务方往往几天后才发现。Broker 内部跑的东西至少能跟着 Broker 的日志和监控体系一起被看见。
第三是重复投递。源端 ACK 之后、目标端确认之前的窗口如果发生网络分区,重启后必然重复。Shovel 同样有这个窗口,但它的重连语义是明确的、可预期的,你能提前设计幂等,而不是靠猜。
第四是权限和资源。自研程序需要申请账号、走安全审批、配连接池、管心跳,而 Shovel 直接复用 Broker 自身的连接管理。
注意:Shovel 不是"绝对不丢",它的可靠性和
ack-mode直接挂钩。选错模式,它和自研脚本一样会丢消息,这一点后面会用表格讲清楚。
1.2 Shovel 与 Federation 的选型边界
提到 Shovel,就绕不开 Federation。两者经常被放在一起比较,我给一个我自己的判断标准:看你要同步的是"一个点"还是"一堆点"。
Shovel 的配置单位是"一条链路",一条 Shovel 对应一个源和一个目标,方向明确、语义清晰、调试直观。Federation 的配置单位是"一个上游",它配在交换机或者队列上,一条上游配置可以同时服务同一个 vhost 下多个交换机或队列,适合那种"我有 50 个队列都要从总部汇聚到中心"的场景。
| 对比项 | Shovel | Federation |
|---|---|---|
| 配置粒度 | 一条链路 = 一个源 + 一个目标 | 一个上游 = 可被多个交换机/队列复用 |
| 连接方向 | 由本地发起,连接远端源和远端目标 | 本地主动连上游拉取 |
| 协议支持 | AMQP 0-9-1 与 AMQP 1.0 都支持 | 主要面向 AMQP 0-9-1 |
| 消息顺序 | 单队列内基本有序,跨 Shovel 无序 | 单队列内有序 |
| 典型场景 | 定向迁移、跨协议桥接、灾备、临时搬数 | 多队列汇聚、树状拓扑 |
| 动态生效 | 支持,运行参数形式,无需重启 | 支持,运行参数形式 |
还有一个我实际很在意的差别:Shovel 支持delete-after,可以做到"搬完就把源队列删掉"。这在机房迁移收尾阶段特别有用——数据搬完了,源端队列自动清理,不用人工守着点删除,也不会误删还没搬完的队列。
1.3 Shovel 的内部模型:一个特殊的消费者
理解 Shovel 的工作模型,排查问题时你会省一半力气。它是一个"双连接"结构:进程持有一条到源端的连接,在这条连接上开一个 channel 做basicConsume;同时持有一条到目标端的连接,在这个 channel 上做basicPublish。两条连接的地址、认证、TLS 参数都是分开配置的,这就是它能跨机房的原因。
它的运行状态大致有这么几个:starting(正在建立连接、声明资源)、running(正常搬运)、terminating(收到停止指令,正在收尾)。在管理界面里你能实时看到状态、已搬运的消息数、最近的错误信息。
有几个由此推导出来的结论,很实用:
Shovel 是一个消费者。它和业务消费者在同一条源队列上是竞争关系。如果你已经有一批在线业务在消费同一个队列,再挂一条 Shovel,消息会被两边瓜分。要做"只同步不影响业务",正确做法是让业务侧用独立的队列,或者靠交换机的 routing key 分流出一份拷贝到专用队列。
Shovel 是一条独立的连接。源端和目标端各占一条,会消耗连接数配额,也会有心跳。网络抖动导致连接断开时,它会按
reconnect-delay重试,不是直接完蛋。Shovel 没有"高可用"这个概念。它就是某个节点上的一个 Erlang 进程。节点重启、进程崩溃都会让它中断(随后重连),所以监控必须做,不能假设它永远活着。
Shovel 不做消息内容转换。它基本是原样搬运,消息属性(properties)会保留,headers 也保留。如果你想在搬运过程中改 header 或者打标记,可以用
add-forward-headers让它带上x-shovel-*前缀的追踪头,方便在目标端识别数据来源。
2. 核心参数逐条拆解:哪些必须懂,哪些可以默认
2.1 源端与目标端描述:协议、URI、队列与交换机
Shovel 的描述分源端(src-*)和目标端(dest-*)两组,结构是对称的。协议上支持amqp091(也就是我们最常用的 AMQP 0-9-1)和amqp10,后者用于和 AMQP 1.0 的中间件对接。绝大多数场景用默认的amqp091就行。
URI 的格式遵循标准的 AMQP URI 规范:amqp://用户名:密码@主机:端口/vhost。这里最容易出错的不是用户名密码,而是vhost 的写法。默认 vhost 是斜杠/,在 URL 里必须写成%2f,写成/会被解析成路径分隔符。我见过太多次因为这一个字符导致 Shovel 一直卡在starting的案例。
# 默认 vhost 的正确写法 amqp://sync_user:Passw0rd@10.0.1.11:5672/%2f # 自定义 vhost 叫 /order 的写法 amqp://sync_user:Passw0rd@10.0.1.11:5672/%2forder源端有两种取数方式,二选一,不能同时配:
src-queue:直接消费指定队列的所有消息。适合定向迁移、灾备同步。src-exchange+src-exchange-key:Shovel 会在源端自动声明一个临时的、自动删除的队列绑定到指定交换机上,用给定的 routing key 收消息。适合"按 routing key 分流一份出来同步"的场景。
目标端同理,dest-queue是投递到指定队列,dest-exchange+dest-exchange-key是投递到交换机并由交换机路由。
注意:当目标是队列时,Shovel 会尝试对目标队列做声明(declare)。如果你的目标队列是 quorum 队列或者是带特殊参数的镜像队列,声明时的参数必须和已存在的队列完全一致,否则会报
PRECONDITION_FAILED。我的习惯是目标队列提前用脚本创建好,属性写死在脚本里,不让 Shovel 去声明,这样最稳。
2.2 ack-mode:决定消息会不会丢,也决定吞吐
ack-mode是整份配置里最需要动脑子的一个参数,没有之一。它决定了"什么时候算搬运成功、什么时候向源端 ACK"。三个取值,语义差别很大。
| 取值 | 行为 | 丢消息风险 | 重复风险 | 吞吐 |
|---|---|---|---|---|
no-ack | 消费到就 ACK 源端,不等目标端 | 高 | 低 | 最高 |
on-publish | 目标端 publish 成功即 ACK 源端 | 中 | 中 | 高 |
on-confirm | 等目标端 publisher confirm 回来才 ACK 源端 | 低 | 中 | 最低 |
no-ack基本只适合"丢几条无所谓"的场景,比如日志采集类的冗余数据。生产上做业务数据同步,我不会选它。
on-publish是很多人的默认选择,因为快。但你要清楚它的风险:publish 成功只代表消息写进了目标端的 TCP 缓冲区或者 Broker 的内存,如果目标端 Broker 这时候宕机、且消息还没落盘,这条消息就没了,而源端已经 ACK。
on-confirm是默认值,也是我推荐生产使用的模式。它会等目标端返回 publisher confirm(也就是 Broker 确认接收)之后才 ACK 源端。代价是每条消息多一个确认的等待,吞吐会下降。但注意,Shovel 内部对这个等待做了流水线处理,配合prefetch-count,实际吞吐没有想象中那么惨。
实操心得:如果你的场景是"必须一条不丢",选
on-confirm,同时在业务侧做好幂等。因为on-confirm依然不能避免"目标端已写入、但 confirm 回包丢失"导致的重复投递,这是分布式系统的固有问题,插件解决不了。
2.3 prefetch-count、reconnect-delay、delete-after 的取舍
src-prefetch-count控制 Shovel 预先从源队列拉多少条到本地缓冲。默认值是 1000。这个值本质上是"在途消息量",它直接决定了三件事:内存占用、吞吐上限、以及崩溃时的重复量。
因为 Shovel 是批量预取再逐条投递的,如果进程在搬运过程中崩掉,那些已经拉走但还没 ACK 的消息会重新回到源队列被再次消费。也就是说,prefetch-count越大,崩溃瞬间的重复窗口越大。1000 这个默认值在内存充足的机器上完全没问题,单条消息如果平均 10KB,缓冲也就 10MB。但如果你的消息是大报文,比如单条 1MB 的图片摘要,1000 的预取就是 1GB 内存,这就必须调小。
我的经验值是这样的:
- 小消息(小于 1KB)、追求吞吐:
src-prefetch-count设 1000 到 5000。 - 中等消息(10KB 左右):300 到 1000。
- 大消息(100KB 以上):50 到 200,宁可慢一点也别把 Broker 内存打爆。
reconnect-delay是断线后的重连间隔,单位秒,默认 5。这个值的取舍比较微妙:设太小(比如 1 秒),源端整体挂掉时会造成大量无效重连尝试,日志刷屏;设太大(比如 60 秒),正常抖动后的恢复时间就变长了。跨公网的链路我一般设 10 到 15 秒,同机房内网设 5 秒。
src-delete-after只有两个值:never(默认)和queue-length。设成queue-length意味着当源队列被搬空之后,Shovel 会删除这个源队列并停止。这是为一次性数据迁移量身定做的功能。我做过一次跨机房搬 3 亿条订单消息,就是给每个队列配一条 Shovel,配好src-delete-after: queue-length,然后睡觉。第二天早上起来,源端队列全部清空并自动删除,干干净净。
注意:
queue-length的删除动作有误伤风险。如果源队列在生产上还有别的消费者在写,被搬空的瞬间被删掉,业务就炸了。用之前一定确认这个源队列是"专供搬运"的。
2.4 静态配置与动态参数:两种管理方式的差别
Shovel 有两种定义方式,很多文章混着讲,导致读者抄配置的时候一直失败。
静态方式写在advanced.config里,节点启动时加载。它的位置在不同系统上不一样:Linux 一般在/etc/rabbitmq/advanced.config,Windows 一般在%APPDATA%\RabbitMQ\advanced.config。静态方式的好处是"配置即代码",能进版本管理,节点重建后配置还在;坏处是改配置要重启节点。
动态方式通过运行参数(runtime parameter)定义,走管理 HTTP API、管理界面或者命令行工具,立即生效,不用重启。这是我现在的主力方式,因为改一条链路不用动节点,风险小得多。
这里有个特别容易踩的坑:网上大量老文章写的是source/destination嵌套的 proplist 格式,那是 3.6 及更早版本的rabbitmq.config写法。新版advanced.config里用的是统一的src-*/dest-*键名。你直接把老配置贴进新文件,节点很可能直接起不来,或者插件静默不加载。
动态参数的键名和静态配置几乎一致,只是把下划线换成了短横线风格,比如src-uri、src-queue、dest-uri、dest-queue、ack-mode、src-prefetch-count、src-delete-after、reconnect-delay。
| 参数 | 静态键名 | 动态键名 | 默认值 |
|---|---|---|---|
| 源连接地址 | src-uri | src-uri | 无,必填 |
| 源队列 | src-queue | src-queue | 无 |
| 源交换机 | src-exchange | src-exchange | 无 |
| 源路由键 | src-exchange-key | src-exchange-key | 无 |
| 目标连接地址 | dest-uri | dest-uri | 无,必填 |
| 目标队列 | dest-queue | dest-queue | 无 |
| 预取数量 | src-prefetch-count | src-prefetch-count | 1000 |
| 确认模式 | ack-mode | ack-mode | on-confirm |
| 搬完是否删源队列 | src-delete-after | src-delete-after | never |
| 重连间隔(秒) | reconnect-delay | reconnect-delay | 5 |
| 附加追踪头 | add-forward-headers | add-forward-headers | false |
3. 从零跑通一条 Shovel 链路
3.1 插件启用与前置检查
Shovel 的功能由rabbitmq_shovel提供,管理界面里的 Shovel 状态页由rabbitmq_shovel_management提供。注意第二个插件是依赖第一个的,两个都要开。
# 在需要运行 Shovel 的节点上启用插件 rabbitmq-plugins enable rabbitmq_shovel rabbitmq_shovel_management # 确认插件状态 rabbitmq-plugins list -e | grep shovel跑起来之前,有几个前置条件必须确认,这几条我在生产上一条一条核对过:
账号权限。Shovel 用的账号需要在源端有
read权限(消费队列需要读),在目标端有write权限(投递消息需要写),如果让它自动声明队列,还需要configure权限。权限不足的表现是 Shovel 卡在starting,日志里出现ACCESS_REFUSED。网络连通性。从运行 Shovel 的节点出发,要能访问源端和目标端的 5672 端口。注意是从 Broker 节点发起,不是从你的办公电脑发起,很多人在这里判断错方向。
目标资源已存在。目标队列、目标交换机提前建好,避免 Shovel 声明时因为属性不一致失败。
vhost 要存在。如果动态 Shovel 定义在某个 vhost 下,而这个 vhost 在运行节点上不存在,参数是无法创建的。
实操心得:不要在源端和运行端之间搞混。Shovel 的连接是由运行 Shovel 的那个节点发起的。如果源端在 A 机房、目标端在 B 机房,你在 C 机房的节点上配 Shovel,那 C 节点必须同时能访问 A 和 B。我一般把 Shovel 放在离源端近的一侧,减少搬运动作对生产集群的影响。
3.2 动态方式:用 HTTP API 建一条 Shovel
假设场景是这样:源端10.0.1.11上有个 vhost/order,队列叫order.sync.source;目标端10.0.2.22上 vhost/order,队列叫order.sync.dest。我们要把源端的消息搬到目标端。
用管理 API 创建(注意%2f的转义和 URL 里的 vhost 编码):
curl -u admin:Admin123 -X PUT \ 'http://10.0.1.11:15672/api/parameters/shovel/%2f/order-sync-01' \ -H 'Content-Type: application/json' \ -d '{ "value": { "src-protocol": "amqp091", "src-uri": "amqp://sync_user:Sync%40123@10.0.1.11:5672/%2forder", "src-queue": "order.sync.source", "src-prefetch-count": 500, "src-delete-after": "never", "dest-protocol": "amqp091", "dest-uri": "amqp://sync_user:Sync%40123@10.0.2.22:5672/%2forder", "dest-queue": "order.sync.dest", "ack-mode": "on-confirm", "reconnect-delay": 10 } }'几处细节值得说一下。URL 路径里的%2f是 vhost 的编码,最后一段order-sync-01是这条 Shovel 的名字,自己起,建议带上环境和用途,比如prod-order-to-dr。JSON 里的src-uri密码如果含有@、:之类的特殊字符,必须做 URL 编码,@编码成%40,否则解析会错位,症状同样是卡在starting。
创建完立刻查状态:
curl -s -u admin:Admin123 'http://10.0.1.11:15672/api/shovels' | python -m json.tool返回里重点看三个字段:state应该是running,info数组里如果有error关键字就要警觉,name和vhost确认是你建的那条。如果state长时间是starting,九成是连接或权限问题,去看节点的日志。
3.3 静态方式:写进 advanced.config
如果你更偏好配置即代码,就用advanced.config。注意这是 Erlang 语法,结尾的点和逗号错一个字符整个文件就废掉。
[ {rabbitmq_shovel, [{shovels, [{order_sync_01, [{src-protocol, amqp091}, {src-uri, ["amqp://sync_user:Sync%40123@10.0.1.11:5672/%2forder"]}, {src-queue, <<"order.sync.source">>}, {src-prefetch-count, 500}, {src-delete-after, never}, {dest-protocol, amqp091}, {dest-uri, ["amqp://sync_user:Sync%40123@10.0.2.22:5672/%2forder"]}, {dest-queue, <<"order.sync.dest">>}, {ack-mode, on-confirm}, {reconnect-delay, 10} ]} ]} ]} ].写完后改文件、重启节点(或者用rabbitmqctl eval热加载,但生产上我更倾向于找窗口期重启,热加载出错不好回滚)。启动后同样用 API 查状态,静态 Shovel 也会出现在/api/shovels列表里。
src-uri和dest-uri的值是列表,这一点和动态方式不同。列表的意义是配多个地址做故障转移,第一个不可用时会尝试后面的。如果你的源端有多个节点,可以把集群里所有节点的地址都列上,比只写一个 VIP 更灵活。
注意:静态 Shovel 的配置是节点级的。在集群里部署时,一定要清楚这条配置会被哪些节点加载、实际在哪几个节点上跑起来,避免同一个源队列被多条链路重复消费。
3.4 验证:消息到底过去没有
配置成功不等于数据通了,一定要做端到端验证。我的验证流程固定三步。
第一步,看队列计数。分别在源端和目标端查队列深度:
# 源端 rabbitmqctl -n rabbit@node1 list_queues -p /order name messages messages_ready # 目标端 rabbitmqctl -n rabbit@node2 list_queues -p /order name messages messages_ready第二步,投一条带标记的测试消息,用管理 API 往源端的默认交换机投递:
curl -u admin:Admin123 -X POST \ 'http://10.0.1.11:15672/api/exchanges/%2forder/amq.default/publish' \ -H 'Content-Type: application/json' \ -d '{ "properties": {"delivery_mode": 2, "content_type": "application/json"}, "routing_key": "order.sync.source", "payload": "{\"testId\":\"shovel-check-001\",\"ts\":1700000000}", "payload_encoding": "string" }'第三步,在目标端把这个shovel-check-001捞出来,确认消息体和属性都没变:
curl -u admin:Admin123 -X POST \ 'http://10.0.2.22:15672/api/queues/%2forder/order.sync.dest/get' \ -H 'Content-Type: application/json' \ -d '{"count":1,"ackmode":"ack_requeue_false","encoding":"auto"}'返回里能看到 payload 完全一致、properties.delivery_mode还是 2,说明消息属性被正确保留。如果 payload 对但属性丢了,检查你源端生产消息的方式是不是本来就没带属性。
实操心得:验证一定要用带唯一标识的消息,不要用线上真实消息。线上的消息可能被其他消费者抢走,导致你误判成"Shovel 没搬"。用
order.sync.source这种专供同步的队列最省心。
4. 常见问题与排查技巧实录
4.1 插件启不来、Shovel 卡在 starting
问题一:rabbitmq-plugins enable报错说找不到插件。这通常不是插件本身的问题,而是 Erlang 版本或者安装包不完整。RabbitMQ 的插件是跟着版本走的,3.11 和 3.12 的插件不通用。先确认你的 RabbitMQ 版本,再看该版本的插件有没有随包安装。在 Windows 上安装时如果用的是绿色解压包,插件目录容易被忽略,建议用官方安装器。
另外排查时优先看日志文件而不是控制台输出。日志里搜shovel关键字,会看到插件启动过程中的完整信息,比界面上那个starting有用得多。
问题二:Shovel 一直卡在 starting,日志里有ACCESS_REFUSED。权限问题。到源端给账号配read权限,到目标端配write,需要声明队列的话再加configure:
rabbitmqctl set_permissions -p /order sync_user "^order\.sync\..*" "^order\.sync\..*" "^order\.sync\..*"三个参数依次是配置、写、读的正则。别图省事直接用".*",权限范围收窄一点,出事故时影响面小。
问题三:卡在 starting,日志里是ENOTFOUND或者连接超时。DNS 解析不了或者网络不通。注意 Shovel 是从 Broker 节点发起连接的,先在那个节点上telnet 目标IP 5672试一下。还有一种是 URI 里 vhost 写成/而不是%2f,解析出来的地址就错了,症状和网络不通一模一样,很容易误判。
问题四:报PRECONDITION_FAILED。目标端已存在的队列属性和 Shovel 尝试声明的属性不一致,常见于目标端是 quorum 队列或者设置了自定义参数。解决办法是提前把目标队列建好,属性写清楚,不让 Shovel 声明。
4.2 消息丢了、重复了、堆积了
这三类问题的排查思路完全不同,我整理成一张速查表,从现象倒推原因。
| 现象 | 最可能的原因 | 排查动作 | 处理方式 |
|---|---|---|---|
| 源队列空了,目标端少了消息 | ack-mode配成no-ack或on-publish | 查 Shovel 参数确认 ack-mode | 改成on-confirm |
| 目标端消息数多于源端发出数 | 断线重连导致的重复投递 | 对比源端 ACK 数与目标端写入数 | 业务侧做幂等,不做去重 |
| 目标端数量对不上,且 Shovel 状态 running | 有别的消费者在抢源队列的消息 | 源端查该队列的 consumer 数量 | 把同步队列独立出来 |
| 源队列持续堆积,Shovel 状态 starting | 链路根本没建立 | 查日志、查权限、查网络 | 按 4.1 排查 |
| 源队列堆积但状态 running | 搬运速度跟不上生产速度 | 看 Shovel 的速率指标 | 拆分队列、提高并行度 |
| 目标端出现同一批消息反复写入 | 目标端拒绝投递,Shovel 重试 | 查目标端日志、查队列是否满 | 扩大目标端容量或加限流 |
关于"消息丢失"我再强调一句:Shovel 的丢消息只可能发生在no-ack和on-publish两种模式下。如果你用的是默认的on-confirm,并且源端消息本身是持久化的、目标端队列也是持久化的,那消息不会因为 Shovel 崩了而丢,只会重复。搞不清这一点,排查方向就会完全跑偏,花大量时间在网络和磁盘上找原因。
还有一个隐蔽的坑:源端消息不是持久化的。很多人排查半天 Shovel,最后发现源端的生产者在发消息时delivery_mode是 1(非持久化)。Broker 重启后这些消息本来就不在了,跟 Shovel 一点关系都没有。看问题要往上游看一步。
4.3 断链与监控:别等业务方来告诉你
Shovel 崩溃或者断链,Broker 自己是不会告警的,它只会在日志里默默写一行。等你发现的时候,可能源队列已经堆积了上千万条消息。所以我强烈建议把 Shovel 纳入监控体系。
最直接的方案是定时轮询管理 API,把状态和错误信息一起采集出来:
curl -s -u admin:Admin123 'http://10.0.1.11:15672/api/shovels' \ | python -c " import json,sys data = json.load(sys.stdin) for s in data: name = s.get('name') vhost = s.get('vhost') state = s.get('state') print(f'{vhost}/{name} state={state}') for item in s.get('info', []): if 'error' in str(item).lower(): print(' ERROR:', item) "把这段包进监控脚本,状态不是running就发告警。同时监控两个队列的深度差:如果源队列深度持续上涨、目标队列深度不涨,说明搬运链路已经断了,这个信号比状态字段更早暴露问题。
另外要监控源队列的消费者数量。如果 Shovel 挂了,这个队列的消费者数会从 1 变成 0。这个指标比队列深度更灵敏,因为队列深度要堆积到一定程度才触发告警,而消费者数变化是即时的。
实操心得:Shovel 的断链恢复有延迟,
reconnect-delay设 10 秒意味着最坏情况下断链 10 秒后才开始重连尝试,重连再花几秒。如果你的业务对延迟敏感,把reconnect-delay调小一点,代价是集群抖动时日志会多一些。我个人的平衡点是 5 秒。
5. 生产落地的一些取舍经验
5.1 吞吐怎么提:并行度、prefetch 与 confirm
单条 Shovel 的吞吐是有限制的,它的结构决定了它是一个单线程的搬运工:一条源连接、一条目标连接、一个 channel。实测下来,小消息场景单条 Shovel 大概能跑到每秒一两万条,具体取决于消息大小、网络延迟和ack-mode。
如果这个速度不够,有三个思路,我按推荐程度排。
第一是拆分队列。这是最有效的办法。比如你有 20 个订单队列要搬,就配 20 条 Shovel,每条搬一个队列,天然并行,互不干扰,出问题也只影响一个队列。缺点是配置文件会变长,配错的可能性上升,建议用脚本生成配置。
第二是调大src-prefetch-count。在内存允许的前提下,预取多一些能让连接上的数据流更饱满,减少等待往返的时间。这是最简单的一招,先调这个,把 1000 提到 3000 试试,观察吞吐和内存变化。
第三是ack-mode降级。从on-confirm降到on-publish能明显提速,但代价是可靠性下降。我只在"允许丢少量、且目标端是同步刷盘配置"的场景下这么干。降级之前一定要评估,别为了性能把数据搞丢。
反过来,如果你发现 Shovel 把源端的 CPU 或者内存吃满了,说明并行度太高或者预取太大,要往下调。搬运这件事千万别追求极致速度,稳比快重要。
5.2 什么时候不该用 Shovel
用了几年,我总结出几个不该用 Shovel的场景,避开这些能省很多事。
第一,要求严格顺序且不能重复的场景。Shovel 在断线重连后会有重复,这是机制决定的。如果你的业务对消息顺序极度敏感(比如账户流水必须严格按序处理),那要靠业务侧的分区有序 + 幂等来兜底,Shovel 只管把消息搬过去。
第二,消息量极大且要求低延迟的场景。Shovel 是一个独立的搬运环节,天然有一层延迟。如果你的场景是"下单后 100 毫秒内对端必须看到",那应该用双向的业务级同步,而不是靠 Shovel 搬消息。
第三,需要复杂内容转换的场景。Shovel 不做内容转换,它只是个搬运工。如果你需要在搬运过程中解析消息体、改字段、然后重新组装,那还是得写业务程序,Shovel 帮不上忙。
第四,短期的、一次性的少量数据搬运。如果只有几百条消息要搬,手动导一下或者写个临时脚本更快,配 Shovel 加验证的时间比手工搬还长。
5.3 我踩过的几个坑
最后分享几个具体到能直接避开的坑,都是我自己或者同事踩过的。
坑一:add-forward-headers打开后,目标端的业务代码收到陌生 header 就报错。这个参数会往消息头里加一批x-shovel-*字段,如果目标端用的是严格模式的反序列化(比如某些把 header 映射成对象字段的框架),可能直接抛异常。开启之前先和目标端确认一下。
坑二:在镜像队列上做同步,两个节点同时跑 Shovel。有一次我把静态配置部署到了集群所有节点,结果同一条源队列被多条 Shovel 消费,消息被搬了两遍。静态配置是节点级的,部署时一定确认清楚在哪几个节点生效。这个坑排查起来特别费劲,因为两边的日志看起来都很正常。
坑三:源端队列设置了消息 TTL,搬运速度慢导致消息过期。源队列有 TTL 的话,消息在队列里待太久会被丢弃,Shovel 还没搬就没了。做迁移之前一定要检查源队列的 TTL、最大长度这些策略,必要时先把策略临时调整,或者提高搬运速度。
坑四:把 Shovel 配在了从节点上,主节点故障切换后 Shovel 消失了。节点的角色变化会影响负载分布。生产上我会把搬运类的工作放在专用节点上,和业务节点做物理或逻辑隔离,避免互相影响,也避免节点角色变动带来的意外。
坑五:密码里有特殊字符没转义。前面提过,但还是值得单独列一条。密码里的@、:、/、#在 AMQP URI 里都是保留字符,必须编码。我现在的做法是直接用纯字母数字加少量安全符号生成同步专用密码,从源头上避开这个问题,比在配置里到处转义省心得多。
坑六:以为 Shovel 会跟着集群自动漂移。它不会。节点挂了,跑在上面的 Shovel 就停了。如果你的业务不能接受这种中断,要么在另一个节点上准备一条备用配置(注意别同时跑),要么在应用层做双写兜底。这个认知比配置技巧更重要。
关于这个组件我个人的体会是:Shovel 的价值不在于它有多强大,而在于它把一个"看起来简单、做起来全是细节"的事情标准化了。什么时候 ACK、断线怎么重连、搬完怎么清理,这些边界条件它都替你考虑过一遍。你要做的其实只有两件事——把ack-mode选对,把监控做上。前面那些配置参数,大部分场景下默认值就是最优解,不用折腾。