我在很多技术评审会上见过一个奇怪的现象:业务请求量每天才几万,系统也只有一个实例,但一聊到某些异步任务,第一反应就是“要不要上消息队列”。其实消息队列(message queue)不是装饰品,它解决的是确定场景下的确定问题:异步解耦、流量削峰、可靠传递。反过来说,很多系统真正引入消息队列之后,问题不但没有变少,反而多了一堆“消息丢失了怎么办”“重复消费了怎么办”“消费积压了怎么办”的烦恼。
这篇文章我会从实际经历出发,把消息队列最核心的东西掰开讲清楚。不回避原理,也不堆概念,重点放在:为什么业务需要队列、队列的内部机制是什么、怎么用 Spring Boot 接一个能跑的 Redis Stream 示例、重复消费和延迟队列怎么处理,以及面试和实战里绕不开的丢消息、乱序和积压三大话题。无论你是在做单体架构改造、准备面试,还是已经在上消息队列但老觉得不稳,这篇文章都适合你慢慢读。
1. 中间件还能扛得住,为什么业务还是需要消息队列
很多开发者在系统还很小时不理解消息队列的价值,因为同步调用看起来没啥问题:用户注册,我把账号插入数据库,然后调用短信服务发一条欢迎短信,调用邮件服务发一封激活邮件,再调用积分服务送一点新人积分。外部接口慢一点,用户等着就好,反正也就多几百毫秒。
但真实的业务增长从来不跟你商量。短信服务如果是一次第三方 HTTP 调用,网络抖动、对方服务超时、回调延迟都会直接拖垮你的注册接口;积分服务一旦出现慢 SQL,整个链路跟着遭殃;更别提秒杀场景,流量瞬间是平时的几十倍,如果所有请求都同步打到数据库,系统基本当场就垮。
消息队列在这个位置发挥的作用非常具体:
- 异步化:用户只需要关注“注册成功”这个核心结果,发短信、发邮件、加积分这些动作全部丢到队列里慢慢执行;
- 削峰:瞬时流量先进队列,消费者根据自己的处理能力源源不断去拉,数据库永远不会被瞬间流量冲垮;
- 解耦:订单系统和库存系统不再需要互相知道对方的存在,订单完成就发一条消息,谁关心谁订阅。
举一个我优化过的例子。注册接口原本的调用链路是:落库 300ms,短信服务 500ms,邮件服务 800ms,新人积分 300ms,总响应时间接近 1.9s。把短信、邮件、积分全部改成投递到消息队列后,核心链路就只剩下落库这一步,响应时间直接降到 400ms 以内,而短信和邮件后台照常发送,用户完全感知不到差别。
这就是消息队列的核心价值:它不是用来炫技的中间件,而是换个方式思考“哪些事情必须现在做,哪些事情可以等一下做”。这里的“等一下做”不是延迟,而是交给别人异步做。
但必须承认,引入消息队列不是零成本的。机器、运维、监控、告警、消息重复处理、消费失败重试,这些都是成本。所以接下来我要讲的不是“无脑上队列”,而是当你真的决定使用队列之后,有哪些机制必须想清楚,以及写代码时最容易踩哪些坑。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先搞懂队列里的七件事,再动手写任何代码
我看到很多年轻同学写消息队列代码,流程就是找个 Demo,生产者往队列里塞一条 JSON,消费者收到后打一行日志,然后就以为消息队列学完了。一旦生产环境出现丢消息、重复消费、顺序错乱,立刻懵了。只了解 API、不理解机制,是消息队列使用者的通病。
在写代码前,请把下面七个机制刻在脑子里。这七个东西比任何 API 都重要。
2.1 点对点与发布订阅
点对点模型好理解:一条消息只会被一个消费者取走,取走后消息就没了。它适合任务分配,比如“有一批邮件要发送,消费者集群一起抢活,每个消费者处理一部分”。
发布订阅模型则是广播:一条消息被所有订阅者都收一遍。它适合事件通知,比如订单创建后,积分服务、短信服务、搜索服务都想知道这件事。这个场景如果用点对点模型,只能给每个服务各发一份,代码和维护成本都会比较高。
很多队列同时支持两种模型。Kafka 的 topic 和消费者组就是一种组合:同一个消费者组内部是竞争消费(点对点),不同消费者组之间是广播订阅(发布订阅)。RabbitMQ 的 exchange 模式和 Redis Stream 的消费者组也有类似的逻辑。搞混了模型,消息就会“发不出去”或者“大家都抢着消费”。
2.2 消费者组为什么能水平扩展
消费者组是队列的核心抽象。它代表一组共同消费某个队列的处理节点。同一组内,一条消息只会分给其中一个消费者,分给谁由队列的负载均衡策略决定。这样做的直接好处是:一个消费者处理不过来,加机器即可,消费者组自动做拆分。
Kafka 里组内消费者数量和分区数量存在强相关:理想情况下一个分区同时只有一个消费者在处理,如果消费者数量超过分区数,多出来的消费者只能闲着。原因很简单,同一个分区内的消息必须保证顺序,如果同一个分区的消息被多个消费者同时抢,顺序就没法保证了。很多人扩容后仍然消费不过来,就是没理解这个约束。
2.3 消息确认和 at least once
消费者拿到消息并处理完毕后,必须给队列一个明确信号:“我处理好了”,这个信号就是 ack。队列收到 ack 后才算这条消息被消费完成,否则它会觉得消费者处理失败,然后把消息重新投递。
这里的“否则就会重投”是一把双刃剑。它在保证消息不丢失的同时,制造了重复消费的可能。几乎所有主流消息队列在默认语义下都是 at least once:消息不丢,但可能重复。严谨地去重和幂等,是消费者拿消息后必须做的事。
2.4 持久化与重试
队列通常会提供持久化选项。Redis Stream 依赖 RDB/AOF 持久化,RabbitMQ 的消息可以标记为持久化,Kafka 通过分区日志落盘。如果关掉持久化,队列重启后消息会丢。很多中小团队在测试阶段图省事,生产环境也用默认配置,直到队列宕机才发现消息全部蒸发。
消费失败的重试也很关键。失败消息不能无限循环消费,否则会卡住后面的消息;也不能没有任何限制地重投,否则失败消息频繁重复会让业务不断重复做无效动作。比较实用的做法是:配置有限次失败重试,超过阈值后转入死信队列,由人工或专门进程处理。
2.5 顺序性
队列只能保证它内部存储的先后顺序,不能保证消费者处理结果一定按这个顺序落地。一个订单的“支付成功”和“关闭订单”两条消息如果被不同消费者抢走,可能出现“关闭”先执行,“支付成功”后执行,最终把支付流水给冲掉。
保证顺序的通用方法只有一个:把需要保持顺序的消息路由到同一个分区/队列,并且这个分区/队列只被一个消费者顺序消费。不要一边谈多线程消费带来吞吐提升,一边又要求绝对顺序,这两个需求在单条消息层面天然矛盾。
2.6 死信队列
死信是重试到极限仍失败的消息的最终去处。它不是一个花哨功能,而是生产队列必须有的“保险丝”。人为开发的消息队列系统,一旦没有死信机制,失败消息会一直重试,日志里全是错误,磁盘和 CPU 白忙,而业务早就不正常了。
2.7 回溯能力
Kafka 这类基于日志的队列支持按 offset 重新消费,Redis Stream 也支持从指定 ID 重新读。生产环境经常出现“修复 bug 后要把过去一小时的消息重新消费一遍”的情况,没有回溯能力就只能干瞪眼。
把上面七个机制放在一起看,你会发现自己需要的根本不是“把消息从 A 搬到 B”,而是一套关于可靠性、并发、顺序、失败处理的完整设计。后面所有实战代码,都是围绕这套设计展开的。
3. Spring Boot + Redis Stream:一个能落地的消息队列示例
技术选型时很多人会纠结:用 Kafka?RabbitMQ?RocketMQ?还是用 Redis Stream?我的建议是:如果你们团队已经把 Redis 作为基础依赖,消息量在每天百万级以内,对高级特性要求不高,Redis Stream 是一个非常好的起步选择。如果消息量达到千万级以上,对事务、死信、消息轨迹、顺序有硬性要求,再考虑上 RabbitMQ 或 Kafka。
为了让你看到一整套可运行的消息队列设计流程,这一节我带着你用 Spring Boot 3.x + Redis Stream 实现订单创建后的异步处理场景。生产端提交订单,消费端监听“订单创建成功”事件,执行异步加分、发送通知等操作。
3.1 为什么示例选了 Redis Stream 而不是 RabbitMQ 或 Kafka
我知道一定有人会质疑:Redis 还能当消息队列用?
先看数据:Redis Stream 是 Redis 5.0 引入的数据结构,6.x 已经相当成熟。它天然经历了 Redis 自身的单线程高性能验证,支持持久化,支持消费者组模式,操作接口也足够简洁。对一个日活不高、业务消息量平稳的项目,它完全能扛住。
再看成本:很多团队没有专职运维消息中间件的人,部署 Kafka 至少三节点,磁盘、内存、监控、告警全都要配,出问题排查还需要专门的机制知识。而 Redis Stream 直接复用你已有的 Redis 环境,代码量不大,出了问题可以顺着 Redis 命令一层层查,心智负担小很多。
RabbitMQ 和 Kafka 当然更强。RabbitMQ 路由能力强、管理界面好用、消息机制成熟;Kafka 吞吐量极高、分区和日志模型特别适合大数据链路。但如果你的场景用 Redis Stream 都够了,就没必要让架构复杂化。
当然,Redis Stream 也有自己的软肋:消费组模型比 Kafka 简单,缺少 Kafka 的分区级灵活扩展;消息累积较多时会持续占内存;依赖 Redis 本身的持久化配置。数据可靠性要求极高的业务,建议把 Stream 数据源做成“短暂缓冲 + 最终落库”,别让 Redis 成为唯一存储。
3.2 生产端:订单服务发送异步消息
假设订单落库成功后,我们要往 stream:order-create 这个 Stream 发一条消息,里面包含订单 ID、用户 ID 和创建时间。
java复制@Service
@RequiredArgsConstructor
public class OrderCommandService {
private final StringRedisTemplate stringRedisTemplate;
private static final String ORDER_STREAM = "stream:order-create";
public void createOrder(OrderCreateRequest request) {
// 这里做订单核心业务,落库成功后继续
saveOrder(request);
Map<String, String> message = new HashMap<>();
message.put("orderId", request.getOrderId());
message.put("userId", request.getUserId());
message.put("createTime", String.valueOf(System.currentTimeMillis()));
// 发送到 Redis Stream
RecordId recordId = stringRedisTemplate.opsForStream().add(
StreamRecords.mapBacked(message).withStreamKey(ORDER_STREAM)
);
log.info("订单消息发送成功, orderId={}, streamId={}", request.getOrderId(), recordId.getValue());
}
}
这段代码基于 Spring Data Redis 3.x 的 StreamRecords 写法。如果你用的版本较老,opsForStream().add(key, map) 也有效,核心概念一致:向指定 Stream 追加一条消息,返回的 RecordId 相当于这条消息在队列里的唯一凭证。
生产端真正要决定的是:业务落库和发消息如何保持一致? 如果消息先发再把订单状态改成成功,消费者可能读到一条“还没成功”的订单;如果先改状态再发消息,消息一旦丢失,状态就没人处理。
最稳妥但最简单的方案是用本地消息表:把“订单创建成功”事件也作为一张表记录进同一个本地事务,写完订单同时插入一条 status 为“待发送”的消息记录;发送成功后把消息表状态改成“已发送”,失败则后台任务扫描重发。这套方案不依赖分布式事务,代码也容易理解。复杂的高可用方案还有很多,但本地消息表是最底层的兜底思路。
3.3 消费端:消费者组的完整示例
消费端需要建立一个消费者组。同一个组内的消费者会分摊 Stream 中的消息,每个消费者都只处理一部分。
先给出初始化消费者组的代码:
java复制@Component
@RequiredArgsConstructor
public class OrderStreamInitializer {
private final StringRedisTemplate stringRedisTemplate;
private static final String ORDER_STREAM = "stream:order-create";
private static final String CONSUMER_GROUP = "group:order-create";
@PostConstruct
public void initGroup() {
try {
stringRedisTemplate.opsForStream().createGroup(
ORDER_STREAM,
ReadOffset.latest(),
CONSUMER_GROUP
);
log.info("消费者组创建成功: {}", CONSUMER_GROUP);
} catch (Exception e) {
// 组已存在时会报 BUSYGROUP,属于正常情况,这里只记录
log.info("消费者组已存在或创建逻辑跳过: {}", e.getMessage());
}
}
}
这里有两个细节必须理解清楚:
第一,ReadOffset.latest() 表示在创建消费者组时,只消费从创建时间点之后进来的新消息。如果 Stream 里已经积累了很多历史消息,而你又需要把它们也纳入消费范围,应该用 ReadOffset.from("0"),这样消费者组会从 Stream 开头开始消费。
第二,消费者组创建是幂等操作,但由于重复申请时会返回 BUSYGROUP 错误,所以我在初始化方法里主动捕获了异常。有经验的开发者会专门判断错误信息是否包含 BUSYGROUP,避免把所有异常都吞掉。
消费逻辑使用定时拉取模式:
java复制@Component
@RequiredArgsConstructor
public class OrderCreateConsumer {
private final StringRedisTemplate stringRedisTemplate;
private static final String ORDER_STREAM = "stream:order-create";
private static final String CONSUMER_GROUP = "group:order-create";
// 应用启动时先创建消费者组
@EventListener(ApplicationReadyEvent.class)
public void init() {
try {
stringRedisTemplate.opsForStream().createGroup(
ORDER_STREAM,
ReadOffset.latest(),
CONSUMER_GROUP
);
} catch (Exception ignored) {
// 组已存在,忽略
}
}
// 每 200ms 拉取一次消息
@Scheduled(fixedDelay = 200)
public void pollMessage() {
List<MapRecord<String, String, String>> records = stringRedisTemplate.opsForStream().read(
Consumer.from(CONSUMER_GROUP, "consumer-" + System.nanoTime() % 10000),
StreamReadOptions.empty().count(20).block(Duration.ofSeconds(2)),
StreamOffset.create(ORDER_STREAM, ReadOffset.lastConsumed())
);
for (MapRecord<String, String, String> record : records) {
try {
Map<String, String> body = record.getValue();
handleCreatedOrder(body);
// 业务处理成功后必须 ack
stringRedisTemplate.opsForStream().acknowledge(
ORDER_STREAM,
CONSUMER_GROUP,
record.getId()
);
} catch (Exception e) {
log.error("消费订单创建消息失败, recordId={}", record.getId(), e);
// 失败不 ack,消息会保留在 Pending List 里等待后续处理
}
}
}
private void handleCreatedOrder(Map<String, String> body) {
// 执行异步赠送积分、发送欢迎短信等业务逻辑
log.info("处理订单创建事件, orderId={}, userId={}", body.get("orderId"), body.get("userId"));
}
}
这段代码里有三个容易被新人忽视的点,我单独说一下。
第一,Consumer.from(CONSUMER_GROUP, "consumer-" + ...) 里的 consumer 名称要是实际有意义的,最好伪装成“节点编号”或“业务类型”,不要每次调用都生成一个随机名字。消费者很多时,Redis 里会维护很多 consumer 节点信息,过度随机命名会让 Stream 的 Pending List 维护成本变大。
第二,ReadOffset.lastConsumed() 的语义是“读自己上次消费之后的内容”。如果消费者刚创建,还没消费过,它会从 Stream 的当前末尾开始读。业务场景中,消费者重启后往往希望能从上次处理的位置继续读,这里就需要配合 Pending List 再加一层处理,具体在 3.4 节讲。
第三,消息处理成功后必须 ack,否则这条消息会永远停留在消费者的 Pending List 中,形成幽灵消息。Redis 服务端不知道你的业务处理是否成功,它只知道你有没有发送确认指令。
3.4 这里不得不提的 Pending 和 Ack 细节
Pending List 是 Redis Stream 里非常容易踩坑的概念。消费者从 Stream 读出一条消息,但没有 ack,这条消息会进入该消费者自己的 Pending List。它的存在是为了支持故障恢复:消费者处理到一半宕机,队列可以知道“哪些消息还没确认”。
下面这个场景你一定遇到过:消费者 A 拉取了一条消息,执行到业务代码最后一行时被 kill -9,这条消息没来得及 ack。消费者 A 重启后继续消费新消息,但那条没 ack 的消息还躺在它的 Pending List 里。如果所有消费者都忽略 Pending List,这条消息就永久滞留,业务没有完成,也不会被重新投递。
处理这种消息的标准做法是把 Pending List 中滞留时间较长的消息重新分配给消费者处理。Redis 提供一个 XAUTOCLAIM 命令,可以按超时时间把 pending 消息转移给其他消费者。
实际运维排查时,常用这几条命令:
bash复制# 查看 Stream 中的总量
XLEN stream:order-create
# 查看消费者组消费状态
XINFO GROUPS stream:order-create
# 查看指定消费者组的每个消费者 Pending 情况
XINFO CONSUMERS stream:order-create group:order-create
# 将 Pending 超过 1 小时的消息转移给 consumer-retry 处理
XAUTOCLAIM stream:order-create group:order-create consumer-retry 3600000 0
3600000 是毫秒,代表这里只转移 pending 超过一小时的旧消息。执行之后返回一个消息 ID 列表,其中被转移消息的归属消费者会变成 consumer-retry。你的消费者程序需要轮换读取自己的 Pending List,把分配给自己的消息拿起来处理并 ack。
看到这里你应该明白了:消息队列不是“发出去就不管”的东西,消费端需要有明确的 ack 机制、失败不 ack 机制、pending 超时转移机制,这才能构成一个可靠的处理闭环。
4. 重复消费无法彻底避免,你能做的只有幂等设计
重复消费问题几乎人人都会遇到。你也许已经发现,同一个用户同一天收到了两条一模一样的短信,同一个订单的积分被加了两遍。排查之后发现消息确实只发了一次,但消费端做了两遍。
“消息只发一次,但消费端做了两遍”,这就是重复消费的本质。
4.1 重复消费究竟是怎么发生的
重复消费的源头归起来有三条:
第一,消费者的 ack 时机问题。消息处理完之后,应用还没来得及返回 ack 就宕机了。队列等了一会没收到 ack,认为消费者没处理成功,于是重新投递。这条消息会被第二个消费者实例处理,但上一个消费者其实已经处理完了。
第二,消费者处理成功,但 ack 反馈在网络传输中丢失。队列同样误以为处理失败,再次投递。
第三,生产端出现了重试。比如生产者发送消息时网络超时,消息实际已经到达队列,但生产者拿不到响应,于是进行重发,队列里就出现两条一模一样的业务消息。
不管哪一种,结论是:消息队列很难做到 across-the-board 的 exactly once。与其寄希望于队列帮你精确去重,不如接受“重复一定会发生”,然后把消费者设计成幂等。
幂等的意思是:同样一个请求,执行一次和执行一百次,最终结果一样。这要求你设计消息内的业务唯一标识,并且在消费端对重复请求做去重。
4.2 幂等方案选择:从数据库唯一键到 Redis SETNX
实际项目中最常用的幂等方案有四类:
| 方案 | 核心思路 | 适用场景 | 注意点 |
|---|---|---|---|
| 数据库唯一键去重 | 消费时先插入一条带业务唯一键的记录,插入成功才处理,插入失败说明重复 | 绝大多数业务场景 | 唯一键字段要建立唯一索引 |
| Redis SETNX 去重 | 用 setIfAbsent 加锁/标记,抢占成功的才能处理 |
对 Redis 依赖充分,处理环节很快 | key 的过期时间要大于业务最长处理时间 |
| 状态机前置校验 | 消息里带上业务当前状态,处理前先 compare-and-set | 订单状态流等强流程场景 | 需要业务表有明确状态流转 |
| 业务自身唯一逻辑 | 发短信前先查 today 是否已经发过;加积分前先查任务是否已完成 | 业务本身带有唯一条件的场景 | 查询逻辑要能抵抗并发 |
方案没有绝对的好坏,关键是先想清楚你的业务天然具备哪种去重条件。比如“给某用户加注册奖励积分”这个场景,注册这个行为天然只会发生一次,你在用户表上标一个 rewarded 字段,加积分前检查这个字段即可。
如果你的业务本身没有天然的唯一约束,通常用“消费记录表 + 唯一键”来解决:为每个消息创建一个全局唯一的消息 ID,每次消费先向本地表插入这个消息 ID 对应的记录,如果插入报主键冲突,说明重复直接跳过。
4.3 我的去重代码示例与踩坑心得
先给出我常用的 Redis SETNX 去重通用模板。它适合消费响应比较快、可以容忍跨实例锁竞争成本的场景。
java复制public void processWithDedup(String businessId, Runnable process) {
String dedupKey = "dedup:order-supplement:" + businessId;
Boolean firstTry = redisTemplate.opsForValue()
.setIfAbsent(dedupKey, "1", Duration.ofMinutes(30));
if (!Boolean.TRUE.equals(firstTry)) {
log.warn("重复消息,直接丢弃, businessId={}", businessId);
return;
}
try {
process.run();
} catch (Exception e) {
// 业务处理失败时要删除标记,否则该消息后续重试会被幂等挡住
redisTemplate.delete(dedupKey);
throw e;
}
}
这段代码有几个非常值得注意的地方,都是我曾经踩过的:
如果业务处理失败了,但你没有删除 dedupKey,下次队列把同一条消息重新投递过来,这个模板会把它当成重复消息直接丢弃。结果是消息看起来被消费了,实际业务没完成。这就是典型的“幂等把重试吞了”。
所以模板里 catch 异常后先删除幂等 key,再向上抛异常,让外层把消息标记为消费失败并进入重试流程。这条消息再次进来时,dedupKey 不存在,可以正常处理。
另一个坑是 dedupKey 过期时间不能设置太短。经验值是有理数:至少大于“队列同一消息可能重复投递的最长间隔”。如果你设置的过期时间小于业务处理时间,大量并发场景下可能出现两次消费同时通过去重校验,造成重复。也不要设置太长,否则 Redis 会积累大量无用键,内存白白浪费。一般 10 到 60 分钟足以覆盖绝大多数问题排查和重试窗口。
数据库唯一键方案同样有类似细节:插入唯一键记录和业务操作必须在同一个事务里,否则可能出现唯一键插入成功、业务操作失败,导致该消息永远无法重试补账。如果你用的是 Spring 事务,把消息记录表的插入和核心业务表操作放在同一个 @Transactional 方法中即可。
幂等的核心理念很简单:它不是用来防止“重复消息”的,而是用来保证“重复消息不会产生重复影响”。只要设计到位,即使队列的投递语义变成 at least once,你的业务最终状态依然是一致的。
5. 延迟队列:从 Redis ZSet 到 MQ 插件
延迟队列的消息不像普通消息那样立刻被消费者看到,而是要等到指定时间点之后才能被消费。常见业务包括:下单后 15 分钟未支付自动关闭、支付超时提醒、用户七天未使用触发召回、定时活动开始后的状态变更。
热搜词里“mq延迟消息队列”被反复搜索,说明这个问题在实际开发中出现的频率不低。但很多团队一想到延迟队列就直接去配置 RabbitMQ 的延迟插件,或者 RocketMQ 的延迟等级,结果发现根本没有理解延迟队列的本质。
5.1 延迟任务为什么不能靠 sleep 或定时扫描
最原始的做法是:收到“订单 15 分钟后未支付就关闭”这个信号时,开启一个延时任务,Thread.sleep(15 * 60 * 1000),睡醒后检查订单,如果没支付就关闭。这种做法在小规模 demo 里可行,但一旦重启或者任务并发多了就全乱套。进程重启后线程没了,延时任务就没了。
第二种做法是定时任务每分钟扫一次订单表,找出创建时间超过 15 分钟且未支付关闭的订单。这样做问题相对小一些,但订单表大之后,每分钟全表扫描成本很高。即使加索引,数据量大了查询压力也大,时间精度还只能到分钟级。
延迟队列是把延迟任务本身作为消息存下来,到时间后再投递给消费者。它要求队列底层有一个“时间排序”能力:存储时带目标执行时间,消费时只取到期的消息。实现方式可以直接用 Redis 的 ZSet 作为底层结构,也可以依赖 RabbitMQ 的死信交换机或 RocketMQ 的延迟等级。
5.2 Redis ZSet 实现一个轻量延迟队列
用 Redis ZSet 实现延迟队列非常顺手,原理也很直观:
- 插入消息时,把“计划执行时间戳”作为 score,消息内容作为 member;
- 有一个消费进程定时查询 score 小于等于当前时间戳的消息,取出并删除;
- 在分布式环境下,取和删必须原子执行,避免多个消费者同时取出同一条消息。
发送端示例:
java复制@Service
@RequiredArgsConstructor
public class DelayQueueService {
private final StringRedisTemplate stringRedisTemplate;
private static final String DELAY_QUEUE_KEY = "delay:queue:order-close";
public void putDelayTask(String orderId, long delayMillis) {
long executeAt = System.currentTimeMillis() + delayMillis;
// member 建议带上唯一标记,例如 orderId + "_" + UUID
stringRedisTemplate.opsForZSet().add(DELAY_QUEUE_KEY, orderId, executeAt);
}
}
消费端如果只是用 zrangeByScore 取出消息再 zrem,会有一个隐患:两个消费者同时查到同一条 message,两边都当作自己拿到了。所以取消息和删消息必须通过 Lua 脚本在 Redis 端原子完成:
lua复制-- pop_expired_task.lua
local key = KEYS[1]
local now = ARGV[1]
local messages = redis.call("zrangebyscore", key, "-inf", now, "limit", 0, 1)
if #messages == 0 then
return nil
end
local message = messages[1]
redis.call("zrem", key, message)
return message
Java 调用端:
java复制@Component
@RequiredArgsConstructor
public class DelayQueueConsumer {
private final StringRedisTemplate stringRedisTemplate;
private static final String DELAY_QUEUE_KEY = "delay:queue:order-close";
private static final DefaultRedisScript<String> POP_SCRIPT = new DefaultRedisScript<>(scriptText, String.class);
@Scheduled(fixedDelay = 500)
public void pollExpiredTask() {
long now = System.currentTimeMillis();
String task = stringRedisTemplate.execute(
POP_SCRIPT,
List.of(DELAY_QUEUE_KEY),
String.valueOf(now)
);
if (task == null) {
return;
}
// 任务到点,执行关闭订单逻辑
closeOrderIfUnpaid(task);
}
}
这里的 fixedDelay = 500 决定了延迟精度约 500ms,对绝大多数“15 分钟未支付关闭订单”业务完全够用。如果延迟精度要求到毫秒级,这个方案就不合适了,应该把轮询间隔降到 10ms 以内,或者直接依赖 MQ 基础设施。
使用 Redis ZSet 延迟队列有三个必须知道的实际问题:
第一,消息丢失风险。消费端宕机时,已经通过 Lua 脚本把消息从 ZSet 里删掉,但订单关闭逻辑还没执行,这条任务就丢了。要兜底,可以用定时任务扫描业务表作为最终保障,因为真正的支付状态在数据库里,DelayQueue 只是一种辅助触发。
第二,member 必须唯一。同一时间同一个订单不能插入两条 score 不同的相同 member,因为 ZSet 里 member 是唯一的,后插入操作会覆盖原 score,可能导致延迟时间被意外变更。生成 member 时建议带上 UUID。
第三,Redis 集群部署时,使用 Lua 脚本要保证操作的 key 在同一个 slot 中。只要多个延迟队列 key 都加上了统一的 hashtag 前缀,比如 {delay:queue}:order-close,就不会出现跨 slot 阻塞问题。
5.3 MQ 自带的延迟能力怎么选
如果你的项目已经引入了 RabbitMQ 或 RocketMQ,就没有必要自己造延迟队列轮子。
RabbitMQ 实现延迟队列有两条路。一是死信交换机:消息正常发给 A 队列,但 A 队列设置了 TTL,消息过期后变成死信,路由到真正的业务队列。二是使用 rabbitmq-delayed-message-exchange 插件,生产端直接指定延时时间,交换机到时间再把消息投递给队列。第二种用起来更直观,但需要单独安装插件,并保证所有 RabbitMQ 节点都装了同一个插件。
RocketMQ 原生支持延迟消息,但不是任意秒级延迟,而是预设等级,比如 1s、5s、10s、30s、1m、2m、3m、4m、5m、6m、7m、8m、9m、10m、20m、30m、1h、2h。它不支持“15 分钟后执行”这种任意时间粒度,只能选择最接近的延迟等级。
Kafka 本身没有延迟消息能力。标准做法是把业务消息和预计执行时间一起存在外部存储,再由调度器扫描到期消息发送到 Kafka,相当于自己实现一套调度服务。很多人以为 Kafka 是大厂标配,但为了一个延迟队列场景强行用 Kafka,成本并不低。
如果你已经有 RabbitMQ,我会优先建议用延迟插件;如果团队基础设施里只有 Redis,ZSet 方案是最务实的选择。延迟队列不应该成为架构选型的唯一理由,真正的架构理由永远是你们最核心的数据链路跑在哪个系统上。
6. 面试与实战通用的三个硬话题:不丢消息、不乱序、不积压
消息队列的面试题层出不穷,但剥开包装,底层就是三个话题:消息会不会丢、消息会不会乱、消息会不会堆。这三个话题在实战中也同样高频。下面我从代码和架构两个视角讲清楚。
6.1 消息不丢失:按链路逐段检查
消息从业务发起,到最终被消费端处理,中间要经过生产端发送、Broker 存储、消费端拉取三个阶段。任何一段没有做好可靠性设计,消息都可能丢。
为了讲清楚,用一个表格梳理各段的关键配置:
| 阶段 | 丢消息的常见原因 | 对策 |
|---|---|---|
| 生产端 | 网络异常、超时后发送方没有重试 | 开启发送确认机制,发送失败重试,必要时本地消息表兜底 |
| Broker | 消息只存在内存里,宕机丢失;副本数量不足 | 开启持久化;Kafka 设置 replication-factor 大于 1,acks=all |
| 消费端 | 收到消息后先 ack,再处理业务 | 业务成功后才 ack;失败重试而不是直接确认 |
Kafka 里有一个常见的误区:很多人看到 acks=0 觉得“性能很好”,却不知道这是在拿“可能丢消息”换吞吐。要求不丢消息的场景,至少 acks=1,关键场景 acks=all,不要在这个参数上赌运气。
Redis Stream 的情况类似。它默认只存在于内存,必须依赖 Redis 自身的持久化配置。如果你的 Redis 只做缓存并开了 appendonly no,重启之后 Stream 消息基本全丢。既然已经作为业务消息队列使用,就必须开启 AOF 并配置合理的同步策略,或者至少让核心消息通过本地消息表兜底。
消费端最隐蔽的丢消息方式,是“try-catch 里吞掉异常然后 ack”。有些人觉得已经记录日志了,这条消息就算处理完了,不,你只是把异常记下来了,业务流程没有完成。正确的做法是:处理失败不 ack,并进入重试流程;待重试耗尽,再转入死信队列并告警。
6.2 顺序消费:瓶颈不在队列本身,而在业务设计
面试官常问“怎么保证消息的顺序”。简单的回答是“同类消息放入同一个分区/队列,单消费者消费”。但想拿到高分,你需要把问题拆成两层。
第一层是队列模型层。Kafka 天然保证单个 partition 内有序,那你就需要把同一业务主键(比如订单号)通过 hash 规则路由到同一 partition。RabbitMQ 可以用 routing key 让同一类消息进入同一个队列,并保证这个队列只有一个消费者线程依次消费。Redis Stream 中,单个 Stream 在一个消费者组内部按记录顺序派发,只要组内单消费者处理,顺序天然没问题。
第二层是业务层。即使队列有序,消费端也可能因为重试造成乱序。比如订单 A 的“支付成功”消息先被消费,但处理时抛异常,本地重试三次都失败,于是它被丢到死信队列;紧接着“订单关闭”消息后被消费,看到订单状态是未支付,直接关闭订单。这时候“支付成功”的消息从死信队列恢复出来重新执行,却发现订单已经被关闭了。
遇到这种场景,最有效的设计是给业务加上状态校验。处理“支付成功”前先检查订单当前状态,只有它等于“待支付”才处理;处理“关闭订单”前也检查状态,只有它等于“待支付”或“支付中”才允许关闭。状态机校验天然具备防乱序能力,因为任何消息都不能把一个非法状态的订单推到下一步。
如果消息内本身带有业务版本号,也可以在消费时做版本比较:小于等于当前版本的直接忽略,大于当前版本的处理。这一招在消息跨多系统传播时特别有用。
6.3 积压处理:扩容之前先搞清楚瓶颈
消息积压不像丢消息那么隐蔽,它会直接表现为队列长度持续上升,消费者处理不过来。最常见的应对是增加消费者实例。但扩容不是无脑加机器,先想清楚瓶颈在哪里。
如果瓶颈在消费者数量不够,且底层队列的分区数大于当前消费者数,那增加消费者确实可以线性分担负载。Kafka 里如果你只建了 3 个分区,消费者实例加到 10 个,其中 7 个都会空闲,因为它们永远抢不到分区。Redis Stream 的消费者组没有 Kafka 那么强的分区限制,你增加组内消费者也可以分担消息,但单个 Stream 的消费能力仍受 Redis 单实例性能约束。
如果瓶颈在消费者的某个下游调用(比如每次消费要调一次第三方接口,而对方限流100 QPS),那么加再多消费者也只能把上游的负载送到同一个限流点,下游依然兜不住。这时要做的是削峰等待,让消费者拉取速率配合下游能力,而不是无脑并发。
很多人处理积压时会慌,看到队列积压了,立刻停掉消费,重起消费者加多线程,结果业务数据错乱。这里分享一套我验证过多次的处理流程:
- 第一步,暂停该队列新消息的消费,或者先让消费者把消息捞出来暂存到外部存储;
- 第二步,修复消费者代码,导出失败原因;
- 第三步,从最早的消息开始回放,逐条处理;
- 第四步,新消息恢复消费,同时保持新老处理进程隔离观察。
这套流程的本质是“先止血,再排查,后回放”。不管用 Kafka 的 reset offset,还是 Redis Stream 从指定 ID 重新读取,都能复现“重新消费”的能力。如果队列本身没有回溯能力,那才叫真的难处理。
回到最初的话题,消息队列确实是你架构工具箱里一件非常有用的工具,但它并不是魔法。真正的价值要等你在代码里把持久化、ack、消费者组、死信、幂等、延迟这些机制都吃透之后才会释放出来。
最后说点我自己的选型体会。很多团队项目刚起步,没必要一上来就是三节点 Kafka。一个几百万日请求量的系统,复用现有 Redis 用 Stream 做异步链路,结构清晰,运维压力小;等真正出现大数据吞吐、线性扩展和消息审计需求时,再迁移到 KafKa 也不迟。与其纠结用什么中间件,不如先把你手里已经拥有的工具用扎实。用 Redis Stream 把消息队列的核心机制全部跑通一遍之后,再去看 RabbitMQ 和 Kafka,你会发现整个世界豁然开朗。
