Redis 최적화하기
우리회사는 Response 속도를 극대화하기위해 다중 캐시 시스템을 구축하고 있다.
처음에는 Redis를확인하고, Redis에 데이터가 없다면 Database를 확인 데이터를 확인하며, 둘 다 캐시된 데이터가 없을 경우 최신 데이터를 불러오는 방식이다.
이 때 Redis 서버가 바로 응답하지 못하는 상황에 빠지는 상황에 대비하여 실제 get요청을 하기 이전에 ping이라는 가벼운 요청을 통해 Redis를 사용할 수 있는지를 검증한다.
그리고 만약에 ping요청이 200ms 보다 더 오래걸린다면 빠르게 응답할 수 없는 상태로 간주하기로 하였다. 그 이유는 ping은 대체로 50ms 이내로 응답이 와야하는것이 일반적이기 때문이다.
(개선 후의 캡쳐본-개선 이전의 기록는 시간이 지나 삭제됐다..)
APM 기록에 따르면 대부분의 Ping 요청은 50ms 이내에 성공한다.
하지만, 왜 단순 ping/pong에 200ms가 넘는 시간이 걸리는건지 궁금했다.
이부분을 최적화 해 보기로했다.
예전에 진행했던 최적화
예전에 이미 최적화를 진행한적이 있다.
수천 수만개의 모든 redis 요청을 보낼 때 마다 ping 요청을 보내도록 되어있었고, 싱글쓰레드인 redis 입장에서는 pong을 하나씩 응답해주다보니 순서에 밀려 pong의 응답이 지연되는 것 이었다.
당시 ping을 한번에 모아서 보내주게 수정하였다.
기존
Class RedisService {
constructor(private readonly pool: RedisPool) {}
async ping() {
const now = new Date().getTime();
const doNotPingAgain = now - this.lastSuccessfullyPingedAt < this.pingIntervalMs;
if (doNotPingAgain) {
return true;
}
try {
const redisStatus = await this.pool.use(redis => {
const redisPing = Bluebird.method(async () => await redis.ping());
return redisPing().timeout(this.pingTimeout, 'Redis ping timeout');
});
if (redisStatus === 'PONG') {
this.lastSuccessfullyPingedAt = now;
return true;
} else {
throw new Error('Redis is not connected');
}
} catch (err) {
return false;
}
}
}
수정 후
Class RedisService {
private pingPromise: Promise<'PONG'>
constructor(private readonly pool: RedisPool) {}
async ping() {
// 기존과 동일
try {
this.pingPromise = this.pool.use(redis => {
const redisPing = Bluebird.method(async () => await redis.ping());
return redisPing().timeout(this.pingTimeout, 'Redis ping timeout');
});
const redisStatus = await this.pingPromise;
if (redisStatus === 'PONG') {
this.lastSuccessfullyPingedAt = now;
return true;
} else {
throw new Error('Redis is not connected');
}
} catch (err) {
return false;
} finally {
this.pingPromise = null;
}
}
}
이전에는 한개의 node 서버에서 여러건의 ping을 한순간에 보냈었다면, 개선 이후에는 Promise를 공유함을 통해 단 한건의 요청만을 보내도록 개선했다.
그럼에도 지속적인 Ping Timeout이 발생하여 다시 한번 확인해보게 되었다.
첫번째 개선
각 Node서버에 Redis Pool 자체가 가득 찬건지에 대한 의문이 생겼다.
apm에 에러로그에 pool available 과 size를 함께 수집해보았고, 내가 설정해놓은 pool max size인 100과는 한참 여유가 있는 3~7에서도 timeout 이슈가 발생한다는점을 확인했다.
나는 여기에서 단순 Ping Pong을 하기위해 Pool에서 Redis Connection을 가져오는데, 이미 보유하고있는 Connection을 전부 사용했을 경우 새로 Connect를 진행해야하고, 이를 위해 일부 시간을 소비하게된다는 것을 알게되었다.
조금 더 정확하게 문제를 파악해보기위해 pool이 추가하는데 소비되는 시간을 측정해보았다.
예상했다시피 connection 추가하는데 예상보다 훨씬 긴 시간이 소비됐다.
(어쩔 때 connection 혹은 ping pong 할 때 50초 씩이나 소모되었던 것일까.. 측정오류라고 믿고싶다.)
아무튼 ping을 위한 connection만 별도로 연결만 해놓아도 ping은 200ms 이내에 성공해야 할 것이다.
측정시간대 기준, 기존 2% 언저리 되던 에러율이 0.5프로로 낮아졌다.
아무리 커넥션으로 인한 시간은 줄었다고 하더라도 레디스 업무량에 따라 조금씩 늦게 response가 오는데, 해당 시간대 에서는 최대 75% 에러율 향상됐다.(단기간 측정으로 정확하지 않다.)
(나중에 확인 해 보니, 향상된 평균 에러율은 2.8%대 였다.)
두번째 개선
여전히 발생하는 Ping Timeout 그리고 또다른 문제
Redis 네트워크 사용량이 너무 많다.
AWS 메트릭을 확인해보면 1.2Gigabyte per second가 나오는데 우리가 흔히 네트워크 대역폭을 말하는 Bit 단위로 변환하면 9.6Gbps로 대역폭을 사용하는 상황이다.
아무리 내부서버의 로직을 최적화를 한다고 하더라도, 그만큼 Redis자체에서 처리해야하는 데이터가 많고 바쁘다는 뜻이고, 이는 곧 파란색 선 그래프와 같이 단순 ping 작업에도 소요되는 시간이 길어질 수 있다는 의미이다.(녹색 막대그래프는 횟수를 의미한다.)
기존에는 raw object를 단순히 JSON stringify 하여 Redis에 저장하였었는데, 이를 개선하기 위해 Compress를 해서 최적화 하는 방식을 적용하기로 하였다.
하지만 여전히 두가지 걱정거리가 있었다.
- Compress 와 decompress를 하는데 EC2 CPU를 더 사용 할 것이다.
- Redis에서 자체적으로 데이터를 보는 사람들은 데이터를 decompress를 해야한다.
이는 아래와 같이 생각함을 통해 해결하기로 했다.
- 압축할 때 CPU를 덜 사용하는 알고리즘을 사용한다.
- Redis에서 자체적으로 데이터를 보는 사람들이 있는지 우선적으로 확인하고, 있다면 그들을 위하여 key를 입력하면 decompress까지 해주는 BO 페이지를 신설한다.
기존, 우리 회사에서 JSON을 압축하기위해 보통 사용하던 방식은 Brotli였다.
Brotli는 압축률이 굉장히 좋았기 때문이었다.
(예전 Brotli 내부 공유시 사용했던 테스트 자료)
Quality를 지정하여 압축률을 설정할 수 있었고, 최적의 옵션을 설정하여 사용했었다.
하지만 Brotli는 CPU를 많이 사용하는 축에 속한다.
따라서 다른 압축방식에 대해서 더 알아보게되었다.
그리고 아래의 다양한 압축방식을 비교해준 사이트를 발견하게 되었다.
https://quixdb.github.io/squash-benchmark/#results
확실히 Brotli는 CPU intensive한 압축 방식에 속했고, 수많은 IO가 필요한 나의 상황에서는 적합하지 못하다는 생각이 들었다.
이중 나는 snappy라는 Google에서 만들었다는 압축방식에 눈이 갔고, 실제 테스트를 진행 해 보았다.
brotli와 snappy 두개 압축을 진행해 보았는데, 결과물은 1/10 정도의 압축률을 보여주었고, brotli와 snappy 사이에는 생각보다 큰 차이가 없었다.
10,000회 작업시 소요되는 시간(m1 pro)
하지만, snappy가 brotli에 비하여 훨씬 바르다는것을 확인할 수 있다.
(cpu사용량도 한번 확인해보고는 싶지만, 아쉽게도 어떻게 테스트해야하는지를 모르겠다.)
Staging에 해당 압축을 적용하여 Staging에 적용해보고, 문제가 없고 유의미한 변화가 있다면 적용 해 보기로 했다.
Staging 2주 사용 결과(12월 27일 배포)
Redis
EC2
Staging에서 테스트 한 결과로는, EC2에서 CPU 상승에 대한 이슈는 발견되지 않았고, Redis에서는 눈으로도 결과가 보여지는 큰 향상을 볼 수 있었다.
실제 Prod 배포 결과 (1월 8일 배포)
Redis
CPU
아무래도 EC2인스턴스들이 많고, 그 EC2마다 도커 컨테이너들로 요청들이 분산이 된 덕분인지 눈에 보이는 차이가 없었다.
Redis ping timeout 에러율
Before
After
이번 최적화로 CPU사용량, 네트워크 사용량 그리고 메모리 사용량이 현저히 줄었다.
단순 데이터 압축만으로도 이러한 향상을 볼 수 있다는것이 굉장히 놀라웠다.
게다가 약 20%의 추가적인 Ping timeout 에러율 감소를 이끌어 냈다.
하지만 왜 아직도 Ping timeout이 잔재하는 것일까...
내가 마지막으로 예상하는 이유는 아무래도 한순간 10개가 넘는 노드 인스턴스에서 순간적으로 동시에 요청을 하기 때문 인 것 같다.
레디스는 다른 요청들을 처리하고 있는데, 동시에 10개의 ping이 오다보니 싱글쓰레드인 redis는 이전작업 처리 후에 ping을 처리하려다 보니 response가 늦어진건지..
Redis 클러스터링을 통해 요청을 분산한다면 더 안정적으로 redis를 사용할 수 있겠지만, 회사의 예산에 한계가 있다보니 이는 내가 현재는 해결하기에는 버거울 것으로 생각된다.