외부 시세를 받아 오는 호출이 가끔 거절당했는데 요청 수 한도를 넘었다는 응답이었다.
Table of contents
Open Table of contents
원인 — 같은 한도를 쓰던 셋
그 API 를 부르는 자리가 셋이었다.
실시간 시세 갱신 1분에 60회
과거 데이터 채우기 제한 없이 계속
통계 집계 1시간에 1회
두 번째가 문제였는데 빠진 과거 데이터를 채우는 배치가 상한 없이 뒤에서 계속 돌고 있었다.
한도가 분당 100회여서 실시간이 60회를 쓰고 남은 40회를 배치가 쓰는데 배치가 그 40을 넘겨 쓰면 실시간이 거절당했다.
backfill 처럼 급하지 않은 것이 realtime 을 막고 있는 모양이었다. 실시간 쪽 사용량만 보고 있었으니 원인이 그 안에서는 안 보였다.
조치 — 한도를 나눠 줬다
전체 한도를 용도별 몫으로 미리 갈라 두었다.
class RateLimiter {
private const QUOTA = [
'realtime' => 70,
'backfill' => 20,
'stats' => 10,
];
public function acquire(string $group): bool {
$key = "rate:{$group}:" . (int)(time() / 60);
$n = $this->redis->incr($key);
if ($n === 1) { $this->redis->expire($key, 120); }
return $n <= self::QUOTA[$group];
}
}
QUOTA 에 적힌 몫을 넘으면 acquire 가 거짓을 내고 부르는 쪽이 멈춘다.
그래서 배치는 자기 몫을 다 쓰면 그 자리에서 기다리게 된다.
while (!$limiter->acquire('backfill')) {
sleep(1);
}
$this->fetch($symbol, $date);
backfill 이 기다리는 동안에도 realtime 은 자기 몫이 남아 있어 안 막힌다.
설정 — 남는 몫을 쓰게 했다
그런데 실시간이 언제나 70을 다 쓰는 것은 아니었다.
public function acquireSpare(string $group): bool {
$used = 0;
foreach (array_keys(self::QUOTA) as $g) {
$used += (int)$this->redis->get("rate:{$g}:" . (int)(time() / 60));
}
if ($used >= self::TOTAL - self::RESERVE) { return false; }
return $this->acquire($group, true);
}
TOTAL 에서 RESERVE 를 뺀 범위 안에서만 다른 용도의 남는 몫을 쓴다.
RESERVE 를 남긴 것은 실시간 요청이 갑자기 늘 수 있어서였다. 배치는 남는 것을 쓰게 되므로 밤처럼 한가한 시간에 더 빨리 돈다.
대응 — 거절당했을 때
몫을 나눠 두어도 상대 쪽 사정으로 거절당할 수 있다.
if ($res->status === 429) {
$wait = (int)($res->header('Retry-After') ?? 60);
if ($group === 'realtime') {
log_error("실시간 시세 거절됨. {$wait}초 대기");
$this->useLastKnown($symbol);
} else {
sleep($wait);
}
}
realtime 은 기다리지 않고 useLastKnown 으로 마지막 값을 쓴다.
화면에 나가는 시세는 몇 초 낡아도 아예 없는 것보다는 낫기 때문이다. 배치는 급하지 않으니 Retry-After 만큼 그냥 기다리게 두었다.
같은 429 응답이라도 용도에 따라 다르게 다룬 것이다. 한쪽에 맞춰 한 가지로 처리하면 반드시 다른 쪽이 손해를 본다.
검증 — 용도별 사용량 기록
그래서 용도별 사용량을 분마다 로그로 남겨 두기로 했다.
14:02 realtime 58/70 backfill 20/20 stats 0/10 total 78/100
14:03 realtime 61/70 backfill 20/20 stats 0/10 total 81/100
realtime 과 backfill 중 어느 쪽이 몫을 다 쓰고 있는지가 그대로 보인다.
backfill 이 계속 20을 다 쓰고 있으면 몫을 늘릴지 판단할 재료가 된다. 나누기 전에 얼마나 쓰는지 아는 것이 몫을 정하는 첫 단계였다.
주의 — 부르는 자리를 한 곳으로
몫을 나누는 것이 뜻대로 되려면 부르는 자리를 다 알아야 했다.
$ grep -rn "quote-api" --include=*.php src/ | grep -v RateLimiter
RateLimiter 를 안 거치는 자리가 하나 더 나왔는데 관리 화면의 확인 버튼이었다.
그 버튼도 RateLimiter 를 거치도록 같은 방식으로 고쳤다. 한 자리라도 안 거치면 셋의 몫이 맞아도 전체가 넘친다.
class QuoteClient {
public function get(string $symbol, string $group): array {
if (!$this->limiter->acquire($group)) { throw new RateLimited($group); }
return $this->http->get(...);
}
}
QuoteClient 를 거치지 않고 직접 부르는 것을 막았다.
마지막으로 상대에게 그 한도를 올릴 수 있는지도 물어봤다. 요금제를 바꾸면 분당 300회까지 되지만 비용을 계산해 보니 배치 하나를 위해 올릴 만큼은 아니었고 배치는 밤에 남는 몫으로 돌면 며칠이면 끝났다.
정리
- 같은 한도를 나눠 쓰면 급하지 않은 것이 급한 것을 막는다
- 우리 쪽 사용량만 보면 원인이 안 보인다
- 부르는 자리를 전부 찾아야 나눌 수 있다
- 용도별로 몫을 정하고 자기 몫을 다 쓰면 기다린다
- 남는 몫을 쓰게 하되 여유분은 남긴다
- 거절당했을 때를 용도에 따라 다르게 다룬다
- 실시간은 마지막 값을 쓰고 배치는 기다린다
- 어느 용도가 얼마나 쓰는지 분마다 남긴다
- 부르는 자리가 하나라도 빠지면 전체가 넘친다
- 한도를 올리는 것과 나눠 쓰는 것의 비용을 비교한다