Redis + 长轮询 消息队列完整方案
理清核心思路:
长轮询 = HTTP 客户端挂住等待;Redis = 消息存储 + 阻塞等待原语(BLPOP)
两者组合就是:服务端收到长轮询请求 → 调用 Redis 阻塞弹出命令,有消息立刻返回,超时返回空,客户端马上重连。
区分两个容易混淆概念:1.1. 业务层长轮询:HTTP 层面,前端 ↔ 应用服务
- Redis 阻塞队列(BLPOP/BRPOP):应用服务 ↔ Redis,属于服务内部阻塞等待
很多人会混在一起,整套方案是二者叠加。
一、核心组件
- Redis List(队列)
LPUSH key msg:生产者推送消息BLPOP key timeout:阻塞取消息
- API 服务(Go/Java/Node):承接前端 HTTP 长轮询
- 前端:收到响应立刻发起下一轮长轮询
整体时序
plaintext
【生产者】
LPUSH order_notify {"orderId":123}
【前端】 → HTTP GET /api/wait-message
【API服务】执行:BLPOP order_notify 30
↓
Redis 队列暂无消息 → 阻塞等待
消息到达 → Redis 返回消息 → API 立刻 HTTP 响应前端
前端收到 → 立即再次发起 /api/wait-message
如果 30s 内无消息 → BLPOP 返回 nil
API 返回空数据,前端同样重建长轮询
二、Redis 关键命令
1)基础阻塞队列(List + BLPOP)
redis
# 生产者推送消息
LPUSH order_notify '{"id":1}'
# 消费者阻塞读取,最多等待30秒
BLPOP order_notify 30
特性:
- 消息取出即删除,无重试机制;
- 点对点模式:一条消息只会被一个消费者抢到;
- 适合通知类消息、不要求可靠投递场景。
2)缺陷:消息丢失风险
如果 API 拿到消息后,还没返回前端进程崩溃,消息直接丢了。
👉 改进方案:带 “待处理队列” 的可靠队列(RPOPLPUSH + BRPOPLPUSH)
redis
# 生产者 LPUSH queue msg # 消费者:取出消息,自动移入pending队列,防止丢失 BRPOPLPUSH queue pending_queue 30
业务处理成功后,再
LREM pending_queue msg 删除;
处理失败 / 崩溃,后台定时任务扫描 pending 队列,超时消息放回主队列重试。
三、最简架构两种模式
模式 A:单队列,多客户端竞争(一对一通知)
适用:订单通知、任务分发
- 一条消息,任意一个在线客户端收到即可
缺点:无法实现 “所有人收到同一条消息”(广播)
模式 B:广播场景(每个用户独立队列)
场景:站内公告、全员推送
plaintext
queue:user:1001 queue:user:1002
生产者循环 LPUSH 推送到每个用户队列;
弊端:用户量大时 Redis 键数量爆炸。
优化方案:Pub/Sub
四、Redis Pub/Sub + 长轮询(广播方案)
注意:Pub/Sub 不持久化!离线消息直接丢失!流程:
- API 服务启动时 SUBSCRIBE channel
- 收到前端长轮询请求,把请求上下文暂存内存(Map<userId, Response>)
- 生产者 PUBLISH channel msg
- Redis 推送消息给所有订阅的 API 实例
- API 找到等待中的 HTTP 连接,直接返回消息
⚠️ 巨大短板:
消息只发给当前在线且有长轮询挂起的客户端;用户离线期间消息彻底丢失。
适合实时弹幕、在线互动,不适合订单通知、支付消息。
五、完整伪代码演示(Go 风格逻辑)
go
运行
// 长轮询接口
func WaitMessage(w http.ResponseWriter, r *http.Request) {
userId := r.Query("uid")
// 阻塞等待消息,最长30s
result, err := redisClient.BLPop(ctx, 30*time.Second, "queue:"+userId).Result()
if err == redis.Nil {
// 超时无消息,返回空
json.NewEncoder(w).Encode(map[string]any{"data": nil})
return
}
// 拿到消息,返回前端
json.NewEncoder(w).Encode(map[string]any{"data": result[1]})
}
前端逻辑:
js
运行
function poll() {
fetch("/api/wait-message?uid=1001")
.then(res => res.json())
.then(resp => {
if(resp.data) {
handleMessage(resp.data)
}
// 无论有无消息,立刻再次发起请求
poll()
})
}
poll()
六、生产环境必踩坑清单
1. 负载均衡无粘滞会话会出问题吗?
✅ 使用 Redis 队列(BLPOP)不会
请求落在任意 API 实例,最终都去同一个 Redis 拉消息;
❌ 如果是 Pub/Sub + 内存缓存等待连接:必须会话黏滞,否则消息到达 A 实例,等待连接在 B 实例,无法推送。
2. Nginx 代理超时
长轮询 30s 超时,Nginx 默认
proxy_read_timeout 60 够用;
如果你的阻塞超时设为 60s,务必调大:
nginx
location /api/wait-message {
proxy_pass http://backend;
proxy_read_timeout 60s;
}
否则 Nginx 主动断开连接,前端收到异常。
3. 并发连接上限
每个前端长轮询占用一条 HTTP 连接。
1 万在线用户 = 近万条 TCP 连接,需要调优:
- 操作系统 fd 上限
- Web 服务最大连接数(Gin、Tomcat、Node)
4. 消息堆积风险
生产者推送速度 > 消费速度,Redis List 持续膨胀;
监控 list 长度,超过阈值告警。
5. 多实例重复消费?
BLPOP 是原子操作,一条消息只会被一个消费者取出,天然防重复。
若业务需要幂等,自己在消息里带上唯一 msgId。
七、方案选型对比
-
点对点通知(订单、任务)
首选:Redis List + BLPOP + HTTP 长轮询
追求可靠:BRPOPLPUSH 实现死信 / 重试队列
-
广播通知(全员公告)
- 允许丢失离线消息:Pub/Sub + 长轮询
- 需要保存离线消息:每个用户独立 List 队列
- 如果可以选择 WebSocket
同等场景下 WebSocket 优于长轮询:更少 HTTP 头开销、双向通信;
长轮询一般作为 WebSocket 降级兜底(防火墙拦截 WS 时启用)
八、延伸:和 Stream 的区别(Redis 5.0+)
很多人会问:为什么不用 Stream(XREAD BLOCK)?
- List BLPOP:简单、轻量,适合简单通知
- Stream XREAD:支持消息持久化、消费者组、ack、重试、多流,适合更严谨的消息队列
整套架构逻辑不变,只是把 BLPOP 替换成 XREAD BLOCK。
如果你想要,我可以给一份:
- 可直接部署的 Nginx 长轮询配置
- Java / Go 完整最小可运行 Demo
- 带失败重试、死信队列的可靠版本代码
