Outbox Pattern — 分散式事務一致性

問題:Dual-Write

在微服務中,一個業務操作往往需要:

  1. 寫入資料庫(持久化訂單)
  2. 發布事件到 Kafka(通知下游服務)

這兩個動作無法包在同一個 ACID transaction 裡(資料庫 + Kafka 是兩個不同的資源管理器):

1
2
3
4
5
6
7
// 危險的 Dual-Write:兩個操作不是原子的
@Transactional
public void createOrder(CreateOrderCommand cmd) {
    Order order = orderRepository.save(new Order(cmd));  // DB 寫入成功
    kafkaTemplate.send("order.created", new OrderCreatedEvent(order)); // 若此處失敗?
    // DB 已提交,但 Kafka 事件未送出 → 下游永遠不知道這筆訂單
}

可能的失敗情境

1
2
3
4
5
6
7
8
情境 A:DB 成功,Kafka 失敗
  → 訂單存在,但下游沒收到事件,資料不一致

情境 B:Kafka 成功,DB 回滾
  → 下游已處理不存在的訂單

情境 C:應用程序在兩個操作中間 crash
  → 同情境 A 或 B

Outbox Pattern 解法

核心思想:把事件記錄寫入與業務資料同一個 DB transaction,讓 DB 的 ACID 保證兩者的原子性。再由獨立的 Publisher 非同步地將事件發布到 Kafka。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
┌──────────────────────────────────────────────────────┐
│  同一個 DB Transaction                                │
│                                                      │
│  INSERT INTO orders (...)                            │
│  INSERT INTO outbox_events (payload, status='PENDING')│
│                                                      │
└──────────────────────────────────────────────────────┘
          │
          │ 獨立 Publisher(非同步)
          ▼
    ┌───────────┐       ┌─────────────────┐
    │  outbox   │──────►│  Kafka Topic    │
    │  events   │       │  order.created  │
    └───────────┘       └─────────────────┘

資料庫 Schema

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
CREATE TABLE outbox_events (
    id             BIGINT PRIMARY KEY AUTO_INCREMENT,
    aggregate_type VARCHAR(64)  NOT NULL,  -- e.g. 'Order'
    aggregate_id   VARCHAR(64)  NOT NULL,  -- e.g. '12345'
    event_type     VARCHAR(128) NOT NULL,  -- e.g. 'OrderCreatedEvent'
    payload        JSON         NOT NULL,
    status         ENUM('PENDING', 'SENT') DEFAULT 'PENDING',
    created_at     DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
    sent_at        DATETIME,
    INDEX idx_status_created (status, created_at)
);

status + created_at 的複合索引讓 Polling Publisher 能高效查詢 WHERE status = 'PENDING' ORDER BY created_at。


業務層寫入 Outbox

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Service
public class OrderService {

    private final OrderRepository orderRepository;
    private final OutboxEventRepository outboxRepository;
    private final ObjectMapper objectMapper;

    @Transactional
    public Order createOrder(CreateOrderCommand cmd) {
        Order order = new Order(cmd.userId(), cmd.productId(), cmd.quantity());
        Order saved = orderRepository.save(order);

        // 同一 transaction 寫入 outbox
        String payload = objectMapper.writeValueAsString(
            new OrderCreatedPayload(saved.getId(), saved.getUserId(), saved.getProductId())
        );
        OutboxEvent event = new OutboxEvent(
            "Order",
            String.valueOf(saved.getId()),
            "OrderCreatedEvent",
            payload
        );
        outboxRepository.save(event);

        return saved;
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@Entity
@Table(name = "outbox_events")
public class OutboxEvent {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String aggregateType;
    private String aggregateId;
    private String eventType;

    @Column(columnDefinition = "JSON")
    private String payload;

    @Enumerated(EnumType.STRING)
    private EventStatus status = EventStatus.PENDING;

    private LocalDateTime createdAt = LocalDateTime.now();
    private LocalDateTime sentAt;

    protected OutboxEvent() {}

    public OutboxEvent(String aggregateType, String aggregateId,
                       String eventType, String payload) {
        this.aggregateType = aggregateType;
        this.aggregateId = aggregateId;
        this.eventType = eventType;
        this.payload = payload;
    }

    public void markSent() {
        this.status = EventStatus.SENT;
        this.sentAt = LocalDateTime.now();
    }
}

enum EventStatus { PENDING, SENT }

Publisher 方式一:Polling(簡單)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Component
public class OutboxPollingPublisher {

    private final OutboxEventRepository outboxRepository;
    private final KafkaTemplate<String, String> kafkaTemplate;

    @Scheduled(fixedDelay = 5000)  // 每 5 秒執行一次
    @Transactional
    public void publishPendingEvents() {
        List<OutboxEvent> events = outboxRepository
            .findTop100ByStatusOrderByCreatedAtAsc(EventStatus.PENDING);

        for (OutboxEvent event : events) {
            String topic = resolveTopicName(event.getEventType());
            kafkaTemplate.send(topic, event.getAggregateId(), event.getPayload());
            event.markSent();
            outboxRepository.save(event);
        }
    }

    private String resolveTopicName(String eventType) {
        return switch (eventType) {
            case "OrderCreatedEvent" -> "order.created";
            case "OrderCancelledEvent" -> "order.cancelled";
            default -> throw new IllegalArgumentException("Unknown event type: " + eventType);
        };
    }
}
1
2
3
4
@Repository
public interface OutboxEventRepository extends JpaRepository<OutboxEvent, Long> {
    List<OutboxEvent> findTop100ByStatusOrderByCreatedAtAsc(EventStatus status);
}

缺點:Polling 有延遲(最多 5 秒);高頻輪詢對 DB 有額外負擔。


Publisher 方式二:Debezium CDC(低延遲)

Debezium 監聽 MySQL binlog,發現 outbox_events 有新記錄時立即觸發,不需 Polling。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# docker-compose 啟動 Debezium Connector
version: "3"
services:
  kafka-connect:
    image: debezium/connect:2.4
    environment:
      BOOTSTRAP_SERVERS: kafka:9092
      GROUP_ID: debezium-connect
      CONFIG_STORAGE_TOPIC: debezium_connect_configs
      OFFSET_STORAGE_TOPIC: debezium_connect_offsets
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// POST /connectors — 建立 Outbox Connector
{
  "name": "outbox-connector",
  "config": {
    "connector.class": "io.debezium.connector.mysql.MySqlConnector",
    "database.hostname": "mysql",
    "database.port": "3306",
    "database.user": "debezium",
    "database.password": "dbz",
    "database.server.name": "myapp",
    "table.include.list": "mydb.outbox_events",
    "transforms": "outbox",
    "transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter",
    "transforms.outbox.table.field.event.key": "aggregate_id",
    "transforms.outbox.table.field.event.type": "event_type",
    "transforms.outbox.route.by.field": "aggregate_type"
  }
}

Debezium 的 EventRouter transform 會自動:

  • 以 aggregate_type 決定目標 Kafka topic(Order → outbox.event.Order)
  • 以 aggregate_id 作為 Kafka message key(保證同一 aggregate 的事件有序)
  • 發布後不需要修改 DB 的 status,offset 存在 Kafka 中

At-Least-Once 語意與冪等性

Outbox Pattern 保證 at-least-once delivery:

1
2
3
4
Publisher 發出事件 → Kafka 確認收到 → 更新 outbox status 為 SENT
                                ↑
                      若 Publisher 在此 crash,
                      下次 Polling 會重發同一事件

消費者必須是冪等的(idempotent):

1
2
3
4
5
6
7
8
9
@KafkaListener(topics = "order.created")
public void handleOrderCreated(OrderCreatedEvent event) {
    // 用 event ID(或 aggregate_id + event_type)做冪等檢查
    if (processedEventRepository.existsById(event.getId())) {
        return; // 已處理過,忽略重複
    }
    inventoryService.reserve(event.getProductId(), event.getQuantity());
    processedEventRepository.save(new ProcessedEvent(event.getId()));
}

Spring Modulith 內建支援

Spring Modulith 提供開箱即用的 Outbox 機制:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 應用事件發布(不直接呼叫 Kafka)
@Service
public class OrderService {

    private final ApplicationEventPublisher events;

    @Transactional
    public Order createOrder(CreateOrderCommand cmd) {
        Order order = orderRepository.save(...);
        events.publishEvent(new OrderCreatedEvent(order.getId())); // Spring Event
        return order;
    }
}
1
2
3
4
5
// 在另一個 module 訂閱,Spring Modulith 自動寫 outbox
@ApplicationModuleListener
public void onOrderCreated(OrderCreatedEvent event) {
    kafkaTemplate.send("order.created", event.toPayload());
}

Spring Modulith 會自動在 application_module_events 表中記錄事件,確保 at-least-once 發布,無需手寫 Outbox 表與 Publisher。


方案比較

方案複雜度延遲適用場景
Polling Publisher低秒級流量不大、容忍延遲
Debezium CDC中毫秒級高吞吐、低延遲需求
Spring Modulith低(框架包辦)秒級Spring Modulith 既有項目
2PC(XA Transaction)高高幾乎不用,效能差
Saga高中複雜多步驟流程

小結

  • Dual-Write 是分散式系統中的反模式,DB 與 MQ 不能保證原子性。
  • Outbox Pattern 用 DB ACID 保證「業務資料 + 事件記錄」的原子性,再非同步發布。
  • Polling 實作簡單,CDC(Debezium)延遲低,二者各有適用場景。
  • Consumer 必須設計為冪等,因為 at-least-once 保證可能重複投遞。

留言討論