사가 예시
Saga 패턴은 분산 애플리케이션의 일관성을 설정하고 여러 마이크로서비스 간의 트랜잭션을 조정하여 데이터 일관성을 유지하는 데 도움이 되는 오류 관리 패턴입니다. 이 프로젝트는 오케스트레이션 기반 사가 구현을 보여줍니다.
관련 저장소(10K+ LOC):
- https://github.com/minghsu0107/saga-purchase
- https://github.com/minghsu0107/saga-account
- https://github.com/minghsu0107/saga-product
- https://github.com/minghsu0107/saga-pb
다음 구성 요소를 포함하는 올인원 docker-compose 배포가 제공됩니다.
- Traefik - 외부 트래픽 라우팅 및 내부 grpc 로드 밸런싱을 담당하는 에지 프록시입니다.
- 계정 서비스 - 로그인, 가입, 인증, 토큰 관리를 처리하는 서비스입니다.
- 구매 서비스 - 구매를 생성하고 각 사가 단계의 결과를 스트리밍하는 서비스입니다.
- 거래 서비스
- 상품 서비스 - 상품을 생성하고 확인하는 서비스입니다. 제품 재고를 업데이트합니다.
- 주문 서비스 - 주문을 생성하고 조회하는 서비스입니다.
- 결제 서비스 - 결제를 생성하고 조회하는 서비스입니다.
- 오케스트레이터 서비스 - 상태 비 저장 사가 오케스트레이터.
- 로컬 데이터베이스
- 계정 데이터베이스(MySQL 8.0)
- 제품 데이터베이스(MySQL 8.0)
- 결제 데이터베이스(MySQL 8.0)
- 주문 데이터베이스(MySQL 8.0)
- 6노드 Redis 클러스터
- 계정, 제품, 주문 및 결제를 위한 메모리 내 캐시로 사용됩니다.
- 캐시 침투 방지를 위한 블룸/뻐꾸기 필터( Redis Bloom 사용 )
- 가능한 문제: 데이터베이스를 쿼리하기 전에 항상 Bloom/Cuckoo 필터를 확인하므로 Redis와 MySQL 간의 데이터 일관성에 대해 크게 답변합니다. 그러나 필터 업데이트가 실패하거나 Redis에서 필터를 제거하면 캐시와 스토리지 간에 데이터 상태가 일관되지 않아 가양성 쿼리가 발생합니다.
- 해결 방법: 블룸 필터 쿼리를 다른 서비스로 분리하고 메시지 브로커(예: Kafka)를 사용하여 최소 한 번 전달(TODO)을 보장하기 위해 모든 데이터 수정 이벤트를 처리합니다.
- 캐시 사태를 방지하기 위한 분산 잠금 장치
- 로컬 캐시 무효화를 위한 게시/구독으로 사용됩니다.
- 실시간 구매 결과를 얻기 위한 스트리밍 플랫폼입니다.
- 가시성
- Prometheus - 모든 서비스에서 측정항목을 가져옵니다.
- Jaeger - 서비스 경계에 걸쳐 추적 범위를 보존하고 쿼리합니다.
- NATS 스트리밍 - saga 명령 및 이벤트에 대한 메시지 브로커입니다.
다음 다이어그램은 아키텍처에 대한 간략한 개요를 보여줍니다.
![](https://blog.kakaocdn.net/dn/dKwac9/btsCtditVVK/WppXpjD500GfLH4E0rbBu1/img.png)
이 다이어그램에는 캐시 데이터 흐름, 블룸 필터 및 로컬 데이터베이스가 생략되어 있습니다.
용법
docker-compose v1을 통해 모든 서비스를 로컬로 실행하려면 다음을 실행하세요.
./run.sh run
그러면 모든 서비스와 Docker 컨테이너의 복제본이 부트랩됩니다.
모든 서비스를 중지하려면 다음을 실행합니다.
./run.sh stop
계정 서비스
먼저, 새로운 사용자를 등록해야 합니다:
curl -X POST localhost/api/account/auth/signup \
--data '{"password":"abcd5432","firstname":"ming","lastname":"hsu","email":"ming@ming.com","address":"taipei","phone_number":"1234567"}'
사용자 계정 로그인:
curl -X POST localhost/api/account/auth/login \
--data '{"email":"ming@ming.com","password":"abcd5432"}'
그러면 새 토큰 쌍(새로 고침 토큰 + 액세스 토큰)이 반환됩니다. Authorization인증을 통해 해당 API의 헤더 에 액세스 토큰을 제공해야 합니다 .
새로 고침 토큰으로 새로 고쳐서 새 토큰 쌍을 얻을 수 있습니다.
curl -X POST localhost/api/account/auth/refresh \
--data '{"refresh_token":"<refresh_token>"}'
사용자 개인 정보 얻기:
curl localhost/api/account/info/person -H "Authorization: bearer <access_token>"
사용자 개인 정보 업데이트:
curl -X PUT localhost/api/account/info/person -H "Authorization: bearer <access_token>" \
--data '{"firstname":"newfirst","lastname":"newlast","email":"ming3@ming.com"}'
사용자 배송 정보 가져오기:
curl localhost/api/account/info/shipping -H "Authorization: bearer <access_token>"
사용자 배송 정보 업데이트:
curl -X PUT localhost/api/account/info/shipping -H "Authorization: bearer <access_token>" \
--data '{"address":"japan","phone_number":"54321"}'
제품 서비스
다음으로 몇 가지 새로운 제품을 만들어 보겠습니다.
curl -X POST localhost/api/product \
--data '{"name": "product1","description":"first product","brand_name":"mingbrand","price":100,"inventory":1000}'
curl -X POST localhost/api/product \
--data '{"name": "product2","description":"second product","brand_name":"mingbrand","price":100,"inventory":10}'
API는 생성된 제품의 ID를 반환합니다.
페이지 매김이 있는 모든 제품을 나열합니다.
curl "localhost/api/products?offset=0&size=100"
ID, 이름, 가격, 현재 재고를 포함한 제품 카탈로그 목록이 반환됩니다.
제품 세부정보 확인:
curl "localhost/api/product/<product_id>"
그러면 쿼리된 제품의 이름, 설명, 브랜드, 가격 및 캐시된 재고가 반환됩니다.
구매 서비스
여기에 핵심 부분이 있습니다. 우리는 새로운 구매 이벤트를 사가 오케스트레이터에 보내고 분산 트랜잭션을 트리거하는 새로운 구매를 생성할 것입니다. 성공하면 새 구매의 ID가 반환됩니다.
curl -X POST localhost/api/purchase -H "Authorization: bearer <access_token>" \
--data '{"purchase_items":[{"product_id":<product_id>,"amount":1}],"payment":{"currency_code":"NT"}}'
구매를 생성한 후 구독하여 실시간 거래 결과를/api/purchase/result 받을 수 있습니다 . 구매 서비스는 서버 전송 이벤트(SSE)를 사용하여 결과를 푸시합니다 . 다음 코드 예제에서는 Javascript를 사용하여 서버에서 보낸 이벤트를 구독하는 방법을 보여줍니다. 이 라이브러리를 사용하여 헤더 와 함께 SSE 요청을 보냅니다 .Authorization
var script = document.createElement('script');script.src = "https://unpkg.com/event-source-polyfill@1.0.9/src/eventsource.js";document.getElementsByTagName('head')[0].appendChild(script);
var es = new EventSourcePolyfill('http://localhost/api/purchase/result', {
headers: {
'Authorization': 'bearer <access_token>'
},
});
var listener = function (event) {
var data = JSON.stringify(event.data);
console.log(data);
};
es.addEventListener("data", listener);
구독이 성공하면 다음과 같은 실시간 결과를 받게 됩니다.
![](https://blog.kakaocdn.net/dn/df8TQU/btsCyooQxMa/kkwmOfbMphC62yUlgMSh11/img.png)
주문 서비스
다음으로 주문이 성공적으로 생성되었는지 확인할 수 있습니다.
curl "localhost/api/order/<payment_id>" -H "Authorization: bearer <access_token>"
결제 서비스
마지막으로 결제가 성공적으로 생성되었는지 확인할 수 있습니다.
curl "localhost/api/payment/<payment_id>" -H "Authorization: bearer <access_token>"
관찰 가능성
Prometheus 측정항목을 노출하고 추적 범위를 보내도록 모든 서비스를 구성할 수 있습니다. 기본적으로 모든 서비스에는 prometheus 지표 엔드포인트가 port 에 노출되어 있습니다 8080. JAEGER_URL분산 추적의 경우 환경 변수를 Jaeger 컬렉션 엔드포인트 로 설정하여 간단히 활성화할 수 있습니다 .
모든 서비스가 활성 상태인지 확인하려면 Prometheus( )를 방문하세요 http://localhost:9090/targets.
![](https://blog.kakaocdn.net/dn/wpGif/btsCyKLX3IT/b7KTmadgWsWZBXJAZVeTk0/img.png)
Jaeger 웹 UI를 방문하세요 http://localhost:16686. Traefik에서 시작하여 API 호출 체인의 모든 추적 범위를 확인할 수 있습니다. 예를 들어 다음 그림은 를 쿼리하는 요청을 보여줍니다 /api/order/<order_id>. auth.AuthService.Auth주문 서비스는 요청을 받으면 계정 서비스에서 제공하는 gRPC 인증 API인 를 호출하여 먼저 요청을 인증하는 것을 볼 수 있습니다 . 인증이 성공하면 주문 서비스는 계속해서 요청을 처리합니다. 전체 주문을 받기 위해 주문 서비스에서는 다른 gRPC 호출을 통해 구매한 제품의 세부정보를 제품 서비스에 요청합니다 product.ProductService.GetProducts.
![](https://blog.kakaocdn.net/dn/bmGzCQ/btsCwLZA8jp/8thb3G4iMWhSZVLoZIH4fK/img.png)
좀 더 복잡한 예를 살펴보겠습니다. 이 그림은 새로운 구매를 생성한 후 거래 서비스가 서로 상호 작용하는 방식을 보여줍니다. 인증 프로세스는 이전 예와 유사합니다. CreatePurchaseCmd구매 서비스는 요청을 성공적으로 인증한 후 메시지 브로커에 이벤트를 게시합니다 . 그런 다음 Orchestrator 서비스는 이벤트를 수신하고 saga 트랜잭션을 시작합니다. 다음 다이어그램은 스트리밍 결과 및 Redis 작업의 추적을 포함하여 단일 구매의 모든 관련 추적을 보여줍니다.
![](https://blog.kakaocdn.net/dn/TbMeg/btsCtGrgqtC/E7qMJpVMjodZGMvvfoNbrk/img.png)
각 트랜잭션 서비스는 이벤트를 게시하기 전에 이벤트에 현재 범위 컨텍스트를 추가합니다. 구독자가 새 이벤트를 수신하면 이벤트 페이로드에서 범위 컨텍스트를 추출합니다. 이렇게 추출된 범위는 현재 범위의 상위 범위가 됩니다. 이렇게 하면 모든 트랜잭션에 걸쳐 전체 게시/구독 호출 체인을 생성할 수 있습니다.
또한 Jaeger는 범위에 대한 서비스 토폴로지를 생성합니다. 다음 그림은 클라이언트가 새 구매를 생성할 때의 토폴로지를 보여줍니다.
![](https://blog.kakaocdn.net/dn/bldM3J/btsCuNDEhmP/AmUSyioCciq0uylB7WqMHK/img.png)