小站消息队列选型:从数据库表到Redis

一、消息队列到底解决了什么问题

先说一个前提:绝大多数个人站、小团队站根本不需要消息队列。一块表加一个定时任务,就能解决 99% 的「异步处理」需求,而且排查起来只需要一条 SQL。真正需要引入消息队列的场景只有三类:第一,处理速度跟不上生产速度,需要削峰,比如一次活动涌进来几万个需要发邮件的任务;第二,同一件事要触发多个下游,且下游可以各自失败、各自重试;第三,上游和下游属于不同的团队或不同的技术栈,需要一个稳定的中间层约定。

如果三条里一条都不占,引入消息队列只会带来新的运维负担:多一个常驻服务、多一套监控、多一种「消息去哪了」的排查路径。所以第一步不是选型,而是算账。把「生产者每秒产生多少条、消费者每秒能处理多少条、峰值是均值的几倍、能接受多长的延迟」这四个数写下来,如果峰值也只是均值的两倍、延迟能接受一分钟,那么定时任务就够了。

本文讨论的是确实需要引入的情况。下面的选型建议基于「一个人在维护」这个约束,而不是「公司有专门的中间件团队」。

二、三条技术路线,先分清层次

市面上常被放在一起比较的东西,其实不在一个层次上。Redis 的 List 和 Stream 是「用缓存顺便做队列」,RabbitMQ 是「正经的通用消息中间件」,Kafka 是「日志型的分布式流平台」。它们要解决的问题不同,用错地方的代价也不同。

Redis 做队列的优势是零新增服务(如果你本来就有 Redis),延迟极低,实现简单。缺点是持久化不是为消息设计的:AOF 默认 everysec,掉电最多可能丢一秒的数据;主从切换时未同步的部分也会丢。适合「丢几条无所谓、但延迟要低」的任务,比如站内通知、非关键日志。抗不住的情况是「一条都不能丢」的支付回调。

RabbitMQ 的优势是消息级别的确认和重投,路由能力很强,管理界面好用。缺点是单个队列的吞吐有限(几万条每秒就要开始考虑集群),并且它的内存和磁盘使用是跟着队列积压增长的。适合「量中等、要求不丢、路由规则复杂」的场景,比如订单状态变更要分发给库存、积分、通知三个消费者。

Kafka 的优势是吞吐极高(单机十万级),消息可回放(按 offset 重读),天生为多消费者设计。缺点是运维复杂:依赖 ZooKeeper(新版用 KRaft 但仍需规划),分区数一旦确定不好改,消费位点的管理需要自己理解清楚。适合「日志、埋点、变更流」这类量大且允许多个下游独立消费的场景。个人站几乎用不到。

三、一个更容易上手的选择:数据库表

在讨论上面三个之前,我想先把「用数据库表做队列」这条路线认真说一遍,因为它在中小规模下往往是最优解,而且常被过早淘汰。

CREATE TABLE task_queue (
  id         BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
  type       VARCHAR(32)  NOT NULL,
  payload    MEDIUMTEXT   NOT NULL,
  status     TINYINT      NOT NULL DEFAULT 0,   -- 0 待处理 1 处理中 2 成功 3 失败
  attempts   SMALLINT     NOT NULL DEFAULT 0,
  available_at DATETIME   NOT NULL,
  locked_by  VARCHAR(64)  DEFAULT NULL,
  locked_at  DATETIME     DEFAULT NULL,
  created_at DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
  KEY idx_ready (status, available_at),
  KEY idx_locked (status, locked_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

取任务用一条带 FOR UPDATE SKIP LOCKED 的语句,多个 worker 并发也不会抢到同一条。这是 MySQL 8.0 之后才有的能力,5.7 上需要用 UPDATE ... WHERE status=0 LIMIT 1 加行锁模拟,性能差一些但也能用。

START TRANSACTION;
SELECT id, type, payload FROM task_queue
 WHERE status = 0 AND available_at <= NOW()
 ORDER BY id LIMIT 10
 FOR UPDATE SKIP LOCKED;
-- 拿到 id 列表后
UPDATE task_queue SET status = 1, locked_by = ?, locked_at = NOW(), attempts = attempts + 1
 WHERE id IN (...);
COMMIT;

这套方案的好处非常实在:任务状态可以直接用 SQL 查,出问题不需要学任何新工具;重试逻辑写在业务代码里,想加什么都行;失败任务留在表里,肉眼可见。代价是吞吐上限在每秒几百到几千条,且轮询会有延迟。对绝大多数站来说,这个上限远远够用。

必须补的两件事:一是「处理中」的任务需要有一个超时回收机制,worker 崩了以后锁要能释放,否则任务永远卡在 status=1。二是表会一直变大,成功超过七天的任务要定期归档到历史表,否则索引膨胀后查询会变慢。

四、如果真的要用 Redis:优先 Stream 而不是 List

很多人用 LPUSH 加 BRPOP 做队列,这在简单场景下能用,但它有一个致命问题:消息一旦被 BRPOP 取出,就从 Redis 里消失了。worker 在处理过程中崩溃,这条消息就丢了,没有任何补救机会。要自己做「取出后备份到 processing 列表、成功后再删」,等于自己实现一遍确认机制,很容易做漏。

Redis 5.0 引入的 Stream 类型原生支持这个模式:消费者组、消息确认、未确认消息重新投递。XADD 追加,XREADGROUP 读取,处理完成后 XACK 确认,超时未确认的可以用 XAUTOCLAIM 抢回来。这套语义和 RabbitMQ 接近,但不需要额外服务。

# 生产
XADD tasks * type send_mail to user@example.com

# 建消费组(只建一次,MKSTREAM 允许流不存在时创建)
XGROUP CREATE tasks workers 0 MKSTREAM

# 消费(> 表示只读新消息)
XREADGROUP GROUP workers w1 COUNT 10 BLOCK 5000 STREAMS tasks >

# 处理成功后确认
XACK tasks workers 1699999999999-0

# 回收超过 60 秒未确认的消息
XAUTOCLAIM tasks workers w1 60000 0-0 COUNT 10

要注意的问题有三个:第一,Stream 会一直增长,必须定期 XTRIM 或者用 MAXLEN 限制长度,否则内存会被吃光;第二,AOF 的持久化策略仍然影响可靠性,appendfsync always 能保证不丢但吞吐会掉到几千;第三,主从切换时 Stream 的幂等性要靠消费端自己做去重,Redis 不保证严格一次。

五、RabbitMQ 值得上的信号

什么时候该从数据库表或者 Redis 换到 RabbitMQ?我总结了几个信号:一是任务类型超过五种、每种的重试策略和超时时间都不一样,写在代码里已经开始用 if 堆;二是需要「一条消息分发给三个不同系统」并且各自有独立的失败重试;三是积压量开始影响到 Redis 的内存(Redis 是用来做缓存的,队列把内存吃满会导致缓存大面积失效,这个连锁反应很难受);四是需要可视化的管理界面来看队列深度和消费速率。

部署上,个人场景用单节点加持久化队列就够,不要上来就搞镜像队列集群,那是为高可用设计的,配置复杂且性能会下降。真正需要做的是三件事:声明 durable 队列、消息发送时设 delivery_mode=2、消费端用 manual ack 且业务成功后才 ack。忘掉任何一条,断电时就可能丢消息。

# 声明持久化队列(PHP amqp 扩展写法)
$ch->queue_declare('mail_jobs', false, true, false, false);
// 参数顺序:queue, passive, durable, exclusive, auto_delete

# 发布持久化消息
$msg = new AMQPMessage($json, ['delivery_mode' => 2]);
$ch->basic_publish($msg, '', 'mail_jobs');

# 消费端:手动 ack,一次预取 10 条
$ch->basic_qos(null, 10, null);
$ch->basic_consume('mail_jobs', '', false, false, false, false, $callback);

function $callback($msg) {
    // 业务处理
    $ch->basic_ack($msg->delivery_info['delivery_tag']);
}

预取数量(prefetch)是个需要调的参数。设得太小,消费端大部分时间在等网络;设得太大,一次把一个 worker 的内存塞满,而且一旦 worker 崩溃,这一批消息全部要重新投递。经验值是从 10 开始,看消费延迟和内存占用再调。

六、积压了怎么办

队列积压是这类系统最常见的事故,处理顺序很重要,顺序错了会把小问题放大成事故。

第一步是判断积压的原因。是生产者突然变多(活动、爬虫、重试风暴),还是消费者变慢了(下游接口变慢、数据库锁等待、代码发布引入了 bug)?看两个数:入队速率和出队速率。如果入队没变而出队掉了,问题在消费端,去查消费端的日志和下游依赖,不要急着加机器。

第二步,如果是生产者突增,先看能不能在入口限流。很多人第一反应是加消费者,但如果下游数据库扛不住,加消费者只会让数据库先崩。限流比扩容更安全。

第三步,如果要扩容,先确认消费端是否可以并行。如果消费逻辑里有「按用户有序处理」的约束,盲目加 worker 会导致同一用户的任务乱序。这类约束必须事先写清楚,事后才发现基本只能回滚。

第四步,积压处理完之后要有「降级丢弃」的预案。有些任务过时了就没有意义,比如三小时前的「新文章通知」推给用户只会让人困惑。给消息带一个 expires_at,消费时判断过期就直接 ack 掉,比硬着头皮处理完更合理。

七、监控看什么

队列系统的监控不需要复杂,五个数就够:当前队列深度、入队速率、出队速率、消费失败率、最老未处理消息的年龄。前四个数字大部分中间件后台都有,最后一个最容易被忽略但最有价值:队列深度可能只有一百条,但最老那条已经等了四个小时,说明消费端被某条消息卡住了,正在死循环重试。

# RabbitMQ 看队列深度与消费者数
rabbitmqctl list_queues name messages consumers

# Redis Stream 看积压(pending 数量)
redis-cli XPENDING tasks workers - + 10

# 数据库表方案看积压与超时锁
SELECT status, COUNT(*) FROM task_queue GROUP BY status;
SELECT id, attempts, locked_at FROM task_queue
 WHERE status = 1 AND locked_at < NOW() - INTERVAL 10 MINUTE;

报警阈值不要设成绝对数。队列深度 1000 对一个每天几万任务的站来说是异常,对一个批量导入系统来说是日常。用「最老消息年龄超过业务能忍受的延迟」做报警,比队列深度更有意义。

八、选型的一句话结论

如果现在的需求是「发邮件、生成缩略图、清理过期数据」这类,用数据库表加定时任务,最多加一个带 SKIP LOCKED 的多 worker,这是最省心的路线,也是最容易排查的。如果已经开始出现「多下游分发、复杂重试」的迹象,并且手上有现成的 Redis,上 Stream 是平滑的一步。如果消息绝对不能丢、需要可视化管理和流量控制,再考虑 RabbitMQ。Kafka 请等到有明确的「日志量大且多消费者独立消费」需求时再说,为了用而用只会让你多一份永远学不透的运维债。

九、小结

消息队列是一个「能力越大、责任越大」的组件。它在解决削峰和解耦的同时,把「同步调用的失败」变成了「异步状态的追踪」,你需要新的工具、新的指标、新的排障思路。所以在引入之前,先把数据库表方案做扎实,先积累一批真实的、可复查的任务数据,等真正知道自己的瓶颈在哪,再选对应的中间件。这一步慢一点,后面会省很多事。

Last modification:November 8th, 2026 at 01:28 pm

Leave a Comment