1. 为什么一个“文档第九篇”值得单独拿出来聊
Axon 4.4 中文版文档翻到第九篇这个位置,基本上已经越过了“跑个 Hello World 看看事件总线长什么样”的阶段,进入了整个框架里最容易让人卡住的那一层:事件处理与查询模型的落地细节。我见过太多团队在 Demo 阶段跑得飞起,一到真实业务里就开始出现“事件丢了”“聚合状态对不上”“查询端数据延迟到用户投诉”这类问题,回头翻文档才发现,第九篇前后讲的恰恰就是这些坑的官方解释。
这篇内容适合两类人看。一类是已经用 Axon 搭起了基本骨架、聚合和命令网关都能跑通,但准备把系统往生产环境推的开发者;另一类是正在做技术选型评估,想搞清楚 Axon 在事件溯源这条路上到底把哪些脏活累活替你干了、哪些又必须你自己兜底。我会围绕 Axon 4.4 这个版本的具体行为来讲,因为 4.x 系列在事件处理器配置、跟踪事件处理器(Tracking Event Processor)的令牌管理上和 3.x 有实打实的差异,照搬老教程很容易踩空。
先把核心关键词摆出来:事件处理器(Event Handler)、跟踪事件处理器(Tracking Event Processor,简称 TEP)、订阅事件处理器(Subscribing Event Processor,简称 SEP)、令牌存储(Token Store)、查询模型(Query Model)、事件溯源(Event Sourcing)。这几个词在第九篇里反复出现,也是后面所有实操的地基。你如果对这几个概念还停留在“大概知道是干嘛的”,那这篇正好帮你把它们之间的边界划清楚。
我个人的判断是:Axon 4.4 文档第九篇的价值不在于教你 API 怎么调,而在于它把“事件从产生到被消费”这条链路上的每一个可配置点都摊开了。你读懂了这些配置点背后的取舍,后面无论遇到重复消费、顺序错乱还是重放卡死,都能自己定位。下面我就按实际项目里推进的顺序,把这块内容拆开讲。
2. 事件处理器的两条路线:TEP 与 SEP 到底怎么选
2.1 两种处理器的本质差异
Axon 里事件处理器分两大类,这个划分在 4.4 里非常明确,也是第九篇开篇就要讲清楚的东西。**订阅事件处理器(SEP)**走的是“事件发布即推送”的路子,事件总线把事件直接塞给处理器,处理逻辑和发布逻辑在同一个线程里跑,事务边界也基本绑在一起。**跟踪事件处理器(TEP)**则完全不同,它自己维护一个令牌(token),记录“我处理到哪个位置了”,然后主动去事件存储里拉取后续事件,处理进度和事件产生是解耦的。
这个差异带来的直接后果是:SEP 简单、实时,但一旦处理器抛异常,事件可能就丢了,而且它没法做重放;TEP 复杂、有延迟,但它能保证至少一次消费、支持从头重放、支持多实例并行。我在实际项目里的经验是,写模型内部那些必须和命令保持强一致的副作用用 SEP,读模型投影和跨聚合的补偿逻辑一律用 TEP。这个划分不是拍脑袋,而是由它们各自的事务语义决定的。
举个具体场景。订单聚合在命令处理过程中发布了一个OrderConfirmedEvent,如果你有个处理器要立刻给聚合本身发个后续命令做状态修正,这种用 SEP 是合理的,因为它需要和当前事务同生共死。但如果你是要把这个事件投影到一张查询表里给前端列表页用,那就必须用 TEP,否则投影失败一次,这条数据就永远缺了,而 TEP 至少会重试。
2.2 令牌存储的选择与配置细节
TEP 的核心是令牌,令牌存哪里直接决定了你的系统能不能水平扩展。Axon 4.4 默认提供几种令牌存储实现:基于 JPA 的JpaTokenStore、基于 JDBC 的JdbcTokenStore,还有内存版的InMemoryTokenStore。内存版只能用于测试,这个不用多说,生产环境用了就是给自己埋雷。
JPA 和 JDBC 两个版本的选择,我踩过的坑是这样的:如果你的项目本身已经用 JPA 管理实体,且事件存储也走 JPA,那JpaTokenStore配置起来最省事,它复用同一个EntityManager。但如果你的事件存储用的是 Axon Server 或者独立的 JDBC 事件存储,那JdbcTokenStore更干净,它不依赖 JPA 的持久化上下文,事务边界也更清晰。配置的时候有个细节容易被忽略:令牌表的token字段需要足够的长度,默认配置在某些数据库上会因为事件处理器名称过长而截断,导致令牌读写异常。我一般会手动把token_entry表的token列设成varchar(1000)以上。
// JdbcTokenStore 的典型配置片段 @Bean public TokenStore tokenStore(DataSource dataSource) { return JdbcTokenStore.builder() .dataSource(dataSource) .build(); }注意:切换令牌存储实现时,旧存储里的令牌不会自动迁移。如果你是从内存版切到持久化版,所有 TEP 会从头开始消费,务必确认事件处理器是幂等的,或者提前做好数据清理。
2.3 并行处理与分段(Segment)机制
TEP 支持多实例并行消费,靠的是**分段(Segment)**机制。一个事件处理器默认只有一个分段,也就是单线程顺序消费。你可以通过配置把分段数调大,比如设成 4,那么最多可以有 4 个线程(或 4 个实例)同时消费这个处理器的事件,每个线程负责一个分段。这里的关键是:分段数一旦确定并开始消费,就不能随意改小,因为令牌是按分段记录的,改小会导致部分分段的事件无人认领。
我在一个中等规模的系统里把分段数设成 8,配合 4 个应用实例,每个实例分到 2 个分段,吞吐量比单分段提升了大概 3 倍多。但要注意,分段之间的事件顺序是不保证的,只有同一分段内才有序。所以如果你的业务对某个聚合的事件顺序有强依赖,那这个聚合的事件必须落在同一个分段里——Axon 默认按聚合标识符哈希来分配分段,这一点在 4.4 里是稳定的,不用额外操心。
// 通过配置调整 TEP 的分段数 @Bean public EventProcessingConfigurer eventProcessingConfigurer() { return configurer -> configurer .splitStreamSegments("orderProjection", 8); }配置分段数的时候有个经验值:分段数不要超过你预期的最大实例数乘以每实例线程数,否则会有大量分段闲置,反而增加令牌管理的开销。我一般按“峰值实例数 × 2”来估,留一点余量应对扩容。
3. 事件重放:最有用也最容易出事的功能
3.1 重放的三种触发方式与适用场景
事件重放是事件溯源架构的招牌能力,Axon 4.4 里触发重放主要有三种方式:通过TrackingEventProcessor#resetTokens()编程式重置、通过 Axon Server 的控制面板操作、以及通过管理端点(如果引入了 actuator 集成)发起。三种方式底层都是把令牌重置到某个位置,然后让处理器重新消费。
重放最典型的场景是查询模型重建。比如你新加了一个投影表,或者发现某个投影逻辑有 bug 导致数据算错了,这时候不需要去修数据,直接把对应的 TEP 令牌重置,让它从头把事件再放一遍,新逻辑自然就生效了。这个能力在传统 CRUD 架构里是不可想象的,你得写一堆数据修复脚本,还容易漏。
但重放不是无脑按按钮。我见过最惨的一次事故是:某同学在线上直接重置了一个投影处理器的令牌,而这个处理器同时还负责给下游发通知消息,结果所有历史订单的通知被重新发了一遍,用户炸锅。所以重放之前必须确认:这个处理器有没有对外副作用。如果有,要么先把副作用部分拆出去,要么在重放期间做开关屏蔽。
3.2 重放期间的性能与一致性权衡
重放本质上是把历史事件重新灌一遍,事件量大的时候对数据库压力不小。Axon 4.4 的 TEP 在重放时默认会尽量快地拉取事件,批大小(batch size)可以通过配置调整。我一般会在重放前把批大小调小一点,比如从默认的 100 降到 50,牺牲一点速度换取更平滑的数据库负载,避免重放把线上查询拖垮。
另一个容易被忽视的点是重放期间的新事件。TEP 在重放时,新产生的事件会排在历史事件后面,处理器会先追完历史再处理新的,这期间查询模型的数据是“旧逻辑算的历史 + 新逻辑算的增量”混合状态,可能短暂不一致。如果业务对这段时间的读一致性有要求,标准做法是给查询模型加一个“重建中”的标记,前端读的时候做降级处理。这个方案不复杂,但文档里不会手把手教你,得自己想到。
| 重放方式 | 适用场景 | 风险点 |
|---|---|---|
| 编程式 resetTokens | 开发调试、自动化脚本 | 需自行控制并发,避免重复触发 |
| 管理端点 | 运维手动操作 | 权限控制要到位 |
| 控制面板 | 可视化操作、多处理器管理 | 误操作影响面大 |
3.3 重放与幂等性的强绑定关系
只要用了 TEP,就必须假设事件会被重复消费,这是“至少一次”语义的必然结果。所以事件处理器里的逻辑必须幂等。投影类处理器相对好办,用“先删后插”或者“按主键 upsert”就能保证。但涉及计数、累加的场景就要小心了,比如“订单数量 +1”这种操作,重放一次就多加一次。
我的处理习惯是:投影表里永远带上事件标识符或聚合版本号作为唯一约束,插入时靠数据库的唯一约束来兜底幂等。这样即使处理器被重复调用,重复的那次会因为约束冲突被拒绝,捕获异常忽略即可。这个做法比在应用层查一遍再插要可靠得多,也省了一次查询。
-- 投影表加唯一约束保证幂等 CREATE TABLE order_projection ( order_id VARCHAR(64) PRIMARY KEY, status VARCHAR(32), last_event_id VARCHAR(128) UNIQUE, updated_at TIMESTAMP );提示:唯一约束冲突在批量重放时会产生大量异常日志,建议在处理器里显式捕获
DuplicateKeyException并降级为 debug 级别日志,否则日志系统会被刷爆。
4. 查询模型的落地:从事件到可查数据的完整链路
4.1 查询模型与命令模型的解耦边界
Axon 的 CQRS 落地里,查询模型(Query Model)是和命令模型完全分开的。命令侧通过聚合处理写操作,产生事件;查询侧通过事件处理器把事件投影成适合查询的结构。这个解耦带来的好处是查询侧可以按读需求自由设计表结构,不用迁就聚合的存储形态。但代价是最终一致性,写完之后立刻查可能查不到。
这个延迟在 Axon 4.4 里取决于 TEP 的拉取间隔。默认配置下,TEP 在没有新事件时会等待一段时间再拉,这个等待时间可以通过TokenStore的 fetch 相关参数调整。我在对实时性要求高的场景里会把拉取间隔调短,但要注意别调得太激进,否则空轮询会把数据库连接占满。一般 100 到 500 毫秒是个比较稳的区间,具体看你的数据库承受能力。
查询侧的另一个设计要点是查询对象的粒度。我见过有人直接把聚合的完整状态序列化存进查询表,查询时反序列化出来用。这种做法在聚合状态简单时没问题,但聚合一复杂,查询性能就崩了。正确做法是查询表按查询需求扁平化设计,一个查询对应一张或几张宽表,字段冗余是允许的,因为查询侧本来就不追求范式。
4.2 投影处理器的组织方式
一个系统里往往有多个投影处理器,按业务域拆分是常见做法。Axon 4.4 允许你用@ProcessingGroup注解把多个事件处理器归到同一个处理组里,同一个组共享一个令牌,也就意味着组内的事件处理是顺序的。这个机制很有用:如果你有几个投影必须按事件顺序更新,把它们放进同一个处理组就能保证顺序。
但分组也有代价。同一个组内的事件处理是串行的,如果组里某个处理器很慢,会拖累整个组的进度。所以我的原则是:只有存在顺序依赖的处理器才放同一组,其余按吞吐需求独立分组。比如订单投影和库存投影之间没有顺序依赖,就分成两个组,各自独立消费,互不影响。
@Component @ProcessingGroup("order-projection") public class OrderProjectionHandler { @EventHandler public void on(OrderConfirmedEvent event) { // 更新订单查询表 } }4.3 查询网关与查询处理器的配合
查询侧除了投影,还有查询处理器(Query Handler)。Axon 的查询总线把查询请求路由到对应的处理器,处理器从查询模型里读数据返回。这部分在 4.4 里比较直接,但有个细节值得说:查询处理器应该只读查询模型,不要回头去查聚合。一旦查询处理器去查聚合,就等于把命令侧和查询侧又耦合回去了,CQRS 的解耦白做了,而且聚合的加载成本很高,查询性能会很难看。
我在项目里会强制约定:查询处理器所在的模块不依赖聚合所在的模块,从编译期就杜绝这种反向依赖。这个约束一开始会让人觉得别扭,但坚持下来之后,查询侧的演进自由度会大很多,加字段、换存储、拆服务都不影响命令侧。
5. 实操中高频出现的坑与排查路径
5.1 令牌卡住不动的排查思路
TEP 最常见的故障现象是“令牌不动了”,也就是处理器不再消费新事件。排查顺序我一般是这样:先看处理器是不是抛了异常导致不断重试同一条事件,这个在日志里会有明显的重复堆栈;再看令牌表里对应分段的token是不是被别的实例占着(owner字段),如果是,说明有实例异常退出没释放令牌,需要等令牌声明超时或者手动清理;最后看事件存储本身是不是有新事件写入,有时候是上游根本没产生事件,处理器空转是正常的。
令牌声明超时(claim timeout)这个参数在 4.4 里可以配置,默认值在某些场景下偏长,导致实例崩溃后要等很久才能被其他实例接管。我在容器化部署的环境里会把它调短一些,配合健康检查,故障转移会快很多。但也不能调太短,否则正常的 GC 停顿都可能触发令牌被抢,造成重复消费。
5.2 事件顺序错乱的定位方法
事件顺序问题通常表现为投影数据“看起来对但偶尔不对”。定位的时候先确认这个处理器是不是单分段,如果是多分段,那跨聚合的顺序本来就不保证,得看业务是否真的需要全局顺序。如果确实需要,那就只能退回单分段,用吞吐换顺序。
另一个隐蔽的原因是同一聚合的事件被分配到了不同分段。正常情况下 Axon 按聚合标识符哈希分配,不会出现这个问题,但如果你在事件里改了聚合标识符,或者用了自定义的分段分配策略,就可能出岔子。我遇到过一次是聚合标识符在某个事件后被重新赋值,导致后续事件哈希到了别的分段,顺序全乱。这种问题只能靠仔细审查聚合的标识符管理逻辑来避免。
5.3 常见问题速查表
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| 令牌长时间不动 | 处理器异常重试 / 令牌被占用 | 查重复堆栈、查 token_entry 的 owner |
| 投影数据重复 | 处理器非幂等 / 重放未做约束 | 检查唯一约束、检查重放记录 |
| 查询延迟高 | 拉取间隔过长 / 分段数不足 | 调 fetch 参数、评估分段数 |
| 重放后数据错乱 | 重放期间有新事件混入 | 检查重放窗口、加重建标记 |
| 多实例下重复消费 | 令牌声明超时过短 | 调大 claim timeout |
注意:排查令牌问题时,直接改数据库里的令牌记录是下策,容易造成状态不一致。优先用框架提供的 reset 接口,实在要手动改,改完必须重启对应处理器让它重新加载令牌。
5.4 我踩过的一个真实坑:事件处理器里的懒加载
这个坑值得单独说。早期我在事件处理器里直接访问了聚合关联的懒加载集合,本地测试没问题,因为事务还开着。但 TEP 处理事件时,事务边界和命令侧不一样,懒加载直接抛LazyInitializationException,而且这个异常被 TEP 捕获后不断重试,令牌就卡住了。排查了半天才定位到是懒加载惹的祸。
解决办法很简单:事件处理器里只使用事件本身携带的数据,不要回头去加载聚合或其他实体。事件应该是自包含的,需要什么数据在发布事件时就带上。这个原则不仅解决懒加载问题,也让事件处理器更容易测试、更容易重放。从那以后我把“事件自包含”写进了团队的编码规范里。
6. 版本特性与升级注意事项
Axon 4.4 在事件处理这块相比 4.0 到 4.3 有一些行为调整,升级的时候需要留意。最明显的是 TEP 的默认配置在某些场景下变得更保守了,比如令牌的初始化行为、分段分配的默认策略。如果你是从更早的版本升上来,升级后第一次启动可能会看到处理器从头开始消费,这是因为令牌存储的格式或初始化逻辑变了,得提前做好数据兼容评估。
另一个点是 Axon Server 的配合。4.4 推荐搭配的 Axon Server 版本对事件存储的索引做了优化,查询历史事件的速度有提升,这对重放场景是实打实的利好。但如果你的 Axon Server 版本偏旧,某些新特性用不上,重放大数据量时还是会慢。升级前建议先在预发环境跑一遍完整重放,记录耗时,心里有个数。
我在升级时的一般流程是:先在预发环境用生产数据的一份快照跑起来,观察所有 TEP 的令牌推进是否正常、投影数据是否和升级前一致,确认无误再动生产。生产升级时选低峰期,并且提前把重放相关的开关准备好,万一出问题能快速回退。这套流程不新鲜,但真能救命。
7. 一点个人体会
Axon 这套东西用下来,最大的感受是它把事件溯源的“骨架”搭得很稳,但“血肉”得你自己填。第九篇文档讲的那些配置项,每一个背后都是一次取舍:要实时还是要可靠,要吞吐还是要顺序,要简单还是要可重放。没有标准答案,只有适不适合你的业务场景。
我现在的习惯是,每引入一个新的 TEP,先问自己三个问题:这个处理器有没有对外副作用?它是不是幂等的?它的分段数上限是多少?这三个问题答清楚了,后面百分之八十的坑都能提前避开。剩下的百分之二十,就靠日志和令牌表慢慢磨,磨多了自然就有手感了。