背景
目前賬号系統從研發維護的角度有以下痛點:
賬号研發團隊支援多條業務線:B站國内版、B站國際版、海外遊戲等。但目前每一個業務線都是獨立的代碼分支,研發的維護成本很高,随着業務線的增加,維護成本成線性增長;其次因為版本比較多研發小夥伴很難熟悉所有版本細節,是以部分業務支撐存在研發小夥伴單點問題;同時多版本對上遊接入也非常痛苦,我們共向遊戲團隊提供了三套API,也被多次吐槽
目前賬号系統内部微服務拆分很細,B站國内版賬号服務 20+ 微服務,系統架構、領域邊界等問題長時間被人诟病
在考慮到賬号内部很多不合理的設計在現有系統上維護更新成本較高,我們計劃對現有的賬号系統進行重構:即賬号多租戶架構更新,所有業務線使用一套全新的代碼,以海外遊戲為起點,後續慢慢替換所有業務線。今天就主要是給大家介紹一下,我們賬号系統重構之後的架構是什麼樣的,代碼是如何實作不同租戶之間的差異的,以及在上線過程中的遇到的一些核心問題
賬号架構
系統分析
賬号提供的核心能力是認證,同時使用者也可以對認證時的賬号資訊進行管理
使用者資訊
賬号資訊要素并不複雜,主要資訊如下:
- 基礎資訊:mid、使用者名、密碼
- 手機号:使用者可以通過手機号+密碼、手機号+短信驗證碼 等完成登入,同時也是觸達使用者的管道
- 郵箱:使用者可以通過郵箱+密碼登入,同時也是觸達使用者的管道
- SNS:主要包含微信、QQ、微網誌、Google、Facebook、Twitter、Apple,使用者通過綁定三方賬号可以通過三方認證進行登入
系統能力
為了讓使用者能夠更友善使用以及更好地管理自己的賬号,我們提供了一些面向使用者的能力
- 注冊:生成 B 站賬号,用于記錄使用者資料,同時還可以關聯外部資訊如:手機、郵箱、SNS網際網路賬号。
- 登入:使用者使用使用者名+密碼、郵箱+密碼、手機号+密碼、手機驗證碼、三方認證等方式來确認使用者身份,完成登入,同時頒發一個唯一的token
- 密碼管理:使用者可以通過手機号、郵箱來找回密碼,也可以通過個人中心修改密碼
- 手機、郵箱綁定:使用者可以綁定手機、郵箱,也可以更換手機号、郵箱
- SNS管理:使用者可以綁定、解綁第三方賬号
我們除了提供面向使用者的功能之外,我們還提供了服務于業務系統的使用者認證能力如下:
- 鑒權:使用者登入成功之後,後續隻需要 token 資訊就可以确認身份,token認證使用者是感覺不到的,一般是在做一些需要認證的操作時(例:發表評論、發彈幕)請求會帶上token,後端服務來賬号服務進行鑒權。
當然為了更好地支撐以上能力,我們還有很多邊緣的功能,這些功能特點就是他不是單獨提供能力給使用者,而是服務于以上能力,如下:
- 行為驗證碼:注冊、登入時為了防止刷子,一些操作需要行為驗證碼校驗,主要提供注冊和驗證行為驗證碼的功能
- 手機号黑名單:如果被明确為刷子手機号,可以加入黑名單,禁止注冊和登入
- 發郵件、短信:在注冊或登入時,通過發郵件驗證使用者身份
根據賬号業務邊界和業務對象的關聯性以及高内聚低耦合的原則,我們把賬号劃分一下子域
- 使用者子域:負責 B 站賬戶的注冊以及賬号基礎資訊(與登入、鑒權相關的基礎資訊)維護
- 認證子域:負責 B 站賬号登入、鑒權。登入和鑒權雖然是兩種完全不同的操作,但是都是用來來認證目前操作是哪一個使用者,登入之後頒發的 token,後續使用者不再需要簡化了後續的認證流程。
- 驗證碼子域:負責登入注冊等流程中的驗證碼注冊和展示功能
- 黑名單子域:負責黑名單管理和驗證工作
- 郵箱、短信子域:負責短信、郵箱驗證碼以及消息觸達
在考慮微服務拆分時,我們既要考慮不同領域之間的依賴關系,同時也要考慮領域未來的擴充能力,以及單個服務的子產品是否清晰合理。另外混亂的微服務架構反而會比單體服務帶來更大的問題,最終我們把新的架構拆分為4個微服務:使用者服務、登入服務、鑒權服務、賬号支撐服務,主要考量如下:
- 使用者域相對比較獨立,主要負責登入、鑒權相關基礎資訊的維護以及查詢工作,我們單獨微服務
- 認證域拆為登入、鑒權服務,首先是因為登入、鑒權在流量上差别很大,鑒權流量大很多,運維保障的要求也有一定差别,鑒權服務要求絕對重保;其次從讀寫分離上看鑒權是讀、登入是寫。因為兩個服務處理的資料是一緻的,防止兩個服務對資料的操作有差異,我們把對資料的操作抽出了公共子產品被兩個服務依賴
- 賬号支撐服務承接很多子域的功能,a. 這類子域相對比較獨立,如果單獨微服務會拆的很細 b. 此域是支撐域,也可以把這些域劃分到對應的使用者、認證兩個核心服務内,但是這類功能很碎,放到核心域會使關鍵服務很臃腫,不夠聚焦 。我們參考DDD的戰術落地,後續某個子域如果發生了較大發展,我們可以快速獨立出一個服務
架構模型
我們基于DDD四層架構模型來指導我們的微服務落地,但DDD對我們研發有相當高的要求,需要整個團隊自上而下對架構模型有比較深刻的了解,同時考慮到DDD四層架構模型有很多概念比較繁瑣,賬号系統并不是那種十分複雜的系統,完全實踐反而會提高了解成本,增加維護成本,是以我們基于目前大家熟悉的go工程結構,融合了DDD四層架構模型,做了以下定義:
- 接口層:所有流量入口,接口定義、實作,同時還包括消息的監聽,job的觸發入口,主要有這些:grpc、http、mq、job
- 應用層:負責流程編排、差異化能力路由。例如登入,應用層就負責以下5個流程節點的串聯排程:1. 入參校驗 2. 登入次數校驗 3. 賬密校驗 4. 生成token 5. 傳回
- 領域層:具體的業務邏輯實作,包含所有租戶的能力實作。例:建構token、持久化token、寫入緩存
- 基礎層:和外部互動的擴充卡,屏蔽外部特性,轉化成應用内識别定義的資料類型。例:持久化token,mysql插入一條token資料即可根據表裡所有字段來進行查詢,kv(key-value)存儲要根據其他字段查詢時就需要單獨建立二級索引,是以插入3條來保證完整性,但不管基礎層如何實作,上層完全不感覺。
賬号業務架構圖
多租戶方案
此次重構的一個重要目标就是所有賬号體系使用同一套代碼,為此我們引入租戶的概念,每一個租戶都是一套獨立的賬戶體系(如:B站國内版、B站海外、海外遊戲等),不同賬号體系的差異都最終落腳在租戶上
不同租戶可能會有以下特點:
- 資料實體隔離:不同租戶資料是要隔離的,我們不能允許B站國内版賬号登入B站國際版App,也不允許在B站國際版app的登入token能夠在B站國内版進行鑒權
- 業務邏輯差異:不同租戶使用的能力可能是有差異的,有些是展現在能力上的不同,比如B站國内版不支援Google等三方登入,但是B站國際版支援;有些相同能力内部的小部分邏輯是有差異,比如B站國際版不允許國内手機号登入,而B站國内版可以,這個更多的是在業務校驗邏輯的時候有差異
- 外部依賴差異:不同租戶所處環境不同,可能會使用到不同的中間件,一些基礎元件在國内是有的,但是在海外沒有,比如kv國内有,海外遊戲日韓卻沒有部署
如果這些差異我們都是通過代碼裡if else 來進行實作,那我們的代碼會變得很醜陋,随着接入更多賬号體系,維護成本會越來越高。那以下我們就詳細介紹代碼裡我們是如何實作多租戶差異的
資料實體隔離
資料實體隔離我們主要提供了兩種方案,資料庫次元隔離,和表次元隔離,賬号系統通過配置來進行持久化資料的選擇
庫次元隔離:隔離級别最高,安全性最好,适用于資料量以及qps比較大的業務,或者是明确政策要求庫次元隔離的業務。目前海外遊戲使用的此隔離方案
表次元隔離:不需要申請資源,在現有服務上能快速支援新的業務,适用于資料量以及qps比較小的業務
業務邏輯差異
不同的能力可以使用不同的接口,但是如果是相同的能力,比如登入,比如B站國際版不允許國内手機号登入,而B站國内版可以,我們的方案如下:
- 我們對所有接口進行梳理,把服務抽象成一個個獨立流程,每一個流程可以了解為更小的流程節點,流程節點的抽象是要考慮産品本身提供的能力,而不僅僅是考慮某一個租戶的邏輯
- 不同租戶在同一個能力的邏輯差異展現在具體的流程節點上,再把流程節點抽象成接口,然後進行不同的實作,不同租戶根據配置檔案選擇不同的實作來完成邏輯差異
例子:
某一個接口我抽象出來流程節點有P1、P2、P3、P4、P5,其中P2、P3、P5不同租戶之間邏輯有差異,那麼我們就會對其進行接口抽象,針對于如圖三個租戶選擇不同的實作,走不同的鍊路
租戶1:P1、P2.1、P3.1、P4、P5.1
租戶2:P1、P2.2、P3、P4、P5.2
租戶3:P1、P2、P3.2、P4、P5
下面是我們梳理的B站國内版、B站國際版、海外遊戲賬密登入的流程節點,其中紅色是差異實作,缺失的節點空實作代替
序号 | B站國内版 | 海外遊戲 | B站國際版 |
1 | 入參校驗 | ||
2 | 驗證碼校驗(極驗) | 圖形驗證碼 | |
3 | 登入次數校驗 | ||
4 | 使用者名密碼校驗(相容二次号邏輯) | 使用者名密碼校驗 | 使用者名密碼校驗 |
5 | 驗證登入管控 | 空實作 | |
6 | 驗證風控 | 空實作 | 空實作 |
7 | 驗證常用裝置 | 空實作 | 空實作 |
8 | 生成token | ||
9 | 傳回Response |
注:很多時候多租戶之間是沒有差異的,我們為了防止過度設計,我們沒有從一開始就抽象了過多流程節點的接口,而是通過演進的方式遇到有差異時再将流程節點抽象成接口
外部依賴差異
不同租戶所處的實體環境不同,依賴的外部資源也可能有差異,比如我們token資訊B站國内版存儲到taishan (kv存儲)裡,但是海外遊戲台灣、日韓是隻能存儲在mysql,如何用同一套代碼做相容?
我們的方案是把所有資料的操作、以及外部依賴操作如果有差異都抽象出不同的接口,如上面所說的token資訊的存儲差異,我們會抽象出資料持久化的接口,分别有mysql、taishan(kv存儲)的實作,在執行資料操作時,根據配置選擇不同的接口實作來完成操作,領域服務層完全不需要關心存儲細節。
典型類圖
我們在需要差異化實作的地方抽象接口,然後在Proxy實作中根據租戶進行路由選擇,那Proxy是如何選擇的呢?就需要介紹我們的配置化接入
配置化接入
之前我們接入一個新的賬号體系時,我們會申請新的服務樹,拷貝一份代碼倉庫,在新的代碼倉庫上個性化改造,申請一整套資源然後部署。這樣做接入成本很高,同時也不容易沉澱能力。多租戶賬号系統我們把賬号體系的接入轉換為租戶的接入,接入時主要有以下兩種方式接入
獨立部署
如果考慮到此賬号體系QPS、使用者數量、服務等級都比較高,服務要求嚴格重保,我們會采用獨立部署的政策,獨享服務樹和外部資源,但是代碼倉庫隻有一套,如果有個性化邏輯,隻需要按照我們上面所說的差異化實作方案來進行實作就好了
共享部署
如果考慮到此賬号體系QPS、使用者數量、服務等級都比較低,我們可以多個賬号體系公用一套資源,這樣我們的接入成本會更低,隻需要更改配置就可以快速接入,具體配置如下
- 我們把所有的外部資源都定義到一個map對象裡,如配置中的DB、Redis
- 然後定義租戶配置,租戶配置也是一個map對象,key是租戶key,value就是具體DB、Redis、流程節點的選擇
# db資料源配置
[db]
[db.intl]
addr = "172.0.0.1:5805"
dsn = "bstar:xxxxxxxxxxxx@tcp(172.0.0.1:5805)/intl"
active = 10
[db.main]
addr = "172.0.0.1:5062"
dsn = "main:xxxxxxxxxxxx@tcp(172.0.0.1:5062)/main"
active = 10
# redis資料源配置
[redis]
[redis.intl]
addr = "172.0.0.1:7101"
[redis.main]
addr = "172.0.0.1:7102"
# 租戶配置化
[tenant]
defaultKey = "main"
[tenant.configs.main.domainService]
"TokenService" = "TokenServiceMain" # 選擇具體tokenService的實作
[tenant.configs.main.daoService]
"TokenPersistence" = "KvToken" # 選擇不同的存儲實作
[tenant.configs.main.dao]
db = "main" # 選擇庫,可以做庫次元資料隔離
table = "" # 選擇不同的表名字尾,可以做表次元的資料隔離
redis = "main" # 選擇目前租戶使用的redis資源
[tenant.configs.intl.domainService]
"TokenService" = "TokenServiceBstar"
[tenant.configs.intl.daoService]
"TokenPersistence" = "DbToken"
[tenant.configs.intl.dao]
db = "intl"
table = "intl"
redis = "intl"
如果沒有新的差異化實作,新增租戶時,隻要在配置中增加租戶配置就可以了,不管是獨立部署還是共享部署,都大大降低了接入成本。
系統灰階方案
全新的多租戶系統完成之後,我們就要考慮如何完成新老系統的遷移工作,針對于遷移我們給自己定了幾個明确的目标:
- 安全:資料不丢,可灰階、可復原
- 範圍可控:上遊無感覺,減少項目依賴方
- 可監控:盡早感覺因灰階導緻的資料問題,提早做人工幹預
我們整體遷移方案如下:
路由
上遊系統完全無感覺,繼續調用老服務,在老服務做路由控制,可以根據接口類型做不同的灰階方式。灰階政策可以根據白名單、百分比進行灰階
雙向同步
新老DB通過binlog雙向資料同步,保障新老資料準實時最終一緻,保障可灰階、可復原,新老系統業務邏輯完全不感覺灰階狀态。同時因為全新設計的新的資料模型和老系統資料模型有很大的差異,同步邏輯要做差異轉換
回環問題
資料雙向同步就帶來資料回環的問題,通常大家熟知的資料傳輸服務,比如A-B 兩個庫互相同步,一般是監聽到A有事務同步到B,在寫入B庫的同時也在事務中增加一個固定辨別,那麼資料傳輸服務監聽到B的事務時會判斷一下是否有此固定辨別,如果有就不再同步到A了。
但是我們通過監聽canal消息隻能獲得到最新的資料,在A産生一個binlog,同步到B之後也會産生一個新的binlog,如果仍然同步這條binlog的話,就可能産生無限循環。最後我們主要總結如下三類循環為例:
資料更新産生循環:
如下圖,此為典型的資料回環,如果不加以處理,seq_1 會無限循環更新下去
解決方案:
我們的方案是所有需要同步的表我們新增字段
sync_time` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '同步判斷時間';
當監聽到binlog消息後我們會判斷binlog消息中的sync_time 是否大于目前資料的 sync_time,如果大于執行更新
update user_reg_origin set guest=?,join_ip=?,join_time=?,ctime=?,mtime=?,sync_time=? where mid=? and sync_time<?;
插入/删除産生循環:
在短時間内出現一次插入和删除操作,如果insert的消息沒有同步結束,這個時候立即删除資料就可能會出現新、舊庫不斷插入、删除的無限循環。如下圖,第2步如果監聽的新庫的insert seq_1 消息時,舊庫資料已經被删除,那麼再次插入就能插入成功,然後又會同步到已被删除的新庫,出現循環
解決方案:
我們把每一條binlog都作為一條資料變更消息,這條消息處理完成之後增加redis一個辨別,監聽一條binlog消息後,我們查詢反方向這條消息是否被處理過,如果被處理過就直接丢棄,如下圖
新系統軟删除舊系統硬删除、
新系統硬删除舊系統軟删除産生的循環
因為在執行更新操作同步的時候發現如果沒有資料,我們就把更新之後的資料進行插入操作,如果瞬間再來一次删除,那麼就會出現類似上面“插入/删除”循環的場景。
解決方案:
我們把軟删除操作轉換成對應delete和insert操作,後續政策和“插入/删除産生循環”解決方案一緻
資料核對
為了及時發現新老系統資料不一緻,我們增加了核對邏輯,主要有兩種
資料變動增量核對:監聽binlog消息,然後查詢新、老庫資料是否一緻
查詢接口傳回核對:針對關鍵接口查詢時會查詢新老系統,進行比對
核對不一緻的資料我們通過監控告警及時發現,但是考慮到資料修複的風險,我們并沒有做自動修複,而是通過人工确認之後手工修複資料
未來展望
在目前公司的基礎設施環境下,通過Canal來做資料雙向同步确實比較複雜,随着公司DTS的建設,後續會考慮把此類同步通過DTS完成,就不需要再考慮資料回環的問題了
總結
多租戶的架構更新替換現有的老系統并讓業務完全無感覺是一個高風險的事情,猶如行駛中的飛機換引擎,并且在海外遊戲的替換過程出現了很多我們預期之外的問題,我和我的小夥伴們總結沉澱了問題解決方案,也為我們後續接入B站國内版、B站國際版等業務帶來了更多的借鑒和參考。在此期間我們也接入了多個賬号體系,順利并高效地完成了賬号能力的支撐工作
本期作者
韓建凱 - 哔哩哔哩資深開發工程師
來源:微信公衆号:哔哩哔哩技術
出處:https://mp.weixin.qq.com/s/7I6q07wVNMJ6UhFWzmKN_g