Split View: 분산 트랜잭션: 2PC vs Saga 패턴
분산 트랜잭션: 2PC vs Saga 패턴
- 들어가며 — 트랜잭션이 서비스를 넘어서는 순간
- 왜 서비스를 가로지르는 ACID가 어려운가
- 2단계 커밋(2PC) — 억지로 원자성 만들기
- Saga 패턴 — 원자성을 포기하고 보상으로 대체
- 코레오그래피 vs 오케스트레이션
- 이중 쓰기 문제 — 왜 아웃박스가 필요한가
- 아웃박스 패턴 — 하나의 로컬 트랜잭션으로 묶기
- 결과적 일관성 — 실무에서 뜻하는 것
- 실무 지침 정리
- 마치며
- 참고 자료
들어가며 — 트랜잭션이 서비스를 넘어서는 순간
한 데이터베이스 안에서 트랜잭션은 든든한 친구입니다. BEGIN으로 시작하고 COMMIT으로 끝내면, 그 안의 모든 변경이 전부 반영되거나 전부 취소됩니다. 그런데 시스템을 여러 서비스로 쪼개면 이 든든함이 흔들립니다. 주문 서비스, 결제 서비스, 재고 서비스가 각자 자기 데이터베이스를 가지면, "주문을 만들고, 결제를 처리하고, 재고를 줄이는" 하나의 논리적 작업이 세 개의 물리적으로 분리된 데이터베이스에 걸치게 됩니다.
이제 무서운 질문이 돌아옵니다. 결제는 성공했는데 재고 감소가 실패하면? 하나의 COMMIT으로 세 데이터베이스를 한꺼번에 확정할 방법이 없습니다. 이것이 분산 트랜잭션(distributed transaction) 문제이고, 마이크로서비스가 피할 수 없는 근본적 어려움입니다. 이 글은 두 가지 대표 접근인 2단계 커밋과 Saga 패턴을 대조하며, 그 사이에서 우리가 무엇을 잃고 무엇을 얻는지를 정리합니다.
이 개념들과 연결되는 메시징을 눈으로 보고 싶다면, 이 사이트의 메시지 큐 놀이터에서 비동기 메시지 흐름을 시각화해 볼 수 있습니다.
왜 서비스를 가로지르는 ACID가 어려운가
단일 데이터베이스의 트랜잭션이 강력한 이유는, 하나의 시스템이 모든 데이터를 통제하기 때문입니다. 하나의 트랜잭션 관리자가 잠금을 걸고, WAL에 기록하고, 원자적으로 커밋을 확정합니다. 모든 것이 한 지붕 아래 있으니 "전부 또는 전무"를 보장하기 쉽습니다.
여러 서비스로 나뉘면 이 전제가 깨집니다.
- 공유 트랜잭션 관리자가 없습니다. 각 서비스의 데이터베이스는 자기 트랜잭션만 압니다. 서비스 A의 커밋과 서비스 B의 커밋을 하나로 묶어 줄 상위 존재가 기본적으로는 없습니다.
- 네트워크가 끼어듭니다. 서비스 간 통신은 네트워크를 탑니다. 네트워크는 느리고, 메시지를 잃고, 지연시키고, 순서를 뒤바꾸며, 무엇보다 끊깁니다. "요청은 갔는데 응답이 안 왔다"가 성공인지 실패인지 보내는 쪽은 알 수 없습니다.
- 부분 실패가 일상입니다. 단일 프로세스에서는 "전체가 죽거나 전체가 산다"에 가깝지만, 분산에서는 "일부는 성공하고 일부는 실패한" 어중간한 상태가 흔합니다.
이 세 가지 때문에, 단일 DB에서 당연했던 원자성을 여러 서비스에 걸쳐 재현하는 것은 근본적으로 어렵습니다. 두 갈래의 해법이 있습니다. 하나는 "그래도 원자성을 억지로라도 구현하자"는 2단계 커밋이고, 다른 하나는 "원자성을 포기하고 대신 결과적 일관성으로 가자"는 Saga입니다.
2단계 커밋(2PC) — 억지로 원자성 만들기
**2단계 커밋(Two-Phase Commit, 2PC)**은 여러 참가자(participant)의 커밋을 하나로 묶으려는 고전적 프로토콜입니다. 중심에는 **코디네이터(coordinator)**가 있고, 이름 그대로 두 단계로 진행됩니다.
1단계 — 준비(prepare / voting). 코디네이터가 모든 참가자에게 "커밋할 준비가 됐는가?"라고 묻습니다. 각 참가자는 실제로 변경을 커밋하지는 않되, 커밋할 수 있도록 모든 준비를 마치고(잠금 확보, 로그 기록) "예(준비됨)" 또는 "아니오"로 투표합니다. "예"라고 답했다면, 그 참가자는 이후 코디네이터가 커밋을 명령하면 반드시 커밋할 수 있어야 합니다.
2단계 — 커밋 또는 중단(commit / abort). 모든 참가자가 "예"라고 답하면, 코디네이터는 전원에게 "커밋하라"고 명령합니다. 하나라도 "아니오"이거나 응답이 없으면, 전원에게 "중단하라"고 명령합니다.
1단계 (준비):
코디네이터 --"준비됐나?"--> 참가자들
참가자들 --"예/아니오"--> 코디네이터
2단계 (커밋/중단):
모두 "예"였다면:
코디네이터 --"커밋!"--> 참가자들 (전원 커밋)
하나라도 "아니오"/무응답이면:
코디네이터 --"중단!"--> 참가자들 (전원 롤백)
논리적으로는 깔끔합니다. 모두가 준비된 것을 확인한 뒤에야 커밋하므로, 원자성이 지켜지는 듯 보입니다. 하지만 2PC에는 심각한 약점이 있습니다.
블로킹(blocking) 문제. 2PC의 가장 큰 결함입니다. 참가자가 "예(준비됨)"라고 투표한 뒤, 코디네이터가 2단계 명령을 내리기 직전에 코디네이터가 죽으면, 참가자는 오도 가도 못하는 상태에 빠집니다. 커밋하라는 것도 중단하라는 것도 못 들었는데, 자기 마음대로 결정할 수도 없습니다("예"라고 약속했으니까요). 이 참가자는 코디네이터가 부활할 때까지 잠금을 쥔 채 무기한 기다립니다. 그 잠금에 걸린 다른 트랜잭션들도 함께 멈춥니다.
기타 실패 모드. 준비 단계는 통과했는데 커밋 명령이 일부 참가자에게만 도달하는 경우, 네트워크 분단으로 코디네이터와 일부 참가자가 갈라지는 경우 등, 부분 실패의 조합이 많습니다. 이를 다루기 위한 변형(3PC 등)이 있지만 완전한 해법은 아니며 복잡성만 커집니다.
그래서 2PC는 원자성을 주지만, 대가로 가용성과 성능을 희생합니다. 코디네이터가 단일 실패점이 되고, 준비-커밋 사이에 잠금을 오래 쥐어 처리량이 떨어집니다. 서비스가 많고 지연이 큰 마이크로서비스 환경에서 2PC가 잘 쓰이지 않는 이유입니다. (반대로, 잘 통제된 단일 데이터센터 내부나 XA 트랜잭션 같은 특정 상황에서는 여전히 쓰입니다.)
Saga 패턴 — 원자성을 포기하고 보상으로 대체
2PC가 "원자성을 지키려다 블로킹을 감수"한다면, Saga 패턴은 발상을 뒤집습니다. 원자성을 포기하는 대신, 각 단계를 개별 로컬 트랜잭션으로 커밋하고, 문제가 생기면 이미 한 일을 되돌리는 보상 트랜잭션으로 정리한다.
핵심 개념은 **보상 트랜잭션(compensating transaction)**입니다. 어떤 단계를 커밋한 뒤 나중 단계가 실패하면, 앞서 커밋한 것을 "의미적으로 취소"하는 별도의 트랜잭션을 실행합니다. 롤백이 "아직 커밋 안 한 것을 없던 일로" 하는 것이라면, 보상은 "이미 커밋해서 세상에 반영된 것을 되돌리는" 것입니다.
주문 예시로 보면 이렇습니다.
정상 흐름 (각 단계는 독립적으로 커밋됨):
1. 주문 생성 (커밋)
2. 결제 청구 (커밋)
3. 재고 감소 (커밋)
4. 배송 예약 (커밋)
3단계에서 재고 부족으로 실패하면 -> 보상을 역순으로:
2번 보상: 결제 환불
1번 보상: 주문 취소
여기서 중요한 성질 몇 가지를 짚어야 합니다.
- 보상은 원래 연산의 완벽한 역이 아닐 수 있습니다. "결제 청구"의 보상은 "환불"이지만, 환불은 청구가 없던 상태와 완전히 같지 않습니다(거래 내역이 남고, 수수료가 붙을 수도 있습니다). 보상은 물리적 되돌리기가 아니라 의미적(semantic) 되돌리기입니다.
- 중간 상태가 외부에 노출됩니다. Saga가 진행되는 동안, 다른 관찰자는 "결제는 됐지만 배송은 아직 없는" 중간 상태를 볼 수 있습니다. 2PC의 격리와 달리 Saga는 격리를 보장하지 않습니다. 그래서 애플리케이션이 이런 중간 상태를 견디도록 설계돼야 합니다.
- 보상 자체도 실패할 수 있습니다. 그래서 보상은 재시도 가능하고 멱등(idempotent)해야 하며, 최악의 경우 사람이 개입하는 경로도 필요합니다.
Saga는 이렇게 원자성과 격리를 내주는 대신, 블로킹 없는 높은 가용성과 서비스 간 느슨한 결합을 얻습니다. 이것이 마이크로서비스에서 Saga가 사실상 표준이 된 이유입니다.
코레오그래피 vs 오케스트레이션
Saga를 실제로 구현하는 방식은 크게 두 가지입니다. 누가 "다음 단계로 넘어가라"를 결정하느냐가 갈림길입니다.
코레오그래피(choreography) — 중앙 지휘자 없는 춤. 각 서비스가 자기 일을 마치면 이벤트를 발행하고, 다른 서비스가 그 이벤트를 구독해 자기 단계를 이어갑니다. 중앙 조정자가 없습니다. 마치 무용수들이 지휘자 없이 서로의 동작에 반응해 춤을 이어가는 것과 같습니다.
주문 서비스: "주문 생성됨" 이벤트 발행
|
v (구독)
결제 서비스: 결제 처리 -> "결제 완료" 이벤트 발행
|
v (구독)
재고 서비스: 재고 감소 -> "재고 확정" 이벤트 발행
|
v (구독)
배송 서비스: 배송 예약
장점은 서비스 간 결합이 느슨하고, 새 서비스를 이벤트 구독만으로 끼워 넣기 쉽다는 것입니다. 단점은 전체 흐름이 여러 서비스에 흩어져 한눈에 파악하기 어렵다는 것입니다. "지금 이 주문이 어느 단계에 있지?"를 알려면 여러 서비스를 뒤져야 하고, 이벤트가 순환하거나 의도치 않게 얽히면 디버깅이 까다롭습니다.
오케스트레이션(orchestration) — 지휘자가 있는 연주. 중앙의 **오케스트레이터(orchestrator)**가 전체 Saga를 지휘합니다. 오케스트레이터가 "이제 결제해", 응답을 받고 "이제 재고 줄여"처럼 각 단계를 순서대로 호출하고, 실패하면 보상 단계들을 지시합니다.
┌───────────────── 오케스트레이터 ─────────────────┐
│ 1. 결제 서비스에 "청구" 요청 -> 성공 │
│ 2. 재고 서비스에 "감소" 요청 -> 실패! │
│ 3. 보상: 결제 서비스에 "환불" 요청 │
│ 4. 보상: 주문 서비스에 "취소" 요청 │
└──────────────────────────────────────────────────┘
장점은 전체 흐름이 한곳에 모여 이해하고 추적하기 쉽다는 것입니다. Saga의 상태를 오케스트레이터가 들고 있으니 "지금 어느 단계"인지 명확합니다. 단점은 오케스트레이터가 로직의 중심이 되어 복잡해지고, 잘못하면 다시 단일 병목이나 단일 실패점이 될 수 있다는 것입니다.
둘 사이 선택은 규모와 복잡도에 달렸습니다. 단계가 적고 단순하면 코레오그래피가 가볍고, 단계가 많고 흐름이 복잡하며 가시성이 중요하면 오케스트레이션이 관리하기 좋습니다.
이중 쓰기 문제 — 왜 아웃박스가 필요한가
Saga든 이벤트 기반 아키텍처든, 실무에서 반드시 부딪히는 함정이 하나 있습니다. 이중 쓰기(dual write) 문제입니다.
상황은 이렇습니다. 서비스가 어떤 작업을 하면 두 가지를 해야 할 때가 많습니다. (1) 자기 데이터베이스를 갱신하고, (2) 그 사실을 알리는 이벤트/메시지를 발행합니다. 문제는 이 두 가지가 서로 다른 시스템(로컬 DB와 메시지 브로커)이라 하나의 트랜잭션으로 묶이지 않는다는 것입니다.
위험한 순서:
1. DB에 주문 저장 (커밋 성공)
2. 카프카에 "주문 생성됨" 발행 <- 여기서 프로세스가 죽으면?
결과: DB에는 주문이 있는데, 이벤트는 발행 안 됨.
하류 서비스는 이 주문을 영영 모른다. (불일치!)
순서를 바꿔도 마찬가지입니다. 이벤트를 먼저 발행하고 DB 저장이 실패하면, 세상에는 "생성된 주문" 이벤트가 떠도는데 실제 DB에는 주문이 없습니다. 어느 쪽을 먼저 하든, 그 사이에 죽으면 DB 상태와 발행된 이벤트가 어긋납니다.
이 문제의 표준 해법이 **아웃박스 패턴(outbox pattern)**입니다.
아웃박스 패턴 — 하나의 로컬 트랜잭션으로 묶기
아웃박스 패턴의 핵심 아이디어는 이렇습니다. 발행하려는 이벤트를, 비즈니스 데이터와 같은 데이터베이스의 "아웃박스" 테이블에 같은 트랜잭션으로 함께 저장한다. 그러면 "데이터 변경"과 "이벤트 기록"이 하나의 로컬 트랜잭션 안에 들어가므로, 둘은 원자적으로 함께 커밋되거나 함께 실패합니다. 이중 쓰기가 단일 쓰기로 바뀌는 것입니다.
하나의 로컬 트랜잭션:
BEGIN
INSERT INTO orders (...) -- 비즈니스 데이터
INSERT INTO outbox (event, ...) -- 발행할 이벤트
COMMIT -- 둘 다 커밋되거나 둘 다 롤백
이후 별도의 프로세스가:
아웃박스 테이블을 폴링(또는 DB 로그 추적)
-> 새 이벤트를 메시지 브로커로 발행
-> 발행 성공하면 아웃박스에서 처리 표시/삭제
이벤트를 실제 브로커로 내보내는 방법은 두 가지가 흔합니다. 하나는 **폴링 발행자(polling publisher)**로, 별도 프로세스가 아웃박스 테이블을 주기적으로 읽어 아직 안 보낸 이벤트를 발행합니다. 다른 하나는 **트랜잭션 로그 추적(transaction log tailing / CDC)**으로, 데이터베이스의 변경 로그(예: PostgreSQL의 WAL)를 읽어 아웃박스에 삽입된 행을 감지해 발행합니다(Debezium 같은 도구가 이 방식입니다).
여기서 중요한 점: 아웃박스는 이벤트가 "적어도 한 번(at-least-once)" 발행되는 것을 보장하지, "정확히 한 번"을 보장하지는 않습니다. 발행 후 "처리 표시"를 하기 전에 발행자가 죽으면, 같은 이벤트가 다시 발행될 수 있습니다. 그래서 이벤트를 받는 쪽(소비자)은 반드시 멱등해야 합니다. 같은 이벤트를 두 번 받아도 결과가 같도록 말이지요. 이 멱등성과 중복 제거가 분산 시스템 신뢰성의 핵심인데, 이는 다음 글의 주제이기도 합니다.
결과적 일관성 — 실무에서 뜻하는 것
2PC를 포기하고 Saga와 아웃박스로 가면, 우리는 강한 일관성 대신 **결과적 일관성(eventual consistency)**을 받아들이는 것입니다. 이 말의 뜻을 오해 없이 정리합시다.
결과적 일관성은 "언젠가는 모든 복제본과 서비스의 상태가 일치한다"는 보장입니다. 단, 그 사이에는 불일치가 관찰될 수 있습니다. 결제는 처리됐지만 주문 상태가 아직 "결제 완료"로 안 바뀐 짧은 창이 존재할 수 있습니다. Saga가 아직 진행 중이거나, 이벤트가 아직 전파 중이기 때문입니다.
이것이 실무에서 의미하는 바는 구체적입니다.
- UI와 API가 중간 상태를 견뎌야 합니다. "처리 중입니다"라는 상태를 사용자에게 보여 주는 것이 정직하고 안전합니다. 즉시 최종 상태를 약속하지 마세요.
- 읽은 직후 자기 쓰기가 안 보일 수 있습니다. 방금 만든 것을 바로 조회했는데 아직 없을 수 있습니다("read-your-writes"가 기본 보장되지 않음). 필요하면 그 부분만 강한 일관성을 따로 확보해야 합니다.
- 비즈니스가 중간 상태를 정의해야 합니다. "결제는 됐는데 재고 확보 실패" 같은 상황에서 무엇을 사용자에게 보이고 어떻게 보상할지는 기술이 아니라 비즈니스 결정입니다.
핵심은, 결과적 일관성이 "일관성 없음"이 아니라 **"지연된 일관성"**이라는 것입니다. 이 지연을 인정하고 그 창을 설계에 반영하는 것이 분산 트랜잭션을 다루는 실전 감각입니다.
실무 지침 정리
지금까지의 내용을 압축합니다.
가능하면 분산 트랜잭션 자체를 피하세요. 하나의 트랜잭션으로 묶여야 하는 데이터가 있다면, 그것들을 같은 서비스·같은 데이터베이스에 두는 경계 설계가 최선입니다. 서비스를 나누는 선은 트랜잭션 경계를 존중해야 합니다.
정말 서비스를 가로질러야 한다면 2PC보다 Saga를 우선 고려하세요. 2PC의 블로킹과 단일 실패점은 마이크로서비스의 가용성 목표와 충돌합니다. Saga의 결과적 일관성과 보상이 대개 더 현실적입니다.
흐름이 단순하면 코레오그래피, 복잡하고 가시성이 중요하면 오케스트레이션. 단계가 늘고 실패 처리가 복잡해질수록 중앙 오케스트레이터의 추적 가능성이 값집니다.
이벤트를 발행한다면 아웃박스 패턴을 기본으로 쓰세요. 이중 쓰기 불일치는 조용히 데이터를 어긋나게 만드는 대표적 버그입니다. 아웃박스로 "데이터 변경과 이벤트 기록"을 하나의 로컬 트랜잭션으로 묶으세요.
소비자를 멱등하게 만드세요. 아웃박스든 어떤 브로커든 "적어도 한 번"이 현실입니다. 중복 수신을 전제로 설계하지 않으면 반드시 중복 처리 버그가 납니다.
마치며
단일 데이터베이스의 트랜잭션은 하나의 시스템이 모든 것을 통제하기에 강력했습니다. 시스템을 여러 서비스로 나누는 순간 그 통제가 흩어지고, 네트워크와 부분 실패가 끼어들며, "전부 또는 전무"를 그대로 재현하기가 근본적으로 어려워집니다.
이에 대한 두 갈래 해법이 2PC와 Saga입니다. 2PC는 원자성을 지키려다 블로킹과 단일 실패점을 감수하고, Saga는 원자성을 포기하는 대신 보상 트랜잭션과 결과적 일관성으로 높은 가용성을 얻습니다. 그리고 이벤트 기반 세계의 이중 쓰기 함정은 아웃박스 패턴으로 다스립니다. 그 밑바탕에는 언제나 멱등성이라는 전제가 깔려 있습니다.
결국 분산 트랜잭션은 "완벽한 원자성이라는 이상"과 "가용한 시스템이라는 현실" 사이의 선택입니다. 대부분의 경우 우리는 약간의 지연된 일관성을 받아들이고, 그 창을 설계로 감싸는 쪽을 택합니다. 그 트레이드오프를 의식적으로 다룰 때, 분산 시스템의 트랜잭션은 두려움이 아니라 다룰 수 있는 공학이 됩니다.
참고 자료
- Chris Richardson, "Pattern: Saga": https://microservices.io/patterns/data/saga.html
- Chris Richardson, "Pattern: Transactional outbox": https://microservices.io/patterns/data/transactional-outbox.html
- Hector Garcia-Molina & Kenneth Salem, "Sagas" (1987): https://www.cs.cornell.edu/andru/cs711/2002fa/reading/sagas.pdf
- Martin Kleppmann, "Designing Data-Intensive Applications" (9장, Consistency and Consensus)
- Debezium, "Outbox Event Router": https://debezium.io/documentation/reference/stable/transformations/outbox-event-router.html
Distributed Transactions: 2PC vs the Saga Pattern
- Introduction — When a Transaction Crosses Service Boundaries
- Why ACID Across Services Is Hard
- Two-Phase Commit (2PC) — Forcing Atomicity
- The Saga Pattern — Trading Atomicity for Compensation
- Choreography vs Orchestration
- The Dual-Write Problem — Why You Need an Outbox
- The Outbox Pattern — Bundling into One Local Transaction
- Eventual Consistency — What It Means in Practice
- Practical Guidance
- Wrapping Up
- References
Introduction — When a Transaction Crosses Service Boundaries
Inside a single database, a transaction is a trusty friend. Begin with BEGIN, end with COMMIT, and every change inside takes effect entirely or is entirely undone. But split the system into several services and that trust wobbles. Once the order service, payment service, and inventory service each own their own database, a single logical operation — "create an order, process a payment, decrement stock" — spans three physically separate databases.
Now the scary question returns: what if the payment succeeds but the stock decrement fails? There's no single COMMIT that finalizes all three databases at once. This is the distributed transaction problem, an unavoidable difficulty for microservices. This post contrasts the two classic approaches — two-phase commit and the Saga pattern — and lays out what we lose and gain in between.
If you want to see the messaging that connects to these ideas, you can visualize asynchronous message flow in this site's Message Queue Playground.
Why ACID Across Services Is Hard
A single-database transaction is powerful because one system controls all the data. One transaction manager takes locks, writes to a WAL, and finalizes the commit atomically. With everything under one roof, "all or nothing" is easy to guarantee.
Split across services and that premise breaks.
- There is no shared transaction manager. Each service's database only knows its own transactions. By default, there is no higher authority to bundle service A's commit with service B's commit into one.
- The network intrudes. Inter-service communication rides the network, which is slow, drops messages, delays them, reorders them, and above all disconnects. "The request went out but no response came back" leaves the sender unable to tell success from failure.
- Partial failure is routine. In a single process, it's closer to "the whole thing lives or the whole thing dies," but in a distributed system the in-between state where "some succeeded and some failed" is common.
Because of these three, reproducing across services the atomicity that was obvious in a single database is fundamentally hard. There are two branches of solution. One is two-phase commit — "implement atomicity anyway, by force." The other is Saga — "give up atomicity and go with eventual consistency instead."
Two-Phase Commit (2PC) — Forcing Atomicity
Two-phase commit (2PC) is the classic protocol for bundling the commits of multiple participants into one. At its center is a coordinator, and as the name says it proceeds in two phases.
Phase 1 — prepare (voting). The coordinator asks every participant, "are you ready to commit?" Each participant does not actually commit its changes, but makes every preparation so that it could commit (acquiring locks, writing logs) and votes "yes (prepared)" or "no." If it answered "yes," that participant must be able to commit later if the coordinator so orders.
Phase 2 — commit or abort. If all participants voted "yes," the coordinator orders everyone to commit. If even one said "no," or failed to respond, it orders everyone to abort.
Phase 1 (prepare):
coordinator --"ready?"--> participants
participants --"yes/no"--> coordinator
Phase 2 (commit/abort):
if all said "yes":
coordinator --"commit!"--> participants (all commit)
if any said "no"/no reply:
coordinator --"abort!"--> participants (all roll back)
Logically it's tidy. Since it commits only after confirming everyone is prepared, atomicity appears to hold. But 2PC has serious weaknesses.
The blocking problem. This is 2PC's biggest flaw. After a participant votes "yes (prepared)," if the coordinator dies right before issuing the phase-2 order, the participant is stranded. It has heard neither "commit" nor "abort," and it cannot decide on its own (it promised "yes," after all). This participant waits indefinitely, holding its locks, until the coordinator recovers. Other transactions blocked on those locks stall along with it.
Other failure modes. The prepare phase passes but the commit order reaches only some participants; a network partition splits the coordinator from some participants — there are many combinations of partial failure. Variants exist to address these (3PC and others), but none is a complete fix, and they only add complexity.
So 2PC gives atomicity, but at the cost of availability and performance. The coordinator becomes a single point of failure, and holding locks across the prepare-commit gap drops throughput. That's why 2PC is rarely used in microservice environments with many services and high latency. (Conversely, it's still used in specific settings like a well-controlled single data center or XA transactions.)
The Saga Pattern — Trading Atomicity for Compensation
If 2PC "keeps atomicity but tolerates blocking," the Saga pattern inverts the idea: give up atomicity, commit each step as an individual local transaction, and if something goes wrong, clean up with compensating transactions that undo what was already done.
The core concept is the compensating transaction. If a later step fails after an earlier step has committed, you run a separate transaction that "semantically cancels" what was already committed. Where a rollback undoes something not yet committed, a compensation reverses something already committed and reflected in the world.
Seen through an order example:
Happy path (each step commits independently):
1. create order (commit)
2. charge payment (commit)
3. decrement stock (commit)
4. reserve shipping (commit)
If step 3 fails on insufficient stock -> compensate in reverse:
compensate 2: refund payment
compensate 1: cancel order
Some important properties to note here:
- A compensation may not be a perfect inverse of the original operation. The compensation for "charge payment" is "refund," but a refund is not exactly identical to a state where the charge never happened (a transaction record remains, and fees may apply). Compensation is semantic undoing, not physical undoing.
- Intermediate state is exposed externally. While a Saga is in progress, other observers can see the in-between state "payment done but no shipping yet." Unlike 2PC's isolation, a Saga guarantees no isolation. So the application must be designed to tolerate such intermediate states.
- Compensations themselves can fail. So compensations must be retryable and idempotent, and in the worst case you need a path for human intervention.
By giving up atomicity and isolation, a Saga gains high availability without blocking and loose coupling between services. That's why the Saga has become the de facto standard in microservices.
Choreography vs Orchestration
There are broadly two ways to actually implement a Saga. The fork is: who decides "move to the next step?"
Choreography — a dance with no central conductor. When each service finishes its work it publishes an event, and other services subscribe to that event and carry on with their own step. There is no central coordinator. It's like dancers continuing a dance by reacting to each other's moves, with no conductor.
order service: publishes "order created" event
|
v (subscribe)
payment service: process payment -> publishes "payment done" event
|
v (subscribe)
inventory service: decrement stock -> publishes "stock confirmed" event
|
v (subscribe)
shipping service: reserve shipping
The upside is loose coupling between services, and it's easy to slot in a new service just by subscribing to events. The downside is that the whole flow is scattered across services and hard to grasp at a glance. To know "which step is this order at now?" you have to dig through several services, and if events loop or get tangled unintentionally, debugging is tricky.
Orchestration — a performance with a conductor. A central orchestrator conducts the whole Saga. The orchestrator calls each step in order — "now charge," get a response, "now decrement stock" — and if a step fails, directs the compensating steps.
┌───────────────── orchestrator ─────────────────┐
│ 1. ask payment service to "charge" -> success │
│ 2. ask inventory service to "decrement" -> fail!│
│ 3. compensate: ask payment service to "refund" │
│ 4. compensate: ask order service to "cancel" │
└─────────────────────────────────────────────────┘
The upside is that the whole flow lives in one place and is easy to understand and trace. Since the orchestrator holds the Saga's state, "which step it's at" is clear. The downside is that the orchestrator becomes the center of the logic and grows complex, and if done poorly it can become a single bottleneck or single point of failure again.
The choice between them depends on scale and complexity. When steps are few and simple, choreography is lighter; when steps are many, the flow is complex, and visibility matters, orchestration is easier to manage.
The Dual-Write Problem — Why You Need an Outbox
Whether Saga or any event-driven architecture, there is one trap you will inevitably hit in practice: the dual-write problem.
Here's the situation. When a service does some work, it often must do two things: (1) update its own database, and (2) publish an event/message announcing that fact. The problem is that these two are different systems (the local DB and the message broker) and cannot be bundled into one transaction.
Dangerous order:
1. save order to DB (commit succeeds)
2. publish "order created" to Kafka <- what if the process dies here?
Result: the order is in the DB, but the event was never published.
downstream services never learn of this order. (inconsistent!)
Reversing the order doesn't help. If you publish the event first and the DB save fails, an "order created" event is loose in the world while no order exists in the DB. Whichever you do first, dying in between leaves the DB state and the published event out of sync.
The standard solution to this problem is the outbox pattern.
The Outbox Pattern — Bundling into One Local Transaction
The core idea of the outbox pattern is this: store the event you want to publish into an "outbox" table in the same database as the business data, within the same transaction. Then "data change" and "event record" live inside one local transaction, so the two commit atomically together or fail together. The dual write becomes a single write.
One local transaction:
BEGIN
INSERT INTO orders (...) -- business data
INSERT INTO outbox (event, ...) -- event to publish
COMMIT -- both commit or both roll back
Then a separate process:
polls the outbox table (or tails the DB log)
-> publishes new events to the message broker
-> on successful publish, marks/removes them from the outbox
There are two common ways to push events out to the actual broker. One is a polling publisher, where a separate process periodically reads the outbox table and publishes not-yet-sent events. The other is transaction log tailing (CDC), which reads the database's change log (e.g., PostgreSQL's WAL) to detect rows inserted into the outbox and publish them (tools like Debezium work this way).
An important point here: the outbox guarantees that events are published at least once, not exactly once. If the publisher dies after publishing but before "marking as processed," the same event can be published again. So the receiving side (the consumer) must be idempotent — same result even if it receives the same event twice. This idempotency and deduplication are the heart of distributed-system reliability, which is also the subject of the next post.
Eventual Consistency — What It Means in Practice
When you give up 2PC and go with Saga and outbox, you are accepting eventual consistency in place of strong consistency. Let's state what this phrase means without misunderstanding.
Eventual consistency is the guarantee that "eventually the state of all replicas and services will agree." But in the meantime, inconsistency may be observed. There can be a short window where the payment is processed but the order's status hasn't flipped to "paid" yet — because the Saga is still in progress, or the event is still propagating.
What this means in practice is concrete:
- The UI and API must tolerate intermediate states. Showing the user a "processing" status is honest and safe. Don't promise a final state immediately.
- Your own write may not be visible right after reading. You might query something you just created and not find it yet ("read-your-writes" is not guaranteed by default). If you need it, secure strong consistency separately for just that part.
- The business must define the intermediate states. In a situation like "payment done but stock reservation failed," what to show the user and how to compensate is a business decision, not a technical one.
The key is that eventual consistency is not "no consistency" but "delayed consistency." Acknowledging this delay and reflecting that window in your design is the practical sense of handling distributed transactions.
Practical Guidance
Let's compress everything.
Avoid distributed transactions altogether when you can. If some data must be bundled into one transaction, the best design is a boundary that keeps it in the same service and same database. The lines along which you split services should respect transaction boundaries.
If you truly must cross services, prefer Saga over 2PC. 2PC's blocking and single point of failure conflict with the availability goals of microservices. Saga's eventual consistency and compensation are usually more realistic.
Choreography if the flow is simple; orchestration if it's complex and visibility matters. The more steps and the more complex the failure handling, the more valuable a central orchestrator's traceability becomes.
If you publish events, use the outbox pattern by default. Dual-write inconsistency is a classic bug that quietly drifts your data out of sync. Use an outbox to bundle "data change and event record" into one local transaction.
Make consumers idempotent. Whether outbox or any broker, "at least once" is the reality. If you don't design assuming duplicate delivery, you will inevitably get duplicate-processing bugs.
Wrapping Up
A single-database transaction was powerful because one system controlled everything. The moment you split the system into several services, that control scatters, the network and partial failure intrude, and reproducing "all or nothing" as-is becomes fundamentally hard.
The two branches of solution are 2PC and Saga. 2PC keeps atomicity but tolerates blocking and a single point of failure; Saga gives up atomicity but gains high availability through compensating transactions and eventual consistency. And the dual-write trap of the event-driven world is tamed by the outbox pattern — with idempotency always underlying it as a premise.
In the end, distributed transactions are a choice between "the ideal of perfect atomicity" and "the reality of an available system." In most cases we accept a little delayed consistency and wrap that window in design. When you handle that trade-off deliberately, transactions in distributed systems become not a fear but an engineering problem you can manage.
References
- Chris Richardson, "Pattern: Saga": https://microservices.io/patterns/data/saga.html
- Chris Richardson, "Pattern: Transactional outbox": https://microservices.io/patterns/data/transactional-outbox.html
- Hector Garcia-Molina & Kenneth Salem, "Sagas" (1987): https://www.cs.cornell.edu/andru/cs711/2002fa/reading/sagas.pdf
- Martin Kleppmann, "Designing Data-Intensive Applications" (Chapter 9, Consistency and Consensus)
- Debezium, "Outbox Event Router": https://debezium.io/documentation/reference/stable/transformations/outbox-event-router.html