Colder框架硬核更新(Sharding+IOC)

寒空飛箭發表於2019-06-24

目錄

引言

前方硬核警告:全文乾貨11000+字,請耐心閱讀
遙想去年這個時候,差不多剛剛畢業,如今正式工作差不多一年了。Colder開源快速開發框架從上次版本釋出至今差不多有三個月了,Github的星星5個版本框架總共也有近800顆,QQ群從最初的一個人發展到現在的500人(吐槽下,人數上限了,太窮開不起SVIP,所以另開了一個,群號在文章末),這都是大家共同發展的結果,本框架能夠幫助到大家鄙人就十分開心。但是,技術是不斷髮展的,本框架也必須適應潮流,不斷升級才能夠與時俱進,在實際意義上提高生產力。本系列框架從原始雛形(鄙人畢業設計)=>.NET45+Easyui=>.NET Core2.1+Easyui=>.NET45+AdminLTE=>.NET Core2.1+AdminLTE,這其中都是根據實際情況不斷升級。例如鄙人最初的畢業設計搭建了框架的雛形(倉儲層不夠完善、介面較簡陋),並不適合實際的生產開發,因此使用Easyui作為前端UI框架(控制元件豐富,使用簡單),後又由於.NET Core的發展迅速,已經發展到2.0,其基礎類庫元件也相對比較成熟了,因此從.NET45遷移到.NET Core。後來發現Easyui的樣式比較落後,給人一種過時古老的感覺,故而又將前端UI改為基於Bootstrap的AdminLTE,比較成熟主流並且開源。
但是,新的要求又出現了:

  • 由於沒有使用IOC導致各個類通過New導致的強耦合問題
  • 資料庫大資料量如何處理的問題
    因此,本次版本更新主要就是為了解決上述的問題,即全面使用Autofac作為IOC容器實現解耦以及資料庫讀寫分離分庫分表(Sharding)支援。下面將分別介紹。
    這次更新.NET45版本與.NET Core版本同步更新:
.NET版本 前端UI 地址
Core2.2 AdminLTE https://github.com/Coldairarrow/Colder.Fx.Core.AdminLTE
.NET4.52 AdminLTE https://github.com/Coldairarrow/Colder.Fx.Net.AdminLTE

控制反轉

IOC(DI),即控制反轉(依賴注入),相關概念大家應該都知道,並且大多數人應該都已經運用於實際。我就簡單描述下,簡單講就是面向介面程式設計,通過介面來解除類之間的強耦合,方便開發維護測試。這個概念在JAVA開發中應該比較普遍,因為有Spring框架的正確引導,但是在.NET中可能開發人員的相關意識就沒那麼強,JAVA與.NET我這裡不做評價,但是作為技術人員,天生就是不斷學習的,好的東西當然要學習,畢竟技多不壓身。

在.NET 領域中IOC框架主流有兩個,即Autofac與Unity,這兩個都是優秀的開源框架,經過一番考量後我最終選擇了更加主流的(星星更多)Autofac。

關於Autofac的詳細使用教程請看官方文件https://autofac.org/,我這裡主要介紹下整合到本框架的思路以及用法。
傳統使用方法通過手動註冊具體的類實現某介面,這種做法顯然不符合實際生產需求,需要一種自動註冊的方式。本框架通過定義兩個介面類:IDependency與ICircleDependency來作為依賴注入標記,所有需要使用IOC的類只需要繼承其中一個介面就好了,其中IDependency是普通注入標記,支援屬性注入但不支援迴圈依賴,ICircleDependency是迴圈依賴注入標記,支援迴圈依賴,實際使用中按需選擇即可。下面程式碼就是自動註冊的實現:

var builder = new ContainerBuilder();

var baseType = typeof(IDependency);
var baseTypeCircle = typeof(ICircleDependency);

//Coldairarrow相關程式集
var assemblys = BuildManager.GetReferencedAssemblies().Cast<Assembly>()
    .Where(x => x.FullName.Contains("Coldairarrow")).ToList();

//自動注入IDependency介面,支援AOP
builder.RegisterAssemblyTypes(assemblys.ToArray())
    .Where(x => baseType.IsAssignableFrom(x) && x != baseType)
    .AsImplementedInterfaces()
    .PropertiesAutowired()
    .InstancePerLifetimeScope()
    .EnableInterfaceInterceptors()
    .InterceptedBy(typeof(Interceptor));

//自動注入ICircleDependency介面,迴圈依賴注入,不支援AOP
builder.RegisterAssemblyTypes(assemblys.ToArray())
    .Where(x => baseTypeCircle.IsAssignableFrom(x) && x != baseTypeCircle)
    .AsImplementedInterfaces()
    .PropertiesAutowired(PropertyWiringOptions.AllowCircularDependencies)
    .InstancePerLifetimeScope();

//註冊Controller
builder.RegisterControllers(assemblys.ToArray())
    .PropertiesAutowired();

//註冊Filter
builder.RegisterFilterProvider();

//註冊View
builder.RegisterSource(new ViewRegistrationSource());

//AOP
builder.RegisterType<Interceptor>();

var container = builder.Build();
DependencyResolver.SetResolver(new AutofacDependencyResolver(container));

AutofacHelper.Container = container;

程式碼中有相關注釋,使用方法推薦使用建構函式注入:
Colder框架硬核更新(Sharding+IOC)
框架已在Business層與Web層全面使用DI,Util層、DataRepository層與Entity層不涉及業務邏輯,因此不使用DI。

讀寫分離分庫分表

前面的IOC或許沒啥可驚喜的,但是資料庫讀寫分離分庫分表應該不會讓大家失望。接下來將闡述下框架支援Sharding的設計思路以及具體使用方法。

理論基礎

資料庫讀寫分離分庫分表(以下簡稱Sharding),這並不是什麼新概念,網上也有許多的相關資料。其根本就是為了解決一個問題,即資料庫大資料量如何處理的問題。

當業務規模較小時,使用一個資料庫即可滿足,但是當業務規模不斷擴大(資料量增大、使用者數增多),資料庫最終將會成為瓶頸(響應慢)。資料庫瓶頸主要有三種情況:資料量不大但是讀寫頻繁資料量大但是讀寫不頻繁以及資料量大並且讀寫頻繁

首先,為了解決資料量不大但是讀寫頻繁導致的瓶頸,需要使用讀寫分離,所謂讀寫分離就是將單一的資料庫分為多個資料庫,一些資料庫作為寫庫(主庫),一些資料庫作為讀庫(從庫),並且開啟主從複製(實時將寫入的資料同步到從庫中),這樣將資料的讀寫分離後,將原來單一資料庫使用者的讀寫操作分散到多個資料庫中,極大的降低了資料庫壓力,並且打多數情況下讀操作要遠多於寫操作,因此實際運用中大多使用一主多從的模式。

其次,為了解決資料量大但是讀寫不頻繁導致的瓶頸,需要使用分庫分表。其實思想也是一樣的,即分而治之,一切複雜系統都是通過合理的拆分從而有效的解決問題。分庫分表就是將原來的單一資料庫拆分為多個資料庫,將原來的一張表拆分為多張表,這樣表的資料量就將下來了,從而解決問題。但是,拆表並不是胡亂拆的,隨便拆到時候資料都找不到,那還怎麼玩,因此拆表需要按照一定的規則來進行。最簡單的拆表規則,就是根據Id欄位Hash後求餘,這種方式使用簡單但是擴容很麻煩(絕大多數都需要遷移,工作量巨大,十分麻煩),因此大多用於基本無需擴容的業務場景。後來經過一番研究後,發現可以使用雪花Id(分散式自增Id)來解決問題,雪花Id中自帶了時間軸,因此在擴容時可以根據時間段來判斷具體的分片規則,從而擴容時無需資料遷移,但是存在一定程度上的資料熱點問題。最後,找到了葵花寶典-一致性雜湊,關於一致性雜湊的理論我這裡就不獻醜了,相關資料網上一大把。一致性雜湊從一定程度上解決了普通雜湊的擴容問題與資料熱點問題,框架也支援使用一致性雜湊分片規則。

最後,就是大BOSS,大資料量與大訪問量,很簡單隻需要結合讀寫分離與分庫分表即可,下表是具體業務場景與採用方案的關係
| 資料量\訪問量 | | |
|-|-|-|
|| 無| 讀寫分離 |
| | 分庫分表 |讀寫分離分庫分表|

設計目標

首先定一個小目標(先賺他一個億):支援多種資料庫,使用簡單,業務升級改動小。
有了目標就需要調查業界情況,實現Sharding,市面上主要分為兩種,即使用中介軟體與客戶端實現。

現狀調研

中介軟體的優點是對客戶端透明,即對於客戶端來講中介軟體就是資料庫,因此對於業務改動影響幾乎沒有,但是對中介軟體的要求就很高,目前市面上比較主流成熟的就是mycat,其對MySQL支援比較好,但是對於其他資料庫支援就比較無力(個人測試,沒有深入研究,若有不妥請不要糾結),並且不支援EF,此方案行不通。其它型別資料庫也有對應的中介軟體,但是都並不如意,自己開發更不現實,因此使用中介軟體方案行不通。

既然中介軟體行不通,那就只能選擇客戶端方案了。目前在JAVA中有大名鼎鼎的Sharding-JDBC,瞭解了下貌似很牛逼,可惜.NET中並沒有Sharding-NET,但是有FreeSql,粗略瞭解了下是一個比較強大ORM框架,但我的框架原來底層是使用EF的,並且EF是.NET中主流的ORM框架,整體遷移到FreeSql不現實,因此最終沒找到成熟的解決方案。

設計思路

最後終於到了最壞的情況,既沒有完美的中介軟體方案,又沒有現成的客戶端方案,怎麼辦呢?放棄是不可能的,這輩子都不可能放棄的,終於,內心受到了黨的啟發,決定另起爐灶(既然沒有現成的那就自己早造)、打掃乾淨屋子再請客(重構資料倉儲層,實現Sharding)、一邊倒(堅定目標不改變,不妥協),由於EF支援多種資料庫,已經對底層SQL進行了抽象封裝,因此決定基於EF打造一套讀寫分離分庫分表方案。

資料庫讀寫分離實現:讀寫分離比較簡單,在倉儲介面中已經明確定義了CRUD操作介面,其中增、刪、改就是指寫操作,寫的時候按照具體的讀寫規則找到具體的寫庫進行寫操作即可,讀操作(查資料)按照具體的讀規則找到具體的讀庫進行讀即可。

資料庫分庫分表:分庫還好說,使用不同的資料庫即可,分表就比較麻煩了。首先實現分表的寫操作,可以根據分片規則能夠找到具體的物理表然後進行操作即可,實現比較容易。然後實現分表的讀操作,這個就比較麻煩了,就好比前面的都是鬥皇以下的在小打小鬧,而這個卻是鬥帝(騎馬),但是,作為一名合格的攻城獅是不怕鬥帝的,遇到了困難不要慌,需要冷靜思考處理。前面提到過,解決複雜問題就是一個字“”,首先聯表查詢就直接不考慮支援了(大資料量進行笛卡爾積就是一種愚蠢的做法,怎麼優化都沒用,物理資料庫隔絕聯表不現實,實現難度太大放棄)。接下來考慮最常用的方法:分頁查詢、資料篩選、最大值、最小值、平均值、資料量統計,EF中查詢都是通過IQueryable介面實現的,IQueryable中主要包括了資料來源(特定表)與關聯的表示式樹Expression,通過考慮將資料來源與關聯的表示式樹移植到分表的IQueryable即可實現與抽象表相同的查詢語句,最後將併發多執行緒查詢分表的資料通過合併演算法即可得到最終的實際資料。想法很美好,現實很殘酷,下面為大家簡單闡述下實現過程,可以說是過五關斬六將

實現之過五關斬六將

動態物件

首先考慮分表的寫操作,傳統用法都有具體的實體型別進行操作,但是分表時,例如Base_UnitTest_0、Base_UnitTest_1、Base_UnitTest_2,這些表全部儲存為實體類不現實,因此需要一種非泛型方法,後來在EF的關鍵類DbContext中找到DbEntityEntry Entry(object entity)方法,通過DbEntityEntry可以實現資料的增刪改操作,又注意到傳入引數是object,由此猜測EF支援非泛型操作,即只需要傳入特定型別的object物件也行。例如抽象表是Base_UnitTest,實際需要對映到表Base_UnitTest_0,那麼怎樣將Base_UnitTest型別的物件轉換成Base_UnitTest_0型別的物件?經過查閱資料,可以通過System.Reflection.Emit名稱空間下的TypeBuilder在執行時建立動態型別,即可以在執行時建立Base_UnitTest_0型別,該型別擁有與Base_UnitTest完全一樣的屬性(因為表結構完全一樣),建立了需要的型別,接下來只需要通過Json.NET將Base_UnitTest物件轉為Base_UnitTest_0即可。實現到這裡,原以為會順利成功,但是並沒有那麼簡單,EF直接報錯“上下文不包含模型Base_UnitTest_0”,這明顯就是模型的問題了,接下來進入下一關:EF動態模型快取

動態模型快取

通常都是通過繼承DbContext重寫OnModelCreating方法來註冊實體模型,這裡有個坑就是OnModelCreating只會執行一次,並最終生成DbCompiledModel然後將其快取,後續建立的DbContext就會直接使用快取的DbCompiledModel,由於最初註冊實體模型的時候只有抽象型別Base_UnitTest,所有後續在使用Base_UnitTest_0物件的時候會報錯。為了解決這個問題,需要自己管理DbCompiledModel快取,實現過程比較麻煩,這裡就不詳細分析了,有興趣的直接看原始碼即可。將快取問題解決後,終於成功的實現了Base_UnitTest_0的增刪改,這時,心裡一喜(有戲)。實現了寫操作(增、刪、改)之後,接下來就是實現查詢了,那麼如何實現查詢呢?EF中查詢操作都是通過IQueryable介面實現的,IQueryable中包括了具體資料表的資料來源和關聯的查詢表示式樹,那麼如何將IQueryable < Base_UnitTest >轉換為IQueryable < Base_UnitTest_0 > 並且保留原始查詢語句就成了關鍵問題。

資料來源移植

根據經驗,想一舉同時移植資料來源與表示式樹應該不現實,實際情況也是如此,移植資料來源,通過使用ExpressionVisitor可以找到根資料來源,其實是一個ObjectQuery型別,並且在表示式樹中是以ConstantExpression存在,同樣通過ExpressionVisitor則可將原ObjectQuery替換為新的,實現過程省略10000字。

查詢表示式樹深度移植

資料來源移植後,別以為就大功告成了,接下來進入一個深坑(最難點),表示式樹移植,經過一番踩坑後發現,表示式樹中的所有節點都是樹狀結構,任何一個查詢(Where、OrderBy、Skip、Take等)在表示式樹中都是以一個節點存在,並且一級扣一級,也就是說你改了資料來源沒用,因為資料來源只是表示式樹的根節點,下面的所有子節點還都是原來的根節點發的牙,並不能使用,那怎樣才能用新資料來源構建與原資料來源一樣的表示式樹呢?經過如下分析:IQuryable中的所有操作都是MethodCallExpression一層一層包裹,那麼我從外到內剝開方法,然後再從內到外包裹新的資料來源,那不就模擬得一模一樣了嗎?(貌似有戲),想到先進後出腦子裡直接就蹦出了資料結構中的,強大的.NET當然支援棧了,經過一番操作(奮鬥幾個晚上),此處省略10000字,最終完成IQueryable的移植,即從IQueryable < Base_UnitTest >轉換為IQueryable < Base_UnitTest_0 > 並且保留原始查詢語句。有了分表的IQueryable就能夠獲取分表的資料了,最後需要將獲取的分表資料進行合併。

資料合併演算法

分表後的資料合併演算法主要參考了網上的一些資料,雖然分庫分表的實現方式各不相同,但是思想都是差不多的,例如需要獲取Count,只需要將各個分表的Count求和即可,最大值只需要所有分表的最大值的最大值即可,最小值只需要所有分表最小值的最小值即可,平均值需要所有分表的和然後除以所有分表的資料條數即可。最後比較麻煩的就是分頁查詢,分頁查詢需要分表排序後獲取前N頁的所有資料(不能直接獲取某一頁的資料,因為不一定就是那一頁),最後將所有表的資料再進行分頁即可。實現到這裡,已經實現了增、刪、改、查了,看似革命已經成功,其實還有最後的大BOSS:事務支援

事務支援

因為分表很可能不在同一個資料庫中,因為普通的單庫事務顯然不能滿足需求,原本框架中已經有分散式事務支援(多庫事務),這裡需要整合到Sharding中,實現過程省略10000字,最終黃天不負有心人終於實現了。

到這裡,肯定有暴躁老哥坐不住了:你前面BBB那麼多,說得那麼牛逼,到底怎麼用啊???,若文章到此為止,估計就是下圖:

Colder框架硬核更新(Sharding+IOC)

鄙人則回覆如下:

Colder框架硬核更新(Sharding+IOC)

深夜12點了,放鬆一下,最後介紹如何使用

實際使用

本框架支援資料庫讀寫分離分庫分表(即Sharding),並且支援主流關係型資料庫(SQLServer、Oracle、MySQL、PostgreSQL),理論上只要EF支援那麼本框架支援。
由於技術原因以及結合實際情況,目前本框架僅支援單表的Sharding,即支援單表的CRUD、分頁、統計(數量、最大值、最小值、平均值),支援跨庫(表分散在不同的資料庫中,不同型別資料庫也支援)。具體如何使用如下:

  • Sharding配置
    首先、要進行分庫分表操作,那麼必要的配置必不可少。配置程式碼如下:
ShardingConfigBootstrapper.Bootstrap()
    //新增資料來源
    .AddDataSource("BaseDb", DatabaseType.SqlServer, dbBuilder =>
    {
        //新增物理資料庫
        dbBuilder.AddPhsicDb("BaseDb", ReadWriteType.ReadAndWrite);
    })
    //新增抽象資料庫
    .AddAbsDb("BaseDb", absTableBuilder =>
    {
        //新增抽象資料表
        absTableBuilder.AddAbsTable("Base_UnitTest", tableBuilder =>
        {
            //新增物理資料表
            tableBuilder.AddPhsicTable("Base_UnitTest_0", "BaseDb");
            tableBuilder.AddPhsicTable("Base_UnitTest_1", "BaseDb");
            tableBuilder.AddPhsicTable("Base_UnitTest_2", "BaseDb");
        }, new ModShardingRule("Base_UnitTest", "Id", 3));
    });

上述程式碼中完成了Sharding的配置:
ShardingConfigBootstrapper.Bootstrap()在一個專案中只能執行一次,所以建議放到Application_Start中(ASP.NET Core中的Startup)
AddDataSource是指新增資料來源,資料來源可以看做抽象資料庫,一個資料來源包含了一組同型別的物理資料庫,即實際的資料庫。一個資料來源至少包含一個物理資料庫,多個物理資料庫需要開啟主從複製或主主複製,通過ReadWriteType(寫、讀、寫和讀)引數來指定資料庫的操作型別,通常將寫庫作為主庫,讀庫作為從庫。同一個資料來源中的物理資料庫型別相同,表結構也相同。
配置好資料來源後就可以通過AddAbsDb來新增抽象資料庫,抽象資料庫中需要新增抽象資料表。如上抽象表Base_UnitTest對應的物理表就是Base_UnitTest_0、Base_UnitTest_1與Base_UnitTest_2,並且這三張表都屬於資料來源BaseDb。分表配置當然需要分表規則(即通過一種規則找到具體資料在哪張表中)。
上述程式碼中使用了最簡單的取模分片規則
原始碼如下:
Colder框架硬核更新(Sharding+IOC)
可以看到其使用方式及優缺點。
另外還有一致性HASH分片規則
Colder框架硬核更新(Sharding+IOC)
雪花Id的mod分片規則
Colder框架硬核更新(Sharding+IOC)

上述的分片規則各有優劣,都實現IShardingRule介面,實際上只需要實現FindTable方法即可實現自定義分片規則。
實際使用中個人推薦使用雪花Id的mod分片規,這也是為什麼前面資料庫設計規範中預設使用雪花Id作為資料庫主鍵的原因(PS,之前版本使用GUID作為主鍵被各種嫌棄,這次看你們怎麼說)Colder框架硬核更新(Sharding+IOC)

  • 使用方式
    配置完成,下面開始使用,使用方式非常簡單,與平常使用基本一致
    首先獲取分片倉儲介面IShardingRepository
IShardingRepository _db = DbFactory.GetRepository().ToSharding();

然後即可進行資料操作:

Base_UnitTest _newData  = new Base_UnitTest
{
    Id = Guid.NewGuid().ToString(),
    UserId = "Admin",
    UserName = "超級管理員",
    Age = 22
};
List<Base_UnitTest> _insertList = new List<Base_UnitTest>
{
    new Base_UnitTest
    {
        Id = Guid.NewGuid().ToString(),
        UserId = "Admin1",
        UserName = "超級管理員1",
        Age = 22
    },
    new Base_UnitTest
    {
        Id = Guid.NewGuid().ToString(),
        UserId = "Admin2",
        UserName = "超級管理員2",
        Age = 22
    }
};
//新增單條資料
_db.Insert(_newData);
//新增多條資料
_db.Insert(_insertList);
//清空表
_db.DeleteAll<Base_UnitTest>();
//刪除單條資料
_db.Delete(_newData);
//刪除多條資料
_db.Delete(_insertList);
//刪除指定資料
_db.Delete<Base_UnitTest>(x => x.UserId == "Admin2");
//更新單條資料
_db.Update(_newData);
//更新多條資料
_db.Update(_insertList);
//更新單條資料指定屬性
_db.UpdateAny(_newData, new List<string> { "UserName", "Age" });
//更新多條資料指定屬性
_db.UpdateAny(_insertList, new List<string> { "UserName", "Age" });
//更新指定條件資料
_db.UpdateWhere<Base_UnitTest>(x => x.UserId == "Admin", x =>
{
    x.UserId = "Admin2";
});
//GetList獲取表的所有資料
var list=_db.GetList<Base_UnitTest>();
//GetIQPagination獲取分頁後的資料
var list=_db.GetIShardingQueryable<Base_UnitTest>().GetPagination(pagination);
//Max
var max=_db.GetIShardingQueryable<Base_UnitTest>().Max(x => x.Age);
//Min
var min=_db.GetIShardingQueryable<Base_UnitTest>().Min(x => x.Age);
//Average
var min=_db.GetIShardingQueryable<Base_UnitTest>().Average(x => x.Age);
//Count
var min=_db.GetIShardingQueryable<Base_UnitTest>().Count();
//事務,使用方式與普通事務一致
using (var transaction = _db.BeginTransaction())
{
    _db.Insert(_newData);
    var newData2 = _newData.DeepClone();
    _db.Insert(newData2);
    bool succcess = _db.EndTransaction().Success;
}

上述操作中表面上是操作Base_UnitTest表,實際上卻在按照一定規則使用Base_UnitTest_0~2三張表,使分片對業務操作透明,極大提高開發效率,基本達成了最初定製的小目標。
具體使用方式請參考單元測試原始碼:
"\src\Coldairarrow.UnitTests\DataRepository\ShardingTest.cs"

最後放上簡單的測試圖:300W的表分成三張100W的表後效果
Colder框架硬核更新(Sharding+IOC)
Colder框架硬核更新(Sharding+IOC)
看來功夫沒白費,效果明顯(還不快點贊

展望未來

結束也是是新的開始,版本後續計劃採用前後端完全分離方案,前端使用vue-element-admin,後端以.NET Core為主,傳統的.NET將逐步停止更新,敬請期待!
文章雖然結束了,但是技術永無止境,希望我的文件能夠幫助到大家。
深夜碼字,實屬不易,文章中難免會出現一些紕漏,一些觀點也不一定完全正確,還望各位大哥不吝賜教。
最後覺得文件不錯,請點贊,Github請星星,若有各種疑問歡迎進群交流:
QQ群1:373144077(已滿)
QQ群2:579202910

See You

相關文章