i007.cc

i007.cc

优先队列-降维打击

05.价值资料

Redis + 长轮询 消息队列完整方案

理清核心思路:

长轮询 = HTTP 客户端挂住等待;Redis = 消息存储 + 阻塞等待原语(BLPOP)

两者组合就是:服务端收到长轮询请求 → 调用 Redis 阻塞弹出命令,有消息立刻返回,超时返回空,客户端马上重连。

区分两个容易混淆概念:

1.1. 业务层长轮询:HTTP 层面,前端 ↔ 应用服务

  1. Redis 阻塞队列(BLPOP/BRPOP):应用服务 ↔ Redis,属于服务内部阻塞等待

    很多人会混在一起,整套方案是二者叠加。

一、核心组件

  1. Redis List(队列)
    • LPUSH key msg:生产者推送消息
    • BLPOP key timeout:阻塞取消息
  2. API 服务(Go/Java/Node):承接前端 HTTP 长轮询
  3. 前端:收到响应立刻发起下一轮长轮询

整体时序

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 不持久化!离线消息直接丢失!

流程:

  1. API 服务启动时 SUBSCRIBE channel
  2. 收到前端长轮询请求,把请求上下文暂存内存(Map<userId, Response>)
  3. 生产者 PUBLISH channel msg
  4. Redis 推送消息给所有订阅的 API 实例
  5. 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。

七、方案选型对比

  1. 点对点通知(订单、任务)

    首选:Redis List + BLPOP + HTTP 长轮询

    追求可靠:BRPOPLPUSH 实现死信 / 重试队列

  2. 广播通知(全员公告)
  • 允许丢失离线消息:Pub/Sub + 长轮询
  • 需要保存离线消息:每个用户独立 List 队列
  1. 如果可以选择 WebSocket

    同等场景下 WebSocket 优于长轮询:更少 HTTP 头开销、双向通信;

    长轮询一般作为 WebSocket 降级兜底(防火墙拦截 WS 时启用)

八、延伸:和 Stream 的区别(Redis 5.0+)

很多人会问:为什么不用 Stream(XREAD BLOCK)?
  • List BLPOP:简单、轻量,适合简单通知
  • Stream XREAD:支持消息持久化、消费者组、ack、重试、多流,适合更严谨的消息队列
整套架构逻辑不变,只是把 BLPOP 替换成 XREAD BLOCK。
如果你想要,我可以给一份:
  1. 可直接部署的 Nginx 长轮询配置
  2. Java / Go 完整最小可运行 Demo
  3. 带失败重试、死信队列的可靠版本代码

发表回复