菜鸟的进阶--手写一个小型dubbo框架

2 篇文章 0 订阅
2 篇文章 0 订阅
本文详细介绍了RPC调用流程,包括使用Protobuf和JSON进行编解码,对比了两种序列化方式的性能。接着讨论了负载均衡策略,如加权轮询和一致性哈希,并给出了具体的实现代码。此外,还阐述了基本的心跳机制用于检测服务的可用性。最后,文章提到了压力测试的结果以及简单的服务管理界面的实现。
摘要由CSDN通过智能技术生成

1.rpc调用流程


2.组件


1.Redis注册中心


3.编解码/序列化


在本例的Netty通信中,由于每次调用rpc服务都要发送同一个类对象invoker,所以可以使用Protobuf。但是在接受方法调用结果的时候就不行了,因为我们无法提前确定对方方法返回结果的类型,所以在发送result的时候需要使用序列化将object 变成 byte [] ;接收 result的时候需要使用反序列化将 byte [] 变成 object

invoker

publicclassInvokerimplementsSerializable {
​
    privateStringinterfaceName;
    privateStringmethodName;
    privateClass[] paramsType;
    privateObject[] params;
}

序列化


序列化/反序列化可以使用数据流完成,也就是jdk的那一套序列化 / 反序列化操作。

publicstaticObjectByteToObject(byte[] bytes) {
    Objectobj=null;
    try {
        // bytearray to object
        ByteArrayInputStreambi=newByteArrayInputStream(bytes);
        ObjectInputStreamoi=newObjectInputStream(bi);
​
        obj=oi.readObject();
        bi.close();
        oi.close();
    } catch (Exceptione) {
        System.out.println("translation"+e.getMessage());
        e.printStackTrace();
    }
    returnobj;
}
​
publicstaticbyte[] ObjectToByte(Objectobj) {
    byte[] bytes=null;
    try {
        // object to bytearray
        ByteArrayOutputStreambo=newByteArrayOutputStream();
        ObjectOutputStreamoo=newObjectOutputStream(bo);
        oo.writeObject(obj);
​
        bytes=bo.toByteArray();
​
        bo.close();
        oo.close();
    } catch (Exceptione) {
        System.out.println("translation"+e.getMessage());
        e.printStackTrace();
    }
​
    returnbytes;
}

但是这一套操作的性能比较堪忧,我又测试了一遍json序列化反序列化的性能.

json的性能明显要好于jdk的性能。

    private static void testJson() throwsIOException {
        //10w次 序列化 / 反序列化 耗时238
        Personperson=newPerson();
        person.setName("tom");
        person.setAge(17);
        ObjectMapperobjectMapper=newObjectMapper();
        longstart=System.currentTimeMillis();
        for (inti=0; i<100000; i++) {
            person.setAge(i);
            Strings=objectMapper.writeValueAsString(person);
            objectMapper.readValue(s, Person.class);
        }
        longend=System.currentTimeMillis();
        System.out.println("json / 耗时: "+(end-start));
​
    }
​
    private static void testJdk(){
        //10w次 序列化/反序列化耗时: 599ms
        Personperson=newPerson();
        person.setName("tom");
        person.setAge(17);
        longstart=System.currentTimeMillis();
​
        for (inti=0; i<100000; i++) {
            person.setAge(i+1);
            byte[] bys=BytesUtils.ObjectToByte(person);
            Objectp=BytesUtils.ByteToObject(bys);
        }
        longend=System.currentTimeMillis();
        System.out.println("jdk / 耗时: "+(end-start));
    }

  • 最终敲定,编解码采用protobuf+json的方式

  • 当然我还提供了Jdk的方式

4.负载均衡策略


1.加权轮询策略


  • 加权轮询为每个服务器设置了优先级,每次请求过来时会挑选优先级最高的服务器进行处理。

  • 比如服务器 1~3 分配了优先级{3,1,1}

  • 当请求到来时,先访问服务器1,此时服务器1优先级变为2.

  • 再来请求,还是访问服务器1,此时服务器1优先级变为1.

  • 再来请求,还是服务器1,此时服务器1优先级变为0.

  • 再来就是服务器2,3,1.。。。。

  • 加权轮询可以定制化地为运算能力较强的服务器多分配点请求,能有效的提高服务器资源利用率。

  • 加权轮询策略的优点就是,实现简单,且对于请求所需开销差不多时,负载均衡效果比较明显,同时加权轮询策略还考虑了服务器节点的异构性,即可以让性能更好的服务器具有更高的优先级,从而可以处理更多的请求,使得分布更加均衡。

  • 但轮询策略的缺点是,每次请求到达的目的节点不确定,不适用于有状态请求的场景。并且,轮询策略主要强调请求数的均衡性,所以不适用于处理请求所需开销不同的场景。

  • 不过我们可以使用轮询策略作为默认选项,因为对于rpc来说,我们在调用A服务器的helloService 或者B服务器的helloService 所需要的运算资源是大体一致的。

​
public class RoundRobinLoadBalance implements LoadBalance {
    private final Map<String,RoundRobinSelector>selectorMap=new ConcurrentHashMap<>();

    @Override
    public Url selectUrl(Invoker invoker)  {
        String interfaceName = invoker.getInterfaceName();
        if (!selectorMap.containsKey(interfaceName)){
            selectorMap.put(interfaceName,new RoundRobinSelector(interfaceName));
        }
        String url = selectorMap.get(interfaceName).rrAddWeight();
        Url url0 = null;
        try {
            url0 = RedisRegistry.getUrlForStr(url);
        } catch (IOException e) {
            e.printStackTrace();
        }
        return url0;
    }

    public void deleteUrl(Invoker invoker,Url url){
        String interfaceName = invoker.getInterfaceName();
        selectorMap.get(interfaceName).deleteUrl(url);
    }

private static class RoundRobinSelector{
    private final LinkedHashMap<String,Integer> weights=new LinkedHashMap<>();
    private final LinkedHashMap<String,Integer>currents=new LinkedHashMap<>();;
    private final LinkedHashMap<String,Integer>effectives=new LinkedHashMap<>();
    private final ObjectMapper objectMapper=new ObjectMapper();
    private int totalWeights;
    private final int maxFails=3;//最大失败次数
    private final int ERROR_WEIGHT=-127;

    public RoundRobinSelector(String interfaceName) {
        int sum=0;
        Map<String, String> mp = RedisRegistry.getUrlForWeights(interfaceName);
        for (Map.Entry<String, String> e : mp.entrySet()) {
            String key = e.getKey();
            int wi=Integer.parseInt(e.getValue());
            weights.put(key,wi);
            currents.put(key,wi);
            effectives.put(key,wi);
            sum+=wi;
        }
        totalWeights=sum;
    }
    private String rrAddWeight() {
        if (currents.size()<=1){
            for (String url : currents.keySet()) {
                return url;
            }
        }
        checkFail();
        checkError();
        int max=0;
        String maxKey=null;
        for (Map.Entry<String, Integer> e : currents.entrySet()) {
            if (e.getValue()!=ERROR_WEIGHT && max<e.getValue()){
                max=e.getValue();
                maxKey=e.getKey();
            }else if (e.getValue()==ERROR_WEIGHT){
                System.out.println("检测到错误链接: "+e.getKey());
            }
        }
//        //拿到最大key,去减total
        if (maxKey!=null){
            int newCurr = currents.get(maxKey) - totalWeights;
            currents.put(maxKey,newCurr);
        }
        System.out.println("选择连接: "+maxKey+" 权值: "+max);
//        //遍历+eff
        for (String k : currents.keySet()) {
            if (ERROR_WEIGHT==currents.get(k))
                continue;
            currents.put(k,currents.get(k)+effectives.get(k));
        }
        return maxKey;


    }
    private void checkError() {
        List<String> urls=new ArrayList<>(16);
        for (Map.Entry<String, Integer> e : currents.entrySet()) {
            String url = e.getKey();
            if (RedisRegistry.isError(url)){
                urls.add(url);
            }
        }
        for (String url : urls) {
            currents.remove(url);
            effectives.remove(url);
            totalWeights-=weights.get(url);
            weights.remove(url);
        }
    }
    private void checkFail() {
        if (!RedisRegistry.containFails()){
            resetEffectives();
            return;
        }
        Map<String, String> failMap = RedisRegistry.getFailMap();
        for (Map.Entry<String, Integer> e : effectives.entrySet()) {
             String url = e.getKey();
             Integer wt = e.getValue();
            if (failMap.containsKey(url)){
                int newEff = (wt - weights.get(url)) / maxFails;
                effectives.put(url,newEff);
            }
        }
    }

    private void resetEffectives() {
        for (Map.Entry<String, Integer> e : effectives.entrySet()) {
            Integer wt = weights.get(e.getKey());
            if (e.getValue()!= wt){
                effectives.put(e.getKey(), wt);
            }
        }
    }

    public void deleteUrl(Url url){
        try {
            String urls = objectMapper.writeValueAsString(url);
            weights.remove(urls);
            currents.remove(urls);
            effectives.remove(urls);
        } catch (JsonProcessingException e) {
            e.printStackTrace();
        }
    }
}


}

2.一致性哈希


  • 个人认为,一致性哈希算法并不是太适合作为远程服务调用的负载均衡策略,因为它的性能并不是多么优秀,而且它天生就占用了挺大的内存。它的更好归宿应该是用于针对缓存方面的负载均衡,用以解决数据不一致时出现的缓存穿透问题。但是dubbo里提供了这一策略,所以我也模仿它实现了一下该策略。

  • 这个算法的大体思路是这样的:

  1. 构建一个环形的散列表,该散列表根据key值有序排列,所以这里可以选择TeeMap作为它的实现(dubbo也是这样做的)。

  1. 环形散列表上的key是服务提供者的信息和方法调用接口的信息,我个人使用了服务提供者的url+interface+methodName。

  1. 当消费方选择服务的时候,先通过一样的哈希算法计算出本次方法调用的hash值,然后从环形散列表上中找出比这个值小并且离它最近的那个主机。这个主机就是我们想要找的主机。

  1. 为了解决数据倾斜问题——主机数量过少时,大量的请求都是打到同一台主机上面的问题,我们需要在环形散列表上面添加虚拟节点,dubbo实现时默认选用了160个虚拟节点,我也参考了这个值。

  • 算法的思路其实不复杂,只不过在不清楚它的具体作用时,还是比较难理解的。


//一致性哈希负载均衡策略
public class ConsistentHashLoadBalance implements LoadBalance {
    private final Map<String, ConsistentHashSelector> selectorMap=new ConcurrentHashMap<>();

    @Override
    public Url selectUrl(Invoker invoker) {
        Url[] urls=RedisRegistry.getProviderUrlsByIntefaceName(invoker.getInterfaceName());
        //检查url列表是否变动过以及当前selector是否不存在.
        int providerCount = RedisRegistry.getProviderCount(invoker.getInterfaceName());
        String key=invoker.getInterfaceName()+"."+invoker.getMethodName();
        ConsistentHashSelector selector = selectorMap.get(key);

        if (null == selector || providerCount!=selector.providerCount){
            System.out.println("重建treeMap");
            selector = new ConsistentHashSelector(providerCount,urls,invoker);
            selectorMap.put(key,selector);
        }
        return selector.select(invoker);
    }

    private static class ConsistentHashSelector{
        private final int providerCount;
        private final TreeMap<Long,Url>virtualInvokers;
        private final int replicaNumber;

        private final static int DEFAULT_REPLICANUMBER=160;
        //构造一致性散列表。
        public ConsistentHashSelector(int providerCount, Url[] urls, Invoker invoker) {
            this.providerCount=providerCount;
            this.virtualInvokers=new TreeMap<>();
            this.replicaNumber=DEFAULT_REPLICANUMBER;

            String interfaceName = invoker.getInterfaceName();
            String methodName = invoker.getMethodName();
            for (Url url : urls) {
                String key=url.getHost()+":"+url.getPort()+":"+interfaceName+"."+methodName;
                for (int i = 0; i < replicaNumber / 4; i++) {
                    //md5计算出16个字节的数组
                    byte[] digest = md5(key + i);
                    for (int h = 0; h < 4; h++) {
                        //四次散列,分别是0-3位,4-7位....
                        long m = hash(digest, h);
                        //放进treeMap
                        virtualInvokers.put(m,url);
                    }
                }
            }
         }

    private long hash(byte[] digest, int number) {
        return (((long) (digest[3 + number * 4] & 0xFF) << 24)
                | ((long) (digest[2 + number * 4] & 0xFF) << 16)
                | ((long) (digest[1 + number * 4] & 0xFF) << 8)
                | (digest[number * 4] & 0xFF))
                & 0xFFFFFFFFL;
    }

    private byte[] md5(String value) {
        MessageDigest md5;
        try {
            md5 = MessageDigest.getInstance("MD5");
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalStateException(e.getMessage(), e);
        }
        md5.reset();
        byte[] bytes = null;
        try {
            bytes = value.getBytes("UTF-8");
        } catch (UnsupportedEncodingException e) {
            throw new IllegalStateException(e.getMessage(), e);
        }
        md5.update(bytes);
        return md5.digest();
    }

        public Url select(Invoker invoker) {
            String key = tokey(invoker);
            byte[] digest = md5(key);
            Map.Entry<Long, Url> e = virtualInvokers.tailMap(hash(digest, 0), true).firstEntry();

            if (null==e){
                e=virtualInvokers.firstEntry();
            }

            return e.getValue();
        }

        private String tokey(Invoker invoker) {
            StringBuilder sb=new StringBuilder();
            sb.append(invoker.getInterfaceName()).append(invoker.getMethodName());
            for (Object param : invoker.getParams()) {
                sb.append(param.toString());
            }
            return sb.toString();
        }
    }
}

3.加权随机


  • 顾名思义,该算法就是体现随机二字。但是并不是简单的随机选择。

  • 由于有了权值,所以在随机的过程中可以稍微地"大小眼"

  • 举例说明: 例如说有这样一个服务列表 :{4,2,1,1}

  • 那么此时完成加权随机就可以这样做:

  • 创建一个列表,权值高的服务给他多分配几个(拷贝),权值低的分配少一点

  • 例如本例,创建一个服务列表可以这样

  • {服务1,服务1,服务1,服务1,服务2,服务2,服务3,服务3,}

  • 然后通过random函数获取随机数,这个随机数最大不超过这个列表的长度,这样就可以完成加权随机了。

  • 不过我们可以在这一基础上做点优化,将服务实例换成其他占空间更小的元素,节省内存空间。

  • 本例中选择了一个整数值来替换服务实例,这样就不需要拷贝多个服务实例了。

  • 代码:

private static class RandomSelector{
    private final ConcurrentHashMap<Integer,String>selectUrlMap=new ConcurrentHashMap<>(16);
    private final ConcurrentHashMap<Integer,Integer>selectHelpMap=new ConcurrentHashMap<>(16);
    private final Map<String,Integer>urlToWeight;
    private final int providerCount;
    private final int total;
    private final Random random=new Random();
    public RandomSelector(int providerCount,Invoker invoker) {
        this.providerCount=providerCount;
        urlToWeight=new HashMap<>();
        int sum=0;
        int idx=0;
        Map<String, String> mp = RedisRegistry.getUrlForWeights(invoker.getInterfaceName());
        for (Map.Entry<String, String> e : mp.entrySet()) {
            String url = e.getKey();
            int weight=Integer.parseInt(e.getValue());
            selectUrlMap.put(idx++,url);
            urlToWeight.put(url,weight);
            sum+=weight;
        }
        total=sum;
        idx=0;
        for (Map.Entry<Integer, String> e : selectUrlMap.entrySet()) {
            int index = e.getKey();
            String url = e.getValue();
            int weight = urlToWeight.get(url);
            for (int i = idx; i < idx+weight; i++) {
                selectHelpMap.put(i,index);
            }
            idx+=weight;
        }
    }

    public String select(){
        int idx = random.nextInt(total);
        int index = selectHelpMap.get(idx);
        return selectUrlMap.get(index);
    }
}

5.基本心跳机制


  • 本项目的心跳检测机制是通过Redsi注册中心来完成的。

  • 简单概括一下:

  1. 当服务注册进来的时候,此时为它建立一个HEART_BEAT key,值是什么无所谓,因为它仅仅代表一个状态。

  1. 然后为它设定一个ttl值,本例中默认是12s

  1. 开启一个子线程,该子线程会循环地刷新这个ttl值,以保持它的新鲜。循环间隔在本例中是10s。

  1. 当负载均衡的时候未检测到对应服务的HEART_BEAT,表示目标服务已经下线。此时要从负载均衡列表中剔除该服务。

  • 对应的代码部分:

package MicroRpc.framework.redis.Registry.core;


public class RedisRegistry {
....
    //服务注册
    public static void registUrl(String appName, Url url) {
        String urls = null;
        try (Jedis jedis = jedisPool.getResource()){
            urls=objmapper.writeValueAsString(url);
            jedis.hset(SERVER_REGISTRY_KEY+appName,URL,urls);
            jedis.set(URL_MAP_KEY+urls,appName);

        } catch (JsonProcessingException e) {
            e.printStackTrace();
        }

        String finalUrls = urls;
        new Thread(()->{
            while (true){
                try (Jedis jedis = jedisPool.getResource()){
                    String key = buildHeartBeatKey(appName, finalUrls);
                    jedis.set(key,"1");
                    jedis.expire(key,12);
                    Thread.sleep(10*1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        },"heartbeat-thread").start();
    }
}


//loadBalance
 boolean tiktok = checkHeartBeat(appName, url);
            if (tiktok)
                url0 = RedisRegistry.getUrlForStr(url);
            else {
                System.out.println("目标url无心跳,稍等重试..");
                Thread.sleep(1000);
                selectUrl(invoker);
}

6.压力测试


  • 测试采用HelloService的say方法,该业务特别简单,仅仅返回一个字符串。

    public String say(String msg) {
        return "hello! this is from provider1 "+msg;
    }
  • 注意: 以下的测试结果均选取最高TPS的记录结果。

  • 在本次测试中的最好成绩是:

  • 单机环境下,并发发起请求,配置是使用了 TCP协议+随机加权负载均衡策略

  • TPS:14998

单机环境


  • 单提供者单线程(单线程发起20W个请求):

代码:

private final static AtomicInteger sum=new AtomicInteger(0);
public static void main(String[] args) throws ClassNotFoundException {
    AbstractApplicationContext context=
        new DefaultDubboApplicationContext(
        ProtocolTypes.DUBBO, LoadBalance.RANDAM_WEIGHT);
    context.refresh();
    System.out.println("刷新完毕");
    HelloService helloService = context.getProtocol().getService(HelloService.class);

    log.info("预热一下..");
    for (int i = 0; i < 10; i++) {
        helloService.say("a");
    }
    log.info("预热完毕..");
    long start = System.currentTimeMillis();

    for (int j = 0; j < 200000; j++) {
        String test = helloService.say("test");
        if (StringUtils.hasText(test))
            sum.incrementAndGet();
    }
    log.info("任务完成! 总耗时: {} 完成了 {} 个服务调用",(System.currentTimeMillis()-start),sum.get());

}
  • 测试结果

  • HTTP: 任务完成! 总耗时: 37600 完成了 200000 个服务调用

  • TPS= 5319

  • TCP : 任务完成! 总耗时: 17712 完成了 200000 个服务调用

  • TPS = 11291

  • 可以看到本例中TCP长连接秒杀HTTP短连接,当然也可能是因为我HTTP采用了汤姆猫的原因,或许采用其他的服务器实现会有更优的表现,这里我也不敢下定论说HTTP就一定比TCP差。

  • 单提供者多线程(1000个线程发起1000个请求,测试20W个请求的完成耗时时间):

代码:

    private final static ExecutorService exePool= Executors.newFixedThreadPool(1000);
    private final static AtomicInteger sum=new AtomicInteger(0);

    public static void main(String[] args) throws ClassNotFoundException, IOException {
        System.out.println("任意键开始...");
        System.in.read();
        AbstractApplicationContext context =
                new DefaultDubboApplicationContext(ProtocolTypes.DUBBO, LoadBalance.RANDAM_WEIGHT);
        context.refresh();
        System.out.println("刷新完毕");
        HelloService helloService = context.getProtocol().getService(HelloService.class);

        log.info("预热一下..");
        for (int i = 0; i < 10; i++) {
            helloService.say("a");
        }
        log.info("预热完毕..");
        long start = System.currentTimeMillis();
        for (int i = 0; i < 1000; i++) {
            exePool.submit(() -> {
                for (int j = 0; j < 1000; j++) {
                    String test = helloService.say("test");
                    if (StringUtils.hasText(test)) {
                        sum.incrementAndGet();
                    }
                }
            });
        }
        while (true){
            int get = sum.get();
            if (get>=200000)
                break;
        }
        log.info("任务完毕 总耗时{}ms,共完成{}个任务调用 ", (System.currentTimeMillis() - start), sum.get());
    }
  • 测试结果(TCP):

  • 任务完毕 任务完毕 总耗时13335ms,共完成200000个任务调用

  • 计算TPS = 20000/ (13335/1000) ==> 14998

分布式环境


  • 这里我在本地环境下使用了三个服务提供者来进行模拟分布式环境下的服务能力

  • 由于本地测试环境并不能做到模拟真正的分布式环境,因为一台机器要肩负三台机器的任务,所以分布式环境的测试结果仅供参考。

  • 代码仍然选用上面的测试代码,测试仍然分为单线程以及多线程的测试,这里我仅测试TCP协议。

  • 单线程:

  • 任务完成! 总耗时: 34573 完成了 200000 个服务调用

  • 任务完成! 总耗时: 26859 完成了 200000 个服务调用

  • 任务完成! 总耗时: 28397 完成了 200000 个服务调用

  • 多线程:

  • 任务完毕 总耗时21077ms,共完成200001个任务调用

  • 任务完毕 总耗时20129ms,共完成200001个任务调用

  • 任务完毕 总耗时21612ms,共完成200001个任务调用

  • 单机的成绩明显要好于这里的分布式环境,原因有几个:

  1. 一台机器模拟三台机器,CPU能力打了折扣。

  1. 这里的业务逻辑比较简单,无法体现分布式的优势。

  1. 由于以上原因,负载均衡反倒成了累赘,因为在单机情况下根本不需要走复杂的负载均衡逻辑,仅仅需要返回唯一的服务提供商即可。

7.服务管理界面


为了直观的观察服务,我这边开发了一个简陋的管理界面。

仓库地址(gitee),里面有readme文档 看看就懂得怎么玩了。

看到这里,如果觉得对你有帮助,请帮我点个赞 感谢。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值