初始发布后,我们收到了大量与组织结构相关的 Bug 投诉,主要集中在:
这些问题在大型企业中更为明显。
问题分析
全量同步方案难以支持大型企业同步的原因在于服务端采用了全量Hash值方案,该方案存在以下问题:
拉取大量冗余信息,哪怕只有一个成员信息发生变化,服务器也会把全套哈希节点都往下发,对于几十万人的大型企业来说,这个流量消耗是相当大的,因此大型企业应该尽量降低更新频率,但这会导致架构数据更新不及时。
大型企业容易出现信息拉取失败的情况,全量同步方案中架构首次同步会一次性拉取全量架构树的压缩包,对于超大型企业来说,这个包里的数据是几十兆,解压之后就是几百兆,对于内存不足的低端设备,架构首次加载可能会出现内存不足的情况,当对比新增节点,请求非首次同步的具体信息时,可能会因为数据量过大导致请求超时。
客户端无法过滤无效数据。客户端不了解哈希值的具体含义,导致本地比较哈希值时无法过滤掉无效的哈希,这可能导致组织结构的显示出现错误。
优化组织架构同步解决方案的必要性越来越大。
寻找优化思路
寻找同步解决方案的优化点,需要找到原有解决方案的痛点和不合理之处,并通过调整解决方案来规避这些问题。
组织结构同步困难
准确且以最少的资源同步组织结构非常困难。困难主要在于:
技术选择-提出增量更新方案
上述问题在大型企业中会更加明显,经过几轮讨论,我们在原有方案上增加了两个特性来实现增量更新:
增量式。服务端记录组织结构修改历史,客户端通过版本号增量同步结构。
分片。同步组织结构的接口支持通过阈值来拉取分片。
在新的方案中,服务器针对一个节点的存储结构可以简化如下:
vid指节点用户唯一标识id,指节点的部门id,表示该节点是否已被删除。
其中,seq是一个自增值,可以理解为版本号,每当组织结构中有一个节点更新,服务端就会增加对应节点的seq值。客户端通过一个旧的seq请求服务端,服务端就会将这个seq和最新的seq之间的所有变化返回给客户端,完成增量更新。
该图显示:
通过提出增量同步方案,我们从技术选型层面解决了问题,但在实际运行中还会遇到很多问题,下面我们将对该方案的原理以及实际运行中遇到的问题进行讲解。
增量同步解决方案
本节主要讲解增量同步架构方案在客户端的原理和实现,以及基本概念。
增量同步方案原理
在企业微信中,增量同步方案的核心思想是:
服务端发送增量节点,支持通过阈值分片拉取增量节点,若服务端无法算出客户端的差异,则发送全节点让客户端对比差异。
增量同步方案可以抽象为四个步骤:
客户端传入本地的版本号,拉取变化的节点。
客户端找到发生变化的节点,并拉取该节点的具体信息。
客户端处理数据并存储版本号。
判断全架构同步是否完成,若未完成则重复步骤1,若组织结构全同步完成则清除本地同步状态。
忽略各种边界条件和异常情况,增量同步解决方案的流程图可以抽象如下:
接下来我们来看一下增量同步方案中的关键概念以及完整的流程。
版本号
同步的版本号是多个版本号组成的字符串,版本号的具体含义对客户端来说是透明的,但是对服务端来说却非常重要。
版本号的组成部分包括:
版本号回退
增量同步在实际运行中可能会遇到一些问题:
服务端无法永久保存删除的记录,删除的记录对服务端来说毫无意义,永久保存会占用大量硬盘空间。另外无效数据过多也会影响架构读取速度。当节点数超过一定阈值时,服务端会物理删除所有真实节点,此时客户端会重新拉取全量数据进行本地比对。
一旦架构潜规则发生变化,服务端就很难再计算增量节点,这时候就会把全节点发下来,让客户端来对比差异。
理想情况下,如果服务端发送全节点,客户端会移除旧数据,拉取全节点信息,并用新数据覆盖。但在移动端这样做会消耗大量用户流量,这是不可接受的。因此,如果服务端发送全节点,客户端需要在本地对比新增、删除、修改的节点,然后拉取变化节点的具体信息。
在增量同步的情况下,如果服务端发了一个全节点,这种情况我们在本文中称之为版本号回滚,类似于客户端使用空版本号同步的架构。根据统计结果,在线版本同步中,有4%会发生版本号回滚。
阈值碎片提取
如果客户端发送的seq太旧,增量数据可能会非常大,此时如果一次性返回所有更新的数据,客户端请求的数据量会非常大,时间会很长,成功率也会很低。针对这种场景,客户端和服务端需要约定一个阈值,如果请求的更新数据总量超过这个阈值,那么服务端每次最多返回不超过这个阈值的数据。如果客户端发现服务端返回的数据量等于阈值,那么就会再次向服务端请求数据,直到服务端发送的数据量小于阈值。
节点结构优化
全量同步方案中,节点通过hash唯一标识,客户端将服务端发送的全量hash列表与本地保存的全量hash列表进行对比,如果有新的hash值,则客户端请求该节点的具体信息,如果有已删除的hash值,则客户端删除该节点信息。
在完全同步方案中,客户端无法理解Hash值的具体含义,可能遇到Hash碰撞等极端情况,导致客户端无法正确处理发送的Hash列表。
在增量同步方案中,使用结构体来代替哈希值,增量更新中节点的定义是:
在增量同步方案中,使用vid和来唯一标识节点,而hash值则被彻底抛弃。这样,在增量同步时,客户端完全了解节点的具体含义,同时该方案也避免了全量同步方案中遇到的hash值重复的异常情况。
并且node结构体中包含了seq,节点上的seq表示该节点的版本,每次更新节点具体信息时,服务端都会增加该节点的seq,如果客户端发现服务端发送的节点seq大于客户端本地的seq,就需要重新请求该节点的具体信息,避免出现无效的节点信息请求。
确定完整架构同步是否完成
因为 svr 接口支持通过阈值批量拉取变更节点,所以一次网络操作并不代表架构同步已经完成。那么如何判断架构同步是否完成呢?客户端和服务端约定的方案是:
如果服务端发送的(新增节点+删除节点)小于客户端发送的阈值,则认为架构同步完成。
当整个架构同步的时候,客户端需要清除缓存,并进行一些额外的业务工作,比如计算部门负责人数量、会员搜索热度等。
增量同步解决方案-完整流程图
考虑到各种边界条件和异常情况,增量同步方案的完整流程图为:
增量同步解决方案的难点
加入增量和分片特性之后,对于数十万员工的超大型企业来说,版本号回滚的场景下如何保证架构同步和方案选择的完整性成为一个难题。
上面提到,在后台更改隐性规则、物理删除无效节点之后,如果客户端使用非常老的版本去同步,服务端是无法计算增量节点的,这时候服务端就会发全节点,客户端需要在本地比对所有数据才能找到变化的节点。这个场景可以理解为版本号回滚。这种场景下,对于几十万节点的超大型企业,如果服务端发的增量节点太多,客户端请求时间会很长,成功率很低,所以需要分片拉取增量节点。而且对于拉下来的全节点,客户端又无法请求全节点的具体信息来覆盖旧数据,这种情况下每次版本号回滚场景的流量消耗就太大了。
因此对于数十万节点的超大型企业的增量同步,客户端面临的困难是:
恢复。增量同步过程中,如果客户端遇到网络问题或者应用程序终止,则下次网络或应用程序恢复时,可以从上次同步的进度继续同步。
同步过程不影响正常显示,超大型企业同步可能需要较长时间,同步应不影响组织结构的正常显示。
控制同步时间。大型企业的版本回滚场景的同步是非常耗时的,但我们需要想办法加快处理速度,减少同步所消耗的时间。
想法
模式同步开始,将模式树缓存在内存中以加快处理速度。
如果服务端发送需要回滚版本号的标志,则会备份一次db中的本地节点信息。
查询服务器发送过来的架构树中的所有节点,如果找到,则将备份数据转化为正式数据,如果没有找到,则为新节点,需要将具体信息拉取并保存在架构树中。
当全架构同步完成后,查找并删除db中所有备份节点,清除缓存和同步状态。
如果服务端发送的是全节点,客户端的处理序列图如下:
服务器发送版本号回滚标志
从时序图中可以看出,服务端发送的版本号回滚标记是一个非常重要的信号。
版本号回滚标志仅在第一次同步时随新版本号一起发送,全架构同步时客户端需要缓存该标志,并与版本号一起存入数据库,全架构同步完成后需要根据版本号是否回滚来决定是否在数据库中删除该节点。
备份架构树
Tree 备份最直接的方案就是将 db 中的数据复制一份到新表中,如果数据量不大的话还可以,但是 Tree 往往节点很多,这种简单粗暴的方案在移动端是完全不可取的,在一个几十万人的企业里,这会造成巨大的性能问题。
经过深思熟虑,企业微信采取的解决方案是:
如果后端在同步架构的时候,发出需要回滚版本号的标志,那么客户端会把和db中的所有节点标记为挂起删除(序列图中的步骤8和9)。
对于服务器发送的更新节点,清除架构树中该节点的待删除标记(序列图中的第10步和第11步)。
当全架构同步完成后,在db中找到所有标记为删除的节点并删除(时序图中步骤13),并清空所有缓存数据。
而且增量同步过程中不能影响树的正常显示,所以在同步过程中,如果上层请求db中的数据,需要过滤掉标记为删除的节点。
缓存架构树
该方案决定了客户端无法避免全节点比较,将重要信息缓存在内存中将大大加快处理速度,内存中的架构树节点体定义为:
这里使用std::map来缓存树,使用std::pair作为key,在比较节点时会涉及到很多查询操作,而使用map查询的时间复杂度仅为O(logn)。
增量同步方案要点
这部分会写优化同步方案中的重点,这些重点不仅适用于本文中的架构同步,也适用于大多数同步逻辑。
确保数据处理完成后,存储版本号
几乎在所有同步中,版本号都是至关重要的,一旦版本号搞乱了,后果将非常严重。
架构同步最重要的一点是:
存储版本号之前请确保数据处理已经完成。
组织架构同步场景下,为什么不能先存版本号,再存数据呢?
这涉及到组织架构同步数据的一个重要特性:架构节点数据可以重复拉取和覆盖。
考虑实际操作中遇到的真实场景:
如果客户端已经向服务器请求过新增节点信息,由于客户端刚刚插入新增节点,还未存储版本号,因此客户端应用程序终止。
此时客户端重启时会用同一个版本号把刚刚处理过的节点拉下来,将这些节点和本地数据对比后发现节点的seq没有更新过就不会再次拉取节点信息,不会出现节点重复的情况。
如果先保存版本号,再保存具体数据,有概率导致架构更新数据丢失。
同步的原子性
一般情况下,同步的逻辑可以简化如下:
企业微信组织结构的同步存在异步操作,如果同步过程不保证原子性,很可能会出现以下情况:
这张图中,在同步的时候又插入了一次同步,这个很容易引发问题:
输出结果不稳定,如果两次同步几乎同时开始,但是由于网络波动,返回的结果可能会不一样,给调试带来很大麻烦。
中间状态混淆。如果同步时服务器返回的结果依赖于请求同步时的某个中间状态,而发起新的同步时又重置了这个状态,则很可能引发不可预知的异常。
整个同步过程应该是原子性的,如果中间插入其他的同步过程,会打乱整个同步过程的时序,从而引发异常。
如何保证同步的原子性?
我们可以在启动同步的时候记录一个标志位表示同步正在进行,在结束同步的时候清除该标志位。如果另外一个同步到来的时候发现还有另外一个同步正在进行,那么我们可以直接放弃本次同步,或者等到本次同步成功之后再进行下一次同步。
另外,同步也可以串行化,保证同步的时序性,多个同步的时序应该是FIFO的。
缓存数据一致性
移动同步过程中有两种类型的缓存:
内存缓存。加入内存缓存的目的是为了减少文件IO操作,加快程序处理速度。
磁盘缓存。加入磁盘缓存的目的是为了防止程序终止时,同步状态不丢失。
内存缓存用于保存多缓存同步时的数据以及同步的中间状态;磁盘缓存用于缓存同步的中间状态,防止缓存状态丢失。
整个同步过程中,我们要保证缓存数据和数据库数据的变化需要一一对应,增量同步的情况下,每次我们需要更新/删除数据库的某个节点,都需要更新对应的缓存信息,以保证数据的一致性。
优化数据对比
内存使用情况
测试方法:使用工具监控使用同一账号首次加载架构时App进行全量同步和增量同步的内存占用峰值。
内存峰值测试结果
分析
随着架构中节点数量的增加,全量同步方案的内存峰值会不断上升,极端情况下会导致应用内存溢出(实际测试30万节点下需要6次全量同步方案);增量同步方案中,总节点数不会影响内存峰值,只是增加了同步分片数量。
经过优化,在腾讯域中,增量同步方案的总内存占用仅为全量同步方案的53.1%,且企业规模越大,优化效果越明显。并且无论架构总节点数多少,增量同步方案都可以对整个架构进行同步,达到预期效果。
数据使用
测试方法:在管理端添加成员五次,通过日志分析客户端流量消耗,取平均值,日志会打印出请求和body大小,估算流量使用值。
测试结果
分析
对于增量同步方案,新增成员操作只会拉取单个新成员的信息,所以无论架构中有多少人,流量消耗都差不多。而对于全量同步方案,服务器会针对每一次变更请求发送全量hash列表,企业规模越大,消耗的流量越多。可以看出,当某企业的节点数达到20万时,全量同步方案的流量消耗是增量同步方案的近500倍。
经过优化后,在腾讯域下,每次增量同步的流量消耗仅为全量同步方案的0.4%,且企业规模越大,优化效果越明显。
最后的想法
增量同步方案避免了架构同步延迟、流量消耗过大的问题,通过用户反馈和数据分析,增量架构同步上线后运行稳定,达到了理想的优化效果。
关于作者
胡腾,腾讯工程师,参与了企业微信从零到搭建的全过程,目前负责企业微信移动端的组织架构、外部联系人等模块的开发。
今日推荐文章
点击下方图片即可阅读
技术谈:为何KPI毁了索尼,而OKR却让谷歌成功?