카프카 공식 홈페이지에서는 카프카를 이렇게 설명한다
"Apache Kafka is an open-source distributed event streaming platform used by thousands of companies for high-performance data pipelines, streaming analytics, data integration, and mission-critical applications."
여기서 알수있는것은
이번 포스팅에서는 카프카가 높은 성능의 이벤트 스트리밍을 위해서 어떤 노력을 했는지 알아보자.
성능만 생각한다면 그냥 메모리에 올려놓고 빨리 처리해버리는것이 제일 좋다. 하지만 일반적으로 브로커로 들어오는 write가 read보다 많으면 브로커가 잡아놓은 buffer는 꽉차서 특별한 조치가 없는 한 메세지는 유실될수밖에 없다.
이런 이벤트의 유실을 막기 위해서 카프카는 메세지 혹은 여러가지 메타데이터(컨슈머 그룹 정보, 파티션 개수 등)들을 디스크에 쓴다. 이렇게 해야지 브로커가 죽었다가 다시 떠도 무중단으로 메세지를 처리할수 있다.
디스크는 디스크 헤더를 움직이는 방식으로 데이터를 읽는다. 예를들어 seek offset이 138이라면 0에서 138까지 디스크 헤더를 움직여야할것이다.
만약 어떤 데이터가 offset 3, 1000000, 120301203, 10
위치에 저장되어있고, seek point에 있는 데이터를 다 읽어야만 하나의 의미있는 데이터가 만들어진다면 디스크는 열심히 헤더를 왔다갔다 하면서 데이터를 읽어야 할것이다.
하지만 여기서 한번의 헤더 움직임으로 여러가지 데이터를 읽을수 있다면 더 효율적일것 같다.
1000000, 3, 120301203, 10
여기서 디스크 헤더는 가장 최대 크기의 오프셋인 120301203
까지 이동하면서 결국 3, 10, 1000000
을 전부 거친다. 하지만 random I/O 방식에서는 1000000
을 갔다가 다시 seek point를 3으로 잡아서 헤더의 위치를 이미 지나갔던 앞으로 옮겨버린다.
sequential I/O는 처음부터 데이터를 저장할때 1, 2, 3, 4
이렇게 순차적으로 저장하는것이다. seek 를 한번 옮길때 최대한 많은 의미있는 데이터를 읽음으로 인해서 I/O의 효율이 올라가게 되고 kafka는 데이터를 write할때 sequential 한 read 상황을 가정해서 저장한다.
가장 빠른 시나리오는 consumer가 메세지를 메모리에서 바로 읽어가는것이다. 하지만 분산환경에서 publisher의 write 타이밍과 consumer의 read 타이밍은 언제든지 다를수 있다(producer가 메세지를 write 했을때 consumer 노드가 죽어있을수도 있음).
이런 이유로 kafka는 메세지를 디스크에 저장한다.
consumer가 메세지를 disk에서 읽어와서 네트워크를 태워서 보내도록 동작한다.
일반적인 상황에서 메세지를 disk에서 읽어서 네트워크로 내보내는 구현은 아래와 같은 방식으로 어플리케이션에서 동작할것이다(출처)
File.read(fileDesc, buf, len);
Socket.send(socket, buf, len);
이 과정을 풀어보면
- 디스크에서 file을 read하는 시스템콜을 호출한다
- 시스템콜이 커널레벨의 IO buffer에 데이터를 쓴다
- IO buffer에 있는 데이터를 application buffer에 copy 한다
- application buffer에 있는 데이터를 socket buffer에 쓴다
- socket buffer에 있는 데이터를 NIC buffer에 copy 해서 네트워크로 내보낸다
이와 같은 동작을 거치게 되며, 어차피 IO해서 읽어온것을 별다른 처리 없이 네트워크로 바로 내보내야하는데 굳이 application buffer로 복사하는 비효율이 발생한다.
kafka는 자바로 만들어졌고, 자바에서
public void transferTo(long position, long count, WritableByteChannel target);
라는 메서드를 사용해서 zero copy를 구현했다.
transferTo
는 내부적으로
#include <sys/socket.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
라는 시스템콜을 사용한다.
sendfile
시스템콜을 사용하면 위에서 설명한 과정 중에서 3과 4를 스킵할수 있다.
리눅스에서는 네트워크와 하드웨어를 어플리케이션에서 사용하기 위해서 file descriptor로 추상화했다.
이 경우에는 out_fd
인자에 IO의 fd가 들어가고, in_fd
인자에 NIC fd혹은 socket fd가 들어갈것으로 예상된다.
즉, IO의 결과가 IO buffer에 들어가있을것이고, 이 결과를 application 까지 퍼올리지 않고 바로 NIC buffer 혹은 socket buffer로 copy해서 네트워크로 내보낸다.
한가지 의문이 있다면, (여기) 에서는 IO buffer에서 socket buffer로 복사하지 않고 바로 NIC buffer로 복사를 하는것으로 설명이 되어있다. NIC buffer로 바로 복사하면서 어떻게 response 대상을 바로 찾을수 있는지에 대해서는 좀 더 알아봐야할것같다.