Redis がメッセージ キューを実装する方法とコード実装の概要
序文
「Redis をキューとして使用するのが適切かどうか」という問題について多くの人が議論しているのをよく聞きます。
Redis は非常に軽量で、キューとして使用するのに便利だと考えている人もいます。Redis ではデータが「失われる」ため、安全性を高めるには「プロフェッショナルな」キュー ミドルウェアを使用する方が良いと考えて反対する人もいます。
この記事では、Redis をキューとして使用することが適切かどうかについて説明します。単純なものから複雑なものまで段階的に詳細を整理し、一般的に使用される実装方法を示します。
始める前に
1.依存関係を追加する
<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. 構成された Bean を追加する
保存されたデータを閲覧するためにソフトウェアを使用する煩わしさを回避します
/**
* 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;
}
実装
1. 最も単純なリストキューから始めます。
まず、最も単純なシナリオから始めましょう。
ビジネス ニーズが十分に単純で、Redis をキューとして使用したい場合、最初に思い浮かぶのは、List データ型を使用することです。
List の基礎となる実装は「リンク リスト」であるため、先頭と末尾の操作要素の時間計算量は O(1) です。これは、メッセージ キュー モデルとの一貫性が非常に高いことを意味します。
List をキューとして扱う場合は、次のように使用できます。
コード
プロデューサー側は次のように述べています。
@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中没数据!";
}
実装モデル:
このモデルも非常にシンプルで理解しやすいです。
ただし、ここには小さな問題があり、キューにメッセージがない場合、RPOP を実行するとコンシューマーは NULL を返します。
一般にコンシューマを記述する場合は無限ループを利用し、キューからデータを引き続ける実装方法となります。
@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();
}
}
}
この時点でキューが空の場合、コンシューマは引き続き頻繁にメッセージをプルするため、「CPU アイドリング」が発生し、CPU リソースが無駄になるだけでなく、Redis に負荷がかかります。
この問題を解決するにはどうすればよいでしょうか?
これも非常に簡単で、キューが空の場合は、しばらく「スリープ」して、再度メッセージのプルを試みることができます。コードは次のように変更できます。
@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);
}
}
これにより、CPU のアイドリングの問題が解決されます。
この問題は解決されましたが、別の問題が発生します。コンシューマがスリープして待機している間に新しいメッセージが到着すると、コンシューマによる新しいメッセージの処理に「遅延」が発生します。
設定されたスリープ時間が 2 秒であると仮定すると、新しいメッセージには最大 2 秒の遅延が発生します。
この遅延を短縮するには、スリープ時間を短縮するしかありません。ただし、スリープ時間が短いほど、CPU がアイドル状態になる可能性が高くなります。
両方の方法を持つことはできません。
では、新しいメッセージをタイムリーに処理し、CPU のアイドリングを回避するにはどうすればよいでしょうか?
Redis にはそのようなメカニズムがありますか: キューが空の場合、コンシューマーはメッセージをプルするときに「ブロックして待機します。新しいメッセージが到着すると、コンシューマーは新しいメッセージをすぐに処理するように通知されますか?」
幸いなことに、Redis はメッセージをプルするための「ブロック」コマンド BRPOP / BLPOP を提供します。ここで、B は Block を指します。
これもJavaでカプセル化されており、popメソッドを呼び出す際に有効期限を直接設定するだけです。
@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();
}
BRPOP のブロッキング メソッドを使用してメッセージをプルする場合、「タイムアウト期間」の受け渡しもサポートされます。0 に設定すると、タイムアウトは設定されず、新しいメッセージが存在するまで戻りません。それ以外の場合は NULL指定されたタイムアウト期間の経過後に返されます。
このソリューションは優れており、効率を考慮するだけでなく、CPU のアイドリングの問題も回避でき、一石二鳥です。
注: タイムアウトの設定が長すぎて、接続が長期間アクティブでなかった場合、Redis Server によって無効な接続と判断される可能性があり、Redis Server はクライアントを強制的にオフラインにします
。したがって、このソリューションでは、クライアントに再接続メカニズムが必要です。
タイムリーなメッセージ処理の問題を解決した後、このキュー モデルの欠点は何でしょうか?
一緒に分析しましょう:
- 繰り返しの消費はサポートされていません: コンシューマがメッセージをプルした後、メッセージはリストから削除され、他のコンシューマが再度消費することはできません。つまり、複数のコンシューマが同じデータ バッチを消費することはサポートされていません。
- メッセージ損失: コンシューマがメッセージをプルした後、異常なダウンタイムが発生すると、メッセージが失われます。最初の
問題は機能的なものです。リストをメッセージ キューとして使用すると、最も単純なプロデューサのグループのみがサポートされます。コンシューマのグループに対応します。 、生産者と消費者の複数のグループのビジネス シナリオを満たすことができません。
2 番目の問題は、メッセージがリストから POP された後、リンクされたリストからすぐに削除されるため、より困難です。つまり、コンシューマーが正常に処理したかどうかに関係なく、このメッセージを再度使用することはできません。
これは、メッセージの処理中にコンシューマが異常クラッシュした場合、メッセージが失われたことと同じであることも意味します。
これら 2 つの問題を解決するにはどうすればよいでしょうか? 一つずつ見ていきましょう。
2. パブリッシュおよびサブスクライブ モデル: Pub/Sub
名前からわかるように、このモジュールは Redis によって「パブリッシュ/サブスクライブ」キュー モデル用に特別に設計されています。
それは、上で述べた最初の問題、つまり繰り返し消費する問題を解決するだけです。
つまり、生産者と消費者の複数のグループのシナリオがどのように機能するかを見てみましょう。
Redis は、パブリッシュおよびサブスクライブ操作を完了するための PUBLISH / SUBSCRIBE コマンドを提供します。
依存関係には以前のものを引き続き使用してください。
1. RedisMessageListenerContainer を使用してサブスクリプションを実装する
- MessageListener インターフェイスを実装して、受信したメッセージを処理します。これにより、依存関係注入やその他の Spring 機能を使用するなど、Spring アプリケーションでより高度な方法でメッセージを処理できるようになります。また、注釈ベースのメッセージ リスナーもサポートしているため、メッセージ処理がより簡潔かつ柔軟になります。
- このメソッドは、Spring アプリケーションで Redis のパブリッシュおよびサブスクライブ機能を使用するために Spring Data Redis ライブラリによって提供されるメソッドです。MessageListenerContainer オブジェクトを作成し、addMessageListener メソッドを呼び出してメッセージ リスナーを追加する必要があります。
- チャンネルをモニタリングするためのモニターを追加する
より直感的な比較テストを容易にするために、2 つのモニターを追加しました。
/**
* @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);
}
}
- 構成サブスクリプションは単一または複数にすることができます
。トピック (チャネル) とリスナーをバインドするために使用され、送信は
/**
* @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;
}
}
- 指定したチャネルにメッセージを送信する
/**
* 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. redisTemplate を使用してサブスクリプションを実装することもできます
このメソッドは Redis クライアントのメソッドであり、独立した Redis クライアントでパブリッシュおよびサブスクライブ機能を直接使用するために使用されます。Redis 接続オブジェクトを作成し、subscribe メソッドを呼び出して 1 つ以上のチャネルにサブスクライブする必要があります。
スタンドアロン Redis クライアントでパブリッシュ/サブスクライブ機能のみを使用しており、Spring の他の機能を使用する必要がない場合は、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();
}
メッセージを送ります
@GetMapping("/pub")
public Object pub(String channel,String name,Integer age) {
User user = new User(name, age);
redisTemplate.convertAndSend(channel,user);
return user;
}
最終的なモニタリング結果
3. 成熟する傾向にあるキュー: ストリーム
Stream が上記の問題をどのように解決するかを見てみましょう。
単純なものから複雑なものまで、メッセージ キューを実行するときに Stream がどのように処理されるかを見てみましょう。
まず、Stream は、XADD と XREAD を通じて最も単純な生産および消費モデルを完成させます。
- プロデューサーは 2 つのメッセージをパブリッシュします。
// *表示让Redis自动生成消息ID
127.0.0.1:6379> XADD queue * name zhangsan
"1618469123380-0"
127.0.0.1:6379> XADD queue * name lisi
"1618469127777-0"
- 消費者はメッセージをプルします。
// 从开头读取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"
フローチャート
特定の Java コードの実装:
- まずリスニングメッセージクラスを設定します
@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());
}
}
- 初期化を実装するツールクラスを追加
@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);
}
}
}
- 構成クラスを追加してストリームを構成する
/**
* @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;
}
}
- 通話テスト
/**
* @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());
}
}
Stream の利点は、永続化のために RDB および AOF に書き込むことができることです。
ストリームは新しく追加されたデータ型で、他のデータ型と同様に、すべての書き込み操作は RDB および AOF にも書き込まれます。
Redis が停止して再起動された場合でも、ストリーム内のデータを RDB または AOF から回復できるように、永続化戦略を構成するだけで済みます。
要約する
さて、要約しましょう。この記事では、「Redisをキューとして利用できるのか」という観点から、List、Pub/Sub、Streamがどのようにキューとして利用されるのか、それぞれのメリット・デメリットを紹介します。
その後、Redis とプロフェッショナルなメッセージ キュー ミドルウェアを比較し、Redis の欠点を発見しました。
最後に、Redis がキューを実行するための適切なシナリオにたどり着きます。