
1. 队列这东西真的不只是“排队”那么简单队列Queue大概是数据结构里最贴近日常生活的概念之一——你去奶茶店点单、在银行取号、在食堂打饭全都是先到先得这就是队列的“先进先出”FIFOFirst In First Out原则。但在计算机世界里队列绝不只是“排队”两个字能概括的它是操作系统调度、网络请求处理、消息通信、任务异步化等无数底层机制的地基。我见过太多初学者刚学队列的时候觉得“这玩意儿不就是个数组加两个指针吗”然后转头在真实项目里被各种队列问题虐得怀疑人生——线程池该选哪种阻塞队列消息中间件到底该用Kafka还是RabbitMQ为什么消费端数据会重复其实这些问题回到根上都绕不开“队列”这个基本数据结构。这篇东西我本来只想写个读书笔记结果越整理越觉得这块内容值得展开最后干脆写成了一篇完整的梳理。不管你是正在准备数据结构考试、考研408还是已经在做后端开发、中间件选型这篇都可以帮你在“队列”这件事上把知识体系和实战经验串起来。2. 从底层实现开始先把数组队列和循环队列吃透2.1 数组队列的局限性为什么一定要“循环”先看最朴素的实现——用数组模拟队列。我们维护两个指针front指向队头rear指向队尾的下一个位置。入队时在rear位置放元素rear后移出队时取front位置的元素front后移。// 最朴素的顺序队列C语言示范 #define MAXSIZE 100 typedef struct { int data[MAXSIZE]; int front; // 队头下标 int rear; // 队尾下标的下一个位置 } SeqQueue; // 入队 int EnQueue(SeqQueue *q, int x) { if (q-rear MAXSIZE) return 0; // 队满 q-data[q-rear] x; q-rear; return 1; } // 出队 int DeQueue(SeqQueue *q, int *x) { if (q-front q-rear) return 0; // 队空 *x q-data[q-front]; q-front; return 1; }这段代码问题很明显假设 MAXSIZE 是 100你入队 100 个元素再出队 100 个元素front和rear都变成了 100此时队列明明是空的但EnQueue判断rear MAXSIZE直接返回 0——这个队列“假满”了而且前面 100 个空间全部被浪费。这就是“假溢出”现象。解决思路其实也很简单把数组掰弯让rear到顶之后重新回到下标 0。这就是循环队列Circular Queue也叫环形队列。2.2 循环队列的细节判空判满的四种姿势循环队列的核心公式有三个入队rear (rear 1) % MAXSIZE出队front (front 1) % MAXSIZE队列长度(rear - front MAXSIZE) % MAXSIZE但问题是循环队列里front rear既可以表示“空”也可以表示“满”这就有四种主流解法方案一牺牲一个存储单元。约定front rear为空(rear 1) % MAXSIZE front为满。这是王道数据结构、408 考研教材默认的方案也是我个人最推荐在考试和工程里用的方案——简单直接不容易写出 bug。方案二增设 size 字段。入队size出队size--用size 0判空size MAXSIZE判满。空间不浪费但每次操作要多维护一个变量。方案三增设 tag 字段。每次删除操作后标记tag 0插入操作后标记tag 1。判满条件变成front rear tag 1判空是front rear tag 0。方案四直接用(rear - front MAXSIZE) % MAXSIZE算长度长度等于 MAXSIZE - 1 就算满。这里说一个我在实际笔试里踩过的坑很多同学会下意识地写if ((q-rear 1) % MAXSIZE q-front)判满但在入队之前必须先判断满出队之前必须先判断空这两个顺序搞反了队列状态就全乱了。建议在代码里把判空和判满封装成独立的函数别图省事内联在逻辑里。// 循环队列标准实现牺牲一个单元 typedef struct { int data[MAXSIZE]; int front, rear; } CircularQueue; int IsEmpty(CircularQueue *q) { return q-front q-rear; } int IsFull(CircularQueue *q) { return (q-rear 1) % MAXSIZE q-front; } int EnQueue(CircularQueue *q, int x) { if (IsFull(q)) return 0; q-data[q-rear] x; q-rear (q-rear 1) % MAXSIZE; return 1; } int DeQueue(CircularQueue *q, int *x) { if (IsEmpty(q)) return 0; *x q-data[q-front]; q-front (q-front 1) % MAXSIZE; return 1; }2.3 链式队列没有“满”的概念但要小心内存链式队列Linked Queue本质上是一个带front和rear两个指针的单链表front指向头结点rear指向队尾结点。入队在尾部插入出队在头部删除。好处是理论上没有容量上限适合数据量不可预估的场景坏处是每个元素多存一个指针内存占用略高且频繁 malloc/free 可能会产生内存碎片。实操中有一个细节链式队列带头结点和不带头结点处理方式不一样。带头结点时初始front rear均指向头结点判空条件是front-next NULL不带头结点时判空条件是front NULL。我见过不少面试者在白板上写链式队列因为不带头结点删除最后一个元素后还要额外把rear置为NULL很容易漏掉。建议面试和手写代码时直接用带头结点版本逻辑更稳。至于 Java 里LinkedList实现了Deque接口可以直接当队列用但性能上不是最优节点分散缓存不友好。高性能场景下 JDK 提供了ArrayDeque它内部是一个循环数组默认容量 16扩容时按 2 倍扩展。这一点和 C/C 里手写的循环队列思路完全一致只是 JDK 帮你把扩容写好了。3. 进阶形态双端队列、优先队列、单调队列、阻塞队列队列的进阶形态看似花哨其实都是围绕“能不能两头操作、按什么顺序出队、满了怎么办”这几个维度演化的。把这几个维度理清楚后面学任何框架里的队列都很快。3.1 双端队列Deque两头都能进能出双端队列Double-Ended Queue允许在队头和队尾两端进行插入和删除操作。它不是一个新数据结构而是队列和栈的结合体——你可以用它实现栈也可以用它实现普通队列这就是为什么 Java 里ArrayDeque既可以当Stack用也可以当Queue用。在算法题里双端队列最常见的应用场景是“滑动窗口求最值”。LeetCode 239 题“滑动窗口最大值”就是经典代表用双端队列维护窗口内候选元素的下标队头永远是当前窗口最大值的下标新元素入队时把队尾所有比它小的元素全部弹出因为它们“又老又弱”永远不会再成为最大值。这个思路的本质是单调队列后面展开讲。3.2 优先队列Priority Queue出队顺序由优先级决定优先队列的元素出队顺序不按入队先后而按优先级。底层实现几乎无一例外是二叉堆Binary HeapJava 里的PriorityQueue默认是小顶堆最小元素优先出队C 的priority_queue默认是大顶堆最大元素优先出队。堆的本质是一个完全二叉树用数组存储父节点下标是i左孩子是2*i1右孩子是2*i2。优先队列最典型的应用是“合并 K 个有序链表”、“Dijkstra 最短路径”、“任务调度按优先级执行”。我当年在写调度模块时用户提交的任务有紧急、普通、低优三个级别如果直接用普通 FIFO 队列紧急任务会被前面的普通任务堵死换成优先队列出队时自动按优先级排序用户体验直接上升一个档次。关于优先队列有一个容易忽略的点比较器的稳定性。如果两个元素优先级相同PriorityQueue不保证它们的出队顺序就是入队顺序因为堆排序本身是不稳定排序。需要稳定性时建议在比较器里加上入队序号字段作为次级排序键。3.3 阻塞队列BlockingQueue队列的“满则等空则等”阻塞队列是并发编程里的核心概念。它在普通队列的基础上增加了“阻塞”能力队列满时入队线程阻塞等待队列空时出队线程阻塞等待。Java 里BlockingQueue接口下有几个实现各自适用场景不同实现类底层结构锁策略适用场景ArrayBlockingQueue循环数组有界单一锁入队出队共用一把锁并发量适中需要有界控制LinkedBlockingQueue链表默认无界可设容量入队锁、出队锁两把锁分离吞吐量较高生产消费解耦SynchronousQueue无存储空间直接传递生产者消费者速度匹配不缓存DelayQueue优先队列延迟到期才可取出定时任务、延时消息PriorityBlockingQueue优先队列无界需要按优先级处理的场景线程池选型这里我吃过大亏。有一次线上服务 OOM查了半天发现是Executors.newFixedThreadPool()用了无界的LinkedBlockingQueue。当时任务量突然暴增线程池里的线程处理不过来任务全部堆积在队列里每个任务都是一个不小的对象几个小时就把堆内存打爆了。后来改成new ArrayBlockingQueue(1000)加CallerRunsPolicy拒绝策略队列塞满时让提交任务的线程自己消化这个任务反而做了天然背压系统稳如老狗。所以线程池的阻塞队列选择实质上是“要吞吐还是要安全”的权衡。面试被问线程池参数时能答出“无界队列 固定线程数 任务无限堆积 OOM 风险”这一层基本就能和只会背八股文的候选人拉开差距。3.4 单调队列一个被低估的算法利器单调队列和普通队列最大的区别是它内部维护的元素具有单调性单调递增或递减而且队头到队尾的某些条件被刻意约束。刚才提到的滑动窗口最大值就是单调递减队列队头是最大值。以 LintCode 362“滑动窗口最大值”为例核心伪代码如下// 借助双端队列维护单调递减队列 DequeInteger deque new ArrayDeque(); // 存下标 for (int i 0; i nums.length; i) { // 1. 清理队头不在窗口范围内就弹出 while (!deque.isEmpty() deque.peekFirst() i - k 1) { deque.pollFirst(); } // 2. 维护单调性队尾比当前元素小的全部弹出 while (!deque.isEmpty() nums[deque.peekLast()] nums[i]) { deque.pollLast(); } // 3. 入队 deque.offerLast(i); // 4. 窗口满时记录结果 if (i k - 1) { result[i - k 1] nums[deque.peekFirst()]; } }时间复杂度 O(n)每个元素最多入队一次、出队一次。这个技巧不止用于滑动窗口还广泛用于“区间内求最值”类的动态规划优化尤其是单调队列优化 DP比如求一段区间内满足某些条件的最值转移。热词里出现了“单调队列优化dp”如果你在准备算法竞赛或者考研复试机试这块务必练熟。4. 生产实战消息队列选型对比与避坑指南如果说前面的内容是在“造轮子”那生产环境里我们更多是在“选轮子”。消息队列Message QueueMQ是分布式系统中最典型的队列应用——把队列从内存中的数据结构提升到了跨进程、跨主机的通信协议层面。我的项目里用过的消息队列有 RabbitMQ、Kafka、RocketMQ三者的定位差异非常明显下面这张对比表是我实际踩坑后整理的对比维度KafkaRabbitMQRocketMQ核心定位大数据流处理、日志采集轻量级消息路由、企业集成阿里系电商场景、金融级可靠性吞吐量非常高百万级/秒一般万级/秒高十万级/秒消息顺序分区内有序单队列有序多队列需处理分区/队列内有序消息堆积能力强基于磁盘顺序读写弱大量堆积影响性能强支持海量堆积消费模式拉模式推模式为主推拉结合事务消息支持较复杂不支持原生支持半消息机制学习曲线中等简单中等偏难4.1 Kafka高吞吐的代价是“可能丢消息”Kafka 的致命优势是吞吐量原因是它把消息顺序写入磁盘利用操作系统的 Page Cache 和顺序读写的特性让磁盘速度接近内存。但很多新手不知道的是Kafka 默认的ack1配置下leader 收到消息后还没有来得及同步给 follower 就宕机这条消息就丢了。如果业务要求“一条都不能丢”必须设置为acksall并且min.insync.replicas至少设为 2这同时会明显降低吞吐量。我在对接日志采集系统时团队一开始用的是 Kafka 默认配置数据量峰值期发生过 leader 切换导致部分日志丢失排查后发现就是ack1的锅。改配置后虽然吞吐略有下降但日志场景本身量级远没到瓶颈安全性反而更重要。4.2 RabbitMQ功能丰富但别让它堆积消息RabbitMQ 的模型是 Exchange 加 Queue通过路由键Routing Key把消息分发到不同队列特别适合复杂的路由逻辑。但它本质是内存型队列消息默认存在内存里大量堆积时性能断崖式下跌甚至 OOM。生产环境强烈建议开启lazy queue惰性队列消息直接落盘用磁盘换内存牺牲一点吞吐换稳定性。另外 RabbitMQ 有一个经典坑消息消费的确认机制。默认是自动 ack也就是消费者一收到消息 RabbitMQ 就直接标记删除。如果消费者处理消息的过程中宕机这条消息就永久丢失了。改成手动 ack 后又可能出现“消费者处理完但 ack 没发出去导致重复投递”的问题。重复消费的问题下面单独讲。4.3 RocketMQ事务消息是电商订单场景的利器RocketMQ 最让我觉得惊艳的是事务消息方案它解决的是“本地事务和消息发送的一致性”问题。以支付下单为例你的订单服务需要在数据库里插入一条订单记录再发送一条消息到 MQ 通知积分服务加积分。这两个操作如果不同步就会出现“订单成功但积分没加”或者“消息发了但订单失败”的不一致。RocketMQ 的事务消息方案分三步先发送半消息half message消息暂不可见事务执行成功后把半消息提交消费者才看见如果事务执行失败回滚半消息。期间 MQ 会通过回查接口checkLocalTransaction确认事务状态防止本地事务成功但提交消息时网络超时。我当时用这个功能的时候自己实现TransactionListener的两个方法executeLocalTransaction返回本地事务结果checkLocalTransaction查询数据库确认订单状态。这里有一个隐藏的坑回查次数不能太多RocketMQ 默认回查 15 次超过后消息会被丢弃并记录日志。所以checkLocalTransaction里必须幂等且要能快速返回结果否则事务消息会静默消失。4.4 面试必考题生产者的“消息可靠投递”消费者的“重复消费”消息队列领域最绕不开的两个问题怎么保证消息不丢怎么处理重复消费。不丢消息的思路是三段式确认生产者到 Broker 这一段采用acksall加重试来解决Broker 存储这一段用多副本同步加刷盘策略Broker 到消费者这一段关闭自动 ack消费者处理成功后再手动 ack。每一段都能单独验证把“不丢”拆解为“三个不丢”。重复消费的本质是消费端在“处理成功”和“提交位移”之间发生宕机下次重平衡时消息被重新消费。哪怕你做到 at-least-once至少一次投递也没法在 MQ 层面完全避免重复。唯一的解法是消费端幂等——在消费逻辑里做去重。常用方案有两个利用数据库唯一键比如订单号、消息ID做唯一索引重复插入时报冲突直接忽略。利用 Redis SETNX以消息ID为 keySET key 1 EX 86400 NX返回成功才继续处理否则说明已消费过。我在对接支付回调时回调消息被 MQ 重复投递了至少三次每次都是因为消费逻辑里没有幂等导致用户积分被重复增加。后来在消费入口加了一个 Redis 去重问题立刻消失。记住这句话“MQ 不保证不重复只保证不丢失幂等是消费端自己的责任”。5. 工程实践中的另类队列应用从 Canvas 导出到并发任务调度队列的应用远不止在 MQ 中间件我在具体业务开发中至少碰到过两类“队列”问题一个是前端一个后端但思维模式完全一致。5.1 iOS Safari uni-app Canvas 导出白图异步任务的队列化改造热词里有一条“ios safari 使用 uniapp canvas 队列时导出白图”看着冷门其实是很多用 uni-app 做跨端开发的同学会踩的真实问题。现象是在 iOS Safari 上调用 uni-app 的 canvas API 绘制多张图片然后uni.canvasToTempFilePath导出图片经常导出出来一张白图。排查后我发现根因是 canvas 的绘制操作和导出操作之间存在严重的异步竞争问题。iOS Safari 对 canvas 的drawImage执行时机不太稳定如果你代码里连续执行多个绘制操作后立刻导出某些绘制操作还没来得及真正提交到 canvas 底层就被导出流程“截胡”了导出结果自然就是空白。解法就是用队列把绘制动作串行化把每个绘制操作封装成一个 Promise放入一个队列上一个操作完成后再执行下一个最后再执行导出。我当时写了一个简单的串行队列// 串行执行绘制任务队列 const drawQueue []; let isDrawing false; function pushDraw(task) { drawQueue.push(task); runNext(); } function runNext() { if (isDrawing || drawQueue.length 0) return; isDrawing true; const task drawQueue.shift(); task().finally(() { isDrawing false; runNext(); }); } // 用法 pushDraw(() drawImageAsync(img1, x, y)); pushDraw(() drawImageAsync(img2, x, y)); pushDraw(() exportCanvas());这个思路的本质就是队列的“生产者-消费者”模型事件循环把绘制任务当作生产者canvas 绘制引擎作为消费者通过队列让生产和消费速率匹配。这个问题告诉我们队列思想不只在后端有任何存在“异步顺序敏感”的前端场景都可以用队列来理顺执行秩序。5.2 大模型调度平台的任务队列排队不是无脑 FIFO热词里还有一条“大模型调度平台的任务及队列管理是什么”这正好是我最近在做的一个方向。大模型推理服务和普通 Web 服务不同GPU 显存是稀缺资源一个推理请求动辄占用几十秒如果并发请求全部打进来GPU 显存直接爆掉。调度的核心就是维持一个任务队列按策略决定谁先谁后。最简单的方案是 FIFO但实际工程里往往需要优先级调度付费用户的请求插队、短任务优先、长任务限流。我的实现是多个队列并行一个高优先级队列、一个普通队列、一个后台队列调度器每次从高优先级队列里拿任务没有则从普通队列拿。再配合“排队超时判定”机制防止低优先级任务永远被饿死。这个场景里队列的长度和等待时间本身就是产品指标——用户侧看到“排队人数”和“预计等待时间”就是从任务队列里实时算出来的。理解了这个你会意识到队列在系统设计中已经成了“规模化和优雅降级”的基本手段当系统处理不过来时队列帮你把冲击力先缓存下来用时间换取系统的稳定。6. 常见问题与排查技巧实录最后把这些年我遇到过的队列相关问题整理成一份速查方便你排查时对着看现象大概率原因排查/解决方向循环队列运行时数据错乱取模判断写错或队列满/空未分开判断单独封装IsEmpty、IsFull单测覆盖“空队列出队、满队列入队”线程池任务堆积内存飙升使用了无界队列改为有界ArrayBlockingQueue配拒绝策略Kafka 消费组重平衡频繁session 超时时间太短或消费耗时过长调大session.timeout.ms开启max.poll.records限流MQ 消费者收到重复消息处理成功后未提交位移或 ack 超时重投消费端幂等数据库唯一键或 Redis SETNXRabbitMQ 大量消息堆积后性能骤降默认内存存储堆积触发换页开启 lazy queue或增加消费者实例优先队列顺序不符合预期比较器没有处理相等情形增加序号字段作为次级比较键Canvas 导出白图绘制异步未完成就导出用串行队列把绘制和导出排队数据库事务和 MQ 消息不一致缺少事务消息机制使用 RocketMQ 事务消息或本地消息表这些坑有些是我自己踩过的有些是帮别人排查踩过的。核心的心得归结为一句用队列时永远把队列的“满”“空”“消费失败”这三个边界情况想清楚绝大多数生产事故都出在这三个词上。7. 最后说点实际的我在带新人的时候发现一个规律科班出身、数据结构考高分的同学能把循环队列的代码默写得一字不差但问他“线上服务消息积压了怎么处理”经常答不上来。反过来一些半路转行的同学业务代码写得贼溜但问他“为什么 Redis 的 List 能当队列用、和专业的 MQ 有什么区别”又说不清楚。队列就是这样——它够简单简单到几乎每个程序员都觉得自己会又够复杂复杂到分布式系统、并发编程、调度系统里处处都有它的影子。学数据结构不只是为了笔试面试更重要的是建立一种“资源受限时先到先得、处理不过来时先缓后放、顺序敏感时串行化”的思维模型。有了这种模型很多看似复杂的系统设计问题你都会下意识地先从队列开始拆解。如果你正在准备考研或者面试建议把队列这章吃透数组队列和链式队列的手写代码、循环队列的判空判满、双端队列的滑动窗口应用、优先队列的堆实现、线程池的阻塞队列选型、MQ 的可靠性和幂等性这几条线串起来关于队列的知识体系就完整了。后面我会继续整理栈、树、图这些内容欢迎持续关注。