企业微信针对百万级组织架构的客户端性能优化实践
本文由腾讯 WXG 客户端开发工程师 yecong 分享,本文做了修订和改动。
1、引言
相对于传统的消费级 IM 应用,企业级 IM 应用的特殊之外在于它的用户关系是按照所属企业的组织架构来关联的起来,而组织架构的大小是无法预设上限的,这也要求企业级 IM 应用在遇到真正的超大规模组织架构时,如何保证它的应用性能不受限于(或者说是尽可能不受限于)企业架构规模,这是个比较有难度的技术问题。
本文主要分享的是企业微信在百对百万级大规模组织架构(后文简称大架构)时,是如何对客户端进行性能优化过程的,希望带给你启发。
内容分成两部分讲述,第一部分是短线迭代的优化,主要是并发性能的优化。第二部分是长线迭代的优化,主要是从业务模式上做了根本性优化。
以下是相关文章,推荐一并阅读:
《企业微信的IM架构设计揭秘:消息模型、万人群、已读回执、消息撤回等》
《钉钉——基于IM技术的新一代企业OA平台的技术挑战(视频+PPT) [附件下载]》
《阿里钉钉技术分享:企业级IM王者——钉钉在后端架构上的过人之处》
技术交流:
- 移动端 IM 开发入门文章:《新手入门一篇就够:从零开发移动端IM》
- 开源 IM 框架源码:https://github.com/JackJiang2011/MobileIMSDK(备用地址点此)
(本文已同步发布于:http://www.52im.net/thread-4437-1-1.html)
2、100 万级组织架构时的性能问题
当私有化的组织架构上升到 100W 的量级时,出现了严重影响组织架构使用的问题:打开二级部门时,加载缓慢。
如图所示,loading 可能持续一分钟以上:
3、100 万级组织架构的问题分析
我们分析一下加载二级部门的流程。
下面是加载二级部门的流程图:
1)如果从来没加载过该部门,需要从服务端拉取部门下的节点详情(这里是因为之前我们已经做了优化,首次登录时只拉取了部门的节点 ID,没有拉取详情);
2)如果加载过该部门,就直接从 DB 读取该部门的数据,然后返回 UI 展示。
当只有一条 DB 线程时,组织架构更新的任务,可能会插入到加载二级部门的任务的前面。而在百万级别的组织架构中,全量更新的 DB 任务有可能比较久,全量更新的插入或者更新节点可能比较多,导致本来很快可以完成的二级部门加载任务,要排队比较久才能执行完。
下面是组织架构全量更新的流程图:
在这里,读写并发上出现了明显的瓶颈。
原因总结如下:
1)加载二级部门和全量更新共用一条 DB 线程;
2)当全量更新大量节点时,全量更新的低优先级任务卡住加载二级部门的高优先级任务。
4、针对 100 万级组织架构的优化方案
4.1 基本
读写分离为了提高组织架构在大规模数据下的读写并发性能,我们开启了 wal 模式,把读写任务分别放在不同的线程中执行。
针对加载二级部门的流程,可以在读线程中读取部门的详情节点,而组织架构更新可以在写线程中单独执行。
由于加载二级部门的原流程是拉取数据、写入 DB、再从 DB 读取数据,而且 wal 只支持一写多读,因此我们调整了缓存策略,把保存节点详情的写任务延迟到流程最后,优先构造了 cache 返回 UI。
这样从 DB 中读出数据的读任务,就不需要等待保存节点详情的写任务。避免了保存节点的写任务再次被其他写任务阻塞,读任务又被保存节点的写任务阻塞,退化成串行操作。
4.2WAL 机制的原理
调用方修改的数据并不直接写入到数据库文件中,而是写入到另外一个称为 WAL 的文件中,然后在随后的某个时间点被写回到数据库文件中。
在这个时间点的回写操作,会降低数据库当时的读写性能。
但是通过设置对 WAL 文件大小的限制,这种性能影响是可控的。
实际上线后也没有遇到由于 checkpoint 同步导致数据库慢的反馈。
4.3 缓存策略
写策略的步骤:先更新缓存中的数据,再更新数据库中的数据。
读策略的步骤:
1)如果读取的数据命中了缓存,则直接返回数据;
2)如果读取的数据没有命中缓存,则从数据库中读取数据,然后将数据写入到缓存,并且返回给 UI。
4.4 方案总结
5、100 万级优化后的效果
在优化前,只有 52%的用户能在 1s 内加载完二级部门。上线之后,93%的用户都能在 1s 内打开二级部门。耗时小于 1s 的用户占比提升 40%!
6、当对面 300 万组织架构时的问题
6.1 概述
当业务进一步发展时,我们预估未来将要到达 300W 量级的组织架构。于是我们就开始提前规划如何能在组织架构数量一直增长的情况下,还能让组织架构流畅好用。
6.2 问题
主要是:
1)选人控件闪退和 ANR;
2)组织架构全量更新闪退。
在 300w 的组织架构环境中,旧的组织架构加载方案,在全量更新、选人控件中均出现了占用内存过大甚至闪退的问题。而且旧方案的加载时间会随着节点数量的增加,不可避免地成正比增长。
6.3 分析
当前方案的耗时、内存占用与用户组织架构的大小成正比,单点优化无法满足组织架构持续增长的需求。
具体来说,会造成下面的一些问题:
1)选人控件会加载全量的组织架构 ID 树,数量过多时容易发生闪退和 ANR;
2)组织架构全量更新占用内存过大,造成闪退。
因此,我们需要一个新的业务模式,即便总的组织架构规模一直上涨的情况下,也能维持较好的性能。
7、针对 300 万组织架构的优化方案
比较容易想到的一个方案是 web 加载的模式,不保存本地数据,但是体验比较差,每层都会出 loading。
联系到我们的具体业务,由于私有化对不同的部门,划分出了具有意义的独立组织机构——单位。
单位是具有管理意义的部门,不同单位可以独立加载。而每个人,也拥有主单位和兼岗单位。所以可以按照单位加载的方式,从根本上解决目前组织架构面临的瓶颈。
按单位加载,可以简单理解为按部门加载:
概念定义:
1)单位:政府行政组织结构中的职能部门,组建架构并承担对应责任;
2)主单位:“我”所在的单位;
3)其他单位:除了“我”所在的其他单位;
4)骨架:通讯录骨架包含了所有的单位节点;
5)普通部门:不属于任何单位的部门节点。
下图是组织架构树的示意图:
如上图所示:蓝色节点是优先加载的本单位,灰色节点是其他单位,红色节点是骨架。不同的单位独立加载。
8、300 万优化方案中的“按单位加载”技术思路
8.1 加载策略
接下来我们看看加载策略。
第一:是对自己所在的主单位(蓝色节点),每次唤醒时就会更新,跟旧组织架构的逻辑类似,但是会限制拉取节点的数量。
第二:对于其他单位(灰色节点),点击到该单位时才会拉取,2 个小时后会淘汰删除,避免数据表过大。
第三:对于骨架(红色节点),会全量加载节点 ID,再拉取节点详情。
拉取策略限制了能够拉取的节点详情数量,如果单位节点数量超过了限制,首先拉取全量 ID,再按照优先规则,拉取配置的节点详请数量。
8.2 加载流程
加载的流程是先拉取自己的单位列表,然后拉取每个单位的全量通讯录 ID,再按照后台策略,拉取所需的详细节点,最后拉取骨架。
如果点击到主单位:
1)如果只有 ID 没有节点,会立刻拉取节点详情返回界面;
2)如果 ID 和节点详情都有,可以直接返回 UI 展示,然后延迟刷新节点。
如果是点击到其他单位:可能出现 ID 和详情都没有的情况,需要拉取其他单位的节点,界面 loading 等待。
如果是骨架:就一定有节点和详情,只需要延迟刷新。
9、300 万优化方案的分层设计思路
接下来我们看看如何分层。
在 300 万量级的大规模组织架构下,移动端和 pc 端都出现了组织架构卡顿、闪退的问题,所以我们希望能够开发一套各端共用的逻辑,统一维护。
第一:是要抽取公共的基础库,包括 boost 库、任务框架、线程管理框架等。
第二:是设计公共的数据结构。
第三:因为不同端的网络库差异比较大,这里不好完全共用,所以需要抽取网络任务接口,由各端独立实现。
具体到框架图,我们从下往上看:
1)底层是基础库;
2)接着是 C++实现的跨平台业务层;
3)Service 层是移动端和 pc 端分开实现,主要是做接口调用和回调的简单封装;
4)上层则各端界面实现。
上层界面为了兼容新旧两套组织架构,也做了接口抽象,可以通过开关自由切换。这样优点就是有统一的业务逻辑代码、DB 设计和线程管理。
关键点:
1)抽取公共基础库;
2)抽象公共的数据结构;
3)抽象网络层和数据库层接口。
优点:统一的业务逻辑代码、DB 设计、线程管理。
10、300 万优化方案的整体架构设计思路
在具体实现之前,我们来看看架构设计的一些概念。
10.1 架构整洁之道
1)业务实体和用例:
关键业务逻辑和关键业务数据是紧密相关的,所以它们很适合被放在同一个对象中处理。
我们将这种对象称为“业务实体”。业务实体这个概念中应该只有业务逻辑,没有别的,与数据库、用户界面、第三方框架等内容无关。
用例所描述的是某种特定应用情景下的业务逻辑,可以理解为:输入 + 业务实体 + 输出 = 用例。
2)软件架构:
软件的系统架构应该为该系统的用例提供支持。
一个良好的架构设计应该围绕着用例来展开,这样的架构设计可以在脱离框架、工具以及使用环境的情况下完整地描述用例。
3)整洁架构:
下图的同心圆分别代表了软件系统中的不同层次,越靠近中心,其所在的软件层次就越高。基本上,外层圆代表的是机制,内层圆代表的是策略。
这其中有一条贯穿整个架构设计的规则,即依赖关系规则:
10.2 我们的架构
我们的类图与架构设计概念的对应关系如下:
1)业务实体:ArchTask;
2)用例:ArchProto;
3)模型层:即最外层,各种第三方框架,如 DbInterface(数据库模块)、ArchLogicHandler(网络模块)等。
我们从一次具体的业务调用流程来看看这样设计的意义。
下面是从 UI 发起的一次架构更新流程,大家可以主要关注控制流是怎么穿越各层的边界:控制流从最外层的用户界面开始,穿过用例(Arch),最后调用最外层的组件:网络模块和数据库模块。但是我们源码中的依赖方向却都是向内指向用例的。
这里,我们采用的是依赖反转原则(DIP)来解决这种相反性。我们可以通过调整代码中的接口和继承关系,利用源码中的依赖关系,限制控制流只能在正确的地方跨域架构边界。
在上面的流程图中,主要有两个应用依赖反转原则的地方:
1)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 反向调用。
classArchProto : publicArchServiceContext
{
...
};
2)最外层的模型层一般是由工具、数据库、网络框架等组成的。
框架与驱动程序层中包含了所有的实现细节。
从系统架构的角度看,工具通常是无关紧要的,因为这只是一个底层的实现细节,一种达成目标的手段。
当 Task 需要调用网络模块收发请求或者调用数据库模块获取数据时,为了避免内层策略依赖外层机制,Task 只会调用外层工具的接口层,而不会依赖实现细节。
这样的架构设计给我们带来的好处是,我们可以轻松替换框架,而不影响内层策略。比如在桌面端,我们会有另外一套完全不同的网络模块实现,只需要挂接不同的网络实现子类,我们就可以在桌面端复用新的大架构模块。
良好的架构设计应该尽可能地允许用户推迟和延后决定采用什么框架、数据库、网络框架以及其他与环境相关的工具。
总之,良好的架构设计应该只关注用例,并能将它们与其他的周边因素隔离。
10.3 新旧组织架构模块的交互
大架构跨平台层,跟原来的组织架构模块是怎么交互的呢?
原来的组织架构的数据表主要分成三部分:
1)部门表;
2)人员信息表;
3)部门人员关系表。
而出现性能问题的主要在于关系表上。所以数据设计上,人员信息保留在原组织架构底层,部门人员关系表、部门表在大架构底层。
表结构设计:
1)主要组成:人员信息表、部门表、部门人员关系表;
2)大架构底层保存部门和部门人员关系表,人员信息保留在原组织架构底层。
大架构底层与原组织架构底层的业务关联:
1)人员展示的部门链路如何获取?从大架构底层获取,因为关系表存放在大架构底层;
2)搜索如何做?部门名字保存到原组织架构底层,复用原组织架构底层的索引建立逻辑。
11、300 万优化方案的双 DB 切换模式
11.1 旧的读写表切换方式
旧方案里组织架构的全量更新流程:
当后台告诉客户端需要全量更新时,客户端会将所有节点标为待删除,然后同步后台的节点,清除待删除标记。同步完成后,将写表的数据同步到读表,更新版本号。最后 UI 就可以从读表中读取到最新的数据。
而之前通过用户日志案例分析,最长的耗时主要是在将写表的数据拷贝到读表上面。在这个过程中,大架构下部分用户的日志里有更新 57w 节点的数据用了 2 个半小时的情况,而且这个步骤是原子操作,如果不能够一次完成,下次还得重新执行。
原有流程里,读表和写表是固定的,导致全量更新需要等读表同步完数据,界面才能读到新数据。
分析:写表同步数据到读表耗时很久,当全量更新时,如果有大量节点需要更新,会耗时很长。
缺点:写表和读表固定,全量更新需要等数据同步完成,界面才能读取到新数据。
11.2 新的双 DB 切换方式
针对旧方案中读写表同步过久的问题,大架构方案里我们换成了双 DB 切换的模式。下面是我们的状态机设计和业务代码获取表名的逻辑。
这样修改之后,不需要等读写表同步完,UI 就可以读取到最新数据。而同步的过程可以在后台慢慢完成,并且不会受原子性操作的限制。业务代码获取读表的逻辑,也收拢到了一个函数。
因为单位模式下,每个单位的节点数量都不会很多,而且大多数用户只会加载日常有交流的几个单位,所以读写表同步这里,我们采用了把原表删掉,全量拷贝的方式。
12、200 万级优化后的效果
对于耗时,优化前使用全量加载的方式使得耗时很长,而优化后采用的“本单位+骨架”的预加载逻辑使得加载耗时大幅度减小。优化后的内存占用大小在各场景下均有减小,通讯录页面的流畅度也得到了一定的提升。
耗时:
CPU 占用率:
内存占用大小:
卡顿:
13、相关资料
[2] 企业微信的IM架构设计揭秘:消息模型、万人群、已读回执、消息撤回等
[3] 钉钉——基于IM技术的新一代企业OA平台的技术挑战(视频+PPT) [附件下载]》
[4] 阿里钉钉技术分享:企业级IM王者——钉钉在后端架构上的过人之处》
[7] IM开发干货分享:万字长文,详解IM“消息“列表卡顿优化实践
[9] 移动端IM实践:Android版微信如何大幅提升交互性能(一)
[10] 移动端IM实践:Android版微信如何大幅提升交互性能(二)
[11] 移动端IM实践:iOS版微信的多设备字体适配方案探讨
[12] 爱奇艺技术分享:爱奇艺Android客户端启动速度优化实践总结
[13] 微信团队分享:微信支付代码重构带来的移动端软件架构上的思考
(本文已同步发布于:http://www.52im.net/thread-4437-1-1.html)
评论