이번 '자바 SDK 내부'의 두 번째 편에서는 SDK가 다양한 노드와 서비스에 대한 소켓을 관리하고 풀링하는 방법을 자세히 살펴보겠습니다. 궁극적으로 반드시 따라야 할 필요는 없지만, 첫 번째 포스팅을 확인하시기 바랍니다. 부트스트랩 도 마찬가지입니다.
이 게시물은 Java SDK 2.5.9 / 2.6.0 릴리스를 염두에 두고 작성되었습니다. 시간이 지남에 따라 상황이 바뀔 수 있지만 전반적인 접근 방식은 거의 동일하게 유지될 것입니다.
OSI 및 TCP 모델의 정신에 따라 SDK 연결 스택을 나타내는 3계층 모델을 제안합니다:
1 2 3 4 5 6 7 |
+-----------------+ | 서비스 레이어 | +-----------------+ | 엔드포인트 레이어 | +-----------------+ | 채널 레이어 | +-----------------+ |
상위 레벨은 하위 레벨 위에 구축되므로 채널 레이어부터 시작하여 스택을 쌓아 올리겠습니다.
채널 레이어
채널 계층은 SDK가 네트워킹을 다루는 가장 낮은 수준이며, 다음과 같은 우수한 완전 비동기 IO 라이브러리 위에 구축됩니다. Netty 우리는 수년간 광범위한 Netty 사용자로서 패치를 제공했을 뿐만 아니라 멤캐시 코덱 프로젝트로 돌아갑니다.
모든 네티 채널 는 소켓에 해당하며 이벤트 루프 위에 다중화됩니다. 스레딩 모델에 대해서는 추후 블로그 게시물에서 다룰 예정이지만, 지금은 기존 블로킹 IO의 "소켓당 하나의 스레드" 모델 대신 Netty가 모든 열린 소켓을 가져와 소수의 이벤트 루프에 분산시킨다는 점을 알아두는 것이 중요합니다. 매우 효율적인 방식으로 이 작업을 수행하므로 Netty가 사용되는 것은 당연합니다. 업계 전반에서 고성능, 저지연 네트워킹 컴포넌트입니다.
채널은 바이트가 들어오고 나가는 것에만 관심이 있기 때문에 애플리케이션 수준 요청(예: N1QL 쿼리 또는 키/값 가져오기 요청)을 적절한 이진 표현으로 인코딩하고 디코딩하는 방법이 필요합니다. Netty에서는 이 작업을 수행하기 위해 핸들러 를 채널 파이프라인. 모든 네트워크 쓰기 작업은 파이프라인을 따라 내려가고 서버 응답은 다시 파이프라인을 따라 올라오게 됩니다(Netty 용어로는 인바운드 및 아웃바운드라고도 함).
일부 핸들러는 사용되는 서비스와 무관하게 추가되며(예: 로깅 또는 암호화), 다른 핸들러는 서비스 유형에 따라 달라집니다(예: N1QL 응답의 경우 응답 구조에 맞게 사용자 정의된 JSON 스트리밍 파서가 있습니다).
개발 또는 디버깅 중에 패킷 수준 로깅 출력을 얻는 방법이 궁금하신 경우(프로덕션에서는 tcpdump, wireshark 등을 사용), 자주 사용하는 로그 라이브러리에서 TRACE 로그 수준을 활성화하기만 하면 다음과 같은 출력을 볼 수 있습니다:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
[cb-io-1-1] 2018-06-28 14:03:34 TRACE 로깅 핸들러:94 - [id: 0x41407638, L:/127.0.0.1:60923 - R:localhost/127.0.0.1:11210] 쓰기: 243B +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 80 1f 00 db 00 00 00 00 00 00 00 e5 00 00 00 00 |................| |00000010| 00 00 00 00 00 00 00 00 7b 22 61 22 3a 22 63 6f |........{"a":"co| |00000020| 75 63 68 62 61 73 65 2D 6A 61 76 61 2D 63 6C 69 |uchbase-java-cli| |00000030| 65 6e 74 2f 32 2e 36 2e 30 2d 53 4e 41 50 53 48 |ent/2.6.0-SNAPSH| |00000040| 4f 54 20 28 67 69 74 3a 20 32 2e 36 2e 30 2d 62 |OT (git: 2.6.0-b|) |00000050| 65 74 61 2D 31 36 2D 67 35 63 65 30 38 62 30 2C |ETA-16-G5CE08B0,| |00000060| 20 63 6F 72 65 3A 20 31 2E 36 2E 30 2D 62 65 74 | CORE: 1.6.0-BET| |00000070| 61 2D 33 33 2D 67 31 62 33 65 36 66 62 29 20 28 |a-33-g1b3e6fb) (| |00000080| 4d 61 63 20 4f 53 20 58 2f 31 30 2e 31 33 2e 34 |Mac OS X/10.13.4| |00000090| 20 78 38 36 5f 36 34 3b 20 4a 61 76 61 20 48 6f | x86_64; Java Ho| |000000a0| 74 53 70 6f 74 28 54 4D 29 20 36 34 2D 42 69 74 |tSpot(TM) 64-Bit| |000000b0| 20 53 65 72 76 65 72 20 56 4d 20 31 2e 38 2e 30 | Server VM 1.8.0| |000000c0| 5f 31 30 31 2d 62 31 33 29 22 2c 22 69 22 3a 22 |_101-b13)"","i":"| |000000d0| 30 43 34 37 35 41 43 41 35 46 33 38 30 41 32 31 |0C475ACA5F380A21| |000000e0| 2층 30 30 30 30 30 30 30 30 30 34 31 34 34 37 36 33 |/000000004140763| |000000f0| 38 22 7d |8"} | +--------+-------------------------------------------------+----------------+ |
작은 로깅 핸들러 를 추가할 수 있을까요? 파이프라인에 추적이 활성화된 경우에만 로깅 핸들러를 추가하기 때문에 사용하지 않는 경우(대부분의 경우) 오버헤드를 지불하지 않아도 됩니다:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
부트스트랩 = new 부트스트랩 어댑터(new 부트스트랩() // *스닙* .옵션(채널 옵션.할당자, 할당자) .옵션(채널 옵션.TCP_NODELAY, tcpNodelay) .옵션(채널 옵션.CONNECT_TIMEOUT_MILLIS, 환경.소켓 연결 시간 초과()) .핸들러(new 채널 초기화 프로그램<Channel>() { 오버라이드 보호됨 void initChannel(채널 채널) 던지기 예외 { 채널 파이프라인 파이프라인 = 채널.파이프라인(); 만약 (환경.sslEnabled()) { 파이프라인.addLast(new SslHandler(sslEngineFactory.get())); } 만약 (로거.isTraceEnabled()) { 파이프라인.addLast(로깅_핸들러_인스턴스); } 사용자 지정 엔드포인트 핸들러(파이프라인); } })); |
또한 환경 구성에 따라 파이프라인에 SSL/TLS 핸들러를 추가하거나 TCP 노드 레이 및 소켓 타임아웃을 구성하는 등 다른 조정 작업을 수행하는 것을 볼 수 있습니다.
그리고 사용자 지정 엔드포인트 핸들러 메서드가 각 서비스에 대해 재정의되며, 다음은 KV 계층에 대한 파이프라인입니다(약간 단순화):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
만약 (환경().keepAliveInterval() > 0) { 파이프라인.addLast(new 유휴 상태 핸들러(환경().keepAliveInterval(), 0, 0, 시간 단위.밀리세컨드)); } 파이프라인 .addLast(new 바이너리멤캐시클라이언트코덱()) .addLast(new 바이너리멤캐시 오브젝트 어그리게이터(정수.MAX_VALUE)); 파이프라인 .addLast(new 키값 기능 핸들러(컨텍스트())) .addLast(new 키값 오류 맵 핸들러()); 만약 (!환경().certAuthEnabled()) { 파이프라인.addLast(new 키값 인증 핸들러(사용자 이름(), 비밀번호(), 환경().forceSaslPlain())); } 파이프라인 .addLast(new 키값 선택 버킷 핸들러(버킷())) .addLast(new 키값 핸들러(이, 응답 버퍼(), false, true)); |
많은 일이 일어나고 있습니다! 하나씩 살펴봅시다:
- 그리고 유휴 상태 핸들러 는 애플리케이션 수준 킵라이브를 트리거하는 데 사용됩니다.
- 다음 두 핸들러 바이너리멤캐시클라이언트코덱 그리고 바이너리멤캐시 오브젝트 어그리게이터 는 멤캐시 요청 및 응답 객체를 바이트 단위 표현으로 인코딩하고 다시 인코딩하는 작업을 처리합니다.
- 키값 기능 핸들러 , 키값 오류 맵 핸들러 , 키값 인증 핸들러 그리고 키값 선택 버킷 핸들러 는 모두 연결 단계에서 핸드셰이킹, 인증, 버킷 선택 등을 수행하고 완료되면 파이프라인에서 스스로를 제거합니다.
- 마지막으로 키값 핸들러 는 대부분의 작업을 수행하며 시스템에 들어오고 나가는 모든 다양한 요청 유형을 "알고" 있습니다.
다른 것을 살펴보고 싶다면, 여기 은 N1QL 파이프라인을 예로 들 수 있습니다.
한 레이어 위로 이동하기 전에 중요한 부분이 하나 있습니다. RxJava 옵저버블 완료 도 이 계층에서 발생합니다. 응답이 디코딩되면 이벤트 루프에서 직접 또는 스레드 풀(기본적으로 구성됨)에서 완료됩니다.
채널이 다운되면(기본 소켓이 닫히기 때문에) 이 레벨의 모든 상태가 사라진다는 것을 아는 것이 중요합니다. 다시 연결을 시도하면 새로운 채널이 생성됩니다. 그렇다면 채널은 누가 관리할까요? 한 계층 위로 올라가 보겠습니다.
엔드포인트 계층
그리고 엔드포인트 계층은 부트스트랩, 재연결, 연결 해제 등 채널의 수명 주기를 관리합니다. 코드를 찾을 수 있습니다. 여기.
엔드포인트와 엔드포인트가 관리하는 채널 사이에는 항상 1:1 관계가 있지만, 채널이 끊어져 소켓을 다시 연결해야 하는 경우 엔드포인트는 그대로 유지되고 내부적으로 새로운 소켓을 얻습니다. 엔드포인트는 요청이 이벤트 루프로 전달되는 곳이기도 합니다(단순화):
1 2 3 4 5 6 7 8 |
오버라이드 public void 보내기(final 카우치베이스 요청 요청) { 만약 (채널.isActive() && 채널.isWritable()) { 채널.쓰기(요청, 채널.voidPromise()); } else { 응답 버퍼.게시 이벤트(응답 핸들러.응답_번역기, 요청, 요청.관찰 가능()); } } |
채널이 활성화되어 쓰기 가능하면 요청을 파이프라인에 쓰고, 그렇지 않으면 다시 전송되어 다른 시도를 위해 대기열에 다시 대기합니다.
채널이 닫힌 경우 엔드포인트는 명시적으로 중지하라는 지시가 없는 한 (구성된 백오프를 사용하여) 다시 연결을 시도한다는 점을 명심하세요. 채널의 관리자가 엔드포인트 통화 연결 끊기 를 설정하여 해당 서비스/노드가 더 이상 구성의 일부가 아닐 때 궁극적으로 발생합니다. 따라서 리밸런싱이 끝날 때 또는 장애 조치 중에 클라이언트는 이 엔드포인트가 종료될 수 있다고 추론하는 새 클러스터 구성을 수신한 다음 그에 따라 종료합니다. 어떤 이유로든 소켓 연결 해제와 이 정보 전파 사이에 지연이 발생하면 결국 재연결 시도가 중단되는 것을 볼 수 있습니다.
하나의 엔드포인트도 좋지만 많으면 많을수록 좋겠죠? 이제 한 단계 더 올라가서 엔드포인트가 어떻게 풀링되어 노드 및 서비스별로 정교한 연결 풀을 만드는지 알아봅시다.
서비스 계층
그리고 서비스 레이어는 노드당 하나 이상의 엔드포인트를 관리합니다. 각 서비스는 하나의 노드만 담당하므로 예를 들어 5개의 노드로 구성된 Couchbase 클러스터가 있고 각 노드에서 KV 서비스만 활성화된 경우 힙 덤프를 검사하면 5개의 키값 서비스 .
이전 클라이언트 버전에서는 다음과 같은 방법을 통해서만 서비스당 고정된 수의 엔드포인트를 구성할 수 있었습니다. kvEndpoints , 쿼리 엔드포인트 등. 더 복잡한 요구 사항으로 인해 강력한 연결 풀 구현으로 이 "고정" 접근 방식을 더 이상 사용하지 않게 되었습니다. 그렇기 때문에 대신 쿼리 엔드포인트 이제 다음을 사용해야 합니다. 쿼리 서비스 구성 및 이에 상응하는 항목.
다음은 2.5.9 및 2.6.0의 서비스별 현재 기본 풀입니다:
- 키값 서비스 : 노드당 엔드포인트 1개, 고정.
- 쿼리 서비스 노드당 0~12개의 엔드포인트, 동적.
- ViewService 노드당 0~12개의 엔드포인트, 동적.
- 분석 서비스 노드당 0~12개의 엔드포인트, 동적.
- 검색 서비스 노드당 0~12개의 엔드포인트, 동적.
KV가 기본적으로 풀링되지 않는 이유는 연결 핸드쉐이킹 비용이 훨씬 더 많이 들고(파이프라인의 모든 핸들러를 기억하세요) 트래픽 패턴이 일반적으로 무거운 쿼리 기반 서비스와는 매우 다르기 때문입니다. 현장의 경험에 따르면 KV 엔드포인트 수를 늘리는 것은 '대량 부하' 시나리오와 한 소켓의 '파이프'가 너무 작은 매우 급격한 트래픽에서만 의미가 있는 것으로 나타났습니다. 이를 제대로 벤치마킹하지 않으면 KV 계층에 소켓을 더 추가하면 성능이 향상되는 대신 성능이 저하될 수 있습니다. 즉, 많다고 해서 항상 좋은 것은 아니라는 것입니다.
풀링 로직은 다음에서 확인할 수 있습니다. 여기 궁금하다면 그 안에 있는 특정 의미를 살펴볼 가치가 있습니다.
서비스의 연결 단계에서 최소 엔드포인트 수가 미리 설정되어 있는지 확인합니다. 최소값이 최대값과 같으면 동적 풀링이 효과적으로 비활성화되고 코드가 각 요청에 대해 엔드포인트 중 하나를 선택합니다:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
동기화 (epMutex) { int 눔투커넥트 = 최소 엔드포인트 - 엔드포인트.크기(); 만약 (눔투커넥트 == 0) { 로거.debug("연결에 필요한 엔드포인트가 없으므로 건너뛸 수 있습니다."); 반환 관찰 가능.그냥(상태()); } 에 대한 (int i = 0; i < 눔투커넥트; i++) { 엔드포인트 엔드포인트 = 엔드포인트팩토리.create(호스트 이름, 버킷, 사용자 이름, 비밀번호, 포트, ctx); 엔드포인트.추가(엔드포인트); 엔드포인트 상태.등록(엔드포인트, 엔드포인트); } 로거.debug(로그아이덴트(호스트 이름, 풀링된 서비스.이) + "새 엔드포인트 수는 {}입니다.", 엔드포인트.크기()); } |
이는 부트스트랩 중에 바로 로그에서 확인할 수 있습니다:
1 2 |
[cb-계산-5] 2018-06-28 14:03:34 DEBUG 서비스:257 - [localhost][키값 서비스]: 신규 숫자 의 엔드포인트 는 1 [cb-계산-8] 2018-06-28 14:03:35 DEBUG 서비스:248 - [localhost][쿼리 서비스]: 아니요 엔드포인트 필요 에 연결, 건너뛰기. |
요청이 들어오면 요청이 디스패치되거나 다른 엔드포인트를 만들어야 하는 경우(풀에 여전히 여유가 있음) 이를 처리하기도 합니다(약간 단순화):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
오버라이드 public void 보내기(final 카우치베이스 요청 요청) { 엔드포인트 엔드포인트 = 엔드포인트.크기() > 0 ? 선택 전략.선택(요청, 엔드포인트) : null; 만약 (엔드포인트 == null) { 만약 (고정 엔드포인트 || (엔드포인트.크기() >= 최대 엔드포인트)) { 재시도 도우미.재시도 또는 취소(환경, 요청, 응답 버퍼); } else { maybeOpenAndSend(요청); } } else { 엔드포인트.보내기(요청); } } |
적절한 엔드포인트를 찾을 수 없고 풀이 고정되어 있거나 한도에 도달한 경우 작업이 다시 시도되도록 예약되며, 이는 엔드포인트가 활성화되어 있지 않거나 쓰기 불가능한 경우의 로직과 매우 유사합니다.
풀링된 HTTP 기반 서비스에서는 이러한 소켓을 영원히 유지하고 싶지 않으므로 유휴 시간(기본값은 300초)을 구성할 수 있습니다. 각 풀은 유휴 타이머를 실행하여 엔드포인트가 설정된 간격보다 오래 유휴 상태인지 정기적으로 검사하고 유휴 상태인 경우 연결을 끊습니다. 이 로직은 항상 최소값 이하로 떨어지지 않도록 합니다.
일반적인 연결 관련 오류
이제 SDK가 소켓을 처리하고 풀링하는 방법에 대해 잘 이해했으니 발생할 수 있는 몇 가지 오류 시나리오에 대해 알아보겠습니다.
취소 요청하기
에 대해 이야기해 보겠습니다. 요청 취소 예외 먼저.
작업을 수행 중인데 실패하는 경우 요청 취소 예외 일반적으로 두 가지 원인이 있습니다:
- 작업이 네트워크를 통해 전송되지 않고 클라이언트 내부를 돌며 구성된 시간보다 더 오래 최대 요청 수명 .
- 네트워크에 요청이 작성되었지만 응답을 받기 전에 기본 채널이 닫혔습니다.
요청 인코딩 중 문제와 같이 덜 일반적인 다른 이유도 있지만, 이 블로그에서는 두 번째 원인에 초점을 맞출 것입니다.
그렇다면 왜 요청을 취소하고 아직 활성 상태인 다른 소켓에서 다시 시도하지 않아야 할까요? 그 이유는 해당 작업이 이미 서버에 부작용을 일으켰는지(예: 변이가 적용된 경우) 알 수 없기 때문입니다. 비활성화되지 않은 작업을 다시 시도하면 실제로 진단하기 어려운 이상한 결과가 발생할 수 있습니다. 대신 호출자에게 요청이 실패했음을 알리고 다음에 수행할 작업은 애플리케이션 로직에 달려 있습니다. 단순한 가져오기 요청이었고 아직 시간 초과 예산 내에 있다면 직접 다시 시도할 수 있습니다. 변조인 경우 문서를 읽고 적용 여부를 파악하기 위한 로직을 추가하거나 즉시 다시 보낼 수 있는지 확인해야 합니다. 그런 다음 API 호출자에게 오류를 다시 전파하는 옵션이 항상 있습니다. 어떤 경우든 SDK 측에서 예측할 수 있으며 백그라운드에서 더 이상 문제를 일으키지 않습니다.
부트스트랩 문제
알아두어야 할 또 다른 오류 원인은 소켓 연결 단계의 문제입니다. 일반적으로 로그에서 무슨 일이 일어나고 있는지 알려주는 설명적인 오류(예: 잘못된 자격 증명)를 찾을 수 있지만, 해독하기 조금 더 어려운 두 가지 오류가 있습니다: 연결 보호 시간 초과와 재조정 중 버킷 선택 오류입니다.
앞서 살펴본 것처럼 KV 파이프라인에는 부트스트랩 중에 서버와 주고받으며 모든 종류의 구성 설정을 파악하고 지원되는 기능을 협상하는 많은 핸들러가 포함되어 있습니다. 현재 각 개별 작업에는 개별적인 시간 제한이 없으며, 총 예산 측면에서 연결 단계가 허용되는 시간보다 오래 걸리는 경우 연결 보호 시간 제한이 시작됩니다.
따라서 연결 시간 초과 예외 메시지와 함께 로그에 연결 콜백 did not 반환, 히트 보호 시간 초과. 는 하나의 작업 또는 모든 작업의 합이 예산보다 오래 걸렸으며 다른 재연결 시도가 수행된다는 의미입니다. 다시 연결하기 때문에 일반적으로는 문제가 되지 않지만 네트워크 또는 스택의 다른 곳에서 속도가 느려질 수 있으므로 더 주의 깊게 살펴봐야 한다는 좋은 신호입니다. 좋은 다음 단계는 다음을 시작하는 것입니다. 와이어샤크 / tcpdump 를 클릭하고 부트스트랩 단계를 기록하여 시간이 소요되는 위치를 파악한 다음 기록된 타이밍에 따라 클라이언트 또는 서버 측으로 피벗합니다. 기본적으로 세이프가드 타임아웃은 다음과 같이 구성됩니다. 소켓 연결 시간 초과 플러스 연결 콜백 유예 기간 로 설정되어 있으며 2초로 조정할 수 있습니다. com.카우치베이스.연결 콜백 유예 기간 시스템 속성입니다.
RBAC(역할 기반 액세스 제어)에 대한 지원을 추가한 이후 부트스트랩 중 단계 중 하나인 "버킷 선택"은 키값 선택 버킷 핸들러 . 인증과 버킷에 대한 액세스 사이에 단절이 있기 때문에 클라이언트가 KV 서비스에 연결했지만 KV 엔진 자체가 아직 서비스를 제공할 준비가 되지 않았을 수 있습니다. 클라이언트는 상황을 정상적으로 처리하고 재시도하며 실제 워크로드에 미치는 영향은 관찰되지 않지만 로그 위생도 문제이므로 현재 여기에서 SDK 알고리즘을 개선하고 있습니다. 원하는 경우 다음에서 진행 상황을 확인할 수 있습니다. JVMCBC-553.
최종 생각
이제 SDK가 기본 소켓을 관리하고 서비스 계층에서 풀링하는 방법을 확실히 이해하셨을 것입니다. 코드베이스를 자세히 살펴보고 싶다면 다음을 시작하세요. 여기 에 대한 각 네임스페이스를 살펴본 다음 서비스 그리고 엔드포인트 . 모든 Netty 채널 핸들러는 엔드포인트 네임스페이스도 사용할 수 있습니다.
추가 질문이 있으시면 아래에 댓글을 달아주세요! 다음 글에서는 SDK의 전반적인 스레딩 모델에 대해 설명하겠습니다.