看到“手写消息队列”这个标题,估计会有人觉得是重复造轮子。Kafka、RabbitMQ、RocketMQ摆在那里,Java生态里现成的队列组件也多得是,为什么要自己写?我最初也是这个想法,直到业务里出现了一个不算复杂的需求:用户设置的“定时提醒”需要在指定时间触发,订单超时30分钟未支付要自动关闭,还有运维告警需要延迟5分钟重试。消息量不大,撑死一天几万条,但延迟投递这个能力是刚性需求。
当时第一反应是上Redis的ZSet做延迟队列,但部分环境没有Redis,而且为了这个场景引入外部依赖总觉得不值。另一个选择是直接上Kafka,可单机部署、运维成本、多消费者协调的学习成本,对这个小项目来说都太大了。于是决定自己动手写一个:一个内存版阻塞队列,再扩展一个延迟队列,顺便把消费确认、失败重试和重复消费去重也做了。这篇文章就是整个手写过程的完整复盘——从数据结构选型到并发控制,从延迟投递的方案对比到压测踩坑,一步步说清楚。
适合谁来读:正在学消息队列原理、想搞懂BlockingQueue和DelayQueue底层逻辑的人;以及在中小型项目里不想为了延迟任务引入重依赖、打算自己实现一个轻量队列的人。如果你是想在生产环境跑超高并发,这篇文章也可以帮你更清楚Kafka这类组件到底帮你解决了哪些问题。
1. 为什么手写消息队列?——先搞清楚你遇到的问题
1.1 我这边出现“延迟任务”需求的背景
当时的技术栈是Java单体应用,大部分消息流转靠内存队列配合定时任务扫描数据库表实现。比如“订单超过30分钟未支付就自动关闭”,就是起一个定时任务,每隔1分钟扫一次订单表,把超时的订单捞出来处理。
这个方案最明显的问题是:扫表间隔决定了延迟精度。你想让订单在30分钟整被关闭,但定时任务是1分钟扫一次,实际关闭时间可能在30分钟到31分钟之间随机浮动,用户侧感知就是“我明明卡着点支付,系统却提示订单已关闭”。另一个问题是,扫描动作本身要全表捞数据,订单表一旦上了千万级,每次扫表都会产生慢查询和数据库压力,高峰期还会拖垮主库。
后来提醒类需求也来了:用户设置一个提醒时间,到点要给App推送一条通知。这些消息堆积在业务表里,既要频繁查“到期没有”,又要控制查询成本,非常别扭。我意识到问题的本质是:业务系统需要一个“到时间再触发”的组件,而不是靠业务表硬扛。这个组件,就是延迟消息队列。
1.2 手写队列的适用边界:什么场景不该上Kafka
在动手之前,我列了一个决策表,核心是判断这个需求到底该用哪种方案。手写队列不是万能的,甚至大多数情况下不应该手写,但它有非常清晰的适用边界。
| 方案 | 适用场景 | 不适合的场景 |
|---|---|---|
| 手写内存队列 | 单机部署、消息量可控(万级以内)、不需要跨进程通信、以学习和理解原理为主要目标 | 需要持久化、多实例负载均衡、消息量巨大、高可用强依赖 |
| Redis List / ZSet | 已有Redis、需要跨进程、延迟任务量中等、需要简单的消息ACK | 没有Redis环境、对Redis稳定性有顾虑、需要复杂路由和广播 |
| Kafka / RabbitMQ | 大规模异步解耦、多消费者组、持久化、分区、生态成熟、跨语言 | 小项目中为了一个延迟任务就引入,运维成本和学习成本不成比例 |
以我的场景为例:一天几万条消息,单机能扛,没有多语言消费者,不需要把消息保存几天。上Kafka属于典型的过度设计——光Topic、Partition、Consumer Group这些概念,团队里其他人就要学一阵子,更别说部署和监控了。手写队列虽然功能简陋,但代码完全在我掌控之中,出了问题直接调试,不需要去翻组件文档。
1.3 需求清单:先想清楚要做到哪几步
真正动手前,我画了需求清单,明确这个队列要做到什么程度:
- 支持基本的“生产-消费”模型:一个或多个生产者写入,一个或多个消费者读取
- 消费者读取时,如果队列为空,需要阻塞等待,而不是忙轮询
- 支持延迟投递:消息被写入后,到达指定时间才对消费者可见
- 支持消费确认:消费者处理失败时,消息能重新进入队列,不能“取出即消失”
- 消费端天然面临重复消费问题,队列层和业务层都要有对应的幂等设计
- 可选的失败重试上限和死信队列,避免一条坏消息反复重试
这个清单决定了后面每一步的设计。我也建议任何想手写队列的人,都先花半小时把需求写清楚,哪怕只是给自己看。没有这个清单,代码很容易越写越乱,最后变成“既要又要”的四不像。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心队列实现:锁、条件变量和阻塞唤醒
2.1 数据结构选型:链表还是数组
实现一个基本队列,第一步是选数据结构。Java里现成的有ArrayBlockingQueue和LinkedBlockingQueue,分别基于数组和链表。我自己写的时候也面临同样选择。
数组队列的优势是内存连续、遍历快,但必须指定容量,满了之后生产者需要阻塞或者拒绝。链表队列可以动态增长,不需要预先分配大块内存,更适合消息量不确定的场景。
我最终选择的是“可配置容量的链表队列”:底层用LinkedList存储,容量满时offer方法可以选择阻塞等待,也可以选择直接抛出异常,由调用方决定。这样既保留了链表动态扩展的灵活性,又能通过容量上限防止无界增长把内存打爆。
选择链表更实际的原因还有一个:后续做延迟队列时,消息需要按到期时间排序,用数组做排序插入的成本比链表高。链表配合优先队列会更自然。
2.2 用Condition实现阻塞take
Java的并发工具里,ReentrantLock搭配Condition是最适合手写队列的组合。Condition相当于一把锁上的多个“等待室”,线程可以在不同条件上等待,避免无效唤醒。
核心代码是这样:
java复制public class SimpleBlockingQueue<T> {
private final Deque<T> items = new LinkedList<>();
private final ReentrantLock lock = new ReentrantLock();
private final Condition notEmpty = lock.newCondition();
private final Condition notFull = lock.newCondition();
private final int capacity;
public SimpleBlockingQueue(int capacity) {
this.capacity = capacity;
}
public void offer(T item) throws InterruptedException {
lock.lockInterruptibly();
try {
while (items.size() >= capacity) {
notFull.await();
}
items.addLast(item);
notEmpty.signal();
} finally {
lock.unlock();
}
}
public T take() throws InterruptedException {
lock.lockInterruptibly();
try {
while (items.isEmpty()) {
notEmpty.await();
}
T item = items.removeFirst();
notFull.signal();
return item;
} finally {
lock.unlock();
}
}
}
这段代码里有两个细节值得展开。
第一,为什么用lockInterruptibly而不是lock?因为队列的take操作可能让消费者线程长时间阻塞。如果应用要优雅停机,必须能够中断这些等待中的线程,让它们退出。lockInterruptibly允许线程在等待锁的过程中响应中断,用普通lock的话,线程会一直阻塞在那里,shutdown都shutdown不掉。
第二,为什么判断条件用while而不是if?比如消费者线程被notEmpty.signal()唤醒后,它会重新去抢锁。如果有多个消费者同时被唤醒,第一个消费者抢到锁拿走了消息,第二个消费者随后抢到锁,如果用的是if判断,它不会重新检查队列是否为空,直接去removeFirst(),就会抛出NoSuchElementException。while循环会在每次唤醒后重新检查条件,保证只有队列真的非空时才继续执行。这是多线程编程里最经典的“虚假唤醒防护”,任何条件等待都必须用while包裹。
2.3 为什么唤醒用signal而不是signalAll
刚开始学并发的时候,很多人习惯写signalAll,觉得“多唤醒几个总没错”。但实际上,在没有必要的情况下唤醒所有线程,只会增加锁竞争和上下文切换。
想象一个场景:队列为空,消费者A和B都在notEmpty条件上等待。这时候生产者offer一条消息,如果调用signalAll,A和B都会被唤醒,但它们都要去抢同一把锁。A抢到锁,消费消息;B抢到锁,发现队列又空了,继续回到await。这个过程中B被白白唤醒一次,经历了一次完整的“阻塞-唤醒-重新阻塞”循环。
用signal的话,只会唤醒等待队列头部的第一个线程,也就是A。A去消费,B继续安稳地睡觉。这个优化在单消费者场景下效果最明显,在多消费者场景下也能减少无效竞争。
这里有个前提:只有一个条件变量时,signal可能唤醒错对象。比如生产者和消费者共用同一个Condition,生产者offer后调用signal,可能唤醒的是另一个生产者而不是消费者。所以我在实现里把notEmpty和notFull拆开,生产者offer后signal notEmpty,消费者take后signal notFull,语义非常清晰,不会唤醒错人。
用生活类比就是:餐厅叫号,并不是每一桌空出来都要把门口等位的人全部喊一遍,服务员只需要叫下一个号就够了。只有一种情况需要叫所有人——来了一大批空位,比如包厢全部空出来了,可以一次性多叫几个号去领位。
3. 延迟消息队列:三种实现方案的对比与选型
3.1 方案一:全量定时扫描
最容易想到的延迟队列实现,是维护一个List,然后用一个后台线程每隔一定时间扫描一遍全部元素,把到期的消息放入普通队列。
这个方案代码确实最简单,5分钟就能写完:
java复制// 伪代码
while (true) {
Thread.sleep(100); // 每100ms扫一次
for (DelayedItem item : list) {
if (item.isExpired()) {
// 放入普通队列
}
}
}
但它的缺点也很致命。第一,扫描间隔就是延迟精度的天花板。如果一条消息应该在第500ms到期,而扫描周期是100ms,实际被处理的时间可能在500ms到599ms之间随机波动。用户设置的提醒如果误差半秒,体感还不太明显;但如果是订单超时关闭这种强时间敏感场景,误差就不能接受了。
第二,全量扫描的时间复杂度是O(n),消息量上来之后,每次扫描都要遍历所有消息。而真正到期的可能只有几条,绝大多数遍历都是无效操作。最典型的场景是凌晨两三点,队列里躺着几千条“定时提醒”,没有一条到期,但扫描线程仍然每隔100ms把所有消息翻一遍,CPU白白空转。
这个方案的适用范围其实很小:消息量几百条、对延迟精度要求不高的场景。我的消息规模上万,直接排除。
3.2 方案二:优先队列加精确等待
第二个方案基于一个核心观察:我们关心的是“最近一条到期的消息什么时候到期”,而不是把所有消息都翻一遍。
具体做法是:用优先级队列(小顶堆)按到期时间排序,每次只看堆顶元素——堆顶是所有消息里最早到期的那条。如果堆顶还没到期,派发线程就精确等待“剩余时间”,等到了再唤醒。如果有新消息插入,并且比当前堆顶更早到期,就唤醒派发线程,让它重新计算等待时间。
这个方案的优点很突出。每轮循环只检查堆顶,判断是否到期的复杂度是O(1);等待精度高,剩余多久就等多久,误差只受系统和调度器影响;没有消息要处理时,线程会真正休眠,几乎没有CPU开销。
Java标准库里的DelayQueue就是这个思路。我用它作为参考,自己实现了一遍,把里面leader线程的优化细节也一并吃透了。
3.3 方案三:时间轮
时间轮的思路是把时间切成固定大小的槽位,比如每个槽位代表100ms,一圈设置256个槽位,然后让一个指针每100ms跳一格,把当前槽位里的所有任务取出来执行。
这个方案最大的优势是任务的插入和取消都是O(1)。但延迟精度受限于槽位大小——如果槽位是100ms,那么一条设置在第50ms到期的任务,实际上要等到第100ms指针走到对应槽位时才会被处理,误差可能接近一个槽位。另外,如果一条任务的延迟时间超过一圈,需要记录剩余轮数,实现复杂度会明显上升。
我的消息量根本没到需要O(1)插入的规模,相比之下,方案二的“优先队列 + 精确等待”在代码复杂度和性能之间取得了更好的平衡。时间轮更适合大量短周期定时任务的场景,比如Netty里的HashedWheelTimer处理连接超时。手写项目里先实现思路更直白的方案,理解了再往时间轮演进,是更务实的选择。
3.4 延迟队列的完整代码实现
我实现的延迟队列核心逻辑如下:
java复制public class SimpleDelayQueue<T> {
private final PriorityQueue<DelayedItem<T>> heap = new PriorityQueue<>();
private final ReentrantLock lock = new ReentrantLock();
private final Condition available = lock.newCondition();
private Thread leader;
public void offer(T item, long delay, TimeUnit unit) {
lock.lock();
try {
long triggerTime = System.nanoTime() + unit.toNanos(delay);
heap.offer(new DelayedItem<>(item, triggerTime));
// 如果新消息是堆顶,说明它比当前派发线程等待的消息更早到期,
// 必须唤醒派发线程重新计算等待时间
if (heap.peek().item == item) {
leader = null;
available.signal();
}
} finally {
lock.unlock();
}
}
public T take() throws InterruptedException {
lock.lockInterruptibly();
try {
while (true) {
DelayedItem<T> first = heap.peek();
if (first == null) {
available.await();
} else {
long delay = first.triggerTime - System.nanoTime();
if (delay <= 0) {
return heap.poll().item;
}
if (leader != null) {
// 已经有线程在等待堆顶消息,当前线程直接休眠
available.await();
} else {
// 当前线程成为leader,精确等待剩余时间
leader = Thread.currentThread();
try {
available.awaitNanos(delay);
} finally {
leader = null;
}
}
}
}
} finally {
if (leader == null && heap.peek() != null) {
available.signal();
}
lock.unlock();
}
}
private static class DelayedItem<T> implements Comparable<DelayedItem<T>> {
final T item;
final long triggerTime;
DelayedItem(T item, long triggerTime) {
this.item = item;
this.triggerTime = triggerTime;
}
@Override
public int compareTo(DelayedItem<T> o) {
return Long.compare(this.triggerTime, o.triggerTime);
}
}
}
这里有几个关键点:
第一,时间基准用System.nanoTime()而不是System.currentTimeMillis()。nanoTime是一个单调时钟,不受系统修改时间的影响。如果使用currentTimeMillis,运维人员手动调整一下系统时间,整个延迟队列的触发时间就全乱了。
第二,leader线程的优化。如果有多个消费者线程同时调用take,理论上只需要一个线程等待堆顶消息到期,其他线程应该直接休眠。leader就是“唯一负责等待的人”。当堆顶消息被取走,leader会退出,并在finally中signal下一个线程,让它接替leader的角色。这个优化可以避免多个消费者线程同时等待同一个消息,减少无效唤醒。
第三,offer中判断heap.peek().item == item,这里用引用相等来判断“新插入的消息是不是堆顶”。如果多条消息内容相同,引用不同也可以正确判断。如果业务上确实有重复对象入队,建议给DelayedItem加一个自增序列号字段做比较,避免优先级队列认为两个相等对象是同一个。
3.5 延迟队列的使用方式
延迟队列本身不会主动投递消息,需要一个派发线程不断take,把到期的消息转发给业务工作队列:
java复制ExecutorService workers = Executors.newFixedThreadPool(4);
SimpleDelayQueue<Message> delayQueue = new SimpleDelayQueue<>();
// 派发线程
new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) {
try {
Message msg = delayQueue.take();
workers.submit(() -> handle(msg));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
}).start();
这里有一个很容易踩的坑:派发线程如果和业务处理线程混在同一个线程池里,比如用同一个线程池既take队列又处理消息,那么当业务处理耗时较长时,take很可能因为线程池没有空闲线程而无法执行,延迟消息就全部卡住了。派发线程应该是独立的,只负责“把到期消息拿出来交给别人处理”,自己绝不处理业务逻辑。
4. 消费确认、失败重试与重复消费兜底
4.1 ACK机制:为什么不能“取出即删除”
如果队列在take之后立刻把消息从内存中删除,消费者拿到消息后还没来得及处理,进程就崩溃了,这条消息就永远消失了。业务上的表现就是:用户设置的提醒到点了,但通知没发出去,谁也不知道。
成熟的MQ系统都有确认机制:Kafka的offset提交,RabbitMQ的basicAck。手写队列也要设计类似的机制,只不过可以简化。
我的做法是:维护一个“已投递未确认”集合(inFlight)。消费者take时,队列把消息标记为已投递,放入inFlight;消费者处理成功后调用ack(id),队列才把它真正删除。如果超过一定时间没收到ack,队列会认为消费失败,重新投递。
这里关键的变化是:消息在内存中不直接删除,而是从“待投递区”移入“已投递区”,等确认后再删除。这样消费者进程崩溃,重启后消息还在,可以从inFlight里捞出来重新处理。
4.2 重复消费是必然的,队列要去重,业务要幂等
只要用了消费确认机制,重复消费就不可能绝对避免。消费者处理完消息、但在返回ack之前网络闪断了,队列超时后会重新投递;或者消费者处理到一半,重试线程已经把消息重新投递了。所以设计上必须默认一条消息会被消费多次,而不是期望它只被消费一次。
处理分两层:
队列层做的去重,可以给每条消息生成唯一ID,并维护一个已处理集合。消费者收到消息后先查这个集合,如果ID已经存在就说明处理过了,直接ack。但这个方案有上限:集合不能无限增长,消息量大之后旧ID会被淘汰,极端情况下还是可能重复。
业务层的幂等才是治本方案。最有效的方式是在业务表上做唯一约束。比如订单超时关闭的场景,消费者执行的SQL是:
sql复制update t_order
set status = 'CLOSED', close_time = now()
where id = #{orderId} and status = 'PAID'
两个消费者同时收到同一订单的重复消息,同时执行这条更新,数据库的行锁和status条件保证只有一个语句能成功影响1行,另一个影响0行。影响行数为0说明这条消息已经被处理过,直接ack即可。这个方案不需要任何额外存储,依赖于数据库本身的原子性,是我最推荐的幂等方式。
4.3 失败重试与重试上限
消费失败要区分两类场景:
第一类是业务可重试的,比如下游服务超时、网络抖动。这种应该带退避地重试,比如第一次失败后3秒再投递,第二次15秒,第三次2分钟,逐步拉大间隔,给下游恢复的时间。
第二类是业务不可重试的,比如消息里的参数非法、数据格式错误。这种重试多少次都没用,只会浪费CPU和数据库资源。正确的做法是记录下来,转人工处理。
实现上,失败重试可以天然复用延迟队列——重试本身就是一条“延迟消息”:消费失败后,根据当前重试次数计算下一次投递时间,然后把消息再offer进延迟队列。超过最大重试次数(比如5次),投递到死信队列,由专门脚本或者人工介入。
我把重试次数放在消息对象里:
java复制public class Message {
private String id;
private String payload;
private int retryCount;
// getter / setter 省略
}
消费者处理逻辑:
java复制public void handle(Message msg) {
try {
process(msg);
ack(msg.getId());
} catch (RetryableException e) {
if (msg.getRetryCount() >= MAX_RETRY) {
deadLetterQueue.offer(msg);
} else {
msg.setRetryCount(msg.getRetryCount() + 1);
long nextDelay = RETRY_BACKOFF_MS * msg.getRetryCount();
delayQueue.offer(msg, nextDelay, TimeUnit.MILLISECONDS);
}
} catch (FatalException e) {
// 不可重试,记录日志并转人工
deadLetterQueue.offer(msg);
}
}
这个设计最让我满意的一点是:普通队列、延迟队列、重试、死信,本质上是同一套机制在不同参数下的复现。深度理解了延迟队列之后,重试和死信只是它的两个应用场景。
5. 实测数据与排障实录:CPU空转、任务丢失、积压拉爆
5.1 压测方案与结果
写完之后,我在本地做了压测。环境是4核8G的虚拟机,Java 17。测试方式:100万条消息,其中20%带延迟(延迟时间随机1到10秒),用10个生产者线程写入,5个消费者线程消费,每个消费者处理耗时模拟1到2毫秒。
结果记录如下:
- 无延迟场景下,吞吐约2.8万条/秒
- 延迟队列在空闲状态下,派发线程CPU占用接近0%
- 消息延迟误差在正负10毫秒以内
- 100万条消息占用了大约500MB内存,这说明无界队列很危险,必须加容量上限
压测本身没出大问题,但随后的几个小实验暴露了三个明显的问题,每一个都值得展开说说。
5.2 踩坑一:Thread.sleep扫描导致CPU居高不下
最初的延迟派发代码是while(true) { Thread.sleep(100); ... },每隔100毫秒全量扫描一次延迟队列。跑起来之后发现进程空闲状态CPU占用高达30%到40%——即使队列里一条消息都没有,扫描线程也照样每100毫秒醒一次。
这个问题的根源是“主动扫描”模式,解决方案就是前面实现的Condition.awaitNanos精确等待模式——如果有消息就等到最早那条消息的到期时间,如果没有消息就无限期休眠,等待offer时signal。改完以后,空闲状态CPU从30%降到接近0%。
这个坑给我留下的印象非常深:延迟队列的核心不是“定期扫描”,而是“精确等待到需要醒来的那一刻”。理解了这一点,就会发现Java标准库DelayQueue、ScheduledThreadPoolExecutor都不约而同选择了优先队列加条件等待,而不是定时扫描。
5.3 踩坑二:异常路径上的消息丢失
第二版代码里,消费者处理消息的逻辑没有健全的异常处理,大致长这样:
java复制Message msg = queue.take();
process(msg); // 如果这里抛异常,消息直接丢失
ack(msg.getId());
一旦process抛异常,消息既没有ack,也不会重新投递,直接从内存里消失了。测试时我用一个会随机抛异常的业务逻辑跑了几分钟,丢了好几百条消息。
修复方案是重写为:
java复制try {
process(msg);
ack(msg.getId());
} catch (Exception e) {
delayQueue.offer(msg, nextRetryDelay(), TimeUnit.MILLISECONDS);
}
同时还要考虑JVM退出导致的数据丢失。内存队列没有持久化,进程一退出数据全没了。我的处理是对延迟任务做“数据库加内存”双写:写队列前先落一条数据库记录,启动时扫描数据库未完成的任务恢复进内存。这样一来,至少延迟任务不会因为重启而丢。至于普通消息,我允许一定程度的丢失,但加了监控告警,尽量缩短消息丢失对业务的影响窗口。
如果业务不能接受任何丢失,那还是老老实实用Kafka这类自带持久化的组件。手写队列的价值在于理解和轻量,不丢消息这件事上,成熟MQ才是正解。
5.4 踩坑三:消费速率跟不上,内存被积压拉爆
压测时让生产端以最大速度写入,消费端处理业务耗时增加到20毫秒一条,结果发现队列积压量持续上升,内存飙升到1.2GB,GC耗时明显变长。原因很简单:生产速率远大于消费速率,无界队列来者不拒,内存被逐渐吃满。
解决方式有两步。第一步是给队列设置最大容量,offer时如果队列已满,可以阻塞直到有空间——这就是“背压”机制,生产者的速率会被消费者拖住,系统不会无限积压。第二步是根据业务耗时调整消费者线程数,而不是盲目加线程。
线程数与吞吐的关系,我实测的一组数据是:
| 消费者线程数 | 单条处理耗时 | 吞吐 |
|---|---|---|
| 1 | 2ms | 约500条/秒 |
| 4 | 2ms | 约1900条/秒 |
| 8 | 2ms | 约3300条/秒 |
| 16 | 2ms | 约3500条/秒 |
从8线程到16线程,吞吐只提升了6%,但线程切换带来的开销明显增加。这说明“线程越多越快”是常见误区,最优线程数取决于业务处理时长和锁竞争强度。我的经验是:IO密集型的业务可以开到CPU核数的8到10倍,纯计算型的业务最多CPU核数加1。
5.5 还可以继续加什么功能
写到这一步,这个手写队列已经具备了基本的生产可用能力,但离成熟MQ还有几个明显的提升空间:
- 广播模式:目前是多个消费者共享一个队列,每一条消息只被一个消费者拿到。如果要支持“一条消息发给多个订阅者”,需要为每个订阅者维护独立队列。
- 持久化:可以先把消息追加写入WAL日志文件,再更新内存,启动时回放日志恢复数据。这是Kafka等组件持久化的核心思路,手写一遍能学到很多。
- 多队列隔离:不同业务用不同的队列实例,避免某个业务的积压拖垮其他业务。
- 消息TTL:超过一定时间未被消费的消息自动丢弃或转入死信队列。
- 管理后台:队列深度、生产消费速率、积压告警、消息检索,这些在线上排障时非常有用。
写这个队列最大的收获,不是那几百行代码,而是把消息队列的核心抽象彻底梳理了一遍:队列解决的是“解耦和缓冲”,延迟队列解决的是“时间错位”,消费确认解决的是“不丢消息”,幂等设计解决的是“重复消息不可怕”。如果你正在学消息队列原理,我强烈建议花一两个晚上自己写一遍,再回头看RabbitMQ的AMQP协议、Kafka的offset机制,完全会有不同的感受。
最后分享一个我一直在用的小技巧:手写这些基础组件时,从“最小可用”开始——先实现一个能跑的阻塞队列,再加延迟、加ack、加重试,每一步都跑一遍压测验证。不要一上来就想着一次性把所有功能写完,否则出了问题,你很难分清是并发控制的问题,还是业务逻辑的问题。
