feat(mq-starter-core): 同 group 订阅 tag 不一致启动 fail-fast,病态 tag 表达式打 WARN

同组配不同 tag 时消息只投组内某一个消费者,不匹配方直接 ACK 吞掉、另一方
永远收不到(订阅关系不一致,静默丢消息级事故,README 原本只靠文字约定)。
现在注解扫描期强校验:同一 group 声明不等价的 tag 表达式直接 IllegalStateException,
报错点名 group、冲突表达式与两个方法。等价性按 MqTagMatcher.validParts 有效
tag 集合比较("A || B" 与 "B||A" 等价,*/空/病态式同为全收),只覆盖单应用内。

- MqTagMatcher 新增 validParts / looksMisconfigured,matches 重构为基于
  validParts,解析口径单一来源(每消息多一次小集合分配,可忽略)
- 病态表达式("||"、纯空白:写了内容却解析不出有效 tag)扫描期打 WARN 不阻断,
  redis 端静默全收 vs rocketmq 端行为不同的分叉维持现状(拍板记录不修)
- README tag 小节、CLAUDE.md 签名约束一节同步

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 12:21:10 +01:00
parent 2bcce78763
commit 646cc6c238
6 changed files with 188 additions and 13 deletions

View File

@@ -85,6 +85,7 @@ driver 只需实现 `MqDriver``send` / `subscribe` / `stop`)。其余是可
- `batchSize > 1` 时首参要用 `List<T>`,且**必须声明具体元素类型**`List<FrontData>`,不能裸 `List`)—— driver 仍按 `T` 逐条反序列化dispatcher 负责攒批。
- `batchSize` 与首参是否 `List` 的**错配只打 WARN、不阻断启动**2026-07-14 决策,刻意软校验):走不走批量始终只由首参决定——`batchSize>1` 配单 payload 按单条跑,`List` 首参配 `batchSize<=1` 按 1 条一批跑。
- `batchTimeoutMs` 仅批量监听器生效;`batchSize<=1` 且显式配了非缺省值时同样只打 WARN 软校验、不阻断(恰好显式写 60000 检测不出,已知可接受)。
- **同 group 订阅 tag 一致性强校验**2026-07-16同一 group 声明不等价的 tag 表达式直接抛 `IllegalStateException`(订阅关系不一致 = 静默丢消息;等价性按 `MqTagMatcher.validParts` 有效 tag 集合比较,`*`/空/病态式同为全收不算冲突)。只覆盖单应用内,跨应用/滚动发布窗口的不一致防不住。病态表达式(写了内容却解析不出有效 tag`"||"`、纯空白)只打 WARN 不阻断——redis 端全收 vs rocketmq 端行为不同的分叉本身维持现状2026-07-16 拍板记录不修)。
## 启动期 fail-fast 校验(近期新增,改动时留意)

View File

@@ -129,7 +129,7 @@ public void onFile(FileData msg) { } // 只收 tag=node-01 的消息,
- 消费端不写 `tag` 默认 `*`,整个 topic 全收;发送端用两参 `send` 则消息不带 tag——**对端若按 tag 过滤,不带标签的消息它收不到**,跨系统对接时这是个经典坑。
- rocketmq 模式:过滤发生在 **broker 服务端**不匹配的消息根本不会投到消费者tag 表达式直接透传 RocketMQ 原生语法,`"A || B"` 多选一也支持。
- redis-stream 模式:**消费端模拟过滤**——driver 收到消息后按订阅 tag 比对,不匹配的直接 ACK 丢弃、不进消费链(不碰去重、不算重试、不落库),支持与 rocketmq 相同的写法子集(`*` 全收、单 tag 精确、`A || B` 多选一)。与 rocketmq 的差别只剩过滤位置与成本:消息会先走一趟网络到消费端才被丢弃。
- **同一 group 的所有订阅必须用同一个 tag 表达式**(含滚动发布期间的新旧实例)——要按 tag 分流,就各用各的 group。同组配不同 tag 时,消息只会投给组内某一个消费者,拿到"不归自己"消息的那个会直接 ACK 吞掉对方永远收不到两端皆然rocketmq 生态称之为「订阅关系不一致」)。
- **同一 group 的所有订阅必须用同一个 tag 表达式**(含滚动发布期间的新旧实例)——要按 tag 分流,就各用各的 group。同组配不同 tag 时,消息只会投给组内某一个消费者,拿到"不归自己"消息的那个会直接 ACK 吞掉对方永远收不到两端皆然rocketmq 生态称之为「订阅关系不一致」)。**同应用内这条规则由框架启动时强校验**,配错直接启动失败(等价性按有效 tag 集合比较,`"A || B"``"B||A"` 不算冲突);跨应用共用 group、滚动发布窗口内的新旧版本仍需靠约定保持一致。另外写了内容却解析不出任何有效 tag 的表达式(如 `"||"`、纯空白)启动时会打 WARN——这种写法 redis-stream 按全收处理、rocketmq 行为与之不同,多半是写错了,全收请用 `*` 或留空。
## 进阶用法

View File

@@ -1,18 +1,23 @@
package com.njcn.mq.container;
import com.njcn.mq.annotation.MqListener;
import com.njcn.mq.core.MqContext;
import com.njcn.mq.spi.MqTagMatcher;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.core.annotation.AnnotatedElementUtils;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Slf4j
public class MqListenerAnnotationBeanPostProcessor implements BeanPostProcessor {
private final List<MqListenerEndpoint> endpoints = new ArrayList<>();
/** group → 该 group 首个登记的 endpoint用于同 group 订阅 tag 一致性校验。 */
private final Map<String, MqListenerEndpoint> groupFirstSeen = new HashMap<>();
public List<MqListenerEndpoint> getEndpoints() { return endpoints; }
@Override
@@ -53,8 +58,28 @@ public class MqListenerAnnotationBeanPostProcessor implements BeanPostProcessor
log.warn("[mq] @MqListener batchTimeoutMs={} 仅对批量监听器batchSize>1生效当前 batchSize={} 下不生效: {}",
meta.batchTimeoutMs(), meta.batchSize(), m);
}
// tag 写了内容却解析不出任何有效 tag如 "||"、纯空白redis-stream 端会静默按全收处理、
// rocketmq 端透传原生语法行为不同——大概率写错WARN 提示不阻断(全收请用 * 或留空)。
if (MqTagMatcher.looksMisconfigured(meta.tag())) {
log.warn("[mq] @MqListener tag=\"{}\" 解析不出任何有效 tagredis-stream 将按全收处理、rocketmq 行为与之不同,疑似配置错误(全收请用 * 或留空): {}",
meta.tag(), m);
}
m.setAccessible(true);
endpoints.add(new MqListenerEndpoint(bean, m, meta, payloadType, wantsContext, batch));
MqListenerEndpoint endpoint = new MqListenerEndpoint(bean, m, meta, payloadType, wantsContext, batch);
// 同 group 的所有订阅必须用同一个 tag 表达式(订阅关系一致性,等价性按有效 tag 集合比较)。
// 同组配不同 tag 时消息只投组内某一个消费者,拿到"不归自己"消息的会直接 ACK 丢弃、
// 另一方永远收不到——静默丢消息级事故必须在启动期拦下README tag 一节的硬规则)。
MqListenerEndpoint firstInGroup = groupFirstSeen.get(meta.group());
if (firstInGroup == null) {
groupFirstSeen.put(meta.group(), endpoint);
} else if (!MqTagMatcher.validParts(firstInGroup.getMeta().tag()).equals(MqTagMatcher.validParts(meta.tag()))) {
throw new IllegalStateException(String.format(
"@MqListener 同一 group 的订阅 tag 必须一致: group=%s 已声明 tag=\"%s\" (%s),此处又声明 tag=\"%s\" (%s)。"
+ "同组不同 tag 时消息只会投给组内某一个消费者,不匹配方会直接 ACK 丢弃、另一方永远收不到(订阅关系不一致);"
+ "要按 tag 分流请各用各的 group同一类消费请把 tag 配成一致。",
meta.group(), firstInGroup.getMeta().tag(), firstInGroup.getMethod(), meta.tag(), m));
}
endpoints.add(endpoint);
}
return bean;
}

View File

@@ -1,5 +1,9 @@
package com.njcn.mq.spi;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
/**
* 订阅 tag 表达式匹配,语义对齐 RocketMQ 原生 tag 表达式**子集**
* {@code *}/空 = 全收、单 tag 精确匹配、{@code A || B} 多选一。不支持 SQL92 / 否定 / 通配。
@@ -18,25 +22,43 @@ public final class MqTagMatcher {
* @return true = 该消息归本订阅消费
*/
public static boolean matches(String subscriptionTag, String messageTag) {
// 空集 = 全收(*/空/全空段病态式);有有效段则精确命中其一。解析口径单一来源于 validParts。
Set<String> parts = validParts(subscriptionTag);
return parts.isEmpty() || parts.contains(messageTag);
}
/**
* 解析表达式中的有效 tag 集合。空集 = 全收null / 空 / {@code *} / 解析不出有效段的病态表达式),
* 判定口径与 {@link #matches} 完全一致。供订阅一致性比对等复用——等价性按集合比较,
* 顺序与空白差异({@code "A || B"} vs {@code "B||A"})不构成不一致。
*/
public static Set<String> validParts(String subscriptionTag) {
if (subscriptionTag == null) {
return true;
return Collections.emptySet();
}
String expr = subscriptionTag.trim();
if (expr.isEmpty() || "*".equals(expr)) {
return true;
return Collections.emptySet();
}
boolean hasValidPart = false;
Set<String> parts = new HashSet<>();
for (String part : expr.split("\\|\\|")) {
String p = part.trim();
if (p.isEmpty()) {
continue; // 空段忽略(如 "A ||" 的尾段)
}
hasValidPart = true;
if (p.equals(messageTag)) {
return true;
if (!p.isEmpty()) {
parts.add(p);
}
}
// 全空段(如 "||"= 没声明任何有效 tag = 视同全收;有有效段但都没命中 = 不匹配
return !hasValidPart;
return parts;
}
/**
* true = 表达式写了内容(非空串、非 {@code *})却解析不出任何有效 tag如 {@code "||"}、纯空白)。
* 这种写法 redis-stream 端静默按全收处理、rocketmq 端行为不同(透传原生语法,可能启动失败或全不收),
* 大概率是配置写错,调用方应打 WARN 提示;全收请用 {@code *} 或留空。
*/
public static boolean looksMisconfigured(String subscriptionTag) {
if (subscriptionTag == null || subscriptionTag.isEmpty()) {
return false;
}
return !"*".equals(subscriptionTag.trim()) && validParts(subscriptionTag).isEmpty();
}
}

View File

@@ -145,4 +145,90 @@ class MqListenerScanTest {
bpp.postProcessAfterInitialization(new TimeoutOffNegativeOnSingle(), "biz2");
assertEquals(0, warnCount(), "0/负数 = 关闭超时,单条监听器显式关闭语义无害,不应误报 WARN");
}
// ---- 同 group 订阅 tag 一致性(订阅关系一致性)硬校验 ----
static class GroupTagA {
@MqListener(topic = "GT", group = "gt_consumer", tag = "A")
public void on(String msg) { }
}
static class GroupTagB {
@MqListener(topic = "GT", group = "gt_consumer", tag = "B")
public void on(String msg) { }
}
static class GroupTagAOrB {
@MqListener(topic = "GT", group = "gt_eq", tag = "A || B")
public void on(String msg) { }
}
static class GroupTagBOrA {
@MqListener(topic = "GT2", group = "gt_eq", tag = "B||A")
public void on(String msg) { }
}
static class GroupTagStar {
@MqListener(topic = "GT", group = "gt_all", tag = "*")
public void on(String msg) { }
}
static class GroupTagEmpty {
@MqListener(topic = "GT2", group = "gt_all", tag = "")
public void on(String msg) { }
}
static class OtherGroupTagB {
@MqListener(topic = "GT", group = "gt_other", tag = "B")
public void on(String msg) { }
}
@Test
void scan_sameGroupDifferentTag_failsFast() {
MqListenerAnnotationBeanPostProcessor bpp = new MqListenerAnnotationBeanPostProcessor();
bpp.postProcessAfterInitialization(new GroupTagA(), "a");
IllegalStateException ex = assertThrows(IllegalStateException.class,
() -> bpp.postProcessAfterInitialization(new GroupTagB(), "b"),
"同 group 配不同 tag 会静默丢消息(订阅关系不一致),必须启动失败");
assertTrue(ex.getMessage().contains("gt_consumer"), "报错须点名 group" + ex.getMessage());
assertTrue(ex.getMessage().contains("\"A\"") && ex.getMessage().contains("\"B\""),
"报错须给出冲突的两个 tag 表达式:" + ex.getMessage());
}
@Test
void scan_sameGroupEquivalentTagExpressions_notBlocked() {
MqListenerAnnotationBeanPostProcessor bpp = new MqListenerAnnotationBeanPostProcessor();
bpp.postProcessAfterInitialization(new GroupTagAOrB(), "a");
bpp.postProcessAfterInitialization(new GroupTagBOrA(), "b"); // "A || B" 与 "B||A" 集合等价
bpp.postProcessAfterInitialization(new GroupTagStar(), "c");
bpp.postProcessAfterInitialization(new GroupTagEmpty(), "d"); // "*" 与 "" 同为全收
assertEquals(4, bpp.getEndpoints().size(), "等价表达式不应误报不一致");
}
@Test
void scan_differentGroupsDifferentTags_notBlocked() {
MqListenerAnnotationBeanPostProcessor bpp = new MqListenerAnnotationBeanPostProcessor();
bpp.postProcessAfterInitialization(new GroupTagA(), "a");
bpp.postProcessAfterInitialization(new OtherGroupTagB(), "b");
assertEquals(2, bpp.getEndpoints().size(), "按 tag 分流各用各的 group 是推荐用法,不应拦截");
}
// ---- 病态 tag 表达式软校验WARN 不阻断)----
static class PathologicalTag {
@MqListener(topic = "PT", group = "pt_consumer", tag = "||")
public void on(String msg) { }
}
@Test
void scan_pathologicalTagExpression_warnsButNotBlocks() {
MqListenerAnnotationBeanPostProcessor bpp = new MqListenerAnnotationBeanPostProcessor();
bpp.postProcessAfterInitialization(new PathologicalTag(), "biz");
assertEquals(1, bpp.getEndpoints().size(), "病态 tag 软校验不应阻断启动");
assertEquals(1, warnCount(), "解析不出有效 tag 的表达式应打一条 WARN");
assertTrue(logAppender.list.get(0).getFormattedMessage().contains("tag"),
"WARN 应指向 tag 配置问题");
}
@Test
void scan_catchAllTag_noWarn() {
MqListenerAnnotationBeanPostProcessor bpp = new MqListenerAnnotationBeanPostProcessor();
bpp.postProcessAfterInitialization(new GroupTagStar(), "a");
bpp.postProcessAfterInitialization(new GroupTagEmpty(), "b");
assertEquals(0, warnCount(), "*/空串是正常全收写法,不应告警");
}
}

View File

@@ -2,6 +2,11 @@ package com.njcn.mq.spi;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -42,4 +47,40 @@ class MqTagMatcherTest {
assertFalse(MqTagMatcher.matches("A", ""));
assertFalse(MqTagMatcher.matches("A || B", null));
}
@Test
void validParts_parsesExpressionIntoTagSet() {
assertEquals(new HashSet<>(Collections.singletonList("A")), MqTagMatcher.validParts("A"));
assertEquals(new HashSet<>(Arrays.asList("A", "B")), MqTagMatcher.validParts("A || B"));
assertEquals(new HashSet<>(Arrays.asList("A", "B")), MqTagMatcher.validParts("B||A"),
"集合语义:顺序与空白差异不影响等价性");
assertEquals(new HashSet<>(Collections.singletonList("A")), MqTagMatcher.validParts("A ||"),
"尾空段忽略,只留有效段");
}
@Test
void validParts_catchAllAndPathologicalExpressions_yieldEmptySet() {
assertEquals(Collections.emptySet(), MqTagMatcher.validParts(null));
assertEquals(Collections.emptySet(), MqTagMatcher.validParts(""));
assertEquals(Collections.emptySet(), MqTagMatcher.validParts("*"));
assertEquals(Collections.emptySet(), MqTagMatcher.validParts(" * "));
assertEquals(Collections.emptySet(), MqTagMatcher.validParts("||"), "全空段 = 全收,与 matches 语义一致");
}
@Test
void looksMisconfigured_flagsNonEmptyExpressionWithNoValidTag() {
assertTrue(MqTagMatcher.looksMisconfigured("||"), "写了 || 却没有任何有效 tag");
assertTrue(MqTagMatcher.looksMisconfigured(" || "));
assertTrue(MqTagMatcher.looksMisconfigured(" "), "纯空白redis 端静默全收、rocketmq 端行为不同,疑似写错");
}
@Test
void looksMisconfigured_legitimateExpressions_notFlagged() {
assertFalse(MqTagMatcher.looksMisconfigured(null), "没写 = 缺省全收,正常");
assertFalse(MqTagMatcher.looksMisconfigured(""), "显式空串按全收,两端语义一致,不告警");
assertFalse(MqTagMatcher.looksMisconfigured("*"));
assertFalse(MqTagMatcher.looksMisconfigured(" * "));
assertFalse(MqTagMatcher.looksMisconfigured("A"));
assertFalse(MqTagMatcher.looksMisconfigured("A ||"), "有有效段,尾空段无害");
}
}