Netty核心原理剖析与RPC实践26-30

Netty核心原理剖析与RPC实践26-30

26 服务治理:服务发现与负载均衡机制的实现

在分布式系统中,服务消费者和服务提供者都存在多个节点,如果服务提供者出现部分机器节点负载过高,那么可能会导致该节点上接收的请求处理超时,从而导致服务提供者整体可用率下降。所以 RPC 框架需要实现合理的负载均衡算法,那么如何控制流量能够均匀地分摊到每个服务提供者呢?今天这节课我们便讨论 RPC 框架负载均衡机制的相关实现。

源码参考地址:mini-rpc

注册中心选型

服务消费者在发起 RPC 调用之前,需要知道服务提供者有哪些节点是可用的,而且服务提供者节点会存在上线和下线的情况。所以服务消费者需要感知服务提供者的节点列表的动态变化,在 RPC 框架中一般采用注册中心来实现服务的注册和发现。

目前主流的注册中心有 ZooKeeper、Eureka、Etcd、Consul、Nacos 等,选择一个高性能、高可用的注册中心对 RPC 框架至关重要。说到高可用自然离不开 CAP 理论,一致性 Consistency、可用性 Availability 和分区容忍性 Partition tolerance 是无法同时满足的,注册中心一般分为 CP 类型注册中心和 AP 类型注册中心。使用最为广泛的 Zookeeper 就是 CP 类型的注册中心,集群中会有一个节点作为 Leader,如果 Leader 节点挂了,会重新进行 Leader 选举,ZooKeeper 保证了所有节点的强一致性,但是在 Leader 选举的过程中是无法对外提供服务的,牺牲了部分可用性。Eureka 是典型的 AP 类型注册中心,在实现服务发现的场景下有很大的优势,整个集群是不存在 Leader、Flower 概念的,如果其中一个节点挂了,请求会立刻转移到其他节点上。可能会存在的问题是如果不同分区无法进行节点通信,那么可能会造成节点之间的数据是有差异的,所以 AP 类型的注册中心通过牺牲强一致性来保证高可用性 。

对于 RPC 框架而言,即使注册中心出现问题,也不应该影响服务的正常调用,所以 AP 类型的注册中心在该场景下相比于 CP 类型的注册中心更有优势。对于成熟的 RPC 框架而言,会提供多种注册中心的选择,接下来我们便设计一个通用的注册中心接口,然后每种注册中心的实现都按该接口规范行扩展。

注册中心接口设计

注册中心主要用于存储服务的元数据信息,首先我们需要将服务元数据信息封装成一个对象,该对象包括服务名称、服务版本、服务地址和服务端口号,如下所示:

@Data
public class ServiceMeta {
    private String serviceName;
    private String serviceVersion;
    private String serviceAddr;
    private int servicePort;
}

接下来我们提供一个通用的注册中心接口,该接口主要的操作对象是 ServiceMeta,不应该与其他任何第三方的注册中心工具库有任何联系,如下所示。

public interface RegistryService {
    void register(ServiceMeta serviceMeta) throws Exception;
    void unRegister(ServiceMeta serviceMeta) throws Exception;
    ServiceMeta discovery(String serviceName, int invokerHashCode) throws Exception;
    void destroy() throws IOException;
}

RegistryService 接口包含注册中心四个基本操作:服务注册 register服务注销 unRegister服务发现 discovery注册中心销毁 destroy。下面我们以 ZooKeeper 注册中心实现为例,逐一实现上面四个接口。

注册中心初始化和销毁

Zookeeper 常用的开源客户端工具包有 ZkClient 和 Apache Curator,目前都推荐使用 Apache Curator 客户端。Apache Curator 相比于 ZkClient,不仅提供的功能更加丰富,而且它的抽象层次更高,提供了更加易用的 API 接口以及 Fluent 流式编程风格。在使用 Apache Curator 之前,我们需要在 pom.xml 中引入 Maven 依赖,如下所示:

<dependency>
    <groupId>org.apache.curator</groupId>
    <artifactId>curator-framework</artifactId>
    <version>2.12.0</version>
    <exclusions>
        <exclusion>
            <groupId>log4j</groupId>
            <artifactId>log4j</artifactId>
        </exclusion>
    </exclusions>
</dependency>
<dependency>
    <groupId>org.apache.curator</groupId>
    <artifactId>curator-recipes</artifactId>
    <version>2.12.0</version>
</dependency>
<dependency>
    <groupId>org.apache.curator</groupId>
    <artifactId>curator-x-discovery</artifactId>
    <version>2.12.0</version>
</dependency>

需要注意的是,Apache Curator 需要和 Zookeeeper 版本搭配使用,本项目使用的是 Zookeeeper 3.4.14,关于版本兼容性你需要更多关注 Curator 官网(https://curator.apache.org)的版本更新说明。

首先我们需要构建 Zookeeeper 的客户端,使用 Apache Curator 初始化 Zookeeeper 客户端的基于用法大多都与如下代码类似:

public class ZookeeperRegistryService implements RegistryService {
    public static final int BASE_SLEEP_TIME_MS = 1000;
    public static final int MAX_RETRIES = 3;
    public static final String ZK_BASE_PATH = "/mini_rpc";
    private final ServiceDiscovery<ServiceMeta> serviceDiscovery;
    public ZookeeperRegistryService(String registryAddr) throws Exception {
        CuratorFramework client = CuratorFrameworkFactory.newClient(registryAddr, new ExponentialBackoffRetry(BASE_SLEEP_TIME_MS, MAX_RETRIES));
        client.start();
        JsonInstanceSerializer<ServiceMeta> serializer = new JsonInstanceSerializer<>(ServiceMeta.class);
        this.serviceDiscovery = ServiceDiscoveryBuilder.builder(ServiceMeta.class)
                .client(client)
                .serializer(serializer)
                .basePath(ZK_BASE_PATH)
                .build();
        this.serviceDiscovery.start();
    }
}

通过 CuratorFrameworkFactory 采用工厂模式创建 CuratorFramework 实例,构造客户端唯一需你指定的是重试策略,创建完 CuratorFramework 实例之后需要调用 start() 进行启动。然后我们需要创建 ServiceDiscovery 对象,由 ServiceDiscovery 完成服务的注册和发现,在系统退出的时候需要将初始化的实例进行关闭,destroy() 方法实现非常简单,代码如下所示:

@Override
public void destroy() throws IOException {
    serviceDiscovery.close();
}

服务注册实现

初始化得到 ServiceDiscovery 实例之后,我们就可以将服务元数据信息 ServiceMeta 发布到注册中心,register() 方法的代码实现如下所示:

@Override
public void register(ServiceMeta serviceMeta) throws Exception {
    ServiceInstance<ServiceMeta> serviceInstance = ServiceInstance
            .<ServiceMeta>builder()
            .name(RpcServiceHelper.buildServiceKey(serviceMeta.getServiceName(), serviceMeta.getServiceVersion()))
            .address(serviceMeta.getServiceAddr())
            .port(serviceMeta.getServicePort())
            .payload(serviceMeta)
            .build();
    serviceDiscovery.registerService(serviceInstance);
}

ServiceInstance 对象代表一个服务实例,它包含名称 name、唯一标识 id、地址 address、端口 port 以及用户自定义的可选属性 payload,我们有必要了解 ServiceInstance 在 Zookeeper 服务器中的存储形式,如下图所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

一般来说,我们会将相同版本的 RPC 服务归类在一起,所以可以将 ServiceInstance 的名称 name 根据服务名称和服务版本进行赋值,如下所示。

public class RpcServiceHelper {
    public static String buildServiceKey(String serviceName, String serviceVersion) {
        return String.join("#", serviceName, serviceVersion);
    }
}

在《服务发布与订阅:搭建生产者和消费者的基础框架》课程中,我们讲解了 RpcProvider 在启动过程中是如何根据 @RpcService 注解识别需要发布的服务,现在我们可以使用 RegistryService 接口的 register() 方法将识别出的服务进行发布了,完善后的 RpcProvider#postProcessAfterInitialization() 方法实现如下。

@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
    RpcService rpcService = bean.getClass().getAnnotation(RpcService.class);
    if (rpcService != null) {
        String serviceName = rpcService.serviceInterface().getName();
        String serviceVersion = rpcService.serviceVersion();
        try {
            ServiceMeta serviceMeta = new ServiceMeta();
            serviceMeta.setServiceAddr(serverAddress);
            serviceMeta.setServicePort(serverPort);
            serviceMeta.setServiceName(serviceName);
            serviceMeta.setServiceVersion(serviceVersion);
            serviceRegistry.register(serviceMeta); // 注册服务
            rpcServiceMap.put(RpcServiceHelper.buildServiceKey(serviceMeta.getServiceName(), serviceMeta.getServiceVersion()), bean);
        } catch (Exception e) {
            log.error("failed to register service {}#{}", serviceName, serviceVersion, e);
        }
    }
    return bean;
}

至此,服务提供者在启动后就可以将 @RpcService 注解修饰的服务发布到注册中心了,下面我们继续看看服务消费者应当如何通过合理的负载均衡算法得到合适的服务节点呢?在此之前,我们先来了解下负载均衡算法的基础知识。

负载均衡算法基础

服务消费者在发起 RPC 调用之前,需要感知有多少服务端节点可用,然后从中选取一个进行调用。之前我们提到了几种常用的负载均衡策略:Round-Robin 轮询、Weighted Round-Robin 权重轮询、Least Connections 最少连接数、Consistent Hash 一致性 Hash 等。本节课我们讨论的主角是基于一致性 Hash 的负载均衡算法,一致性 Hash 算法可以保证每个服务节点分摊的流量尽可能均匀,而且能够把服务节点扩缩容带来的影响降到最低。下面我们一起看下一致性 Hash 算法的设计思路。

在服务端节点扩缩容时,一致性 Hash 算法会尽可能保证客户端发起的 RPC 调用还是固定分配到相同的服务节点上。一致性 Hash 算法是采用哈希环来实现的,通过 Hash 函数将对象和服务器节点放置在哈希环上,一般来说服务器可以选择 IP + Port 进行 Hash,如下图所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图中 C1、C2、C3、C4 是客户端对象,N1、N2、N3 为服务节点,然后在哈希环中顺时针查找距离客户端对象 Hash 值最近的服务节点,即为客户端对应要调用的服务节点。假设现在服务节点扩容了一台 N4,经过 Hash 函数计算将其放入到哈希环中,哈希环变化如下图所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

此时 N2 和 N4 之间的客户端对象需要重新进行分配,可以看出只有 C3 会被分配到新的节点 N4 上,其他的都保持不变。服务节点下线与上线的处理过程是类似的,你可以自行分析下服务节点下线时哈希环是如何变化的。

如果服务节点的数量很少,不管 Hash 算法如何,很大可能存在服务节点负载不均的现象。而且上图中在新增服务节点 N4 时,仅仅分担了 N1 节点的流量,其他节点并没有流量变化。为了解决上述问题,一致性 Hash 算法一般会引入虚拟节点的概念。如下图所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图中相同颜色表示同一组虚拟服务器,它们经过 Hash 函数计算后被均匀放置在哈希环中。如果真实的服务节点越多,那么所需的虚拟节点就越少。在为客户端对象分配节点的时候,需要顺时针从哈希环中找到距离最近的虚拟节点,然后即可确定真实的服务节点。

有了上述一致性 Hash 算法的基础知识,下面我们看看一致性 Hash 算法是如何实现的。

负载均衡算法实现

与注册中心类似,我们也首先定义一个通用的负载均衡接口,Round-Robin 轮询、一致性 Hash 等负载均衡算法都需要实现该接口,接口的定义如下所示:

public interface ServiceLoadBalancer<T> {
    T select(List<T> servers, int hashCode);
}

select() 方法的传入参数是一批服务节点以及客户端对象的 hashCode,针对 Zookeeper 的场景,我们可以实现一个比较通用的一致性 Hash 算法。

public class ZKConsistentHashLoadBalancer implements ServiceLoadBalancer<ServiceInstance<ServiceMeta>> {
    private final static int VIRTUAL_NODE_SIZE = 10;
    private final static String VIRTUAL_NODE_SPLIT = "#";
    @Override
    public ServiceInstance<ServiceMeta> select(List<ServiceInstance<ServiceMeta>> servers, int hashCode) {
        TreeMap<Integer, ServiceInstance<ServiceMeta>> ring = makeConsistentHashRing(servers); // 构造哈希环
        return allocateNode(ring, hashCode); // 根据 hashCode 分配节点
    }
    private ServiceInstance<ServiceMeta> allocateNode(TreeMap<Integer, ServiceInstance<ServiceMeta>> ring, int hashCode) {
        Map.Entry<Integer, ServiceInstance<ServiceMeta>> entry = ring.ceilingEntry(hashCode); // 顺时针找到第一个节点
        if (entry == null) {
            entry = ring.firstEntry(); // 如果没有大于 hashCode 的节点,直接取第一个
        }
        return entry.getValue();
    }
    private TreeMap<Integer, ServiceInstance<ServiceMeta>> makeConsistentHashRing(List<ServiceInstance<ServiceMeta>> servers) {
        TreeMap<Integer, ServiceInstance<ServiceMeta>> ring = new TreeMap<>();
        for (ServiceInstance<ServiceMeta> instance : servers) {
            for (int i = 0; i < VIRTUAL_NODE_SIZE; i++) {
                ring.put((buildServiceInstanceKey(instance) + VIRTUAL_NODE_SPLIT + i).hashCode(), instance);
            }
        }
        return ring;
    }
    private String buildServiceInstanceKey(ServiceInstance<ServiceMeta> instance) {
        ServiceMeta payload = instance.getPayload();
        return String.join(":", payload.getServiceAddr(), String.valueOf(payload.getServicePort()));
    }
}

JDK 提供了 TreeMap 数据结构,可以非常方便地构造哈希环。通过计算出每个服务实例 ServiceInstance 的地址和端口对应的 hashCode,然后直接放入 TreeMap 中,TreeMap 会对 hashCode 默认从小到大进行排序。在为客户端对象分配节点时,通过 TreeMap 的 ceilingEntry() 方法找出大于或等于客户端 hashCode 的第一个节点,即为客户端对应要调用的服务节点。如果没有找到大于或等于客户端 hashCode 的节点,那么直接去 TreeMap 中的第一个节点即可。

至此,一个基本的一致性 Hash 算法已经实现完了,接下来我们就可以把注册中心的服务发现 discovery() 方法补充完整了。

服务发现实现

服务发现的实现思路比较简单,首先找出被调用服务所有的节点列表,然后通过 ZKConsistentHashLoadBalancer 提供的一致性 Hash 算法找出相应的服务节点。具体代码实现如下:

@Override
public ServiceMeta discovery(String serviceName, int invokerHashCode) throws Exception {
    Collection<ServiceInstance<ServiceMeta>> serviceInstances = serviceDiscovery.queryForInstances(serviceName);
    ServiceInstance<ServiceMeta> instance = new ZKConsistentHashLoadBalancer().select((List<ServiceInstance<ServiceMeta>>) serviceInstances, invokerHashCode);
    if (instance != null) {
        return instance.getPayload();
    }
    return null;
}

服务消费者通过动态代理发起 RPC 调用之前,需要通过服务发现接口获取到可调用的节点,在下节课《动态代理:为用户屏蔽 RPC 调用的底层细节》会有相应的代码实现,本节课先不做展开。

总结

服务注册和发现是 RPC 框架中非常重要的一环,本节课我们设计了通用的注册中心接口,并给出了 Zookeeper 场景下的默认实现。在服务发现中需要使用到负载均衡算法,其中一致性 Hash 算法在很多场景中被广泛使用,它可以保证每个服务节点分摊的流量尽可能均匀,而且能够把服务节点扩缩容带来的影响降到最低。关于一致性 Hash 算法的实现原理务必掌握,这也是面试中的高频问题。

最后留两个课后任务:

  1. 如果你对 Eureka 或者其他类型的注册中心比较熟悉,你可以尝试扩展 RegistryService 接口并实现它。
  2. 在一致性 Hash 算法的实现中,我们只是简单使用了服务实例的 hashCode 作为哈希环的构建依据,更好的 Hash 函数可以参考更加高性能的 MurmurHash,Guava 工具库中就有默认实现,你可以引入 MurmurHash 对上文中的一致性 Hash 算法实现进行优化。

27 动态代理:为用户屏蔽 RPC 调用的底层细节

动态代理在 RPC 框架的实现中起到了至关重要的作用,它可以帮助用户屏蔽 RPC 调用时底层网络通信、服务发现、负载均衡等具体细节,这些对用户来说并没有什么意义。你在平时项目开发中使用 RPC 框架的时候,只需要调用接口方法,然后就拿到了返回结果,你是否好奇 RPC 框架是如何完成整个调用流程的呢?今天这节课我们就一起来完成 RPC 框架的最后一部分内容:RPC 请求调用和处理,看看如何使用动态代理机制完成这个神奇的操作。

源码参考地址:mini-rpc

动态代理基础

为什么需要代理模式呢?代理模式的优势是可以很好地遵循设计模式中的开放封闭原则,对扩展开发,对修改关闭。你不需要关注目标类的实现细节,通过代理模式可以在不修改目标类的情况下,增强目标类功能的行为。Spring AOP 是 Java 动态代理机制的经典运用,我们在项目开发中经常使用 AOP 技术完成一些切面服务,如耗时监控、事务管理、权限校验等,所有操作都是通过切面扩展实现的,不需要对源代码有所侵入。

动态代理是一种代理模式,它提供了一种能够在运行时动态构建代理类以及动态调用目标方法的机制。为什么称为动态是因为代理类和被代理对象的关系是在运行时决定的,代理类可以看作是对被代理对象的包装,对目标方法的调用是通过代理类来完成的。所以通过代理模式可以有效地将服务提供者和服务消费者进行解耦,隐藏了 RPC 调用的具体细节,如下图所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

接下来我们一起探讨下动态代理的实现原理,以及常用的 JDK 动态代理、Cglib 动态代理是如何使用的。

JDK 动态代理

JDK 动态代理实现依赖 java.lang.reflect 包中的两个核心类:InvocationHandler 接口Proxy 类

  • InvocationHandler 接口

JDK 动态代理所代理的对象必须实现一个或者多个接口,生成的代理类也是接口的实现类,然后通过 JDK 动态代理是通过反射调用的方式代理类中的方法,不能代理接口中不存在的方法。每一个动态代理对象必须提供 InvocationHandler 接口的实现类,InvocationHandler 接口中只有一个 invoke() 方法。当我们使用代理对象调用某个方法的时候,最终都会被转发到 invoke() 方法执行具体的逻辑。invoke() 方法的定义如下:

public interface InvocationHandler {
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable;
}

其中 proxy 参数表示需要代理的对象,method 参数表示代理对象被调用的方法,args 参数为被调用方法所需的参数。

  • Proxy 类

Proxy 类可以理解为动态创建代理类的工厂类,它提供了一组静态方法和接口用于动态生成对象和代理类。通常我们只需要使用 newProxyInstance() 方法,方法定义如下所示。

public static Object newProxyInstance(ClassLoader loader, Class<?>[] interfaces, InvocationHandler h) {
    Objects.requireNonNull(h);
    Class<?> caller = System.getSecurityManager() == null ? null : Reflection.getCallerClass();
    Constructor<?> cons = getProxyConstructor(caller, loader, interfaces);
    return newProxyInstance(caller, cons, h);
}

其中 loader 参数表示需要装载的类加载器 ClassLoader,interfaces 参数表示代理类实现的接口列表,然后你还需要提供一个 InvocationHandler 接口类型的处理器,所有动态代理类的方法调用都会交由该处理器进行处理,这是动态代理的核心所在。

下面我们用一个简单的例子模拟数据库操作的事务管理,从而学习 JDK 动态代理的具体使用方式。首先我们定义数据库表 User 的接口以及实现类:

public interface UserDao {
    void insert();
}
public class UserDaoImpl implements UserDao {
    @Override
    public void insert() {
        System.out.println("insert user success.");
    }
}

接下来我们实现一个事务管理的工具类,在数据库操作执行前后执行事务操作,代码如下所示:

public class TransactionProxy {
    private Object target;
    public TransactionProxy(Object target) {
        this.target = target;
    }
    public Object genProxyInstance() {
        return Proxy.newProxyInstance(target.getClass().getClassLoader(),
                target.getClass().getInterfaces(),
                (proxy, method, args) -> {
                    System.out.println("start transaction");
                    Object result = method.invoke(target, args);
                    System.out.println("submit transaction");
                    return result;
                });
    }
}

在 genProxyInstance() 方法中我们最主要的是实现 InvocationHandler 接口,在真实对象方法执行方法调用的前后可以扩展自定义行为,以此来增强目标类的功能。为了便于理解,上述例子中我们只简单打印了控制台日志,可以通过测试类看看 JDK 动态代理的实际效果:

public class TransactionProxyTest {
    @Test
    public void testProxy() {
        UserDao userDao = new UserDaoImpl();
        UserDao proxyInstance = (UserDao) new TransactionProxy(userDao).genProxyInstance();
        proxyInstance.insert();
    }
}

程序运行结果如下:

start transaction
insert user success.
submit transaction
Cglib 动态代理

Cglib 动态代理是基于 ASM 字节码生成框架实现的第三方工具类库,相比于 JDK 动态代理,Cglib 动态代理更加灵活,它是通过字节码技术生成的代理类,所以代理类的类型是不受限制的。使用 Cglib 代理的目标类无须实现任何接口,可以做到对目标类零侵入。

Cglib 动态代理是对指定类以字节码的方式生成一个子类,并重写其中的方法,以此来实现动态代理。因为 Cglib 动态代理创建的是目标类的子类,所以目标类必须要有无参构造函数,而且目标类不要用 final 进行修饰。

在我们使用 Cglib 动态代理之前,需要引入相关的 Maven 依赖,如下所示。如果你的项目中已经引入了 spring-core 的依赖,则已经包含了 Cglib 的相关依赖,无须再次引入。

<dependency>
    <groupId>cglib</groupId>
    <artifactId>cglib</artifactId>
    <version>3.3.0</version>
</dependency>

下面我们还是使用上述数据库事务管理的例子,从而学习 JDK 动态代理的具体使用方式。UserDao 接口和实现类保持不变,TransactionProxy 需要重新实现,代码如下所示:

public class CglibTransactionProxy implements MethodInterceptor {
    private Object target;
    public CglibTransactionProxy(Object target) {
        this.target = target;
    }
    public Object genProxyInstance() {
        Enhancer enhancer = new Enhancer();
        enhancer.setSuperclass(target.getClass());
        enhancer.setCallback(this);
        return enhancer.create();
    }
    @Override
    public Object intercept(Object object, Method method, Object[] args, MethodProxy methodProxy) throws Throwable {
        System.out.println("start transaction");
        Object result = methodProxy.invokeSuper(object, args);
        System.out.println("submit transaction");
        return result;
    }
}

Cglib 动态代理的实现需要依赖两个核心组件:MethodInterceptor 接口和 Enhancer 类,类似于 JDK 动态代理中的InvocationHandler 接口Proxy 类

  • MethodInterceptor 接口

MethodInterceptor 接口只有 intercept() 一个方法,所有被代理类的方法执行最终都会转移到 intercept() 方法中进行行为增强,真实方法的执行逻辑则通过 Method 或者 MethodProxy 对象进行调用。

  • Enhancer 类

Enhancer 类是 Cglib 中的一个字节码增强器,它为我们对代理类进行扩展时提供了极大的便利。Enhancer 类的本质是在运行时动态为代理类生成一个子类,并且拦截代理类中的所有方法。我们可以通过 Enhancer 设置 Callback 接口对代理类方法执行的前后执行一些自定义行为,其中 MethodInterceptor 接口是我们最常用的 Callback 操作。

Cglib 动态代理的测试类与 JDK 动态代理测试类大同小异,程序输出结果也是一样的。测试类代码如下所示:

public class CglibTransactionProxyTest {
    public static void main(String[] args) {
        UserDao userDao = new UserDaoImpl();
        UserDao proxyInstance = (UserDao) new CglibTransactionProxy(userDao).genProxyInstance();
        proxyInstance.insert();
    }
}

学习完动态代理的基础后,我们接下来实现 RPC 框架中的请求调用和处理就易如反掌啦,首先我们先从服务消费者如何通过动态代理发起 RPC 请求入手。

服务消费者动态代理实现

在《服务发布与订阅:搭建生产者和消费者的基础框架》课程中,我们讲解了 @RpcReference 注解的实现过程。通过一个自定义的 RpcReferenceBean 完成了所有执行方法的拦截,RpcReferenceBean 中 init() 方法是当时留下的 TODO 内容,这里就是代理对象的创建入口,代理对象创建如下所示.

public class RpcReferenceBean implements FactoryBean<Object> {
    // 省略其他代码    public void init() throws Exception {
        RegistryService registryService = RegistryFactory.getInstance(this.registryAddr, RegistryType.valueOf(this.registryType));
        this.object = Proxy.newProxyInstance(
                interfaceClass.getClassLoader(),
                new Class<?>[]{interfaceClass},
                new RpcInvokerProxy(serviceVersion, timeout, registryService));
    }    // 省略其他代码
}

RpcInvokerProxy 处理器是实现动态代理逻辑的核心所在,其中包含 RPC 调用时底层网络通信、服务发现、负载均衡等具体细节,我们详细看下如何实现 RpcInvokerProxy 处理器,代码如下所示:

public class RpcInvokerProxy implements InvocationHandler {
    private final String serviceVersion;
    private final long timeout;
    private final RegistryService registryService;
    public RpcInvokerProxy(String serviceVersion, long timeout, RegistryService registryService) {
        this.serviceVersion = serviceVersion;
        this.timeout = timeout;
        this.registryService = registryService;
    }
    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        // 构造 RPC 协议对象
        MiniRpcProtocol<MiniRpcRequest> protocol = new MiniRpcProtocol<>();
        MsgHeader header = new MsgHeader();
        long requestId = MiniRpcRequestHolder.REQUEST_ID_GEN.incrementAndGet();
        header.setMagic(ProtocolConstants.MAGIC);
        header.setVersion(ProtocolConstants.VERSION);
        header.setRequestId(requestId);
        header.setSerialization((byte) SerializationTypeEnum.HESSIAN.getType());
        header.setMsgType((byte) MsgType.REQUEST.getType());
        header.setStatus((byte) 0x1);
        protocol.setHeader(header);
        MiniRpcRequest request = new MiniRpcRequest();
        request.setServiceVersion(this.serviceVersion);
        request.setClassName(method.getDeclaringClass().getName());
        request.setMethodName(method.getName());
        request.setParameterTypes(method.getParameterTypes());
        request.setParams(args);
        protocol.setBody(request);
        RpcConsumer rpcConsumer = new RpcConsumer();
        MiniRpcFuture<MiniRpcResponse> future = new MiniRpcFuture<>(new DefaultPromise<>(new DefaultEventLoop()), timeout);
        MiniRpcRequestHolder.REQUEST_MAP.put(requestId, future);
        // 发起 RPC 远程调用
        rpcConsumer.sendRequest(protocol, this.registryService);
        // 等待 RPC 调用执行结果
        return future.getPromise().get(future.getTimeout(), TimeUnit.MILLISECONDS).getData();
    }
}

RpcInvokerProxy 处理器必须要实现 InvocationHandler 接口的 invoke() 方法,被代理的 RPC 接口在执行方法调用时,都会转发到 invoke() 方法上。invoke() 方法的核心流程主要分为三步:构造 RPC 协议对象、发起 RPC 远程调用、等待 RPC 调用执行结果。

RPC 协议对象的构建,只要根据用户配置的接口参数对 MiniRpcProtocol 类的属性依次赋值即可。构建完MiniRpcProtocol 协议对象后,就可以对远端服务节点发起 RPC 调用了,所以 sendRequest() 方法是我们需要重点实现的内容。

public void sendRequest(MiniRpcProtocol<MiniRpcRequest> protocol, RegistryService registryService) throws Exception {
    MiniRpcRequest request = protocol.getBody();
    Object[] params = request.getParams();
    String serviceKey = RpcServiceHelper.buildServiceKey(request.getClassName(), request.getServiceVersion());
    int invokerHashCode = params.length > 0 ? params[0].hashCode() : serviceKey.hashCode();
    ServiceMeta serviceMetadata = registryService.discovery(serviceKey, invokerHashCode);
    if (serviceMetadata != null) {
        ChannelFuture future = bootstrap.connect(serviceMetadata.getServiceAddr(), serviceMetadata.getServicePort()).sync();
        future.addListener((ChannelFutureListener) arg0 -> {
            if (future.isSuccess()) {
                log.info("connect rpc server {} on port {} success.", serviceMetadata.getServiceAddr(), serviceMetadata.getServicePort());
            } else {
                log.error("connect rpc server {} on port {} failed.", serviceMetadata.getServiceAddr(), serviceMetadata.getServicePort());
                future.cause().printStackTrace();
                eventLoopGroup.shutdownGracefully();
            }
        });
        future.channel().writeAndFlush(protocol);
    }
}

发起 RPC 调用之前,我们需要找到最合适的服务节点,直接调用注册中心服务 RegistryService 的 discovery() 方法即可,默认是采用一致性 Hash 算法实现的服务发现。这里有一个小技巧,为了尽可能使所有服务节点收到的请求流量更加均匀,需要为 discovery() 提供一个 invokerHashCode,一般可以采用 RPC 服务接口参数列表中第一个参数的 hashCode 作为参考依据。找到服务节点地址后,接下来通过 Netty 建立 TCP 连接,然后调用 writeAndFlush() 方法将数据发送到远端服务节点。

再次回到 invoke() 方法的主流程,发送 RPC 远程调用后如何等待调用结果返回呢?在《远程通信:通信协议设计以及编解码的实现》课程中,我们介绍了如何使用 Netty 提供的 Promise 工具来实现 RPC 请求的同步等待,Promise 模式本质是一种异步编程模型,我们可以先拿到一个查看任务执行结果的凭证,不必等待任务执行完毕,当我们需要获取任务执行结果时,再使用凭证提供的相关接口进行获取。

当服务提供者收到 RPC 请求后,又应该如何执行真实的方法调用呢?接下来我们继续看下服务提供者如何处理 RPC 请求。

服务提供者反射调用实现

在《远程通信:通信协议设计以及编解码的实现》课程中,我们已经介绍了服务提供者的 Handler 处理器,RPC 请求数据经过 MiniRpcDecoder 解码成 MiniRpcProtocol 对象后,再交由 RpcRequestHandler 执行 RPC 请求调用。一起先来回顾下 RpcRequestHandler 中 channelRead0() 方法的处理逻辑:

@Slf4j
public class RpcRequestHandler extends SimpleChannelInboundHandler<MiniRpcProtocol<MiniRpcRequest>> {
    private final Map<String, Object> rpcServiceMap;
    public RpcRequestHandler(Map<String, Object> rpcServiceMap) {
        this.rpcServiceMap = rpcServiceMap;
    }
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, MiniRpcProtocol<MiniRpcRequest> protocol) {
        RpcRequestProcessor.submitRequest(() -> {
            MiniRpcProtocol<MiniRpcResponse> resProtocol = new MiniRpcProtocol<>();
            MiniRpcResponse response = new MiniRpcResponse();
            MsgHeader header = protocol.getHeader();
            header.setMsgType((byte) MsgType.RESPONSE.getType());
            try {
                Object result = handle(protocol.getBody());
                response.setData(result);
                header.setStatus((byte) MsgStatus.SUCCESS.getCode());
                resProtocol.setHeader(header);
                resProtocol.setBody(response);
            } catch (Throwable throwable) {
                header.setStatus((byte) MsgStatus.FAIL.getCode());
                response.setMessage(throwable.toString());
                log.error("process request {} error", header.getRequestId(), throwable);
            }
            ctx.writeAndFlush(resProtocol);
        });
    }
}

因为 RPC 请求调用是比较耗时的,推荐的做法是将 RPC 请求提交到自定义的业务线程池中执行。其中 handle() 方法是真正执行 RPC 调用的地方,是我们这节课需要实现的内容,handle() 方法的实现如下所示:

private Object handle(MiniRpcRequest request) throws Throwable {
    String serviceKey = RpcServiceHelper.buildServiceKey(request.getClassName(), request.getServiceVersion());
    Object serviceBean = rpcServiceMap.get(serviceKey);
    if (serviceBean == null) {
        throw new RuntimeException(String.format("service not exist: %s:%s", request.getClassName(), request.getMethodName()));
    }
    Class<?> serviceClass = serviceBean.getClass();
    String methodName = request.getMethodName();
    Class<?>[] parameterTypes = request.getParameterTypes();
    Object[] parameters = request.getParams();
    FastClass fastClass = FastClass.create(serviceClass);
    int methodIndex = fastClass.getIndex(methodName, parameterTypes);
    return fastClass.invoke(methodIndex, serviceBean, parameters);
}

rpcServiceMap 中存放着服务提供者所有对外发布的服务接口,我们可以通过服务名和服务版本找到对应的服务接口。通过服务接口、方法名、方法参数列表、参数类型列表,我们一般可以使用反射的方式执行方法调用。为了加速服务接口调用的性能,我们采用 Cglib 提供的 FastClass 机制直接调用方法,Cglib 中 MethodProxy 对象就是采用了 FastClass 机制,它可以和 Method 对象完成同样的事情,但是相比于反射性能更高。

FastClass 机制并没有采用反射的方式调用被代理的方法,而是运行时动态生成一个新的 FastClass 子类,向子类中写入直接调用目标方法的逻辑。同时该子类会为代理类分配一个 int 类型的 index 索引,FastClass 即可通过 index 索引定位到需要调用的方法。

至此,整个 RPC 框架的原型我们已经实现完毕。你可以在本地先启动 Zookeeper 服务器,然后启动 rpc-provider、rpc-consumer 两个模块,通过 HTTP 请求发起测试,如下所示:

$ curl http://localhost:8080/hello
hellomini rpc

总结

本节课我们介绍了动态代理的基本原理,并使用动态代理技术完成了 RPC 请求的调用和处理。动态代理技术是 RPC 框架的核心技术之一,也是很重要的一个性能优化点。选择哪种动态代理技术需要根据场景有的放矢,实践出真知,在技术选型时还是要做好性能测试。例如,在 JDK 1.8 版本之后 JDK 动态代理在运行多次之后比 Cglib 的速度更快了,但是它还是有使用的局限性;虽然 Javassist 字节码生成的性能相比 JDK 动态代理和 Cglib 动态代理更好,但是 Javassist 在生成动态代理类上性能较慢的。

28 实战总结:RPC 实战总结与进阶延伸

经过前面几节的实战课,我们已经初步完成了一个 RPC 框架原型,其中串联了 RPC 框架所涉及的大部分核心知识点。纸上得来终觉浅,绝知此事要躬行,编码是每个程序员的基本功,一定要亲自动手做一遍,不要停留在纸上谈兵。虽然 RPC 框架原型已经可以运行起来了,但是离生产级使用还差得很远,例如性能、高可用等。本节课我会做一个有关知识点的总结回顾,并结合业界成熟的 RPC 框架再做一些知识补充,希望对你提升系统设计能力所有帮助。

实战知识点总结

Netty 服务端启动

Netty 提供了 ServerBootstrap 引导类作为程序启动入口,ServerBootstrap 将 Netty 核心组件像搭积木一样组装在一起,服务端启动过程我们需要完成以下三个基本步骤:

  • 配置线程池。Netty 是采用 Reactor 模型进行开发的,在大多数场景下,我们采用的都是主从多线程 Reactor 模型。
  • Channel 初始化。设置 Channel 类型,并向 ChannelPipeline 中注册 ChannelHandler,此外可以按需设置 Socket 参数以及用户自定义属性。
  • 端口绑定。调用 bind() 方法会真正触发启动,sync() 方法则会阻塞,直至整个启动过程完成。
自定义通信协议

一个完备的网络协议需要具备的基本要素:魔数、协议版本号、序列化算法、报文类型、长度域字段、请求数据、保留字段。在实现协议编解码时经常用到两个重要的抽象类:MessageToByteEncoder 编码器ByteToMessageDecoder 解码器。Netty 也提供了很多开箱即用的拆包器,推荐最广泛使用的 LengthFieldBasedFrameDecoder,它可以满足实际项目中的大部分场景。如果对 LengthFieldBasedFrameDecoder 的参数不够熟悉,实际直接使用 ByteBuf 反而更加直观,根据个人喜好按需选择。

ByteBuf

ByteBuf 是必须要掌握的核心工具类,并且能够理解 ByteBuf 的内部构造。ByteBuf 包含三个指针:读指针 readerIndex写指针 writeIndex最大容量 maxCapacity,根据指针的位置又可以将 ByteBuf 内部结构可以分为四个部分:废弃字节、可读字节、可写字节和可扩容字节。如下图所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

Pipeline & ChannelHandler

ChannelPipeline 和 ChannelHandler 也是我们在平时应用开发的过程中打交道最多的组件,这两个组件为用户提供了 I/O 事件的全部控制权。ChannelPipeline 是双向链表结构,包含 ChannelInboundHandler 和 ChannelOutboundHandler 两种处理器。Inbound 事件和 Outbound 事件的传播方向相反,Inbound 事件的传播方向为 Head -> Tail,而 Outbound 事件传播方向是 Tail -> Head。在设计之初一定要梳理清楚 Inbound 和 Outbound 处理的传递顺序,以及数据模型之间是如何转换的。

注册中心

注册中心是 RPC 框架中一个非常重要的组件,主要用于实现服务的注册和发现。目前主流的注册中心有 ZooKeeper、Eureka、Etcd、Consul、Nacos 等,到底选择 CP 还是 AP 类型的注册中心呢?没有最好的选择,需要根据实际的业务场景进行技术选型。对于 RPC 框架而言,应当弱依赖于注册中心,即使注册中心出现问题,也不应该影响服务正常使用。所以建议使用 AP 类型的注册中心,在实现服务发现的场景下相比 CP 类型的注册中心有性能优势,整个集群是不存在 Leader、Flower 概念的,如果其中一个节点挂了,请求会立刻转移到其他节点上,通过牺牲强一致性来保证高可用性。

当服务节点下线时,注册中心需要及时通知服务消费者该节点已经下线了,否则可能会造成部分服务调用出现问题。实现服务优雅下线比较好的方式是采用主动通知 + 心跳检测的方案,心跳检测可以由节点或者注册中心负责,例如注册中心可以向服务节点每 60s 发送一次心跳包,如果 3 次心跳包都没有收到请求结果,可以认为该服务节点已经下线。心跳检测通常也是客户端和服务端之间通知对方存活状态的一种机制,下文我会给你展示心跳检测的基本实现方式。

动态代理和反射调用

如果想做到 RPC 底层细节对服务消费者无感知,就无法绕开动态代理。动态代理提供了一种能够在运行时动态构建代理类以及动态调用目标方法的机制,我们必须创建一个接口代理对象,在代理对象中实现编码、请求调用、解码等操作。

常用的动态代理实现有 JDK 动态代理和 Cglib 动态代理,选择哪种动态代理技术需要根据场景有的放矢,需要做好性能压测。JDK 动态代理所代理的对象必须实现一个或者多个接口,生成的代理类也是接口的实现类,然后通过 JDK 动态代理是通过反射调用的方式代理类中的方法,不能代理接口中不存在的方法。Cglib 动态代理相比 JDK 动态代理更加灵活,Cglib 是通过字节码技术对指定类生成一个子类,并重写其中的方法,所以代理类的类型是不受限制的。

服务提供者在接收到 RPC 请求后,需要通过反射机制执行真实的方法调用。为了加速服务接口调用的性能,可以采用 Cglib 提供的 FastClass 机制直接调用方法,相比于反射性能更高。FastClass 机制并没有采用反射的方式调用被代理的方法,而是运行时动态生成一个新的 FastClass 子类,向子类中写入直接调用目标方法的逻辑。同时该子类会为代理类分配一个 int 类型的 index 索引,FastClass 即可通过 index 索引定位到需要调用的方法。生成 FastClass 子类是比较耗时的,可以使用缓存 FastClass 的方式进一步优化 RPC 框架的性能。

性能优化篇

RPC 框架的性能取决于很多因素,我们通常会关注几个方面:I/O 模型、网络参数、序列化方法、内存管理等。接下来我们主要以知识点的形式逐一介绍 RPC 框架中常用的优化方法。

I/O 模型

Netty 提供了高效的主从 Reactor 多线程模型,主 Reactor 线程负责新的网络连接 Channel 创建,然后把 Channel 注册到从 Reactor,由从 Reactor 线程负责处理后续的 I/O 操作。主从 Reactor 多线程模型很好地解决了高并发场景下单个 NIO 线程无法承载海量客户端连接建立以及 I/O 操作的性能瓶颈。

通常我们使用如下的方式配置主从 Reactor 线程模型:

EventLoopGroup bossGroup = new NioEventLoopGroup();
EventLoopGroup workerGroup = new NioEventLoopGroup();
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)

如果你没有指定 workerGroup 线程组初始化的线程数,那么 Netty 会默认创建 2 倍 CPU 核数作的线程,但这并不一定是一个最佳数量,可以根据实际的压测情况进行适当调整。一般来说,只要服务性能能够满足要求,workerGroup 初始化的线程数应该越少越好,这样可以有效地减少线程上下文切换。

Netty 提供了一个参数 ioRatio,可以调整 I/O 事件处理和任务处理的时间比例,默认值为 50。对于高并发的 RPC 调用场景,ioRatio 可以适当调大,控制 Netty 有更多的时间比例在执行 I/O 任务。

Netty 网络参数配置

Netty 提供了 ChannelOption 以便于我们优化 TCP 参数配置,为了提高网络通信的吞吐量,一些可选的网络参数我们有必要掌握。

  • TCP_NODELAY,是否开启 Nagle 算法。Nagle 算法通过缓存的方式将网络数据包累积到一定量才会发送,从而避免频繁发送小的数据包。Nagle 算法 在海量流量的场景下非常有效,但是会造成一定的数据延迟。如果对数据传输延迟敏感,那么应该禁用该参数。
  • SO_BACKLOG,已完成三次握手的请求队列最大长度。同一时刻服务端可能会处理多个连接,在高并发海量连接的场景下,该参数应适当调大。但是 SO_BACKLOG 也不能太大,否则无法防止 SYN-Flood 攻击。
  • SO_SNDBUF/SO_RCVBUF,TCP 发送缓冲区和接收缓冲区的大小。为了能够达到最大的网络吞吐量,SO_SNDBUF 不应当小于带宽和时延的乘积。SO_RCVBUF 一直会保存数据到应用进程读取为止,如果 SO_RCVBUF 满了,接收端会通知对端 TCP 协议中的窗口关闭,保证 SO_RCVBUF 不会溢出。
  • SO_KEEPALIVE,连接保活。启用了 TCP SO_KEEPALIVE 属性,TCP 会主动探测连接状态,Linux 默认设置了 2 小时的心跳频率。TCP KEEPALIVE 机制主要用于回收死亡时间交长的连接,不适合实时性高的场景。
序列化方式

在网络通信过程中,必然涉及序列化和反序列化操作,即将对象编码成字节,再把字节解码成对象的过程。序列化和反序列化属于高频且较笨重的操作,属于 RPC 框架中一个重要的性能优化点。在选择序列化方式时需要综合考虑各方面因素,如高性能、跨语言、可维护性、可扩展性等。

比较常用的序列化算法有 Kryo、Hessian、Protobuf 等,这些第三方序列化算法都比 Java 原生的序列化操作都更加高效。Kryo 序列化后占用字节数较少,网络传输效率更高,但是不支持跨语言。Hessian 是目前业界使用较为广泛的序列化协议,它的兼容性好,支持跨语言,API 方便使用,序列化后的字节数适中。Protobuf 是 gRPC 框架默认使用的序列化协议,属于 Google 出品的序列化框架。Protobuf 支持跨语言、跨平台,具有较好的扩展性,并且性能优于 Hessian。但是 Protobuf 使用时需要编写特定的 prpto 文件,然后进行静态编译成不同语言的程序后拷贝到项目工程中,一定程序增加了开发者的复杂度。综合各方面因素以及实际口碑,个人比较推荐使用 Hessian 和 Protobuf 序列化协议。

关于 RPC 框架序列化进一步的性能优化我们可以采用以下方法:

  • 减少不必要的字段以及精简字段的长度,从而降低序列化后占用的字节数。
  • 提供不同的序列化策略。可以将不同的字段拆分至不同的线程里进行反序列化,例如 Netty I/O 线程可以只负责 className 和 消息头 Header 的反序列化,然后根据 Header 分发到不同的业务线程池中,由业务线程负责反序列化消息内容 Content,这样可以有效地降低 I/O 线程的压力。
内存管理

Netty 会使用堆外内存 DirectBuffer 进行 Socket 读写,相比使用堆内存减少了一次内存拷贝。然而堆外内存的创建和销毁成本更高,所以通常会使用内存池来提高性能,你可以回顾下《轻量级对象回收站:Recycler 对象池技术解析》课程中所介绍的 Netty 池化技术。对于数据量较小的一些场景,可以考虑使用 HeapBuffer,由 JVM 负责内存的分配和回收可能效率更高。

此外,Netty 还提供了一些技巧来避免内存拷贝:

  • CompositeByteBuf 是 Netty 中实现零拷贝机制非常重要的一个数据结构,它可以组合多个 Buffer 对象合并成一个逻辑上的对象,避免通过传统内存拷贝的方式将几个 Buffer 合并成一个大的 Buffer,我们经常使用 CompositeByteBuf 拼接协议数据的 头部信息 Header 和消息体数据 Body。
  • 在失败重试的场景,我们想保留 ByteBuf 继续使用,你可以使用 copy() 方法拷贝原始 ByteBuf 的所有信息。但是深拷贝非常浪费性能的,你可以使用浅拷贝操作 oldBuffer.duplicate().retain() 复制出独立的读写索引,底层分配的内存、引用计数都是与原始 ByteBuf 共享的,其中 retain() 又会将 ByteBuffer 的引用计数加 1,从而避免了 ByteBuffer 被释放。

高可用篇

在整个 RPC 框架实践课中,我们并没有太多考虑 RPC 框架高可用相关的内容,但是高可用是分布式系统架构设计中一个重要的因素,下面我们便一起讨论如何提高 RPC 框架的可用性。

连接空闲检测+心跳检测

连接空闲检测是指每隔一段时间检测连接是否有数据读写,如果服务端一直能收到客户端连接发送过来的数据,说明连接处于活跃状态,对于假死的连接是收不到对端发送的数据的。如果一段时间内没收到客户端发送的数据,并不能说明连接一定处于假死状态,有可能客户端就是长时间没有数据需要发送,但是建立的连接还是健康状态,所以服务端还需要通过心跳检测的机制判断客户端是否存活。客户端可以定时向服务端发送一次心跳包,如果有 N 次没收到心跳数据,可以判断当前客户端已经下线或处于不健康状态。由此可见,连接空闲检测和心跳检测是应对连接假死的一种有效手段,通常空闲检测时间间隔要大于 2 个周期的心跳检测时间间隔,主要是为了排除网络抖动的造成心跳包未能成功收到。

Netty 中提供了开箱即用的 IdleStateHandler 实现连接空闲检测,如果我们想把一定时间间隔内没有读到数据的客户端连接进行关闭,可以采取如下的实现方式:

public class RpcIdleStateHandler extends IdleStateHandler {
    public RpcIdleStateHandler() {
        super(60, 0, 0, TimeUnit.SECONDS);
    }
    @Override
    protected void channelIdle(ChannelHandlerContext ctx, IdleStateEvent evt) {
        ctx.channel().close();
    }
}

IdleStateHandler 实现心跳检测本质是向任务队列中添加定时任务,判断 channelRead() 或 write() 方法是否发生空闲超时,IdleStateHandler 的构造函数支持设置读空闲时间、写空闲时间、读写空闲时间。super(60, 0, 0, TimeUnit.SECONDS) 表示我们只关注读空闲时间,如果服务端 60s 没未读到数据,就会回调 channelIdle() 方法,此时我们进行连接关闭,避免资源浪费。

心跳检测在 Netty 中并没有现成的实现,但是与空闲检测实现的原理是差不多的,客户端可以采用 EventLoop 提供的 schedule() 方法向任务队列中添加心跳数据上报的定时任务,如下所示:

public class RpcHeartBeatHandler extends ChannelInboundHandlerAdapter {
    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        super.channelActive(ctx);
        doHeartBeatTask(ctx);
    }
    private void doHeartBeatTask(ChannelHandlerContext ctx) {
        ctx.executor().schedule(() -> {
            if (ctx.channel().isActive()) {
                HeartBeatData heartBeatData = buildHeartBeatData();
                ctx.writeAndFlush(heartBeatData);
                doHeartBeatTask(ctx);
            }
        }, 10, TimeUnit.SECONDS);
    }
}

客户端向服务端定时发送心跳包,服务端收到后并不回复响应,因为如果同时与服务端建立的客户端连接规模较大,响应心跳数据需要消耗一定的资源。如果想要实现客户端和服务端互相感知存活状态,需要采用双向心跳机制。我们需要根据实际场景选择最合理的心跳检测方式。

线程池隔离

如果你的 RPC 服务是公司的基础服务,可能会有非常多的调用方,例如用户接口、订单接口等等。在我们实现的 RPC 框架中,业务线程池是共用的,所有的 RPC 请求都会有该线程池处理。如果有一天其中一个服务调用方的流量激增,导致线程池资源耗尽,那么其他服务调用方都会受到严重的影响。我们可以尝试将不同的服务调用方划分到不同等级的业务线程池中,通过分组的方式对服务调用方的流量进行隔离,从而避免其中一个调用方出现异常状态导致其他所有调用方都不可用,提高服务整体性能和可用率。

流量隔离技术是服务治理中非常重要的一个措施,在很多大规模流量的业务系统中都有所应用,例如秒杀系统,可以根据特殊的请求头识别出是否是秒杀请求,从而跟日常请求的流量隔离开来。那么对于 RPC 框架而言,如何对服务调用方进行合理的分组呢?一般来说,根据应用的重要等级作为分组依据是一个很好的衡量标准,一定要保障核心业务不受影响,例如下单、支付等接口都需要有自己独立的业务线程池,避免受到其他服务调用方的影响。

重试机制

重试机制你再熟悉不过了,在平时的项目开发中你一定经常用到。为了保障服务的稳定性和容错性,重试机制是一般可以帮助我们解决不少问题,例如网络抖动、请求超时等场景都需要重试机制。

关于 RPC 框架的重试机制有几点最佳实践和注意事项,有必要与你分享一下:

  • 被调用的服务接口的业务逻辑需要保证幂等才可以考虑使用重试机制,例如数据插入、更新操作,无论重复请求多少次都不会产生任何影响。
  • 重试机制虽然可以提升服务可用性,但是重试可能会导致服务提供方流量倍增,极端情况下甚至造成雪崩。服务调用方最好设置合理的服务调用超时时间以及失败后的重试次数,需要综合考虑接口依赖服务的平均耗时、TP99 响应时间、服务重要等级等因素作为参考依据。为了防止重试引发的流量风暴,服务提供方必须考虑熔断、限流、降级等保护措施。
  • RPC 框架的重试机制一般会采取指数退避的策略,两次重试之间指数级增加间隔时间,例如 1s、2s、4s、8s,以此类推,同时必须限制最大延迟时间。指数退避会存在负载峰值的问题,例如服务提供方可能发生 FullGC 导致同一时间产生超时重试的请求增多。为了解决负载峰值问题,可以在重试间隔中增加随机值,将请求分摊在不同的时间点中。
  • 在负载均衡选择服务节点时,应该剔除上次重试失败的节点,进一步提高重试的成功率。
集群容错

集群容错是指服务消费者调用服务提供者集群时发生异常时的处理方案。以 Dubbo 框架为例,提供了六种内置的集群容错措施。

  • Failover,失效转移策略。Failover 是 Dubbo 默认的集群容错措施,当出现调用失败时,会重新尝试调用其他服务节点。对于幂等性操作我们可以选择 Failover 策略,但是重试的副作用在上文中我们已经提到过,如果服务提供者出现问题可能会产生大量的重试请求。
  • Failfast,快速失败策略。Failfast 非常适合非幂等性操作,服务消费者只发起一次调用,如果出现失败的情况则立刻报错,不进行任何重试。Failfast 的缺点就是需要服务消费者自己控制重试逻辑。
  • Failsafe,失效安全策略。Failsafe 策略在出现异常时,直接忽略。Failsafe 策略适合执行非核心的操作,如监控日志记录。
  • Failback,失效自动恢复策略。服务消费者调用失败后,Dubbo 会记录此次失败请求到队列中,然后定时重新发送该请求。Failback 策略适用于实时性不高的场景,如消息推送。
  • Forking,并行措施。服务调用者并行调用多个服务提供者节点,只要有一个调用成功就返回结果。通常用于实时性要求较高的操作,而且可以降低 TP999 指标,但是需要牺牲一定的服务器资源。
  • Broadcast,广播措施。Broadcast 策略会广播所有的服务提供者,逐个调用,任意一台失败则等待广播最后完成之后抛出,通常用于更新服务提供方的本地资源状态。

以上几种集群容错措施可以根据实际的业务场景进行配置选择,而且 Dubbo 给我们提供了 Cluster 扩展接口,我们可以自己定制集群的容错模式。

此外,实现 RPC 框架高可用的措施还有很多,如限流保护、动态扩容、平滑重启、服务治理等等,由于篇幅有限,我在这里就不一一展开了。实现一个 RPC 框架原型并不是什么难事,但是如何保证 RPC 框架的高性能、高可用、易扩展,是需要我们不断去学习和积累的技能。

总结

要想精通一门技术,自然离不开源码学习以及长期的实践经验。为了便于学习,本专栏完整地实现了 RPC 框架的基础功能,更有趣的是 RPC 框架还有更多高阶特性等待我们去挖掘,如服务治理、线程池隔离、集群容错、熔断限流等。你是否已经迫不及待地想去进一步深入研究 RPC 框架更多的知识了呢?一起动手把实战项目打磨得更加完善,一步步提升自己架构设计和编码的基本功!


29 编程思想:Netty 中应用了哪些设计模式?

设计模式的运用是面试过程中常考的,学习设计模式切勿死记硬背,结合优秀项目的源码去理解设计模式的使用会事半功倍。Netty 源码中运用了大量的设计模式,常见的设计模式在 Netty 源码中都有所体现。本节课我们便一起梳理 Netty 源码中所包含的设计模式,希望能帮助你更深入地了解 Netty 的设计精髓,并可以结合 Netty 源码向面试官讲述你对设计模式的理解。

单例模式

单例模式是最常见的设计模式,它可以保证全局只有一个实例,避免线程安全问题。单例模式有很多种实现方法,其中我比较推荐三种最佳实践:双重检验锁静态内部类方式饿汉方式枚举方式,其中双重检验锁和静态内部类方式属于懒汉式单例,饿汉方式和枚举方式属于饿汉式单例。

双重检验锁

在多线程环境下,为了提高实例初始化的性能,不是每次获取实例时在方法上加锁,而是当实例未创建时才会加锁,如下所示:

public class SingletonTest {
    private volatile static SingletonTest instance;<span class="hljs-function"><span class="hljs-keyword">public</span> SingletonTest <span class="hljs-title">getInstance</span><span class="hljs-params">()</span> </span>{    <span class="hljs-keyword">if</span> (instance == <span class="hljs-keyword">null</span>) {        <span class="hljs-keyword">synchronized</span> (<span class="hljs-keyword">this</span>) {            <span class="hljs-keyword">if</span> (instance == <span class="hljs-keyword">null</span>) {                instance = <span class="hljs-keyword">new</span> SingletonTest();            }        }    }    <span class="hljs-keyword">return</span> instance;}}

``

静态内部类方式

静态内部类方式实现单例巧妙地利用了 Java 类加载机制,保证其在多线程环境下的线程安全性。当一个类被加载时,其静态内部类是不会被同时加载的,只有第一次被调用时才会初始化,而且我们不能通过反射的方式获取内部的属性。由此可见,静态内部类方式实现单例更加安全,可以防止被反射入侵。具体实现方式如下:

public class SingletonTest {
    private SingletonTest() {
    }
    public static Singleton getInstance() {
        return SingletonInstance.instance;
    }
    private static class SingletonInstance {
        private static final Singleton instance = new Singleton();
    }
}
饿汉方式

饿汉式实现单例非常简单,类加载的时候就创建出实例。饿汉方式使用私有构造函数实现全局单个实例的初始化,并使用 public static final 加以修饰,实现延迟加载和保证线程安全性。实现方式如下所示:

public class SingletonTest {
    private static Singleton instance = new Singleton();
    private Singleton() {
    }
    public static Singleton getInstance() {
        return instance;
    }
}
枚举方式

枚举方式是一种天然的单例实现,在项目开发中枚举方式是非常推荐使用的。它能够保证序列化和反序列化过程中实例的唯一性,而且不用担心线程安全问题。枚举方式实现单例如下所示:

public enum SingletonTest {
    SERVICE_A {
        @Override
        protected void hello() {
            System.out.println("hello, service A");
        }
    },
    SERVICE_B {
        @Override
        protected void hello() {
            System.out.println("hello, service B");
        }
    };
    protected abstract void hello();
}

在《源码篇:解密 Netty Reactor 的线程模型》课程中,我们介绍了 NioEventLoop 的核心原理。NioEventLoop 通过核心方法 select() 不断轮询注册的 I/O 事件,Netty 提供了选择策略 SelectStrategy 对象,它用于控制 select 循环行为,包含 CONTINUE、SELECT、BUSY_WAIT 三种策略。SelectStrategy 对象的默认实现就是使用的饿汉式单例,源码如下:

final class DefaultSelectStrategy implements SelectStrategy {
    static final SelectStrategy INSTANCE = new DefaultSelectStrategy();
    private DefaultSelectStrategy() { }
    @Override
    public int calculateStrategy(IntSupplier selectSupplier, boolean hasTasks) throws Exception {
        return hasTasks ? selectSupplier.get() : SelectStrategy.SELECT;
    }
}

此外 Netty 中还有不少饿汉方式实现单例的实践,例如 MqttEncoder、ReadTimeoutException 等。

工厂方法模式

工厂模式封装了对象创建的过程,使用者不需要关心对象创建的细节。在需要生成复杂对象的场景下,都可以使用工厂模式实现。工厂模式分为三种:简单工厂模式工厂方法模式抽象工厂模式

  • 简单工厂模式。定义一个工厂类,根据参数类型返回不同类型的实例。适用于对象实例类型不多的场景,如果对象实例类型太多,每增加一种类型就要在工厂类中增加相应的创建逻辑,这是违背开放封闭原则的。
  • 工厂方法模式。简单工厂模式的升级版,不再是提供一个统一的工厂类来创建所有对象的实例,而是每种类型的对象实例都对应不同的工厂类,每个具体的工厂类只能创建一个类型的对象实例。
  • 抽象工厂模式。较少使用,适用于创建多个产品的场景。如果按照工厂方法模式的实现思路,需要在具体工厂类中实现多个工厂方法,是非常不友好的。抽象工厂模式就是把这些工厂方法单独剥离到抽象工厂类中,然后创建工厂对象并通过组合的方式来获取工厂方法。

Netty 中使用的就是工厂方法模式,这也是项目开发中最常用的一种工厂模式。工厂方法模式如何使用呢?我们先来看个简单的例子:

public class TSLAFactory implements CarFactory {
    @Override
    public Car createCar() {
        return new TSLA();
    }
}
public class BMWFactory implements CarFactory {
    @Override
    public Car createCar() {
        return new BMW();
    }
}

Netty 在创建 Channel 的时候使用的就是工厂方法模式,因为服务端和客户端的 Channel 是不一样的。Netty 将反射和工厂方法模式结合在一起,只使用一个工厂类,然后根据传入的 Class 参数来构建出对应的 Channel,不需要再为每一种 Channel 类型创建一个工厂类。具体源码实现如下:

public class ReflectiveChannelFactory<T extends Channel> implements ChannelFactory<T> {
    private final Constructor<? extends T> constructor;
    public ReflectiveChannelFactory(Class<? extends T> clazz) {
        ObjectUtil.checkNotNull(clazz, "clazz");
        try {
            this.constructor = clazz.getConstructor();
        } catch (NoSuchMethodException e) {
            throw new IllegalArgumentException("Class " + StringUtil.simpleClassName(clazz) +
                    " does not have a public non-arg constructor", e);
        }
    }
    @Override
    public T newChannel() {
        try {
            return constructor.newInstance();
        } catch (Throwable t) {
            throw new ChannelException("Unable to create Channel from class " + constructor.getDeclaringClass(), t);
        }
    }
    @Override
    public String toString() {
        return StringUtil.simpleClassName(ReflectiveChannelFactory.class) +
                '(' + StringUtil.simpleClassName(constructor.getDeclaringClass()) + ".class)";
    }
}

虽然通过反射技术可以有效地减少工厂类的数据量,但是反射相比直接创建工厂类有性能损失,所以对于性能敏感的场景,应当谨慎使用反射。

责任链模式

想必学完本专栏的前面课程后,责任链模式大家应该再熟悉不过了,自然而然联想到 ChannlPipeline 和 ChannelHandler。ChannlPipeline 内部是由一组 ChannelHandler 实例组成的,内部通过双向链表将不同的 ChannelHandler 链接在一起,如下图所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

对于 Netty 中责任链模式的实现,也遵循了责任链模式的四个基本要素:

责任处理器接口

ChannelHandler 对应的就是责任处理器接口,ChannelHandler 有两个重要的子接口:ChannelInboundHandler和ChannelOutboundHandler,分别拦截入站和出站的各种 I/O 事件。

动态创建责任链,添加、删除责任处理器

ChannelPipeline 负责创建责任链,其内部采用双向链表实现,ChannelPipeline 的内部结构定义如下所示:

public class DefaultChannelPipeline implements ChannelPipeline {
    static final InternalLogger logger = InternalLoggerFactory.getInstance(DefaultChannelPipeline.class);
    private static final String HEAD_NAME = generateName0(HeadContext.class);
    private static final String TAIL_NAME = generateName0(TailContext.class);
    // 省略其他代码
    final AbstractChannelHandlerContext head; // 头结点
    final AbstractChannelHandlerContext tail; // 尾节点
    private final Channel channel;
    private final ChannelFuture succeededFuture;
    private final VoidChannelPromise voidPromise;
    private final boolean touch = ResourceLeakDetector.isEnabled();    // 省略其他代码
}

ChannelPipeline 提供了一系列 add 和 remove 相关接口用于动态添加和删除 ChannelHandler 处理器,如下所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

上下文

从 ChannelPipeline 内部结构定义可以看出,ChannelHandlerContext 负责保存责任链节点上下文信息。ChannelHandlerContext 是对 ChannelHandler 的封装,每个 ChannelHandler 都对应一个 ChannelHandlerContext,实际上 ChannelPipeline 维护的是与 ChannelHandlerContext 的关系。

责任传播和终止机制

ChannelHandlerContext 提供了 fire 系列的方法用于事件传播,如下所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

以 ChannelInboundHandlerAdapter 的 channelRead 方法为例,ChannelHandlerContext 会默认调用 fireChannelRead 方法将事件默认传递到下一个处理器。如果我们重写了 ChannelInboundHandlerAdapter 的 channelRead 方法,并且没有调用 fireChannelRead 进行事件传播,那么表示此次事件传播已终止。

观察者模式

观察者模式有两个角色:观察者和被观察。被观察者发布消息,观察者订阅消息,没有订阅的观察者是收不到消息的。首先我们通过一个简单的例子看下观察者模式的是如何实现的。

// 被观察者
public interface Observable {
    void registerObserver(Observer observer);
    void removeObserver(Observer observer);
    void notifyObservers(String message);
}
// 观察者
public interface Observer {
    void notify(String message);
}
// 默认被观察者实现
public class DefaultObservable implements Observable {
    private final List<Observer> observers = new ArrayList<>();
    @Override
    public void registerObserver(Observer observer) {
        observers.add(observer);
    }
    @Override
    public void removeObserver(Observer observer) {
        observers.remove(observer);
    }
    @Override
    public void notifyObservers(String message) {
        for (Observer observer : observers) {
            observer.notify(message);
        }
    }
}

Netty 中观察者模式的运用非常多,但是并没有以上示例代码这么直观,我们平时经常使用的ChannelFuture#addListener 接口就是观察者模式的实现。我们先来看下 ChannelFuture 使用的示例:

ChannelFuture channelFuture = channel.writeAndFlush(object);
channelFuture.addListener(future -> {
    if (future.isSuccess()) {
        // do something
    } else {
        // do something
    }
});

addListener 方法会将添加监听器添加到 ChannelFuture 当中,并在 ChannelFuture 执行完毕的时候立刻通知已经注册的监听器。所以 ChannelFuture 是被观察者,addListener 方法用于添加观察者。

建造者模式

建造者模式非常简单,通过链式调用来设置对象的属性,在对象属性繁多的场景下非常有用。建造者模式的优势就是可以像搭积木一样自由选择需要的属性,并不是强绑定的。对于使用者来说,必须清楚需要设置哪些属性,在不同场景下可能需要的属性也是不一样的。

Netty 中 ServerBootStrap 和 Bootstrap 引导器是最经典的建造者模式实现,在构建过程中需要设置非常多的参数,例如配置线程池 EventLoopGroup、设置 Channel 类型、注册 ChannelHandler、设置 Channel 参数、端口绑定等。ServerBootStrap 引导器的具体使用可以参考《引导器作用:客户端和服务端启动都要做些什么?》课程,在此我就不多作赘述了。

策略模式

策略模式针对同一个问题提供多种策略的处理方式,这些策略之间可以相互替换,在一定程度上提高了系统的灵活性。策略模式非常符合开闭原则,使用者在不修改现有系统的情况下选择不同的策略,而且便于扩展增加新的策略。

Netty 在多处地方使用了策略模式,例如 EventExecutorChooser 提供了不同的策略选择 NioEventLoop,newChooser() 方法会根据线程池的大小是否是 2 的幂次,以此来动态的选择取模运算的方式,从而提高性能。EventExecutorChooser 源码实现如下所示:

public final class DefaultEventExecutorChooserFactory implements EventExecutorChooserFactory {
    public static final DefaultEventExecutorChooserFactory INSTANCE = new DefaultEventExecutorChooserFactory();
    private DefaultEventExecutorChooserFactory() { }
    @SuppressWarnings("unchecked")
    @Override
    public EventExecutorChooser newChooser(EventExecutor[] executors) {
        if (isPowerOfTwo(executors.length)) {
            return new PowerOfTwoEventExecutorChooser(executors);
        } else {
            return new GenericEventExecutorChooser(executors);
        }
    }    // 省略其他代码
}

装饰者模式

装饰器模式是对被装饰类的功能增强,在不修改被装饰类的前提下,能够为被装饰类添加新的功能特性。当我们需要为一个类扩展功能时会使用装饰器模式,但是该模式的缺点是需要增加额外的代码。我们先通过一个简单的例子学习下装饰器模式应当如何使用,如下所示:

public interface Shape {
    void draw();
}
class Circle implements Shape {
    @Override
    public void draw() {
        System.out.print("draw a circle.");
    }
}
abstract class ShapeDecorator implements Shape {
    protected Shape shapeDecorated;
    public ShapeDecorator(Shape shapeDecorated) {
        this.shapeDecorated = shapeDecorated;
    }
    public void draw() {
        shapeDecorated.draw();
    }
}
class FillReadColorShapeDecorator extends ShapeDecorator {
    public FillReadColorShapeDecorator(Shape shapeDecorated) {
        super(shapeDecorated);
    }
    @Override
    public void draw() {
        shapeDecorated.draw();
        fillColor();
    }
    private void fillColor() {
        System.out.println("Fill Read Color.");
    }
}

我们创建了一个 Shape 接口的抽象装饰类 ShapeDecorator,并维护 Shape 原始对象,FillReadColorShapeDecorator 是用于装饰 ShapeDecorator 的实体类,它不对 draw() 方法做任何修改,而是直接调用 Shape 对象原有的 draw() 方法,然后再调用 fillColor() 方法进行颜色填充。

下面我们再来看一下 Netty 中 WrappedByteBuf 是如何装饰 ByteBuf 的,源码如下所示:

class WrappedByteBuf extends ByteBuf {
    protected final ByteBuf buf;
    protected WrappedByteBuf(ByteBuf buf) {
        if (buf == null) {
            throw new NullPointerException("buf");
        }
        this.buf = buf;
    }
    @Override
    public final boolean hasMemoryAddress() {
        return buf.hasMemoryAddress();
    }
    @Override
    public final long memoryAddress() {
        return buf.memoryAddress();
    }    // 省略其他代码
}

WrappedByteBuf 是所有 ByteBuf 装饰器的基类,它并没有什么特别的,也是在构造函数里传入了原始的 ByteBuf 实例作为被装饰者。WrappedByteBuf 有两个子类 UnreleasableByteBuf 和 SimpleLeakAwareByteBuf,它们是真正实现对 ByteBuf 的功能增强,例如 UnreleasableByteBuf 类的 release() 方法是直接返回 false 表示不可被释放,源码实现如下所示。

final class UnreleasableByteBuf extends WrappedByteBuf {
    private SwappedByteBuf swappedBuf;
    UnreleasableByteBuf(ByteBuf buf) {
        super(buf instanceof UnreleasableByteBuf ? buf.unwrap() : buf);
    }
    @Override
    public boolean release() {
        return false;
    }
    // 省略其他代码
}

不知道你会不会有一个疑问,装饰器模式和代理模式都是实现目标类增强,他们有什么区别吗?装饰器模式和代理模式的实现确实是非常相似的,都需要维护原始的目标对象,装饰器模式侧重于为目标类增加新的功能,代理模式更侧重于在现有功能的基础上进行扩展。

总结

学习设计模式切勿死记硬背,不仅要吸收设计模式的思想,还要理解为什么使用该设计模式。锻炼代码设计能力比较好的办法就是读优秀框架的源码,Netty 就是一个非常丰富的学习资源。我们需要了解源码中设计模式的使用场景,不断吸收消化,并能够做到在项目开发中学以致用。

本节课所介绍的设计模式在 Netty 中并不能涵盖所有,还有很多等待我们去挖掘。留一个课后任务,学习以下设计模式在 Netty 中的运用。

  • 模板方法模式:ServerBootStrap 和 Bootstrap 的 init 过程实现;
  • 迭代器模式:CompositeByteBuf;
  • 适配器模式:ScheduledFutureTask。

如果你还有更多设计模式在 Netty 中的巧妙运用,欢迎留言分享。


30 实践总结:Netty 在项目开发中的一些最佳实践

这是专栏的最后一节课,首先恭喜你持之以恒学习到现在,你已经离成为一个 Netty 高手不远啦!本节课我会结合自身的实践经验,整理出一些 Netty 的最佳实践,帮助你回顾之前课程的知识点以及进一步提升 Netty 的进阶技巧。

本节课我们的内容以知识点列表的方式呈现,仅仅对 Netty 的核心要点进行提炼,更多详细的实现原理需要你课后深入研究源码。

性能篇

网络参数优化

Netty 提供了 ChannelOption 以便于我们优化 TCP 参数配置,为了提高网络通信的吞吐量,一些可选的网络参数我们有必要掌握。在之前的课程中我们已经介绍了一些常用的参数,我们在此基础上再做一些详细地扩展。

  • SO_SNDBUF/SO_RCVBUF

TCP 发送缓冲区和接收缓冲区的大小。为了能够达到最大的网络吞吐量,SO_SNDBUF 不应当小于带宽和时延的乘积。SO_RCVBUF 一直会保存数据到应用进程读取为止,如果 SO_RCVBUF 满了,接收端会通知对端 TCP 协议中的窗口关闭,保证 SO_RCVBUF 不会溢出。

SO_SNDBUF/SO_RCVBUF 大小的设置建议参考消息的平均大小,不要按照最大消息来进行设置,这样会造成额外的内存浪费。更灵活的方式是可以动态调整缓冲区的大小,这时候就体现出 ByteBuf 的优势,Netty 提供的 ByteBuf 是可以支持动态调整容量的,而且提供了开箱即用的工具,例如可动态调整容量的接收缓冲区分配器 AdaptiveRecvByteBufAllocator。

  • TCP_NODELAY

是否开启 Nagle 算法。Nagle 算法通过缓存的方式将网络数据包累积到一定量才会发送,从而避免频繁发送小的数据包。Nagle 算法 在海量流量的场景下非常有效,但是会造成一定的数据延迟。如果对数据传输延迟敏感,那么应该禁用该参数。

  • SO_BACKLOG

已完成三次握手的请求队列最大长度。同一时刻服务端可能会处理多个连接,在高并发海量连接的场景下,该参数应适当调大。但是 SO_BACKLOG 也不能太大,否则无法防止 SYN-Flood 攻击。

  • SO_KEEPALIVE

连接保活。启用了 TCP SO_KEEPALIVE 属性,TCP 会主动探测连接状态,Linux 默认设置了 2 小时的心跳频率。TCP KEEPALIVE 机制主要用于回收死亡时间交长的连接,不适合实时性高的场景。

在海量连接的场景下,也许你会遇到类似 “too many open files” 的报错,所以 Linux 操作系统最大文件句柄数基本是必须要调优参数。可以通过 vi /etc/security/limits.conf,添加如下配置:

* soft nofile 1000000
* hard nofile 1000000

修改保存以后,执行 sysctl -p 命令使配置生效,然后通过 ulimit -a 命令查看参数是否生效。

业务线程池的必要性

Netty 是基于 Reactor 线程模型实现的,I/O 线程数量固定且资源珍贵,ChannelPipeline 负责所有事件的传播,如果其中任何一个 ChannelHandler 处理器需要执行耗时的操作,其中那么 I/O 线程就会出现阻塞,甚至整个系统都会被拖垮。所以推荐的做法是在 ChannelHandler 处理器中自定义新的业务线程池,将耗时的操作提交到业务线程池中执行。以 RPC 框架为例,在服务提供者处理 RPC 请求调用时就是将 RPC 请求提交到自定义的业务线程池中执行,如下所示:

public class RpcRequestHandler extends SimpleChannelInboundHandler<MiniRpcProtocol<MiniRpcRequest>> {
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, MiniRpcProtocol<MiniRpcRequest> protocol) {
        RpcRequestProcessor.submitRequest(() -> {
            // 处理 RPC 请求
        });
    }
}
共享 ChannelHandler

我们经常使用以下 new HandlerXXX() 的方式进行 Channel 初始化,在每建立一个新连接的时候会初始化新的 HandlerA 和 HandlerB,如果系统承载了 1w 个连接,那么就会初始化 2w 个处理器,造成非常大的内存浪费。

ServerBootstrap b = new ServerBootstrap();
            b.group(bossGroup, workerGroup)
                    .channel(NioServerSocketChannel.class)
                    .localAddress(new InetSocketAddress(port))
                    .childHandler(new ChannelInitializer<SocketChannel>() {
                        @Override
                        public void initChannel(SocketChannel ch) {
                            ch.pipeline()
                                    .addLast(new HandlerA())
                                    .addLast(new HandlerB());
                        }
                    });

为了解决上述问题,Netty 提供了 @Sharable 注解用于修饰 ChannelHandler,标识该 ChannelHandler 全局只有一个实例,而且会被多个 ChannelPipeline 共享。所以我们必须要注意的是,@Sharable 修饰的 ChannelHandler 必须都是无状态的,这样才能保证线程安全。

设置高低水位线

高低水位线 WRITE_BUFFER_HIGH_WATER_MARK 和 WRITE_BUFFER_LOW_WATER_MARK 是两个非常重要的流控参数。Netty 每次添加数据时都会累加数据的字节数,然后判断缓存大小是否超过所设置的高水位线,如果超过了高水位,那么 Channel 会被设置为不可写状态。直到缓存的数据大小低于低水位线以后,Channel 才恢复成可写状态。Netty 默认的高低水位线配置是 32K ~ 64K,可以根据发送端和接收端的实际情况合理设置高低水位线,如果你没有足够的测试数据作为参考依据,建议不要随意更改高低水位线。高低水位线的设置方式如下:

// Server
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.childOption(ChannelOption.WRITE_BUFFER_HIGH_WATER_MARK, 32 * 1024);
bootstrap.childOption(ChannelOption.WRITE_BUFFER_LOW_WATER_MARK, 8 * 1024);
// Client
Bootstrap bootstrap = new Bootstrap();
bootstrap.option(ChannelOption.WRITE_BUFFER_HIGH_WATER_MARK, 32 * 1024);
bootstrap.option(ChannelOption.WRITE_BUFFER_LOW_WATER_MARK, 8 * 1024);

当缓存超过了高水位,Channel 会被设置为不可写状态,调用 isWritable() 方法会返回 false。建议在 Channel 写数据之前,使用 isWritable() 方法来判断缓存水位情况,防止因为接收方处理较慢造成 OOM。推荐的使用方式如下:

if (ctx.channel().isActive() && ctx.channel().isWritable()) {
    ctx.writeAndFlush(message);
} else {
    // handle message
}
GC 参数优化

对不同场景下的网络应用程序进行 JVM 参数调优,可以取得很好的性能提升,以及避免 OOM 风险。因为不同业务系统的特性是不一样的,在此我只能给你分享一些重要的注意事项。

  • 堆内存:-Xms 和 -Xmx 参数,-Xmx 用于控制 JVM Heap 的最大值,必须设置其大小,合理调整 -Xmx 有助于降低 GC 开销,提升系统吞吐量。-Xms 表示 JVM Heap 的初始值,对于生产环境的服务端来说 -Xms 和 -Xmx 最好设置为相同值。
  • 堆外内存:DirectByteBuffer 最容易造成 OOM 的情况,DirectByteBuffer 对象的回收需要依赖 Old GC 或者 Full GC 才能触发清理。如果长时间没有 Old GC 或者 Full GC 执行,那么堆外内存即使不再使用,也会一直在占用内存不释放。我们最好通过 JVM 参数 -XX:MaxDirectMemorySize 指定堆外内存的上限大小,当堆外内存的大小超过该阈值时,就会触发一次 Full GC 进行清理回收,如果在 Full GC 之后还是无法满足堆外内存的分配,那么程序将会抛出 OOM 异常。
  • 年轻代:-Xmn 调整新生代大小,-XX:SurvivorRatio 设置 SurvivorRatio 和 eden 区比例。我们经常遇到 YGC 频繁的情况,应该清楚程序中对象的基本分布情况,如果存在大量朝生夕灭的对象,应适当调大新生代;反之应适当调大老年代。例如在类似百万长连接、推送服务等延迟敏感的场景中,老年代的内存增长缓慢,优化年轻代的空间大小以及各区的比例可以带来更大的收益。
内存池 & 对象池

从内存分配的角度来看,ByteBuf 可以分为堆内存 HeapByteBuf 和堆外内存 DirectByteBuf。DirectByteBuf 相比于 HeapByteBuf,虽然分配和回收的效率较慢,但是在 Socket 读写时可以少一次内存拷贝,性能更佳。

为了减少堆外内存的频繁创建和销毁,Netty 提供了池化类型的 PooledDirectByteBuf。Netty 提前申请一块连续内存作为 ByteBuf 内存池,如果有堆外内存申请的需求直接从内存池里获取即可,使用完之后必须重新放回内存池,否则会造成严重的内存泄漏。Netty 中启用内存池可以在创建客户端或者服务端的时候指定,示例代码如下:

bootstrap.option(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT);
bootstrap.childOption(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT);

对象池与内存池的都是为了提高 Netty 的并发处理能力,通常在项目开发中我们会将一些通用的对象缓存起来,当需要该对象时,优先从对象池中获取对象实例。通过重用对象,不仅避免频繁地创建和销毁所带来的性能损耗,而且对 JVM GC 是友好的。如果你是一个高性能的网络应用系统,不妨试下 Netty 提供的 Recycler 对象池。Recycler 对象池如何使用在之前的课程有介绍过,在此我们一起回顾下。假设我们有一个 User 类,需要实现 User 对象的复用,具体实现代码如下:

public class UserCache {
    private static final Recycler<User> userRecycler = new Recycler<User>() {
        @Override
        protected User newObject(Handle<User> handle) {
            return new User(handle);
        }
    };
    static final class User {
        private String name;
        private Recycler.Handle<User> handle;
        public void setName(String name) {
            this.name = name;
        }
        public String getName() {
            return name;
        }
        public User(Recycler.Handle<User> handle) {
            this.handle = handle;
        }
        public void recycle() {
            handle.recycle(this);
        }
    }
    public static void main(String[] args) {
        User user1 = userRecycler.get(); // 1、从对象池获取 User 对象
        user1.setName("hello"); // 2、设置 User 对象的属性
        user1.recycle(); // 3、回收对象到对象池
        User user2 = userRecycler.get(); // 4、从对象池获取对象
        System.out.println(user2.getName());
        System.out.println(user1 == user2);
    }
}ß

由此可见,Netty 内存池和 Recycler 对象池优化的核心目标都是为了减少资源分配的开销,避免大量朝生夕灭的对象造成严重的内存消耗和 GC 压力。关于内存池和对象池的原理可以复习下之前课程《举一反三:Netty 高性能内存管理设计》《轻量级对象回收站:Recycler 对象池技术解析》,值得我们反复消化理解。

Native 支持

从 4.0.16 版本起,Netty 提供了用 C++ 编写 JNI 调用的 Socket Transport,相比 JDK NIO 具备更高的性能和更低的 GC 成本,并且支持更多的 TCP 参数。

<dependency>
    <groupId>io.netty</groupId>
    <artifactId>netty-transport-native-epoll</artifactId>
    <version>4.1.42.Final</version>
</dependency>

使用 Netty Native 非常简单,只需要替换相应的类即可:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

线程绑定

如果是经常关注系统性能调优,一定挖掘过 Linux 操作系统 CPU 亲和性的黑科技招数。CPU 亲和性是指在多核 CPU 的机器上线程可以被强制运行在某个 CPU 上,而不会调度到其他 CPU,也被称为绑核。当绑定线程到某个固定的 CPU 后,不仅可以避免 CPU 切换的开销,而且可以提高 CPU Cache 命中率,对系统性能有一定提升。

在 C/C++、Golang 中实现绑核操作是非常容易的事,遗憾的是在 Java 中是比较麻烦的。目前 Java 中有一个开源 affinity 类库,GitHub 地址https://github.com/OpenHFT/Java-Thread-Affinity。如果你的项目想引入使用它,需要先引入 Maven 依赖:

<dependency>
    <groupId>net.openhft</groupId>
    <artifactId>affinity</artifactId>
    <version>3.0.6</version>
</dependency>

affinity 类库可以和 Netty 轻松集成,比较常用的方式是创建一个 AffinityThreadFactory,然后传递给 EventLoopGroup,AffinityThreadFactory 负责创建 Worker 线程并完成绑核。代码实现如下所示:

EventLoopGroup bossGroup = new NioEventLoopGroup(1);
ThreadFactory threadFactory = new AffinityThreadFactory("worker", AffinityStrategies.DIFFERENT_CORE);
EventLoopGroup workerGroup = new NioEventLoopGroup(4, threadFactory);
ServerBootstrap serverBootstrap = new ServerBootstrap().group(bossGroup, workerGroup);

高可用篇

连接空闲检测 + 心跳检测

连接空闲检测是指每隔一段时间检测连接是否有数据读写,如果服务端一直能收到客户端连接发送过来的数据,说明连接处于活跃状态,对于假死的连接是收不到对端发送的数据的。如果一段时间内没收到客户端发送的数据,并不能说明连接一定处于假死状态,有可能客户端就是长时间没有数据需要发送,但是建立的连接还是健康状态,所以服务端还需要通过心跳检测的机制判断客户端是否存活。

客户端可以定时向服务端发送一次心跳包,如果有 N 次没收到心跳数据,可以判断当前客户端已经下线或处于不健康状态。由此可见,连接空闲检测和心跳检测是应对连接假死的一种有效手段,通常空闲检测时间间隔要大于 2 个周期的心跳检测时间间隔,主要是为了排除网络抖动的造成心跳包未能成功收到。

TCP 中已经有 SO_KEEPALIVE 参数,为什么我们还要在应用层加入心跳机制呢?心跳机制不仅能说明应用程序是活跃状态,更重要的是可以判断应用程序是否还在正常工作。然而 TCP KEEPALIVE 是有严重缺陷的,KEEPALIVE 设计初衷是为了清除和回收处于死亡状态的连接,实时性不高。KEEPALIVE 只能检查连接是否活跃,但是不能判断连接是否可用,例如服务端如果处于高负载假死状态,但是连接依然是处于活跃状态的。

解码器保护

Netty 在实现数据解码时,需要等待到缓冲区有足够多的字节才能开始解码。为了避免缓冲区缓存太多数据造成内存耗尽,我们可以在解码器中设置一个最大字节的阈值,然后结合 Netty 提供的 TooLongFrameException 异常通知 ChannelPipeline 中其他 ChannelHandler。示例如下:

public class MyDecoder extends ByteToMessageDecoder {
    private static final int MAX_FRAME_LIMIT = 1024;
    @Override
    public void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
        int readable = in.readableBytes();
        if (readable > MAX_FRAME_LIMIT) {
            in.skipBytes(readable);
            throw new TooLongFrameException("too long frame");
        }
        // decode
    }
}

检测缓冲区可读字节是否大于 MAX_FRAME_LIMIT,如果超过忽略这些可读字节,对于应用程序在特定的场景下是一种有效的保护措施。

线程池隔离

我们知道,如果有复杂且耗时的业务逻辑,推荐的做法是在 ChannelHandler 处理器中自定义新的业务线程池,将耗时的操作提交到业务线程池中执行。建议根据业务逻辑的核心等级拆分出多个业务线程池,如果某类业务逻辑出现异常造成线程池资源耗尽,也不会影响到其他业务逻辑,从而提高应用程序整体可用率。对于 Netty I/O 线程来说,每个 EventLoop 可以与某类业务线程池绑定,避免出现多线程锁竞争。如下图所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

流量整形

流量整形(Traffic Shaping)是一种主动控制服务流量输出速率的措施,保证下游服务能够平稳处理。流量整形和流控的区别在于,流量整形不会丢弃和拒绝消息,无论流量洪峰有多大,它都会采用令牌桶算法控制流量以恒定的速率输出,如下图所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

Netty 通过实现流量整形的抽象类 AbstractTrafficShapingHandler,提供了三种类型的流量整形策略:GlobalTrafficShapingHandler、ChannelTrafficShapingHandler 和 GlobalChannelTrafficShapingHandler,它们之间的关系如下:

GlobalTrafficShapingHandler = ChannelTrafficShapingHandler + GlobalChannelTrafficShapingHandler

全局流量整形 GlobalChannelTrafficShapingHandler 作用范围是所有 Channel,用户可以设置全局报文的接收速率、发送速率、整形周期。Channel 级流量整形 ChannelTrafficShapingHandler 作用范围是单个 Channel,可以对不同的 Channel 设置流量整形策略。举个简单的例子,火爆的旅游景区不仅在大门口对游客限流(相当于 GlobalChannelTrafficShapingHandler),而且在景区内部不同的小景点也对游客有限流(相当于 ChannelTrafficShapingHandler),这两个流量整形策略加起来就是 GlobalTrafficShapingHandler。

流量整形并不能保证系统处于安全状态,当流量洪峰过大,数据会一直积压在内存中,所以流量整形和流控应该结合使用才能保证系统的高可用。

堆外内存泄漏排查思路

堆外内存泄漏问题是 Netty 应用程序的热点问题,经常遇到 Java 进程占用内存很高,但是堆内存并不高的情况。这里给你分享一些排查堆外内存泄漏的基本思路:

堆外内存回收

jmap -histo:live <pid> 手动触发 FullGC, 观察堆外内存是否被回收,如果正常回收很可能是因为堆外设置太小,可以通过 -XX:MaxDirectMemorySize 调整。当然这无法排除堆外内存缓慢泄漏的情况,需要借助其他工具进行分析。

堆外内存代码监控

前面的课程我们介绍过堆外内存回收原理,建议你再回过头复习下。JDK 默认采用 Cleaner 回收释放 DirectByteBuffer,Cleaner 继承于 PhantomReference,因为依赖 GC 进行处理,所以回收的时间是不可控的。对于 hasCleaner 的 DirectByteBuffer,Java 提供了一系列不同类型的 MXBean 用于获取 JVM 进程线程、内存等监控指标,代码实现如下:

BufferPoolMXBean directBufferPoolMXBean = ManagementFactory.getPlatformMXBeans(BufferPoolMXBean.class).get(0);
LOGGER.info("DirectBuffer count: {}, MemoryUsed: {} K", directBufferPoolMXBean.getCount(), directBufferPoolMXBean.getMemoryUsed()/1024);

对于 Netty 中 noCleaner 的 DirectByteBuffer,直接通过 PlatformDependent.usedDirectMemory() 读取即可。

Netty 自带检测工具

Netty 提供了自带的内存泄漏检测工具,我们可以通过以下命令启用堆外内存泄漏检测工具:

-Dio.netty.leakDetection.level=paranoid

Netty 一共提供了四种检测级别:

  1. disabled,关闭堆外内存泄漏检测;
  2. simple,以 1% 的采样率进行堆外内存泄漏检测,消耗资源较少,属于默认的检测级别;
  3. advanced,以 1% 的采样率进行堆外内存泄漏检测,并提供详细的内存泄漏报告;
  4. paranoid,追踪全部堆外内存的使用情况,并提供详细的内存泄漏报告,属于最高的检测级别,性能开销较大,常用于本地调试排查问题。

Netty 会检测 ByteBuf 是否已经不可达且引用计数大于 0,判定内存泄漏的位置并输出到日志中,你需要关注日志中 LEAK 关键字。

MemoryAnalyzer 内存分析

我们可以通过传统 Dump 内存的方法排查堆外内存泄漏问题,运行如下命令:

jmap -dump:format=b,file=heap.dump pid

Dump 完内存堆栈之后,将其导入 MemoryAnalyzer 工具进行分析内存泄漏的可疑点,最终定位到代码源头。关于如何 MemoryAnalyzer 工具我在此就不展开了,需要你自行学习研究,这是每一个 Java 程序员的必备技能。

Btrace 神器

Btrace 是一款通过字节码检测 Java 程序的排障神器,它可以获取程序在运行过程中的一切信息,与 AOP 的使用方式类似。我们可以通过如下方式追踪 DirectByteBuffer 的堆外内存申请的源头:

@BTrace
public class TraceDirectAlloc {
    @OnMethod(clazz = "java.nio.Bits", method = "reserveMemory")
    public static void printThreadStack() {
        jstack();
    }
}
二分排查法:笨方法解决大问题

堆外内存泄漏问题有时候非常隐蔽,并不是很容易定位发现。为了提高问题排查的效率,我们最好能够在本地模拟复现出堆外内存泄漏问题,如果本地能够成功复现,那么已经成功了一半了。

我们可以根据近期代码变更的记录,通过二分法对代码进行回滚,然后再次尝试是否可以复现出堆外内存泄漏问题,最终可以定位出有问题的代码 commit。该思路虽然是一种笨方法,但是很多场景下可以有效解决问题。

总结

以上都是项目实践中的一些重要技巧,对于我们上手 Netty 应用程序开发已经足够使用,还有更多 Netty 的技巧和使用心得需要我们去自己在实践中探索。纸上得来终觉浅,绝知此事要躬行,当你积累了丰富的经验,不管是项目开发还是问题排障,都会越来越得心应手。


  • 30
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值