小Cの已经记不起来的博客

微服务架构下 消息队列 的设计与权衡

从一次线上事故说起

之前负责的系统里,订单服务下单成功后要同步调用积分、通知、物流三个下游服务,有一次通知服务发布重启挂了半分钟,结果把订单服务也拖垮了,用户下单直接超时。当时的第一反应就是:这三件事凭什么要在下单的主流程里同步干完?加个消息队列吧。

说干就干,但真正接入之后才发现,消息队列不是引个依赖、发条消息那么简单,它解决老问题的同时会带进来一堆新问题,而且很多坑只有踩过才知道疼。这篇文章就聊聊我们在设计和使用消息队列过程中做过的那些权衡,不一定对,但都是实测出来的经验。

选型:没有最好的,只有合适的

市面上常见的就那几个:RabbitMQ、Kafka、RocketMQ,还有不少小项目直接拿 Redis 的 list 或者 stream 凑合着用。

我个人的看法是别过度设计。如果只是服务之间解个耦,消息量一天几万条,RabbitMQ 完全够用,管理界面友好,路由规则灵活,出问题排查也快。Kafka 的强项是大吞吐和日志类的流式处理,单机十万级别的写入很轻松,但它的语义是“消息流”而不是“任务队列”,你要拿它做重试、死信这些,就得自己搭一套 topic 的流水线,写起来挺啰嗦的。RocketMQ 功能上比较全面,延迟消息、事务消息开箱即用,Java 技术栈的话可以优先考虑。

至于拿 Redis 当队列,说实话小项目我不反对,lpush + brpop 几行代码就跑起来了。但要想清楚,主从切换的瞬间消息是可能丢的,这时候你敢不敢让它承载支付回调这种消息,就是另一个问题了。

可靠性:消息到底会不会丢

这是绕不开的问题。一条消息从生产到消费,有三个环节可能丢:生产端发出去 broker 还没确认进程就挂了;broker 收到了还没刷盘机器宕机了;消费端拿到了,业务逻辑没执行完消费者崩了。

对应的解法分别是生产端 confirm/ack、broker 持久化加副本、消费端手动 ack。看着挺简单,但每一步都有代价。比如 Kafka 默认的配置其实是偏性能的,你要真的一笔都不肯丢,就得改成这样:

acks=all
enable.idempotence=true
min.insync.replicas=2

吞吐量立刻掉一截,刷盘策略同理,每条都 fsync 和攒一批再刷,性能能差出好几倍。所以我的结论是:先想清楚业务上能容忍什么。积分晚一分钟到账无所谓,但订单状态和支付结果对不上就是事故,这两类消息的投递策略就不该一样。

重复消费与幂等:躲不开的组合拳

只要你保证了消息不丢,那基本上就等价于保证消息会重复,这就是所谓的 at-least-once。理论上存在 exactly-once,但要么依赖特定框架自己的事务,开销大到没法用,要么只覆盖它那一亩三分地,所以实际项目里大家默认的做法都是:允许重复,消费端做幂等。

幂等的实现不复杂,常见的就是拿业务唯一键(订单号、流水号)去重,要么数据库加唯一索引靠冲突兜底,要么先查 Redis 的 setnx。我更推荐数据库唯一索引,因为 Redis 那个 key 是有过期时间的,过期之后旧消息再来一遍还是可能重复写,反正兜底逻辑放在数据库里,睡着都踏实一点。

顺序性:想要有序,就得付出代价

另一个常见需求是同一实体的消息要按顺序消费,比如同一个订单的创建、支付、发货不能乱。Kafka 的做法是按 key 分区,同一个 key 落到同一个分区里天然有序,但代价是分区负载可能不均,热门订单所在的分区会把整个消费组拖慢。RabbitMQ 想做到有序就更麻烦了,单队列单消费者,吞吐直接废掉。

我们的做法是先想清楚到底需不需要严格有序。绝大多数场景其实只需要“最终状态正确”,那就在消费端比较一下消息里的版本号或者时间戳,旧的直接丢弃,比强行保证顺序划算多了。

积压、重试与死信

最后说说运营层面。重试不要无脑无限重试,一般配个递增的延迟,重试几次还失败就扔进死信队列,人工介入。延迟消息这个功能各家支持不一样,RabbitMQ 要装插件或者用 TTL + 死信绕一圈,RocketMQ 原生就有,这也是我前面说选型要看功能的原因。监控上重点盯消费 lag,积压了要能第一时间告警,别等用户来投诉了才发现队列里堆了五十万条消息...

写在最后

消息队列是个好东西,但它不是银弹。每加一个队列,就多了一处可能出问题的基础设施,多了一套要维护的消费逻辑和监控。所以每次想引入的时候我都会先问自己一句:这个异步真的有必要吗?同步调用加个超时和重试能不能解决?想清楚了再上,比上了之后再拆,要省心太多了。

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放