12.1 事件驱动集成

响应式事件驱动集成架构:ApplicationEventPublisher Reactor 适配与 @EventListener、自定义 Sinks.Many 响应式事件总线、领域事件与集成事件边界、事件网关转换模式、JSON 事件序列化与版本兼容策略。

事件驱动架构核心

事件驱动不是「用消息队列」,而是一种松耦合的通信模式:

命令(Command)              事件(Event)
    → 意图明确                    → 事实陈述
    → 点对点                       → 广播/多订阅
    → 需要响应                     → 无响应期望
    → 如:CreateOrder             → 如:OrderCreated

eventd v2 内部使用事件驱动解耦模块:

OrderController (WebFlux)
    → OrderCreated Event
        → InventoryService(扣库存)
        → NotificationService(发通知)
        → AuditService(记日志)

Spring 事件(ApplicationEventPublisher)

Spring 的 ApplicationEventPublisher 可以发布事件,也可以在监听器里调用响应式服务,但发布和监听本身是同步的,不是响应式流:

import org.springframework.context.ApplicationEventPublisher;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;

@Component
public class OrderEventHandler {
    private final ApplicationEventPublisher publisher;

    public OrderEventHandler(ApplicationEventPublisher publisher) {
        this.publisher = publisher;
    }

    public Mono<Order> createOrder(OrderRequest request) {
        return validate(request)
            .flatMap(this::save)
            .doOnNext(saved -> publisher.publishEvent(new OrderCreatedEvent(
                saved.id(), saved.symbol(), saved.quantity(), Instant.now())));
    }
}

// 监听事件:用 @Async 放到独立线程,避免阻塞 Netty 事件循环
@Component
public class InventoryHandler {
    @Async
    @EventListener
    public void onOrderCreated(OrderCreatedEvent event) {
        deductInventory(event)
            .doOnError(err -> log.error("Inventory deduction failed", err))
            .subscribe();
    }
}

publishEvent 是同步调用,@EventListener 会在发布线程上执行;如果监听器里有阻塞操作,需要 @Async 或把工作投递到线程池。@Async 还需要在配置类上 @EnableAsync,否则注解不会生效。监听器内部调用 .subscribe() 属于 fire-and-forget,异常和背压都不会回到发布方——这适合「通知、审计」这类可丢事件,不适合必须完成的业务动作。需要可靠执行时,把动作放进发布方的主链(flatMap 里 then 掉),或者交给下面的 Sinks 总线和单一订阅者。

响应式事件总线

Spring 事件是同步单进程的。更通用的方案是自定义响应式事件总线:

public interface DomainEvent {
    String getType();
}
import reactor.core.publisher.Sinks;
import reactor.core.publisher.Flux;

import java.util.Objects;

@Component
public class ReactiveEventBus {
    private final Sinks.Many<DomainEvent> sink =
        Sinks.many().multicast().onBackpressureBuffer();

    public Mono<Void> publish(DomainEvent event) {
        // Mono.defer 让发送发生在订阅时,而不是组装时
        return Mono.defer(() -> {
            Sinks.EmitResult result = sink.tryEmitNext(event);
            return result.isFailure()
                ? Mono.error(new IllegalStateException("Event publish failed: " + result))
                : Mono.empty();
        });
    }

    public Flux<DomainEvent> subscribe(String eventType) {
        return sink.asFlux()
            .filter(e -> Objects.equals(e.getType(), eventType));
    }
}

multicast().onBackpressureBuffer() 默认只有 256 条共享缓冲,一个慢订阅者拖慢缓冲后,tryEmitNext 会持续返回 FAIL_OVERFLOW,直到该订阅者把积压消费掉;需要更大缓冲时显式传 onBackpressureBuffer(size),并继续检查返回值。

它还有一层容易误读的预热语义:没有任何订阅者时 tryEmitNext 返回 OK,事件进入缓冲而不是被丢弃,第一个订阅者能收到订阅之前缓冲的事件;缓冲满了才会 FAIL_OVERFLOW。但它仍是热流:第一个订阅者之后的消费者收不到历史,进程崩溃时缓冲里的事件也会一起丢。换成 directAllOrNothing() / directBestEffort() 这类 direct 变体,零订阅者才会直接返回 FAIL_ZERO_SUBSCRIBER。所以不能把这里的 publish 当成可靠投递,集成事件要先落库或写 Kafka,再广播给进程内消费者。

使用(OrderCreatedEvent 的字段以本节下方的 record 定义为准):

// 发布
bus.publish(new OrderCreatedEvent(
        order.id(), order.symbol(), order.quantity(), Instant.now()))
    .subscribe();

// 订阅
bus.subscribe("OrderCreated")
    .flatMap(event -> process(event), 4)  // 并发度 4
    .subscribe();

领域事件 vs 集成事件

类型范围消费者序列化版本
领域事件单个服务内部进程内不需要随代码变更
集成事件服务之间跨进程JSON/Avro/Protobuf需兼容

eventd v2 的领域事件:

public record OrderCreatedEvent(
    Long orderId,
    String symbol,
    int quantity,
    Instant occurredOn
) implements DomainEvent {
    public String getType() { return "OrderCreated"; }
}

集成事件(发送到外部系统):

public record OrderCreatedIntegrationEvent(
    String eventId,      // 全局唯一
    String eventType,    // "order.created.v1"
    Instant timestamp,
    OrderPayload payload
) {}

public record OrderPayload(Long orderId, String symbol, int quantity) {}

集成事件需要版本号(v1、v2),支持消费者平滑升级。

事件网关

事件网关负责把内部领域事件转为外部集成事件:

import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import reactor.core.Disposable;
import reactor.util.retry.Retry;

import java.time.Duration;

@Component
public class EventGateway {
    private final ReactiveEventBus bus;
    private final WebClient externalWebhook;

    private Disposable subscription;

    @PostConstruct
    public void start() {
        subscription = bus.subscribe("OrderCreated")
            .map(this::toIntegrationEvent)
            .flatMap(event -> externalWebhook.post()
                    .uri("/webhooks/order-created")   // WebClient 必须显式给 URI,即使配了 baseUrl
                    .bodyValue(event)
                    .retrieve()
                    .toBodilessEntity()
                    .retryWhen(Retry.backoff(3, Duration.ofMillis(200)))
                    .onErrorResume(err -> {
                        log.error("Webhook failed, eventId={}", event.eventId(), err);
                        return Mono.empty();  // 转入死信/告警后丢弃,避免整条流终止
                    }), 4)
            .subscribe();
    }

    @PreDestroy
    public void stop() {
        subscription.dispose();
    }

    private OrderCreatedIntegrationEvent toIntegrationEvent(DomainEvent event) {
        OrderCreatedEvent e = (OrderCreatedEvent) event;
        return new OrderCreatedIntegrationEvent(
            UUID.randomUUID().toString(),
            "order.created.v1",
            Instant.now(),
            new OrderPayload(e.orderId(), e.symbol(), e.quantity())
        );
    }
}

retrieve() 会把 4xx 和 5xx 都转成 WebClientResponseException,上面的 retryWhen 没有过滤,400/401 也会被重试三次。生产实现应只重试 5xx、超时和连接错误,并且确认下游接口对重复投递幂等;否则重试只会放大无效请求。

与外部系统的衔接

eventd v2 的事件流最终进入 Kafka(卷三处理):

eventd v2 (内部事件总线)
    → OrderCreated / InventoryUpdated / PaymentProcessed
        → 事件网关
            → HTTP Webhook(实时通知下游)
            → Kafka Producer(异步持久化,卷三消费)

Kafka 衔接点在卷三的「Kafka Streams 与 RS 的衔接」章节展开。

事件序列化版本兼容

JSON 版本兼容策略:

{
  "eventType": "order.created.v1",
  "payload": { "orderId": 123, "symbol": "BTC" }
}
{
  "eventType": "order.created.v2",
  "payload": { "orderId": 123, "symbol": "BTC", "leverage": 1 }
}

v2 新增了 leverage 字段,v1 消费者忽略未知字段即可。Spring Boot 自动配置的 Jackson 默认关闭 FAIL_ON_UNKNOWN_PROPERTIES,所以消费方直接反序列化不会因新字段失败;如果消费方是手写 ObjectMapper 或其他语言,要显式确认这一行为。

升级 eventType 也有兼容成本:把 order.created.v1 直接改成 v2,按旧值路由的消费者会一条都收不到。上线期间通常要并行发布 v1/v2,等所有消费者迁移完成后再停掉 v1。

规则:

  • 新增字段:消费者忽略未知字段
  • 删除字段:发空值或默认值
  • 修改字段:用新版本号,eventType 区分

小结

概念实现注意
内部事件ApplicationEventPublisher@EventListener 同步,阻塞操作要 @Async
响应式总线Sinks.Many + 检查 EmitResultmulticast 支持多订阅
领域事件进程内,无序列化随代码变更
集成事件JSON + 版本号消费者需兼容旧版本
事件网关内部 → 外部转换错误处理 + 重试
版本兼容新增字段忽略、删除字段默认eventType 区分版本

下一节 12.2 Saga 编排 :事务补偿、最终一致性、Saga 状态机与响应式实现。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 15.3 全书总结与学习路径回顾
  2. 15.2 响应式流处理的演进趋势
  3. 15.1 引擎选型指南