fix(stream): autoclaim 先 XCLAIM 抢占再处理,杜绝多实例重复消费/重复死信

claimOne 此前死信分支不抢占、正常分支抢占但不校验结果,多实例并发认领同一条
PEL 消息时会被重复 dispatch、重复落死信。改 claim 返回是否真抢到(XCLAIM 不带
JUSTID 返回认领到的消息列表,空即未抢到),claimOne 先抢占成功才 dispatch / 落死信。
补 claimOne mock 单测(抢到/抢不到 × 正常/死信 共 4 例)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-06-26 13:21:32 +08:00
parent e30faf4cff
commit a2965dd80e
2 changed files with 170 additions and 10 deletions

View File

@@ -17,6 +17,7 @@ import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.StringRedisTemplate;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
@@ -166,15 +167,18 @@ public class StreamAutoClaimer implements SmartLifecycle {
} }
/** /**
* 处理单条 PEL 消息:取内容 → 死信判定前置 → 认领重投。 * 处理单条 PEL 消息:取内容 → XCLAIM 抢占 → 死信判定 → 认领重投。
* *
* <p>{@code deliveries} 为本轮 {@code XCLAIM} 之前的投递计数;因 {@link #claim} 用<b>不带 JUSTID</b> * <p><b>先抢占再处理</b>{@link #claim} 抢不到idle 不足 minIdle或并发实例已认领并重置 idle
* 的 XCLAIM每次认领会把投递计数 +1故 deliveries 随 autoclaim 轮次单调增长 * 即直接退出,保证多实例下同一条消息只被一个实例 dispatch / 落死信(幂等),不重复消费、不重复落死信
*
* <p>{@code deliveries} 为本轮 {@code XCLAIM} 之前的投递计数XPENDING 报告值);因 {@link #claim}
* 用<b>不带 JUSTID</b> 的 XCLAIM每次认领会把投递计数 +1故 deliveries 随 autoclaim 轮次单调增长。
* {@code maxRetry} 语义 = 最多投递 maxRetry 次(含首投),第 maxRetry 次仍 RETRY 即落死信。 * {@code maxRetry} 语义 = 最多投递 maxRetry 次(含首投),第 maxRetry 次仍 RETRY 即落死信。
* 死信判定<b>前置于 dispatch</b>:达上限直接落异常 + 强制 ACK不再多跑一次业务副作用。 * 死信分支抢占后<b>只落异常 + 强制 ACK不再 dispatch</b>,不跑业务副作用。
*/ */
private void claimOne(EnhanceStreamConsumerHandler<?> handler, String stream, String group, void claimOne(EnhanceStreamConsumerHandler<?> handler, String stream, String group,
long minIdle, int maxRetry, String id, long deliveries) { long minIdle, int maxRetry, String id, long deliveries) {
List<MapRecord<String, Object, Object>> records = List<MapRecord<String, Object, Object>> records =
redis.opsForStream().range(stream, Range.closed(id, id)); redis.opsForStream().range(stream, Range.closed(id, id));
if (records == null || records.isEmpty()) { if (records == null || records.isEmpty()) {
@@ -185,6 +189,12 @@ public class StreamAutoClaimer implements SmartLifecycle {
MapRecord<String, Object, Object> rec = records.get(0); MapRecord<String, Object, Object> rec = records.get(0);
Map<String, String> fields = toStringMap(rec.getValue()); Map<String, String> fields = toStringMap(rec.getValue());
// 先 XCLAIM 抢占仅当真正认领到idle 足够且未被并发实例抢走)才由本实例独占处理;
// 抢不到说明已有其它实例认领,直接退出——杜绝多实例对同一条消息重复 dispatch 或重复落死信。
if (!claim(stream, group, minIdle, id)) {
return;
}
if (deliveries >= maxRetry) { if (deliveries >= maxRetry) {
// 投递计数已达上限 → 落异常 + 强制 ACK死信不再无限滞留 PEL // 投递计数已达上限 → 落异常 + 强制 ACK死信不再无限滞留 PEL
// 由 handler 内部反序列化后回调,使死信回调能拿到业务消息对象(解析失败兜底 null // 由 handler 内部反序列化后回调,使死信回调能拿到业务消息对象(解析失败兜底 null
@@ -194,8 +204,7 @@ public class StreamAutoClaimer implements SmartLifecycle {
return; return;
} }
// 认领到本 claimer同时把投递计数 +1、重置 idle),再重投 // 认领投递计数 +1、idle 重置)→ 重投
claim(stream, group, minIdle, id);
ConsumeResult result = handler.dispatchMessage(fields); ConsumeResult result = handler.dispatchMessage(fields);
if (result == ConsumeResult.ACK) { if (result == ConsumeResult.ACK) {
redis.opsForStream().acknowledge(stream, group, rec.getId()); redis.opsForStream().acknowledge(stream, group, rec.getId());
@@ -209,14 +218,24 @@ public class StreamAutoClaimer implements SmartLifecycle {
* <p>不带 JUSTID 时 XCLAIM 会转移归属、重置 idle 并把投递计数 +1——后者是 maxRetry 死信判定的 * <p>不带 JUSTID 时 XCLAIM 会转移归属、重置 idle 并把投递计数 +1——后者是 maxRetry 死信判定的
* 依据listener 用 {@code XREADGROUP lastConsumed()} 永不重读 PEL若此处用 JUSTID 则投递计数 * 依据listener 用 {@code XREADGROUP lastConsumed()} 永不重读 PEL若此处用 JUSTID 则投递计数
* 永久冻结在首投值 1{@code deliveries >= maxRetry} 恒不成立、死信分支永不触发。 * 永久冻结在首投值 1{@code deliveries >= maxRetry} 恒不成立、死信分支永不触发。
*
* @return 是否真正认领到本条XCLAIM 抢到返回非空消息列表,没抢到返回空。返回类型未知时保守当作
* 抢到(宁可退化回"可能重复"也不让消息因误判没抢到而永久卡 PEL
*/ */
private void claim(String stream, String group, long minIdle, String id) { private boolean claim(String stream, String group, long minIdle, String id) {
redis.execute((RedisCallback<Object>) connection -> connection.execute("XCLAIM", Object claimed = redis.execute((RedisCallback<Object>) connection -> connection.execute("XCLAIM",
stream.getBytes(StandardCharsets.UTF_8), stream.getBytes(StandardCharsets.UTF_8),
group.getBytes(StandardCharsets.UTF_8), group.getBytes(StandardCharsets.UTF_8),
claimConsumer.getBytes(StandardCharsets.UTF_8), claimConsumer.getBytes(StandardCharsets.UTF_8),
String.valueOf(minIdle).getBytes(StandardCharsets.UTF_8), String.valueOf(minIdle).getBytes(StandardCharsets.UTF_8),
id.getBytes(StandardCharsets.UTF_8))); id.getBytes(StandardCharsets.UTF_8)));
if (claimed instanceof Collection) {
return !((Collection<?>) claimed).isEmpty();
}
if (claimed instanceof Object[]) {
return ((Object[]) claimed).length > 0;
}
return claimed != null;
} }
private static Map<String, String> toStringMap(Map<Object, Object> raw) { private static Map<String, String> toStringMap(Map<Object, Object> raw) {

View File

@@ -0,0 +1,141 @@
package com.njcn.middle.stream.container;
import com.njcn.middle.stream.autoconfig.RedisStreamProperties;
import com.njcn.middle.stream.domain.StreamBaseMessage;
import com.njcn.middle.stream.handler.ConsumeResult;
import com.njcn.middle.stream.handler.EnhanceStreamConsumerHandler;
import com.njcn.middle.stream.support.StreamKeyBuilder;
import org.junit.jupiter.api.Test;
import org.springframework.data.domain.Range;
import org.springframework.data.redis.connection.stream.MapRecord;
import org.springframework.data.redis.connection.stream.RecordId;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.StreamOperations;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 单元测试:autoclaim 认领单条 PEL 消息({@link StreamAutoClaimer#claimOne})的多实例幂等。
*
* <p>核心:XCLAIM 真正抢到(返回非空)才由本实例 dispatch / 落死信;抢不到(并发实例已认领、
* idle 被重置)直接退出,杜绝重复消费与重复死信回调。
*/
class StreamAutoClaimerClaimTest {
private static final String STREAM = "T";
private static final String GROUP = "G";
private static final String ID = "1-0";
private final StringRedisTemplate redis = mock(StringRedisTemplate.class);
@SuppressWarnings("unchecked")
private final StreamOperations<String, Object, Object> ops = mock(StreamOperations.class);
@SuppressWarnings("unchecked")
private StreamAutoClaimer newClaimer(RecordingHandler handler) {
when(redis.opsForStream()).thenReturn(ops);
Map<Object, Object> fields = new HashMap<>();
fields.put("enc", "plain");
fields.put("body", "{}");
MapRecord<String, Object, Object> rec = mock(MapRecord.class);
when(rec.getValue()).thenReturn(fields);
when(rec.getId()).thenReturn(RecordId.of(ID));
when(ops.range(eq(STREAM), any(Range.class))).thenReturn(Collections.singletonList(rec));
return new StreamAutoClaimer(redis, new StreamKeyBuilder(false, ""),
new RedisStreamProperties(), Collections.singletonList(handler));
}
@SuppressWarnings("unchecked")
private void stubClaimAcquired(boolean acquired) {
when(redis.execute(any(RedisCallback.class)))
.thenReturn(acquired ? Collections.singletonList(new Object()) : Collections.emptyList());
}
@Test
void skipsDispatchWhenClaimLost() {
RecordingHandler handler = new RecordingHandler();
StreamAutoClaimer claimer = newClaimer(handler);
stubClaimAcquired(false); // 并发实例已抢走,本实例 XCLAIM 抢不到
claimer.claimOne(handler, STREAM, GROUP, 1000L, 3, ID, 0);
assertFalse(handler.dispatched, "未抢到 XCLAIM 不应 dispatch");
verify(ops, never()).acknowledge(eq(STREAM), eq(GROUP), any(RecordId.class));
}
@Test
void skipsDeadLetterWhenClaimLost() {
RecordingHandler handler = new RecordingHandler();
StreamAutoClaimer claimer = newClaimer(handler);
stubClaimAcquired(false);
claimer.claimOne(handler, STREAM, GROUP, 1000L, 3, ID, 9); // deliveries 已超限
assertFalse(handler.deadLettered, "未抢到 XCLAIM 不应重复落死信");
verify(ops, never()).acknowledge(eq(STREAM), eq(GROUP), any(RecordId.class));
}
@Test
void dispatchesWhenClaimAcquired() {
RecordingHandler handler = new RecordingHandler();
StreamAutoClaimer claimer = newClaimer(handler);
stubClaimAcquired(true);
claimer.claimOne(handler, STREAM, GROUP, 1000L, 3, ID, 0);
assertTrue(handler.dispatched, "抢到 XCLAIM 应 dispatch");
verify(ops, times(1)).acknowledge(eq(STREAM), eq(GROUP), any(RecordId.class));
}
@Test
void deadLettersWhenClaimAcquiredAndOverLimit() {
RecordingHandler handler = new RecordingHandler();
StreamAutoClaimer claimer = newClaimer(handler);
stubClaimAcquired(true);
claimer.claimOne(handler, STREAM, GROUP, 1000L, 3, ID, 9);
assertTrue(handler.deadLettered, "抢到且超限应落死信");
assertFalse(handler.dispatched, "死信不应再 dispatch 业务");
verify(ops, times(1)).acknowledge(eq(STREAM), eq(GROUP), any(RecordId.class));
}
static class TestMsg extends StreamBaseMessage {
}
/** 记录 dispatch / 死信回调是否被触发的测试桩。 */
static class RecordingHandler extends EnhanceStreamConsumerHandler<TestMsg> {
boolean dispatched = false;
boolean deadLettered = false;
@Override public String topic() { return STREAM; }
@Override public String group() { return GROUP; }
@Override public Class<TestMsg> messageType() { return TestMsg.class; }
@Override public void handleMessage(TestMsg message) { }
@Override public boolean isRetry() { return true; }
@Override public boolean throwException() { return false; }
@Override
public ConsumeResult dispatchMessage(Map<String, String> fields) {
dispatched = true;
return ConsumeResult.ACK;
}
@Override
public void saveExceptionFromFields(Map<String, String> fields, Exception cause) {
deadLettered = true;
}
}
}