09 - 네트워크 통신 최적화 직렬화: Java 직렬화 사용 방지

        현재 백엔드 서비스의 대부분은 마이크로서비스 아키텍처를 기반으로 구현됩니다. 서비스가 사업부별로 분할되어 서비스의 디커플링(Decoupling)을 구현하는 동시에 새로운 문제도 발생하며, 서로 다른 사업부 간의 커뮤니케이션은 인터페이스를 통해 호출되어야 합니다. 두 서비스 간에 데이터 객체를 공유하려면 해당 객체를 바이너리 스트림으로 변환하여 네트워크를 통해 전송한 후 다른 서비스로 보낸 후 서비스 메서드가 호출할 수 있도록 다시 객체로 변환해야 합니다. 이러한 인코딩 및 디코딩 프로세스를 직렬화 및 역직렬화라고 합니다.

        동시 요청 수가 많은 경우 직렬화 속도가 느리면 요청 응답 시간이 길어지고, 직렬화된 전송 데이터량이 많아 네트워크 처리량이 감소하게 됩니다. 따라서 우수한 직렬화 프레임워크는 시스템의 전반적인 성능을 향상시킬 수 있습니다.

        우리는 Java가 서비스 간의 인터페이스 노출 및 호출을 실현하기 위해 RMI 프레임워크를 제공하고 RMI의 데이터 객체 직렬화는 Java 직렬화를 사용한다는 것을 알고 있습니다. 그러나 현재 주류 마이크로서비스 프레임워크에서는 Java 직렬화를 거의 사용하지 않으며 SpringCloud는 Json 직렬화를 사용합니다. Dubbo는 Java 직렬화와 호환되지만 기본적으로 Hessian 직렬화를 사용합니다. 왜 이런거야?

        오늘은 Java 직렬화에 대해 자세히 알아보고, 지난 2년 동안 인기를 끌었던 Protobuf 직렬화와 비교해 Protobuf가 어떻게 최적의 직렬화를 달성하는지 살펴보겠습니다.

1. 자바 직렬화

결함에 대해 이야기하기 전에 먼저 Java 직렬화가 무엇인지, 어떻게 작동하는지 알아야 합니다.

Java는 디스크에 쓰거나 네트워크에 출력하기 위해 객체를 바이너리 형식(바이트 배열)으로 직렬화할 수 있고 네트워크나 디스크에서 바이트 배열을 읽어 객체로 역직렬화하고 프로그램에서 사용할 수 있는 직렬화 메커니즘을 제공합니다. .

JDK에서 제공하는 두 개의 입력 및 출력 스트림 개체 ObjectInputStream 및 ObjectOutputStream은 Serialized 인터페이스를 구현하는 클래스의 개체를 역직렬화 및 직렬화할 수만 있습니다.

ObjectOutputStream의 기본 직렬화 방법은 객체의 비일시적 인스턴스 변수만 직렬화하지만 객체의 임시 인스턴스 변수나 정적 변수는 직렬화하지 않습니다.

직렬화 가능 인터페이스를 구현하는 클래스의 객체에서 serialVersionUID 버전 번호가 생성됩니다. 이 버전 번호의 용도는 무엇입니까? deserialization 과정에서 직렬화된 객체가 deserialized 클래스와 함께 로드되었는지 확인하는데, 동일한 클래스 이름의 버전 번호가 다른 클래스인 경우 deserialization 과정에서 해당 객체를 얻을 수 없습니다.

직렬화의 구체적인 구현은 writeObject 및 readObject입니다. 일반적으로 이 두 가지 메소드가 기본값입니다. 물론 직렬화 가능 인터페이스를 구현하는 클래스에서 이를 다시 작성하여 일련의 직렬화 및 역직렬화 메커니즘을 사용자 정의할 수도 있습니다.

또한 Java 직렬화 클래스에는 writeReplace()와 readResolve()라는 두 가지 재작성 메소드가 정의되어 있는데, 전자는 직렬화 전 직렬화된 객체를 대체하는 데 사용되고, 후자는 역직렬화 후 객체를 해결하는 데 사용된다. 처리.

2. Java 직렬화의 결함

일부 RPC 통신 프레임워크를 사용한 경우 이러한 프레임워크가 JDK에서 제공하는 직렬화를 거의 사용하지 않는다는 것을 알게 될 것입니다. 사실 대부분 유용하지 않고 사용하기 쉽지 않은 것과 관련이 있는데, JDK 기본 직렬화의 결함을 살펴보겠습니다.

2.1, 언어를 넘을 수 없음

오늘날의 시스템 설계는 점점 더 다양해지고 있으며, 많은 시스템이 애플리케이션을 작성하기 위해 여러 언어를 사용합니다. 예를 들어, 우리 회사에서 개발한 일부 대규모 게임은 여러 언어를 사용합니다. C++는 게임 서비스 작성에 사용되고 Java/Go는 주변 서비스 작성에 사용되며 Python은 일부 모니터링 응용 프로그램 작성에 사용됩니다.

Java 직렬화는 현재 Java 언어 기반 프레임워크에만 적용되며 대부분의 다른 언어는 Java 직렬화 프레임워크를 사용하지 않으며 Java 직렬화 프로토콜을 구현하지도 않습니다. 따라서 서로 다른 언어로 작성된 두 애플리케이션이 서로 통신하는 경우 두 애플리케이션 서비스 간에 전송되는 객체의 직렬화 및 역직렬화를 구현할 수 없습니다.

2.2 공격에 취약함

Java 공식 웹사이트 보안 코딩 지침에는 "신뢰할 수 없는 데이터의 역직렬화는 본질적으로 위험하므로 피해야 합니다."라고 명시되어 있습니다. Java 직렬화가 안전하지 않음을 알 수 있습니다.

우리는 ObjectInputStream의 readObject() 메소드를 호출함으로써 객체가 역직렬화된다는 것을 알고 있습니다. 이 메소드는 실제로 직렬화 가능 인터페이스를 구현하는 클래스 경로의 거의 모든 객체를 인스턴스화할 수 있는 마법의 생성자입니다.

이는 또한 바이트 스트림을 역직렬화하는 과정에서 이 메서드가 모든 유형의 코드를 실행할 수 있다는 것을 의미하며 이는 매우 위험합니다.

장기간 역직렬화해야 하는 개체의 경우 코드를 실행하지 않고도 공격을 시작할 수 있습니다. 공격자는 원형 개체 체인을 생성한 후 직렬화된 개체를 프로그램에 전송하여 역직렬화할 수 있으며, 이로 인해 hashCode 메서드 호출 횟수가 기하급수적으로 증가하여 스택 오버플로 예외가 발생하게 됩니다. 예를 들어, 다음과 같은 경우가 잘 설명될 수 있습니다.

    Set root = new HashSet();
    Set s1 = root;
    Set s2 = new HashSet();
    for (int i = 0; i < 100; i++) {
        Set t1 = new HashSet();
        Set t2 = new HashSet();
        t1.add("foo"); // 使 t2 不等于 t1
        s1.add(t1);
        s1.add(t2);
        s2.add(t1);
        s2.add(t2);
        s1 = t1;
        s2 = t2;
    }

2015년 FoxGlove 보안 보안 팀의 breenmachine은 긴 블로그를 게시했는데, 주요 내용은 다음과 같습니다. Java 역직렬화 취약점은 Apache Commons Collections를 통해 공격될 수 있습니다. 한때 WebLogic, WebSphere, JBoss, Jenkins 및 OpenNMS의 최신 버전을 휩쓸었고 모든 주요 Java 웹 서버가 총을 쐈습니다.

실제로 Apache Commons Collections는 Java 표준 라이브러리의 컬렉션 구조를 확장하고 많은 강력한 데이터 구조 유형을 제공하며 다양한 컬렉션 도구 클래스를 구현하는 타사 기본 라이브러리입니다.

공격 원리는 다음과 같습니다. Apache Commons Collections는 연결된 임의 클래스 함수 반사 호출을 허용하고, 공격자는 "Java 직렬화 프로토콜을 구현"하는 포트를 통해 공격 코드를 서버에 업로드한 다음 Apache Commons Collections의 TransformedMap을 실행합니다.

그렇다면 이 취약점을 어떻게 해결하셨나요?

많은 직렬화 프로토콜은 객체를 저장하고 검색하기 위한 데이터 구조 세트를 개발했습니다. 예를 들어 JSON 직렬화, ProtocolBuf 등은 일부 기본 유형과 배열 데이터 유형만 지원하므로 일부 불확실한 인스턴스를 생성하기 위한 역직렬화를 피할 수 있습니다. 설계는 간단하지만 대부분의 최신 시스템의 데이터 전송 요구 사항을 충족하기에 충분합니다.

역직렬화된 개체의 화이트리스트를 통해 역직렬화된 개체를 제어할 수도 있으며, 해결클래스 메서드를 재정의하고 이 메서드에서 개체 이름을 확인할 수 있습니다. 코드는 다음과 같습니다.

    @Override
    protected Class resolveClass(ObjectStreamClass desc) throws IOException,ClassNotFoundException {
        if (!desc.getName().equals(Bicycle.class.getName())) {
            throw new InvalidClassException(
                    "Unauthorized deserialization attempt", desc.getName());
        }
        return super.resolveClass(desc);
    }

2.3 직렬화된 스트림이 너무 큼

직렬화된 이진 스트림의 크기는 직렬화 성능을 반영할 수 있습니다. 직렬화된 이진 배열이 클수록 더 많은 저장 공간을 차지하며 저장 하드웨어 비용도 높아집니다. 네트워크 전송을 수행하는 경우 더 많은 대역폭이 점유되어 시스템 처리량에 영향을 미칩니다.

ObjectOutputStream은 Java 직렬화에서 객체를 바이너리 인코딩으로 변환하는 데 사용되므로 이 직렬화 메커니즘으로 구현된 바이너리 인코딩으로 완성된 바이너리 배열의 크기와 NIO의 ByteBuffer로 구현된 바이너리 인코딩으로 완성된 배열의 크기에 차이가 있습니까? ? ?

간단한 예를 통해 이를 확인할 수 있습니다.

    User user = new User();
    user.setUserName("test");
    user.setPassword("test");

    ByteArrayOutputStream os =new ByteArrayOutputStream();
    ObjectOutputStream out = new ObjectOutputStream(os);
    out.writeObject(user);

    byte[] testByte = os.toByteArray();
    System.out.print("ObjectOutputStream 字节编码长度:" + testByte.length + "\n");
    ByteBuffer byteBuffer = ByteBuffer.allocate( 2048);

    byte[] userName = user.getUserName().getBytes();
    byte[] password = user.getPassword().getBytes();
    byteBuffer.putInt(userName.length);
    byteBuffer.put(userName);
    byteBuffer.putInt(password.length);
    byteBuffer.put(password);
        
    byteBuffer.flip();
    byte[] bytes = new byte[byteBuffer.remaining()];
    System.out.print("ByteBuffer 字节编码长度:" + bytes.length+ "\n");

작업 결과:

ObjectOutputStream 字节编码长度:99
ByteBuffer 字节编码长度:16

여기서 우리는 Java 직렬화로 구현된 바이너리 인코딩으로 완성된 바이너리 배열의 크기가 ByteBuffer로 구현된 바이너리 인코딩으로 완성된 바이너리 배열의 크기보다 몇 배 더 크다는 것을 분명히 알 수 있습니다. 따라서 Java 시퀀스 이후의 스트림은 더 커지고 결국 시스템 처리량에 영향을 미치게 됩니다.

2.4 직렬화 성능이 너무 나쁨

직렬화 속도는 직렬화 성능을 나타내는 중요한 지표이기도 하며, 직렬화 속도가 느리면 네트워크 통신 효율성에 영향을 미쳐 시스템 응답 시간이 늘어납니다. 위의 예를 사용하여 NIO의 Java 직렬화 및 ByteBuffer 인코딩 성능을 비교해 보겠습니다.

    User user = new User();
    user.setUserName("test");
    user.setPassword("test");

    long startTime = System.currentTimeMillis();
    for(int i=0; i<1000; i++) {
        ByteArrayOutputStream os =new ByteArrayOutputStream();
        ObjectOutputStream out = new ObjectOutputStream(os);
        out.writeObject(user);
        out.flush();
        out.close();
        byte[] testByte = os.toByteArray();
        os.close();
    }

    long endTime = System.currentTimeMillis();
    System.out.print("ObjectOutputStream 序列化时间:" + (endTime - startTime) + "\n");
    long startTime1 = System.currentTimeMillis();
    for(int i=0; i<1000; i++) {
        ByteBuffer byteBuffer = ByteBuffer.allocate( 2048);

        byte[] userName = user.getUserName().getBytes();
        byte[] password = user.getPassword().getBytes();
        byteBuffer.putInt(userName.length);
        byteBuffer.put(userName);
        byteBuffer.putInt(password.length);
        byteBuffer.put(password);

        byteBuffer.flip();
        byte[] bytes = new byte[byteBuffer.remaining()];
    }
    long endTime1 = System.currentTimeMillis();
    System.out.print("ByteBuffer 序列化时间:" + (endTime1 - startTime1)+ "\n");

작업 결과:

ObjectOutputStream 序列化时间:29
ByteBuffer 序列化时间:6

위의 사례를 통해 Java 직렬화의 인코딩 시간이 ByteBuffer의 인코딩 시간보다 훨씬 길다는 것을 분명히 알 수 있습니다.

3. Java 직렬화를 Protobuf 직렬화로 대체

현재 업계에는 우수한 직렬화 프레임워크가 많이 있으며 대부분은 Java 기본 직렬화의 일부 결함을 방지합니다. 예를 들어 FastJson, Kryo, Protobuf, Hessian 등이 최근 몇 년간 인기를 끌었습니다. Java 직렬화를 대체하는 방법을 찾을 수 있습니다. 여기서는 Protobuf 직렬화 프레임워크를 사용하는 것이 좋습니다.

Protobuf는 여러 언어를 지원하는 Google에서 출시한 직렬화 프레임워크입니다. 현재 주류 웹사이트의 직렬화 프레임워크 성능 비교 테스트 보고서에서 Protobuf는 시간이 많이 걸리는 인코딩 및 디코딩, 바이너리 스트림 압축 크기 측면에서 최고 수준으로 평가됩니다.

Protobuf는 접미사가 .proto인 파일을 기반으로 합니다. 이 파일은 필드와 필드 유형을 설명하며 도구는 다양한 언어로 데이터 구조 파일을 생성할 수 있습니다. 데이터 개체를 직렬화할 때 Protobuf는 .proto 파일 설명을 통해 프로토콜 버퍼 형식으로 인코딩을 생성합니다.

여기서 조금 확장하기 위해 프로토콜 버퍼 저장 형식과 그 구현 원리에 대해 이야기하겠습니다.

프로토콜 버퍼는 가볍고 효율적인 구조화된 데이터 저장 형식입니다. TLV(identity-length-field value) 데이터 형식을 사용하여 데이터를 저장하고, T는 필드의 양수 시퀀스(태그)를 나타내며, 프로토콜 버퍼는 개체의 각 필드를 양수 시퀀스와 연결하며, 해당 관계는 생성된 코드에 의해 제공됩니다. 직렬화 시 필드 이름을 대체하기 위해 정수 값이 사용되므로 전송 트래픽을 크게 줄일 수 있습니다. L은 일반적으로 1바이트만 차지하는 Value의 바이트 길이를 나타내고, V는 필드 값의 인코딩된 값을 나타냅니다. 이 데이터 형식에는 구분 기호와 공백이 필요하지 않으며 중복되는 필드 이름이 줄어듭니다.

Protobuf는 Java/Python 및 기타 언어의 거의 모든 기본 데이터 유형을 매핑할 수 있는 자체 인코딩 방법을 정의합니다. 다양한 인코딩 방법은 다양한 데이터 유형에 해당하며 다양한 저장 형식도 사용할 수 있습니다. 아래 그림과 같이:

 Varint로 인코딩된 데이터를 저장하는 경우 데이터가 차지하는 저장 공간이 고정되어 있으므로 바이트 길이 Length를 저장할 필요가 없으므로 실제로 프로토콜 버퍼의 저장 방법은 T-V이므로 저장 공간이 1바이트 줄어듭니다.

Protobuf에서 정의한 Varint 인코딩 방식은 가변 길이 인코딩 방식으로, 각 데이터 타입의 바이트의 마지막 비트는 플래그 비트(msb)로 0과 1로 표현되며, 0은 현재 바이트가 마지막 1바이트, 1은 이 숫자 뒤에 1바이트가 더 있음을 의미합니다.

int32 유형 숫자의 경우 일반적으로 표현하는 데 4바이트가 필요합니다. Varint 인코딩 방법을 사용하는 경우 매우 작은 int32 유형 숫자의 경우 1바이트를 사용하여 표현할 수 있습니다. 대부분의 정수형 데이터의 경우 일반적으로 256보다 작으므로 이 작업을 수행하면 데이터를 효과적으로 압축할 수 있습니다.

int32는 양수와 음수를 나타내는 것으로 알고 있으므로 일반적으로 마지막 비트는 양수와 음수를 나타내는 데 사용됩니다. 이제 Varint 인코딩 방법은 마지막 비트를 플래그 비트로 사용하므로 양수와 음수를 어떻게 표현할 수 있을까요? 음수를 표현하기 위해 int32/int64를 사용한다면 이를 표현하기 위해 여러 바이트가 필요하며, Varint 인코딩 방식에서는 Zigzag 인코딩을 통해 변환한 후 음수를 부호 없는 숫자로 변환한 후 sint32/sint64를 사용하여 음수를 표현하게 되는데, 이는 크게 개선될 수 있습니다.인코딩된 바이트 수를 줄이세요.

Protobuf의 이러한 데이터 저장 형식은 데이터를 압축하고 저장하는 데 좋은 효과가 있을 뿐만 아니라 인코딩 및 디코딩 성능 측면에서도 매우 효율적입니다. Protobuf의 인코딩 및 디코딩 프로세스는 .proto 파일 형식 및 프로토콜 버퍼의 고유한 인코딩 형식과 결합되며 인코딩 및 디코딩을 완료하려면 간단한 데이터 작업과 변위 작업만 필요합니다. Protobuf의 전반적인 성능은 매우 좋다고 할 수 있습니다.

4. 요약

네트워크 전송이든 디스크 영구 데이터이든 데이터를 바이트코드로 인코딩해야 하며, 프로그램에서 일반적으로 사용하는 데이터는 메모리 기반 데이터 유형이나 객체이므로 이진 바이트 스트림 인코딩을 통해 이러한 데이터를 바이트코드로 변환해야 합니다. ; 수신하거나 재사용해야 하는 경우 바이너리 바이트 스트림을 메모리 데이터로 변환하기 위해 디코딩해야 합니다. 우리는 일반적으로 이 두 프로세스를 직렬화 및 역직렬화라고 부릅니다.

Java의 기본 직렬화는 직렬화 가능 인터페이스를 통해 구현됩니다. 클래스가 인터페이스를 구현하고 기본 버전 번호를 생성하는 한 수동으로 설정할 필요가 없으며 클래스가 자동으로 직렬화 및 역직렬화를 구현합니다.

Java의 기본 직렬화는 구현이 편리하지만 보안 허점, 언어 간 호환성, 성능 저하 등의 결함이 있으므로 Java 직렬화를 사용하지 않는 것이 좋습니다.

주류 직렬화 프레임워크를 살펴보면 FastJson, Protobuf 및 Kryo는 매우 독특하며 성능과 보안이 업계에서 인정을 받았습니다. 우리는 자체 비즈니스를 결합하여 적합한 직렬화 프레임워크를 선택하여 시스템 성능 순서를 최적화할 수 있습니다.

5. 생각하는 질문

이것은 싱글톤 패턴을 사용하여 구현된 클래스입니다. 이 클래스에 Java의 직렬화 가능 인터페이스를 구현하면 여전히 싱글톤인가요? Java의 직렬화 가능 인터페이스를 구현하는 싱글톤을 작성한다면 어떻게 작성하시겠습니까?

public class Singleton implements Serializable{
 
    private final static Singleton singleInstance = new Singleton();
 
    private Singleton(){}
 
    public static Singleton getInstance(){
       return singleInstance; 
    }
}

추천

출처blog.csdn.net/qq_34272760/article/details/132345202