diff --git a/yudao-framework/yudao-spring-boot-starter-mq/pom.xml b/yudao-framework/yudao-spring-boot-starter-mq/pom.xml
index c8972f1..8a677fd 100644
--- a/yudao-framework/yudao-spring-boot-starter-mq/pom.xml
+++ b/yudao-framework/yudao-spring-boot-starter-mq/pom.xml
@@ -38,6 +38,18 @@
rocketmq-spring-boot-starter
true
+
+
+
+ org.mockito
+ mockito-inline
+ test
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
\ No newline at end of file
diff --git a/yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/redis/core/job/RedisPendingMessageResendJob.java b/yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/redis/core/job/RedisPendingMessageResendJob.java
index d0b7ae3..d8709ca 100644
--- a/yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/redis/core/job/RedisPendingMessageResendJob.java
+++ b/yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/redis/core/job/RedisPendingMessageResendJob.java
@@ -35,6 +35,13 @@ public class RedisPendingMessageResendJob {
*/
private static final int EXPIRE_TIME = 5 * 60;
+ /**
+ * 每个消费者每轮最多扫描的 pending 消息数量
+ *
+ * 避免 pending 堆积时,单次 XPENDING 返回过多数据而长时间占用 Redis;未扫描到的消息,由后续轮次继续处理
+ */
+ private static final int PENDING_BATCH_SIZE = 1000;
+
private final List> listeners;
private final RedisMQTemplate redisTemplate;
private final RedissonClient redissonClient;
@@ -74,8 +81,9 @@ public class RedisPendingMessageResendJob {
Map pendingMessagesPerConsumer = pendingMessagesSummary.getPendingMessagesPerConsumer();
pendingMessagesPerConsumer.forEach((consumerName, pendingMessageCount) -> {
log.info("[processPendingMessage][消费者({}) 消息数量({})]", consumerName, pendingMessageCount);
- // 每个消费者的 pending消息的详情信息
- PendingMessages pendingMessages = ops.pending(listener.getStreamKey(), Consumer.from(listener.getGroup(), consumerName), Range.unbounded(), pendingMessageCount);
+ // 每个消费者的 pending消息的详情信息,分批扫描:https://github.com/YunaiV/ruoyi-vue-pro/issues/1271
+ PendingMessages pendingMessages = ops.pending(listener.getStreamKey(), Consumer.from(listener.getGroup(), consumerName),
+ Range.unbounded(), Math.min(pendingMessageCount, PENDING_BATCH_SIZE));
if (pendingMessages.isEmpty()) {
return;
}
@@ -89,6 +97,11 @@ public class RedisPendingMessageResendJob {
List> records = ops.range(listener.getStreamKey(),
Range.of(Range.Bound.inclusive(pendingMessage.getIdAsString()), Range.Bound.inclusive(pendingMessage.getIdAsString())));
if (CollUtil.isEmpty(records)) {
+ // 消息体已不存在(例如被 RedisStreamMessageCleanupJob 裁剪),无法重新投递,直接 ack 避免 pending 永久滞留
+ redisTemplate.getRedisTemplate().opsForStream().acknowledge(listener.getStreamKey(),
+ listener.getGroup(), pendingMessage.getId());
+ log.warn("[processPendingMessage][消息({}) 的消息体已不存在,无法重新投递,直接 ack]",
+ pendingMessage.getIdAsString());
return;
}
// 重新投递消息
diff --git a/yudao-framework/yudao-spring-boot-starter-mq/src/test/java/cn/iocoder/yudao/framework/mq/redis/core/job/RedisPendingMessageResendJobTest.java b/yudao-framework/yudao-spring-boot-starter-mq/src/test/java/cn/iocoder/yudao/framework/mq/redis/core/job/RedisPendingMessageResendJobTest.java
new file mode 100644
index 0000000..d005368
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-mq/src/test/java/cn/iocoder/yudao/framework/mq/redis/core/job/RedisPendingMessageResendJobTest.java
@@ -0,0 +1,175 @@
+package cn.iocoder.yudao.framework.mq.redis.core.job;
+
+import cn.iocoder.yudao.framework.mq.redis.core.RedisMQTemplate;
+import cn.iocoder.yudao.framework.mq.redis.core.stream.AbstractRedisStreamMessage;
+import cn.iocoder.yudao.framework.mq.redis.core.stream.AbstractRedisStreamMessageListener;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.redisson.api.RLock;
+import org.redisson.api.RedissonClient;
+import org.springframework.dao.QueryTimeoutException;
+import org.springframework.data.domain.Range;
+import org.springframework.data.redis.connection.stream.Record;
+import org.springframework.data.redis.connection.stream.*;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.core.StreamOperations;
+
+import java.time.Duration;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import static java.util.Collections.singletonList;
+import static java.util.Collections.singletonMap;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * {@link RedisPendingMessageResendJob} 的单元测试类
+ *
+ * @author 芋道源码
+ */
+@ExtendWith(MockitoExtension.class)
+public class RedisPendingMessageResendJobTest {
+
+ private static final String STREAM_KEY = "test_stream";
+ private static final String GROUP = "test_group";
+ private static final String CONSUMER_NAME = "test_consumer";
+
+ private RedisPendingMessageResendJob resendJob;
+
+ @Mock
+ private RedisMQTemplate redisMQTemplate;
+ @Mock
+ private RedisTemplate redisTemplate;
+ @Mock
+ private StreamOperations streamOperations;
+ @Mock
+ private RedissonClient redissonClient;
+ @Mock
+ private RLock lock;
+
+ @BeforeEach
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ public void setUp() {
+ AbstractRedisStreamMessageListener> listener = new AbstractRedisStreamMessageListener<>(STREAM_KEY, GROUP) {
+
+ @Override
+ public void onMessage(AbstractRedisStreamMessage message) {
+ }
+
+ };
+ resendJob = new RedisPendingMessageResendJob(singletonList(listener), redisMQTemplate, redissonClient,
+ RedisPendingMessageResendJob.DEFAULT_RESEND_LOCK_KEY);
+ // mock 分布式锁
+ when(redissonClient.getLock(eq(RedisPendingMessageResendJob.DEFAULT_RESEND_LOCK_KEY))).thenReturn(lock);
+ when(lock.tryLock()).thenReturn(true);
+ when(lock.isHeldByCurrentThread()).thenReturn(true);
+ // mock Redis Stream 操作
+ when(redisMQTemplate.getRedisTemplate()).thenReturn((RedisTemplate) redisTemplate);
+ when(redisTemplate.opsForStream()).thenReturn((StreamOperations) streamOperations);
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ public void testMessageResend_success() {
+ // mock 数据:一条超时消息,一条未超时消息
+ RecordId expiredId = RecordId.of("1-0");
+ RecordId freshId = RecordId.of("2-0");
+ mockPending(2L, List.of(buildPendingMessage(expiredId, Duration.ofMinutes(6)),
+ buildPendingMessage(freshId, Duration.ofMinutes(1))));
+ Map