순서와 격리는 같은 자원을 반대로 당긴다 - 핫 키가 워커 하나를 넘는 순간 세 설계가 갈라지는 지점
엔지니어링 요약
Problem
같은 키의 순서를 지키려면 그 키를 한 곳에 모아야 하고, 느린 것을 격리하려면 다른 곳으로 보내야 한다. 리뷰를 쓰면서 이 긴장을 여러 번 만났는데, 어느 지점에서 실제로 갈라지는지와 각 설계가 무엇을 얼마나 내주는지는 재 본 적이 없었다.
Decision
워커 8개에 같은 도착 스트림을 주고 디스패치 방식만 셋으로 바꿔 쟀다. 공유 큐, 키 해시 파티션, 파티션 + 오래 기다린 큐를 쪼개 훔치기. 핫 키 하나의 부하를 워커 0.4개·1.0개·2.0개분으로 스윕해 갈라지는 지점을 찾고, 작업 비용에 ±50% 지터를 넣어 동시에 도는 같은 키 항목이 실제로 뒤집힐 수 있게 했다. 측정값은 순서 위반 수와 핫 키가 아닌 키들의 지연이다.
Result
핫 부하 0.4까지는 셋이 사실상 같았다(피해자 p99 9.6·116.6·109.4ms, 위반 전부 0). 1.0을 넘자 파티션의 피해자 p99가 4,430ms, 2.0에서 22,894ms로 발산했고 순서 위반은 아홉 번 실행 내내 0이었다. 훔치기는 그 지연을 507ms로 45배 줄이면서 위반 188건을 만들었고 항목 1,394개를 옮겼다. 공유 큐는 모든 부하에서 p99 10ms를 유지했고 위반은 0·0·56이었다. 순서 위반은 전략의 성질이 아니라 한 키의 도착률이 처리율을 넘는지의 함수였다.
카카오뱅크 알림 플랫폼 리뷰에서 “순서와 격리는 같은 자원을 두고 반대 방향으로 당긴다”고 썼다. monticker의 파티션 실험에서는 핫 종목이 같은 파티션 이웃을 p99 13초로 함께 밀어내는 것을 봤다. 두 번 다 현상을 확인했지만 어느 지점에서 갈라지는지는 재지 않았다. 이 글이 그 측정이다.
저장소는 data-ops-lab이고 재실행은 한 줄이다.
1
./.venv/bin/python experiments/ordering/run.py --seconds 20 --repeat 3
세 가지 설계
워커 8개에 같은 도착 스트림을 주고 나눠 주는 방식만 바꾼다.
| 설계 | 동작 | 기대 |
|---|---|---|
| shared | 큐 하나, 아무 워커나 집는다 | 격리 최대, 순서 보장 없음 |
| partition | 키 해시로 큐를 정하고 큐마다 워커 하나 | 키별 순서 보장, 이웃이 인질 |
| steal | 파티션 + 500ms 넘게 기다린 큐를 쪼개 유휴 워커에게 | 격리 회복, 순서는? |
키 200개 중 하나가 핫이다. 보통 항목은 5ms, 핫 항목은 100ms의 IO 대기이고 비용에 ±50% 지터를 줬다. 지터가 없으면 같은 키 항목이 동시에 돌아도 같은 시간이 걸려 순서가 유지되고, 공유 큐가 실제보다 얌전해 보인다.
핫 키의 부하를 워커 몇 개분인지로 스윕했다. 도착률 400/s에서 핫 비율 1%면 초당 4건 × 100ms = 워커 0.4개분이다.
결과 (3회 중앙값)
| 핫 부하 | 설계 | 순서 위반 | 훔친 항목 | 피해자 p50 | 피해자 p95 | 피해자 p99 |
|---|---|---|---|---|---|---|
| 0.4 | shared | 0 | 0 | 6.2 ms | 8.9 | 9.6 ms |
| 0.4 | partition | 0 | 0 | 8.1 | 28.3 | 116.6 |
| 0.4 | steal | 0 | 0 | 8.1 | 22.9 | 109.4 |
| 1.0 | shared | 0 | 0 | 6.1 | 8.9 | 9.6 |
| 1.0 | partition | 0 | 0 | 8.3 | 2,257.4 | 4,430.4 |
| 1.0 | steal | 26 | 192 | 8.4 | 274.4 | 439.9 |
| 2.0 | shared | 56 | 0 | 6.2 | 8.9 | 10.0 |
| 2.0 | partition | 0 | 0 | 8.2 | 11,770.2 | 22,894.5 |
| 2.0 | steal | 188 | 1,394 | 9.1 | 408.1 | 506.9 |
워커 하나를 넘기 전에는 아무 차이가 없다
핫 부하 0.4에서 셋의 피해자 p99는 9.6, 116.6, 109.4ms다. 순서 위반은 전부 0이다.
이것이 첫 번째 결론이다. 한 키가 워커 하나보다 적게 쓰는 동안에는 디스패치 설계를 고민할 이유가 없다. 설계 논쟁이 의미를 갖는 것은 그 임계를 넘는 순간부터다.
파티션은 정확히 그 지점에서 발산한다
1
2
3
핫 부하 0.4 → 피해자 p99 116.6 ms
핫 부하 1.0 → 피해자 p99 4,430.4 ms
핫 부하 2.0 → 피해자 p99 22,894.5 ms
핫 키를 담당하는 파티션은 워커 하나다. 그 키가 워커 하나분보다 많이 필요해지는 순간 그 큐는 영원히 따라잡지 못하고, 큐에 같이 들어 있는 무관한 키들이 그 뒤에 줄을 선다.
여기서 중요한 것은 피해자가 핫 키가 아니라는 점이다. 이 키들은 5ms짜리 일이고 도착률도 낮다. 자기 잘못이 없는데 해시가 겹쳤다는 이유로 22초를 기다린다. monticker에서 “파티션이 격리 단위이자 운명 공동체”라고 적은 것이 이 표다.
그리고 파티션은 아홉 번 실행 내내 순서 위반 0이었다. 그것을 사려고 낸 값이 위 지연이다.
훔치기는 지연을 45배 줄이고 순서를 판다
1
2
3
핫 부하 2.0
partition 피해자 p99 22,894.5 ms 위반 0
steal 피해자 p99 506.9 ms 위반 188 (항목 1,394개 이동)
큐를 쪼개 유휴 워커에게 넘기면 이웃이 풀려난다. 그 순간 그 큐 안의 순서가 사라진다. 앞부분은 원래 워커가, 뒷부분은 훔친 워커가 동시에 처리하므로 완료 순서가 뒤집힌다.
카카오뱅크 원문이 “동일 사용자의 연속된 요청은 순서 보장이 필요할 수 있다”를 미래 요구사항으로 적어 둔 지점이 정확히 여기다. 훔치기는 순서 요구가 없을 때만 자유롭게 할 수 있고, 생기는 순간 “같은 키는 나누지 않는다”는 제약이 붙으며, 그러면 그 키의 큐는 다시 head-of-line이 된다.
공유 큐의 청구서는 늦게 온다
공유 큐는 모든 부하에서 피해자 p99 9.6~10.0ms로 가장 좋다. 그런데 순서 위반이 0, 0, 56이다.
이 세 숫자가 이 실험에서 가장 배울 것이 많았다. 공유 큐는 “순서가 없는” 설계가 아니다. 한 키의 항목이 동시에 두 개 이상 처리되지 않는 한 순서는 유지된다. 뒤집히려면 그 키가 처리되는 속도보다 빨리 도착해야 한다.
그리고 그 임계는 파티션이 발산하는 임계와 같은 값이다. 한 키가 워커 하나분을 넘는 순간, 파티션에서는 큐가 자라고 공유 큐에서는 순서가 깨진다. 같은 사실을 양쪽에서 본 것이다.
그래서 “공유 큐는 순서를 보장하지 않으니 못 쓴다”는 판단은 절반만 맞다. 정확한 문장은 이렇다. 어떤 키의 도착률이 처리율을 넘지 않는다면 공유 큐도 순서를 지킨다. 그 조건을 아는지 모르는지가 설계 판단을 가른다.
고르는 기준
측정에서 나오는 기준은 단순하다.
- 어떤 키도 워커 하나분을 넘지 않는가? 넘지 않으면 무엇을 써도 된다. 공유 큐가 가장 단순하고 가장 빠르다.
- 넘는 키가 있고 순서가 필요한가? 파티션이 순서를 주지만 그 키의 이웃이 대가를 낸다. 이웃을 줄이려면 파티션을 늘려야 하고, 그러면 워커도 늘어야 한다.
- 넘는 키가 있는데 순서는 필요 없는가? 공유 큐다. 파티션으로 격리 문제를 만들 이유가 없다.
- 둘 다 필요한가? 훔치기는 답이 아니다. 지연을 사고 순서를 판다. 진짜 답은 그 키를 더 잘게 쪼개는 것(순서 단위를 사용자에서 요청으로 낮추기)이거나 그 키의 처리 비용을 줄이는 것이다.
4번이 실무에서 가장 자주 마주치는 자리이고, 디스패치 설계로는 해결되지 않는다는 것이 이 표의 결론이다. ParityPay 5편에서 “한 Aggregate에 몰린 적체는 초당 26.6건이 상한”이라고 적은 것도 같은 벽이었다.
측정에서 고친 것 둘
작업 비용이 전부 같아서 공유 큐가 얌전해 보였다. 첫 실행에서 공유 큐의 순서 위반이 0이었다. 같은 키 항목이 동시에 돌아도 비용이 같으면 시작 순서대로 끝나기 때문이다. 실제 핸들러는 그렇지 않으므로 ±50% 지터를 넣었고, 그제서야 핫 부하 2.0에서 56건이 나왔다. 지터가 없는 부하는 실제보다 순서가 잘 지켜지는 것처럼 보인다.
조건이 하나뿐이면 “파티션은 나쁘다”밖에 못 말한다. 처음에는 핫 부하 2.0만 쟀다. 그러면 파티션의 지연이 실행 길이에 비례해 커지는 값이고, 어느 조건에서 그런지가 빠진다. 부하를 0.4·1.0·2.0으로 스윕하자 갈라지는 지점이 결론이 됐다. 하나의 나쁜 수치보다 전이 곡선이 훨씬 많은 것을 말한다.
한계
- 작업이 IO 대기 시뮬레이션이다.
time.sleep은 DB나 HTTP를 기다리는 핸들러를 모사하고, GIL 아래에서 워커 스레드가 실제로 동시에 돌게 해준다. CPU 바운드 핸들러는 다르게 동작하고 여기서 재지 않았다. - 브로커가 아니라 디스패치 하니스다. Kafka의 리밸런스·페치 배칭·커밋 동작을 일부러 배제해 디스패치 전략만 남겼다. 브로커 쪽 버전은 monticker에서 따로 쟀다.
- 핫 키가 하나다. 여러 개가 다른 파티션에 흩어지면 피해가 분산되고, 같은 파티션에 몰리면 집중된다.
- 훔치기 임계 500ms를 스윕하지 않았다. 그 값이 지연과 순서를 얼마씩 교환하는지를 직접 정하는데, 그 곡선이 없다.
- 순서 위반을 완료 순서로 센다. 하류에서 시퀀스 번호로 재정렬하는 소비자라면 보이지 않는다.
- 3회 반복이다. 결과의 순서는 안정적이었고, 발산한 파티션 수치는 포화된 큐만큼의 폭을 갖는다.
정리
- 한 키가 워커 하나분보다 적게 쓰는 동안에는 세 설계가 사실상 같다. 그 전에는 고민할 필요가 없다.
- 파티션은 그 임계에서 발산한다. 피해자 p99가 116ms → 4,430ms → 22,894ms이고, 대가를 내는 것은 해시가 겹친 무관한 키들이다.
- 파티션은 아홉 번 실행 내내 순서 위반 0이었다. 그것을 사려고 낸 값이 위 지연이다.
- 훔치기는 지연을 45배 줄이고 순서 위반 188건을 만든다. 큐를 쪼개는 순간 그 안의 순서가 사라진다.
- 공유 큐는 “순서가 없는” 설계가 아니다. 한 키가 처리되는 속도보다 빨리 도착할 때만 뒤집힌다.
- 그 임계는 파티션이 발산하는 임계와 같은 값이다. 같은 사실을 양쪽에서 보는 것이다.
- 순서와 격리가 둘 다 필요하면 디스패치 설계로는 해결되지 않는다. 순서 단위를 낮추거나 처리 비용을 줄여야 한다.
참고
- data-ops-lab — 원본은
reports/data/t12-ordering.json, 표는reports/01-experiment-report.md - 카카오뱅크 알림 시스템 리뷰 — 같은 긴장을 원문에서 본 글
- monticker 파티션 × 컨슈머 격자 — 브로커 쪽에서 잰 같은 문제
- 큐잉 이론 한 조각
댓글
아직 댓글이 없습니다