feat(mq-starter): redis-stream 重试可放量(PEL reclaim + 超限落库)
- MqSubscription 增加 retryExhaustedHandler;registry 从 errorHandler 接线 RETRY_EXHAUSTED 回调 - redis driver 每轮 reclaim PEL:connection 层 xClaim 认领超 idle 消息并使 delivery+1 - delivery <= maxRetry:重投消费,成功 ACK / 失败留 PEL - delivery > maxRetry:回调 retryExhaustedHandler 落库 + ACK 终止,不再卡队列 - 可配 reclaimMinIdleMs(默认 5000ms,对齐 legacy 5s 延迟);4 参构造 + 3 参重载 - 集成测试连真实 redis 验证 重试耗尽→落库→ACK;全仓 28 测试绿 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,9 @@ import com.njcn.mq.spi.MqDriver;
|
||||
import com.njcn.mq.spi.MqSubscription;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.connection.stream.*;
|
||||
import org.springframework.data.domain.Range;
|
||||
import org.springframework.data.redis.connection.stream.PendingMessage;
|
||||
import org.springframework.data.redis.connection.stream.PendingMessages;
|
||||
import org.springframework.data.redis.core.RedisCallback;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
@@ -20,11 +23,17 @@ public class RedisStreamMqDriver implements MqDriver {
|
||||
private final StringRedisTemplate redis;
|
||||
private final StreamKeyBuilder keyBuilder;
|
||||
private final RedisStreamProperties props;
|
||||
private final long reclaimMinIdleMs;
|
||||
private final List<Thread> workers = new ArrayList<>();
|
||||
private volatile boolean running = true;
|
||||
|
||||
public RedisStreamMqDriver(StringRedisTemplate redis, StreamKeyBuilder keyBuilder, RedisStreamProperties props) {
|
||||
this(redis, keyBuilder, props, 5000L);
|
||||
}
|
||||
|
||||
public RedisStreamMqDriver(StringRedisTemplate redis, StreamKeyBuilder keyBuilder, RedisStreamProperties props, long reclaimMinIdleMs) {
|
||||
this.redis = redis; this.keyBuilder = keyBuilder; this.props = props;
|
||||
this.reclaimMinIdleMs = reclaimMinIdleMs;
|
||||
}
|
||||
|
||||
@Override public void send(String topic, MqMessage message) {
|
||||
@@ -54,6 +63,7 @@ public class RedisStreamMqDriver implements MqDriver {
|
||||
int maxRetry = sub.getMaxRetry() > 0 ? sub.getMaxRetry() : props.getMaxRetry();
|
||||
while (running && !Thread.currentThread().isInterrupted()) {
|
||||
try {
|
||||
reclaimPending(sub, stream, group, consumer, maxRetry);
|
||||
List<MapRecord<String, Object, Object>> recs = redis.opsForStream().read(
|
||||
Consumer.from(group, consumer),
|
||||
StreamReadOptions.empty().count(props.getReadCount()).block(Duration.ofMillis(props.getBlockMs())),
|
||||
@@ -88,7 +98,52 @@ public class RedisStreamMqDriver implements MqDriver {
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
log.warn("[mq-redis] handle failed key={}, leave PEL for retry (max={})", msg.getKey(), maxRetry, e);
|
||||
return false; // 留 PEL;超 maxRetry 的兜底由后续 autoclaim 增强补(轻量版先靠重投)
|
||||
return false; // 留 PEL,由 reclaimPending 重投或落库
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 PEL 中超过 {@code reclaimMinIdleMs} 仍未 ACK 的消息:
|
||||
* delivery 次数未超 maxRetry 则重投消费;超过则回调 retryExhaustedHandler 落库并 ACK 终止。
|
||||
*/
|
||||
private void reclaimPending(MqSubscription sub, String stream, String group, String consumer, int maxRetry) {
|
||||
PendingMessages pendings;
|
||||
try {
|
||||
pendings = redis.opsForStream().pending(stream, group, Range.unbounded(), props.getReadCount());
|
||||
} catch (Exception e) {
|
||||
return; // group 尚未建立或暂无 PEL,忽略
|
||||
}
|
||||
if (pendings == null || pendings.isEmpty()) return;
|
||||
Duration minIdle = Duration.ofMillis(reclaimMinIdleMs);
|
||||
for (PendingMessage pm : pendings) {
|
||||
if (pm.getElapsedTimeSinceLastDelivery().toMillis() < reclaimMinIdleMs) continue;
|
||||
long deliveryCount = pm.getTotalDeliveryCount();
|
||||
List<ByteRecord> claimed;
|
||||
try {
|
||||
// StreamOperations(2.3) 无 claim,走 connection 层 xClaim:转移所有权并使 delivery 计数 +1。
|
||||
claimed = redis.execute((RedisCallback<List<ByteRecord>>) conn ->
|
||||
conn.streamCommands().xClaim(stream.getBytes(StandardCharsets.UTF_8), group, consumer, minIdle, pm.getId()));
|
||||
} catch (Exception e) { continue; }
|
||||
if (claimed == null || claimed.isEmpty()) continue; // 被其它消费者抢先 claim 或消息已删
|
||||
ByteRecord rec = claimed.get(0);
|
||||
Map<String, String> fields = new HashMap<>();
|
||||
rec.getValue().forEach((k, v) -> fields.put(
|
||||
new String(k, StandardCharsets.UTF_8), new String(v, StandardCharsets.UTF_8)));
|
||||
if (deliveryCount > maxRetry) {
|
||||
if (sub.getRetryExhaustedHandler() != null) {
|
||||
try {
|
||||
sub.getRetryExhaustedHandler().accept(MqMessage.of(
|
||||
fields.get(StreamMessageConstant.F_KEY), fields.get(StreamMessageConstant.F_TAG), null));
|
||||
} catch (Exception ex) {
|
||||
log.error("[mq-redis] retryExhausted handler error key={}", fields.get(StreamMessageConstant.F_KEY), ex);
|
||||
}
|
||||
}
|
||||
redis.opsForStream().acknowledge(stream, group, rec.getId());
|
||||
log.warn("[mq-redis] retry exhausted, acked key={} delivery={}", fields.get(StreamMessageConstant.F_KEY), deliveryCount);
|
||||
} else {
|
||||
boolean ack = dispatch(sub, fields, maxRetry);
|
||||
if (ack) redis.opsForStream().acknowledge(stream, group, rec.getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.njcn.mq.driver.redis;
|
||||
|
||||
import com.njcn.mq.core.MqMessage;
|
||||
import com.njcn.mq.spi.MqSubscription;
|
||||
import com.njcn.middle.stream.autoconfig.RedisStreamProperties;
|
||||
import com.njcn.middle.stream.support.StreamKeyBuilder;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
|
||||
import org.springframework.data.redis.connection.stream.PendingMessagesSummary;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
|
||||
import java.util.concurrent.BlockingQueue;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
||||
|
||||
class RedisStreamMqDriverRetryIT {
|
||||
|
||||
@Test
|
||||
void retryExhausted_afterMaxRetry_callsHandlerAndAcks() throws Exception {
|
||||
StringRedisTemplate redis = newRedisOrSkip();
|
||||
StreamKeyBuilder kb = new StreamKeyBuilder(false, "");
|
||||
RedisStreamProperties props = new RedisStreamProperties();
|
||||
props.setConsumerName("it-retry");
|
||||
props.setReadCount(10);
|
||||
props.setBlockMs(300);
|
||||
props.setMaxRetry(2);
|
||||
RedisStreamMqDriver driver = new RedisStreamMqDriver(redis, kb, props, 200L);
|
||||
|
||||
String topic = "MQ_RETRY_IT_" + System.nanoTime();
|
||||
AtomicInteger attempts = new AtomicInteger();
|
||||
BlockingQueue<String> exhausted = new LinkedBlockingQueue<>();
|
||||
driver.subscribe(MqSubscription.builder()
|
||||
.topic(topic).group("g1").tag("*").payloadType(String.class)
|
||||
.concurrency(1).batchSize(1).maxRetry(2)
|
||||
.listener(m -> { attempts.incrementAndGet(); throw new RuntimeException("always fail"); })
|
||||
.retryExhaustedHandler(m -> exhausted.add(m.getKey()))
|
||||
.build());
|
||||
|
||||
driver.send(topic, MqMessage.of("rk1", "", "data"));
|
||||
|
||||
String key = exhausted.poll(12, TimeUnit.SECONDS);
|
||||
assertEquals("rk1", key, "重试耗尽应回调 retryExhaustedHandler");
|
||||
assertTrue(attempts.get() >= 2, "应实际重试若干次后才耗尽, attempts=" + attempts.get());
|
||||
|
||||
Thread.sleep(500);
|
||||
PendingMessagesSummary summary = redis.opsForStream().pending(kb.buildStream(topic), "g1");
|
||||
assertEquals(0L, summary.getTotalPendingMessages(), "耗尽后应 ACK,PEL 清空");
|
||||
driver.stop();
|
||||
}
|
||||
|
||||
private static StringRedisTemplate newRedisOrSkip() {
|
||||
try {
|
||||
LettuceConnectionFactory cf = new LettuceConnectionFactory("localhost", 6379);
|
||||
cf.afterPropertiesSet();
|
||||
StringRedisTemplate t = new StringRedisTemplate(cf);
|
||||
t.afterPropertiesSet();
|
||||
t.hasKey("ping");
|
||||
return t;
|
||||
} catch (Exception e) { assumeTrue(false, "no local redis, skip"); return null; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user