看到“手写消息队列”这个标题,估计会有人觉得是重复造轮子。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
