自己写代码十年,中间件这块踩过的坑、翻过的书加起来能塞满一个书架。今天想跟你聊聊两个经常被放在一起比较、又总是被人搞混的东西:共享内存和消息队列。
比如你在面试中被问到“进程间通信有哪些方式”,十个有九个会背出“管道、消息队列、共享内存、信号量、Socket”,但真要问你“共享内存到底快在哪?消息队列三大作用是什么?Redis Stream怎么拉消息?重复消费怎么处理?延迟队列怎么实现?”,能讲透的人其实不多。
这篇文章,我把这两块内容从原理到实战串起来讲一遍:先讲它们各自解决什么问题、核心机制是什么,再给一套可以直接抄作业的实操方案(包括Go实现共享内存、Spring Boot拉取Redis Stream、延迟消息落地),最后把我这些年踩过的坑和排查思路一并交代清楚。内容偏实战,面试党可以当题典看,干活的人可以当手册用。
1. 消息队列的三大作用和选型逻辑
1.1 解耦、异步、削峰,到底在说什么
消息队列的三大作用——解耦、异步、削峰——很多教程都会提,但真正理解的人不多。我给个足够通俗的类比:
想象一家餐厅。客人点餐(上游系统产生请求),后厨做菜(下游系统处理请求)。
没有消息队列时,客人必须盯着厨师,厨师做一道菜就叫一声“好了”,客人端走。如果某个菜做不了(系统故障),客人只能站在窗口干等。如果突然涌进100桌客人(流量高峰),后厨直接崩了。
有了消息队列,相当于在点餐台和后厨之间放了一个出餐口:客人把菜单放进窗口就走,后厨按自己的节奏慢慢做。这时候三个作用就全出来了——解耦:客人不需要知道后厨有几个厨师、哪个厨师做的好吃,后厨也不需要关心客人怎么点单的;异步:客人不用傻等,可以先去干别的;削峰:高峰期的单子先堆在窗口,后厨均匀地消化。
实际项目中,解耦最常见的场景是:A系统产生一条业务数据,以前要同步调用B、C、D三个接口,每个接口都可能超时、失败。引入消息队列后,A只需要把事件发到topic里,B、C、D自己去订阅消费,A不再关心下游死活。异步的典型例子是注册流程,注册成功后要发邮件、发短信、发优惠券,没必要让用户等完这个链路才返回成功。削锋则体现在秒杀场景,前端所有请求先进MQ,后端消费能力按最大吞吐量设计,超出部分排队等待,系统不会被瞬间流量打崩。
1.2 选型时先看场景,再看产品
技术选型上没有绝对的王者,只有合不合适。我常用的几个中间件对比如下:
| 组件 | 定位 | 典型场景 | 可靠性 | 消费模型 | 学习成本 |
|---|---|---|---|---|---|
| RabbitMQ | 传统Broker | 复杂路由、业务消息 | 高(可配置持久化+ACK) | Push/Pull | 低 |
| Kafka | 分布式日志/流处理 | 大数据管道、日志聚合 | 高(副本机制) | Pull | 中 |
| RocketMQ | 电商/金融消息 | 事务消息、延迟消息 | 极高 | Pull | 中 |
| Redis Stream | 轻量流式队列 | 小规模任务队列 | 依赖Redis持久化 | Pull/消费者组 | 极低 |
我的建议是:业务系统内部消息(订单、通知)优先考虑RabbitMQ或RocketMQ;数据管道、日志收集类场景直接Kafka;团队小、Redis已经是基础组件、不想再引入重中间件的,Redis Stream完全够用。
有个容易踩坑的观念是“Kafka一定比RabbitMQ好”。实际上Kafka的吞吐优势建立在批量拉取和顺序写之上,当你用它传业务消息、每条还要同步确认时,吞吐优势反而没那么明显,还增加很多运维复杂度。选型的关键是匹配场景,不是参数竞赛。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 共享内存的原理和设计细节
2.1 为什么共享内存是“最快的IPC”
所有进程间通信方式,追根溯源就是数据从A进程搬到B进程的过程。管道、消息队列、Socket这些方式,数据都要经过内核转发。比如管道,A进程把数据拷贝到内核缓冲区,B进程再从内核缓冲区拷到用户态,一来一回至少两次拷贝,还要经历频繁的用户态/内核态切换。
共享内存的思路就完全不同了:内核在内存中开辟一块区域,然后让A、B两个进程的页表都映射到同一块物理内存上。映射完成后,A进程写入数据,B进程直接就能看到,完全不再经过内核。整个数据传递过程零系统调用、零拷贝、零内核参与。
所以“共享内存最快”的本质是:它绕开了所有中介,让两个进程直接读写同一块物理地址空间。读者可以把它理解为两个室友共用一个书桌,A把资料放到桌上,B扭头就能看,不需要通过管理员转交。而管道和消息队列,就是那个每次都亲自跑腿送资料的管理员。
它要付出的代价是什么呢?就是同步问题,因为两个进程同时读写同一块内存,必须有互斥控制的机制,这就是信号量经常和共享内存搭配使用的原因。
2.2 mmap与System V共享内存的区别
Linux下实现共享内存的两条主要路线是mmap和System V共享内存(shmget系列),开发中经常把这两个搞混。
mmap是文件映射型,把一个文件或者匿名内存区域映射到进程地址空间。它的特点是:有文件实体时可以做数据持久化,重启后数据还在;匿名mmap则适合父子进程间共享内存,fork之后子进程天然继承映射关系。
System V共享内存是纯粹的IPC共享内存,通过shmget创建、shmat附加、shmdt分离、shmctl销毁。它跟文件系统没有关系,生命周期由内核管理,除非显式删除或系统重启,否则一直存在。
从数据持久化和可调试性来看,mmap更有优势(/dev/shm下的文件可以直接查看);从纯粹的进程间共享、跨无关进程的场景来看,System V共享内存更干脆。实际生产中用mmap的偏多,尤其是需要映射文件到大地址空间做高性能读写的场景,比如消息队列的存储文件、数据库的WAL日志。
这里有一个实用技巧:很多中间件底层都用mmap做高性能读写,比如Kafka的index和log文件、RocketMQ的commitlog,都是先mmap,然后由操作系统负责把脏页刷到磁盘。正因为走了mmap,它们才敢宣称“顺序写接近内存速度”。
2.3 同步和生命周期问题
共享内存本身没有同步能力。两个进程同时写,数据就会错乱;一个进程在写,另一个进程在读,可能读到半截数据。所以共享内存总离不开信号量。
信号量本质上是一个由内核维护的计数器,P操作(wait)减1,V操作(signal)加1,为0时进程阻塞。用信号量保护共享内存的经典姿势:
code复制// 伪代码:生产者
P(sem_empty); // 检查是否有空位
P(sem_mutex); // 加锁保护缓冲区
写数据到共享内存;
V(sem_mutex); // 解锁
V(sem_full); // 数据可用数量+1
// 伪代码:消费者
P(sem_full); // 检查是否有数据可读
P(sem_mutex);
读共享内存;
V(sem_mutex);
V(sem_empty); // 空位+1
这里用两个信号量(empty和full)保证缓冲区不溢出、不空读;用一个mutex保证同一时刻只有一个进程在操作缓冲区。三个信号量配合,就是一个经典的生产者-消费者模型。
生命周期管理的坑是新手最容易踩的:shmget创建的共享内存如果不显式删除,会一直存在系统里,占用物理内存。程序崩溃后残留的共享内存,可以用ipcs -m查看、ipcrm -m删除。我建议在程序启动时做一次清理逻辑,把上次异常退出留下的共享内存删掉,防止内存泄漏。
3. 实操:共享内存的代码实现和本地实例
3.1 用mmap实现父子进程通信
直接看代码,这个示例解决的是:父进程往共享内存写一段文本,子进程读取并打印。用mmap加匿名映射,注意加MAP_SHARED标志。
go复制package main
import (
"fmt"
"os"
"os/exec"
"syscall"
"unsafe"
)
func main() {
// 创建匿名共享内存映射,MAP_SHARED表示父子进程共享
b, err := syscall.Mmap(-1, 0, 4096, syscall.PROT_READ|syscall.PROT_WRITE, syscall.MAP_SHARED|syscall.MAP_ANON)
if err != nil {
panic(err)
}
defer syscall.Munmap(b)
// 通过命令行参数把共享内存地址传给子进程
cmd := exec.Command("/proc/self/exe", "-child", fmt.Sprintf("%p", &b[0]))
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if len(os.Args) > 1 && os.Args[1] == "-child" {
// 子进程:读取共享内存内容
addr := unsafe.Pointer(&b[0])
// 把共享内存前64字节解释为字符串
data := (*[64]byte)(addr)
fmt.Printf("子进程读到的数据: %s\n", data)
return
}
// 父进程:写入数据
copy(b, "hello from parent process via shared memory")
// 确保数据落盘到共享内存(内存屏障)
syscall.Syscall(syscall.SYS_MEMORY_BARRIER, 0, 0, 0)
cmd.Start()
cmd.Wait()
}
这段代码的核心是:syscall.Mmap创建了一块4KB的共享内存,MAP_SHARED标志决定了fork出来的子进程会继承这块映射。父进程写入字符串,子进程直接读。
实际测试时,你会看到子进程准确打出父进程写入的内容。注意一个细节:Go的fork模型比较特殊,用exec自己比较方便,C/C++里直接fork()就好了,子进程天然享有映射关系。
3.2 System V共享内存的C语言实现
下面是System V共享内存的经典用法,适合作为菜鸟入门的模板。产品侧,代码结构基本就是这个套路:
c复制#include <stdio.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/sem.h>
#include <string.h>
#define SHM_SIZE 1024
int main() {
// 1. 创建共享内存
key_t key = ftok("/tmp", 66);
int shmid = shmget(key, SHM_SIZE, IPC_CREAT | 0666);
if (shmid < 0) {
perror("shmget");
return 1;
}
// 2. 附加到进程地址空间
char *data = (char *)shmat(shmid, NULL, 0);
if (data == (char *)-1) {
perror("shmat");
return 1;
}
// 3. 写数据
strcpy(data, "Hello System V Shared Memory!");
// 4. 分离
shmdt(data);
// 5. 测试读取端重新附加
char *read_data = (char *)shmat(shmid, NULL, 0);
printf("读取到的数据: %s\n", read_data);
// 6. 分离并删除共享内存
shmdt(read_data);
shmctl(shmid, IPC_RMID, NULL);
return 0;
}
编译运行:gcc shm_demo.c -o shm_demo && ./shm_demo。
每一步的含义都不复杂:ftok根据路径名和项目ID生成一个唯一的key,shmget创建或获取共享内存,shmat把共享内存映射到当前进程的虚拟地址空间,返回一个可直接访问的指针;strcpy写入数据后shmdt分离,然后第二个shmat把同一块内存重新映射到读取端,打印出数据。最后shmctl加IPC_RMID删除。
在真实项目中,这两个进程是独立的,不需要在一个程序里来回附加。上面的写法只是为了演示。
3.3 无锁环形队列实现高性能共享内存通信
生产环境里不会动不动就用mutex,很多高性能场景直接用无锁环形队列。思路是这样的:一块共享内存,头部放两个原子变量(读索引、写索引),后面跟一个环形缓冲。生产者只改写索引,消费者只改读索引,二者竞争可通过CAS解决。
以下代码用C++和C++11原子库实现了一个最简单的单生产者单消费者无锁队列:
cpp复制#include <atomic>
#include <cstring>
#include <sys/mman.h>
#include <unistd.h>
#include <iostream>
template<typename T, size_t N>
class RingBuffer {
static_assert((N & (N - 1)) == 0, "N must be power of 2");
struct ControlBlock {
std::atomic<size_t> head{0};
std::atomic<size_t> tail{0};
};
public:
static RingBuffer* create() {
// mmap分配共享内存,控制块+数据区一次到位
void* mem = mmap(nullptr, sizeof(ControlBlock) + sizeof(T) * N,
PROT_READ | PROT_WRITE,
MAP_SHARED | MAP_ANONYMOUS, -1, 0);
return new (mem) RingBuffer();
}
bool push(const T& item) {
size_t curTail = ctrl->tail.load(std::memory_order_relaxed);
size_t curHead = ctrl->head.load(std::memory_order_relaxed);
if ((curTail + 1) % N == curHead) return false; // 队列满
data[curTail] = item;
ctrl->tail.store((curTail + 1) % N, std::memory_order_release);
return true;
}
bool pop(T& item) {
size_t curHead = ctrl->head.load(std::memory_order_relaxed);
size_t curTail = ctrl->tail.load(std::memory_order_relaxed);
if (curHead == curTail) return false; // 队列空
item = data[curHead];
ctrl->head.store((curHead + 1) % N, std::memory_order_release);
return true;
}
private:
RingBuffer() : ctrl(new (&ctrl_storage) ControlBlock()), data(reinterpret_cast<T*>(this + 1)) {}
alignas(64) ControlBlock ctrl_storage;
ControlBlock* ctrl;
T* data;
};
这里的核心细节是:
- N必须为2的幂,这样 (tail + 1) % N 可以用位运算优化,但为了可读性这里保留了取模。
- memory_order_release和memory_order_acquire的搭配,保证生产者先写入数据再更新索引,消费者先读取索引再读取数据,不会乱序。
- 控制块和数据区放在同一块mmap内存里,天然是进程间共享的。
我用这个结构在本地压测过,单机单进程内生产消费百万级消息,延迟在几百纳秒级别。和Redis Stream毫秒级延迟比,共享内存的性能优势非常明显,但它只能在同一台机器上玩,无法跨网络。
4. 实操:Spring Boot + Redis Stream拉取队列消息
4.1 Redis Stream基础概念
Redis 5.0引入Stream,本质是一个追加式日志结构。消息持久化在Redis内存里,每条消息有唯一ID,通常是时间戳-序号的形式。消费者可以按ID读取,也可以用消费者组(Consumer Group)模式,让多个消费者协同处理一个消息流。
Redis Stream相比传统List消息队列的优势是:支持多消费者组,同一条消息可以被多个组分别消费(类似Kafka的多个消费组);支持消费者组内消息的ACK机制,消费失败可以重新投递;支持阻塞读取,不用轮询。
缺点同样明显:数据量受限于Redis内存,不能当大消息管道用;没有像Kafka那样彻底的持久化保障。
4.2 用Spring Boot消费Redis Stream
假设你已经有一个Redis实例,下面这段代码用Spring Boot集成Redis Stream,实现消息的实时拉取和处理。
java复制@Component
public class StreamMessageListener implements StreamListener<String, MapRecord<String, String, String>> {
private static final Logger log = LoggerFactory.getLogger(StreamMessageListener.class);
@Override
public void onMessage(MapRecord<String, String, String> message) {
String streamKey = message.getStream();
String messageId = message.getId().getValue();
Map<String, String> body = message.getValue();
log.info("收到消息, stream={}, id={}, body={}", streamKey, messageId, body);
try {
// 在这里写业务处理逻辑,比如保存订单、发送通知
processBusiness(body);
// 确认消息,Redis会自动记录last-delivered-id
message.getConsumer().ack(message);
} catch (Exception e) {
log.error("消息处理失败, id={}", messageId, e);
// 不ack,消息会留在pending列表里,后续可重新消费
}
}
private void processBusiness(Map<String, String> body) {
// 模拟业务处理
Thread.sleep(100);
}
}
这里的关键是:StreamListener是Spring Data Redis提供的监听器接口,onMessage方法在有消息时被触发。ack操作对应XACK命令,通知Redis这条消息已被消费,不会出现在pending列表里。
4.3 配置消费者组和自动拉取
监听器需要注册到StreamMessageListenerContainer中,并指定消费组和消费模式。下面是一个完整的配置类:
java复制@Configuration
public class RedisStreamConfig {
@Bean
public StreamMessageListenerContainer<String, MapRecord<String, String, String>> streamContainer(
RedisConnectionFactory connectionFactory,
StreamMessageListener streamMessageListener) {
// StreamMessageListenerContainerOptions用于配置拉取策略
StreamMessageListenerContainerOptions<String, MapRecord<String, String, String>> options =
StreamMessageListenerContainerOptions
.builder()
.pollTimeout(Duration.ofSeconds(2)) // 无消息时阻塞时间
.batchSize(10) // 每次拉取10条
.build();
StreamMessageListenerContainer<String, MapRecord<String, String, String>> container =
StreamMessageListenerContainer.create(connectionFactory, options);
// 创建消费组,从最早未消费的消息开始读
StreamOffset<String> streamOffset = StreamOffset.create("my-stream", ReadOffset.lastConsumed());
container.register(streamMessageListener, streamOffset);
container.start();
return container;
}
}
几个关键的配置项:
- pollTimeout:没有消息时Redis侧最长阻塞时间,设太短会导致频繁空轮询,设太长会导致消息处理延迟。一般1到3秒是合理区间。
- batchSize:一次批量拉取的消息条数。适合处理能力强的场景,减少网络往返。
- ReadOffset.lastConsumed():从上次消费的位置继续读。如果是新的消费组,等价于从第一条开始读;如果想要只读最新消息,用ReadOffset.latest()。
启动应用后,你在Redis命令行执行XADD my-stream * field1 value1,应用会在几百毫秒内打日志收到消息。这个方案的好处是不用引入任何新的消息中间件,项目里本来就有Redis就能直接开跑。
5. 消息队列重复消费和延迟队列的实现思路
5.1 为什么重复消费是必然的
很多人第一次遇到底层消息重复消费,第一反应是“中间件出bug了”。其实不是必然,而是分布式系统里“至少一次投递”语义的副产品。为了保证消息不丢,大多数消息中间件采用了“至少一次”(At-Least-Once)的投递模型:发送端不确定接收端是否收到,就反复重发;接收端不确定消息是否处理成功,就反复重试。这就导致同一条消息可能被处理多次。
Kafka的幂等生产者能保证消息不重复写入,但消费者处理时依然可能重复,因为消费者可能在处理完但还没提交offset时就宕机了,恢复后会重新消费。RocketMQ默认也是At-Least-Once。
所以,重复消费问题无法靠中间件消除,只能靠业务侧做幂等。
5.2 幂等处理的三种常见方案
第一种是数据库唯一索引,最直接。比如消费消息时要插入一条订单流水,给业务单据号建立唯一索引,重复插入会报错,catch住即可。这个方案胜在简单有效,但只适用有唯一业务键的场景。
第二种是Redis分布式锁或者SETNX。在处理消息前,以业务唯一键为key执行SETNX,设置一个短过期时间,只有拿到锁的才处理。适合需要控制并发的场景,但要小心锁过期时间过短导致并发问题,时间过长又会阻塞正常处理,需要根据业务耗时精细调整。
第三种是状态机幂等。给业务对象加状态字段,比如订单状态。消费消息时先检查当前状态,如果已经是目标状态就直接跳过。这个在状态流转明确的场景特别优雅。
实际生产里,我做过一个比较稳的组合:数据库唯一索引作为兜底,消费逻辑前用状态检查做快速返回,两套叠加既高效又可靠。
5.3 延迟队列的三种落地方式
延迟消息是消息队列里非常实用的功能。典型的业务场景:订单超时未支付自动关闭(延迟30分钟);下单成功后超时未付款提醒用户;定时任务到点执行。
实现方式有很多种,常见三种:
第一种依赖MQ自带的延迟消息能力。RocketMQ原生支持延迟消息,发送时设置延迟级别即可,无需额外开发。RabbitMQ需要借助TTL(消息过期时间)加死信交换机实现延迟队列。Kafka本身没有延迟消息功能,用时间轮或发到延迟主题再定时转发。
第二种是基于Redis ZSet的定时器方案。把消息ID作为member,执行时间戳作为score,用一个定时任务每秒扫描score小于当前时间的成员,取出来投递到实际消费队列。这个方案的好处是通用、可控、不依赖具体MQ产品,缺点是要自己维护定时任务,在大规模下需要分片。
第三种是时间轮算法。把时间划分为一个个tick,每个tick上挂载到点的延迟任务,指针走动时依次扫描。这个方案延迟精度高、内存开销小,很多中间件底层都在用。比如Netty的HashedWheelTimer,以及Kafka内部的时间轮实现。
如果是面试问到延迟队列,从RocketMQ延迟级别、RabbitMQ TTL+DLX、Redis ZSet、时间轮这四个维度答,基本能覆盖考官的预期。
6. 典型问题排查与避坑心得
6.1 共享内存常见问题
内存泄漏:shmget创建的共享内存不会随进程退出而释放。程序崩溃后,ipcs -m还能看到残留段,积少成多会耗尽系统内存。处理办法是启动时主动清理,或者定期巡检。
并发写入导致数据错乱:两个进程同时写同一块共享内存,没有信号量保护,轻则数据不完整,重则指针结构错乱导致段错误。一旦出现数据莫名其妙丢失、内容被截断,优先检查同步机制。
page cache导致的数据持久化假象:mmap写的文件看着文件大小变了,但进程崩溃或断电后,数据可能没落盘。因为mmap默认是异步刷盘,靠操作系统在后台把脏页写回。需要强制落盘时,调用msync(MS_SYNC)。
6.2 Redis Stream消费常见问题
重复消费:原因多半是消费者处理成功了但未ACK,或者消费超时导致Redis认为未消费。排查方式:用XINFO GROUPS查看组的pending数量,用XPENDING查看具体消息ID和consumer。
消费堆积:stream长度快速增长,消费者处理不过来。排查Consumer Group的lag指标,注意Redis内存增长。
顺序问题:单个消费者组内,Redis保证按消息ID顺序投递,但如果有多个消费者并行消费,处理完成顺序不保证。对顺序敏感的业务,必须绑定到同一消费者。
6.3 消息队列通用问题排查思路
我整理了这几年遇到比较多的问题和对应的定位思路,可以当作速查表:
| 问题现象 | 排查思路 | 常见根因 |
|---|---|---|
| 消息一直不进消费端 | 检查topic/queue是否有人生产,消费者是否订阅正确,检查消费组offset | 生产端异常、订阅错误、offset被重置 |
| 消息重复消费 | 检查消费端是否提交offset、是否做幂等 | 消费超时重投、offset提交失败 |
| 消息堆积严重 | 看消费速度、消费者线程数、下游处理时间 | 下游太慢、消费者不够、批处理不合理 |
| 消息丢失 | 检查生产者确认机制、Broker持久化配置、消费者offset提交 | 生产端未开启ack、broker未持久化 |
| 消费延迟高 | 检查pollTimeout配置、消费端线程阻塞 | 业务处理慢、锁竞争、GC |
排查消息中间件问题,我个人的建议永远是先看监控,再看日志,最后才怀疑中间件本身。很多“中间件丢消息”的结论最后都发现是消费者代码bug。你可以在本地搭一套三个节点的Kafka,故意杀掉一个broker,看看生产端和消费端表现,这个实验非常能建立直觉。
7. 面试常问的几个问题顺手总结
高热词里还有一条是“消息队列面试题”。既然聊到这里,顺手把最常被问到的几个问题列一下,大家面试前可以直接对照自查:
第一个:消息队列的作用是什么?参考答案:解耦、异步、削峰,每个都要展开说清楚实际场景,最好能结合项目具体讲一个案例。
第二个:如何保证消息不丢失?从三个环节答:生产端确认机制、Broker持久化机制、消费端手动提交offset加幂等。逐层递进讲。
第三个:如何保证消息不重复消费?一句话答“靠业务幂等”,然后展开讲数据库唯一索引、分布式锁、状态机三种方案。
第四个:如何实现延迟消息?按产品说:RocketMQ延迟级别、RabbitMQ TTL+DLX、Redis ZSet定时器、时间轮算法。
第五个:共享内存为什么快?和管道比,少两次内核拷贝、少多次用户态内核态切换,直接操作物理内存。然后补充同步方式的说明。
第六个:共享内存和消息队列的区别?这个其实是我的最爱。一句话回答:共享内存是共享一个地址空间直接读写,快但在单机;消息队列是分布式解耦管道,慢但能跨网络、削峰填谷。然后展开对比。
8. 落地建议和我的实际体会
共享内存虽然快,但工程复杂度不低。单机多进程通信,用共享内存加信号量能获得极致的性能,但要注意生命周期管理和异常恢复。一旦程序崩溃,信号量可能遗留,共享内存可能残留,重启后状态混乱。
消息队列虽然引入了一些新的故障点,但换来的是系统间的解耦和弹性,这个是业务系统最需要的。如果项目架构里还没有消息队列,我的建议是先别急着上Kafka这种大家伙,可以先用Redis Stream把业务流程打通,等数据量起来、对可靠性要求高了,再碾压式迁移到RocketMQ或Kafka。
还要强调一点:延迟队列的坑比想象中多。RabbitMQ的TTL+DLX方案有个致命问题,队列头的消息过期后才会触发后续消息的过期检查,导致队列前面的消息阻塞后面所有消息。要绕开这个问题,要么分级队列,要么直接换支持延迟消息的中间件。这个坑我踩过,代码能跑,但业务上炸过一次,教训很深刻。
我对这两块技术的一个总结性判断:共享内存适合极致性能的单机场景,消息队列适合需要分布式协作和弹性吞吐的架构中。不管选哪个,都要对底层机制有足够理解,才能避免在排查问题时瞎猜。做技术最怕的就是知道API不太懂原理,遇到问题只能靠重启、靠运气。希望这篇内容能帮你把底层逻辑和实际落地串起来,少走一些我走过的弯路。
