[#2] Redis에 한번에 많은 데이터 추가 시 네트워크 병목 개선하기 - Redis Pipeline 이용하기
문제 상황
예를들어 레디스에 리스트를 저장하였고 그 리스트에 한번에 여러개의 원소를 추가하는 상황이라고 가정해보겠습니다. 레디스에 기본적으로 한번의 추가 연산을 하면 O(1)시간이 들며 요청을 보내고 다음과 같이 응답 값을 받습니다.
레디스는 싱글스레드와 이벤트루프 기반으로 비동기방식으로 요청을 처리하기 때문에 고성능입니다. 하지만 기본적으로 TCP 기반의 네트워크 모델을 따르기 때문에 네트워크 I/O 에서 병목이 생길 수 있는 가능성이 있습니다.
하지만 매번 요청을 할 때마다 응답값을 받기 때문에(요청을 보내고 응답을 받을때까지는 blocking이 됩니다) 한번에 수십만개의 요청을 보낸다면 이 응답값을 매번 받는 것도 부하가 생길 수 있습니다. 즉, 레디스 서버에 반복문을 돌며 여러번 리스트의 원소를 push한다면 RTT때문에 오버헤드가 생길 수 있습니다.
이렇게 여러번 응답을 받지 않고 처음에 한번 레디스에 연결을 한 뒤 수십만 개의 원소를 추가하고 응답을 한번 받는 방식으로 로직을 변경하면 더 좋은 성능을 가질 것입니다. 따라서 레디스 서버에 요청을 보낼때 한번에 여러 원소들을 보내야합니다.
HTTP의 커넥션 관리도 한번에 수많은 요청을 보낼 때를 대비해 HTTP Pipelining을 지원하고 있습니다.
첫 그림은 Keep Alive가 적용되기 전 매번 연결을 하고 리퀘스트를 날리고 리스폰스를 받을 때까지 기다렸다 받으면 연결을 종료했으나 이는 TCP연결을 매번해야 하는 오버헤드가 있어 Keep Alive속성이 생겼습니다.
2번째 그림은 Http의 Keep Alive속성때문에 매번 연결을 하지 않아도 연결이 지속되어 리퀘스트를 보내고 리스폰스를 받는 형식입니다.
3번째 그림은 한번에 수많은 요청을 보내기 위해 파이프라인화를 시켜서 리퀘스트를 보낸후 리스폰스를 받을때까지 기다린 다음에 리퀘스트를 보냈어야 했던 로직을 리스폰스를 기다리지 않고 바로 다음 리퀘스트를 보낼 수 있도록 해주는 기능입니다.
MySQL에서는 이러한 기능을 위해 bulk insert를 지원하지만 레디스에서는 bulk(다중) insert가 따로 존재하지 않습니다.따라서 레디스에서 지원해주는 pipeline api인 executePipelined 메소드를 이용해 레디스에 연결을 한후 모든 원소들을 push한 뒤 연결을 닫습니다.
여러 문서를 찾아보았으나 한글로 된 문서는 없었고 스프링 공식문서에 pipelining 문서가 다음과 같이 있었습니다.
이문서는 list에서 Pop을 하는 예제였고 이를 보고 저는 push를 해야했으므로 redistemplate의 rPush를 이용해 제 프로젝트에 맞게 변경하였습니다. 처음에는 위 스프링 예제에서 rPop("myQueue")와 batchsize가 무엇을 의미하는지 몰라 헤매었는데 rPop,rPush등 레디스 커맨드들은 파라미터를 bytes[] 로 넘겨주어야했고 key, value 순으로 파라미터를 정해줘야합니다.
우선 다음과 같은 코드로 변경하였습니다.
제 프로젝트에서 keySerializer는 StringRedisSerializer를 쓰고 있고
valueSerializer는 GenericJackson2JsonRedisSerializer를 쓰고 있습니다.
RedisSerializer keySerializer = redisTemplate.getStringSerializer();
RedisSerializer valueSerializer = redisTemplate.getValueSerializer();
redisTemplate.executePipelined(new RedisCallback<Object>() {
public Object doInRedis(RedisConnection connection)
throws DataAccessException {
for (int i = 0; i < cartList.size(); i++) {
connection.rPush(keySerializer.serialize(key),
valueSerializer.serialize(cartList.get(i)));
}
return null;
}
});
rPush , lPush등 레디스 command들은 parameter값을 bytes [] 배열로 받고 있었기 때문에 key나 value를 bytes[] 배열로 변환해야했습니다.
String.getBytes()를 사용하여도 되지만 검색해보니 getBytes함수의 성능이 안좋다는 의견이 많았고 DTO를 어차피 serialize해야 했기 때문에 key와 value 모두 serialize를 하였습니다.
레디스에 이전보다 훨씬 빠른속도로 여러 원소들이 들어가게 변경되었습니다. 또한 람다식을 이용해 최종적으로 변환하였습니다.
RedisSerializer keySerializer = redisTemplate.getStringSerializer();
RedisSerializer valueSerializer = redisTemplate.getValueSerializer();
redisTemplate.executePipelined((RedisCallback<Object>) RedisConnection -> {
cartList.forEach(cart -> {
RedisConnection.rPush(keySerializer.serialize(key),
valueSerializer.serialize(cart));
});
return null;
});
프로젝트 url
https://github.com/f-lab-edu/make-delivery
참고자료
https://docs.spring.io/spring-data/redis/docs/current/reference/html/#pipeline
developer.mozilla.org/ko/docs/Web/HTTP/Connection_management_in_HTTP_1.x