命名规范不是风格指南
主题的命名看起来是一件小事,但当平台里有几百个主题时,命名不规范的代价是:运维不知道从哪找日志、开发者不知道消费哪个主题、血缘工具无法自动识别域归属。命名规范在事件平台里不是"代码风格",而是基础设施的入口。
命名规范必须满足三个要求:
- 可解析:从名字里能提取 domain、event type、action。
- 可排序:同一 domain 的事件能按名字分组。
- 可版本化:带
vN后缀,区分同一事件的不同 schema 版本。
domain.event.action.vN 格式
本书采用的命名格式是四层点分:
domain.event.action.vN
| 段 | 示例 | 含义 | 校验规则 |
|---|---|---|---|
| domain | library | 业务域 | 全小写,与事件目录中的 domain 一致 |
| event | loan | 事件对象 | 名词,聚合根名 |
| action | created | 动作 | 过去时,描述状态变化 |
| version | v1 | 版本号 | v 前缀 + 正整数,双写阶段靠它区分 |
四个反例:
| 反例 | 问题 |
|---|---|
loanCreated | 不是点分,无法按域分组 |
library.loan.created | 缺少版本号,schema 升级时无法双写 |
library.Loan.Created.v1 | 大写,Kafka 特性允许但跨系统迁移时大小写会丢失 |
library.loan.create.v1 | action 不是过去时,暗示是命令不是事件 |
五态生命周期
一个主题从诞生到销毁,至少经历五个状态:
| 状态 | 说明 | 触发条件 |
|---|---|---|
| CREATING | 正在创建中 | 工单通过 INIT 校验 |
| ACTIVE | 可读写 | 工单到达 CREATED |
| DEPRECATED | 只读不写 | 新版本主题已上线,旧主题停止接收写入 |
| RETAINED | 归档保留 | 保留期(如 90 天)内可读,仅用于重放 |
| DELETED | 已删除 | 保留期结束,数据清除 |
状态转换有严格的合法路径:
CREATING → ACTIVE → DEPRECATED → RETAINED → DELETED
非法转换举例:ACTIVE → DELETED(缺少 DEPRECATED 过渡,消费者没时间迁移)、RETAINED → ACTIVE(归档后不能再激活,必须新建)。
纯 Java 命名与生命周期模拟
下面的 TopicLifecycleManager 包含命名规范校验、五态机转换、以及孤儿主题检测(90 天无消费标记为孤儿)。
// TopicLifecycleManager.java — Topic 命名校验、生命周期管理与孤儿检测
import java.time.*;
import java.util.*;
public class TopicLifecycleManager {
enum State { CREATING, ACTIVE, DEPRECATED, RETAINED, DELETED }
record Topic(String name, State state, Instant lastConsumedAt,
Instant createdAt, Instant deprecatedAt) {
boolean namingValid() {
return name.matches("^[a-z]+\\.[a-z]+\\.[a-z]+\\.v[0-9]+$");
}
}
static final Map<String, Long> ORPHAN_THRESHOLD_DAYS = Map.of(
"library", 90L,
"member", 60L,
"order", 30L,
"payment", 30L
);
static State transition(Topic topic, State target) {
State current = topic.state();
// 合法转换
if (current == State.CREATING && target == State.ACTIVE) return target;
if (current == State.ACTIVE && target == State.DEPRECATED) return target;
if (current == State.DEPRECATED && target == State.RETAINED) return target;
if (current == State.RETAINED && target == State.DELETED) return target;
System.out.println(" [ILLEGAL] 无法从 " + current + " 转换到 " + target);
return current; // 转换失败,保持原状态
}
static boolean isOrphan(Topic topic, Instant now) {
if (topic.state() != State.ACTIVE && topic.state() != State.DEPRECATED) {
return false; // 只有活跃或弃用的主题可能成为孤儿
}
String domain = topic.name().split("\\.")[0];
long threshold = ORPHAN_THRESHOLD_DAYS.getOrDefault(domain, 90L);
long daysUnused = Duration.between(topic.lastConsumedAt(), now).toDays();
return daysUnused > threshold;
}
static void demoLifecycle() {
System.out.println("=== Topic 生命周期管理 ===\n");
// 创建主题
Topic t = new Topic("library.loan.created.v1", State.CREATING,
Instant.now(), Instant.now(), null);
System.out.println("创建: " + t.name() + " -> " + t.state());
System.out.println(" 命名规范: " + (t.namingValid() ? "PASS" : "FAIL"));
// 激活
t = new Topic(t.name(), transition(t, State.ACTIVE), t.lastConsumedAt(),
t.createdAt(), t.deprecatedAt());
System.out.println("转正: " + t.name() + " -> " + t.state());
// 弃用
t = new Topic(t.name(), transition(t, State.DEPRECATED), t.lastConsumedAt(),
t.createdAt(), Instant.now());
System.out.println("弃用: " + t.name() + " -> " + t.state());
// 归档
t = new Topic(t.name(), transition(t, State.RETAINED), t.lastConsumedAt(),
t.createdAt(), t.deprecatedAt());
System.out.println("归档: " + t.name() + " -> " + t.state());
// 删除
t = new Topic(t.name(), transition(t, State.DELETED), t.lastConsumedAt(),
t.createdAt(), t.deprecatedAt());
System.out.println("删除: " + t.name() + " -> " + t.state());
System.out.println();
}
static void demoIllegalTransition() {
System.out.println("=== 非法状态转换 ===\n");
Topic active = new Topic("member.profile.updated.v1", State.ACTIVE,
Instant.now(), Instant.now(), null);
System.out.println("试图: ACTIVE -> DELETED");
transition(active, State.DELETED);
Topic retained = new Topic("order.payment.confirmed.v2", State.RETAINED,
Instant.now(), Instant.now(), Instant.now());
System.out.println("试图: RETAINED -> ACTIVE");
transition(retained, State.ACTIVE);
System.out.println();
}
static void demoNamingCheck() {
System.out.println("=== 命名规范检查 ===\n");
String[] cases = {
"library.loan.created.v1", // 通过
"loanCreated", // 失败:点分
"library.loan.created", // 失败:缺少版本
"library.Loan.Created.v1", // 失败:大写
"library.loan.create.v1", // 失败:非过去时
"order.invoice.generated.v2" // 通过
};
for (String c : cases) {
Topic t = new Topic(c, State.CREATING, Instant.now(), Instant.now(), null);
System.out.printf(" %-40s -> %s%n", c, t.namingValid() ? "PASS" : "FAIL");
}
System.out.println();
}
static void demoOrphanDetection() {
System.out.println("=== 孤儿主题检测 ===\n");
Instant now = Instant.now();
// library 主题:91天未消费 -> 孤儿
Topic libOrphan = new Topic("library.reservation.cancelled.v1",
State.ACTIVE, now.minus(Duration.ofDays(91)),
now.minus(Duration.ofDays(100)), null);
System.out.println(libOrphan.name() + " 最后消费=" + libOrphan.lastConsumedAt()
+ " -> 孤儿=" + isOrphan(libOrphan, now));
// member 主题:61天未消费 -> 孤儿(member 阈值 60 天)
Topic memOrphan = new Topic("member.points.expired.v1",
State.ACTIVE, now.minus(Duration.ofDays(61)),
now.minus(Duration.ofDays(70)), null);
System.out.println(memOrphan.name() + " 最后消费=" + memOrphan.lastConsumedAt()
+ " -> 孤儿=" + isOrphan(memOrphan, now));
// order 主题:29天未消费 -> 非孤儿(order 阈值 30 天)
Topic ordActive = new Topic("order.shipment.delivered.v1",
State.ACTIVE, now.minus(Duration.ofDays(29)),
now.minus(Duration.ofDays(40)), null);
System.out.println(ordActive.name() + " 最后消费=" + ordActive.lastConsumedAt()
+ " -> 孤儿=" + isOrphan(ordActive, now));
// 已弃用主题:即使很久没消费也不算孤儿(需要等待保留期)
Topic deprecated = new Topic("payment.refund.processed.v1",
State.DEPRECATED, now.minus(Duration.ofDays(120)),
now.minus(Duration.ofDays(130)), now.minus(Duration.ofDays(100)));
System.out.println(deprecated.name() + " 状态=DEPRECATED 最后消费="
+ deprecated.lastConsumedAt() + " -> 孤儿=" + isOrphan(deprecated, now));
}
public static void main(String[] args) {
demoLifecycle();
demoIllegalTransition();
demoNamingCheck();
demoOrphanDetection();
}
}
本机实测(Temurin 25.0.5+8 / 2026-10-29):
javac --release 21 TopicLifecycleManager.java && java TopicLifecycleManager
=== Topic 生命周期管理 ===
创建: library.loan.created.v1 -> CREATING
命名规范: PASS
转正: library.loan.created.v1 -> ACTIVE
弃用: library.loan.created.v1 -> DEPRECATED
归档: library.loan.created.v1 -> RETAINED
删除: library.loan.created.v1 -> DELETED
=== 非法状态转换 ===
试图: ACTIVE -> DELETED
[ILLEGAL] 无法从 ACTIVE 转换到 DELETED
试图: RETAINED -> ACTIVE
[ILLEGAL] 无法从 RETAINED 转换到 ACTIVE
=== 命名规范检查 ===
library.loan.created.v1 -> PASS
loanCreated -> FAIL
library.loan.created -> FAIL
library.Loan.Created.v1 -> FAIL
library.loan.create.v1 -> FAIL
order.invoice.generated.v2 -> PASS
=== 孤儿主题检测 ===
library.reservation.cancelled.v1 最后消费=2026-07-30... -> 孤儿=true
member.points.expired.v1 最后消费=2026-08-29... -> 孤儿=true
order.shipment.delivered.v1 最后消费=2026-09-30... -> 孤儿=false
payment.refund.processed.v1 状态=DEPRECATED 最后消费=... -> 孤儿=false
命名规范中的 vN 不是装饰
很多人把版本后缀当成"以后再说"的装饰,实际上它是事件治理的基础设施:
| 场景 | 没有 vN 的后果 | 有 vN 的方案 |
|---|---|---|
| 双写过渡 | 新旧版本必须共用同一个主题名,消费者被迫同时解析两个 schema | v1 和 v2 是两个独立主题,可以分别管理 |
| 消费者迁移 | 不知道哪个 schema 版本是当前消费的目标 | 消费者配置里写死 v1,迁移到 v2 时只改一行 |
| 保留期管理 | 无法为不同版本设置不同保留策略 | library.loan.created.v1 设 30 天,v2 设 90 天 |
命名规范中的 vN 不是装饰,而是防止双写阶段同名冲突的第一道防线。
孤儿主题清理流程
孤儿主题不是垃圾——它们可能还在被某个离线消费者消费,或者历史数据仍有合规保留要求。清理流程必须分阶段:
- 标记:检测到孤儿后先标记,不删除,通知业务团队。
- 确认:给业务团队 7 天确认,确认无消费则进入下一状态。
- DEPRECATED → RETAINED:先停止写入,再进入只读保留期。
- RETAINED → DELETED:保留期结束,数据清除。
即使已经标记为孤儿,如果业务团队突然说"还有人在用",平台也不能直接删除。这是治理与运营的边界:治理工具通知、运营决策删除。
不同域的不同阈值
代码里的 ORPHAN_THRESHOLD_DAYS 按域分别设置:
| 域 | 阈值 | 理由 |
|---|---|---|
| library | 90 天 | 教育场景,借阅周期较长,寒暑假期间消费稀疏 |
| member | 60 天 | 会员体系,活跃周期中等 |
| order/payment | 30 天 | 交易场景,消息时效性强,30 天未消费即异常 |
阈值的设定不是技术决策,而是业务决策。同一个平台的不同域可以有不同的阈值,甚至同一域内的不同事件也可以有不同的阈值(如 payment.fraud.alert 可能只需要 7 天)。
本节小结
- 命名规范是可解析、可排序、可版本化的基础设施,不是风格指南。
- 命名格式:
domain.event.action.vN,四层点分,全小写,action 用过去时。 - 五态生命周期:
CREATING → ACTIVE → DEPRECATED → RETAINED → DELETED,非法转换被拦截。 - 命名规范中的
vN不是装饰,而是双写阶段防止同名冲突的第一道防线。 - 孤儿主题检测按域分别设阈值,标记后须经业务确认再清理。
- 清理流程分四步:标记→确认→DEPRECATED→RETAINED→DELETED。
- 非法状态转换必须在代码层面拦截,不能依赖"不会有人这么做"的假设。
- DEPRECATED 状态的主题不算孤儿——它需要等待保留期,不是消费活跃度决定。
阅读导航:上一节 11.1 事件平台自助化 | 下一节 11.3 配额与成本分摊
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。