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 8ede99d..6f2622c 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 com.njcn.mq.spi.MqMessageInProgressException; import lombok.extern.slf4j.Slf4j; import java.util.ArrayList; @@ -35,7 +36,15 @@ public class MqConsumeDispatcher { public void dispatch(MqMessage msg) throws Exception { if (idempotent && store != null && !store.tryAcquire(msg.getKey())) { - return; // processing 中或已 success,跳过 + // 跳过分两类,决定 driver 能否 ACK: + // - 已 success:正常返回 → driver ACK,挡掉重复投递(正常去重); + // - 仍 processing(并发处理中,或消费者在 mark 前崩溃残留的标记):抛信号异常 → + // driver 不 ACK、保留消息,待 processing 标记过期后真正重投。否则该消息可能从未被 + // 成功消费却被去重挡回并 ACK → 静默丢消息。 + if (store.isCompleted(msg.getKey())) { + return; + } + throw new MqMessageInProgressException(msg.getKey()); } if (batchSize <= 1) { try { diff --git a/mq-starter-core/src/main/java/com/njcn/mq/spi/MqIdempotentStore.java b/mq-starter-core/src/main/java/com/njcn/mq/spi/MqIdempotentStore.java index b04c4f8..06aeb5c 100644 --- a/mq-starter-core/src/main/java/com/njcn/mq/spi/MqIdempotentStore.java +++ b/mq-starter-core/src/main/java/com/njcn/mq/spi/MqIdempotentStore.java @@ -21,4 +21,22 @@ public interface MqIdempotentStore { /** 消费失败,标记 fail(允许后续重新消费)。 */ void markFail(String key); + + /** + * {@link #tryAcquire(String)} 返回 false(跳过)后,判断该 key 是否已消费成功。 + * + *
用于让 driver 区分两类"跳过",这决定了 driver 能否 ACK 该消息: + *
本次投递既不能视为成功(否则 driver 会 ACK,导致该消息若从未被成功消费即静默丢失), + * 也不属于业务处理失败。driver 捕获后应保留消息(不 ACK),待 processing 标记过期后由 MQ 真正重投。 + * + *
不携带堆栈(纯控制流,规避噪音与开销)。
+ */
+public class MqMessageInProgressException extends RuntimeException {
+
+ public MqMessageInProgressException(String key) {
+ super("message in-progress (dedup processing), leave for replay: key=" + key);
+ }
+
+ @Override
+ public synchronized Throwable fillInStackTrace() {
+ return this;
+ }
+}
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 1febbfd..f8f4924 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
@@ -143,6 +143,50 @@ class MqConsumeDispatcherTest {
assertEquals(2, succeeded.size(), "flush 成功后应对每条标记 success");
}
+ @Test
+ void idempotent_skipInProgress_throwsSoDriverWontAckAndReplays() {
+ // 崩溃残留场景:去重标记停在 processing(tryAcquire=false 且 isCompleted=false)。
+ // 消费者在 tryAcquire 之后、markSuccess/markFail 之前崩溃,消息从未被成功消费。
+ // 若此时静默返回,driver 会把这条消息 ACK → 静默丢消息(Critical)。
+ // 正确行为:抛异常,使 driver 不 ACK、保留消息,待 processing 标记过期后真正重投。
+ List isCompleted 必须仅在 key 值为 {@code success} 时返回 true:processing / 无记录 一律 false,
+ * 否则消费者崩溃后残留的 processing 标记会被 driver 当成"已完成"而 ACK,造成静默丢消息。
+ */
+class RedisMqIdempotentStoreTest {
+
+ @Test
+ void isCompleted_trueOnlyForSuccessValue() {
+ StringRedisTemplate redis = mock(StringRedisTemplate.class);
+ @SuppressWarnings("unchecked")
+ ValueOperations> consumed = new ArrayList<>();
+ MqIdempotentStore store = new MqIdempotentStore() {
+ @Override public boolean tryAcquire(String key) { return false; }
+ @Override public void markSuccess(String key) { }
+ @Override public void markFail(String key) { }
+ @Override public boolean isCompleted(String key) { return false; }
+ };
+ MqConsumeDispatcher d = MqConsumeDispatcher.builder()
+ .batchSize(1).idempotent(true).store(store)
+ .consumer(consumed::add)
+ .build();
+
+ assertThrows(Exception.class,
+ () -> d.dispatch(MqMessage.of("k1", "", "a")),
+ "processing 残留(非 success)跳过时应抛异常,使 driver 不 ACK、留待过期后重投,避免静默丢消息");
+ assertEquals(0, consumed.size(), "不应消费");
+ }
+
+ @Test
+ void idempotent_skipCompleted_returnsNormallySoDriverAcks() {
+ // 已成功的重复投递:应被静默去重(driver 正常 ACK),不抛异常、不重复消费。
+ List
> consumed = new ArrayList<>();
+ MqIdempotentStore store = new MqIdempotentStore() {
+ @Override public boolean tryAcquire(String key) { return false; }
+ @Override public void markSuccess(String key) { }
+ @Override public void markFail(String key) { }
+ @Override public boolean isCompleted(String key) { return true; }
+ };
+ MqConsumeDispatcher d = MqConsumeDispatcher.builder()
+ .batchSize(1).idempotent(true).store(store)
+ .consumer(consumed::add)
+ .build();
+
+ assertDoesNotThrow(() -> d.dispatch(MqMessage.of("k1", "", "a")),
+ "已成功的重复投递应被静默去重(driver ACK),不抛异常");
+ assertEquals(0, consumed.size(), "不应重复消费");
+ }
+
@Test
void singleMode_whenErrorHandlerThrows_stillMarksFailAndRethrowsOriginal() {
List