포스트

ParityPay로 검증하는 결제 정합성 11 - 지터는 언제 값을 하는가: 봉우리를 만들어야 보이는 것

엔지니어링 요약

Problem

9편에서 '재시도 3회는 기관 요청을 정확히 3.00배로 만들었고 지터는 봉우리를 낮추지 못했다'로 끝냈다. 그러면서 지터가 값을 하는 조건은 동기화된 폭주일 텐데 그 부하에는 펼칠 봉우리가 없었다고 적어 뒀다. 가설을 적어 두고 재지 않은 것이다.

Decision

폭주를 만들었다. 결제 150건을 같은 순간에 쏘고 기관이 클라이언트 타임아웃보다 오래 붙잡게 하면 150건이 같은 순간에 타임아웃되고 재시도도 같은 순간에 나간다. 타임아웃이 동기화 장치다. 그리고 9편의 비교가 '즉시 재시도 vs 지수+지터'라 백오프 효과와 지터 효과가 섞여 있었으므로, 일정이 같고 지터만 다른 EXPONENTIAL을 대조군으로 세웠다.

Result

지터가 재시도 봉우리를 100ms당 141건에서 38건으로 3.7배 낮췄다. 총량은 두 조건 모두 정확히 3.00배, 재시도도 둘 다 300건으로 같다. 지터는 기관이 받는 요청 수를 줄이지 않고 도착 시각만 바꾼다. 그리고 같은 데이터를 1초 눈금으로 보면 두 조건이 똑같이 150이다 - 9편이 쓴 눈금이 현상보다 굵었다.

9편은 지터(재시도 대기 시간에 섞는 난수)가 총량을 줄이지 않았고 봉우리도 낮추지 못했다고 적은 뒤, 그 이유를 이렇게 추정했다.

지터가 값을 하는 조건은 동기화된 폭주, 장애 복구 직후 수천 클라이언트가 같은 순간에 재시도하는 것인데, 이 부하는 초당 10건이 고르게 도착하는 것이라 펼칠 봉우리가 없었다.

가설을 적어 두고 재지 않은 것이다. 이 글이 그 측정이다.

9편의 실험에는 문제가 두 개 있었다

첫째, 봉우리(짧은 시간 칸 하나에 몰린 요청 수의 최대치)가 없었다. 초당 10건이 고르게 도착하면 재시도도 고르게 나간다. 지터가 할 일이 없다.

둘째, 비교가 두 변화를 섞었다. 9편은 “즉시 재시도”와 “지수+지터”를 견줬다. 그 둘 사이에는 백오프(재시도 사이 대기 시간을 늘려 가는 것)를 넣은 변화와 지터를 넣은 변화가 둘 다 들어 있다. 지터의 값을 알고 싶으면 일정이 같고 지터만 다른 짝이 필요하다.

코드에는 쓰이지 않던 EXPONENTIAL이 있었다. 그것이 대조군이다.

조건백오프지터
no-retry--
retry-3-exponential0.5s · 1s · 2s없음
retry-3-jitter같음× random()

폭주를 만드는 법

결제 150건을 같은 순간에 출발시킨다. 기관은 HANG_BEFORE_PROCESSING으로 8초 붙잡고, 클라이언트 타임아웃은 3초다. 그러면 150건이 같은 순간에 타임아웃되고, 재시도도 같은 순간에 나간다.

동기화 장치는 내가 만든 것이 아니라 타임아웃 자체다. 같은 순간에 시작한 요청들이 같은 지연을 겪으면 같은 순간에 실패한다. 실제 장애에서 벌어지는 일도 이것이다.

출발 신호는 threading.Barrier가 아니라 Event로 줬다. Barrier는 한 스레드가 예외로 죽으면 나머지를 깨뜨리고, 그때 남는 것은 일부만 출발한 폭주인데 그것도 폭주처럼 보인다. 예전에 같은 실수를 한 적이 있어 ADR로 남겨 뒀다.

판정은 봉우리다. 기관 도착을 100ms 칸으로 세고, 발사 후 1초 이내의 첫 물결은 제외한다. 그 물결은 클라이언트가 만든 것이고 세 조건에서 같으므로, 지터는 그 뒤에서만 읽을 수 있다.

결과

3회 중앙값이다.

재시도기관 요청배수재시도 요청봉우리 /100ms봉우리 /1s클라이언트 p50
없음1501.00×0003,194 ms
3회 지수4503.00×30014115010,752 ms
3회 지수+지터4503.00×300381509,932 ms

실행별 봉우리는 지수가 141 / 150 / 121, 지터가 39 / 36 / 38이다. 흔들림이 작다.

지터가 봉우리를 3.7배 낮췄다. 9편의 음성 결과는 지터에 대한 사실이 아니라 그 부하에 대한 사실이었다.

지터가 하지 않는 일

총량은 두 조건 모두 정확히 3.00배이고 재시도 요청도 둘 다 300건이다. 지터는 기관이 받는 요청 수를 하나도 줄이지 않는다.

그것을 줄이는 방법은 하나뿐이다. 재시도하지 않는 것. 그래서 이 프로젝트는 승인 재시도를 금지하고(ADR-007) UNKNOWN과 상태 조회를 쓴다. 재시도는 이미 아픈 기관에 세 배의 부하를 얹는 일이고, 지터는 그 세 배를 덜 폭력적으로 만들 뿐 정당화하지 않는다.

사용자가 겪는 것도 그대로다. 재시도가 없으면 3.2초 뒤 202, 있으면 10초 뒤 같은 202다.

1초 눈금으로 보면 아무 일도 안 일어난다

표의 봉우리 /1s 열을 보면 두 조건이 똑같이 150이다.

첫 재시도의 지터 창이 [0, 0.5초]이기 때문이다. 1초 창 안에서 펼쳐진 것은 1초 눈금에 보이지 않는다. 같은 데이터, 같은 실행인데 눈금 하나 바꾸면 “3.7배 개선”이 “차이 없음”이 된다.

9편이 쓴 지표가 “기관 초당 최대”였다. 눈금이 현상보다 굵으면 효과 없음이 나온다. 그게 9편의 결론에 기여한 몫이 얼마인지는 이제 알 수 없지만, 적어도 그 실험은 지터가 하는 일을 볼 수 없는 자로 재고 있었다.

그래서 어떤 현상을 찾을 때는 그 현상의 시간 폭보다 고운 눈금을 먼저 골라야 한다. 백오프 창이 0.5초면 100ms로 본다.

지터는 평균 지연도 줄인다

이 구현의 백오프는 (500 << (n-1)) * random()이다. Marc Brooker가 full jitter라고 부른 형태(sleep = random(0, min(cap, base * 2 ** attempt)), Exponential Backoff And Jitter)이고, 0과 기준 사이에서 고르게 뽑으므로 기댓값이 기준의 절반이다.

클라이언트 p50이 10,752 → 9,932ms로 낮아진 것이 그 몫이다. 그러면 봉우리 감소분 중 일부는 “펼쳐서”가 아니라 “일정이 통째로 짧아져서”다. 둘을 분리하지 않았으므로 3.7배 전부를 펼침의 공으로 돌리면 안 된다.

이중 청구는 여기서 문제가 되지 않았다

승인 기록이 세 조건 모두 0건이다. HANG_BEFORE_PROCESSING이라 기관이 처리 전에 응답을 끊었고, 돈이 움직이지 않았다.

돈이 움직인 뒤 응답이 유실되는 경우는 9편의 HANG_AFTER_PROCESSING이고, 거기서 승인 기록이 요청 수와 같았던 것은 기관이 외부 키로 멱등했기 때문이다. 그 멱등성이 없는 기관이었으면 한 결제에 세 번 청구였다. 우리 쪽에서 막을 방법은 재시도하지 않는 것뿐이다.

틀렸던 것 다섯 가지

이번에는 실수가 두 갈래였다.

실행이 성공한 얼굴로 아무것도 안 잰 것 셋:

1. merchantId에 MERCHANT-001을 넣었다. 이 필드는 UUID다. 결제 150건이 전부 500으로 끝났고 기관은 한 건도 못 봤는데, 스크립트는 종료 코드 0으로 “기관 요청 0건, 배수 0.0”을 적고 끝났다. 0으로 채워진 표는 “재시도가 효과 없음”처럼 읽힌다.

2. 컨테이너 로그 시각을 지역시로 읽었다. 컨테이너는 UTC로 찍고 끝에 Z를 다는데, 앞 23자만 자르면 그 Z가 떨어져 나간다. KST에서 9시간이 어긋나 히스토그램과 재시도 창이 전부 비었다. 도착 시각끼리만 비교하는 값은 오차가 상쇄돼 멀쩡했고, 그래서 9편의 수치는 영향이 없다.

3. 기관이 죽은 채로 돌았다. 뒤의 4번 때문에 기관이 OOM(메모리 부족)으로 죽었는데, 그 상태로도 실행은 “기관 요청 0건”으로 조용히 끝난다.

셋 다 같은 모양이라, 기관이 트래픽을 봤고 202가 하나라도 나왔을 때만 실행을 valid로 표시하게 했다.

하네스가 자기 환경을 망가뜨린 것 둘:

4. 기관 붙잡기를 20초로 뒀다. 붙잡힌 요청 하나가 서블릿 스레드 하나를 그 시간 내내 쥔다. 재시도로 물결이 셋이라 450개가 동시에 살아 있었고, 변형을 넘어가며 쌓여서 기관 컨테이너가 OOM으로 죽었다(Exited 137). 8초로 줄이고 변형마다 기관을 재시작한다.

5. finally에서 기관 복구를 앱 종료보다 먼저 했다. 기관이 죽어 있으면 그 호출이 예외를 내고 app.stop()이 실행되지 않는다. pay-api가 포트를 쥔 채 남아서 이후 모든 실행이 “Port 18081 was already in use”로 죽었다. 한 번의 실패가 환경을 오염시켜 그 뒤를 전부 망가뜨렸고, 증상은 원인과 전혀 닮지 않았다.

한계

  • 폭주가 한 종류다. 150건이 같은 순간에 출발하는 것이고, 실제 복구 직후의 폭주는 재연결·세션 복구가 섞여 모양이 다르다.
  • 지터 창이 짧다. 첫 재시도가 [0, 0.5초]다. full jitter를 더 큰 기준에 걸면 결과가 달라진다.
  • 펼침과 일정 단축을 분리하지 않았다. full jitter는 평균도 줄이므로 3.7배가 전부 펼침은 아니다.
  • 기관이 요청당 스레드를 쥐는 구현이다. 비동기 기관이라면 같은 봉우리가 커넥션이나 큐에 걸린다.
  • 3회다. 봉우리가 재현된다고 말할 만큼이고 분포를 말할 만큼은 아니다.

정리

  • 9편의 음성 결과는 지터에 대한 사실이 아니라 그 부하에 대한 사실이었다. 폭주를 만들자 지터가 봉우리를 141에서 38로 낮췄지만, 요청 총량은 그대로 3.00배였다.
  • 같은 데이터가 1초 눈금에서는 차이가 없다. 9편의 지표는 지터가 하는 일을 볼 수 없는 눈금이었다.
  • full jitter는 평균 지연도 줄이므로 3.7배를 전부 펼침의 공으로 돌릴 수 없다. 백오프와 지터를 분리하려면 일정이 같은 짝이 필요했고, 펼침과 일정 단축을 분리하는 짝은 아직 없다.

참고

  1. 1 ParityPay로 검증하는 결제 정합성 1 - 장애가 나도 지켜야 할 금융 불변조건 여섯 가지
  2. 2 ParityPay로 검증하는 결제 정합성 2 - 잠금을 필요 이상으로 오래 쥐고 있었다: 추론을 측정으로 바꾼 기록
  3. 3 ParityPay로 검증하는 결제 정합성 3 - 외부 승인 응답이 사라졌을 때: 타임아웃은 실패가 아니다
  4. 4 ParityPay로 검증하는 결제 정합성 4 - 실험 27종이 찾아낸 결함 12건: 문서와 코드를 읽어서 나온 것은 하나도 없었다
  5. 5 ParityPay로 검증하는 결제 정합성 5 - DB 커밋과 Kafka 발행 사이: Transactional Outbox와 at-least-once 소비자
  6. 6 ParityPay로 검증하는 결제 정합성 6 - 대사: "기관에 물어보지 못했다"와 "기관에 기록이 없다"는 다른 상태다
  7. 7 ParityPay로 검증하는 결제 정합성 7 - 애플리케이션 코드를 믿지 않는 원장: 이중부기를 계정 체계, DB 제약, 속성 테스트로 강제하기
  8. 8 ParityPay로 검증하는 결제 정합성 8 - Kafka에서 중복·유실·순서 역전을 직접 만들어 보기: 멱등 소비자와 Outbox가 막는 것과 못 막는 것
  9. 9 ParityPay로 검증하는 결제 정합성 9 - 느린 기관 앞에서 결제 서버를 지키기: 타임아웃, 재시도, 차단기, 리미터, 벌크헤드가 각각 무엇을 막는가
  10. 10 ParityPay로 검증하는 결제 정합성 10 - 락 lease가 트랜잭션보다 먼저 끝나면 정말 정합성이 깨지는가: SETNX 락, Watchdog, fencing token, 그리고 락 없는 구조의 실측
  11. 11 ParityPay로 검증하는 결제 정합성 11 - 지터는 언제 값을 하는가: 봉우리를 만들어야 보이는 것
  12. 12 ParityPay로 검증하는 결제 정합성 12 - 실험 41종이 못 밟는 경로를 모델 검사가 5단계 만에 찾았다
결제·정산 정합성 장애 대응과 관측
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.
고쳐 쓴 기록 1번 수정 ·
  1. docs(notes): quote parity-pay 9 as written in parity-pay 11

댓글

아직 댓글이 없습니다