1. 从一次半夜告警谈起:为什么需要生产者消费者模型
凌晨两点,手机震动。监控平台显示订单服务响应时间飙到 3 秒,CPU 使用率 100% 但业务线程几乎全部阻塞。我登录服务器看了一眼线程栈,所有工作线程都卡在同一个地方:往消息队列里写入订单数据,队列满了,写不进去,于是线程原地等待。下游的积分服务、短信服务、仓储服务全都慢吞吞地消费,一个订单要拆成十几条数据逐条处理,系统被拖垮了。
那次事故过后,我做的第一件事就是重新设计订单数据的流转链路,把原来的"同步直连下游"改成"生产者消费者模型"。改造后同样的流量水平下,响应时间稳定在 80 毫秒以内,CPU 使用率降到 20% 左右。今天这篇博文就把这个模型彻底讲透,从原理到代码、从单机到分布式、从踩坑到定位,一篇讲完。
生产者消费者模型,本质上解决的是一件事:让速度不匹配的上下游互不阻塞地协作。生产者负责产生数据,消费者负责处理数据,中间通过一个缓冲区解耦。这个模型不只在后端开发里用,消息队列、操作系统的 IO 缓冲、日志采集、流式计算、任务调度,底层全都有它的影子。你学会了它,就拿到了理解一大堆中间件设计思路的钥匙。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 模型本质:解耦、削峰、异步这三件事
2.1 解耦:谁都不该依赖对方的速度
先看最直观的问题。如果生产者和消费者直接对接——生产者调用消费者的接口、写完数据等消费者处理完才返回——那两者的生命周期就紧紧绑在一起。消费者服务挂掉,生产者直接报错;消费者变慢,生产者也跟着变慢;消费者要升级,生产者就得停机配合。
引入一个中间缓冲区之后,生产者只往缓冲区里丢数据,不再关心数据接下来被谁拿走、什么时候被拿走、处理结果如何。消费者只从缓冲区里取数据,也不关心数据是哪个生产者产生的、产生频率多高。两者唯一的约定就是缓冲区的数据结构。这种解耦让系统可以独立演进,消费者哪怕连续发布三个版本,生产者一行代码都不用动。
2.2 削峰:缓冲区是天然的水库
生产者的产出速度往往有尖峰。秒杀活动刚开始那一分钟,订单量可能是平时的上百倍。如果让后端的各个系统直接硬抗这个尖峰,机器配置要按峰值去采购,平时就大量浪费。加了缓冲区后,生产者可以把高峰期的数据暂时堆积在缓冲区里,消费者按自己稳定的速度慢慢消费。高峰期把水库蓄满,低峰期把水库放空。
但这里必须提醒一句:削峰不等于数据不丢。缓冲区容量是有限的,如果生产者持续产出超过消费者消费能力,缓冲区最终会被填满,后续生产怎么办,是做阻塞等待、丢弃数据还是拒绝生产,这一步的决策直接决定系统的数据可靠性等级。这个细节我在后面专门讲。
2.3 异步:调用方不等结果,系统吞吐才上得去
同步调用里,一个请求要等所有子任务全部完成才能返回。假设一个订单要做库存扣减、积分累计、短信通知,三个操作各耗 200 毫秒,同步执行就是 600 毫秒。改成生产者消费者模型后,订单主流程只负责把库存扣减做完(这是核心强一致操作),然后把积分、短信这些次要操作封装成消息丢到缓冲区,立刻返回。用户感知到的下单时间大幅缩短,系统的整体吞吐也随之提升。
异步意味着什么?意味着用户体验优先,次要操作允许延迟完成。短信晚到 5 秒用户基本无感,积分晚入账 30 秒也没人投诉,但下单接口如果多等 400 毫秒,用户可能就跳去别家了。所以做异步设计前要自己先想清楚:哪些操作必须同步保证强一致,哪些操作可以接受最终一致。想不清楚就把所有操作都异步化,最后一定会出现对不上账的脏数据。
3. 单机落地:用 Java 手写一个线程安全的生产者消费者
3.1 最经典的实现:wait/notify + 链表缓冲区
先看最简单的版本。缓冲区用 LinkedList 实现,通过 synchronized 保证线程安全,用 wait 和 notifyAll 实现阻塞与唤醒。这段代码是理解整个模型的基石,面试和工作里提到的"手写生产者消费者"基本都是它。
java复制import java.util.LinkedList;
import java.util.Queue;
public class ProducerConsumerDemo {
private static final int CAPACITY = 10;
private static final Queue<Integer> BUFFER = new LinkedList<>();
static class Producer extends Thread {
@Override
public void run() {
int value = 0;
while (true) {
synchronized (BUFFER) {
while (BUFFER.size() == CAPACITY) {
try {
BUFFER.wait();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
BUFFER.offer(value);
System.out.println("生产: " + value + ", 当前容量: " + BUFFER.size());
value++;
BUFFER.notifyAll();
}
try {
Thread.sleep(10);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
}
static class Consumer extends Thread {
@Override
public void run() {
while (true) {
synchronized (BUFFER) {
while (BUFFER.isEmpty()) {
try {
BUFFER.wait();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
int value = BUFFER.poll();
System.out.println("消费: " + value + ", 剩余容量: " + BUFFER.size());
BUFFER.notifyAll();
}
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
}
public static void main(String[] args) {
new Producer().start();
new Consumer().start();
}
}
这段代码里有几个关键细节值得反复琢磨。
第一个是为什么判断条件用 while 而不是 if。如果用 if,线程被唤醒后不会重新检查条件,直接往下走。假设缓冲区满了,生产者 A 和 B 都阻塞在 wait 上,消费者取走一个元素后 notifyAll,A 和 B 同时被唤醒,A 抢到锁先往缓冲区放入一个元素,缓冲区又满了。B 拿到锁后不再检查条件,也往里放,数据就覆盖了。用 while 的好处是,线程被唤醒后必须重新确认缓冲区确实有空位,才能继续生产,多了一次判断,杜绝了"虚假唤醒"问题。
第二个是notifyAll 和 notify 的选择。只用 notify 随机唤醒一个线程,如果唤醒的是同类线程(生产者唤醒生产者),被唤醒的线程发现条件不满足又重新 wait,而真正应该被唤醒的消费者始终没被唤醒,系统就死锁了。notifyAll 把所有人唤醒,各自重新检查条件,虽然效率低一点,但绝对不会死锁。单机教学场景用 notifyAll 稳妥,生产环境有更好的工具,下面会说。
第三个是sleep 的位置。这段演示代码里 sleep 放在 synchronized 代码块外面,这是刻意的。如果在锁内 sleep,持有锁睡觉,整个系统就是串行执行,生产者消费者模型的性能优势就完全没了。
3.2 生产级替代:BlockingQueue 一行搞定
实际工作中很少直接手写 wait/notify,Java 并发包已经提供了现成的阻塞队列,其中 ArrayBlockingQueue 和 LinkedBlockingQueue 是最常用的两个。它们内部已经完成了锁管理和条件队列的封装,用起来几乎是零成本。
java复制import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
public class BlockingQueueDemo {
private static final BlockingQueue<Integer> QUEUE = new ArrayBlockingQueue<>(10);
static class Producer implements Runnable {
@Override
public void run() {
int value = 0;
while (true) {
try {
QUEUE.put(value);
System.out.println("生产: " + value + ", 队列大小: " + QUEUE.size());
value++;
Thread.sleep(10);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
}
}
static class Consumer implements Runnable {
@Override
public void run() {
while (true) {
try {
int value = QUEUE.take();
System.out.println("消费: " + value + ", 队列大小: " + QUEUE.size());
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
}
}
public static void main(String[] args) throws InterruptedException {
Thread p1 = new Thread(new Producer(), "producer-1");
Thread c1 = new Thread(new Consumer(), "consumer-1");
p1.start();
c1.start();
p1.join();
c1.join();
}
}
put 方法在队列满时阻塞等待,take 方法在队列空时阻塞等待,语义和手写版的 wait/notify 完全一致,但实现的健壮性比我那段代码高一大截。ArrayBlockingQueue 用单一锁配合两个 Condition 实现,LinkedBlockingQueue 用两把锁分别锁队头和队尾,吞吐更高。选型规则很简单:容量确定、不需要动态扩容,用 ArrayBlockingQueue;流量波动大、想避免数组扩容问题,用 LinkedBlockingQueue。
3.3 多消费者场景下的线程池写法
真实系统里消费者几乎不会只有一个线程。订单处理、日志分发这类业务,往往是消费者线程池从队列里拉取任务并行处理。把上面例子里的 Consumer 直接变成线程池里的 worker,逻辑一样,但需要注意线程池的任务队列和模型缓冲区的职责边界。
一种常见的错误设计是:先用阻塞队列做缓冲区,消费端拿到消息后又提交给一个 ExecutorService,而 ExecutorService 内部自带一个任务队列。两级队列叠在一起,参数一多就难排查。更干净的做法是让阻塞队列直接充当线程池的任务队列,消费线程池内部使用这个队列作为工作队列,不需要再额外拉一次。不过这样写的缺点是缓冲区的容量和线程池的排队策略耦合在一起,需要统一考虑。
扯远一点,Disruptor 为什么比 BlockingQueue 快,原因就是它用环形数组配合 CAS 替代了锁,避免了线程上下文切换和锁竞争。JDK 的 BlockingQueue 在高并发下锁竞争很严重,Disruptor 通过预分配内存、批量消费、伪共享避免等手段把吞吐推到千万级。如果生产者消费者模型是你系统的核心链路,吞吐要求极高,Disruptor 是值得研究的替代品。但对大部分业务系统,LinkedBlockingQueue 的吞吐已经够用,盲目上 Disruptor 反而增加维护成本。
4. 分布式版本:消息中间件把缓冲区搬到独立进程
4.1 为什么单机阻塞队列撑不住生产环境
单机模型有一个先天的容量上限:缓冲区在 JVM 堆内存里,进程一重启,队列里的数据全部丢失。生产者消费者接口如果分段在两个进程里,进程间通信就得靠网络,这一下就引入了三个新问题:数据持久化、网络可靠传输、消费失败重试。
这些问题逐个解决的成本很高,所以业界用了几十年堆出了一类独立的基础设施——消息中间件。Kafka 负责高吞吐日志和流式数据,RocketMQ 负责电商交易类消息的可靠投递,RabbitMQ 适合灵活的路由策略。它们的目标都是同一个:把生产者消费者模型里的缓冲区抽出来,做成一个独立的、分布式的、高可用的服务。
4.2 用 Kafka 落地一个生产者消费者
先看最基础的 Kafka 生产者消费者代码。假设业务场景是:订单服务产生订单事件,积分服务和短信服务各自消费。
生产者在订单创建成功后发送一条消息到 order-events 主题:
java复制import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.clients.producer.RecordMetadata;
import java.util.Properties;
import java.util.concurrent.Future;
public class OrderEventProducer {
private final KafkaProducer<String, String> producer;
public OrderEventProducer() {
Properties props = new Properties();
props.put("bootstrap.servers", "192.168.1.10:9092,192.168.1.11:9092");
props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
// acks=all 表示分区副本全部确认才算发送成功,可靠性最高
props.put("acks", "all");
props.put("retries", 3);
producer = new KafkaProducer<>(props);
}
public void sendOrderEvent(String orderId, String eventType) {
String payload = "{\"orderId\":\"" + orderId + "\",\"eventType\":\"" + eventType + "\"}";
ProducerRecord<String, String> record = new ProducerRecord<>("order-events", orderId, payload);
try {
Future<RecordMetadata> future = producer.send(record);
// 业务上通常不需要阻塞等待,这里演示如何获得发送结果
RecordMetadata metadata = future.get();
System.out.println("消息发送成功, offset: " + metadata.offset());
} catch (Exception e) {
// 需要告警和重试,否则消息可能丢失
System.err.println("消息发送失败, orderId: " + orderId + ", error: " + e.getMessage());
}
}
public void close() {
producer.close();
}
}
消费端用 KafkaConsumer 手动控制 offset(偏移量):
java复制import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.clients.consumer.KafkaConsumer;
import java.time.Duration;
import java.util.Collections;
import java.util.Properties;
public class SmsConsumer {
private final KafkaConsumer<String, String> consumer;
public SmsConsumer() {
Properties props = new Properties();
props.put("bootstrap.servers", "192.168.1.10:9092,192.168.1.11:9092");
props.put("group.id", "sms-consumer-group");
props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
props.put("enable.auto.commit", "false");
props.put("auto.offset.reset", "earliest");
consumer = new KafkaConsumer<>(props);
consumer.subscribe(Collections.singletonList("order-events"));
}
public void run() {
while (true) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<String, String> record : records) {
try {
// 这里真正发送短信
System.out.println("发送短信: " + record.value());
// 处理成功后手动提交 offset,确保不丢数据
consumer.commitSync();
} catch (Exception e) {
// 处理失败,不提交 offset,等待下次拉取重试
System.err.println("短信发送失败: " + record.value() + ", error: " + e.getMessage());
}
}
}
}
public static void main(String[] args) {
new SmsConsumer().run();
}
}
这段代码里最容易踩坑的是 offset 处理。启用自动提交时,Kafka 客户端在 poll 后就会自动提交当前消费位置,如果业务处理逻辑比较慢、在提交前进程崩溃,就会重复消费;如果处理完成前就提交了 offset 但消息实际还没处理完,就会丢消息。建议生产环境关闭自动提交,处理成功后手动 commitSync,用"至少一次"语义换业务安全。
4.3 消费组与分区:分布式模型的核心抽象
Kafka 里一个消费组内的消费者共同消费一个主题的所有分区,每条消息只会被组内一个消费者实例处理。组内有 3 个消费者、主题有 6 个分区,每个消费者分到 2 个分区;如果只有 1 个消费者,它要处理全部 6 个分区。分区数量的设定既决定并行度,也决定消息顺序性。
这里有个经典的取舍:同一订单的多个事件(创建、支付、发货)如果进了不同的分区,就会被不同消费者并行处理,顺序就乱了。 解决办法是在生产端指定 key,Kafka 保证同一个 key 的消息进入同一个分区并被同一个消费者按顺序消费。上面代码里我用 orderId 作为消息 key,正是这个原因。
分布式模型里消费者实例数超过分区数时,多余的消费者会空闲,造成资源浪费。消费者数量应该小于等于分区数,并且要预留一定的扩容余量。比如 6 个分区的主题,初期 3 个消费者就能扛住日常流量,大促时临时扩到 6 个。
5. 最关键的参数设计:队列容量、消费能力与拒绝策略
5.1 容量设计:不是越大越好
很多人在设计缓冲区容量时习惯拍脑袋,觉得"队列越大越能扛压"。但分区或者阻塞队列的容量直接和两个风险挂钩:数据堆积带来的延迟,以及系统宕机时的数据丢失量。
假设订单消息都被持久化到 Kafka,容量主要受磁盘和分区数影响。Kafka 的保留策略默认是"按时间删除",比如保留 7 天,超过 7 天的数据自动清理。如果你的消费能力长期跟不上生产速度,消费 lag 持续增长,最终会把磁盘填满。监控 lag 是分布式模型运维的必修课。
单机阻塞队列的容量设计则更敏感。队列里堆积的是对象引用,如果每个元素是 1KB,队列容量 10 万就是 100MB。堆内存吃紧会导致 Full GC 频发,反而拖垮整体性能。我一般会按"消费者处理能力的 2 到 3 倍"来估算初始容量,再根据压测结果调整。
5.2 消费能力公式:算清楚需要多少个消费者
假设系统高峰期每秒产生 5000 个订单事件,每个消费者线程每秒能处理 200 个事件,理论需要的消费者线程数是 25。但实际中消费者要处理重试、网络抖动、业务逻辑的偶发慢调用,直接按峰值除单消费者吞吐算出的数量并不保险。务实的做法是:
- 按峰值流量算,再乘 1.5 到 2 的冗余系数
- 用压测验证,观察消费者的空闲率,如果 CPU 和 IO 一直打满,就需要扩容
- Kafka 场景下,消费者的并行上限是分区数,需要提前把分区数规划到预期的最大并行度
分区数规划错了很被动,因为 Kafka 的分区数只能增加不能减少,增加分区还会触发 rebalance,影响消费过程中的可用性。
5.3 队列满时的四种拒绝策略:各自的适用场景
单机 BlockingQueue 配合线程池时,队列满后执行拒绝策略。JDK 提供了四种:
- AbortPolicy:直接抛出 RejectedExecutionException,调用方感知到失败。适合流量过大时快速失败,保护系统不被打垮。
- CallerRunsPolicy:让提交任务的线程自己执行任务。相当于一种天然的背压机制,生产者和消费者速度自动对齐。适合不想丢任务,但可以接受生产者变慢的场景。
- DiscardPolicy:默默丢弃任务。适合可容忍丢弃的日志采集类场景。
- DiscardOldestPolicy:丢弃最老的任务。适合任务时效性要求极高的场景,比如实时行情推送,旧数据价值低。
我在订单链路里常用 CallerRunsPolicy,因为订单事件不能丢,宁可让主线程多花时间同步处理,也不能丢失数据。日志采集场景用 DiscardOldestPolicy,因为最新日志永远比旧日志有价值。
6. 实战排障:一个消费延迟事故的完整排查链路
6.1 现象:lag 从 0 涨到 5000,消费端 CPU 却很低
有一次大促预热期间,监控面板显示某个核心主题的消费者 lag 从 0 一路涨到 5000,还在持续增长。奇怪的是消费端应用的 CPU 占用率只有 15%,线程数也正常。如果消费能力饱和,CPU 应该打满才对,这个现象说明消费者大概率在等待什么。
登录服务器后我做了四件事:
- 看线程栈,发现大量消费线程 Blocked 在
java.net.SocketInputStream.socketRead0上 - 查 MySQL 慢查询日志,有一条 SQL 执行了 4 秒,锁等待严重
- 查 DBA 提供的 InnoDB 锁信息,一个事务长时间持有行锁未提交
- 顺着代码找,发现消费逻辑里对用户余额表的更新操作依赖了下游账户系统的 RPC 调用,RPC 超时设置 3 秒,而账户系统当时正在做灰度发布,部分接口响应变慢
6.2 根因:消费逻辑里套了一次同步 RPC,把整个消费线程堵死了
问题本质是消费端处理业务时执行了一次同步远程调用,这个 RPC 一慢,消费线程就停滞。Kafka 拉取消息后必须等该消息处理完成才能提交 offset,继续拉下一批。一个线程被堵,消费者整体吞吐骤降,lag 自然飙升。
6.3 修复方案:三步走
修复分了三次迭代:
- 第一步:给 RPC 调用加上超时和熔断。RPC 超过 500 毫秒直接返回默认值,避免线程无限期等待。
- 第二步:把可异步化的远程操作(比如更新用户画像)改为发送到另一个 Kafka 主题,由独立消费者处理。消费线程只做本地数据库操作,不再依赖远程服务。
- 第三步:增加告警指标。除了监控 lag,还监控单条消息的平均处理耗时和消费者线程的阻塞时间,出现异常马上定位到具体代码。
这个事故给我的教训是:消费者里的业务逻辑必须尽量轻,所有可能变慢的远程依赖都要拆出去。消费线程和 RPC 调用耦合在一起,就像一条流水线上有人停下来打电话,整条线都会堵住。
6.4 补充一个容易忽略的坑:消费端幂等设计
由于采用"至少一次"语义,消息重复消费是必然的。Redis 里用 SETNX 做幂等标记、数据库里用唯一索引约束业务单据号、或者本地记录已处理消息 ID,三种方案都能用。我通常选择数据库唯一索引,因为业务数据最终落在 DB,唯一索引顺带提供了幂等能力,代价最低。
7. 模型变体与进阶实践:批处理、背压、死信队列
7.1 批量消费:吞吐翻倍的关键
Kafka 消费端默认一次 poll 可能返回多条记录,但业务代码逐条处理。逐条处理在数据库写入场景下浪费了批量提交的机会。改为批量处理后,效果立竿见影:把多次单条插入合并成一次 batch insert,吞吐提升了 3 倍。
RocketMQ 的批量消费更明显,官方文档建议一次消费 32 条消息再整体处理。批量处理的核心收益是减少网络往返和数据库提交次数,用一次操作的固定开销摊薄到多条消息上。
7.2 背压机制:让上游感知下游的压力
缓冲区的容量是有限的,如果生产者不加控制地疯狂生产,最终缓冲区会被填满。这时候有几种做法:
- 阻塞式背压:生产者 put 时队列满就阻塞,上游自然放慢速度。单机 BlockingQueue 天然支持这一点。
- 丢弃策略:队列满时丢弃新数据或旧数据。适合监控指标、日志等对短暂丢失容忍度高的场景。
- 限流反馈:消费者把当前积压量作为指标上报,生产者在积压超过阈值时主动降速。Kafka 的 lag 监控配合动态调整生产速率,就是一种反馈式背压。
做流式计算时 FLink 也提供了自动背压机制,当下游处理不过来时通过反压信号让上游放慢生产速度。理解单机模型的阻塞背压,再看 Flink 的背压原理就非常顺,本质上都是同一件事。
7.3 死信队列:处理"毒丸"消息
消费失败时通常有重试机制,但一条消息如果永远处理失败,反复重试只会浪费资源、阻塞后续消息。专门的死信队列用来接收这类消息,消费者尝试 N 次失败后投递到死信队列,再由人工或者定时任务去排查。
RocketMQ 内置了死信队列,配置简单;Kafka 没有原生死信队列,需要自己创建 DLT 主题,在消费端捕获异常后转发到 DLT。实践中我在死信消息里补充原始消息、失败原因、失败时间三个字段,方便后续排查。
7.4 结合虚拟线程:新 JDK 下的生产者消费者写法
JDK 21 以虚拟线程正式落地,给生产消费者模型带来了一种新的写法。虚拟线程极其轻量,可以一个业务请求开一个虚拟线程,不用再过度复用线程池。把阻塞队列放入虚拟线程池,配合 BlockingQueue,代码比传统线程池模式更易读,处理吞吐也足够。但要注意虚拟线程并不是银弹,CPU 密集型的消费逻辑依然受 CPU 核数限制,虚拟线程只解决了线程阻塞带来的资源浪费问题。
8. 选型决策:单机队列还是消息中间件?一张表说清楚
日常开发里最高频的困惑是"我这个场景到底该用 BlockingQueue 还是上 Kafka"。我按场景整理了一张表:
| 维度 | 单机 BlockingQueue | 消息中间件(Kafka/RocketMQ/RabbitMQ) |
|---|---|---|
| 数据可靠性 | 进程重启即丢失 | 持久化存储,可配置多副本 |
| 吞吐量 | 单机百万级 | 分布式水平扩展,千万级以上 |
| 缓冲区容量 | 受 JVM 堆内存限制 | 受磁盘容量限制 |
| 跨进程通信 | 不支持 | 原生支持 |
| 运维成本 | 无额外组件 | 需要维护 Broker 集群 |
| 消费模式 | 内存内竞争消费 | 消费组、分区、广播多种模式 |
| 适用场景 | 进程内异步解耦 | 跨服务解耦、削峰填谷、大数据管道 |
决策准则很直接:如果你的生产者和消费者在同一个 JVM 里、数据丢了无所谓、量级在百万以内,用 BlockingQueue 完全够;只要跨进程、要求高可靠、需要消费组横向扩展,就上消息中间件。
很多团队一上来就把所有异步场景都丢给 Kafka,结果引入大量网络开销和运维复杂度。一个服务内部两个模块之间解耦,用内存队列就够了,没必要为了"统一技术栈"增加一条 MQ 依赖。技术选型首先是权衡成本,不是追求时髦。
9. 写在最后:一次消费改造的完整复盘
回到开头的那个凌晨事故。我当时把订单服务的异步处理从"同步调用下游接口"改成了"用户下单时只落库,把订单事件发送到 Kafka",下游的积分、短信、仓储服务各自独立消费。改动并不复杂,但效果显著:下单接口从 600 毫秒降到 80 毫秒,线下游服务的性能问题被隔离在自己的消费逻辑里,最多造成该服务消费滞后,不会拖垮订单主链路。
那次改造也让我养成了一个习惯:任何引入生产者消费者的环节,都要提前写清楚三个参数——队列容量、消费者实例数、失败处理策略,并且写进设计文档。这三个参数是模型能否正常运行的核心,也是后续排查问题时的第一参照系。
个人经验是,生产消费者模型不是学会概念就算完,真正吃透的标志是能回答这几个问题:队列满了怎么办?消费者挂了消息会不会丢?消息重复了怎么处理?消费太慢怎么扩容?把它们都想明白了,这个模型才算真正长在你身上。以后不管是调优 Kafka 还是设计一个新系统,你会发现很多选择都是同一个模型在不同尺度上的变形。
