2、Redis消息队列:Redis List数据结构、LPUSH/BRPOP实现、Redis Pub/Sub模式

好,咱们今天聊聊Redis做消息队列这件事。

说实话,很多新手一上来就上Kafka或者RabbitMQ,其实有点杀鸡用牛刀。我早期做手游后端的时候,服务器资源紧张得很,一个4核8G的机器要撑起几千人在线。那时候我就琢磨,能不能用Redis把消息队列的活也干了?答案是:能,而且用得好的话,性能非常可观。

2.1 Redis List数据结构:消息队列的基石

先说说List。Redis的List本质上是一个双向链表。你想想看,链表的特点是什么?头尾操作快,中间操作慢。这不正好适合消息队列吗?我们只需要从一端塞消息,从另一端取消息,完美避开中间操作。

我个人习惯把List比作一个水管。LPUSH就是从水管的一头往里塞乒乓球,RPOP就是从另一头把乒乓球取出来。反过来也行,BRPOP就是阻塞式地等在水管出口,有球来了立刻拿走。

核心要点:List作为消息队列,天然支持FIFO(先进先出)。你用LPUSH塞,用BRPOP取,顺序就对了。

这里有个细节要注意:List本身不限制长度。我在项目中遇到过一个问题,某个业务线的消息生产速度远大于消费速度,结果List越积越长,最后撑爆了Redis内存。嗯,后来我们加了长度监控和限流,才稳住。

2.2 LPUSH/BRPOP实现:生产者和消费者

咱们直接上代码。先看生产者怎么往队列里塞消息:

// 生产者:推送消息到队列
public void pushMessage(String queueKey, String message) {
    jedis.lpush(queueKey, message);
    // 我个人习惯在push之后记录一下队列长度
    Long len = jedis.llen(queueKey);
    if (len > 10000) {
        log.warn("队列 {} 长度已达 {}, 请关注消费速度", queueKey, len);
    }
}

再看消费者。这里有个关键点:用BRPOP而不是RPOP。为什么?因为BRPOP是阻塞的,没有消息的时候它会挂在那里等,而不是空转轮询。你想想看,如果用RPOP加死循环,CPU直接拉满,服务器风扇呼呼转,运维大哥就该来找你喝茶了。

// 消费者:阻塞式获取消息
public void consumeMessage(String queueKey) {
    // 超时时间设为0表示一直阻塞,直到有消息
    List<String> result = jedis.brpop(0, queueKey);
    if (result != null) {
        // result[0]是队列名,result[1]是消息内容
        String message = result.get(1);
        processMessage(message);
    }
}

避坑指南:我曾经在生产环境遇到过BRPOP超时设置不当的问题。如果你设了超时时间比如30秒,那么30秒内没消息就会返回null。这时候你的循环逻辑要处理好,别把null当成正常消息处理了。

还有一个坑:多个消费者同时BRPOP同一个队列时,Redis会保证每条消息只被一个消费者取走。这其实就是个简单的竞争消费模式。我做过一个排行榜更新服务,就是用这种方式把计算任务分发给多个worker,效果还不错。

2.3 Redis Pub/Sub模式:发布订阅

说完List,咱们聊聊Pub/Sub。这俩有啥区别?我打个比方你就明白了。

List模式像是一个快递站。你把包裹(消息)放到货架上,快递员(消费者)来取。取走了,货架上就没了。一个包裹只能被一个快递员取走。

Pub/Sub模式像是一个广播电台。主播(发布者)在直播间说话,所有听众(订阅者)都能听到。而且听众只听直播时的内容,你错过了直播时间,就听不到了。

这个区别很关键。Pub/Sub的消息是即时的,没有持久化。消费者如果不在线,消息就丢了。

// 发布者
public void publish(String channel, String message) {
    jedis.publish(channel, message);
}

// 订阅者(需要单独线程运行)
public void subscribe(String channel) {
    JedisPubSub listener = new JedisPubSub() {
        @Override
        public void onMessage(String channel, String message) {
            System.out.println("收到消息: " + message);
            // 这里处理业务逻辑
        }
    };
    jedis.subscribe(listener, channel);
}

重要提醒:Pub/Sub的订阅操作是阻塞的。你调用了subscribe之后,当前线程就卡在那里了,专门等着收消息。所以一定要用单独的线程来处理订阅,别把主线程堵死了。我见过有人直接在业务线程里调subscribe,结果整个服务都hang住了,血的教训。

2.4 List vs Pub/Sub:怎么选?

我整理了一个对比表,你一看就明白了:

特性 List + BRPOP Pub/Sub
消息持久化 有,消息存在List里 无,离线就丢失
消费模式 竞争消费(一个消息一个消费者) 广播消费(一个消息所有订阅者都收到)
性能 高,适合大量消息堆积 高,但订阅者过多时压力大
适用场景 任务队列、延迟任务、异步处理 实时通知、状态广播、聊天室
可靠性 高,消息不会丢 低,订阅者离线消息就没了

我个人建议:如果你的消息丢了会造成业务问题,比如订单处理、支付回调,那就老老实实用List。如果只是发个通知、刷新一下缓存,丢了也无所谓,那Pub/Sub用起来更轻量。

2.5 实战中的一些经验

最后分享几个我在项目中踩过的坑,你遇到了可以少走弯路:

  • 消息体不要太大。我见过有人把整个JSON对象塞进去,一个消息几MB。Redis是单线程的,大消息会阻塞其他操作。建议控制在10KB以内。
  • 消费失败要重试。用List的时候,如果消费失败,可以把消息重新LPUSH回去,或者放到一个专门的失败队列里。别直接丢掉。
  • 监控队列长度。我习惯在监控系统里加一个队列长度的指标,超过阈值就报警。这样能提前发现消费瓶颈。
  • Pub/Sub的频道命名要有规范。比如用冒号分隔:game:chat:room1、game:notice:global。这样好管理,也方便做权限控制。

好了,关于Redis消息队列的基础就聊到这里。说白了,List和Pub/Sub就是Redis给咱们的两把武器,用好了能解决很多实际问题。下一章咱们聊聊更高级的玩法——Redis Stream,那才是真正为消息队列设计的专业数据结构。