Transactional Outbox Pattern, dağıtık sistemlerde (mikroservislerde) veri tutarlılığını sağlamak için kullanılan en güvenilir tasarım desenidir.
Bu desenin temel amacı, "Veritabanı güncellemesi" ile "Mesajın kuyruğa (Kafka vb.) gönderilmesi" işlemlerini tek bir atomik birim haline getirmektir.
1. Sorun: Dual Write (İkili Yazma) Problemi
Mikroservislerde genellikle bir iş akışı şu şekildedir:
- Veritabanına bir kayıt at (Örn:
Orderstablosuna sipariş ekle). - Diğer servisleri haberdar etmek için bir mesaj yayınla (Örn: Kafka'ya
OrderCreatedmesajı gönder).
Problem şurada başlar: Bu iki işlem farklı dünyalara aittir.
- Veritabanı işlemi başarılı olur ama tam mesaj gönderilecekken ağ koparsa veya uygulama çökerse? Veri kaydedilir ama diğer servislerin haberi olmaz.
- Mesaj gider ama veritabanı işlemi bir hata (constraint vb.) nedeniyle geri alınırsa (rollback)? Bu sefer de var olmayan bir veri için "hayalet mesaj" (ghost message) göndermiş olursunuz.
2. Çözüm: Outbox Tablosu
Outbox pattern, bu iki işlemi birbirine zincirler. Doğrudan Kafka'ya mesaj göndermek yerine, mesajı veritabanındaki teknik bir tabloya (Outbox tablosu) yazarız.
Sistemin İşleyişi:
- Atomik İşlem: Uygulama, ana veriyi (Order) ve mesajı (Outbox) aynı veritabanı işlemi (Transaction) içinde kaydeder. ACID özellikleri sayesinde ya ikisi de kaydedilir ya da hiçbiri.
- Mesaj Aktarıcı (Message Relay): Arka planda çalışan ayrı bir süreç, Outbox tablosunu sürekli kontrol eder.
- Kesin Gönderim: Aktarıcı, tablodaki mesajı okur ve Kafka/RabbitMQ'ya gönderir. Gönderim başarılı olunca mesajı tablodan siler veya "gönderildi" olarak işaretler.
3. Neden Bu Kadar Önemli?
- Veri Kaybı Yok: Veritabanı ayakta olduğu sürece mesajınız güvendedir. Uygulama o an çökse bile mesaj Outbox tablosunda bekler.
- Garantili İletim: Aktarıcı süreç, mesajın kuyruğa ulaştığından emin olana kadar (ACK alana kadar) denemeye devam eder. Bu da bize At-Least-Once (En Az Bir Kez) teslimat garantisi sağlar.
- Bağımsızlık: Mesaj kuyruğu (Kafka) o an kapalı olsa bile kullanıcıya "Hata" döndürmezsiniz. Sistem veriyi DB'ye yazar, Kafka gelince mesaj arka planda iletilir.
4. Uygulama Yöntemleri
Bu deseni hayata geçirmek için iki ana yol vardır:
- Polling Publisher (Sorgulama): Java tarafında
@Scheduledile belirli aralıklarla Outbox tablosunu sorgulayan bir kod yazmak. (Basittir ama veritabanını yorabilir). - Transaction Log Tailing (CDC - Debezium): Veritabanının "değişim günlüklerini" (PostgreSQL'de WAL, MySQL'de Binlog) takip eden bir araç kullanmak. En performanslı ve profesyonel yöntem budur; veritabanına ek sorgu yükü getirmez.
Transactional Outbox Pattern'in çalışma mantığını, bir yazılım mimarı gözüyle en ince ayrıntısına kadar ve Java (Spring Boot) örnekleriyle inceleyelim.
1. Mimari Bileşenler
Bu deseni kurmak için 3 ana yapıya ihtiyacınız vardır:
- Business Table: Asıl iş verinizin olduğu tablo (Örn:
orders). - Outbox Table: Gönderilecek mesajların kuyruğu (Örn:
outbox_events). - Message Relay: Bu tabloyu izleyip mesajları Kafka'ya uçuran tetikleyici.
2. Java ile Uygulama Adımları
A. Veritabanı Şeması ve Entity Yapısı
Outbox tablosu, mesajın içeriğini (JSON) ve durumunu saklamalıdır.
@Entity
@Table(name = "outbox_events")
public class OutboxEvent {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
private String aggregateType; // "ORDER", "USER" gibi
private String aggregateId; // İlgili kaydın ID'si
private String type; // "OrderCreated", "OrderCancelled"
@Column(columnDefinition = "TEXT")
private String payload; // Mesajın JSON hali
private String status; // PENDING, PROCESSED, FAILED
private LocalDateTime createdAt;
}
B. Servis Katmanı: Atomik Yazma
Buradaki en kritik nokta @Transactional kullanımıdır. Eğer sipariş kaydedilir ama outbox tablosuna yazarken hata çıkarsa, Spring tüm işlemi geri alır (rollback). Böylece tutarsızlık engellenir.
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final OutboxRepository outboxRepository;
private final ObjectMapper objectMapper;
@Transactional
public void placeOrder(OrderRequest request) {
// 1. İş kuralını işle ve siparişi kaydet
Order order = new Order(request.getProductId(), request.getQuantity());
orderRepository.save(order);
// 2. Outbox mesajını hazırla
try {
String jsonPayload = objectMapper.writeValueAsString(order);
OutboxEvent event = OutboxEvent.builder()
.aggregateType("ORDER")
.aggregateId(order.getId().toString())
.type("ORDER_CREATED")
.payload(jsonPayload)
.status("PENDING")
.createdAt(LocalDateTime.now())
.build();
// 3. AYNI TRANSACTION içinde outbox tablosuna yaz
outboxRepository.save(event);
} catch (JsonProcessingException e) {
throw new RuntimeException("Mesaj dönüştürme hatası", e);
}
}
}
3. Mesajı Kafka'ya İletme (Relay)
Mesajları veritabanından alıp Kafka'ya göndermek için iki popüler yol vardır:
Yöntem 1: Polling Publisher (Basit ama Veritabanını Yorar)
Bir "Worker" oluşturup her 500ms'de bir PENDING olan kayıtları sorgularız.
@Component
@RequiredArgsConstructor
@Slf4j
public class OutboxPoller {
private final OutboxRepository outboxRepository;
private final KafkaTemplate<String, String> kafkaTemplate;
@Scheduled(fixedDelay = 500)
@Transactional
public void pollOutbox() {
// İşlenmemiş mesajları çek
List<OutboxEvent> events = outboxRepository.findTop10ByStatusOrderByCreatedAtAsc("PENDING");
for (OutboxEvent event : events) {
try {
// Kafka'ya gönder
kafkaTemplate.send("order-events-topic", event.getAggregateId(), event.getPayload())
.get(); // Onay (ACK) bekle
// Başarılıysa durumu güncelle
event.setStatus("PROCESSED");
outboxRepository.save(event);
} catch (Exception e) {
log.error("Mesaj gönderilemedi: {}", event.getId(), e);
// Burada status'ü FAILED yapıp bir "retry count" ekleyebilirsiniz
}
}
}
}
Yöntem 2: Transaction Log Tailing (Debezium - Profesyonel)
Java kodunda @Scheduled kullanmak yerine, veritabanının Write-Ahead Log (WAL) dosyalarını dinleyen bir araç (Debezium) kullanılır.
- Avantajı: Veritabanına ek
SELECTsorgusu atmaz, çok daha hızlıdır (neredeyse real-time). - Akış: DB'ye
INSERTyapıldığı an Debezium bunu yakalar ve doğrudan Kafka'ya basar.
4. Kritik Detaylar ve "Corner Cases"
- Sıralama (Ordering): Outbox tablosundan okurken mutlaka
createdAtveya birsequence_idile sıralı okumalısınız. Siparişin "İptal" mesajı, "Oluşturuldu" mesajından önce giderse sistem karışır. - Performance:
outbox_eventstablosu zamanla şişer.PROCESSEDdurumundaki eski kayıtları temizleyen bir "Cleanup Job" kurmanız gerekir. - At-Least-Once Yan Etkisi: Eğer
kafkaTemplate.sendbaşarılı olur ama tam o sırada veritabanındastatus = 'PROCESSED'güncellemesi yapılamadan sistem çökerse, mesaj Kafka'ya tekrar gönderilir.- Çözüm: Alıcı tarafın (Consumer) mutlaka Idempotent olması gerekir (ilk başta konuştuğumuz konu).