1. 程式人生 > >企業微信組織架構同步優化的思路與實操演練

企業微信組織架構同步優化的思路與實操演練

企業微信

作者:胡騰

編輯:小智

作為企業級的微信,在業務快速發展的背景下,迭代優化的要求也越發急迫。企業微信初版的全量同步方案在快速的業務增長面前已經捉襟見肘,針對其遇到的問題,怎樣做好組織架構同步優化?這是又一篇來自微信團隊的技術實戰。

寫在前面

企業微信在快速發展過程中,陸續有大企業加入使用,企業微信初版採用全量同步方案,該方案在大企業下存在流量和效能兩方面的問題,每次同步消耗大量流量,且在 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 之間所有的變更給客戶端,完成增量更新。

圖示為:

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 定義為:

protobuf

在增量同步方案中,用 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%,且企業越大優化效果越明顯。

寫在最後

增量同步方案從方案上避免了架構同步不及時以及流量消耗過大的問題。通過使用者反饋和資料分析,增量架構同步上線後執行穩定,達到了理想的優化效果。

作者介紹:

胡騰,騰訊工程師,參與企業微信從無到有的整個過程,目前主要負責企業微信移動端組織架構和外部聯絡人等模組的開發工作。

文章來自微信公眾號:InfoQ