精华内容
下载资源
问答
  • 企业微信组织架构同步优化的思路

    万次阅读 2018-02-12 16:03:25
    企业微信初版的全量同步方案在快速的业务增长面前已经捉襟见肘,针对其遇到的问题,怎样做好组织架构同步优化?这是又一篇来自微信团队的技术实战。写在前面企业微信在快速发展过程中,陆续有大企业加入使用,企业...

    作为企业级的微信,在业务快速发展的背景下,迭代优化的要求也越发急迫。企业微信初版的全量同步方案在快速的业务增长面前已经捉襟见肘,针对其遇到的问题,怎样做好组织架构同步优化?这是又一篇来自微信团队的技术实战。

    写在前面

    企业微信在快速发展过程中,陆续有大企业加入使用,企业微信初版采用全量同步方案,该方案在大企业下存在流量和性能两方面的问题,每次同步消耗大量流量,且在 iPhone 5s 上拉取 10w+ 成员架构包解压时会提示 memory warning 而应用崩溃。

    全量同步方案难以支撑业务的快速发展,优化同步方案越来越有必要。本文针对全量同步方案遇到的问题进行分析,提出组织架构增量同步方案,并对移动端实现增量同步方案的思路和重难点进行了讲解。

    企业微信业务背景

    在企业微信中,组织架构是非常重要的模块,用户可以在首页的 tab 上选择"通讯录"查看到本公司的组织架构,并且可以通过"通讯录"找到本公司的所有成员,并与其发起会话或者视频语音通话。

    组织架构是非常重要且敏感的信息,企业微信作为企业级产品,始终把用户隐私和安全放在重要位置。针对组织架构信息,企业管理员具有高粒度隐私保护操作权限,不仅支持个人信息隐藏,也支持通讯录查看权限等操作。

    在企业微信中,组织架构特征有:

    1、多叉树结构。叶子节点代表成员,非叶子节点代表部门。部门最多只有一个父部门,但成员可属于多个部门。

    2、架构隐藏操作。企业管理员可以在管理后台设置白名单和黑名单,白名单可以查看完整的组织架构,其他成员在组织架构里看不到他们。黑名单的成员只能看到自己所在小组和其所有的父部门,其余人可以看到黑名单的成员。

    3、组织架构操作。企业管理员可以在 web 端和 app 端添加 / 删除部门,添加 / 删除 / 移动 / 编辑成员等操作,并且操作结果会及时同步给本公司所有成员。

    全量同步方案的问题

    本节大致讲解下全量同步方案实现以及遇到的问题。

    全量同步方案原理

    企业微信在 1.0 时代,从稳定性以及快速迭代的角度考虑,延用了企业邮通讯录同步方案,采取了全量架构同步方案。

    核心思想为服务端下发全量节点,客户端对比本地数据找出变更节点。此处节点可以是用户,也可以是部门,将组织架构视为二叉树结构体,其下的用户与部门均为节点,若同一个用户存在多个部门下,被视为多个节点。

    全量同步方案分为首次同步与非首次同步:

    首次同步服务端会下发全量的节点信息的压缩包,客户端解压后得到全量的架构树并展示。

    非首次同步分为两步:

    服务端下发全量节点的 hash 值。客户端对比本地数据找到删除的节点保存在内存中,对比找到新增的节点待请求具体信息。

    客户端请求新增节点的具体信息。请求具体信息成功后,再进行本地数据库的插入 / 更新 / 删除处理,保证同步流程的原子性。

    用户反馈

    初版上线后,收到了大量的组织架构相关的 bug 投诉,主要集中在:

    流量消耗过大。

    客户端架构与 web 端架构不一致。

    组织架构同步不及时。

    这些问题在大企业下更明显。

    问题剖析

    深究全量同步方案难以支撑大企业同步的背后原因,皆是因为采取了服务端全量下发 hash 值方案的原因,方案存在以下问题:

    拉取大量冗余信息。即使只有一个成员信息的变化,服务端也会下发全量的 hash 节点。针对几十万人的大企业,这样的流量消耗是相当大的,因此在大企业要尽可能的减少更新的频率,但是却会导致架构数据更新不及时。

    大企业拉取信息容易失败。全量同步方案中首次同步架构会一次性拉取全量架构树的压缩包,而超大企业这个包的数据有几十兆,解压后几百兆,对内存不足的低端设备,首次加载架构可能会出现内存不足而 crash。非首次同步在对比出新增的节点,请求具体信息时,可能遇到数据量过大而请求超时的情况。

    客户端无法过滤无效数据。客户端不理解 hash 值的具体含义,导致在本地对比 hash 值时不能过滤掉无效 hash 的情况,可能出现组织架构展示错误。

    优化组织架构同步方案越来越有必要。

    寻找优化思路

    寻求同步方案优化点,我们要找准原来方案的痛点以及不合理的地方,通过方案的调整来避免这个问题。

    组织架构同步难点

    准确且耗费最少资源同步组织架构是一件很困难的事情,难点主要在:

    组织架构架构数据量大。消息 / 联系人同步一次的数据量一般情况不会过百,而企业微信活跃企业中有许多上万甚至几十万节点的企业,意味着架构一次同步的数据量很轻松就会上千上万。移动端的流量消耗是用户非常在乎的,且内存有限,减少流量的消耗以及减少内存使用并保证架构树的完整同步是企业微信追求的目标。

    架构规则复杂。组织架构必须同步到完整的架构树才能展示,而且企业微信里的涉及到复杂的隐藏规则,为了安全考虑,客户端不应该拿到隐藏的成员。

    修改频繁且改动大。组织架构的调整存在着新建部门且移动若干成员到新部门的情况,也存在解散某个部门的情况。而员工离职也会通过组织架构同步下来,意味着超大型企业基本上每天都会有改动。

    技术选型-提出增量更新方案

    上述提到的问题,在大型企业下会变得更明显。在几轮方案讨论后,我们给原来的方案增加了两个特性来实现增量更新:

    增量。服务端记录组织架构修改的历史,客户端通过版本号来增量同步架构。

    分片。同步组织架构的接口支持传阈值来分片拉取。

    在新方案中,服务端针对某个节点的存储结构可简化为:

    vid 是指节点用户的唯一标识 id,departmentid 是指节点的部门 id,is_delete 表示该节点是否已被删除。

    若节点被删除了,服务端不会真正的删除该节点,而将 is_delete 标为 true。

    若节点被更新了,服务端会增大记录的 seq,下次客户端来进行同步便能同步到。

    其中,seq 是自增的值,可以理解成版本号。每次组织架构的节点有更新,服务端增加相应节点的 seq 值。客户端通过一个旧的 seq 向服务器请求,服务端返回这个 seq 和 最新的 seq 之间所有的变更给客户端,完成增量更新。

    图示为:

    通过提出增量同步方案,我们从技术选型层面解决了问题,但是在实际操作中会遇到许多问题,下文中我们将针对方案原理以及实际操作中遇到的问题进行讲解。

    增量同步方案

    本节主要讲解客户端中增量同步架构方案的原理与实现,以及基础概念讲解。

    增量同步方案原理

    企业微信中,增量同步方案核心思想为:

    服务端下发增量节点,且支持传阈值来分片拉取增量节点,若服务端计算不出客户端的差量,下发全量节点由客户端来对比差异。

    增量同步方案可抽象为四步完成:

    客户端传入本地版本号,拉取变更节点。

    客户端找到变更节点并拉取节点的具体信息。

    客户端处理数据并存储版本号。

    判断完整架构同步是否完成,若尚未完成,重复步骤 1,若完成了完整组织架构同步,清除掉本地的同步状态。

    忽略掉各种边界条件和异常状况,增量同步方案的流程图可以抽象为:

    接下来我们再看看增量同步方案中的关键概念以及完整流程是怎样的。

    版本号

    同步的版本号是由多个版本号拼接成的字符串,版本号的具体含义对客户端透明,但是对服务端非常重要。

    版本号的组成部分为:

    版本号回退

    增量同步在实际操作过程中会遇到一些问题:

    服务端不可能永久存储删除的记录,删除的记录对服务端是毫无意义的而且永久存储会占用大量的硬盘空间。而且无效数据过多也会影响架构读取速度。当 is_delete 节点的数目超过一定的阈值后,服务端会物理删除掉所有的 is_delete 为 true 的节点。此时客户端会重新拉取全量的数据进行本地对比。

    一旦架构隐藏规则变化后,服务端很难计算出增量节点,此时会下发全量节点由客户端对比出差异。

    理想状况下,若服务端下发全量节点,客户端铲掉旧数据,并且去拉全量节点的信息,并且用新数据覆盖即可。但是移动端这样做会消耗大量的用户流量,这样的做法是不可接受的。所以若服务端下发全量节点,客户端需要本地对比出增删改节点,再去拉变更节点的具体信息。

    增量同步情况下,若服务端下发全量节点,我们在本文中称这种情况为版本号回退,效果类似于客户端用空版本号去同步架构。从统计结果来看,线上版本的同步中有 4% 的情况会出现版本号回退。

    阈值分片拉取

    若客户端的传的 seq 过旧,增量数据可能很大。此时若一次性返回全部的更新数据,客户端请求的数据量会很大,时间会很长,成功率很低。针对这种场景,客户端和服务端需要约定阈值,若请求的更新数据总数超过这个阈值,服务端每次最多返回不超过该阈值的数据。若客户端发现服务端返回的数据数量等于阈值,则再次到服务端请求数据,直到服务端下发的数据数量小于阈值。

    节点结构体优化

    在全量同步方案中,节点通过 hash 唯一标示。服务端下发的全量 hash 列表,客户端对比本地存储的全量 hash 列表,若有新的 hash 值则请求节点具体信息,若有删除的 hash 值则客户端删除掉该节点信息。

    在全量同步方案中,客户端并不能理解 hash 值的具体含义,并且可能遇到 hash 碰撞这种极端情况导致客户端无法正确处理下发的 hash 列表。

    而增量同步方案中,使用 protobuf 结构体代替 hash 值,增量更新中节点的 proto 定义为:

    在增量同步方案中,用 vid 和 partyid 来唯一标识节点,完全废弃了 hash 值。这样在增量同步的时候,客户端完全理解了节点的具体含义,而且也从方案上避免了曾经在全量同步方案遇到的 hash 值重复的异常情况。

    并且在节点结构体里带上了 seq 。节点上的 seq 来表示该节点的版本,每次节点的具体信息有更新,服务端会提高节点的 seq,客户端发现服务端下发的节点 seq 比客户端本地的 seq 大,则需要去请求节点的具体信息,避免无效的节点信息请求。

    判断完整架构同步完成

    因为 svr 接口支持传阈值批量拉取变更节点,一次网络操作并不意味着架构同步已经完成。那么怎么判断架构同步完成了呢?这里客户端和服务端约定的方案是:

    若服务端下发的(新增节点+删除节点)小于客户端传的阈值,则认为架构同步结束。

    当完整架构同步完成后,客户端需要清除掉缓存,并进行一些额外的业务工作,譬如计算部门人数,计算成员搜索热度等。

    增量同步方案 - 完整流程图

    考虑到各种边界条件和异常情况,增量同步方案的完整流程图为:

    增量同步方案难点

    在加入增量和分片特性后,针对几十万人的超大企业,在版本号回退的场景,怎样保证架构同步的完整性和方案选择成为了难点。

    前文提到,隐藏规则变更以及后台物理删除无效节点后,客户端若用很旧的版本同步,服务端算不出增量节点,此时服务端会下发全量节点,客户端需要本地对比所有数据找出变更节点,该场景可以理解为版本号回退。在这种场景下,对于几十万节点的超大型企业,若服务端下发的增量节点过多,客户端请求的时间会很长,成功率会很低,因此需要分片拉取增量节点。而且拉取下来的全量节点,客户端处理不能请求全量节点的具体信息覆盖旧数据,这样的话每次版本号回退的场景流量消耗过大。

    因此,针对几十万节点的超大型企业的增量同步,客户端难点在于:

    断点续传。增量同步过程中,若客户端遇到网络问题或应用中止了,在下次网络或应用恢复时,能够接着上次同步的进度继续同步。

    同步过程中不影响正常展示。超大型企业同步的耗时可能较长,同步的时候不应影响正常的组织架构展示。

    控制同步耗时。超大型企业版本号回退的场景同步非常耗时,但是我们需要想办法加快处理速度,减少同步的消耗时间。

    思路

    架构同步开始,将架构树缓存在内存中,加快处理速度。

    若服务端端下发了需要版本号回退的 flag,本地将 db 中的节点信息做一次备份操作。

    将服务端端下发的所有 update 节点,在架构树中查询,若找到了,则将备份数据转为正式数据。若找不到,则为新增节点,需要拉取具体信息并保存在架构树中。

    当完整架构同步结束后,在 db 中找到并删除掉所有备份节点,清除掉缓存和同步状态。

    若服务端下发了全量节点,客户端的处理时序图为:

    服务端下发版本号回退标记

    从时序图中可以看出,服务端下发的版本号回退标记是很重要的信号。

    而版本号回退这个标记,仅仅在同步的首次会随着新的版本号而下发。在完整架构同步期间,客户端需要将该标记缓存,并且跟着版本号一起存在数据库中。在完整架构同步结束后,需要根据是否版本号回退来决定删除掉数据库中的待删除节点。

    备份架构树方案

    架构树备份最直接的方案是将 db 中数据 copy 一份,并存在新表里。如果在数据量很小的情况下,这样做是完全没有问题的,但是架构树的节点往往很多,采取这样简单粗暴的方案在移动端是完全不可取的,在几十万人的企业里,这样做会造成极大的性能问题。

    经过考虑后,企业微信采取的方案是:

    若同步架构时,后台下发了需要版本号回退的 flag,客户端将缓存和 db 中的所有节点标为待删除(时序图中 8,9 步)。

    针对服务端下发的更新节点,在架构树中清除掉节点的待删除标记(时序图中 10,11 步)。

    在完整架构同步结束后,在 db 中找到并删除掉所有标为待删除的节点(时序图中 13 步),并且清除掉所有缓存数据。

    而且,在增量同步过程中,不应该影响正常的架构树展示。所以在架构同步过程中,若有上层来请求 db 中的数据,则需要过滤掉有待删除标记的节点。

    缓存架构树

    方案决定客户端避免不了全量节点对比,将重要的信息缓存到内存中会大大加快处理速度。内存中的架构树节点体定义为:

    此处我们用 std::map 来缓存架构树,用 std::pair 作为 key。我们在比较节点的时候,会涉及到很多查询操作,使用 map 查询的时间复杂度仅为 O(logn)。

    增量同步方案关键点

    本节单独将优化同步方案中关键点拿出来写,这些关键点不仅仅适用于本文架构同步,也适用于大多数同步逻辑。

    保证数据处理完成后,再储存版本号

    在几乎所有的同步中,版本号都是重中之重,一旦版本号乱掉,后果非常严重。

    在架构同步中,最最重要的一点是:

    保证数据处理完成后,再储存版本号。

    在组织架构同步的场景下,为什么不能先存版本号,再存数据呢?

    这涉及到组织架构同步数据的一个重要特征:架构节点数据是可重复拉取并覆盖的。

    考虑下实际操作中遇到的真实场景:

    若客户端已经向服务端请求了新增节点信息,客户端此时刚刚插入了新增节点,还未储存版本号,客户端应用中止了。

    此时客户端重新启动,又会用相同版本号拉下刚刚已经处理过的节点,而这些节点跟本地数据对比后,会发现节点的 seq 并未更新而不会再去拉节点信息,也不会造成节点重复。

    若一旦先存版本号再存具体数据,一定会有概率丢失架构更新数据。

    同步的原子性

    正常情况下,一次同步的逻辑可以简化为:

    在企业微信的组织架构同步中存在异步操作,若进行同步的过程不保证原子性,极大可能出现下图所示的情况:

    该图中,同步的途中插入了另外一次同步,很容易造成问题:

    输出结果不稳定。若两次同步几乎同时开始,但因为存在网络波动等情况,返回结果可能不同,给调试造成极大的困扰。

    中间状态错乱。若同步中处理服务端返回的结果会依赖于请求同步时的某个中间状态,而新的同步发起时又会重置这个状态,很可能会引起匪夷所思的异常。

    时序错乱。整个同步流程应该是原子的,若中间插入了其他同步的流程会造成整个同步流程时序混乱,引发异常。

    怎样保证同步的原子性呢?

    我们可以在开始同步的时候记一个 flag 表示正在同步,在结束同步时,清除掉该 flag。若另外一次同步到来时,发现正在同步,则可以直接舍弃掉本次同步,或者等本次同步成功后再进行一次同步。

    此外也可将同步串行化,保证同步的时序,多次同步的时序应该是 FIFO 的。

    缓存数据一致性

    移动端同步过程中的缓存多分为两种:

    内存缓存。加入内存缓存的目的是减少文件 IO 操作,加快程序处理速度。

    磁盘缓存。加入磁盘缓存是为了防止程序中止时丢失掉同步状态。

    内存缓存多缓存同步时的数据以及同步的中间状态,磁盘缓存用于缓存同步的中间状态防止缓存状态丢失。

    在整个同步过程中,我们都必须保证缓存中的数据和数据库的数据的更改需要一一对应。在增量同步的情况中,我们每次需要更新 / 删除数据库中的节点,都需要更新相应的缓存信息,来保证数据的一致性。

    优化数据对比

    内存使用

    测试方法:使用工具 Instrument,用同一账号监控全量同步和增量同步分别在首次加载架构时的 App 内存峰值。

    内存峰值测试结果

    分析

    随着架构的节点增多,全量同步方案的内存峰值会一直攀升,在极限情况下,会出现内存不足应用程序 crash 的情况(实际测试中,30w 节点下,iPhone 6 全量同步方案必 crash)。而增量同步方案中,总节点的多少并不会影响内存峰值,仅仅会增加同步分片的次数。

    优化后,在腾讯域下,增量同步方案的 App 总内存使用仅为全量同步方案的 53.1%,且企业越大优化效果越明显。并且不论架构的总节点数有多少,增量同步方案都能将完整架构同步下来,达到了预期的效果。

    流量使用

    测试方法:在管理端对成员做增加操作五次,通过日志分析客户端消耗流量,取其平均值。日志会打印出请求的 header 和 body 大小并估算出流量使用值。

    测试结果

    分析

    增加成员操作,针对增量同步方案仅仅会新拉单个成员的信息,所以无论架构里有多少人,流量消耗都是相近的。同样的操作针对全量同步方案,每次请求变更,服务端都会下发全量 hash 列表,企业越大消耗的流量越多。可以看到,当企业的节点数达到 20w 级别时,全量同步方案的流量消耗是增量同步方案的近 500 倍。

    优化后,在腾讯域下,每次增量同步流量消耗仅为全量同步方案的 0.4%,且企业越大优化效果越明显。

    写在最后

    增量同步方案从方案上避免了架构同步不及时以及流量消耗过大的问题。通过用户反馈和数据分析,增量架构同步上线后运行稳定,达到了理想的优化效果。


    展开全文
  • Java C++ 数据结构对比

    千次阅读 2018-09-25 17:17:25
    目的是总结一下 JAVA 和 C++几种常用的数据结构内存模型,知道了内存结构,才能在实战中更好滴选用数据结构。通过总结,日后也避免把两种语言的结构混为一谈。闲言少叙,开始正文。 关键字:JAVA C++ 数据结构 ...

    转载请注明出处:https://blog.csdn.net/mymottoissh/article/details/82826709

    目的是总结一下 JAVA 和 C++几种常用的数据结构内存模型,知道了内存结构,才能在实战中更好滴选用数据结构。通过总结,日后也避免把两种语言的结构混为一谈。闲言少叙,开始正文。

    关键字:JAVA C++ 数据结构

    目录

    一、动态数组

    二、链表

    三、树


    首先做一个整体的概括:

    底层数据存储只分成两大类,一种是连续存储,如数组。一种是链式存储,如链表。在这基础之上,结合结构和算法,才衍生出如树、图之类的结构。前者的优点:随机寻址快。后者优点:插删速度快。这里也会结合源码做一些剖析。

    一、动态数组

    代表结构:

    JAVA :ArrayList、Vector、HashMap/HashTable/LinkedHashMap、HashSet

    C++ : vector、deque<特>、stack/queue <特>

    开始接触的时候很困惑,Java里明明写的是list,为什么还算作数组。这就是恶心的地方,所以千万不要别外表欺骗了。

    Java::ArrayList

    ArrayList无疑是项目中最常用的结构,为了查找他底层的存储原理,直接看他的add方法

        /**
         * Appends the specified element to the end of this list.
         *
         * @param e element to be appended to this list
         * @return <tt>true</tt> (as specified by {@link Collection#add})
         */
        public boolean add(E e) {
            ensureCapacityInternal(size + 1);  // Increments modCount!!
            elementData[size++] = e;
            return true;
        }

    显然,存储结构为一数组 elementData。这里有一个关键方法实现动态内存

        private void ensureCapacityInternal(int minCapacity) {
            if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
                minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
            }
    
            ensureExplicitCapacity(minCapacity);
        }
    
        private void ensureExplicitCapacity(int minCapacity) {
            modCount++;
    
            // overflow-conscious code
            if (minCapacity - elementData.length > 0)
                grow(minCapacity);
        }
    
        private void grow(int minCapacity) {
            // overflow-conscious code
            int oldCapacity = elementData.length;
            int newCapacity = oldCapacity + (oldCapacity >> 1);
            if (newCapacity - minCapacity < 0)
                newCapacity = minCapacity;
            if (newCapacity - MAX_ARRAY_SIZE > 0)
                newCapacity = hugeCapacity(minCapacity);
            // minCapacity is usually close to size, so this is a win:
            elementData = Arrays.copyOf(elementData, newCapacity);
        }

    每次添加一个数据之前,都要保证数组中有比现有数据长度多1的容量,如果保证不了,则会创建一个新的容量为以前1.5倍的数组,并把元素进行复制。可想而知,如果需要反复扩容的话,ArrayList的效率是非常低的。同时,ArrayList也保留了数组本身的特点:如果在某一位置增删数据,需要把后面的数据全都复制一遍。这部分代码就不贴了。

    Java::Vector

        /**
         * Appends the specified element to the end of this Vector.
         *
         * @param e element to be appended to this Vector
         * @return {@code true} (as specified by {@link Collection#add})
         * @since 1.2
         */
        public synchronized boolean add(E e) {
            modCount++;
            ensureCapacityHelper(elementCount + 1);
            elementData[elementCount++] = e;
            return true;
        }
    

    在add方法下,可以看到与ArrayList类似的存储:数组。增加之前,首先确保容量,唯一不同的在于在前面加入了synchronized关键字。所以在多线程环境下,应该选用Vector。

    JAVA::HashMap/HashTable/LinkedHashMap

    这里把HashMap及相关结归结到了动态数组里,这关系到HashMap的实现原理。直接放源码

        public V put(K key, V value) {
            return putVal(hash(key), key, value, false, true);
        }
    
    /**
         * Implements Map.put and related methods
         *
         * @param hash hash for key
         * @param key the key
         * @param value the value to put
         * @param onlyIfAbsent if true, don't change existing value
         * @param evict if false, the table is in creation mode.
         * @return previous value, or null if none
         */
        final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                       boolean evict) {
            Node<K,V>[] tab; Node<K,V> p; int n, i;
            if ((tab = table) == null || (n = tab.length) == 0)
                n = (tab = resize()).length;
            if ((p = tab[i = (n - 1) & hash]) == null) //索引下标计算
                tab[i] = newNode(hash, key, value, null);
            else {
                Node<K,V> e; K k;
                if (p.hash == hash &&
                    ((k = p.key) == key || (key != null && key.equals(k))))
                    e = p;
                else if (p instanceof TreeNode)
                    e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
                else {
                    for (int binCount = 0; ; ++binCount) {
                        if ((e = p.next) == null) {
                            p.next = newNode(hash, key, value, null);
                            if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                                treeifyBin(tab, hash);
                            break;
                        }
                        if (e.hash == hash &&
                            ((k = e.key) == key || (key != null && key.equals(k))))
                            break;
                        p = e;
                    }
                }
                if (e != null) { // existing mapping for key
                    V oldValue = e.value;
                    if (!onlyIfAbsent || oldValue == null)
                        e.value = value;
                    afterNodeAccess(e);
                    return oldValue;
                }
            }
            ++modCount;
            if (++size > threshold)
                resize();
            afterNodeInsertion(evict);
            return null;
        }

    首先可以看到存储结构为一Node数组。在这里有点小疑问,Java里的Node是一个单向链表节点,而这里却用了一个Node数组存储。究其原因,还有分析一下HashMap的原理。当向map中put一个键值对时,首先通过hash方法计算key的哈希值,以 (数组长度-1)&哈希值 作为数组索引下标。如果该下标对应的数组值为空,则new一个新节点。如果已经有值,那该值对应的key一定等于新增的可以吗?当然不是,因为可能会有哈希碰撞。这时候就用到单向链表结构的特性了,如果当前的数组值对应的key与新增key不相等,则会遍历以当前Node值为首节点的链表,直到找到与新key相等的节点为止。其中TREEIFY_THRESHOLD 定义为8,如果超过次链表长度,将转为树存储。更形象的表示,HashMap的结构应该是这样滴。

    在put源码中,有两个HashMap未实现的函数,但是在LinkedHashMap中实现了,这也保证了其有序的元素存储。

    // Callbacks to allow LinkedHashMap post-actions
        void afterNodeAccess(Node<K,V> p) { }
        void afterNodeInsertion(boolean evict) { }
        void afterNodeRemoval(Node<K,V> p) { }
    

    至于HashTable与HashMap的区别,这里就不造轮子了

    引用一段 : https://blog.csdn.net/qq_29631809/article/details/72599708

    HashMap和Hashtable的主要的区别有:线程安全性,同步(synchronization),以及速度。

    • HashMap几乎可以等价于Hashtable,除了HashMap是非synchronized的,并可以接受null(HashMap可以接受为null的键值(key)和值(value),而Hashtable则不行)。 HashMap是非synchronized,而Hashtable是synchronized,这意味着Hashtable是线程安全的,多个线程可以共享一个Hashtable;而如果没有正确的同步的话,多个线程是不能共享HashMap的。Java 5提供了ConcurrentHashMap,它是HashTable的替代,比HashTable的扩展性更好。
    • 另一个区别是HashMap的迭代器(Iterator)是fail-fast迭代器,而Hashtable的enumerator迭代器不是fail-fast的。所以当有其它线程改变了HashMap的结构(增加或者移除元素),将会抛出ConcurrentModificationException,但迭代器本身的remove()方法移除元素则不会抛出ConcurrentModificationException异常。但这并不是一个一定发生的行为,要看JVM。这条同样也是Enumeration和Iterator的区别。 由于Hashtable是线程安全的也是synchronized,所以在单线程环境下它比HashMap要慢。如果你不需要同步,只需要单一线程,那么使用HashMap性能要好过Hashtable。 HashMap不能保证随着时间的推移Map中的元素次序是不变的。

    Java::HashSet

    废话不多说,贴一个源码就知道了

        /**
         * Constructs a new, empty set; the backing <tt>HashMap</tt> instance has
         * default initial capacity (16) and load factor (0.75).
         */
        public HashSet() {
            map = new HashMap<>();
        }
    
        /**
         * Adds the specified element to this set if it is not already present.
         * More formally, adds the specified element <tt>e</tt> to this set if
         * this set contains no element <tt>e2</tt> such that
         * <tt>(e==null&nbsp;?&nbsp;e2==null&nbsp;:&nbsp;e.equals(e2))</tt>.
         * If this set already contains the element, the call leaves the set
         * unchanged and returns <tt>false</tt>.
         *
         * @param e element to be added to this set
         * @return <tt>true</tt> if this set did not already contain the specified
         * element
         */
        public boolean add(E e) {
            return map.put(e, PRESENT)==null;
        }

    HashSet内部维护了一个HashMap的,对HashSet的操作,实际上就是多HashMap的操作。

    C++::vector

    void push_back(_Ty&& _Val)
    {	// insert element at end
    if (_Inside(_STD addressof(_Val)))
    	{	// push back an element
    	size_type _Idx = _STD addressof(_Val) - this->_Myfirst;
    	if (this->_Mylast == this->_Myend)
    		_Reserve(1);
    	_Orphan_range(this->_Mylast, this->_Mylast);
    	_Cons_val(this->_Alval,
    		this->_Mylast,
    		_STD forward<_Ty>(this->_Myfirst[_Idx]));
    	++this->_Mylast;
    	}
    else
    	{	// push back a non-element
    	if (this->_Mylast == this->_Myend)
    		_Reserve(1);
    	_Orphan_range(this->_Mylast, this->_Mylast);
    	_Cons_val(this->_Alval,
    		this->_Mylast,
    		_STD forward<_Ty>(_Val));
    	++this->_Mylast;
    	}
    }

    pushback时,首先判断该元素地址是否属于数组内部,如果是,则直接在改地址对应位置更新数据,同时在最末端位置压入数据。如果不是,则在数组末端添加数据。

    首先,vector是单端数组,只提供了push_back函数,虽然也提供了insert函数,但是插入时,会导致后续元素进行交换反转,所以插入效率极低。

    其次,_Reserve函数用于数组扩容,扩大为原来的1.5倍。在扩容的过程中会new一块新的内存空间。这意味着一旦被扩容,指向原有元素的迭代器将失效。

    C++::deque

    双端数组,提供前端压入函数push_front和末端压入函数push_back。虽然命名为双端数组,而且从某种意义上来说是内存连续的,但是关于deque有必要重点说明一下。首先来做一个实验。

    有如下代码,编译运行

    int main() {
        std::deque<int> deq;
        for(int i = 0; i < 10; i++) {
            deq.push_back(i);
        }
        for(int i = 0; i < 10; i++) {
            cout << deq[i] << " " << &deq[i] << endl;
        }
    	system("pause");
    	return 0;
    }

    来看一运行结果

    0 00000000002CAF10
    1 00000000002CAF14
    2 00000000002CAF18
    3 00000000002CAF1C
    4 00000000002CAF60
    5 00000000002CAF64
    6 00000000002CAF68
    7 00000000002CAF6C
    8 00000000002DD2D0
    9 00000000002DD2D4

    奇怪吧,从运行结果可以看到,每四个元素的内存是连续的,从第五个开始,会跳过一段空间,重新组织内存。那关于deque实现到底是怎样的呢。用一句话说,deque是数组和指针(并不能说是链表)的组合实现。上源码。

    void push_front(_Ty&& _Val)
    {	// insert element at beginning
    	this->_Orphan_all();
    	_PUSH_FRONT_BEGIN;
    	_Cons_val(this->_Alval,
    		this->_Map[_Block] + _Newoff % _DEQUESIZ,
    		_STD forward<_Ty>(_Val));
    	_PUSH_FRONT_END;
    }
    
    void push_back(_Ty&& _Val)
    {	// insert element at end
    	this->_Orphan_all();
    	_PUSH_BACK_BEGIN;
    	_Cons_val(this->_Alval,
    		this->_Map[_Block] + _Newoff % _DEQUESIZ,
    		_STD forward<_Ty>(_Val));
    	_PUSH_BACK_END;
    }

    从源码中可以看到,插入的时候,实际上是取的_Map[_Block]所存储的地址。

    _Mapptr _Map;		// pointer to array of pointers to blocks

     而通过注释看到,_Map实际上是一个二级指针,指向的是一个数组,这个数组存放的是指向block的指针。那么问题又来了,block是什么鬼。

    size_type _Getblock(size_type _Off) const
    {	// determine block from offset
    	// NB: _Mapsize and _DEQUESIZ are guaranteed to be powers of 2
    	return ((_Off / _DEQUESIZ) & (_Mapsize - 1));
    }

     看看这个获取block的函数就大致明白了,block是一块存储内存。其实在deque内部,数组里的每个元素存储的都是一个指向这种block的指针。block是一段连续的存储空间,在block内部的元素,内存都是连续的,但是block与block之间内存不连续。那么block的大小是怎么确定的呢。

    #define _DEQUESIZ	(sizeof (value_type) <= 1 ? 16 \
    	: sizeof (value_type) <= 2 ? 8 \
    	: sizeof (value_type) <= 4 ? 4 \
    	: sizeof (value_type) <= 8 ? 2 \
    	: 1)	/* elements per block (a power of 2) */

     就是通过上述这个算法计算得到。这也解释了为什么我们实验中,针对deque<int>对象,block的大小为4。

    另外,关于前插和后插,offset的计算可以区分。在插入数据的前后,分别通过宏定义做了一些操作,相信这就是区分前还是后的关键。来看一下宏定义中都做了什么。

    #define _PUSH_FRONT_BEGIN \
    	if (this->_Myoff % _DEQUESIZ == 0 \
    		&& this->_Mapsize <= (this->_Mysize + _DEQUESIZ) / _DEQUESIZ) \
    		_Growmap(1); \
    	size_type _Newoff = this->_Myoff != 0 ? this->_Myoff \
    		: this->_Mapsize * _DEQUESIZ; \
    	size_type _Block = --_Newoff / _DEQUESIZ; \
    	if (this->_Map[_Block] == 0) \
    		this->_Map[_Block] = this->_Alval.allocate(_DEQUESIZ)
    #define _PUSH_BACK_BEGIN \
    	if ((this->_Myoff + this->_Mysize) % _DEQUESIZ == 0 \
    		&& this->_Mapsize <= (this->_Mysize + _DEQUESIZ) / _DEQUESIZ) \
    		_Growmap(1); \
    	size_type _Newoff = this->_Myoff + this->_Mysize; \
    	size_type _Block = _Newoff / _DEQUESIZ; \
    	if (this->_Mapsize <= _Block) \
    		_Block -= this->_Mapsize; \
    	if (this->_Map[_Block] == 0) \
    		this->_Map[_Block] = this->_Alval.allocate(_DEQUESIZ)

    对比发现,逻辑基本一致。首先判断容量是否够用,不够用则扩容。然后计算偏移量,即写入数据的位置,最后调用_Cons_val写入数据。不过这个代码是有讲究的,在分析源码之前,我对这个结构也有些误解。

    首先是扩容判断条件。既然是双端数组,以前认为首末两端都会预留一些空位用于写入数据,所以如果有一端空间不足,则会扩容。而实际的情况是,容量不分前后,只有在总容量不够用时才会重新分配。

    那么第二个问题来了,既然不是首末都有空位,那怎么实现push_back和push_front呢。这里deque维护了几个成员变量

    	_Mapptr _Map;		// 指向block指针数组的指针
    	size_type _Mapsize;	// _Map的尺寸
    	size_type _Myoff;	// 首元素的偏移地址
    	size_type _Mysize;	// 当前数组的尺寸

    其中,_Myoff记录了首元素的偏移位置,而且从宏_PUSH_FRONT_BEGIN的源码中可以看到,一旦首部空间不足,则会将push_front的数据写加到数据最末端,从这角度来讲,deque类似于一个循环数组。所以这是一个圈圈,而不是一条直线。

    同时,与vector一样,一旦内存因扩容而重新分配,指向原有元素的迭代器将失效。

    C++::stack/queue

    把这两个放在一起说,一个先进后出,一个先进先出。两个结构内部使用的容器其实都是deque,所以不再深入分析了。.

    class _Container = deque<_Ty> >

    二、链表

    代表结构

    JAVA :LinkedList

    C++ : list

    Java::LinkedList

        /**
         * Appends the specified element to the end of this list.
         *
         * <p>This method is equivalent to {@link #addLast}.
         *
         * @param e element to be appended to this list
         * @return {@code true} (as specified by {@link Collection#add})
         */
        public boolean add(E e) {
            linkLast(e);
            return true;
        }
        /**
         * Links e as last element.
         */
        void linkLast(E e) {
            final Node<E> l = last;
            final Node<E> newNode = new Node<>(l, e, null);
            last = newNode;
            if (l == null)
                first = newNode;
            else
                l.next = newNode;
            size++;
            modCount++;
        }

    单向链表。所以在进行LinkedList随机存取时,需要遍历链表。

    C++::list

    双向链表,结构不多说。这里需要重点说明一下,C++的list到底是不是循环链表呢?是,但不是典型的循环链表,因为在首Node和尾Node之间存在一个"多余"的节点。

    _Nodeptr _Myhead;	// pointer to head node

    就是它。也是因为有它,当我们用迭代器遍历元素时,才不会遍历个没完没了。原因看这

    iterator end()
    {	// return iterator for end of mutable sequence
        return (iterator(this->_Myhead, this));
    }

    end返回的正是他的地址。如果画出来的话,C++中list的数据结构应该是这样子滴。画的糙,凑合看。

    三、树

    代表结构

    JAVA :TreeMap

    C++ : set/map

    Java::TreeMap

        /**
         * The comparator used to maintain order in this tree map, or
         * null if it uses the natural ordering of its keys.
         *
         * @serial
         */
        private final Comparator<? super K> comparator;
    
        private transient Entry<K,V> root;
    
        /**
         * Associates the specified value with the specified key in this map.
         * If the map previously contained a mapping for the key, the old
         * value is replaced.
         *
         * @param key key with which the specified value is to be associated
         * @param value value to be associated with the specified key
         *
         * @return the previous value associated with {@code key}, or
         *         {@code null} if there was no mapping for {@code key}.
         *         (A {@code null} return can also indicate that the map
         *         previously associated {@code null} with {@code key}.)
         * @throws ClassCastException if the specified key cannot be compared
         *         with the keys currently in the map
         * @throws NullPointerException if the specified key is null
         *         and this map uses natural ordering, or its comparator
         *         does not permit null keys
         */
        public V put(K key, V value) {
            Entry<K,V> t = root;
            if (t == null) {
                compare(key, key); // type (and possibly null) check
    
                root = new Entry<>(key, value, null);
                size = 1;
                modCount++;
                return null;
            }
            int cmp;
            Entry<K,V> parent;
            // split comparator and comparable paths
            Comparator<? super K> cpr = comparator;
            if (cpr != null) {
                do {
                    parent = t;
                    cmp = cpr.compare(key, t.key);
                    if (cmp < 0)
                        t = t.left;
                    else if (cmp > 0)
                        t = t.right;
                    else
                        return t.setValue(value);
                } while (t != null);
            }
            else {
                if (key == null)
                    throw new NullPointerException();
                @SuppressWarnings("unchecked")
                    Comparable<? super K> k = (Comparable<? super K>) key;
                do {
                    parent = t;
                    cmp = k.compareTo(t.key);
                    if (cmp < 0)
                        t = t.left;
                    else if (cmp > 0)
                        t = t.right;
                    else
                        return t.setValue(value);
                } while (t != null);
            }
            Entry<K,V> e = new Entry<>(key, value, parent);
            if (cmp < 0)
                parent.left = e;
            else
                parent.right = e;
            fixAfterInsertion(e);
            size++;
            modCount++;
            return null;
        }

    首先TreeMap是一棵红黑树,类的内部维护了两个主要变量:root节点和比较器(谓词)。root为红黑树的树根,当插入一个数据时,根据比较器判断向左子树还是右子树插入数据。构造TreeMap时,可以传入比较器,如果比较器为空,则使用类型默认的比较器。所如果key为自定义类型,则最好实现Comparable接口。

    C++::set/map

    C++中的set和map都继承自xtree,也就是说两种结构都是用红黑树存储的。我们知道,这两种存储都是有序的,且存在默认的排序规则,在构造时,可以自定义仿函数来重定义排序规则。大体上看一下xtree插入的逻辑。

    _Pairib insert(const value_type& _Val, bool _Leftish)
    {	// try to insert node with value _Val, on left if _Leftish
    	_Nodeptr _Trynode = _Root();
    	_Nodeptr _Wherenode = this->_Myhead;
    	bool _Addleft = true;	// add to left of head if tree empty
    	while (!this->_Isnil(_Trynode))
    	{	// look for leaf to insert before (_Addleft) or after
    		_Wherenode = _Trynode;
    		if (_Leftish)
    			_Addleft = !_DEBUG_LT_PRED(this->comp,
    				this->_Key(_Trynode),
    				this->_Kfn(_Val));	// favor left end
    		else
    			_Addleft = _DEBUG_LT_PRED(this->comp,
    				this->_Kfn(_Val),
    				this->_Key(_Trynode));	// favor right end
    		_Trynode = _Addleft ? this->_Left(_Trynode)
    			: this->_Right(_Trynode);
    	}
    
    	if (this->_Multi)
    		return (_Pairib(_Insert(_Addleft, _Wherenode, _Val), true));
    	else
    	{	// insert only if unique
    		iterator _Where = iterator(_Wherenode, this);
    		if (!_Addleft)
    			;	// need to test if insert after is okay
    		else if (_Where == begin())
    			return (_Pairib(_Insert(true, _Wherenode, _Val), true));
    		else
    			--_Where;	// need to test if insert before is okay
    
    		if (_DEBUG_LT_PRED(this->comp,
    			this->_Key(_Where._Mynode()),
    			this->_Kfn(_Val)))
    			return (_Pairib(_Insert(_Addleft, _Wherenode, _Val), true));
    		else
    			return (_Pairib(_Where, false));
    	}
    }

    插入时,首先通过仿函数判断插入左子树还是右子树。然后判断该位置是否有数据,没有的话才会执行插入。最后调用_Pairlib进行数据的添加。

    以上,对Java和C++常用的数据结构做了简要的总结。日后选用结构时可以根据其特性,选却最优的建模方式。

    展开全文
  • 企业微信初版的全量同步方案在快速的业务增长面前已经捉襟见肘,针对其遇到的问题,怎样做好组织架构同步优化?这是又一篇来自微信团队的技术实战。写在前面 企业微信在快速发展过程中,陆续有大企业加入使用,企业...
    企业微信组织架构同步优化的思路与实操演练
    

    作者|胡腾

    编辑|小智

    作为企业级的微信,在业务快速发展的背景下,迭代优化的要求也越发急迫。企业微信初版的全量同步方案在快速的业务增长面前已经捉襟见肘,针对其遇到的问题,怎样做好组织架构同步优化?这是又一篇来自微信团队的技术实战。

    写在前面

    企业微信在快速发展过程中,陆续有大企业加入使用,企业微信初版采用全量同步方案,该方案在大企业下存在流量和性能两方面的问题,每次同步消耗大量流量,且在 iPhone 5s 上拉取 10w+ 成员架构包解压时会提示 memory warning 而应用崩溃。

    全量同步方案难以支撑业务的快速发展,优化同步方案越来越有必要。本文针对全量同步方案遇到的问题进行分析,提出组织架构增量同步方案,并对移动端实现增量同步方案的思路和重难点进行了讲解。

    企业微信业务背景

    在企业微信中,组织架构是非常重要的模块,用户可以在首页的 tab 上选择"通讯录"查看到本公司的组织架构,并且可以通过"通讯录"找到本公司的所有成员,并与其发起会话或者视频语音通话。

    组织架构是非常重要且敏感的信息,企业微信作为企业级产品,始终把用户隐私和安全放在重要位置。针对组织架构信息,企业管理员具有高粒度隐私保护操作权限,不仅支持个人信息隐藏,也支持通讯录查看权限等操作。

    企业微信组织架构同步优化的思路与实操演练

    在企业微信中,组织架构特征有:

    1、多叉树结构。叶子节点代表成员,非叶子节点代表部门。部门最多只有一个父部门,但成员可属于多个部门。

    企业微信组织架构同步优化的思路与实操演练

    2、架构隐藏操作。企业管理员可以在管理后台设置白名单和黑名单,白名单可以查看完整的组织架构,其他成员在组织架构里看不到他们。黑名单的成员只能看到自己所在小组和其所有的父部门,其余人可以看到黑名单的成员。

    3、组织架构操作。企业管理员可以在 web 端和 app 端添加 / 删除部门,添加 / 删除 / 移动 / 编辑成员等操作,并且操作结果会及时同步给本公司所有成员。

    全量同步方案的问题

    本节大致讲解下全量同步方案实现以及遇到的问题。

    全量同步方案原理

    企业微信在 1.0 时代,从稳定性以及快速迭代的角度考虑,延用了企业邮通讯录同步方案,采取了全量架构同步方案。

    核心思想为服务端下发全量节点,客户端对比本地数据找出变更节点。此处节点可以是用户,也可以是部门,将组织架构视为二叉树结构体,其下的用户与部门均为节点,若同一个用户存在多个部门下,被视为多个节点。

    全量同步方案分为首次同步与非首次同步:

    • 首次同步服务端会下发全量的节点信息的压缩包,客户端解压后得到全量的架构树并展示。

    • 非首次同步分为两步:

    1. 服务端下发全量节点的 hash 值。客户端对比本地数据找到删除的节点保存在内存中,对比找到新增的节点待请求具体信息。

    2. 客户端请求新增节点的具体信息。请求具体信息成功后,再进行本地数据库的插入 / 更新 / 删除处理,保证同步流程的原子性。

    用户反馈

    初版上线后,收到了大量的组织架构相关的 bug 投诉,主要集中在:

    • 流量消耗过大。

    • 客户端架构与 web 端架构不一致。

    • 组织架构同步不及时。

    这些问题在大企业下更明显。

    企业微信组织架构同步优化的思路与实操演练

    问题剖析

    深究全量同步方案难以支撑大企业同步的背后原因,皆是因为采取了服务端全量下发 hash 值方案的原因,方案存在以下问题:

    1. 拉取大量冗余信息。即使只有一个成员信息的变化,服务端也会下发全量的 hash 节点。针对几十万人的大企业,这样的流量消耗是相当大的,因此在大企业要尽可能的减少更新的频率,但是却会导致架构数据更新不及时。

    2. 大企业拉取信息容易失败。全量同步方案中首次同步架构会一次性拉取全量架构树的压缩包,而超大企业这个包的数据有几十兆,解压后几百兆,对内存不足的低端设备,首次加载架构可能会出现内存不足而 crash。非首次同步在对比出新增的节点,请求具体信息时,可能遇到数据量过大而请求超时的情况。

    3. 客户端无法过滤无效数据。客户端不理解 hash 值的具体含义,导致在本地对比 hash 值时不能过滤掉无效 hash 的情况,可能出现组织架构展示错误。

    优化组织架构同步方案越来越有必要。

    寻找优化思路

    寻求同步方案优化点,我们要找准原来方案的痛点以及不合理的地方,通过方案的调整来避免这个问题。

    组织架构同步难点

    准确且耗费最少资源同步组织架构是一件很困难的事情,难点主要在:

    • 组织架构架构数据量大。消息 / 联系人同步一次的数据量一般情况不会过百,而企业微信活跃企业中有许多上万甚至几十万节点的企业,意味着架构一次同步的数据量很轻松就会上千上万。移动端的流量消耗是用户非常在乎的,且内存有限,减少流量的消耗以及减少内存使用并保证架构树的完整同步是企业微信追求的目标。

    • 架构规则复杂。组织架构必须同步到完整的架构树才能展示,而且企业微信里的涉及到复杂的隐藏规则,为了安全考虑,客户端不应该拿到隐藏的成员。

    • 修改频繁且改动大。组织架构的调整存在着新建部门且移动若干成员到新部门的情况,也存在解散某个部门的情况。而员工离职也会通过组织架构同步下来,意味着超大型企业基本上每天都会有改动。

    技术选型-提出增量更新方案

    上述提到的问题,在大型企业下会变得更明显。在几轮方案讨论后,我们给原来的方案增加了两个特性来实现增量更新:

    1. 增量。服务端记录组织架构修改的历史,客户端通过版本号来增量同步架构。

    2. 分片。同步组织架构的接口支持传阈值来分片拉取。

    在新方案中,服务端针对某个节点的存储结构可简化为:

    企业微信组织架构同步优化的思路与实操演练

    vid 是指节点用户的唯一标识 id,departmentid 是指节点的部门 id,is_delete 表示该节点是否已被删除。

    • 若节点被删除了,服务端不会真正的删除该节点,而将 is_delete 标为 true。

    • 若节点被更新了,服务端会增大记录的 seq,下次客户端来进行同步便能同步到。

    其中,seq 是自增的值,可以理解成版本号。每次组织架构的节点有更新,服务端增加相应节点的 seq 值。客户端通过一个旧的 seq 向服务器请求,服务端返回这个 seq 和 最新的 seq 之间所有的变更给客户端,完成增量更新。

    图示为:

    企业微信组织架构同步优化的思路与实操演练

    通过提出增量同步方案,我们从技术选型层面解决了问题,但是在实际操作中会遇到许多问题,下文中我们将针对方案原理以及实际操作中遇到的问题进行讲解。

    增量同步方案

    本节主要讲解客户端中增量同步架构方案的原理与实现,以及基础概念讲解。

    增量同步方案原理

    企业微信中,增量同步方案核心思想为:

    服务端下发增量节点,且支持传阈值来分片拉取增量节点,若服务端计算不出客户端的差量,下发全量节点由客户端来对比差异。

    增量同步方案可抽象为四步完成:

    1. 客户端传入本地版本号,拉取变更节点。

    2. 客户端找到变更节点并拉取节点的具体信息。

    3. 客户端处理数据并存储版本号。

    4. 判断完整架构同步是否完成,若尚未完成,重复步骤 1,若完成了完整组织架构同步,清除掉本地的同步状态。

    忽略掉各种边界条件和异常状况,增量同步方案的流程图可以抽象为:

    企业微信组织架构同步优化的思路与实操演练

    接下来我们再看看增量同步方案中的关键概念以及完整流程是怎样的。

    版本号

    同步的版本号是由多个版本号拼接成的字符串,版本号的具体含义对客户端透明,但是对服务端非常重要。

    版本号的组成部分为:

    企业微信组织架构同步优化的思路与实操演练

    版本号回退

    增量同步在实际操作过程中会遇到一些问题:

    1. 服务端不可能永久存储删除的记录,删除的记录对服务端是毫无意义的而且永久存储会占用大量的硬盘空间。而且无效数据过多也会影响架构读取速度。当 is_delete 节点的数目超过一定的阈值后,服务端会物理删除掉所有的 is_delete 为 true 的节点。此时客户端会重新拉取全量的数据进行本地对比。

    2. 一旦架构隐藏规则变化后,服务端很难计算出增量节点,此时会下发全量节点由客户端对比出差异。

    理想状况下,若服务端下发全量节点,客户端铲掉旧数据,并且去拉全量节点的信息,并且用新数据覆盖即可。但是移动端这样做会消耗大量的用户流量,这样的做法是不可接受的。所以若服务端下发全量节点,客户端需要本地对比出增删改节点,再去拉变更节点的具体信息。

    增量同步情况下,若服务端下发全量节点,我们在本文中称这种情况为版本号回退,效果类似于客户端用空版本号去同步架构。从统计结果来看,线上版本的同步中有 4% 的情况会出现版本号回退。

    阈值分片拉取

    若客户端的传的 seq 过旧,增量数据可能很大。此时若一次性返回全部的更新数据,客户端请求的数据量会很大,时间会很长,成功率很低。针对这种场景,客户端和服务端需要约定阈值,若请求的更新数据总数超过这个阈值,服务端每次最多返回不超过该阈值的数据。若客户端发现服务端返回的数据数量等于阈值,则再次到服务端请求数据,直到服务端下发的数据数量小于阈值。

    节点结构体优化

    在全量同步方案中,节点通过 hash 唯一标示。服务端下发的全量 hash 列表,客户端对比本地存储的全量 hash 列表,若有新的 hash 值则请求节点具体信息,若有删除的 hash 值则客户端删除掉该节点信息。

    在全量同步方案中,客户端并不能理解 hash 值的具体含义,并且可能遇到 hash 碰撞这种极端情况导致客户端无法正确处理下发的 hash 列表。

    而增量同步方案中,使用 protobuf 结构体代替 hash 值,增量更新中节点的 proto 定义为:

    企业微信组织架构同步优化的思路与实操演练

    在增量同步方案中,用 vid 和 partyid 来唯一标识节点,完全废弃了 hash 值。这样在增量同步的时候,客户端完全理解了节点的具体含义,而且也从方案上避免了曾经在全量同步方案遇到的 hash 值重复的异常情况。

    并且在节点结构体里带上了 seq 。节点上的 seq 来表示该节点的版本,每次节点的具体信息有更新,服务端会提高节点的 seq,客户端发现服务端下发的节点 seq 比客户端本地的 seq 大,则需要去请求节点的具体信息,避免无效的节点信息请求。

    判断完整架构同步完成

    因为 svr 接口支持传阈值批量拉取变更节点,一次网络操作并不意味着架构同步已经完成。那么怎么判断架构同步完成了呢?这里客户端和服务端约定的方案是:

    若服务端下发的(新增节点+删除节点)小于客户端传的阈值,则认为架构同步结束。

    当完整架构同步完成后,客户端需要清除掉缓存,并进行一些额外的业务工作,譬如计算部门人数,计算成员搜索热度等。

    增量同步方案 - 完整流程图

    考虑到各种边界条件和异常情况,增量同步方案的完整流程图为:

    企业微信组织架构同步优化的思路与实操演练

    增量同步方案难点

    在加入增量和分片特性后,针对几十万人的超大企业,在版本号回退的场景,怎样保证架构同步的完整性和方案选择成为了难点。

    前文提到,隐藏规则变更以及后台物理删除无效节点后,客户端若用很旧的版本同步,服务端算不出增量节点,此时服务端会下发全量节点,客户端需要本地对比所有数据找出变更节点,该场景可以理解为版本号回退。在这种场景下,对于几十万节点的超大型企业,若服务端下发的增量节点过多,客户端请求的时间会很长,成功率会很低,因此需要分片拉取增量节点。而且拉取下来的全量节点,客户端处理不能请求全量节点的具体信息覆盖旧数据,这样的话每次版本号回退的场景流量消耗过大。

    因此,针对几十万节点的超大型企业的增量同步,客户端难点在于:

    1. 断点续传。增量同步过程中,若客户端遇到网络问题或应用中止了,在下次网络或应用恢复时,能够接着上次同步的进度继续同步。

    2. 同步过程中不影响正常展示。超大型企业同步的耗时可能较长,同步的时候不应影响正常的组织架构展示。

    3. 控制同步耗时。超大型企业版本号回退的场景同步非常耗时,但是我们需要想办法加快处理速度,减少同步的消耗时间。

    思路

    1. 架构同步开始,将架构树缓存在内存中,加快处理速度。

    2. 若服务端端下发了需要版本号回退的 flag,本地将 db 中的节点信息做一次备份操作。

    3. 将服务端端下发的所有 update 节点,在架构树中查询,若找到了,则将备份数据转为正式数据。若找不到,则为新增节点,需要拉取具体信息并保存在架构树中。

    4. 当完整架构同步结束后,在 db 中找到并删除掉所有备份节点,清除掉缓存和同步状态。

    若服务端下发了全量节点,客户端的处理时序图为:

    企业微信组织架构同步优化的思路与实操演练

    服务端下发版本号回退标记

    从时序图中可以看出,服务端下发的版本号回退标记是很重要的信号。

    而版本号回退这个标记,仅仅在同步的首次会随着新的版本号而下发。在完整架构同步期间,客户端需要将该标记缓存,并且跟着版本号一起存在数据库中。在完整架构同步结束后,需要根据是否版本号回退来决定删除掉数据库中的待删除节点。

    备份架构树方案

    架构树备份最直接的方案是将 db 中数据 copy 一份,并存在新表里。如果在数据量很小的情况下,这样做是完全没有问题的,但是架构树的节点往往很多,采取这样简单粗暴的方案在移动端是完全不可取的,在几十万人的企业里,这样做会造成极大的性能问题。

    经过考虑后,企业微信采取的方案是:

    1. 若同步架构时,后台下发了需要版本号回退的 flag,客户端将缓存和 db 中的所有节点标为待删除(时序图中 8,9 步)。

    2. 针对服务端下发的更新节点,在架构树中清除掉节点的待删除标记(时序图中 10,11 步)。

    3. 在完整架构同步结束后,在 db 中找到并删除掉所有标为待删除的节点(时序图中 13 步),并且清除掉所有缓存数据。

    而且,在增量同步过程中,不应该影响正常的架构树展示。所以在架构同步过程中,若有上层来请求 db 中的数据,则需要过滤掉有待删除标记的节点。

    缓存架构树

    方案决定客户端避免不了全量节点对比,将重要的信息缓存到内存中会大大加快处理速度。内存中的架构树节点体定义为:

    企业微信组织架构同步优化的思路与实操演练

    此处我们用 std::map 来缓存架构树,用 std::pair 作为 key。我们在比较节点的时候,会涉及到很多查询操作,使用 map 查询的时间复杂度仅为 O(logn)。

    增量同步方案关键点

    本节单独将优化同步方案中关键点拿出来写,这些关键点不仅仅适用于本文架构同步,也适用于大多数同步逻辑。

    保证数据处理完成后,再储存版本号

    在几乎所有的同步中,版本号都是重中之重,一旦版本号乱掉,后果非常严重。

    在架构同步中,最最重要的一点是:

    保证数据处理完成后,再储存版本号

    在组织架构同步的场景下,为什么不能先存版本号,再存数据呢?

    这涉及到组织架构同步数据的一个重要特征:架构节点数据是可重复拉取并覆盖的。

    考虑下实际操作中遇到的真实场景:

    1. 若客户端已经向服务端请求了新增节点信息,客户端此时刚刚插入了新增节点,还未储存版本号,客户端应用中止了。

    2. 此时客户端重新启动,又会用相同版本号拉下刚刚已经处理过的节点,而这些节点跟本地数据对比后,会发现节点的 seq 并未更新而不会再去拉节点信息,也不会造成节点重复。

    若一旦先存版本号再存具体数据,一定会有概率丢失架构更新数据。

    同步的原子性

    正常情况下,一次同步的逻辑可以简化为:

    企业微信组织架构同步优化的思路与实操演练

    在企业微信的组织架构同步中存在异步操作,若进行同步的过程不保证原子性,极大可能出现下图所示的情况:

    企业微信组织架构同步优化的思路与实操演练

    该图中,同步的途中插入了另外一次同步,很容易造成问题:

    1. 输出结果不稳定。若两次同步几乎同时开始,但因为存在网络波动等情况,返回结果可能不同,给调试造成极大的困扰。

    2. 中间状态错乱。若同步中处理服务端返回的结果会依赖于请求同步时的某个中间状态,而新的同步发起时又会重置这个状态,很可能会引起匪夷所思的异常。

    3. 时序错乱。整个同步流程应该是原子的,若中间插入了其他同步的流程会造成整个同步流程时序混乱,引发异常。

    怎样保证同步的原子性呢?

    我们可以在开始同步的时候记一个 flag 表示正在同步,在结束同步时,清除掉该 flag。若另外一次同步到来时,发现正在同步,则可以直接舍弃掉本次同步,或者等本次同步成功后再进行一次同步。

    此外也可将同步串行化,保证同步的时序,多次同步的时序应该是 FIFO 的。

    缓存数据一致性

    移动端同步过程中的缓存多分为两种:

    1. 内存缓存。加入内存缓存的目的是减少文件 IO 操作,加快程序处理速度。

    2. 磁盘缓存。加入磁盘缓存是为了防止程序中止时丢失掉同步状态。

    内存缓存多缓存同步时的数据以及同步的中间状态,磁盘缓存用于缓存同步的中间状态防止缓存状态丢失。

    在整个同步过程中,我们都必须保证缓存中的数据和数据库的数据的更改需要一一对应。在增量同步的情况中,我们每次需要更新 / 删除数据库中的节点,都需要更新相应的缓存信息,来保证数据的一致性。

    优化数据对比

    内存使用

    测试方法:使用工具 Instrument,用同一账号监控全量同步和增量同步分别在首次加载架构时的 App 内存峰值。

    内存峰值测试结果

    企业微信组织架构同步优化的思路与实操演练

    分析

    随着架构的节点增多,全量同步方案的内存峰值会一直攀升,在极限情况下,会出现内存不足应用程序 crash 的情况(实际测试中,30w 节点下,iPhone 6 全量同步方案必 crash)。而增量同步方案中,总节点的多少并不会影响内存峰值,仅仅会增加同步分片的次数。

    优化后,在腾讯域下,增量同步方案的 App 总内存使用仅为全量同步方案的 53.1%,且企业越大优化效果越明显。并且不论架构的总节点数有多少,增量同步方案都能将完整架构同步下来,达到了预期的效果。

    流量使用

    测试方法:在管理端对成员做增加操作五次,通过日志分析客户端消耗流量,取其平均值。日志会打印出请求的 header 和 body 大小并估算出流量使用值。

    测试结果

    企业微信组织架构同步优化的思路与实操演练

    分析

    增加成员操作,针对增量同步方案仅仅会新拉单个成员的信息,所以无论架构里有多少人,流量消耗都是相近的。同样的操作针对全量同步方案,每次请求变更,服务端都会下发全量 hash 列表,企业越大消耗的流量越多。可以看到,当企业的节点数达到 20w 级别时,全量同步方案的流量消耗是增量同步方案的近 500 倍。

    优化后,在腾讯域下,每次增量同步流量消耗仅为全量同步方案的 0.4%,且企业越大优化效果越明显。

    写在最后

    增量同步方案从方案上避免了架构同步不及时以及流量消耗过大的问题。通过用户反馈和数据分析,增量架构同步上线后运行稳定,达到了理想的优化效果。

    作者介绍

    胡腾,腾讯工程师,参与企业微信从无到有的整个过程,目前主要负责企业微信移动端组织架构和外部联系人等模块的开发工作。

    今日荐文

    点击下方图片即可阅读

    企业微信组织架构同步优化的思路与实操演练

    技术漫谈:为何 KPI 毁了索尼,而 OKR 却成就了谷歌?

    展开全文
  • frm文件:存储这张表的表结构 MYD文件:存储这张表的所有数据行 MYI文件:存储这张表的索引字段 InnoDB存储引擎(聚集): 表数据文件本身是按照B+tree组织的一个索引结构文件 frm文件:存储这张表的表结构 ibd文件...


    前言

    本章是本系列的第一章,虽然平时经常可以用到mysql,但是由于对mysql了解不够。提到优化显然有些措手不及的样子。要想将一门技术玩弄于手掌之中,必然要阅读大量的书籍,然后做好总结。而我是那种不太容易专心的人,所以我用这样的方式记录下我学习的过程。这不是权威,只是我个人摘抄喝理解,认为正确的说法。


    一、索引数据结构对比

    二叉树

    左边子节点的数据小于父节点数据,右边子节点的数据大于父节点数据。如果col2是索引,查找索引为89的行元素,那么只需要查找两次,就可以获取到行元素所在的磁盘指针地址。
    在这里插入图片描述
    如果col1是索引,查找索引为6的行元素,那么需要查找六次,就可以获取到行元素所在的磁盘指针地址,即得到了该索引为6的行元素。因此二叉树不适合存储单边增长的序列字段,近乎全表扫描获取数据。

    红黑树

    本质二叉树,属于二叉平衡树;存储大数据量,树的高度不可控, 数量越大,树的高度越高;

    hash表

    通过散列可以快速获取磁盘文件指针,对于指定索引查找文件非常快,但是对于范围查找没法支持,还有hash冲突问题。
    在这里插入图片描述

    B-Tree

    • 叶节点具有相同的深度,叶节点的指针为空
    • 所有索引元素不重复
    • 节点中的数据索引从左到右递增排列

    在这里插入图片描述

    B+Tree(B-Tree变种)

    • 非叶子节点不存储data,只存储索引(冗余),可以放更多的索引
    • 叶子节点包含所有索引字段
    • 叶子节点用指针连接,提高区间访问性能
      在这里插入图片描述

    二、Mysql使用的索引

    MySQL每个B+树节点最大存储容量:16KB (指针+数据+索引)。
    假设我们一行数据大小为1K,那么一页就能存16条数据,也就是一个叶子节点能存16条数据;再看非叶子节点,假设主键ID为bigint类型,那么长度为8B。指针大小在Innodb源码中为6B,一共就是14B,那么
    一页里可以存储

    16K/14=1170个(主键+指针)
    

    那么一颗高度为3的B+树能存储的数据为:

    11701170*16=21902400(千万级条)
    

    因此,B+树存储大数据量的表也可以非常高效的获取数据,MySQL使用B+树作为索引的数据结构。

    MylSAM存储引擎

    存储引擎最终作用于:表 ,不是数据库
    在mysql的安装的根目录下,有一个data目录,里面存放的是所有表的数据。

    在这里插入图片描述

    • MyISAM索引文件和数据文件是分离的(非聚集或稀疏);
    • 主键索引和辅助主键索引存储类似;
    • frm文件:存储这张表的表结构
    • MYD文件:存储这张表的所有数据行
    • MYI文件:存储这张表的索引字段

    InnoDB存储引擎(聚集):

    表数据文件本身是按照B+tree组织的一个索引结构文件

    • frm文件:存储这张表的表结构
    • ibd文件:存储这张表的所有数据行和索引字段
    • 聚集(聚簇)索引----叶节点包含完整数据记录
    • 在这里插入图片描述
      在这里插入图片描述

    为什么InnoDB表必须有主键,并且推荐使用整型的自增主键?

    • 为了满足MySQL的索引数据结构B+树的特性,必须要有索引作为主键,可以有效提高查询效率,因此InnoDB必须要有主键。如果不手动指定主键,InnoDB会从插入的数据中找出不重复的一列作为主键索引,如果没找到不重复的一列,InnoDB会在后台增加一列rowId做为主键索引。
    • 索引的数据类型是整型,一方面整型占有的磁盘空间或内存空间相比字符串更少,另一方面整型比较比字符串比较更快速,字符串比较是先转换为ASCII码,然后再比较的。
    • B+树本质是多路多叉树,如果主键索引不是自增的,那么后续插入的索引就会引起B+树的其他节点的分裂和重新平衡,影响数据插入的效率,如果是自增主键,只用在尾节点做增加就可以。

    为什么非主键索引结构叶子节点存储的是主键值?

    • 主键索引和非主键索引维护各自的B+树结构,当插入的数据的时候,由于数据只有一份,通过非主键索引获取到主键值,然后再去主键索引的B+树数据结构中找到对应的行数据,节省了内存空间;
    • 如果非主键索引的叶子节点也存储一份数据,如果通过非主键索引插入数据,那么要向主键索引对应的行数据进行同步,那么会带来数据一致性问题。可以通过事务的方式解决,我们都知道使用事务后,就会对性能有所消耗。

    三、联合索引

    两个或更多个列上的索引被称作复合索引,当where查询存在多个条件查询的时候,需要对查询的列创建组合索引。

    定义联合索引(姓名,年龄,职务,出生年月),将联合索引按照索引顺序放入节点中,新插入节点时,先按照联合索引中的员工级别比较,如果相同会按照是员工姓名比较,如果员工级别和员工姓名都相同 最后是员工的出生年月比较。可以从图中从上到下,从左到右看,第一个B+树的节点 是通过联合索引的员工级别比较的,第二个节点是 员工级别相同,会按照员工姓名比较,第三个节点是 员工级别和员工姓名都相同,会按照员工出生年月比较。
    在这里插入图片描述

    为什么不对每一列创建索引

    • 减少开销:假如对col1、col2、col3创建组合索引,相当于创建了(col1)、(col1,col2)、(col1,col2,col3)3个索引

    • 覆盖索引:假如查询SELECT col1, col2, col3 FROM 表名,由于查询的字段存在索引页中,那么可以从索引中直接获取,而不需要回表查询

    • 效率高:对col1、col2、col3三列分别创建索引,MySQL只会选择辨识度高的一列作为索引。假设有100w的数据,一个索引筛选出10%的数据,那么可以筛选出10w的数据;对于组合索引而言,可以筛选出100w10%10%*10%=1000条数据

    最左匹配原则

    只有在查询条件中使用了创建索引时的第一个字段,索引才会被使用。使用复合索引时遵循最左前缀集合,所以在建立联合索引的时候查询最频繁的条件要放在左边.

    展开全文
  • 进程同步和通信 进程同步 在OS中引入进程后,一方面使系统的吞吐量和资源的利用率得到提升,另一方面也使得系统变得复杂,如果没有合理的方式对进程进行妥善的管理,必然会引起进程对系统资源的无序竞争,使系统...
  • 用户模式下的线程同步  系统中的线程必须访问系统资源,如堆、串口、文件、窗口以及其他资源。如果一个线程独占了对某个资源的访问,其他线程就无法完成工作。我们也必须限制线程在任何时刻都能访问任何资源。比如...
  • 软件体系结构表示系统的框架结构,用于从较高的层次上来描述各部分之间的关系和接口,主要包括构件、构件性质和构件之间的关系。 通过使用软件体系结构,可以有效地分析用户需求,方便系统的修改,以及减小程序构造...
  • TensorFlow与主流深度学习框架对比

    万次阅读 2017-02-24 10:56:16
    AlphaGo在2017年年初化身Master,在弈城和野狐等平台上横扫中日韩围棋高手,取得60连胜,未尝败绩。AlphaGo背后神秘的推动力就是TensorFlow...本文将带我们简单了解下TensorFlow,并与其他主流深度学习框架进行了对比
  • 机器学习框架对比

    千次阅读 2017-07-18 09:19:16
    2.1 主流深度学习框架对比 各个开源框架在Github上的数据统计 数据统计截止于2017.07.15 可以看到各大主流框架基本都支持Python,目前Python在科学计算和数据挖掘领域可以说是独领风骚。虽然有来自R、Julia等...
  • ODS设计思路-ODS到DW同步

    千次阅读 2017-04-12 15:26:13
    ODS作为DW和业务系统的中间数据层,保留了两者的部分特性,在基本数据上,继承了业务系统的数据形式和组织结构,但出于查询和分析的需求,也可以进行部分粗粒度的汇总,提供部分维度。 ODS与DW对比特点: ...
  • C代码项目组织与开发惯例

    千次阅读 2007-07-19 23:06:00
    粗略总结一下C语言编写代码时的一些组织方法和惯例:静态组织惯例:1、尽量用树形结构组织模块,即尽量保证单级调用关系,即上级调用者不要使用下级调用的模块中的函数、变量,不要有跨级调用关系,更不要有循环调用...
  • PROFINET及其同步实时通讯分析

    千次阅读 2019-04-23 10:57:51
     PROFINET实时以太网是由ProfibusInternational(PI)组织提出的基于以太网的自动化标准。从2004年4月开 始,PI与InterbusClub总线俱乐部联手,负责合作开发与制定标准。PROFINET构成从I/O级直至协调治理级的基于...
  • 无人驾驶 | 自动驾驶技术和机器人技术的对比

    千次阅读 多人点赞 2021-01-09 13:06:36
    单目TOF Kinect二代 各种摄像头性能和成本比较: 由上表对比可知,无论是结构光还是TOF方案,都需要增加主动光,因此,其检测距离受到了光强度的限制,无法适用于远距离的检测,一般只用于机器人的感知,而普通单目...
  • 对比SVN学习GIT版本管理工具

    万次阅读 2008-10-31 14:06:00
    对比SVN学习GIT版本管理工具作者:刘旭晖 Raymond转载请注明出处Email:colorant@163.com BLOG:http://blog.csdn.net/colorant/主页:http://sites.google.com/site/rgbbones/ 因为近期工作需要,要掌握git的使用...
  • 本节向大家介绍一下UML建模工具Rose与PowerDesigner,两款建模工具的对比,主要包括二者的出身,二者的区别等内容,相信通过本节的介绍你对UML建模工具Rose与PowerDesigner,两款建模工具的特性有清楚的认识。...
  • 分布式体系结构之集中式结构 云这个话题对我们来说已经非常熟悉了。可以说,云在我们的生活中无处不在,比如我们平时看的视频通常就是放在云上的。...而如何组织,就是分布式体系结构的范畴了。 你会发现,很多场...
  • 主流深度学习框架对比

    千次阅读 2018-10-16 05:20:30
    在数据并行模式上,TensorFlow和Parameter Server很像,但TensorFlow有独立的Variable node,不像其他框架有一个全局统一的参数服务器,因此参数同步更自由。TensorFlow和Spark的核心都是一个数据计算的流式图,...
  • Angular选择 Vue 而不选择 Angular,有下面几个原因,当然不是对每个人...它允许你以希望的方式组织应用程序,而不是任何时候都必须遵循 Angular 制定的规则。它仅仅是一个视图层,所以你可以将它嵌入一个现有页面而不
  • Playbook结构简单,结构清晰。 具有可变注册功能,可使任务为以后的任务注册变量 比一些其他工具更加精简的代码库 缺点 不如基于其他编程语言的工具强大。 它的逻辑通过它的DSL,这意味着需要经常检查...
  • mysql5.7集群方案对比

    万次阅读 2019-06-17 13:18:25
    离线部署mysql5.7集群(一)对比部署方案对比部署方案需求明确对比方案MySQL Replication架构MMM架构优点:缺点:MHA架构优点缺点InnoDB Cluster架构Mycat架构功能介绍优点缺点PhxSQL优点缺点MySQL Fabric. 对比部署...
  • 魅族生活服务数据同步优化方案

    千次阅读 2018-08-07 16:12:11
    生活服务数据同步优化方案 生活服务数据同步优化方案 数据落地方案 数据分析 程序架构: 程序抽象 进一步优化 数据落地方案 数据分析 生活服务需要定时拉取保存的数据有“商户,团购,电影”三类. ...
  • HashMap源码理解 Java集合之HashMap HashMap原理及实现学习总结 HashMap源码分析 HashMap原理及实现学习总结 HashMap概述HashMap是基于哈希表的Map接口...HashMap的数据结构在java编程语言中,最基本的结构就是两种,
  • IEEE1588精密网络同步时钟协议(PTP)-v2.0协议浅析 本文由安徽京准科技公司提供请勿转载! 1 引言  以太网技术由于其开放性好、价格低廉和使用方便等特点,已经广泛应用于电信级别的网络中,以太网的数据传输速度...
  • 同步 期初同步 日常同步 手动或半自动同步 特殊方法 ALE配置分发 SAP Solution Manager进行配置分发 对象列表 文档 MDG相关对象 简介 大多数实施SAP MDG的客户都需要考虑到不同系统间的配置同步问题...
  • 如上例中,在打包工具支持Less 资源依赖的引入与合并的情况下,目录结构可以改成: app/ ��- main/ ����- index.js ����- index.less ��- part-A/ ����- index.js ����- index.less...
  • 其实这也是真正的数据库了,其余的都是实例,为此先明白一个在ORACLE数据库上容易误会的名词:INSTANCE(实例)和DATABASE(数据库),数据库是一组文件结构,而INSTANCE是一段内存结构,包含了对文件结构的操作过程...
  • 神经网络拓扑结构 IV . 神经网络连接方式 V . 神经网络学习规则 VI . 浅层神经网络 与 深度神经网络 VII . 深度学习 简介 VIII . 机器学习 简介 IX . 深度学习 与 机器学习 建模对比 X . 深度学习 与 机器学习 性能...
  • 计算机网络体系结构中的物理层

    千次阅读 2017-03-16 11:24:26
    国际化标准组织ISO与1997年成立专门的研究机构来研究不同体系结构的计算机网络的相互连接。提出著名的开放系统互联基本参考模型OSI/RM(Open Systems Interconnection Reference Model),简称为 ‘OSI‘。OSI网络...

空空如也

空空如也

1 2 3 4 5 ... 20
收藏数 27,764
精华内容 11,105
关键字:

组织结构同步对比