Spring Boot中借助Redis实现分布式系统全局共享线程安全的阻塞队列

发布时间 2023-07-17 16:30:49作者: 夏威夷8080

背景问题

我们都知道Java里的LinkedBlockingQueue,采用先进先出(FIFO)的方式存储元素,并且支持同时进行并发的读和写操作。内部使用ReentrantLock锁来保证多线程环境下的线程安全性。

LinkedBlockingQueue提供了以下主要方法:

  1. put(E e):将元素e插入队列的尾部,如果队列已满则阻塞直到有空间可用。
  2. offer(E e, long timeout, TimeUnit unit):将元素e插入队列的尾部,如果队列已满则阻塞一段时间,超时后返回false。
  3. take():获取并移除队列头部的元素,如果队列为空则阻塞直到有元素可用。
  4. poll(long timeout, TimeUnit unit):获取并移除队列头部的元素,如果队列为空则阻塞一段时间,超时后返回null。
  5. peek():获取但不移除队列头部的元素,如果队列为空则返回null。
  6. size():返回队列中的元素数量。
  7. isEmpty():判断队列是否为空。

LinkedBlockingQueue相比于ConcurrentLinkedQueue,它提供了阻塞操作,使得线程可以等待队列满或者队列空这样的条件,以便更好地控制线程的同步和协作。因此,它更适用于生产者-消费者模式和任务调度等场景。

需要注意的是,LinkedBlockingQueue的容量可以选择是否有限。可以在创建LinkedBlockingQueue对象时指定容量大小,如果不指定则默认为Integer.MAX_VALUE,即无界队列。当队列达到容量限制时,put操作将被阻塞,直到队列中有空间可用;take操作将被阻塞,直到队列中有元素可取。

总结起来,LinkedBlockingQueue是一个线程安全的阻塞队列,适用于多线程并发环境下的任务调度和协作。

 

LinkedBlockingQueue很好,但是如果我们需要一个分布式系统全局共享的线程安全的阻塞队列,就得换方法实现了,这边我选择的是使用redis的List数据结构实现队列。

redis队列代码

import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.BoundListOperations;
import org.springframework.data.redis.serializer.StringRedisSerializer;

import java.util.List;
import java.util.concurrent.TimeUnit;

/**
 * 夏威夷8080
 * @param <T>
 */
public class RedisBlockingQueue<T> {

    private String queueKey;
    private BoundListOperations<String, T> listOps;

    public RedisBlockingQueue(String queueKey, RedisTemplate<String, T> redisTemplate) {
        this.queueKey = queueKey;
        this.listOps = redisTemplate.boundListOps(queueKey);
        // 设置key和value的序列化器
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(redisTemplate.getDefaultSerializer());
        redisTemplate.afterPropertiesSet();
    }

    public void put(T item) {
        listOps.leftPush(item);
    }

    public T take() {
        while (true) {
            // 从队列右侧取出元素,在这里,参数0表示阻塞时间(单位:毫秒)。
            // 当队列为空时,如果设置阻塞时间为0,则表示立即返回null,而不进行等待。如果设置的阻塞时间大于0,
            // 则表示在指定的时间内等待,直到队列中有可弹出的元素或超时。
            //
            //具体解释如下:
            //
            //如果队列非空,会立即将左侧的一个元素弹出并返回该元素。
            //如果队列为空且阻塞时间为0,那么方法会立即返回null。
            //如果队列为空且阻塞时间大于0,那么方法会在指定的时间内等待,直到队列中有可弹出的元素或超时。如果超时时仍没有可弹出的元素,则方法会返回null。
            //这边的listOps.rightPop(0)被用于实现一个阻塞的队列操作,即当队列为空时,消费者线程会在此处等待,直到队列中有可弹出的元素或超时。
            T item = listOps.rightPop(0, TimeUnit.MINUTES);
            if (item != null) {
                return item;
            }
        }
    }

    public int size() {
        return Math.toIntExact(listOps.size());
    }

    public boolean isEmpty() {
        return size() == 0;
    }
}

测试类

三个线程,一个线程模拟生产者推消息,一个线程模拟消费者消费消息,其中消费的速度比生产的速度慢些,模拟队列堆积消息的场景。还有一个线程打印队列实时大小和元素数量。

import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;

import javax.annotation.PostConstruct;
import java.util.List;


@RestController
@RequestMapping("/test")
@Api(value = "测试", tags = "测试redis队列")
@Slf4j
public class TesterController {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    @GetMapping("/tt")
    @ApiOperation(value = "测试redis队列")
    public void tt() {
        new Thread(new Runnable() {
            @Override
            public void run() {
                // 创建RedisBlockingQueue实例
                RedisBlockingQueue<String> blockingQueue = new RedisBlockingQueue<>("myQueue", redisTemplate);

                new Thread(new Runnable() {
                    @Override
                    public void run() {
                        for (int i = 0; i < 100; i++) {
                            // 往队列中添加元素
                            String a = "item" + i;
                            blockingQueue.put(a);
                            log.info("放进元素:" + a);
                            try {
                                Thread.sleep(700);
                            } catch (InterruptedException e) {
                                e.printStackTrace();
                            }
                        }
                    }
                }).start();

                new Thread(new Runnable() {
                    @Override
                    public void run() {
                        for (int i = 0; i < 100; i++) {
                            // 从队列中取出元素
                            String item = blockingQueue.take();
                            log.info("取出的元素:" + item);
                            try {
                                Thread.sleep(1000);
                            } catch (InterruptedException e) {
                                e.printStackTrace();
                            }
                        }
                    }
                }).start();

                new Thread(new Runnable() {
                    @Override
                    public void run() {
                        for (int i = 0; i < 100; i++) {
                            // 获取队列大小和是否为空
                            log.info("队列大小:" + blockingQueue.size());
                            log.info("队列是否为空:" + blockingQueue.isEmpty());
                            try {
                                Thread.sleep(1000);
                            } catch (InterruptedException e) {
                                e.printStackTrace();
                            }
                        }
                    }
                }).start();
            }
        }).start();
    }


}

升级优化为有界队列

Redis的List是一种无界队列,它可以存储任意多个元素而不受限制。所以RedisBlockingQueue并没有固定的最大容量。我们可以根据实际需求在使用时进行控制,例如通过设置最大长度限制,或者在添加元素时进行判断和处理。

下面是一个修改后的RedisBlockingQueue类的示例,通过设置最大长度来限制队列的容量:

import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.BoundListOperations;
import org.springframework.data.redis.serializer.StringRedisSerializer;

import java.util.List;

public class RedisBlockingQueue<T> {
    private String queueKey;
    private BoundListOperations<String, T> listOps;
    private int maxQueueSize; // 最大队列长度

    public RedisBlockingQueue(String queueKey, RedisTemplate<String, T> redisTemplate, int maxQueueSize) {
        this.queueKey = queueKey;
        this.listOps = redisTemplate.boundListOps(queueKey);
        this.maxQueueSize = maxQueueSize;
        // 设置key和value的序列化器
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(redisTemplate.getDefaultSerializer());
        redisTemplate.afterPropertiesSet();
    }

    public void put(T item) throws InterruptedException {
        while (listOps.size() >= maxQueueSize) {
            // 队列已满,等待消费者取出元素
            Thread.sleep(1000); // 可根据实际情况调整等待时间
        }
        listOps.lefttPush(item);
    }

    // 其他方法同上...
}

线程安全

通过使用Redis的leftPop或rightPop操作,可以实现线程安全的阻塞队列。Redis的leftPop操作是原子性的,确保每个消费者在获取元素时的互斥性,避免了并发竞争的问题。

在示例中,当队列为空时,消费者线程会调用listOps.leftPop(0)进行阻塞等待。由于Redis的leftPop操作是原子性的,每次只有一个线程能够成功地弹出队列中的元素。其他线程虽然也在等待,但它们会被阻塞住,直到有元素可供弹出。

因此,这种方式实现的阻塞队列是线程安全的,可以安全地在多个线程之间进行操作,保证了数据的一致性和并发安全性。

BoundListOperations介绍

BoundListOperations是Spring Data Redis库中的一个接口,它提供了一组用于操作Redis列表(List)数据结构的方法。通过BoundListOperations接口,我们可以方便地对Redis列表进行各种操作,而无需显式指定列表的键。

BoundListOperations接口的实例通常通过RedisTemplate.boundListOps(key)方法来获取,其中RedisTemplate是Spring Data Redis库提供的Redis操作模板类,key是Redis列表的键。

BoundListOperations接口定义了一系列方法,包括:

  • leftPush(V value):将元素从左侧插入列表。
  • rightPush(V value):将元素从右侧插入列表。
  • leftPop():从左侧弹出列表中的元素。
  • rightPop():从右侧弹出列表中的元素。
  • size():获取列表的长度。
  • 等等...

使用BoundListOperations接口,我们可以更方便地操作Redis列表,而无需每次都指定列表的键。例如,在示例中,通过listOps.rightPush(item)就可以将元素从右侧插入到Redis列表中。

总之,BoundListOperations是Spring Data Redis库提供的一个接口,它简化了对Redis列表的操作,并提供了一系列便捷的方法来处理Redis列表。