diff --git a/mq-starter-core/pom.xml b/mq-starter-core/pom.xml
index 81c26d0..a63dd49 100644
--- a/mq-starter-core/pom.xml
+++ b/mq-starter-core/pom.xml
@@ -8,6 +8,7 @@
com.alibabafastjson${fastjson.version}
cn.hutoolhutool-all${hutool.version}
org.projectlomboklombok${lombok.version}true
+ org.slf4jslf4j-api1.7.30provided
org.springframework.bootspring-boot-configuration-processor${springboot.version}true
org.springframework.bootspring-boot-starter-test${springboot.version}test
diff --git a/mq-starter-core/src/main/java/com/njcn/mq/container/MqConsumeDispatcher.java b/mq-starter-core/src/main/java/com/njcn/mq/container/MqConsumeDispatcher.java
index 601eac2..8ede99d 100644
--- a/mq-starter-core/src/main/java/com/njcn/mq/container/MqConsumeDispatcher.java
+++ b/mq-starter-core/src/main/java/com/njcn/mq/container/MqConsumeDispatcher.java
@@ -4,6 +4,7 @@ import com.njcn.mq.core.MqMessage;
import com.njcn.mq.spi.MqConsumeErrorHandler;
import com.njcn.mq.spi.MqErrorIdentity;
import com.njcn.mq.spi.MqIdempotentStore;
+import lombok.extern.slf4j.Slf4j;
import java.util.ArrayList;
import java.util.Collections;
@@ -13,8 +14,10 @@ import java.util.List;
* 消费处理链:编排 去重 →(攒批)→ 业务消费 → 成功标记 / 异常落库。
* 纯逻辑、不依赖 spring / redis,便于单测。重试本身交给 driver 的 MQ 原生重投。
*/
+@Slf4j
public class MqConsumeDispatcher {
+ // >1 时攒批;注意:无 idle flush,低流量下未满批会停滞、进程重启丢未入库批(与 legacy 一致,适合高流量流)。
private final int batchSize;
private final boolean idempotent;
private final MqIdempotentStore store;
@@ -38,13 +41,13 @@ public class MqConsumeDispatcher {
try {
consumer.consume(Collections.singletonList(msg));
} catch (Exception e) {
- if (errorHandler != null) {
- errorHandler.onError(msg, MqErrorIdentity.SINGLE, e);
- }
+ // errorHandler 自身失败不得跳过 markFail:否则去重标记停在 processing,
+ // reclaim 重投时会被去重挡掉并 ACK → 静默丢消息。
+ safeOnError(msg, MqErrorIdentity.SINGLE, e);
if (idempotent && store != null) {
store.markFail(msg.getKey());
}
- throw e; // 交 driver 重投
+ throw e; // 抛原始异常,交 driver 重投
}
if (idempotent && store != null) {
store.markSuccess(msg.getKey());
@@ -66,10 +69,9 @@ public class MqConsumeDispatcher {
} catch (Exception e) {
// 批量入库失败:消息已逐条 ACK,无法精确重投整批 → 对齐 legacy 不卡队列:
// 落库 + 吞掉,不抛、不标 success(去重 processing 标记自然过期)。
- if (errorHandler != null) {
- for (MqMessage m : flush) {
- errorHandler.onError(m, MqErrorIdentity.SINGLE, e);
- }
+ log.warn("[mq] 批量消费失败,已落库并放弃该批 {} 条", flush.size(), e);
+ for (MqMessage m : flush) {
+ safeOnError(m, MqErrorIdentity.SINGLE, e);
}
return;
}
@@ -81,6 +83,16 @@ public class MqConsumeDispatcher {
}
}
+ /** 调用异常落库回调,并隔离其自身异常(落库是 best-effort,不得掩盖/中断主流程)。 */
+ private void safeOnError(MqMessage msg, MqErrorIdentity identity, Exception cause) {
+ if (errorHandler == null) return;
+ try {
+ errorHandler.onError(msg, identity, cause);
+ } catch (Exception ehEx) {
+ log.error("[mq] errorHandler 落库失败 key={}(不影响主流程)", msg.getKey(), ehEx);
+ }
+ }
+
public static Builder builder() { return new Builder(); }
public static class Builder {
diff --git a/mq-starter-core/src/main/java/com/njcn/mq/container/MqListenerRegistry.java b/mq-starter-core/src/main/java/com/njcn/mq/container/MqListenerRegistry.java
index 6a756ac..2e74217 100644
--- a/mq-starter-core/src/main/java/com/njcn/mq/container/MqListenerRegistry.java
+++ b/mq-starter-core/src/main/java/com/njcn/mq/container/MqListenerRegistry.java
@@ -8,10 +8,12 @@ import com.njcn.mq.spi.MqDriver;
import com.njcn.mq.spi.MqIdempotentStore;
import com.njcn.mq.spi.MqMessageListener;
import com.njcn.mq.spi.MqSubscription;
+import lombok.extern.slf4j.Slf4j;
import org.springframework.context.SmartLifecycle;
import java.util.ArrayList;
import java.util.List;
+@Slf4j
public class MqListenerRegistry implements SmartLifecycle {
private final MqDriver driver;
private final List endpoints;
@@ -34,6 +36,10 @@ public class MqListenerRegistry implements SmartLifecycle {
if (running) return;
for (MqListenerEndpoint ep : endpoints) {
MqListener meta = ep.getMeta();
+ if (meta.idempotent() && store == null) {
+ log.warn("[mq] @MqListener(idempotent=true) 但容器中无 MqIdempotentStore bean,去重不生效: topic={} group={}",
+ meta.topic(), meta.group());
+ }
MqConsumeErrorHandler eh = resolveErrorHandler(meta);
MqConsumeDispatcher.Builder db = MqConsumeDispatcher.builder()
@@ -64,7 +70,12 @@ public class MqListenerRegistry implements SmartLifecycle {
private MqConsumeErrorHandler resolveErrorHandler(MqListener meta) {
if (errorHandlerResolver == null || meta.errorHandler().isEmpty()) return null;
- return errorHandlerResolver.apply(meta.errorHandler());
+ try {
+ return errorHandlerResolver.apply(meta.errorHandler());
+ } catch (Exception e) {
+ throw new IllegalStateException("@MqListener(errorHandler=\"" + meta.errorHandler()
+ + "\") 指定的 bean 未找到: topic=" + meta.topic() + " group=" + meta.group(), e);
+ }
}
/** 业务调用适配:批量 endpoint 调 (List<payload>[,ctx]);单条 endpoint 逐条调 (payload[,ctx])。 */
diff --git a/mq-starter-core/src/test/java/com/njcn/mq/container/MqConsumeDispatcherTest.java b/mq-starter-core/src/test/java/com/njcn/mq/container/MqConsumeDispatcherTest.java
index 6afba25..1febbfd 100644
--- a/mq-starter-core/src/test/java/com/njcn/mq/container/MqConsumeDispatcherTest.java
+++ b/mq-starter-core/src/test/java/com/njcn/mq/container/MqConsumeDispatcherTest.java
@@ -142,4 +142,28 @@ class MqConsumeDispatcherTest {
assertEquals(2, succeeded.size(), "flush 成功后应对每条标记 success");
}
+
+ @Test
+ void singleMode_whenErrorHandlerThrows_stillMarksFailAndRethrowsOriginal() {
+ List failed = new ArrayList<>();
+ MqIdempotentStore store = new MqIdempotentStore() {
+ @Override public boolean tryAcquire(String key) { return true; }
+ @Override public void markSuccess(String key) { }
+ @Override public void markFail(String key) { failed.add(key); }
+ };
+ // errorHandler 自身抛异常(落库 sink 挂掉,常与错误风暴同时发生)
+ MqConsumeErrorHandler eh = (m, id, ex) -> { throw new RuntimeException("sink down"); };
+ RuntimeException boom = new RuntimeException("biz boom");
+ MqConsumeDispatcher d = MqConsumeDispatcher.builder()
+ .batchSize(1).idempotent(true).store(store).errorHandler(eh)
+ .consumer(batch -> { throw boom; })
+ .build();
+
+ Exception thrown = assertThrows(Exception.class,
+ () -> d.dispatch(MqMessage.of("k1", "", "a")));
+
+ assertSame(boom, thrown, "errorHandler 失败不应掩盖原始消费异常");
+ assertEquals(1, failed.size(),
+ "errorHandler 抛异常时仍须 markFail,否则去重标记停在 processing 会导致 reclaim 时被去重挡掉而丢消息");
+ }
}
diff --git a/mq-starter-redis-stream/src/main/java/com/njcn/mq/driver/redis/RedisMqIdempotentStore.java b/mq-starter-redis-stream/src/main/java/com/njcn/mq/driver/redis/RedisMqIdempotentStore.java
index 3abd48d..6eecf68 100644
--- a/mq-starter-redis-stream/src/main/java/com/njcn/mq/driver/redis/RedisMqIdempotentStore.java
+++ b/mq-starter-redis-stream/src/main/java/com/njcn/mq/driver/redis/RedisMqIdempotentStore.java
@@ -41,6 +41,8 @@ public class RedisMqIdempotentStore implements MqIdempotentStore {
return true;
}
// key 已存在:fail(上次失败)允许重新消费;processing / success 一律挡回。
+ // 注意:fail→reopen 的 get+set 非原子,跨实例对「共享同一 key 的不同消息」可能都放行;
+ // 同一条消息由 stream PEL/claim 序列化,故仅影响不同消息共享 key 的边缘场景。
if (FAIL.equals(redis.opsForValue().get(k))) {
redis.opsForValue().set(k, PROCESSING, Duration.ofSeconds(processingTtl));
return true;
diff --git a/mq-starter-redis-stream/src/main/java/com/njcn/mq/driver/redis/RedisStreamMqDriver.java b/mq-starter-redis-stream/src/main/java/com/njcn/mq/driver/redis/RedisStreamMqDriver.java
index e6de53d..5da965d 100644
--- a/mq-starter-redis-stream/src/main/java/com/njcn/mq/driver/redis/RedisStreamMqDriver.java
+++ b/mq-starter-redis-stream/src/main/java/com/njcn/mq/driver/redis/RedisStreamMqDriver.java
@@ -28,7 +28,9 @@ public class RedisStreamMqDriver implements MqDriver {
private volatile boolean running = true;
public RedisStreamMqDriver(StringRedisTemplate redis, StreamKeyBuilder keyBuilder, RedisStreamProperties props) {
- this(redis, keyBuilder, props, 5000L);
+ // reclaim 默认 idle 30s:须大于正常处理耗时,避免多实例误 reclaim 仍在处理(in-flight)的消息造成重复消费;
+ // 非幂等消费在多实例下为 at-least-once,可通过 4 参构造按业务处理时延调整。
+ this(redis, keyBuilder, props, 30_000L);
}
public RedisStreamMqDriver(StringRedisTemplate redis, StreamKeyBuilder keyBuilder, RedisStreamProperties props, long reclaimMinIdleMs) {
@@ -132,8 +134,7 @@ public class RedisStreamMqDriver implements MqDriver {
if (deliveryCount > maxRetry) {
if (sub.getRetryExhaustedHandler() != null) {
try {
- sub.getRetryExhaustedHandler().accept(MqMessage.of(
- fields.get(StreamMessageConstant.F_KEY), fields.get(StreamMessageConstant.F_TAG), null));
+ sub.getRetryExhaustedHandler().accept(buildMessage(sub, fields));
} catch (Exception ex) {
log.error("[mq-redis] retryExhausted handler error key={}", fields.get(StreamMessageConstant.F_KEY), ex);
}
@@ -147,6 +148,18 @@ public class RedisStreamMqDriver implements MqDriver {
}
}
+ /** 解码消息体为业务 payload(best-effort:毒消息时 payload 为 null),用于重试耗尽落库回调。 */
+ private MqMessage buildMessage(MqSubscription sub, Map fields) {
+ Object payload = null;
+ try {
+ String json = MessageCodec.decodeBody(fields.get(StreamMessageConstant.F_ENC), fields.get(StreamMessageConstant.F_BODY));
+ payload = JSON.parseObject(json, sub.getPayloadType());
+ } catch (Exception ignore) {
+ // 毒消息:payload 留 null,至少保留 key/tag 供落库
+ }
+ return MqMessage.of(fields.get(StreamMessageConstant.F_KEY), fields.get(StreamMessageConstant.F_TAG), payload);
+ }
+
private void ensureGroup(String stream, String group) {
try {
redis.execute((RedisCallback