散列及Hash环相关的一些理解

数字游侠说
• 阅读 1833

问题场景

假设有三台缓存服务器节点分别为C1、C2、C3,对于一个key,又客户端来决定存放到哪台节点,简单的办法就是 hash(key) % N,其中N是节点的总数。一旦节点扩容或者宕机后,N发生变化,那之前缓存的数据都将失效

如果简单的使用hash(key) % N 算法会出现以下问题

(1)当缓存节点数量发生变化时,会引起缓存的雪崩(同一时间大量缓存失效,引起整体系统压力过大)

(2)当缓存节点数量发生变化时,几乎所有缓存的位置或者说散列地址都发生改变

针对上述问题场景,如果希望达到节点扩容或宕机缓存失效的数目降低最低,我们可以通过一致性哈希环来解决90%的失效问题

普通的Hash表

了解环之前我们先了解下Hash的由来,Hash,一般翻译做散列 或音译为哈希,是把任意长度的输入(又叫做预映射pre-image)通过散列算法变换成固定长度的输出,该输出就是散列值。

散列技术是在记录的存储位置和它的关键字之间建立一个确定的对应关系 f,使得每个关键字key 对应一个存储位置f(key)。查找时根据这个确定的对应关系找到给定值 key 的映射 f(key) ,若查找集合中存在这个记录,则必定在 f(key) 的位置上。

我们把这种对应关系 f 称为散列函数,又称为 哈希(Hash)函数,采用散列技术将记录存储在一块连续的存储空间中,这块连续存储空间称为散列表或者哈希表(Hash table

一致性哈希算法

Hash环【经典实现】

服务节点映射到环

问题场景中是对服务器节点的数量进行取模,而一致性哈希算法是对2^32取模,假定一个Hash函数,其值空间为(0到2^32-1) 无整型数字 ,把这些数字组成一个环

散列及Hash环相关的一些理解

圆环的正上方的点代表0,0点右侧的第一个点代表1,以此类推,2、3、4、5、6……直到2^32-1,也就是说0点左侧的第一个点代表2^32-1,把这个由2的32次方个点组成的圆环称为hash环

那么一致性哈希算法和上图中的圆环有什么关系呢?

以上问题场景中的服务器节点IP为例,使用它们各自的IP地址进行哈希计算,使用哈希后的结果对2^32取模,可以使用如下公式示意。

第一个节点计算: hash(192.168.0.1) % 2^32

通过上述公式算出的结果一定是一个0到2^32-1之间的一个整数,假设这个整数我们定义为A,我们就用这个整数A代表节点C1,既然这个整数肯定处于0到2^32-1之间,那么,上图中的hash环上必定有一个点与这个整数对应,那么A就可以映射到这个环上,用下图示意

散列及Hash环相关的一些理解

同理,我们依次计算出剩余两个节点的值

第二个节点计算: hash(192.168.0.2) % 2^32 = B

第二个节点计算: hash(192.168.0.3) % 2^32 = C

散列及Hash环相关的一些理解

目前我们已经缓存节点和Hash环对应上了,那对于缓存的数据也一样使用同样的方法依次映射到hash环上

缓存数据映射到环

假设我们缓存的是内容是用户的会话连接ID,通过 hash(connectionId) % 2^32 得到一个整数,用橘黄色圆形表示图片

散列及Hash环相关的一些理解

那么上图中的这个橘黄色的圆点到底应该被缓存到哪一个节点上呢?

按照一致性哈希的约定,沿顺时针方向遇到的第一个服务器就是A服务器,所以,上图中的橘黄色的圆点被缓存到服务器A上,如下图所示

散列及Hash环相关的一些理解

如果是多个用户的会话连接ID呢?

散列及Hash环相关的一些理解

一致性哈希算法就是通过这种方法,判断一个对象应该落到哪台服务器上的,将缓存服务器与被缓存对象都映射到hash环上以后,从被缓存对象的位置出发,沿顺时针方向遇到的第一个服务器,就是当前对象将要缓存于的服务器,由于被缓存对象与服务器hash后的值是固定的,所以,在服务器不变的情况下,被缓存的对象必定会被缓存到固定的服务器上,那么,当下次想要访问这个缓存对象时,只要再次使用相同的算法进行计算,即可算出这个缓存对象被缓存在哪个服务器上。

把上述节点映射和值映射的过程归纳总结成以下两点

普通的hash函数,只经过了1次hash,即把key hash到对应的机器编号,而hash环有两次hash过程:
(1)把所有节点特定的值(IP、设备号或其他特定标识)hash到这个环上
(2)把key也hash到这个环上。然后在这个环上进行匹配,看这个key落在哪个节点范围

优点/缺点

优点:节点的扩容和宕机不会到导致全局重新映射,而只需要做增量的重新映射

缺点:hash环偏斜

上文中理想的hash环效果如下图

散列及Hash环相关的一些理解

实际的节点映射有可能会被映射为下图

散列及Hash环相关的一些理解

那被缓存的对象数据就大部分集中缓存在某一台服务器上,如下图

散列及Hash环相关的一些理解

如果出现上图中的情况,A、B、C三台服务器并没有被合理的平均利用,缓存分步不均匀,上图的这种情况被称之为hash环偏斜

虚拟节点

在真正的物理服务器节点不变的情况下,如何凭空补足不均衡的节点?只能将现有的物理节点通过虚拟的方法复制出来,而这些被复制出来的节点就称为虚拟节点。加入了虚拟节点的hash环如下图

散列及Hash环相关的一些理解

如果你还不放心,可以虚拟出更多的虚拟节点,以便减小hash环偏斜所带来的影响,虚拟节点越多,hash环上的节点就越多,缓存被均匀分布的概率就越大。

是不是越多虚拟节点越好?到点一个物理节点复制多少虚拟节点合适呢?

我们拿一致性哈希算法的实现来举例,其中较为有名的一种实现叫Ketama算法,该算法最初是由Last.fm的程序员实现的并得到了广泛的应用,一些开源框架譬如spymemcached,twemproxy等都内置了该算法的实现

Ketama算法一般采用一个节点对应40个虚拟节点。 原因是,节点越多、映射的分布越均匀, 采用虚拟节点可以减少真实节点之间的负载差异

关于一致性哈希算法的映射结果仍然不是很均匀

With 100 replicas (“vnodes”) per server, the standard deviation of load is about 10%. Increasing the number of replicas to 1000 points per server reduces the standard deviation to ~3.2%.

当有100个虚拟节点时,哈希环法的映射结果的分布的标准差大约有 10%10%。 当虚拟节点增加到1000个时,这个标准差降到 3.2%3.2% 左右

https://dgryski.medium.com/co...

虚拟节点是一个绝妙的设计,不仅提高了映射结果的均匀性, 而且为实现加权映射提供了方式。但是,虚拟节点增加了内存消耗和查找时间**, 以常用的ketama为例, 每个节点都对应40个影子节点, 内存的消耗从 O(n)O(n) 变为 O(40n)O(40n) , 查找时间从 O(logn)O(logn) 变为 O(log(40n))O(log(40n)) 。

Hash环下的热扩容和容灾

对于增删节点的情况,哈希环法做到了增量式的重新映射, 不再需要全量数据迁移的工作。 但仍然有部分数据出现了变更前后映射不一致, 技术运营上仍然存在如下问题:

  • 扩容:当增加节点时,新节点需要对齐下一节点的数据后才可以正常服务。
  • 缩容:当删除节点时,需要先把数据备份到下一节点才可以停服移除。
  • 故障:节点突然故障不得不移除时,面临数据丢失风险。

如果我们要实现动态扩容和缩容,即所谓的热扩容,不停止服务对系统进行增删节点, 可以这样做:

  1. 数据备份(双写): 数据写入到某个节点时,同时写一个备份(replica)到顺时针的邻居节点。
  2. 请求中继(代理): 新节点刚加入后,数据没有同步完成时,对读取不到的数据,可以把请求中继(replay)到顺时针方向的邻居节点。

下面的图7.1中演示了这两种规则:

  1. 移除节点的情况: 每一个节点在执行写请求时,都会写一个备份到顺时针的邻居节点。 这样,当 N3N3 节点因故障需要踢除时,新的请求会交接给它的邻居节点 N2, N2 上有 k1 的备份数据,可以正常读到。
  2. 新增节点的情况: 对于新增节点的情况稍微复杂点, 当新增节点 N4 时, N4 需要从邻居节点 N1 上同步数据, 在同步仍未完成时,可能遇到的请求查不到数据, 此时可以先把请求中继给 N1 处理, 待两个节点数据对齐后,再结束中继机制。

    就像细胞分裂一样, N4 刚加入时可以直接算作时 N3 的一部分, N3 算作一个大节点, 当数据对齐后, N4 再从 N3 中分裂出来,正式成为新节点。

散列及Hash环相关的一些理解

另外, 可以备份不止一份。 下面图7.2中演示了备份两次情况, 每个写请求都将备份同步到顺时针方向的最近的两个节点上。 这样就可以容忍相邻的两节点损失的情况, 进一步提高了系统的可用性

散列及Hash环相关的一些理解

同样的,中继也可以不止一次。 下面图7.3中演示了中继两次的情况, 如果一个节点上查不到数据,就中继给下一个节点,最多两次中继, 这样就可以满足同时添加”两个正好在环上相邻的”节点的情况了。

散列及Hash环相关的一些理解

跳跃一致性哈希法

跳跃一致性哈希 ( Jump Consistent Hash ) 是 Google 于2014年发布的一个极简的、快速的一致性哈希算法

Maglev一致性哈希法

Maglev哈希算法来自 Google , 在其2016年发布的一篇论文中:https://writings.sh/post/cons..., 介绍了自2008年起服役的网络负载均衡器Maglev, 文中包括Maglev负载均衡器中所使用的一致性哈希算法,即Maglev一致性哈希 (Maglev Consistent Hashing)。

环的应用场景

定时任务调度

我们以xxl-job任务调度框架中的调度部分的源码来分析下环的应用场景,其中任务定时触发的JobScheduleHelperCron这个阶段流程图如下:

散列及Hash环相关的一些理解

其中最重要的是部分源代码如下

public void start() {

        // schedule thread
        scheduleThread = new Thread(new Runnable() {
            @Override
            public void run() {

                try {
                    TimeUnit.MILLISECONDS.sleep(5000 - System.currentTimeMillis() % 1000);
                } catch (InterruptedException e) {
                    if (!scheduleThreadToStop) {
                        logger.error(e.getMessage(), e);
                    }
                }
                logger.info(">>>>>>>>> init xxl-job admin scheduler success.");

                // pre-read count: treadpool-size * trigger-qps (each trigger cost 50ms, qps = 1000/50 = 20)
                int preReadCount = (XxlJobAdminConfig.getAdminConfig().getTriggerPoolFastMax() + XxlJobAdminConfig.getAdminConfig().getTriggerPoolSlowMax()) * 20;

                while (!scheduleThreadToStop) {

                    // Scan Job
                    long start = System.currentTimeMillis();

                    Connection conn = null;
                    Boolean connAutoCommit = null;
                    PreparedStatement preparedStatement = null;

                    boolean preReadSuc = true;
                    try {

                        conn = XxlJobAdminConfig.getAdminConfig().getDataSource().getConnection();
                        connAutoCommit = conn.getAutoCommit();
                        conn.setAutoCommit(false);

                        preparedStatement = conn.prepareStatement("select * from xxl_job_lock where lock_name = 'schedule_lock' for update");
                        preparedStatement.execute();

                        // tx start

                        // 1、pre read
                        long nowTime = System.currentTimeMillis();
                        List<XxlJobInfo> scheduleList = XxlJobAdminConfig.getAdminConfig().getXxlJobInfoDao().scheduleJobQuery(nowTime + PRE_READ_MS, preReadCount);
                        if (scheduleList != null && scheduleList.size() > 0) {
                            // 2、push time-ring
                            for (XxlJobInfo jobInfo : scheduleList) {

                                // time-ring jump
                                if (nowTime > jobInfo.getTriggerNextTime() + PRE_READ_MS) {
                                    // 2.1、trigger-expire > 5s:pass && make next-trigger-time
                                    logger.warn(">>>>>>>>>>> xxl-job, schedule misfire, jobId = " + jobInfo.getId());

                                    // 1、misfire match
                                    MisfireStrategyEnum misfireStrategyEnum = MisfireStrategyEnum.match(jobInfo.getMisfireStrategy(), MisfireStrategyEnum.DO_NOTHING);
                                    if (MisfireStrategyEnum.FIRE_ONCE_NOW == misfireStrategyEnum) {
                                        // FIRE_ONCE_NOW 》 trigger
                                        JobTriggerPoolHelper.trigger(jobInfo.getId(), TriggerTypeEnum.MISFIRE, -1, null, null, null);
                                        logger.debug(">>>>>>>>>>> xxl-job, schedule push trigger : jobId = " + jobInfo.getId());
                                    }

                                    // 2、fresh next
                                    refreshNextValidTime(jobInfo, new Date());

                                } else if (nowTime > jobInfo.getTriggerNextTime()) {
                                    // 2.2、trigger-expire < 5s:direct-trigger && make next-trigger-time

                                    // 1、trigger
                                    JobTriggerPoolHelper.trigger(jobInfo.getId(), TriggerTypeEnum.CRON, -1, null, null, null);
                                    logger.debug(">>>>>>>>>>> xxl-job, schedule push trigger : jobId = " + jobInfo.getId());

                                    // 2、fresh next
                                    refreshNextValidTime(jobInfo, new Date());

                                    // next-trigger-time in 5s, pre-read again
                                    if (jobInfo.getTriggerStatus() == 1 && nowTime + PRE_READ_MS > jobInfo.getTriggerNextTime()) {

                                        // 1、make ring second
                                        int ringSecond = (int) ((jobInfo.getTriggerNextTime() / 1000) % 60);

                                        // 2、push time ring
                                        pushTimeRing(ringSecond, jobInfo.getId());

                                        // 3、fresh next
                                        refreshNextValidTime(jobInfo, new Date(jobInfo.getTriggerNextTime()));

                                    }

                                } else {
                                    // 2.3、trigger-pre-read:time-ring trigger && make next-trigger-time

                                    // 1、make ring second
                                    int ringSecond = (int) ((jobInfo.getTriggerNextTime() / 1000) % 60);

                                    // 2、push time ring
                                    pushTimeRing(ringSecond, jobInfo.getId());

                                    // 3、fresh next
                                    refreshNextValidTime(jobInfo, new Date(jobInfo.getTriggerNextTime()));

                                }

                            }

                            // 3、update trigger info
                            for (XxlJobInfo jobInfo : scheduleList) {
                                XxlJobAdminConfig.getAdminConfig().getXxlJobInfoDao().scheduleUpdate(jobInfo);
                            }

                        } else {
                            preReadSuc = false;
                        }

                        // tx stop


                    } catch (Exception e) {
                        if (!scheduleThreadToStop) {
                            logger.error(">>>>>>>>>>> xxl-job, JobScheduleHelper#scheduleThread error:{}", e);
                        }
                    } finally {

                        // commit
                        if (conn != null) {
                            try {
                                conn.commit();
                            } catch (SQLException e) {
                                if (!scheduleThreadToStop) {
                                    logger.error(e.getMessage(), e);
                                }
                            }
                            try {
                                conn.setAutoCommit(connAutoCommit);
                            } catch (SQLException e) {
                                if (!scheduleThreadToStop) {
                                    logger.error(e.getMessage(), e);
                                }
                            }
                            try {
                                conn.close();
                            } catch (SQLException e) {
                                if (!scheduleThreadToStop) {
                                    logger.error(e.getMessage(), e);
                                }
                            }
                        }

                        // close PreparedStatement
                        if (null != preparedStatement) {
                            try {
                                preparedStatement.close();
                            } catch (SQLException e) {
                                if (!scheduleThreadToStop) {
                                    logger.error(e.getMessage(), e);
                                }
                            }
                        }
                    }
                    long cost = System.currentTimeMillis() - start;


                    // Wait seconds, align second
                    if (cost < 1000) {  // scan-overtime, not wait
                        try {
                            // pre-read period: success > scan each second; fail > skip this period;
                            TimeUnit.MILLISECONDS.sleep((preReadSuc ? 1000 : PRE_READ_MS) - System.currentTimeMillis() % 1000);
                        } catch (InterruptedException e) {
                            if (!scheduleThreadToStop) {
                                logger.error(e.getMessage(), e);
                            }
                        }
                    }

                }

                logger.info(">>>>>>>>>>> xxl-job, JobScheduleHelper#scheduleThread stop");
            }
        });
        scheduleThread.setDaemon(true);
        scheduleThread.setName("xxl-job, admin JobScheduleHelper#scheduleThread");
        scheduleThread.start();


        // ring thread
        ringThread = new Thread(new Runnable() {
            @Override
            public void run() {

                while (!ringThreadToStop) {

                    // align second
                    try {
                        TimeUnit.MILLISECONDS.sleep(1000 - System.currentTimeMillis() % 1000);
                    } catch (InterruptedException e) {
                        if (!ringThreadToStop) {
                            logger.error(e.getMessage(), e);
                        }
                    }

                    try {
                        // second data
                        List<Integer> ringItemData = new ArrayList<>();
                        int nowSecond = Calendar.getInstance().get(Calendar.SECOND);   // 避免处理耗时太长,跨过刻度,向前校验一个刻度;
                        for (int i = 0; i < 2; i++) {
                            List<Integer> tmpData = ringData.remove((nowSecond + 60 - i) % 60);
                            if (tmpData != null) {
                                ringItemData.addAll(tmpData);
                            }
                        }

                        // ring trigger
                        logger.debug(">>>>>>>>>>> xxl-job, time-ring beat : " + nowSecond + " = " + Arrays.asList(ringItemData));
                        if (ringItemData.size() > 0) {
                            // do trigger
                            for (int jobId : ringItemData) {
                                // do trigger
                                JobTriggerPoolHelper.trigger(jobId, TriggerTypeEnum.CRON, -1, null, null, null);
                            }
                            // clear
                            ringItemData.clear();
                        }
                    } catch (Exception e) {
                        if (!ringThreadToStop) {
                            logger.error(">>>>>>>>>>> xxl-job, JobScheduleHelper#ringThread error:{}", e);
                        }
                    }
                }
                logger.info(">>>>>>>>>>> xxl-job, JobScheduleHelper#ringThread stop");
            }
        });
        ringThread.setDaemon(true);
        ringThread.setName("xxl-job, admin JobScheduleHelper#ringThread");
        ringThread.start();
    }

一个把任务丢到时间环场景代码如下

// 1、make ring second //计算当前任务触发时间 取模,定位到具体某个秒
int ringSecond = (int) ((jobInfo.getTriggerNextTime() / 1000) % 60);
// 2、push time ring //放到时间环中
pushTimeRing(ringSecond, jobInfo.getId());

private void pushTimeRing(int ringSecond, int jobId) {
        // push async ring
        List<Integer> ringItemData = ringData.get(ringSecond);
        if (ringItemData == null) {
            ringItemData = new ArrayList<Integer>();
            ringData.put(ringSecond, ringItemData);
        }
        ringItemData.add(jobId);

        logger.debug(">>>>>>>>>>> xxl-job, schedule push time-ring : " + ringSecond + " = " + Arrays.asList(ringItemData));
    }

循环从时间环中取出任务执行的代码如下

try {
                        // second data
                        List<Integer> ringItemData = new ArrayList<>();
                        int nowSecond = Calendar.getInstance().get(Calendar.SECOND);   // 避免处理耗时太长,跨过刻度,向前校验一个刻度;
                        for (int i = 0; i < 2; i++) {
                            List<Integer> tmpData = ringData.remove((nowSecond + 60 - i) % 60);
                            if (tmpData != null) {
                                ringItemData.addAll(tmpData);
                            }
                        }

                        // ring trigger
                        logger.debug(">>>>>>>>>>> xxl-job, time-ring beat : " + nowSecond + " = " + Arrays.asList(ringItemData));
                        if (ringItemData.size() > 0) {
                            // do trigger
                            for (int jobId : ringItemData) {
                                // do trigger
                                JobTriggerPoolHelper.trigger(jobId, TriggerTypeEnum.CRON, -1, null, null, null);
                            }
                            // clear
                            ringItemData.clear();
                        }
                    } catch (Exception e) {
                        if (!ringThreadToStop) {
                            logger.error(">>>>>>>>>>> xxl-job, JobScheduleHelper#ringThread error:{}", e);
                        }
                    }

时间环如下图所示

散列及Hash环相关的一些理解

通过ringThread无限循环执行这个时间环上的任务,实现任务的定时触发

点赞
收藏
评论区
推荐文章
Stella981 Stella981
3年前
Redis都有哪些数据类型
string这是最基本的类型了,就是普通的set和get,做简单的kv缓存hash这个是类似map的一种结构,这个一般就是可以将结构化的数据,比如一个对象(前提是这个对象没嵌套其他的对象)给缓存在redis里,然后每次读写缓存的时候,可以操作hash里的某个字段。key150value{“id”:
Wesley13 Wesley13
3年前
GoJS API学习
varnode{};node"key""节点Key";node"loc""00";//节点坐标node"text""节点名称";//添加节点通过按钮点击,添加新的节点到画布myDiagram.model.addNodeData(nod
Wesley13 Wesley13
3年前
PHP创建多级树型结构
<!lang:php<?php$areaarray(array('id'1,'pid'0,'name''中国'),array('id'5,'pid'0,'name''美国'),array('id'2,'pid'1,'name''吉林'),array('id'4,'pid'2,'n
Stella981 Stella981
3年前
Consistent hashing一致性算法原理
最近在整理redis分布式集群,首先就整理一下分布式算法原理。常见的分区规则有哈希分区和顺序分区两种,Redis采用的是哈希分区规则。节点取余分区使用特定的数据,如Redis的键或用户ID为key,节点数量为N,则:hash(key)%N,计算出哈希值,然后决定映射到哪个节点上,如节点数为4时,哈希值的结果可能为0、1、2,3.现假
Stella981 Stella981
3年前
Hash算法解决冲突的四种方法
Hash算法解决冲突的方法一般有以下几种常用的解决方法 1,开放定址法: 所谓的开放定址法就是一旦发生了冲突,就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并将记录存入 公式为:fi(key)(f(key)di)MODm(di1,2,3,……,m1) ※用开放定址法解决冲突的做法是:当冲突发
Wesley13 Wesley13
3年前
N数码问题的启发式搜索算法
一、启发式搜索:A算法1)评价函数的一般形式:f(n)g(n)h(n)g(n):从S0到Sn的实际代价(搜索的横向因子)h(n):从N到目标节点的估计代价,称为启发函数(搜索的纵向因子);特点:效率高,无回溯, 搜索算法OPEN表:存放待扩展的节点.CLOSED表:存放已被扩展过的节点
Stella981 Stella981
3年前
Bzoj4899 记忆的轮廓
B.记忆的轮廓题目描述通往贤者之塔的路上,有许多的危机。我们可以把这个地形看做是一颗树,根节点编号为1,目标节点编号为n,其中1n的简单路径上,编号依次递增,在\1,n\中,一共有n个节点。我们把编号在\1,n\的叫做正确节点,\n1,m\的叫做错误节点。一个叶子,如果是正确节点则为正确叶子,否则称
Wesley13 Wesley13
3年前
Java之一致性hash算法原理及实现
一致性哈希算法是分布式系统中常用的算法。比如,一个分布式的存储系统,要将数据存储到具体的节点上,如果采用普通的hash方法,将数据映射到具体的节点上,如key%N,key是数据的key,N是机器节点数,如果有一个机器加入或退出这个集群,则所有的数据映射都无效了。一致性哈希算法,解决了普通余数Hash算法伸缩性差的问题,可以保证在上线、下线服务
Ceph的crush算法与一致性hash对比介绍
一致性hash的基本思想是,有一个hash函数,这个hash函数的值域形成了一个环(收尾相接:thelargesthashvaluewrapsaroundtothesmallesthashvalue),然后存储的节点也通过这个hash函数随机的分配到这个环上,然后某个key具体存储到哪个节点上,是由这个key取hash函数对应到环的一个位置,然后沿着这个位置顺时针找到的第一个节点负责这个key的存储。这样环上的每个节点负责和它前面节点之间的这个区间的数据的存储。
美凌格栋栋酱 美凌格栋栋酱
5个月前
Oracle 分组与拼接字符串同时使用
SELECTT.,ROWNUMIDFROM(SELECTT.EMPLID,T.NAME,T.BU,T.REALDEPART,T.FORMATDATE,SUM(T.S0)S0,MAX(UPDATETIME)CREATETIME,LISTAGG(TOCHAR(
贾蔷 贾蔷
2天前
牛客13279题解:利用递归与深度优先搜索计算树的最大高度(附完整代码)
一、题目解读牛客13279题要求计算给定树的最大高度。题目输入一棵以邻接表形式表示的树(节点从0开始编号),需要输出从根节点到最深叶节点的最长路径长度。树的结构由n个节点和n1条边构成,保证为连通无环图。理解题目核心在于准确获取树的拓扑关系,并设计算法遍历