Log4KJS
Redis pipelining (feat. Spring + Lettuce) 본문
Redis piplining 의 장점과 구현 방법 등을 설명하기 전에,
plain 한 request/response 모델을 먼저 살펴보겠습니다.
클라이언트는 쿼리를 보내고 blocking 하게 응답을 기다립니다. ack 를 받으면 다시 다음 쿼리를 소켓에 씁니다.
(여기서의 ack 는 tcp layer 의 ack 가 아닌 애플리케이션 계층의 ack 입니다)
이러한 방식은 클라이언트 스레드가 (size of requests) * (RTT) 시간만큼 IO_WAIT 상태에 있게 됩니다.
짧은 시간 안에 많은 요청을 보내야 하는 경우에 이러한 방식을 사용하는 것은 굉장한 낭비입니다.
서버의 처리율이 충분히 높은 경우에도 RTT 로 인해 클라이언트가 처리할 수 있는 요청 수가 크게 제한되기 떄문입니다.
Pipelining 은 이러한 문제를 해결하기 위한 유명한 방법입니다. (Tcp layer 에서도 쓰입니다)
구현은 간단합니다. 바로 클라이언트가 서버로부터의 ack 를 기다리지 않고 다음 요청을 전송하는 것입니다.
이러한 방식은 RTT 로 인해 낭비되는 IO_WAIT 시간을 줄여주는 것 외에도 또다른 장점이 존재합니다.
바로 read() 와 write() 시스템 콜 호출 횟수를 훨씬 줄일 수 있습니다.
클라이언트는 여러 요청을 큐잉했다 소켓에 한번의 write 로 쓸 수도 있고, 서버는 한번의 소켓 read() 호출이
보통 여러개의 명령어를 읽게 됩니다. (네트워크가 매우 느리지 않다면)
또, 한번의 write() 를 통해 복수개의 명령어에 대한 응답을 전송할 수 있습니다.
read/write 시스템 콜은 종종 스레드를 블러킹하기 때문에,
시스템 콜 횟수를 줄이는 것은 성능 향상에 큰 도움이 됩니다.
이제 Spring 에서 Redis 의 pipelining 을 구현하는 방법을 알아보겠습니다.
먼저 많이 쓰이는 Redis Client 에는 Jedis 와 Lettuce 가 있는데,
Jedis 는 traditional 한 blocking io 기반으로 구현되어 있고,
Lettuce 는 netty 기반의 non-blocking 한 구현을 기반으로 하고 있습니다.
즉, Jedis 는 기본적으로 요청을 받은 스레드에서 socket write -> blocking socket read 를 수행하고,
따라서 동시에 요청을 처리하는 스레드 수만큼의 connection pool 이 필요합니다.
반면 Lettuce 는 요청을 받은 스레드에서 채널에 쓰지 않고, 큐잉한 뒤,
Netty 의 IO Thread 에서 실제로 채널에 쓰기를 수행합니다. Netty 를 이용하기 때문에 응답 읽기 또한
블러킹하지 않고 write 와 분리되어 IO Thread 에서 비동기적으로 이벤트를 받아 이루어집니다.
(그래서 트랜잭션과, 파이프라인용 커넥션 풀을 제외하면 Shared Connection 하나로 여러 스레드에서의 요청을 처리 가능)
즉, Lettuce 의 기본 동작은 blocking write/read 를 하지 않는 비동기 호출입니다.
pipelining 의 정의는 "ack 응답을 받기 위해 블러킹하지 않고 여러 요청을 한번에 전송하는 것" 이므로 이러한 비동기 호출을 이용하여 여러 요청을 전송한다면 pipelining 이라고 할 수 있습니다. (단 후술할 AutoCommit 설정 때문에 개별 명령어 단위 플러시가 기본 설정입니다)
하지만 Lettuce 에서는 asynchronous call 으로 반환된 Future 에 블러킹을 거는 방식으로 구현된 synchronous api 도 제공합니다. 스프링에서 제공하는 RedisTemplate 의 많은 함수들은 이러한 blocking call 입니다.
한 스레드에서 이러한 synchronous api 를 사용해 여러 요청을 수행하는 경우, 해당 스레드를 기준으로는 pipelining 을 사용하지 않는 것과 같게 됩니다. 그렇다면 lettuce 를 구현체로 사용하는 spring-data-redis 에서 파이프라인 기능을 이용하려면 어떻게 해야 할까요?
공식 문서에 나와 있는 예제대로, executePipelined 를 사용하면 됩니다. SessionCallBack 안에서 RedisConnection 의 함수를 직접 호출할 수 있는데, 여기서 수행된 명령들은 ack 를 기다리며 블러킹하지 않고 모든 쿼리를 서버로 보낸 후 한번에 ack 를 받게 됩니다. 하지만 이렇게 하는 경우에도 소켓으로의 flush 는 각 명령어 단위로 이루어집니다. 이는 write() 시스템 콜이 여러번 호출되는 문제를 일으킬 수 있습니다. 왜 이렇게 동작하는지, 여러 명령어를 한번에 플러시하려면 어떻게 해야할지 spring-data-redis 의 구현을 뜯어 살펴보겠습니다.
RedisConnection 의 구현체인 LettuceConnection 의 메서드입니다.
openPipeline() 은 파이프라인 내의 명령어들을 실행하기 전에 호출되는 메서드입니다.
flushState 를 설정하고, onOpen() 에 dedicatedConnection 을 전달합니다.
위에서 말했듯, Lettuce 는 기본적으로 여러 스레드에서의 요청을 한개의 Connection 으로 처리하지만
파이프라인과 트랜잭션을 위해서는 dedicatedConnection, 즉 독점적인 커넥션을 사용합니다.
pipeline 이 종료될 때 호출되는 closePipeline() 에서는 파이프라인 내에서 수행된 명령어들(ppline) 의 future 를 블럭하여 한번에 기다립니다. 또, flushState.onClose() 를 호출해주는 것을 볼 수 있습니다.
flush 와 관련된 이름을 보았을 때, PipeliningFlushState flushState 멤버 변수가 명령어 단위로 플러시가 이루어지는 문제와 관련되어 있을 가능성이 높을 것 같습니다.
결론적으로, PipeliningFlushState 는 명령어를 어떻게 소켓에 플러시하는지에 대한 정책을 결정하는 것이 맞았습니다.
구현체에 따라 세가지 정책이 존재하는데,
FlushEachCommand 는 명령어 단위로 소켓에 flush 를 수행합니다. Lettuce 의 기본 설정이 명령어 단위로 flush 를 수행하기 때문에 FlushEachCommand 구현은 아무것도 수행하지 않아도 됩니다.
FlushOnClose 는 파이프라인이 종료될 때 모든 명령어를 한번에 플러시합니다.
connection.setAutoFlushCommands(false) 부분이 바로 위에서 언급했었던,
Lettuce 의 명령어 단위 플러시 옵션을 꺼주는 역할을 합니다.
파이프라인이 종료될 때 플러시를 수행함과 동시에 다시 옵션을 켜줍니다.
파이프라인에 사용된 커넥션은 다시 커넥션 풀에 반환되어 재사용되기 때문에 적절히 복구해주어야 하는 것입니다.
마지막으로 BufferedFlushing 정책은 명령어 n 개 단위로 플러시를 수행합니다.
물론, 파이프라인을 닫을 때에도 플러시를 수행합니다.
이러한 PipeliningFlushPolicy 는 LettuceConnectionFactory 에서 다음과 같이 설정해줄 수 있습니다.
PipeliningFlushPolicy.flushClose() 를 넣어주면 모든 명령어를 한번에 플러시할 수 있습니다.
하지만 파이프라이닝을 사용할 때는 주의해야할 점이 있는데요,
IMPORTANT NOTE: While the client sends commands using pipelining,
the server will be forced to queue the replies, using memory.
So if you need to send a lot of commands with pipelining,
it is better to send them as batches each containing a reasonable number,
for instance 10k commands, read the replies, and then send another 10k commands again,
and so forth.
한번에 너무 많은 커맨드를 보낸다면 서버에서 응답을 큐잉하는 데에 메모리를 많이 소모하기 때문에 적당한 숫자로 쪼개는 것이 좋다고 합니다.
'Project' 카테고리의 다른 글
Community - Push 기반 소셜 네트워크 서비스 (1) | 2022.02.19 |
---|---|
Grpc NameResolver + Eureka Service Discovery (0) | 2022.02.17 |
Spring Kafka 의 Async ack 활용 (0) | 2022.02.11 |
Cachy 의 스레딩 모델과 성능 개선 (0) | 2022.02.05 |
Cachy - 로그 구조화 key-value 저장소 (1) | 2021.12.18 |