企业微信大规模组织架构性能优化实践(企业微信大规模组织架构性能优化实践报告)
作者:yecong,腾讯 WXG 客户端开发工程师
本文主要讲述企业微信大规模组织架构(后文简称为大架构)的性能优化过程。分成两部分讲述,第一部分是短线迭代的优化,主要是并发性能的优化。第二部分是长线迭代的优化,主要是从业务模式上做了根本性优化。
一、并发性能优化
1.1 背景
当私有化的组织架构上升到100W的量级时,出现了严重影响组织架构使用的问题:打开二级部门时,加载缓慢。如图所示,loading可能持续一分钟以上。
问题:打开二级部门加载缓慢
1.2 分析
我们分析一下加载二级部门的流程,下面是加载二级部门的流程图。
- 如果从来没加载过该部门,需要从服务端拉取部门下的节点详情。这里是因为之前我们已经做了优化,首次登录时只拉取了部门的节点ID,没有拉取详情。
- 如果加载过该部门,就直接从DB读取该部门的数据,然后返回UI展示。
当只有一条DB线程时,组织架构更新的任务,可能会插入到加载二级部门的任务的前面。而在百万级别的组织架构中,全量更新的DB任务有可能比较久,全量更新的插入或者更新节点可能比较多,导致本来很快可以完成的二级部门加载任务,要排队比较久才能执行完。
下面是组织架构全量更新的流程图。
全量更新
在这里,读写并发上出现了明显的瓶颈。原因总结如下:
- 加载二级部门和全量更新共用一条DB线程
- 当全量更新大量节点时,全量更新的低优先级任务卡住加载二级部门的高优先级任务
1.3 方案
读写分离为了提高组织架构在大规模数据下的读写并发性能,我们开启了wal模式,把读写任务分别放在不同的线程中执行。
针对加载二级部门的流程,可以在读线程中读取部门的详情节点,而组织架构更新可以在写线程中单独执行。
由于加载二级部门的原流程是拉取数据、写入DB、再从DB读取数据,而且WAL只支持一写多读,因此我们调整了缓存策略,把保存节点详情的写任务延迟到流程最后,优先构造了cache返回UI。这样从DB中读出数据的读任务,就不需要等待保存节点详情的写任务。避免了保存节点的写任务再次被其他写任务阻塞,读任务又被保存节点的写任务阻塞,退化成串行操作。
WAL机制的原理
调用方修改的数据并不直接写入到数据库文件中,而是写入到另外一个称为WAL的文件中,然后在随后的某个时间点被写回到数据库文件中。在这个时间点的回写操作,会降低数据库当时的读写性能。但是通过设置对WAL文件大小的限制,这种性能影响是可控的。实际上线后也没有遇到由于checkpoint同步导致数据库慢的反馈。
缓存策略
写策略的步骤:先更新缓存中的数据,再更新数据库中的数据。
读策略的步骤:
如果读取的数据命中了缓存,则直接返回数据;如果读取的数据没有命中缓存,则从数据库中读取数据,然后将数据写入到缓存,并且返回给UI。
方案总结
方案优点缺点1. 开启WAL,拆分DB读写线程 2. 缓存策略适配:先保证UI展示,再让数据落地1. 读写并发
2. 最大化利用缓存WAL文件同步回数据库文件的时候,会降低当时的读写性能。
1.4 效果
在优化前,只有52%的用户能在1s内加载完二级部门。而上线之后,93%的用户都能在1s内打开二级部门。耗时小于1s的用户占比提升40%!
二、业务模式优化
2.1 问题
2.1.1 背景
当业务进一步发展时,我们预估未来将要到达300W量级的组织架构。于是我们就开始提前规划如何能在组织架构数量一直增长的情况下,还能让组织架构流畅好用。
2.1.2 问题
- 选人控件闪退和ANR
- 组织架构全量更新闪退
在300w的组织架构环境中,旧的组织架构加载方案,在全量更新、选人控件中均出现了占用内存过大甚至闪退的问题。而且旧方案的加载时间会随着节点数量的增加,不可避免地成正比增长。
2.1.3 分析
当前方案的耗时、内存占用与用户组织架构的大小成正比,单点优化无法满足组织架构持续增长的需求。具体来说,会造成下面的一些问题:
- 选人控件会加载全量的组织架构ID树,数量过多时容易发生闪退和ANR。
- 组织架构全量更新占用内存过大,造成闪退。
因此,我们需要一个新的业务模式,即便总的组织架构规模一直上涨的情况下,也能维持较好的性能。
2.2 方案比较
比较容易想到的一个方案是web加载的模式,不保存本地数据,但是体验比较差,每层都会出loading。
联系到我们的具体业务,由于私有化对不同的部门,划分出了具有意义的独立组织机构–单位。单位是具有管理意义的部门,不同单位可以独立加载。而每个人,也拥有主单位和兼岗单位。所以可以按照单位加载的方式,从根本上解决目前组织架构面临的瓶颈。
按单位加载,可以简单理解为按部门加载。
方案缺点优点Web加载模式:不保存本地数据体验太差,每层都要出loading理论上可支持的数据量上限最大单位加载模式:按单位加载需要推广到企业符合业务逻辑,可支持到500万量级
概念定义
- 单位:政府行政组织结构中的职能部门,组建架构并承担对应责任
- 主单位:【我】所在的单位
- 其他单位:除了【我】所在的其他单位
- 骨架:通讯录骨架包含了所有的单位节点
- 普通部门:不属于任何单位的部门节点
下图是组织架构树的示意图,蓝色节点是优先加载的本单位,灰色节点是其他单位,红色节点是骨架。不同的单位独立加载。
2.3 按单位加载
2.3.1 加载策略
接下来我们看看加载策略。
第一是对自己所在的主单位(蓝色节点),每次唤醒时就会更新,跟旧组织架构的逻辑类似,但是会限制拉取节点的数量。
第二对于其他单位(灰色节点),点击到该单位时才会拉取,2个小时后会淘汰删除,避免数据表过大。
第三对于骨架(红色节点),会全量加载节点ID,再拉取节点详情。
拉取策略限制了能够拉取的节点详情数量,如果单位节点数量超过了限制,首先拉取全量ID,再按照优先规则,拉取配置的节点详请数量。
2.3.2 加载流程
加载的流程是先拉取自己的单位列表,然后拉取每个单位的全量通讯录ID,再按照后台策略,拉取所需的详细节点,最后拉取骨架。
- 如果点击到主单位:
- 如果只有ID没有节点,会立刻拉取节点详情返回界面。
- 如果ID和节点详情都有,可以直接返回UI展示,然后延迟刷新节点。
- 如果是点击到其他单位,可能出现ID和详情都没有的情况,需要拉取其他单位的节点,界面loading等待。
- 如果是骨架,就一定有节点和详情,只需要延迟刷新。
2.4 跨平台设计:分层设计
接下来我们看看如何分层。在500万量级的大规模组织架构下,移动端和pc端都出现了组织架构卡顿、闪退的问题,所以我们希望能够开发一套各端共用的逻辑,统一维护。
第一是要抽取公共的基础库,包括boost库、任务框架、线程管理框架等。
第二是设计公共的数据结构。
第三,因为不同端的网络库差异比较大,这里不好完全共用,所以需要抽取网络任务接口,由各端独立实现。
具体到框架图,我们从下往上看。底层是基础库,接着是C 实现的跨平台业务层,Service层是移动端和pc端分开实现,主要是做接口调用和回调的简单封装,上层则各端界面实现。上层界面为了兼容新旧两套组织架构,也做了接口抽象,可以通过开关自由切换。这样优点就是有统一的业务逻辑代码、DB设计和线程管理。
- 关键点
- 抽取公共基础库
- 抽象公共的数据结构
- 抽象网络层和数据库层接口
- 优点
- 统一的业务逻辑代码、DB设计、线程管理
2.5 跨平台设计:架构设计
在具体实现之前,我们来看看架构设计的一些概念。
2.5.1 架构整洁之道
业务实体和用例
关键业务逻辑和关键业务数据是紧密相关的,所以它们很适合被放在同一个对象中处理。我们将这种对象称为“业务实体”。业务实体这个概念中应该只有业务逻辑,没有别的,与数据库、用户界面、第三方框架等内容无关。
用例所描述的是某种特定应用情景下的业务逻辑,可以理解为:输入 业务实体 输出 = 用例
软件架构
软件的系统架构应该为该系统的用例提供支持。一个良好的架构设计应该围绕着用例来展开,这样的架构设计可以在脱离框架、工具以及使用环境的情况下完整地描述用例。
整洁架构
下图的同心圆分别代表了软件系统中的不同层次,越靠近中心,其所在的软件层次就越高。基本上,外层圆代表的是机制,内层圆代表的是策略。
这其中有一条贯穿整个架构设计的规则,即依赖关系规则:
源码中的依赖关系必须只指向同心圆的内层,即由底层机制指向高层策略。依赖关系与数据流控制流脱钩,而与组件所在层次挂钩,始终从低层次指向高层次。
2.5.2 我们的架构
我们的类图与架构设计概念的对应关系如下:
- 业务实体:ArchTask
- 用例:ArchProto
- 模型层,即最外层:各种第三方框架,如DbInterface(数据库模块)、ArchLogicHandler(网络模块)等。我们从一次具体的业务调用流程来看看这样设计的意义。下面是从UI发起的一次架构更新流程,大家可以主要关注控制流是怎么穿越各层的边界:控制流从最外层的用户界面开始,穿过用例(Arch),最后调用最外层的组件:网络模块和数据库模块。但是我们源码中的依赖方向却都是向内指向用例的。
这里,我们采用的是依赖反转原则(DIP)来解决这种相反性。我们可以通过调整代码中的接口和继承关系,利用源码中的依赖关系,限制控制流只能在正确的地方跨域架构边界。
在上面的流程图中,主要有两个应用依赖反转原则的地方:
一、CalcPreLoadArchIDs是从SyncUnitArchTask(业务实体)调用调用到ArchProto(用例)。业务实体这样的高层概念,是无须了解像用例这样的底层概念的。反之,底层业务用例却需要了解高层的业务实体。所以在SyncUnitArchTask中,其实是通过调用ArchProto的接口来调用CalcPreLoadArchIDs。SyncUnitArchTask中的调用代码如下:
arch_service_context_->CalcPreLoadArchIDs(unit_id_, arch_service_context_->GetCurrentVid(), other_unit_click_partyid_, vecHashNode, all_tmp_ids, arch_ids, ptr_map_);
ArchProto会在Task初始化时,把自己设置进Task中,给各类型的Task反向调用。
class ArchProto : public ArchServiceContext{...};
二、最外层的模型层一般是由工具、数据库、网络框架等组成的。框架与驱动程序层中包含了所有的实现细节。从系统架构的角度看,工具通常是无关紧要的,因为这只是一个底层的实现细节,一种达成目标的手段。当Task需要调用网络模块收发请求或者调用数据库模块获取数据时,为了避免内层策略依赖外层机制,Task只会调用外层工具的接口层,而不会依赖实现细节。这样的架构设计给我们带来的好处是,我们可以轻松替换框架,而不影响内层策略。比如在桌面端,我们会有另外一套完全不同的网络模块实现,只需要挂接不同的网络实现子类,我们就可以在桌面端复用新的大架构模块。
良好的架构设计应该尽可能地允许用户推迟和延后决定采用什么框架、数据库、网络框架以及其他与环境相关的工具。总之,良好的架构设计应该只关注用例,并能将它们与其他的周边因素隔离。
2.5.3 新旧组织架构模块的交互
大架构跨平台层,跟原来的组织架构模块是怎么交互的呢?原来的组织架构的数据表主要分成三部分:部门表、人员信息表、部门人员关系表,而出现性能问题的主要在于关系表上。所以数据设计上,人员信息保留在原组织架构底层,部门人员关系表、部门表在大架构底层。
- 表结构设计:
- 主要组成:人员信息表、部门表、部门人员关系表。
- 大架构底层保存部门和部门人员关系表,人员信息保留在原组织架构底层。
- 大架构底层与原组织架构底层的业务关联:
- 人员展示的部门链路如何获取?—-从大架构底层获取,因为关系表存放在大架构底层。
- 搜索如何做?—- 部门名字保存到原组织架构底层,复用原组织架构底层的索引建立逻辑。
2.6 双DB切换
2.6.1 旧的读写表切换方式
旧方案里组织架构的全量更新流程
当后台告诉客户端需要全量更新时,客户端会将所有节点标为待删除,然后同步后台的节点,清除待删除标记。同步完成后,将写表的数据同步到读表,更新版本号。最后UI就可以从读表中读取到最新的数据。
而之前通过用户日志案例分析,最长的耗时主要是在将写表的数据拷贝到读表上面。在这个过程中,大架构下部分用户的日志里有更新57w节点的数据用了2个半小时的情况,而且这个步骤是原子操作,如果不能够一次完成,下次还得重新执行。
原有流程里,读表和写表是固定的,导致全量更新需要等读表同步完数据,界面才能读到新数据。
- 分析:写表同步数据到读表耗时很久,当全量更新时,如果有大量节点需要更新,会耗时很长。
- 缺点:写表和读表固定,全量更新需要等数据同步完成,界面才能读取到新数据。
2.6.2 新的双DB切换方式
针对旧方案中读写表同步过久的问题,大架构方案里我们换成了双DB切换的模式。下面是我们的状态机设计和业务代码获取表名的逻辑。
这样修改之后,不需要等读写表同步完,UI就可以读取到最新数据。而同步的过程可以在后台慢慢完成,并且不会受原子性操作的限制。业务代码获取读表的逻辑,也收拢到了一个函数。
因为单位模式下,每个单位的节点数量都不会很多,而且大多数用户只会加载日常有交流的几个单位,所以读写表同步这里,我们采用了把原表删掉,全量拷贝的方式。
2.7 效果
对于耗时,优化前使用全量加载的方式使得耗时很长,而优化后采用的“本单位 骨架”的预加载逻辑使得加载耗时大幅度减小。优化后的内存占用大小在各场景下均有减小,通讯录页面的流畅度也得到了一定的提升。
一、耗时
二、CPU占用率
三、内存占用大小
四、卡顿
作者:yecong
来源:微信公众号:腾讯技术工程
出处:https://mp.weixin.qq.com/s/eK47AzCSSf8-W3wZdjrXXQ