Redis 벌크 로딩(Bulk Loading)

Redis 벌크 로딩(Bulk Loading)

Redis 프로토콜을 이용해 데이터를 한꺼번에 대량으로 쓰는 방법을 설명해요. 대량의 기존 데이터를 빠르고 효율적으로 Redis에 넣고 싶을 때 쓰는 전략들이에요.

출처: https://redis.io/docs/latest/develop/clients/patterns/bulk-loading/

Redis 프로토콜을 이용한 벌크 로딩

일반 Redis 클라이언트로 벌크 로딩을 하는 건 몇 가지 이유로 좋은 생각이 아니에요. 명령을 하나씩 순서대로 보내는 단순한 방식은 매 명령마다 왕복 시간(RTT)을 내야 해서 느려요. 파이프라이닝을 쓸 수도 있지만, 많은 레코드를 벌크 로딩하려면 응답을 읽는 동시에 새 명령을 써서 가능한 한 빨리 삽입해야 해요.

논블로킹 I/O를 지원하는 클라이언트는 일부뿐이고, 모든 클라이언트가 처리량을 극대화하도록 응답을 효율적으로 파싱할 수 있는 것도 아니에요. 이런 이유로 Redis에 데이터를 대량으로 가져오는 preferred 방법은, 필요한 명령을 담은 텍스트 파일을 원시(raw) Redis 프로토콜 형식으로 생성하는 거예요.

예를 들어 keyN -> ValueN 형태의 키가 수십억 개 있는 큰 데이터셋을 만들고 싶다고 해볼게요. 그러면 Redis 프로토콜 형식으로 다음과 같은 명령이 담긴 파일을 만들면 돼요.

SET Key0 Value0
SET Key1 Value1
...
SET KeyN ValueN

파일이 만들어지면 남은 일은 이걸 가능한 한 빨리 Redis에 흘려보내는 것뿐이에요. 예전에는 이렇게 netcat을 썼어요.

(cat data.txt; sleep 10) | nc localhost 6379 > /dev/null

하지만 이 방식은 netcat이 데이터가 언제 모두 전송됐는지 정확히 모르고 에러도 확인할 수 없어서, 대량 가져오기에는 그리 신뢰할 수 없어요. Redis 2.6 이상에서는 redis-cli가 벌크 로딩용으로 설계된 pipe mode라는 새 모드를 지원해요.

파이프 모드를 쓰면 명령은 이렇게 생겼어요.

cat data.txt | redis-cli --pipe

그러면 다음과 비슷한 출력이 나와요.

All data transferred. Waiting for the last reply...
Last reply received from server.
errors: 0, replies: 1000000

redis-cli는 Redis 인스턴스에서 받은 에러만 표준 출력으로 리다이렉트되도록 보장하기도 해요.

Redis 프로토콜 생성하기

Redis 프로토콜은 생성하고 파싱하기가 매우 간단하며, 여기에 문서화되어 있어요. 하지만 벌크 로딩을 위해 프로토콜을 생성할 때 프로토콜의 모든 세부 사항을 알 필요는 없고, 모든 명령이 다음 방식으로 표현된다는 것만 알면 돼요.

*<args><cr><lf>
$<len><cr><lf>
<arg0><cr><lf>
<arg1><cr><lf>
...
<argN><cr><lf>

여기서 <cr>"\r"(ASCII 13)이고, <lf>"\n"(ASCII 10)을 뜻해요.

예를 들어 SET key value 명령은 다음 프로토콜로 표현돼요.

*3<cr><lf>
$3<cr><lf>
SET<cr><lf>
$3<cr><lf>
key<cr><lf>
$5<cr><lf>
value<cr><lf>

또는 따옴표 문자열로 이렇게 표현돼요.

"*3\r\n$3\r\nSET\r\n$3\r\nkey\r\n$5\r\nvalue\r\n"

벌크 로딩용으로 생성할 파일은 위와 같은 방식으로 표현된 명령들을 그냥 연달아 쌓은 것뿐이에요.

다음 Ruby 함수가 유효한 프로토콜을 생성해줘요.

def gen_redis_proto(*cmd)
    proto = ""
    proto << "*"+cmd.length.to_s+"\r\n"
    cmd.each{|arg|
        proto << "$"+arg.to_s.bytesize.to_s+"\r\n"
        proto << arg.to_s+"\r\n"
    }
    proto
end

puts gen_redis_proto("SET","mykey","Hello World!").inspect

위 함수를 쓰면 위 예시의 키-값 쌍을 이 프로그램으로 쉽게 생성할 수 있어요.

(0...1000).each{|n|
    STDOUT.write(gen_redis_proto("SET","Key#{n}","Value#{n}"))
}

이 프로그램을 redis-cli로 직접 파이프하면 첫 번째 대량 가져오기 세션을 수행할 수 있어요.

$ ruby proto.rb | redis-cli --pipe
All data transferred. Waiting for the last reply...
Last reply received from server.
errors: 0, replies: 1000

파이프 모드가 내부에서 동작하는 방식

redis-cli 파이프 모드에서 필요한 마법은 netcat만큼 빠르면서도 서버가 마지막 응답을 보냈을 때를 동시에 이해하는 거예요. 이렇게 구현돼요.

  • redis-cli --pipe가 가능한 한 빠르게 서버에 데이터를 보내려고 해요.
  • 동시에 데이터가 있으면 읽어서 파싱을 시도해요.
  • stdin에서 읽을 데이터가 더 없으면 랜덤한 20바이트 문자열을 담은 특수 ECHO 명령을 보내요. 이것이 마지막으로 보낸 명령임이 보장되고, 같은 20바이트를 bulk 응답으로 받는지 확인해 응답을 맞춰볼 수 있어요.
  • 이 특수 마지막 명령을 보내면 응답 수신 코드가 이 20바이트와 응답을 매칭하기 시작해요. 매칭 응답에 도달하면 성공으로 종료할 수 있어요.

이 트릭을 쓰면 서버에 보내는 프로토콜을 파싱해서 명령 개수를 알 필요 없이, 응답만 파싱하면 돼요. 응답을 파싱하면서 모든 응답 숫자를 세어서, 대량 삽입 세션이 서버에 전송한 명령 개수를 마지막에 사용자에게 알려줄 수 있어요.

더 알아보기 (Learn more)