1. 先弄懂队列到底在解决什么问题
队列这个词,看起来只是大学课本里的一个基础数据结构,但只要你往一线走一走,就会发现所有系统都在跟它较劲:线程池的阻塞队列选型、Redis 的列表队列、Kafka/RabbitMQ 的消息队列重复消费、Windows 窗口消息队列、集群调度器的作业排队。可以说,搞懂队列基础实现,是通读中间件和操作系统源码的前提。我在订单系统里因为队列选型不当出过生产事故,后来做集群调度又天天和排队打交道,所以想借这篇文章把队列的底子从头到尾捋一遍,也算是对自己踩过的坑做个总结。
先理解最基本的结论:队列就是一个只允许从队尾插入、从队头删除的数据结构,遵循先进先出,英文缩写 FIFO。两个核心操作分别是入队和出队,一般叫 enqueue 和 dequeue,查看队头元素通常叫 front 或 peek。操作看上去只有这么几个,但它几乎是整个计算机体系里最常用的缓冲和解耦工具,没有之一。
1.1 队列不只是公平,更是解耦和削峰
人们最容易把队列理解成“排队讲公平”,比如食堂打饭先来先打。但在技术系统里,公平只是副作用,队列真正解决的是两件事:解耦和削峰。
解耦的意思,是生产者和消费者不需要保持同一步调。生产者把数据放进队列就可以继续做自己的事,消费者在有空的时候再取出来处理。没有队列,生产者必须等待消费者完成,消费者也必须等待生产者投递,整体吞吐就被最慢的一方卡死了。我之前维护过一个页面抓取服务,爬虫模块飞快地抓到一批页面,解析模块却跟不上节奏,中间加一个任务队列之后,双方各跑各的,瓶颈立刻消失,代码改动量也很小。
削峰理解起来更直观。秒杀场景里,瞬时流量可能冲到每秒几万笔,直接打到订单数据库上,数据库必然被压垮。用一个有界或无边界的队列把请求按先来后到的顺序缓冲起来,后端根据自己的处理能力慢慢消费,就能把那个尖峰削平,让系统整体维持在稳定水位。这也是为什么很多架构方案里都在强调“异步化”“削峰填谷”,落到代码层面,多半就是某个队列在工作。
队列的“排队顺序”本身也是一种调度约定。谁先进、谁先出、优先级是否可以插队、低优先级任务会不会饿死,这些问题在操作系统调度、线程池排队、消息中间件投递里都有对应。我们讨论“队列安排”的时候,本质上是在讨论时延、公平、吞吐之间的权衡,而不是简单一句“先来后到”就能带过的。
1.2 队列到底藏在哪些系统里
我试着数过自己一天要打交道的队列:操作系统每个 CPU 都有一个就绪队列,新进程进入队列,调度器按策略选出下一个执行的进程;网卡收发包有环形队列,防止处理速度跟不上导致丢包;线程池内部有一个任务队列,提交给线程池的任务先排队,空闲线程再从队里取任务;GUI 程序的事件循环也有队列,Windows 窗口程序会为每个线程维护一个消息队列,把鼠标、键盘、重绘等消息按顺序投递给窗口过程。
再往大了说,Redis List 可以做分布式队列,RabbitMQ 和 Kafka 本质上是更可靠的消息队列系统,LSF 这类集群调度器里也有作业队列,用户提交的作业要先排队,再被调度器安排到合适的计算节点上执行。可以说,队列是计算机系统里最底层的“基础设施”之一。
你会发现一个共同点:队列几乎总是出现在两个速度不匹配的角色中间。CPU 快而磁盘慢,就用缓冲队列;生产者快而消费者慢,就用任务队列;上游流量突发而下游能力有限,就用消息队列。也就是说,队列表面上是数据结构,实际上是系统里“缓冲、排队、削峰”这件事的代名词。把这个基础实现吃透,再去看上面这些系统源码就不会慌,因为万变不离其宗。
1.3 队列和栈,差的不只是顺序
很多初学者分不清队列和栈,其实两者的区别不只是方向。栈是后进先出(LIFO),只允许在同一端插入和删除,像一摞盘子;队列是先进先出(FIFO),两端职责不同,像一条管道。选错结构,行为会完全反转。
典型场景里,函数调用顺序是用栈的,因为你需要先执行最后被调用的函数,再一层层返回;浏览器历史记录也用栈,点后退时退的是最近一次访问的页面。但打印任务、消息推送、订单排队这些场景必须用队列,因为它们要求先来的先处理。判别的原则很简单:如果处理顺序和产生顺序一致,用队列;如果需要撤销最近操作或者递归回溯,用栈。别看这么简单,我在评审代码时确实见过把任务栈当任务队列用的,结果后提交的旧任务反而先被执行,底层数据乱得让人头皮发麻。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 手写队列:数组实现和链表实现,一个都别跳过
谈起队列基础实现,最常见的两个方向就是数组和链表。很多教程喜欢直接甩源码,但我更建议你先自己动手写一遍。不亲手踩一遍边界条件,很难体会为什么要用环形数组,也很难理解为什么无界队列会在高并发下带来灾难性的内存压力。这一节我会把两种实现都拆开讲透,再附上一份我常用的小代码骨架。
2.1 数组实现:小心“假溢出”和环形队列
用数组实现队列,最朴素的方式是维护一个 head 下标和一个 tail 下标。入队时在 tail 位置写入数据,tail 后移;出队时读取 head 位置的数据,head 后移。一开始很好用,但很快就会发现一个问题:随着出队操作不断增多,head 不断往后走,数组前部的空间其实已经空了,但 tail 一旦走到数组末尾,队列就报“已满”,这就是假溢出。
解决办法是改成环形队列。让 head 和 tail 在下标超过 capacity 时对 capacity 取模,绕回到数组开头,把空出来的位置重新利用起来。判断队列是空还是满,有两种常见策略:一是额外记录一个 size 变量,二是故意浪费一个存储单元。我强烈推荐用 size 变量,可读性最好,内存也只多一个整数,边界条件不容易绕晕。
python复制class RingQueue:
def __init__(self, capacity: int):
self.capacity = capacity
self.data = [None] * capacity
self.head = 0
self.tail = 0
self.count = 0
def enqueue(self, item):
if self.count == self.capacity:
raise OverflowError("队列已满")
self.data[self.tail] = item
self.tail = (self.tail + 1) % self.capacity
self.count += 1
def dequeue(self):
if self.count == 0:
raise IndexError("队列为空")
item = self.data[self.head]
self.head = (self.head + 1) % self.capacity
self.count -= 1
return item
def peek(self):
if self.count == 0:
raise IndexError("队列为空")
return self.data[self.head]
def is_empty(self):
return self.count == 0
def is_full(self):
return self.count == self.capacity
def __len__(self):
return self.count
这里有几个地方要特别提醒。第一,enqueue 时要先判断满再写数据,dequeue 时要先判断空再取数据,顺序反过来就会出现越界或者覆盖未消费数据的问题。我在写网络收发缓冲区的时候,就因为 head 和 tail 的更新顺序没理清,结果新数据把还没被读取的旧数据覆盖了,排查了大半天,最后加断言才发现是边界判断写反了。第二,环形队列的容量一旦初始化就是固定的,扩容需要把旧元素按逻辑顺序重新排列,成本不低,所以使用前一定要大致评估峰值容量。第三,如果只是单线程使用,环形队列可以不加锁;一旦跨线程,入队和出队操作都必须加锁,否则 head 和 tail 会互相踩踏,数据错乱。
2.2 链表队列:没有容量上限的排队
数组队列受容量约束,链表队列则天然支持动态扩容。实现上只需要维护 head 和 tail 两个指针:入队时新建节点,接到 tail 后面再移动 tail;出队时把 head 后移一个节点,并返回原 head 的值。空队列时 head 和 tail 都为 null;出队到最后一个元素之后,必须记得把 tail 也置空,否则 tail 会指向一个已经不在链表里的节点。
python复制class QueueNode:
__slots__ = ("value", "next")
def __init__(self, value):
self.value = value
self.next = None
class LinkedListQueue:
def __init__(self):
self.head = None
self.tail = None
self._size = 0
def enqueue(self, value):
node = QueueNode(value)
if self.tail is None:
self.head = self.tail = node
else:
self.tail.next = node
self.tail = node
self._size += 1
def dequeue(self):
if self.head is None:
raise IndexError("队列为空")
node = self.head
self.head = self.head.next
if self.head is None:
self.tail = None
self._size -= 1
return node.value
def peek(self):
if self.head is None:
raise IndexError("队列为空")
return self.head.value
def is_empty(self):
return self.head is None
def __len__(self):
return self._size
链表队列的好处是不需要预估容量,但存储上每个节点都额外需要一个指针的内存,而且内存不连续,CPU 缓存命中也差,遍历性能通常不如数组。Java 的 LinkedList 底层就是双链表,Python 的 collections.deque 在 CPython 里也是双端队列,内部用了块状存储来提升空间效率。我在工程上不会为纯链表队列单独造轮子,但理解这种实现才能解释,为什么无界队列可以在高并发下悄悄把内存吃光。
2.3 两种实现的取舍和我的建议
把两种实现放在一起对比,选型逻辑就很清楚了:
| 维度 | 数组/环形队列 | 链表队列 |
|---|---|---|
| 容量 | 固定容量,需要预估 | 动态,不受容量限制 |
| 内存 | 连续内存,占用小 | 每节点多一个指针,易碎片化 |
| CPU缓存 | 局部性好,遍历快 | 缓存不友好 |
| 空/满判断 | 需要额外 count 或浪费一格 | 看 head 是否为 null 即可 |
| 扩容 | 需要整体拷贝 | 插入节点时动态分配即可 |
| 适用场景 | 网络缓冲、连接池、固定容量缓存 | 任务列表、事件队列、无界缓冲 |
我的习惯是:日志缓冲、网络包暂存这类容量可控且非常在意吞吐的场景,用环形队列;任务列表、事件推送这类无法预估峰值但逻辑上不设上限的场景,用链表或 deque 实现的无界队列,但同时必须给入口加流量限制,否则“无界”两个字会把内存直接拖垮。
线上出过真实的教训:某服务的任务队列用的是无界链表,某天上游系统故障疯狂重试,任务瞬间积压上千万,JVM 持续 Full GC,整组服务一整个下午都缓不过劲。所以我现在默认的选择是“有界队列加合理的拒绝策略”,宁可丢一部分任务并触发告警,也不能让进程被积压任务活活憋死。
3. 阻塞队列和线程池:生产者消费者绕不开的核心
基础队列写好后,一旦进入多线程,事情就不一样了。两个线程同时入队,或者一个在入队一个在出队,如果不加同步,数据会被互相覆盖。于是并发编程里出现了专门的一类队列——阻塞队列。
3.1 阻塞队列在等什么
阻塞队列和普通队列最大的区别,是操作语义不同。它把 put 和 take 设计成可等待的:put 在队列满时挂起,直到有空位再继续;take 在队列空时挂起,直到有数据再继续。这样一来,生产者和消费者不需要互相感知对方的状态,队列本身就把“满了你就睡,空了你也睡”的逻辑承担了。
我常用一个生活化的类比:咖啡店柜台是一个容量有限的队列。客人把订单放进柜台就可以离开,不再占着店员的时间;如果柜台满了,客人就得排队等待;如果柜台空了,店员就暂时歇着,等下一个订单进来。生产者和消费者就这样被解耦,系统的吞吐不再等于最慢一方的速度。
Java 的 BlockingQueue 和 Python 的 queue.Queue 都实现了这个语义,只是在同步机制上有差别:Java 用 Lock/Condition 或者 CAS,Python 背后是 threading.Lock 和 Condition。但不管底层用哪种,对外暴露的核心能力都一样:阻塞入队、阻塞出队、超时入队、非阻塞入队。
3.2 “Python队列queue不堵塞”到底指什么
只要在搜索引擎里敲“python队列queue不堵塞”,大概率会看到一句让人困惑的话:Python 的 queue.Queue 不堵塞。准确地说,queue.Queue 默认的 get() 和 put() 是会堵塞的;不堵塞的是它的非阻塞变体 get_nowait() 和 put_nowait(),以及带超时参数的 get(timeout=3)、put(timeout=3)。
python复制import queue
q = queue.Queue(maxsize=2)
q.put("a")
q.put("b")
try:
q.put_nowait("c")
except queue.Full:
print("队列已满,put_nowait 不阻塞,直接抛 queue.Full")
q.get()
q.get()
try:
q.get_nowait()
except queue.Empty:
print("队列已空,get_nowait 不阻塞,直接抛 queue.Empty")
很多刚接触并发的新手,会误以为用 get() 会阻塞线程导致程序卡死,于是到处找“不堵塞”的写法。其实阻塞本身不是问题,问题在于有没有设置合理的超时。比如消费端建议写成 get(timeout=5),五秒没有新任务就做一轮清理或者心跳上报,而不是永远卡在那里;生产端建议写成 put(timeout=3),队列满了三秒就记录告警,而不是让线程永远挂在 put 上。
还有一点:queue.Queue 内部是 deque 加 Condition,单生产者单消费者的场景下锁开销也不算小。如果只是在线程间传递一批任务,追求极致吞吐,可以考虑更轻量的结构。但绝大多数业务场景用 queue.Queue 就够了,不要过早优化。
3.3 线程池的阻塞队列选型,别拍脑袋
线程池的核心参数除了线程数,就是工作队列。工作队列选型直接决定线程池在过载时的表现。以 Java ThreadPoolExecutor 为例,常用的 BlockingQueue 有这么几类:
| 队列 | 特性 | 适用场景 |
|---|---|---|
| LinkedBlockingQueue | 可无界,也可指定容量 | 任务数量可控、希望排队不拒绝 |
| ArrayBlockingQueue | 有界,固定容量 | 明确限制任务积压,配合拒绝策略 |
| SynchronousQueue | 不缓存任务,直接交接 | 任务短小、希望立即执行或触发扩容 |
| PriorityBlockingQueue | 按优先级出队 | 有明确优先级要求的任务调度 |
重点说无界 LinkedBlockingQueue 的坑。线程池核心线程数量有限,一旦核心线程都在忙,新任务会全部塞进队列。因为队列永远不会满,线程池永远不会触发 maxPoolSize 的扩张逻辑,任务只能无限堆积。表面上看“没有被拒绝”,实际上积压带来的延迟和内存压力会渐渐把服务拖垮。
我自己在线上更多用有界 ArrayBlockingQueue 加 CallerRunsPolicy。队列满时,拒绝策略让生产者线程自己执行任务,把压力回传给生产者,形成自然的背压。这样既不会丢任务,也不会让线程池无限积压。如果使用 AbortPolicy,满了直接抛 RejectedExecutionException,适合明确需要告警的场景;DiscardPolicy 要慎用,因为它会悄悄丢掉任务,没有日志,出问题很难发现。
3.4 一个直接能跑的生产者消费者样例
写一个简单但完整的生产者消费者模型,直观感受阻塞队列的用法:
python复制import threading
import queue
import time
q = queue.Queue(maxsize=5)
def producer(name):
for i in range(10):
item = f"{name}-{i}"
q.put(item)
print(f"生产: {item}")
time.sleep(0.05)
def consumer(name):
while True:
item = q.get()
if item is None:
break
print(f"消费: {item},消费者 {name}")
time.sleep(0.1)
threads = [
threading.Thread(target=producer, args=("P1",)),
threading.Thread(target=producer, args=("P2",)),
]
for i in range(3):
threads.append(threading.Thread(target=consumer, args=(f"C{i}",)))
for t in threads:
t.start()
for t in threads:
t.join()
print("全部任务处理完成")
消费者用 None 作为结束信号,是常见做法;真实系统里更常用超时或者专用的关闭钩子来收尾。如果忘了 put 结束信号,消费者线程会永远阻塞在 get() 上,进程退出时表现得很诡异。这是我调试多线程队列代码时经常遇到的小坑,建议你在生产代码里设计一个明确的 close 流程,不要学这个示例偷懒。
4. 从单机队列到分布式消息队列:重复消费这个坑怎么解
基础队列解决的是进程内共享内存的缓冲问题;消息队列则把队列搬到了网络上,变成独立服务。Redis List、RabbitMQ、Kafka、RocketMQ 本质上都是“更可靠的队列实现”。但分布式环境下问题也来了:消息会重复。这一节重点聊聊重复消费这个高频问题。
4.1 先承认一件事:消息队列做不到“恰好一次”
很多团队一上消息队列,就喜欢问“消息会不会丢、会不会重复”。成熟的中间件通常会给出一个诚实的答案:至少一次语义。也就是说,消息不丢,但消费者可能会重复收到同一条消息。为什么?
核心原因是处理和确认不是同一个原子操作。消费者从 Kafka 拉取消息,处理完业务逻辑,正要提交 offset 告知服务端“这条我消费完了”,程序在这时崩了;重启后订阅关系还在,broker 会从上一次没有提交的位置重新投递。还有一种情况:消息处理成功了,但 ack 响应在网络传输中丢失,服务端误以为没消费,又投递一次。两种情况下,业务方都会看到重复消息。
所以在设计消费逻辑时,不要指望消息中间件自动给你不重复。业务侧必须天生具备幂等性,也就是同一条消息处理多次和只处理一次,最终结果一致。
4.2 幂等消费的三个靠谱姿势
幂等消费不是一个库能解决的,得看业务怎么设计。我常用的有三类方案。
第一,唯一业务ID加数据库去重表。给每条消息生成一个全局唯一 ID,消费时先往去重表里插入记录,message_id 设为唯一索引。插入成功后执行真正的业务逻辑;如果插入时唯一键冲突,说明这一条已经处理过,直接跳过。这个方案成本最低也最可靠,我做订单回调系统时就是靠一张去重表,把上游重复投递的消息全部挡在外面的。
sql复制CREATE TABLE message_consume_record (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
message_id VARCHAR(64) NOT NULL,
biz_key VARCHAR(128) NOT NULL,
consume_time DATETIME NOT NULL,
UNIQUE KEY uk_message_id (message_id)
) ENGINE=InnoDB;
第二,Redis SETNX 抢占。用消息 ID 作为 key,setnx 成功才处理,处理结束后不立刻删 key,而是设置一个过期时间。优点是快,适合对一致性要求稍低的场景;缺点是如果业务处理时间超过 key 的过期时间,会有并发窗口,消息可能被同时处理两次。
第三,业务状态机幂等。比如订单状态只允许从“待支付”变成“已支付”,再收到重复的“支付成功”消息时,发现当前状态已经是“已支付”,直接忽略。这个方案和业务强耦合,但性能最好,不需要额外的去重存储。
我的建议是优先组合使用:涉及金额、库存、订单状态的一定要做状态机幂等,同时配合去重表兜底;纯通知类的消费可以降低标准。别把方案的复杂度叠得太高,否则重复消息问题还没解决,幂等框架本身就变成新的维护负担了。
4.3 排查线上重复消息的经验
遇到重复消费,第一反应不要改代码,先定位重复是在哪个环节产生的。我一般按这个顺序查:
- 把消息唯一 ID 和业务主键同时打进日志。没有这两个字段,重复消息站在你面前你也认不出来。
- 查同一条消息的投递时间和消费时间。重复消息通常会在第一次消费成功但没有提交后的几秒或几分钟内重新出现。
- 对照第一次和第二次的消费日志。如果第一次日志显示消费成功并修改了业务数据,那么问题出在提交阶段;如果第一次日志显示抛了异常,那么问题出在消费逻辑本身。
- 检查 offset 或 ack 的提交位置。采用手动提交时,确认是先提交业务还是先业务后提交。先业务后提交存在重复窗口,这能解释很多线上问题。
总之一句话:把“消息ID、业务主键、消费开始时间、消费结束时间、提交结果”都记全,线上排障会顺畅得多,光靠猜的话,一整天可能都定位不了根因。
5. 队列在 PHP、Windows 和集群调度里的不同面孔
最后绕回来看看队列在不同语言和系统里的落地。这一节更像是一张“队列实现图谱”,但底层还是我前面手写的数组队列和链表队列那套思路。
5.1 PHP 队列:从 array_shift 到 Redis List
PHP 里最土的队列写法,就是数组加 array_shift:
php复制$queue = [];
$queue[] = 'task1';
$queue[] = 'task2';
$task = array_shift($queue);
这段代码不是不能用,但 array_shift 会让数组整体重新索引,任务量一大就是 O(n),性能很难看。写脚本临时处理几个任务没问题,长期跑队列就需要换实现。
PHP 自带的 SplQueue 是双向链表实现的队列,入队出队都是 O(1):
php复制$queue = new SplQueue();
$queue->enqueue('task1');
$task = $queue->dequeue();
不过 SplQueue 只能在一个 PHP 进程里使用,多个 worker 之间没法共享。生产环境更常见的方案是 Redis List 队列:生产者把任务序列化后 LPUSH 到列表,消费者用 BRPOP 阻塞读取。BRPOP 在队列空的时候会挂起等待,不会空转烧 CPU,比轮询 Redis 优雅得多。
php复制// 生产者
$redis->lpush('task_queue', json_encode(['order_id' => 12345]));
// 消费者,循环执行
while ($data = $redis->brpop('task_queue', 30)) {
$task = json_decode($data[1], true);
handle_task($task);
}
这里要多说一句:Redis List 队列同样会遇到重复或者丢失的问题。如果消费者 BRPOP 取出任务后,在处理完成之前进程挂了,任务就丢了;如果处理完后还没来得及确认,另一个消费者又取到了相同任务,就会重复处理。很多 PHP 框架会结合 RPOPLPUSH 或 BRPOPLPUSH,先把任务“备份”到另一个列表,处理成功后再从备份删除,这一招能明显降低丢失概率,但也只是把问题窗口缩短,不是根治。
5.2 Windows 消息队列与“队列对”是怎么回事
Windows 窗口程序的消息机制,是理解操作系统级队列的好例子。当用户按下键盘、移动鼠标、触发窗口重绘时,系统会把对应的事件封装成消息,放进目标线程的消息队列。窗口程序通过 GetMessage 或 PeekMessage 从队列里取消息,再分发给窗口过程处理。这里的队列和我们手写的队列没有本质区别,生产者是操作系统,消费者是你的窗口循环,消息内容是 WM_* 消息而不是网络包。
“队列对”这个概念,常见于通信模块的设计。一次完整的请求/响应,往往需要两个队列配合:请求队列和响应队列。客户端把请求投递到请求队列,服务端从请求队列取出业务参数并处理,处理完把结果放进响应队列,客户端再从响应队列取回结果。很多 RPC 框架、任务分发系统都是这么设计的。如果只用一条队列,请求和响应混在一起,消费者每次都得先判断消息类型,业务表达和排障都会变复杂。看到“队列对”这个词时不用一头雾水,它就是两个队列相互配合完成双向通信的套路。
5.3 集群调度里的队列安排:用 bqueues 查看队列权限
在 LSF 这类高性能计算集群里,用户提交的作业要先进入调度队列,再由调度器分配计算节点。作业队列本质上也是队列实现,只不过队列里的“元素”是作业,队列的“容量”是 CPU、内存、许可证这些集群资源。调度器会按照优先级、用户权限、资源需求决定谁先运行,这已经是一种远比基础 FIFO 复杂得多的队列安排。
实操上,提交作业前先花几秒用 bqueues 看一下队列权限,可以避免提交后作业一直处于 PEND 状态的尴尬:
bash复制# 查看当前集群所有队列
bqueues
# 查看某个队列的详细配置、权限和资源限制
bqueues -l normal
# 查看某个用户可用的队列
bqueues -u yourname
我见过同事把作业提交到没有权限的队列,结果作业显示 PEND 一整个下午,所有人都以为集群故障了,最后排查半天才发现是队列权限配置问题。所以先看 bqueues,确认你的用户组能访问哪个队列,再提交作业,能省下大量无效等待时间。把这段经验放在“队列基础实现”里,是想说明一个道理:队列不仅存在于代码的数据结构里,也存在于系统调度里;而调度的本质,仍然是 FIFO、优先级和资源约束之间的平衡。
最后再分享一个我自己长期保留的习惯:不管是用哪种队列,动手之前先回答三个问题——谁来生产、谁来消费、满了怎么办。具体来说,生产者的写入频率和峰值是多少?消费者的并发数有多少,单条消息的平均处理时长又是什么量级?队列容量该设为多大,超过容量时是拒绝、重试还是落盘?这些数字不需要精确到毫秒,但脑子里一定要有个数量级。有了这个数量级,选环形数组还是链表、选有界还是无界、选 Redis 还是 Kafka,答案几乎自己就浮出来了。
