Resumo de como o Redis implementa filas de mensagens e implementação de código
Prefácio
Muitas vezes ouço muitas pessoas discutindo a questão de "se é apropriado usar o Redis como uma fila".
Algumas pessoas concordam: elas acham que o Redis é muito leve e conveniente para usar como fila. Algumas pessoas se opõem, pensando que o Redis “perderá” dados e é melhor usar middleware de fila “profissional” para ser mais seguro.
Este artigo discutirá se é apropriado usar o Redis como fila. Iremos guiá-lo passo a passo, do simples ao complexo, para resolver os detalhes e mostrar os métodos de implementação comumente usados.
Antes que você comece
1. Adicione dependências
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.4.0</version>
</dependency>
2. Adicione beans configurados
Evite a inconveniência de usar software para visualizar dados armazenados
/**
* redisTemplate 序列化使用的jdkSerializeable, 存储二进制字节码, 所以自定义序列化类
* @param redisConnectionFactory
* @return
*/
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
// 使用Jackson2JsonRedisSerialize 替换默认序列化
Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
objectMapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
jackson2JsonRedisSerializer.setObjectMapper(objectMapper);
// 设置value的序列化规则和 key的序列化规则
redisTemplate.setKeySerializer(new StringRedisSerializer());
//jackson2JsonRedisSerializer就是JSON序列号规则,
redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
redisTemplate.afterPropertiesSet();
return redisTemplate;
}
Implementação
1. Comece com o mais simples: listar fila
Primeiro, vamos começar com o cenário mais simples.
Se as necessidades do seu negócio são bastante simples e você deseja usar o Redis como fila, a primeira coisa que vem à mente é usar o tipo de dados Lista.
Como a implementação subjacente de List é uma "lista vinculada", a complexidade de tempo dos elementos operacionais no início e no final é O(1), o que significa que é muito consistente com o modelo de fila de mensagens.
Se você tratar List como uma fila, poderá usá-la assim.
Código
O lado do produtor lê:
@RestController
@RequestMapping("/redis01")
public class RedisTest1 {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
//LPUSH 发布消息
@GetMapping("/set")
public void set(String code){
redisTemplate.opsForList().leftPush("code",code);
}
// RPOP 拉取消息
@GetMapping("/get1")
public String get1(String key){
Object code = redisTemplate.opsForList().rightPop(key);
if (code!=null){
return code.toString();
}
return "redis中没数据!";
}
Modelo de implementação:
Este modelo também é muito simples e fácil de entender.
Mas há um pequeno problema aqui: quando não há mensagem na fila, o consumidor retornará NULL ao executar o RPOP.
Geralmente, ao escrever um consumidor, um loop infinito é adotado. Este método de implementação consiste em extrair dados continuamente da fila.
@GetMapping("/get2")
public String get2(String key) throws InterruptedException {
while (true){
Object code = redisTemplate.opsForList().rightPop(key);
System.out.println(code);
// 读取到消息,退出,没读到继续循环
if (code!=null){
return code.toString();
}
}
}
Se a fila estiver vazia neste momento, o consumidor ainda puxará mensagens com frequência, o que causará "inatividade da CPU", que não apenas desperdiça recursos da CPU, mas também pressiona o Redis.
Como resolver este problema?
Também é muito simples, quando a fila está vazia podemos "dormir" um pouco e depois tentar extrair a mensagem. O código pode ser modificado assim:
@GetMapping("/get2")
public String get2(String key) throws InterruptedException {
while (true){
Object code = redisTemplate.opsForList().rightPop(key);
System.out.println(code);
// 读取到消息,退出,没读到继续循环
if (code!=null){
return code.toString();
}
Thread.sleep(2000);
}
}
Isso resolve o problema de inatividade da CPU.
Embora esse problema seja resolvido, ele traz outro problema: quando uma nova mensagem chega enquanto o consumidor está dormindo e esperando, haverá um “atraso” no processamento da nova mensagem pelo consumidor.
Supondo que o tempo de sleep definido seja de 2s, haverá um atraso máximo de 2s para novas mensagens.
Para diminuir esse atraso, você só pode reduzir o tempo de sono. No entanto, quanto menor o tempo de suspensão, maior a probabilidade de a CPU ficar ociosa.
Você não pode ter as duas coisas.
Então, como podemos processar novas mensagens em tempo hábil e evitar a ociosidade da CPU?
O Redis tem esse mecanismo: se a fila estiver vazia, o consumidor irá "bloquear e esperar" ao extrair mensagens. Assim que uma nova mensagem chegar, meu consumidor será notificado para processar a nova mensagem imediatamente?
Felizmente, o Redis fornece comandos de "bloqueio" para extrair mensagens: BRPOP / BLPOP , onde B se refere a Bloquear.
Também foi encapsulado em java. Ao chamar o método pop, basta definir um tempo de expiração diretamente.
@GetMapping("/get3")
public String get3(String key) throws InterruptedException {
Object code = redisTemplate.opsForList().rightPop(key,0, TimeUnit.SECONDS);
if (code==null){
return "数据读取超时!";
}
return code.toString();
}
Ao usar o método de bloqueio do BRPOP para extrair mensagens, ele também suporta a passagem de um "período de tempo limite". Se definido como 0, significa que o tempo limite não será definido e não retornará até que haja uma nova mensagem. Caso contrário, NULL será retornado após o período de tempo limite especificado.
Esta solução é boa, pois não só leva em consideração a eficiência, mas também evita o problema de ociosidade da CPU, matando dois coelhos com uma cajadada só.
Nota: Se o tempo limite for definido muito longo e a conexão não estiver ativa por muito tempo, ela poderá ser considerada uma conexão inválida pelo Redis Server e, em seguida, o Redis Server forçará o cliente a ser colocado offline
. Portanto, utilizando esta solução, o cliente deve possuir um mecanismo de reconexão.
Depois de resolver o problema do processamento tardio de mensagens, você pode pensar novamente: quais são as deficiências desse modelo de fila?
Vamos analisar juntos:
- O consumo repetido não é suportado : depois que o consumidor extrai a mensagem, a mensagem é excluída da lista e não pode ser consumida novamente por outros consumidores, ou seja, vários consumidores não têm suporte para consumir o mesmo lote de dados.
- Perda de mensagem : Depois que o consumidor extrai a mensagem, se ocorrer um tempo de inatividade anormal, a mensagem será perdida. O primeiro
problema é funcional. Usar Lista como fila de mensagens suporta apenas o mais simples, um grupo de produtores. Correspondente a um grupo de consumidores , não consegue atender aos cenários de negócios de múltiplos grupos de produtores e consumidores.
O segundo problema é mais difícil, porque depois que uma mensagem é POP da Lista, a mensagem será imediatamente excluída da lista vinculada. Em outras palavras, não importa se o consumidor a processa com sucesso ou não, esta mensagem não pode ser consumida novamente.
Isso também significa que se o consumidor travar de forma anormal durante o processamento da mensagem, a mensagem será equivalente a ser perdida.
Como resolver esses dois problemas? Vamos examiná-los um por um.
2. Modelo de publicação e assinatura: Pub/Sub
Como você pode perceber pelo nome, este módulo foi projetado pelo Redis especificamente para o modelo de fila "publicar/assinar".
Pode resolver exatamente o primeiro problema mencionado anteriormente: o consumo repetido.
Ou seja, no cenário de múltiplos grupos de produtores e consumidores, vamos ver como isso se faz.
O Redis fornece comandos PUBLISH/SUBSCRIBE para concluir as operações de publicação e assinatura.
Continue usando o anterior para dependências.
1. Use RedisMessageListenerContainer para implementar assinatura
- Processe mensagens recebidas implementando a interface MessageListener. Isso permite que você lide com mensagens de uma forma mais avançada em seu aplicativo Spring, como usando injeção de dependência e outros recursos do Spring. Ele também suporta ouvintes de mensagens baseados em anotações, tornando o processamento de mensagens mais conciso e flexível.
- Este método é fornecido pela biblioteca Spring Data Redis para usar as funções de publicação e assinatura do Redis em aplicativos Spring. Requer a criação de um objeto MessageListenerContainer e a adição de um ouvinte de mensagem chamando o método addMessageListener.
- Adicione monitores para monitorar canais.
Para facilitar um teste de comparação mais intuitivo, adicionei dois
/**
* @author zhengfuping
* @version 1.0
* @description: TODO 配置监控器
* @date 2023/7/28 17:10
*/
@Component
public class RedisMessaeListener1 implements MessageListener {
@Override
public void onMessage(Message message, byte[] pattern) {
String channel = new String(message.getChannel());
String body = new String(message.getBody());
System.out.println("监听器1号:消息: " + body + " 通道QQ: " + channel);
}
}
/*########################*/
/**
* @author zhengfuping
* @version 1.0
* @description: TODO 配置监控器1
* @date 2023/8/2 11:24
*/
@Component
public class RedisMessaeListener2 implements MessageListener {
@Override
public void onMessage(Message message, byte[] pattern) {
String channel = new String(message.getChannel());
String body = new String(message.getBody());
System.out.println("监听器2号:消息: " + body + " 通道QQ: " + channel);
}
}
- As assinaturas de configuração podem ser únicas ou múltiplas
para vinculação de tópicos (canais) e ouvintes. O envio é
/**
* @author zhengfuping
* @version 1.0
* @description: TODO 使用RedisMessageListenerContainer直接注入到bean进行监听
* @date 2023/7/28 15:43
*/
@Configuration
public class RedisPubSubExample {
@Autowired
private RedisMessaeListener1 redisMessaeListener1;
@Autowired
private RedisMessaeListener2 redisMessaeListener2;
/**
* 订阅三个频道
* @author zhengfuping
* @date 2023/8/2 11:19
* @param redisConnectionFactory redis线程工厂
* @return RedisMessageListenerContainer
*/
// @Bean
public RedisMessageListenerContainer subscribeToChannel(RedisConnectionFactory redisConnectionFactory){
RedisMessageListenerContainer listenerContainer = new RedisMessageListenerContainer();
listenerContainer.setConnectionFactory(redisConnectionFactory);
List<Topic> list = new ArrayList<>();
list.add(new PatternTopic("TEST01"));
list.add(new PatternTopic("TEST02"));
list.add(new PatternTopic("TEST03"));
/*
* redisMessaeListener 消息监听器
* list 订阅的主题(可以单个和多个)
*/
listenerContainer.addMessageListener(redisMessaeListener1,list);
listenerContainer.addMessageListener(redisMessaeListener2,new PatternTopic("TEST01"));
return listenerContainer;
}
}
- Enviar mensagem para canal especificado
/**
* PUBLISH 发送消息到指定频道
* @author zhengfuping
* @date 2023/8/2 11:14
* @param channel 通道
* @param name 数据
* @param age
* @return Object
*/
@GetMapping("/pub")
public Object pub(String channel,String name,Integer age) {
User user = new User(name, age);
redisTemplate.convertAndSend(channel,user);
return user;
}
2. Você também pode usar redisTemplate para implementar assinatura
Este método é um método do cliente Redis e é usado para usar diretamente a função de publicação e assinatura em um cliente Redis independente. Requer a criação de um objeto de conexão Redis e a assinatura de um ou mais canais chamando o método subscribe.
Se você estiver usando apenas o recurso publicar-assinar em um cliente Redis independente e não precisar usar outros recursos do Spring, poderá escolher connection.subscribe
/**
* 自行添加订阅
*/
@GetMapping("/sub")
public void sub(String channel) {
RedisConnection connection = redisTemplate.getConnectionFactory().getConnection();
/*
* MessageListener:监听器,直接使用内部类实现绑定监听可以把数据传递出去
* channel 订阅频道
*/
connection.subscribe((message, pattern) -> {
String channel1 = new String(message.getChannel());
String body = new String(message.getBody());
System.out.println("subscribe方式监听:消息: " + body + " 通道QQ: " + channel1);
}, channel.getBytes());
// connection.close();
}
Envie uma mensagem
@GetMapping("/pub")
public Object pub(String channel,String name,Integer age) {
User user = new User(name, age);
redisTemplate.convertAndSend(channel,user);
return user;
}
O resultado final do monitoramento
3. Filas com tendência a amadurecer: Stream
Vamos ver como o Stream resolve os problemas acima.
Vamos ainda ir do simples ao complexo e ver como o Stream é processado ao fazer filas de mensagens?
Primeiro, o Stream completa o modelo mais simples de produção e consumo por meio de XADD e XREAD:
- O produtor publica 2 mensagens:
// *表示让Redis自动生成消息ID
127.0.0.1:6379> XADD queue * name zhangsan
"1618469123380-0"
127.0.0.1:6379> XADD queue * name lisi
"1618469127777-0"
- Os consumidores extraem mensagens:
// 从开头读取5条消息,0-0表示从开头读取
127.0.0.1:6379> XREAD COUNT 5 STREAMS queue 0-0
1) 1) "queue"
2) 1) 1) "1618469123380-0"
2) 1) "name"
2) "zhangsan"
2) 1) "1618469127777-0"
2) 1) "name"
2) "lisi"
fluxograma
Implementação específica de código java:
- Primeiro configure a classe da mensagem de escuta
@Slf4j
@Component
public class ListenerMessage implements StreamListener<String, MapRecord<String, String, String>> {
@Override
public void onMessage(MapRecord<String, String, String> entries) {
log.info("接受到来自redis的消息");
System.out.println("message id "+entries.getId());
System.out.println("stream "+entries.getStream());
System.out.println("body "+entries.getValue());
}
}
- Adicionar classe de ferramenta para implementar a inicialização
@Component
@Slf4j
public class RedisStreamUtil {
@Autowired
private RedisTemplate<String,Object> redisTemplate;
/**
* @author zhengfuping 添加数据
* @param streamKey
* @param map
* @return RecordId
*/
public RecordId addStream(String streamKey,Map<String, Object> map){
RecordId recordId = redisTemplate.opsForStream().add(streamKey, map);
return recordId;
}
/**
* 用来创建绑定流和组
*/
public void addGroup(String key, String groupName){
redisTemplate.opsForStream().createGroup(key,groupName);
}
/**
* 用来判断key是否存在
*/
public boolean hasKey(String key){
if(key==null){
return false;
}else{
return redisTemplate.hasKey(key);
}
}
/**
* 用来删除掉消费了的消息
*/
public void delField(String key,String recordIds){
redisTemplate.opsForStream().delete(key,recordIds);
}
/**
* 用来初始化 实现绑定
*/
public void initStream(String key, String group){
//判断key是否存在,如果不存在则创建
boolean hasKey = hasKey(key);
if(!hasKey){
Map<String,Object> map = new HashMap<>();
map.put("field","value");
RecordId recordId = addStream(key, map);
addGroup(key,group); //把Stream和gropu绑定
delField(key,recordId.getValue());
log.info("stream:{}-group:{} initialize success",key,group);
}
}
}
- Adicione classe de configuração e configure Stream
/**
* @author zhengfuping
* @version 1.0
* @description: TODO 添加配置类,配置Stream
*/
@Configuration
@Slf4j
public class RedisStreamConfig {
@Autowired
private RedisStreamUtil redisStream;
@Autowired
private ListenerMessage listenerMessage;
@Bean
public Subscription subscription(RedisConnectionFactory factory){
// 代码中的var是使用了Lombok的可变局部变量。主要是为了方便
// StreamMessageListenerContainer: 消息侦听容器,不能在外部实现。创建后,StreamMessageListenerContainer可以订阅Redis流并使用传入的消息
var options = StreamMessageListenerContainer
.StreamMessageListenerContainerOptions
.builder()
.pollTimeout(Duration.ofSeconds(1))
.build();
redisStream.initStream("mystream","mygroup"); //调用初始化
var listenerContainer = StreamMessageListenerContainer.create(factory,options);
/*
* 注意这里接受到消息后会被自动的确认,如果不想自动确认请使用其他的创建订阅方式
* 消费组 consumer group ,它不能为null (Consumer类型)
* stream offset ,stream的偏移量(StreamOffset 类型)
* listener 不能为null (StreamListener<K,V> 类型)
*/
var subscription = listenerContainer.receiveAutoAck(Consumer.from("mygroup","huhailong"),
StreamOffset.create("mystream", ReadOffset.lastConsumed()),listenerMessage);
listenerContainer.start();
return subscription;
}
}
- Teste de chamada
/**
* @author zhengfuping
* @version 1.0
* @description: TODO
* @date 2023/8/2 16:06
*/
@RestController
@RequestMapping("/redisStream")
public class RedisStreamTest {
@Autowired
private RedisStreamUtil redisStream;
@GetMapping("add")
public void add(String key,String data){
Map<String, Object> map = new HashMap<>();
map.put(key,data);
// 添加数据到mystream流中
RecordId recordId = redisStream.addStream("mystream", map);
// 删除流中消费了的指定key的数据
redisStream.delField("mystream",recordId.getValue());
}
}
A vantagem do Stream é que ele pode ser gravado em RDB e AOF para persistência.
Stream é um tipo de dados recém-adicionado. Como outros tipos de dados, cada operação de gravação também será gravada em RDB e AOF.
Precisamos apenas configurar a estratégia de persistência, para que mesmo que o Redis esteja inativo e reiniciado, os dados no Stream possam ser recuperados do RDB ou AOF.
Resumir
Ok, vamos resumir. Neste artigo, partimos da perspectiva de "O Redis pode ser usado como fila" e apresentamos como List, Pub/Sub e Stream são usados como filas, bem como suas respectivas vantagens e desvantagens.
Mais tarde, comparei o Redis com o middleware de fila de mensagens profissional e descobri as deficiências do Redis.
Finalmente, chegamos a um cenário adequado para o Redis fazer filas.