Spring Boot + Kafka 企业级消息驱动实战:订单系统背后的 Kafka 全特性应用

2 阅读28分钟

Spring Boot + Kafka 企业级消息驱动实战:订单系统背后的 Kafka 全特性应用

一、项目概述

本项目基于 Spring Boot 3.x 和 Apache Kafka 3.7 构建一套完整的“订单-支付-物流”全链路消息驱动系统。系统以真实业务场景为载体,不追求业务逻辑的复杂度,而是聚焦于运用 Kafka 的核心特性解决以下六大企业级工程难题:

  1. 高并发流量削峰填谷与背压控制
  2. 关键业务流程的消息顺序性保证与死信处理
  3. 分布式场景下的数据一致性(事务发件箱与 Kafka 事务协同)
  4. 实时数据流处理与多流聚合分析
  5. 异构数据系统的 CDC 管道同步
  6. 全链路可观测性建设

项目严格对应《Kafka 深度与流处理系列》18 篇文章的知识体系,在每个关键实现处标注对应篇章,帮助开发者将碎片化的理论转化为可落地的系统能力。

二、系统总体架构

2.1 物理架构

系统由 4 个独立部署的 Spring Boot 应用、1 个 Kafka 集群、1 个 MySQL 实例、1 个 Redis 实例以及监控组件构成。

客户端 → order-service (8081) ┐
                                ├→ Kafka (9092) ←→ 所有服务
payment-service (8082)  ────────┤
logistics-service (8083) ──────┤
stream-service (8084) ─────────┘
        │
Debezium Connect ─→ MySQL (3306)
        │
Prometheus ←─ /actuator/prometheus → Grafana (3000)
Redis (6379) ← stream-service 写入

2.2 模块职责

服务名称端口数据库核心职责
order-service8081MySQL 8.0接收下单请求,校验库存(模拟),写入订单表,发送订单状态事件。MySQL 必须开启 binlog 以供 CDC 采集。
payment-service8082H2监听订单状态事件,处理支付,通过发件箱模式保证数据库写入与消息发布的最终一致性,发送支付事件。
logistics-service8083H2维护订单状态机,消费订单状态和支付事件,执行发货操作,生成并发送通知事件(内部消费)。
stream-service8084RocksDB + Redis构建 Kafka Streams 拓扑,执行窗口聚合与多流 Join,通过交互式查询暴露结果,并将聚合数据写入 Redis 供外部仪表盘使用。

2.3 基础设施组件

  • Apache Kafka 3.7:KRaft 模式运行,单节点即可满足演示需求。
  • MySQL 8.0:供 order-service 使用,开启 binlog,格式为 ROW。
  • Redis 7.x:缓存流处理聚合结果,由 stream-service 写入。
  • Debezium 2.7:部署为 Kafka Connect 插件,捕获 MySQL 订单表变更。
  • Prometheus + Grafana:采集各服务 Micrometer 指标,可视化 Kafka 集群及消费者状态。

三、Topic 规划与分区策略

系统共设计 7 个业务 Topic 和 3 个死信队列 Topic,具体定义如下:

Topic 名称分区数消息 Key消息 Value用途与流向
order-request8userIdOrderRequest JSON下单请求缓冲层,order-service 生产者,order-service 消费者(创建订单)。
order-status4orderIdOrderStatusEvent JSON订单状态事件流,order-service 生产,payment-service、logistics-service、stream-service 消费。
payment-events4orderIdPaymentEvent JSON支付完成事件,payment-service 生产,logistics-service、stream-service 消费。
shipment-events4orderIdShipmentEvent JSON发货事件,logistics-service 生产,logistics-service 内部通知消费者消费。
notification-events2userIdNotificationEvent JSON用户通知事件,logistics-service 内部消费者负责模拟发送邮件/短信。
mysql-order-cdc1无(Debezium 自动)CDC JSON订单数据库变更日志,供数据仓库或审计系统消费。
aggregated-metrics2metricType聚合结果 JSON流处理聚合结果,stream-service 写入,独立消费者写入 Redis。
order-status-dlq1原消息 Key原消息 Value + 异常头订单状态乱序或处理失败死信队列。
payment-events-dlq1原消息 Key原消息 Value + 异常头支付事件处理失败死信队列。
shipment-events-dlq1原消息 Key原消息 Value + 异常头发货事件处理失败死信队列。

分区策略说明:

  • order-requestuserId 哈希分区,将同一用户的请求路由至固定分区,在用户级别实现负载均衡,同时避免单个用户请求乱序。
  • order-statuspayment-eventsshipment-events 统一以 orderId 为 Key,利用 Kafka 分区内有序的特性,保证同一订单的所有状态变更和支付、发货事件严格顺序。
  • 死信队列分区数设为 1,简化回溯与重试逻辑,无需关心消息分布。
  • mysql-order-cdc 采用单分区,保证数据库变更的全局顺序。

四、核心数据模型设计

4.1 REST 接口契约

下单接口 POST /orders

// 请求
{
  "userId": "u1001",
  "productId": "p2001",
  "amount": 199.99,
  "quantity": 2
}
// 响应
{
  "code": 200,
  "message": "订单已受理,预计2秒内创建",
  "requestId": "68b1a2d4-..."
}

订单查询 GET /orders/{orderId}

{
  "orderId": "abc12345",
  "userId": "u1001",
  "amount": 199.99,
  "status": "CREATED",
  "createdAt": "2026-05-10T10:00:00"
}

流处理查询 (stream-service)

  • GET /stream/metrics/order-amount?window=10s 返回最近窗口订单总金额
  • GET /stream/users/{userId}/session 返回用户会话行为分析

4.2 事件模型

所有事件均以 JSON 序列化,使用 Spring Kafka 提供的 JsonSerializer / JsonDeserializer,并通过配置 spring.kafka.consumer.properties.spring.json.trusted.packages 明确信任包路径。

OrderRequest(下单消息)

public class OrderRequest {
    private String requestId;
    private String userId;
    private String productId;
    private BigDecimal amount;
    private int quantity;
    // getters & setters
}

OrderStatusEvent

public class OrderStatusEvent {
    private String orderId;
    private String userId;
    private String status; // CREATED, PAID, SHIPPED, COMPLETED
    private BigDecimal amount;
    private long timestamp;
}

PaymentEvent

public class PaymentEvent {
    private String paymentId;
    private String orderId;
    private String userId;
    private BigDecimal amount;
    private String channel; // ALIPAY, WECHAT
    private long timestamp;
}

ShipmentEvent

public class ShipmentEvent {
    private String shipmentId;
    private String orderId;
    private String logisticsCompany;
    private String trackingNumber;
    private long timestamp;
}

NotificationEvent

public class NotificationEvent {
    private String userId;
    private String orderId;
    private String messageType; // EMAIL, SMS
    private String content;
    private long timestamp;
}

五、Kafka 全特性深度实现

5.1 异步解耦与基础消息流转

设计目标:将同步下单接口改为异步受理模式,快速响应用户,通过 Kafka 解耦订单创建、支付、物流等环节。

order-service 生产者配置

@RestController
public class OrderController {

    private final KafkaTemplate<String, OrderRequest> kafkaTemplate;

    public OrderController(KafkaTemplate<String, OrderRequest> kafkaTemplate) {
        this.kafkaTemplate = kafkaTemplate;
    }

    @PostMapping("/orders")
    public ResponseEntity<Map<String, Object>> createOrder(@RequestBody OrderRequest request) {
        request.setRequestId(UUID.randomUUID().toString());
        // 以 userId 为 Key 发送到 order-request 主题(参见 Kafka 第6篇: 分区策略)
        kafkaTemplate.send("order-request", request.getUserId(), request)
                .whenComplete((result, ex) -> {
                    if (ex != null) {
                        log.error("下单消息发送失败", ex);
                        // 触发降级策略:写入本地重试表或告警
                    }
                });
        return ResponseEntity.ok(Map.of(
                "code", 200,
                "message", "订单已受理,预计2秒内创建",
                "requestId", request.getRequestId()));
    }
}

订单创建消费者(同 order-service)

@Component
public class OrderRequestConsumer {

    private final OrderRepository orderRepository;
    private final KafkaTemplate<String, OrderStatusEvent> statusTemplate;

    @KafkaListener(topics = "order-request", groupId = "order-create-consumer")
    public void handleOrderRequest(ConsumerRecord<String, OrderRequest> record) {
        OrderRequest req = record.value();
        // 模拟库存检查(可抛出异常触发重试,参考 Kafka 第12篇错误处理)
        Order order = new Order();
        order.setOrderId(generateOrderId());
        order.setUserId(req.getUserId());
        order.setAmount(req.getAmount());
        order.setStatus("CREATED");
        orderRepository.save(order); // MySQL 写入,触发 binlog

        OrderStatusEvent event = new OrderStatusEvent();
        event.setOrderId(order.getOrderId());
        event.setUserId(order.getUserId());
        event.setStatus("CREATED");
        event.setAmount(order.getAmount());
        event.setTimestamp(System.currentTimeMillis());

        // 以 orderId 为 Key 保证后续状态事件顺序 (Kafka 第6篇)
        statusTemplate.send("order-status", event.getOrderId(), event);
    }
}

payment-service 消费者(监听 order-status

@KafkaListener(topics = "order-status", groupId = "payment-consumer")
public void onOrderStatus(OrderStatusEvent event) {
    if ("CREATED".equals(event.getStatus())) {
        paymentService.processPayment(event.getOrderId(), event.getAmount(), event.getUserId());
    }
}

logistics-service 消费者(监听 order-status

@KafkaListener(topics = "order-status", groupId = "logistics-status-consumer")
public void onOrderStatus(OrderStatusEvent event) {
    // 由状态机统一处理,此处仅入口
    stateMachine.transition(event);
}

通过上述设计,订单创建、支付、物流三个业务域完全解耦,各自独立演化,充分体现 Kafka 的发布/订阅模式(Kafka 第5篇)。

5.2 高并发流量削峰与背压控制

问题场景:秒杀或大促期间,客户端以极高 TPS(如 5000-10000 msg/s)发起下单请求,后端关系数据库无法直接承受瞬时写入压力。

Kafka 解决方案:利用 Kafka 的高吞吐特性作为缓冲层,结合生产者批处理、压缩和分区扩容,实现流量削峰。

5.2.1 生产者性能调优

在 order-service 的 application.yml 中对生产者参数进行系统调优,对应 Kafka 第6篇(生产者原理)与第14篇(性能调优)。

spring:
  kafka:
    producer:
      bootstrap-servers: localhost:9092
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
      properties:
        linger.ms: 10           # 等待时间,合并批次
        batch.size: 65536       # 64KB 批量大小
        compression.type: lz4   # 压缩算法,降低网络开销
        acks: 1                 # 吞吐优先模式
        max.in.flight.requests.per.connection: 5

压测对比方法

  1. 使用 JMeter 构建 500 并发线程,持续 60 秒向 /orders 发送请求。
  2. 分别在三种参数组合下记录 TPS 和平均延迟。测试环境为本地 4 核 16GB 内存、单节点 Kafka(KRaft),网络为本地回环,数据仅供参考绝对值趋势,实际生产需根据硬件和分区数另行评估。
批次大小等待时间压缩算法稳定 TPS平均延迟 (ms)
16KB0msnone320012.5
32KB5mslz456008.2
64KB10mszstd78005.4

数据表明,适当增大批次和开启压缩可显著提升吞吐、降低延迟。

5.2.2 分区动态扩容

order-request 主题成为瓶颈时,通过命令行动态增加分区:

kafka-topics.sh --alter --bootstrap-server localhost:9092 \
  --partitions 16 --topic order-request

分区扩容后,可增加消费者实例或提高并发度以匹配新增分区,提升整体消费速率。

5.2.3 消费者并发优化

在 payment-service 中调整监听器并发度:

spring:
  kafka:
    consumer:
      group-id: payment-consumer
      max-poll-records: 500
    listener:
      concurrency: 8   # 并发线程数,建议等于分区数

concurrency 值小于或等于分区数时,每个线程负责固定分区;若超过分区数,多余的线程将空闲。可通过 Grafana 监控消费者 Lag 变化验证效果(Kafka 第8篇:消费者组与重平衡;第12篇:Spring Kafka 线程模型)。

5.3 关键状态流转的严格顺序与死信处理

问题场景:订单状态必须严格按照 CREATED → PAID → SHIPPED → COMPLETED 流转。若网络重试或异常重投导致消息乱序(如先收到 SHIPPED 再收到 PAID),系统必须能够检测、隔离并纠正。

解决方案:利用 Kafka 分区内有序特性,结合业务状态机与死信队列实现严格顺序保证。相关知识点覆盖 Kafka 第6篇(分区策略)、第8篇(消费者)、第12篇(错误处理)、第16篇(反模式)。

5.3.1 分区有序性保证

所有订单相关事件(order-statuspayment-eventsshipment-events)均以 orderId 为消息 Key。Kafka 的默认分区器对相同 Key 计算得到的分区号一致,因此同一订单的所有事件必然进入同一分区,单分区内消息严格有序。

验证方式:

kafka-console-consumer.sh --bootstrap-server localhost:9092 \
  --topic order-status --property print.key=true --property print.partition=true
5.3.2 基于 KTable 的无丢失状态机

为避免内存状态易失的问题,此处采用 Kafka Streams 的 KTable 重建订单状态。logistics-service 启动一个轻量级 Kafka Streams 实例,将 order-status 主题物化为 KTable,代表每个订单的最新状态。KTable 会自动更新并持久化到 RocksDB,服务重启后从 changelog 恢复,状态不丢失。

状态存储拓扑配置(在 logistics-service 中)

@Bean
public KStream<String, OrderStatusEvent> orderStatusStream(StreamsBuilder builder) {
    KStream<String, OrderStatusEvent> stream = builder.stream(
            "order-status",
            Consumed.with(Serdes.String(), orderStatusEventSerde)
    );
    // 将 order-status 物化为 KTable,存储完整 OrderStatusEvent
    stream.toTable(Materialized.<String, OrderStatusEvent, KeyValueStore<Bytes, byte[]>>as("order-state-store")
            .withKeySerde(Serdes.String())
            .withValueSerde(orderStatusEventSerde)
            .withCachingDisabled()); // 禁用缓存以保证最新状态立即可见
    return stream;
}

这里使用自定义 orderStatusEventSerde,通过 Serdes.serdeFrom(new JsonSerializer<>(), new JsonDeserializer<>(OrderStatusEvent.class)) 构建,存储的 Value 为完整的 OrderStatusEvent 对象。

状态机实现

@Component
public class LogisticsStateMachine {

    private final KafkaTemplate<String, ShipmentEvent> shipmentTemplate;
    private final KafkaTemplate<String, NotificationEvent> notificationTemplate;
    private final KafkaTemplate<String, OrderStatusEvent> dlqTemplate;
    private final ReadOnlyKeyValueStore<String, OrderStatusEvent> orderStateStore;

    public LogisticsStateMachine(KafkaTemplate<String, ShipmentEvent> shipmentTemplate,
                                  KafkaTemplate<String, NotificationEvent> notificationTemplate,
                                  KafkaTemplate<String, OrderStatusEvent> dlqTemplate,
                                  StreamsBuilderFactoryBean streamsFactory) {
        this.shipmentTemplate = shipmentTemplate;
        this.notificationTemplate = notificationTemplate;
        this.dlqTemplate = dlqTemplate;
        this.orderStateStore = streamsFactory.getKafkaStreams()
                .store(StoreQueryParameters.fromNameAndType(
                        "order-state-store", QueryableStoreTypes.keyValueStore()));
    }

    @KafkaListener(topics = "order-status", groupId = "logistics-status-consumer")
    public void handleStatusEvent(OrderStatusEvent event) {
        String orderId = event.getOrderId();
        String newStatus = event.getStatus();

        // 从 KTable 存储中获取当前状态
        OrderStatusEvent currentEvent = orderStateStore.get(orderId);
        String currentStatus = currentEvent == null ? null : currentEvent.getStatus();

        boolean valid = false;
        if ("CREATED".equals(newStatus) && currentStatus == null) {
            valid = true;
        } else if ("PAID".equals(newStatus) && "CREATED".equals(currentStatus)) {
            valid = true;
        } else if ("SHIPPED".equals(newStatus) && "PAID".equals(currentStatus)) {
            valid = true;
        } else if ("COMPLETED".equals(newStatus) && "SHIPPED".equals(currentStatus)) {
            valid = true;
        }

        if (valid) {
            // 正常流转
            if ("SHIPPED".equals(newStatus)) {
                performShipment(event);
            }
        } else {
            // 乱序事件 → 死信队列
            dlqTemplate.send("order-status-dlq", orderId, event);
            log.warn("乱序事件: orderId={}, current={}, received={}", orderId, currentStatus, newStatus);
        }
    }

    private void performShipment(OrderStatusEvent event) {
        ShipmentEvent shipment = new ShipmentEvent();
        shipment.setShipmentId(UUID.randomUUID().toString());
        shipment.setOrderId(event.getOrderId());
        shipment.setLogisticsCompany("SF");
        shipment.setTrackingNumber("SF" + System.currentTimeMillis());
        shipment.setTimestamp(System.currentTimeMillis());
        shipmentTemplate.send("shipment-events", event.getOrderId(), shipment);
    }
}

此时,状态机始终从持久化的 KTable 中读取最新状态,即使 logistics-service 重启,也能准确恢复每个订单的当前状态,彻底消除乱序误判隐患。

5.3.3 死信队列处理与自动恢复

乱序事件被发送至 order-status-dlq 后,需编写恢复调度器定期尝试重投。以下实现通过监听 DLQ 并将其暂存,由定时任务轮询当前状态,满足前置条件后重新发送。

@Component
public class DLQRetryScheduler {

    private final KafkaTemplate<String, OrderStatusEvent> kafkaTemplate;
    private final ReadOnlyKeyValueStore<String, OrderStatusEvent> orderStateStore;
    private final Map<String, OrderStatusEvent> pendingRetry = new ConcurrentHashMap<>();

    @KafkaListener(topics = "order-status-dlq", groupId = "dlq-recovery-consumer")
    public void consumeDLQ(ConsumerRecord<String, OrderStatusEvent> record) {
        pendingRetry.put(record.key(), record.value());
    }

    @Scheduled(fixedDelay = 10000)
    public void retryEligible() {
        pendingRetry.forEach((orderId, event) -> {
            OrderStatusEvent currentEvent = orderStateStore.get(orderId);
            String currentStatus = currentEvent == null ? null : currentEvent.getStatus();
            boolean canRetry = false;
            if ("SHIPPED".equals(event.getStatus()) && "PAID".equals(currentStatus)) {
                canRetry = true;
            } else if ("COMPLETED".equals(event.getStatus()) && "SHIPPED".equals(currentStatus)) {
                canRetry = true;
            }
            if (canRetry) {
                kafkaTemplate.send("order-status", orderId, event);
                pendingRetry.remove(orderId);
                log.info("DLQ 重试成功: orderId={}, event={}", orderId, event.getStatus());
            }
        });
    }
}

5.4 分布式事务与数据一致性

问题场景:支付服务完成处理后,需同时执行三个操作:①更新订单数据库状态为 PAID,②在支付数据库写入支付记录,③发送 PaymentEvent 到 Kafka。这三步必须原子化,防止出现支付已记录但订单未更新或消息未发送的不一致状态。

本项目使用事务发件箱模式作为主流程方案,并独立演示 Kafka 原生事务以展示不同一致性模型的适用场景。涉及 Kafka 第6、7、12、16 篇知识点。

5.4.1 事务发件箱模式(最终一致性,高吞吐)

实现步骤

  1. 在 payment-service 的本地数据库创建 outbox 表:
CREATE TABLE outbox (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    aggregate_id VARCHAR(50),
    event_type VARCHAR(50),
    payload TEXT,
    status VARCHAR(20) DEFAULT 'PENDING',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
  1. 在同一个本地事务中写入支付记录和发件箱记录:
@Service
public class PaymentService {

    private final PaymentRepository paymentRepo;
    private final OutboxRepository outboxRepo;

    @Transactional
    public void processPayment(String orderId, BigDecimal amount, String userId) {
        // 1. 写入支付记录
        Payment payment = new Payment();
        payment.setPaymentId(UUID.randomUUID().toString());
        payment.setOrderId(orderId);
        payment.setAmount(amount);
        payment.setChannel("ALIPAY");
        paymentRepo.save(payment);

        // 2. 写入发件箱
        PaymentEvent event = new PaymentEvent();
        event.setPaymentId(payment.getPaymentId());
        event.setOrderId(orderId);
        event.setUserId(userId);
        event.setAmount(amount);
        event.setChannel("ALIPAY");
        event.setTimestamp(System.currentTimeMillis());

        Outbox outbox = new Outbox();
        outbox.setAggregateId(orderId);
        outbox.setEventType("PaymentEvent");
        outbox.setPayload(JsonUtil.toJson(event));
        outboxRepo.save(outbox);
    }
}
  1. 定时任务读取发件箱并发送至 Kafka:
@Component
public class OutboxScheduler {

    private final OutboxRepository outboxRepo;
    private final KafkaTemplate<String, String> kafkaTemplate;

    @Scheduled(fixedDelay = 2000)
    public void sendOutboxMessages() {
        List<Outbox> pending = outboxRepo.findByStatus("PENDING");
        for (Outbox msg : pending) {
            try {
                // 同步发送,适用于低并发演示。生产环境建议改为异步发送+回调更新状态,
                // 或限制每轮发送数量,避免长时间阻塞定时线程导致后续消息积压。
                kafkaTemplate.send("payment-events", msg.getAggregateId(), msg.getPayload())
                      .get(5, TimeUnit.SECONDS);
                msg.setStatus("SENT");
                outboxRepo.save(msg);
            } catch (Exception e) {
                log.error("发件箱发送失败,等待重试: {}", msg.getId(), e);
            }
        }
    }
}

验证方式:在发送阶段手动暂停 Kafka Broker,消息状态保持 PENDING;恢复 Broker 后,下一轮调度自动发送并更新状态为 SENT。若事务内数据库操作失败,发件箱记录回滚,不会发送任何消息。

5.4.2 Kafka 原生事务(强一致性,较低吞吐)

为实现跨数据库和 Kafka 的严格原子性,需同时管理 DataSourceTransactionManagerKafkaTransactionManager。在 Spring Boot 3.x 中,不推荐使用已移除的 ChainedTransactionManager,而应通过编程式事务或手动协调。此处编写一个独立的演示方法供测试使用,不嵌入主流程。

@Service
public class PaymentTransactionalService {

    private final PaymentRepository paymentRepo;
    private final KafkaTemplate<String, PaymentEvent> kafkaTemplate;
    private final KafkaTransactionManager kafkaTransactionManager;
    private final DataSourceTransactionManager dataSourceTransactionManager;

    public void payWithKafkaTransaction(String orderId, BigDecimal amount) {
        // 手动开启 Kafka 事务
        kafkaTransactionManager.getTransactionFactory().getTransaction().begin();
        try {
            // 数据库事务
            DefaultTransactionDefinition def = new DefaultTransactionDefinition();
            TransactionStatus dbStatus = dataSourceTransactionManager.getTransaction(def);
            try {
                Payment payment = new Payment(orderId, amount);
                paymentRepo.save(payment);

                // 发送 Kafka 消息,此时消息在事务上下文中,未真正提交
                PaymentEvent event = new PaymentEvent(/*...*/);
                kafkaTemplate.send("payment-events", orderId, event);

                // 模拟异常
                if (amount.compareTo(BigDecimal.ZERO) < 0) {
                    throw new RuntimeException("金额异常,回滚");
                }

                dataSourceTransactionManager.commit(dbStatus);
                kafkaTransactionManager.getTransactionFactory().getTransaction().commit();
            } catch (Exception e) {
                dataSourceTransactionManager.rollback(dbStatus);
                throw e;
            }
        } catch (Exception e) {
            kafkaTransactionManager.getTransactionFactory().getTransaction().rollback();
            throw e;
        }
    }
}

消费者隔离级别配置:需将消费 payment-events 的消费者配置 isolation.level=read_committed,确保未提交事务的消息不可见。

spring:
  kafka:
    consumer:
      properties:
        isolation.level: read_committed

演示验证:在事务方法内触发异常,消费者不应收到任何 payment-events 消息。查看 Kafka 日志可确认事务中止。

适用场景对比

特性事务发件箱模式Kafka 原生事务
一致性模型最终一致性强一致性
吞吐量影响极小(仅额外发件箱写入)下降约 30%-50%(事务协调开销)
实现复杂度需定时任务和状态管理配置较复杂,需管理两个事务管理器
适用场景高并发、允许短暂延迟的业务对一致性要求极高的核心交易链路

5.5 实时流处理与多流数据聚合

问题场景:业务方需要实时获取“每 10 秒订单金额统计”和“每 30 秒用户行为会话分析(结合支付事件)”,并将结果写入 Redis 供前端仪表盘查询。

解决方案:使用 Kafka Streams 构建实时流处理拓扑,利用窗口操作和多流 Join 完成聚合,并通过交互式查询和专用 Sink 消费者将结果输出到 Redis。详细对应 Kafka 第11篇(Kafka Streams)与第12篇(Spring Kafka 整合)。

5.5.1 订单金额 Tumbling Window 聚合
@Configuration
public class OrderMetricsTopology {

    @Bean
    public KStream<String, OrderStatusEvent> orderMetricsPipeline(StreamsBuilder builder) {
        KStream<String, OrderStatusEvent> orderStream = builder.stream(
                "order-status",
                Consumed.with(Serdes.String(), orderStatusEventSerde)
        );

        // 仅统计 CREATED 作为订单金额
        orderStream
            .filter((key, value) -> "CREATED".equals(value.getStatus()))
            .groupByKey()
            .windowedBy(TimeWindows.ofSizeWithNoGrace(Duration.ofSeconds(10)))
            .aggregate(
                () -> 0.0,
                (key, value, aggregate) -> aggregate + value.getAmount().doubleValue(),
                Materialized.<String, Double, WindowStore<Bytes, byte[]>>as("order-amount-10s")
                    .withValueSerde(Serdes.Double())
            )
            .toStream((windowedKey, value) -> windowedKey.key())
            .to("aggregated-metrics", Produced.with(Serdes.String(), Serdes.Double()));

        return orderStream;
    }
}
5.5.2 用户会话窗口与支付事件 Join

为分析用户在 30 秒内的下单-支付行为,对 order-status(CREATED 事件)与 payment-events 按用户 ID 重分区后进行 Session Window Join。

@Bean
public KStream<String, String> userSessionPipeline(StreamsBuilder builder) {
    KStream<String, OrderStatusEvent> orders = builder.stream("order-status", ...)
            .filter((k, v) -> "CREATED".equals(v.getStatus()))
            .selectKey((k, v) -> v.getUserId());

    KStream<String, PaymentEvent> payments = builder.stream("payment-events", ...)
            .selectKey((k, v) -> v.getUserId());

    KStream<String, String> joined = orders.join(payments,
            (order, payment) -> "User " + order.getUserId() + " ordered " +
                    order.getOrderId() + " and paid via " + payment.getChannel(),
            JoinWindows.ofTimeDifferenceWithNoGrace(Duration.ofSeconds(30)),
            StreamJoined.with(Serdes.String(), orderStatusEventSerde, paymentEventSerde)
    );
    joined.to("aggregated-metrics", Produced.with(Serdes.String(), Serdes.String()));
    return joined;
}
5.5.3 交互式查询与跨实例路由

本地查询端点

@RestController
@RequestMapping("/stream")
public class StreamQueryController {

    private final StreamsBuilderFactoryBean streamsFactory;

    @GetMapping("/metrics/order-amount")
    public Map<Long, Double> getOrderAmount(@RequestParam(defaultValue = "10s") String window) {
        ReadOnlyWindowStore<String, Double> store = streamsFactory.getKafkaStreams()
                .store(StoreQueryParameters.fromNameAndType(
                        "order-amount-10s", QueryableStoreTypes.windowStore()));

        Instant now = Instant.now();
        Instant windowStart = now.minusSeconds(10);
        WindowStoreIterator<Double> iterator = store.fetch("CREATED", windowStart, now);
        Map<Long, Double> result = new HashMap<>();
        while (iterator.hasNext()) {
            KeyValue<Long, Double> next = iterator.next();
            result.put(next.key, next.value);
        }
        return result;
    }
}

跨实例查询路由:当 stream-service 以多实例部署时,某一实例本地存储可能不包含请求的 Key。此时需要通过 StreamsMetadataService 查找 Key 所在实例,并转发 HTTP 请求。

@Service
public class QueryRoutingService {

    private final StreamsMetadataService metadataService;
    private final RestTemplate restTemplate;

    public <V> V query(String storeName, String key, Class<V> responseType) {
        HostInfo host = metadataService.getHostInfoForStoreAndKey(storeName, key);
        if (host.equals(metadataService.getCurrentHostInfo())) {
            // 查询本地存储
            return queryLocal(storeName, key, responseType);
        } else {
            // 转发到远程实例
            String url = "http://" + host.host() + ":" + host.port() + "/stream/" + storeName + "/" + key;
            return restTemplate.getForObject(url, responseType);
        }
    }
}

在 Controller 中实现本地查询和远程转发统一入口:

@GetMapping("/metrics/order-amount/{key}")
public Double getOrderAmountByKey(@PathVariable String key) {
    return queryRoutingService.query("order-amount-10s", key, Double.class);
}
5.5.4 Redis 写入消费者

启动独立消费者消费 aggregated-metrics 并写入 Redis,供外部仪表盘使用。

@Component
public class MetricsRedisSink {

    private final StringRedisTemplate redisTemplate;

    @KafkaListener(topics = "aggregated-metrics", groupId = "redis-sink-consumer")
    public void sink(ConsumerRecord<String, String> record) {
        redisTemplate.opsForValue().set("metrics:" + record.key(), record.value());
    }
}

状态恢复验证:配置 processing.guarantee: exactly_once_v2,停止 stream-service 后重启,观察状态通过内部 changelog 恢复,窗口聚合值无重复、无遗漏。

5.6 数据管道:Debezium CDC + Kafka Connect

目的:捕获 order-service MySQL 数据库中 orders 表的变更,以事件流形式发送至 Kafka,供数据分析、审计等下游系统消费。对应 Kafka 第15篇(Kafka Connect)。

准备工作

  • MySQL 开启 binlog,binlog_format=ROWbinlog_row_image=FULL
  • 创建具有 REPLICATION SLAVE 权限的用户。

Debezium 连接器配置(以分布式模式提交至 Kafka Connect):

{
  "name": "order-cdc-connector",
  "config": {
    "connector.class": "io.debezium.connector.mysql.MySqlConnector",
    "database.hostname": "mysql",
    "database.port": "3306",
    "database.user": "cdc_user",
    "database.password": "cdc_password",
    "database.server.id": "223344",
    "database.server.name": "order_service",
    "table.include.list": "order_db.orders",
    "database.history.kafka.bootstrap.servers": "kafka:9092",
    "database.history.kafka.topic": "schema-changes.orders",
    "transforms": "unwrap",
    "transforms.unwrap.type": "io.debezium.transforms.ExtractNewRecordState",
    "transforms.unwrap.drop.tombstones": false
  }
}

验证 CDC 消息

kafka-console-consumer.sh --bootstrap-server localhost:9092 \
  --topic mysql-order-cdc --from-beginning

输出示例:

{
  "order_id": "abc12345",
  "user_id": "u1001",
  "amount": 199.99,
  "status": "CREATED",
  "created_at": "2026-05-10T10:00:00"
}

通过此管道,任何订单变更都会实时推送到 Kafka,下游无需直接连接业务数据库。

5.7 全链路监控体系

采用 Micrometer + Prometheus + Grafana 搭建监控体系,覆盖 Broker、Producer、Consumer 及 Streams 应用指标。对应 Kafka 第17篇(监控体系)。

Micrometer 暴露端点:各服务添加依赖 micrometer-registry-prometheus,Spring Kafka 自动注册指标。

Prometheus 配置示例 (prometheus.yml):

scrape_configs:
  - job_name: 'spring-boot'
    metrics_path: '/actuator/prometheus'
    static_configs:
      - targets:
        - 'order-service:8081'
        - 'payment-service:8082'
        - 'logistics-service:8083'
        - 'stream-service:8084'
  - job_name: 'kafka'
    static_configs:
      - targets: ['kafka-exporter:9308']

Grafana 仪表盘:导入 Kafka Exporter Dashboard(ID: 11962),展示:

  • Broker 进出流量、请求延迟
  • 消费者组 Lag 趋势
  • 生产者请求速率与错误率
  • Streams 线程状态与任务分布

通过此监控体系,系统运营人员可实时感知流量尖峰、积压情况以及资源瓶颈,从而快速进行扩缩容决策。

六、故障模拟与全链路验证

本章节对系统进行有计划的故障注入,验证 Kafka 各项特性在异常场景下的行为是否符合设计预期。所有故障场景均可在开发或测试环境中复现,验证结果直接反映系统对生产级异常的容忍能力。

6.1 故障一:Kafka 原生事务回滚验证

验证目标

确认 Kafka 原生事务在数据库操作失败时,能够完整回滚数据库变更和消息发送,且配置了 read_committed 隔离级别的消费者不会读取到未提交的事务消息。

前置条件

  • payment-service 启动,PaymentTransactionalService 就绪。
  • logistics-service 消费者的 isolation.level 配置为 read_committed
  • 在 payment-service 中,payWithKafkaTransaction 方法内存在模拟异常逻辑(金额为负数时抛出 RuntimeException)。

模拟步骤

  1. 通过 REST 调用或单元测试触发 payWithKafkaTransaction("order-10086", new BigDecimal("-1.00"))
  2. 方法内先执行数据库写入和 Kafka 消息发送,随后因金额为负触发 RuntimeException,进入 catch 块执行数据库和 Kafka 的双重回滚。
  3. 观察 payment-service 日志,确认事务回滚日志输出。
  4. 查询支付数据库 payments 表,确认不存在 order-10086 的记录。
  5. 使用命令行消费者检查 payment-events 主题中是否存在对应消息。

验证命令

# 消费 payment-events,隔离级别 read_committed
kafka-console-consumer.sh --bootstrap-server localhost:9092 \
  --topic payment-events \
  --from-beginning \
  --consumer-property isolation.level=read_committed

预期结果

  • 支付数据库 payments 表中无 order-10086 的记录。
  • payment-events 主题中不存在 order-10086 对应的支付事件。
  • logistics-service 未触发任何与该订单相关的发货逻辑。

关联知识点

Kafka 第7篇(生产者幂等与事务)、第12篇(Spring Kafka 事务整合)、第16篇(反模式排查:事务超时与恢复)。


6.2 故障二:事务发件箱重试验证

验证目标

确认事务发件箱模式在 Kafka Broker 短时不可用时,能够保持消息不丢失,并在 Broker 恢复后自动完成投递。

前置条件

  • payment-service 正常运行,OutboxScheduler 定时任务以 2 秒间隔执行。
  • outbox 表中存在若干 status='PENDING' 的记录。
  • 当前无其他 Kafka 故障。

模拟步骤

  1. OutboxScheduler.sendOutboxMessages() 方法执行前,通过 Docker 暂停 Kafka Broker:
    docker-compose stop kafka
    
  2. 等待定时任务触发,观察 OutboxScheduler 日志输出 kafkaTemplate.send(...).get(5, TimeUnit.SECONDS) 抛出超时异常。
  3. 检查 outbox 表,确认目标记录的 status 仍为 PENDING
  4. 恢复 Kafka Broker:
    docker-compose start kafka
    
  5. 等待下一轮定时任务(最多 2 秒),观察日志输出发送成功信息。
  6. 查询 outbox 表,确认记录的 status 已更新为 SENT

验证命令

-- 查看 outbox 表状态变化
SELECT id, aggregate_id, event_type, status, created_at FROM outbox;

预期结果

  • Kafka Broker 中断期间,发件箱消息保持 PENDING 状态。
  • Broker 恢复后,定时任务在下一次执行周期内完成发送并更新状态为 SENT
  • 消费 payment-events 主题可收到对应消息(通过 kafka-console-consumer.sh 验证)。

生产注意事项

本方案中 OutboxScheduler 使用了同步 get(timeout) 方式发送消息。在低并发演示场景下可正常工作,但在生产高并发环境中,同步等待会长时间阻塞定时任务线程,导致后续发件箱消息处理延迟。生产环境建议改为异步发送并注册回调更新状态,或限制每轮处理的记录数量(如每次最多处理 100 条),以保证调度线程的响应性。

关联知识点

Kafka 第7篇(生产者幂等与事务)、第12篇(Spring Kafka 模板与回调)、第16篇(反模式排查:发件箱重复发送)。


6.3 故障三:消息顺序性破坏与死信恢复

验证目标

验证当 order-status 主题中出现乱序事件时,logistics-service 的状态机能够正确检测、隔离到死信队列,并在前置条件满足后通过恢复调度器完成自动重投和正常处理。

前置条件

  • logistics-service 正常运行,KTable 状态存储就绪,DLQRetryScheduler 定时任务以 10 秒间隔运行。
  • order-status 主题已存在若干正常顺序的事件。
  • 预先在数据库中或通过 KTable 确认测试订单的当前状态(假设目标订单 order-test-001 的初始状态为 CREATED)。

模拟步骤

  1. 使用脚本或 Kafka 生产者 API,绕过状态检查直接向 order-status 主题发送乱序消息:
    Key: order-test-001, Value: {"orderId":"order-test-001","status":"SHIPPED","amount":100.00,"timestamp":...}
    
  2. 观察 logistics-service 日志,确认状态机检测到当前状态(CREATED)与接收到的事件(SHIPPED)不匹配,输出乱序警告日志。
  3. 验证事件已被路由至 order-status-dlq
    kafka-console-consumer.sh --bootstrap-server localhost:9092 \
      --topic order-status-dlq --from-beginning \
      --property print.key=true --property print.value=true
    
  4. order-status 发送正确的 PAID 事件:
    Key: order-test-001, Value: {"orderId":"order-test-001","status":"PAID","amount":100.00,"timestamp":...(略晚于上一条)}
    
  5. 观察状态机日志,确认 PAID 事件被正常处理,状态更新为 PAID
  6. 等待 DLQRetryScheduler 下一轮调度(最多 10 秒),观察日志输出“DLQ 重试成功”,表示 SHIPPED 事件被重新投递到 order-status
  7. 状态机再次消费 SHIPPED,此时当前状态为 PAID,校验通过,执行发货逻辑。
  8. 查询 shipment-events 主题或日志,确认 SHIPPED 处理成功。

验证命令

# 监控 order-status 消费情况
kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
  --group logistics-status-consumer --describe

# 查看 DLQ 消息
kafka-console-consumer.sh --bootstrap-server localhost:9092 \
  --topic order-status-dlq --from-beginning \
  --property print.headers=true

预期结果

  • SHIPPED 事件首次到达时被拒绝并写入 order-status-dlq
  • PAID 事件正常处理。
  • 恢复调度器检测到前置条件满足后,将 SHIPPED 从死信队列重新投递回 order-status
  • 重新投递的 SHIPPED 通过状态机校验,物流发货流程正常完成。
  • 整个过程中无消息丢失,最终状态正确。

关联知识点

Kafka 第6篇(生产者分区策略与有序性)、第8篇(消费者组与重平衡)、第12篇(Spring Kafka 错误处理与死信)、第16篇(反模式:状态机设计缺陷)。


6.4 故障四:Kafka Streams 故障恢复与 Exactly-Once 验证

验证目标

验证 stream-service 在进程意外终止并重启后,能够从内部 changelog 主题完整恢复窗口聚合状态,且聚合结果不重复、不遗漏,严格符合 Exactly-Once 语义。

前置条件

  • stream-service 正常运行,processing.guarantee 配置为 exactly_once_v2
  • order-status 主题有持续摄入的订单创建事件。
  • Redis 中持续写入窗口聚合结果(由 MetricsRedisSink 完成)。
  • 准备一份窗口时间戳和聚合值的采样记录,用于重启前后对比。

模拟步骤

  1. 记录当前最近完成的 10 秒窗口的聚合值(例如窗口起始时间 T0,金额 A0):
    • 可从 Redis 中读取:GET metrics:CREATED
    • 或调用 GET /stream/metrics/order-amount?window=10s
  2. 强制终止 stream-service 进程(kill -9 <pid>docker stop stream-service)。
  3. 在 stream-service 停止期间,继续向 order-status 发送若干 CREATED 事件,确保上游消息生产不受影响。
  4. 等待约 30 秒后,重新启动 stream-service。
  5. 观察 stream-service 启动日志,确认 Streams 线程状态从 CREATEDRUNNING,并输出状态恢复日志。
  6. 等待新窗口完成,再次读取相同窗口范围的聚合值,与中断前记录的数值进行对比。

验证命令

# 查看消费组状态(stream-service 使用 application.id 作为 group.id)
kafka-streams-application-reset.sh --bootstrap-server localhost:9092 \
  --application-id stream-service-metrics --dry-run

# 观察 changelog 主题消息(内部主题,格式为 {application.id}-{storeName}-changelog)
kafka-console-consumer.sh --bootstrap-server localhost:9092 \
  --topic stream-service-metrics-order-amount-10s-changelog \
  --from-beginning --property print.key=true

预期结果

  • 重启后,Streams 应用自动从 changelog 主题恢复窗口状态,处理过程中不产生新的聚合起始点。
  • 同一时间窗口的聚合值在 restart 前后保持一致(精确到小数点后两位,因金额运算可能存在浮点误差,生产环境建议使用 BigDecimallong 型金额)。
  • 新摄入的事件继续参与窗口计算,结果连续且无跳变。
  • Redis 中的聚合结果无重复写入(同一窗口 Key 的 Value 不会出现“先小后大再小”的异常波动)。

验证 Exactly-Once 的关键观察点

  • exactly_once_v2 配置下,Streams 使用事务方式写入输出主题,每个消费偏移提交、状态存储更新和输出写入在同一事务中原子完成。
  • 进程崩溃时,未完成的事务会被 Broker 自动中止,重启后从上一个已提交的偏移继续消费,保证无重复处理。
  • 通过对比 Redis 中同一窗口的聚合值序列,确认只包含稳定的提交值,不包含回滚的中间状态。

关联知识点

Kafka 第11篇(Kafka Streams 状态存储与容错)、第7篇(Exactly-Once 语义)、第17篇(监控 Streams 应用状态)。


6.5 补充验证:端到端流程正确性检查

在以上四个故障场景之外,可通过一个简短的端到端流程验证系统的整体正确性:

  1. 发送一个正常下单请求至 POST /orders
  2. 通过消费者日志跟踪事件流转路径:
    • order-request → order-service 消费 → order-status (CREATED)
    • → payment-service 消费 → payment-events
    • → logistics-service 消费 → shipment-events → 通知
  3. 通过 GET /orders/{orderId} 查询最终状态为 SHIPPEDCOMPLETED
  4. 在 Grafana 监控面板中查看各消费者组 Lag 趋势、生产者发送速率以及 Streams 线程状态。

上述步骤确保所有组件在无故障情况下协同工作正常,为前述故障验证提供基准参照。


6.6 故障验证总结

故障场景注入方式核心验证点涉及 Kafka 特性
Kafka 事务回滚业务异常触发数据库与消息的双回滚,消费者隔离级别行为事务、幂等、read_committed
发件箱重试暂停 Broker消息保持 PENDING,恢复后自动发送发件箱模式、定时重试
消息乱序与死信恢复手动发送乱序事件状态机检测、DLQ 路由、定时恢复重投分区有序、状态机、DLQ
Streams 进程崩溃恢复强制 kill 进程窗口状态恢复,Exactly-Once 聚合值无重复Streams 状态存储、changelog、Exactly-Once

这套故障模拟矩阵覆盖了消息系统最核心的可靠性维度:事务边界、投递保证、顺序性、以及有状态流处理的容错能力。每通过一项验证,就意味着系统在处理对应类型生产故障时具备确定性行为,不会产生静默数据丢失或不一致的隐蔽缺陷。

七、项目源码结构与运行指南

7.1 项目目录

order-kafka-suite/
├── pom.xml
├── docker-compose.yml
├── order-service/
│   ├── pom.xml
│   └── src/main/java/.../order/
│       ├── OrderServiceApplication.java
│       ├── controller/OrderController.java
│       ├── consumer/OrderRequestConsumer.java
│       ├── model/Order.java
│       ├── repo/OrderRepository.java
│       └── resources/application.yml
├── payment-service/
│   ├── pom.xml
│   └── src/main/java/.../payment/
│       ├── PaymentServiceApplication.java
│       ├── service/PaymentService.java
│       ├── outbox/OutboxScheduler.java
│       ├── transaction/PaymentTransactionalService.java
│       ├── model/Payment.java, Outbox.java
│       ├── repo/...
│       └── resources/application.yml
├── logistics-service/
│   ├── pom.xml
│   └── src/main/java/.../logistics/
│       ├── LogisticsApplication.java
│       ├── state/LogisticsStateMachine.java
│       ├── topology/OrderStateTopology.java
│       ├── dlq/DLQRetryScheduler.java
│       ├── model/Shipment.java, NotificationEvent.java
│       └── resources/application.yml
├── stream-service/
│   ├── pom.xml
│   └── src/main/java/.../stream/
│       ├── StreamServiceApplication.java
│       ├── topology/OrderMetricsTopology.java
│       ├── topology/UserSessionTopology.java
│       ├── controller/StreamQueryController.java
│       ├── routing/QueryRoutingService.java
│       ├── sink/MetricsRedisSink.java
│       └── resources/application.yml
└── jmeter/
    └── order-load-test.jmx

7.2 核心配置说明

order-service application.yml

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/order_db?useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true
    username: root
    password: root
  jpa:
    hibernate:
      ddl-auto: update
  kafka:
    producer:
      bootstrap-servers: localhost:9092
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
      properties:
        linger.ms: 10
        batch.size: 65536
        compression.type: lz4
        acks: 1
    consumer:
      group-id: order-create-consumer
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
      properties:
        spring.json.trusted.packages: "*"

payment-service application.yml (部分)

spring:
  datasource:
    url: jdbc:h2:mem:payment_db;DB_CLOSE_DELAY=-1
    driver-class-name: org.h2.Driver
  kafka:
    consumer:
      group-id: payment-consumer
    producer:
      # 生产 payment-events 配置

stream-service 关键配置

spring.kafka.streams.properties.processing.guarantee=exactly_once_v2
spring.kafka.streams.properties.num.stream.threads=2
spring.kafka.streams.properties.replication.factor=1

7.3 启动步骤

  1. 启动基础设施:docker-compose up -d(包含 Kafka、MySQL、Redis、Prometheus、Grafana)。
  2. 部署 Debezium 连接器至 Kafka Connect。
  3. 依次启动 order-servicepayment-servicelogistics-servicestream-service
  4. 使用 JMeter 压测 order-service,观察 Grafana 监控大盘。

总结: 本项目以 Spring Boot 和 Kafka 全特性为核心,打造了一套消息驱动型订单-支付-物流系统。通过异步解耦实现高吞吐下单;利用分区策略和 KTable 状态机保证关键业务严格顺序;分别采用事务发件箱与 Kafka 原生事务应对分布式一致性需求;通过 Kafka Streams 实现了实时窗口聚合和用户行为分析;借助 Debezium CDC 完成数据库变更捕获;最终建立从应用到底层 Broker 的全链路监控。