fix(mq-starter): 落实 opus review 反馈(含 1 个 Critical 静默丢消息)
- [Critical] dispatcher errorHandler 抛异常时仍保证 markFail + 抛原始异常(safeOnError 隔离): 否则去重标记停在 processing,reclaim 重投被去重挡掉并 ACK → 静默丢消息 - [Important] 重试耗尽回调携带完整 payload(reclaim 解码 body,不再传 null) - [Important] reclaim 默认 idle 5s→30s,避免多实例误 reclaim in-flight 消息;注释 at-least-once 语义 - [Important] idempotent=true 但无 MqIdempotentStore bean 时 start() 告警 - core 引入 slf4j-api(provided);批量失败 / errorHandler 失败均记日志 - errorHandler bean 未找到时抛带 topic/group 的清晰错误 - 文档化已知限制(批量无 idle flush、去重 fail→reopen 非原子) - 全仓 29 测试绿(含连真实 redis 的 IT) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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<String, String> 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<Object>) c -> c.execute("XGROUP",
|
||||
|
||||
@@ -32,18 +32,20 @@ class RedisStreamMqDriverRetryIT {
|
||||
|
||||
String topic = "MQ_RETRY_IT_" + System.nanoTime();
|
||||
AtomicInteger attempts = new AtomicInteger();
|
||||
BlockingQueue<String> exhausted = new LinkedBlockingQueue<>();
|
||||
BlockingQueue<MqMessage> 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()))
|
||||
.retryExhaustedHandler(exhausted::add)
|
||||
.build());
|
||||
|
||||
driver.send(topic, MqMessage.of("rk1", "", "data"));
|
||||
|
||||
String key = exhausted.poll(12, TimeUnit.SECONDS);
|
||||
assertEquals("rk1", key, "重试耗尽应回调 retryExhaustedHandler");
|
||||
MqMessage exhaustedMsg = exhausted.poll(12, TimeUnit.SECONDS);
|
||||
assertNotNull(exhaustedMsg, "重试耗尽应回调 retryExhaustedHandler");
|
||||
assertEquals("rk1", exhaustedMsg.getKey());
|
||||
assertEquals("data", exhaustedMsg.getPayload(), "耗尽回调应携带完整 payload,而非 null");
|
||||
assertTrue(attempts.get() >= 2, "应实际重试若干次后才耗尽, attempts=" + attempts.get());
|
||||
|
||||
Thread.sleep(500);
|
||||
|
||||
Reference in New Issue
Block a user