消息队列(MQ),核心:都是「一发一存一消费」,再直白点就是一个「转发器」。

消息队列

1、核心优点

解耦、异步、削峰限流

2、缺点

系统可用性降低: 系统可用性在某种程度上降低,为什么这样说呢?在加入MQ之前,你不用考虑消息丢失或者说MQ挂掉等等的情况,但是,引入MQ之后你就需要去考虑了!

系统复杂性提高: 加入MQ之后,你需要保证消息没有被重复消费、处理消息丢失的情况、保证消息传递的顺序性等等问题!

一致性问题: 我上面讲了消息队列可以实现异步,消息队列带来的异步确实可以提高系统响应速度。但是,万一消息的真正消费者并没有正确消费消息怎么办?这样就会导致数据不一致的情况了!

MQ应用有很多,比如ActiveMQ,RabbitMQ,Kafka等,但是也可以基于redis来实现,可以降低系统的维护成本和实现复杂度,redis中实现消息队列的几种方案。

1. 基于List的 LPUSH+BRPOP 的实现
2. PUB/SUB,订阅/发布模式
3. 基于Sorted-Set的实现
4. 基于Stream类型的实现

基于List的 LPUSH+BRPOP 的实现

使用rpushlpush操作入队列,lpoprpop操作出队列。

List支持多个生产者和消费者并发进出消息,每个消费者拿到都是不同的列表元素。

但是当队列为空时,lpop和rpop会一直空轮训,消耗资源;所以引入阻塞读blpop和brpop(b代表blocking),阻塞读在队列没有数据的时候进入休眠状态,一旦数据到来则立刻醒过来,消息延迟几乎为零。

上代码,使用redisTemplate

public class RedisQueue {
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    //---------取数据---------

    public void bRPopLPush( String key, int TIME_OUT, Consumer<Object> consumer) throws Exception {
        if (TIME_OUT <= 0) {  //禁止不超时阻塞
            throw new Exception("阻塞等待时长TIME_OUT 必须大于0!");
        }

        String srcKey = getKey(key);
        String dstKey = getBackupKey(key);
        while (true) {
            boolean sucess = false;
            Object obj = null;
            try {
                obj = redisTemplate.opsForList().rightPopAndLeftPush(srcKey, dstKey,
                        TIME_OUT, TimeUnit.SECONDS);
                if (obj != null) {
                    consumer.accept(obj);
                    sucess = true;
                }
            } catch (Exception ignored) {
                // 防止获取key达到超时时间抛出QueryTimeoutException异常退出
            } finally {
                if (sucess) {
                    // 处理成功才删除备份队列的key
                    redisTemplate.opsForList().remove(dstKey, 1, obj);
                }
            }
        }
    }

    public Object blockingConsume(String key, int TIME_OUT) throws Exception {
        if (TIME_OUT <= 0) {  //禁止不超时阻塞
            throw new Exception("阻塞等待时长TIME_OUT 必须大于0!");
        }

        try {
            Object obj = redisTemplate.opsForList().rightPop(key, TIME_OUT, TimeUnit.SECONDS);
            return obj;
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }

    public Object consume(String key){
        try {
            Object obj = redisTemplate.opsForList().rightPop(key);
            return obj;
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }

    //---------存数据---------

    public Long produce(String key, Object value) {
        try {
            //执行 LPUSH 命令后,列表的长度。
            Long size = redisTemplate.opsForList().leftPush(key, value);
            return size;
        } catch (Exception e) {
            e.printStackTrace();
            return -1L;
        }
    }

    private String getKey(String key) {
        return key;
    }

    private String getBackupKey(String key) {
        return key + "_bak";
    }

}

注意

如果线程一直阻塞在那里,Redis客户端的连接就成了闲置连接,闲置过久,服务器一般会主动断开连接,减少闲置资源占用,这个时候blpop和brpop或抛出异常,所以在编写客户端消费者的时候要小心,需要注意捕获到异常还有重试

缺点:

做消费者确认ACK麻烦,不能保证消费者消费消息后是否成功处理的问题(宕机或处理异常等),通常需要维护一个Pending列表,保证消息处理确认。

不能重复消费,一旦消费就会被删除

不支持分组消费

优化点

基于List的 LPUSH+BRPOPLPUSH+LREM 的实现

为了防止消息丢失,使用bRPopLPush。在消费端取到消息的同时原子性的把该消息放入一个正在处理中的 doingKey 列表(进行备份)。业务处理完成后从正在处理中的 doingKey 列表 lrem 删除当前已经处理 ok 的消息。

正在处理中的队列存在长期不消费的消息怎么办? 可以再添加一台客户端来监控长期不消费的消息,重新将消息打回待消费的队列;这个可以使用循环队列的模式来实现:使用同一个list,从尾部出队的同时,从头部入队,如果没有异常则删除头部入队消息,如果出现异常,那么一直循环处理,当然这种处理有局限性 (慎用!很容易出问题,多线程直接爆炸)。