Discuz!NT資料庫讀寫分離方案

adagadadfafd發表於2012-08-09
目前在Discuz!NT這個產品中,資料庫作為資料持久化工具,必定在併發訪問頻繁且負載壓力較大的情況下成 為系統效能的‘瓶頸’。即使使用本地快取等方式來解決頻繁訪問資料庫的問題,但仍舊會有大量的併發請求要訪問動態資料,雖然 SQL2005及2008以上版本中效能不斷提升,查詢計劃和儲存過程執行得越來越高效,但最終還是 要面臨‘瓶頸’這一問 題。當然這也是許多大型網站不斷研究探索各式各樣的方案來有效降低資料訪問負荷的原 因, 其中的‘讀寫分離’方案就是一種被廣泛採用的方案。
Discuz!NT這個產品在其企業版中提供了對‘讀寫分離’機制的支援,使對CPU及記憶體消耗嚴重的操作(CUD)被 分離到一臺或幾臺效能很高的機器上,而將頻繁讀取的操作(select)放到幾臺配置較低的機器上,然後通過‘事務 釋出訂閱機制’,實現了在多個sqlserver資料庫之間快速高效同步資料,從而達到了將‘讀寫請求’按實際負載 情況進行均衡分佈的效果。

下面就簡要介紹一下其實現思路。注:有關資料同步的工具已在sqlserver中自帶了,可以參考http://www.cnblogs.com/daizhj/archive/2009/11/18/1605293.html

將相應的資料由Master(主)資料庫中‘釋出’出來,然後使用推送的方式(注:事務釋出可以指定是‘通過主 資料庫推送’ 還是‘訂閱伺服器去獲取’)傳送到訂閱它的資料庫中,就實現了資料同步功能。

下面就介紹一下如何通過改變既有程式碼來實現在‘幾個從資料庫(類似快照)’間進行讀取資料的負載均衡。

原有的程式碼中因為使用了分層機制,所以我們只要在‘資料訪問層’動一下心思就可以了。在這裡我的一個設 計思路就是不改變已有的資料庫訪問介面(包括引數等)的前提下,實現底層自動將現有的資料訪問操作進行負載 均衡。這樣做的好處不用多說了,同時也讓這個負載均衡功能與資料訪問層相分離,不要耦合的太緊密,同時如果不曉得底層 的實現原理也可以只通過一個開關(後面會介紹),就可以讓自己的sql語句自動實現動態負載均衡。

說到這裡,我來對照程式碼進一步闡述:

首先就是(Discuz.Data\DbHelper.cs)程式碼,主要變動如下(新增方法部分):

程式碼
/// <summary>
/// 獲取使用的資料庫(或快照)連結串
/// </summary>
/// <param name="commandText">儲存過程名或都SQL命令文字</param>
/// <returns></returns>
public static string GetRealConnectionString(string commandText)
{
if (DbSnapConfigs.GetConfig() != null && DbSnapConfigs.GetConfig().AppDbSnap)
{
commandText = commandText.Trim().ToLower();
if (commandText.StartsWith("select") || ((commandText.StartsWith(BaseConfigs.GetTablePrefix) && UserSnapDatabase(commandText))))
{
DbSnapInfo dbSnapInfo = GetLoadBalanceScheduling.GetConnectDbSnap();

if (DbSnapConfigs.GetConfig().RecordeLog && snapLogList.Capacity > snapLogList.Count)
snapLogList.Add(string.Format("{{'SouceID' : {0}, 'DbconnectString' : '{1}', 'CommandText' : '{2}', 'PostDateTime' : '{3}'}},",
dbSnapInfo.SouceID,
dbSnapInfo.DbconnectString,
commandText.Replace("'",""),
Discuz.Common.Utils.GetDateTime()));

return dbSnapInfo.DbconnectString;
}
}

return ConnectionString;
}


上面的方法將會對傳入的sql語句進行分析,找出其中是CUD操作還是SELECT操作,來區別是讀還是寫操作。而snapLogList列表則是之前所配置的‘事務釋出訂閱’模式下的相關‘從資料庫’(Slave Database)連結串的列表,例如(dbsnap.config檔案的DbSnapInfoList節點):



程式碼
<?xml version="1.0"?>
<DbSnapAppConfig xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<AppDbSnap>true</AppDbSnap>
<WriteWaitTime>1</WriteWaitTime>
<LoadBalanceScheduling>RoundRobinScheduling</LoadBalanceScheduling> --WeightedRoundRobinScheduling
<RecordeLog>false</RecordeLog>
<DbSnapInfoList>
<DbSnapInfo>
<SouceID>1</SouceID>
<Enable>true</Enable>
<DbconnectString>Data Source=DAIZHJ\DNT_DAIZHJ;User ID=sa;Password=123123;Initial Catalog=dnt_snap;Pooling=true</DbconnectString>
<Weight>4</Weight>
</DbSnapInfo>
<DbSnapInfo>
<SouceID>2</SouceID>
<Enable>true</Enable>
<DbconnectString>Data Source=DAIZHJ-PC\2222;User ID=sa;Password=123;Initial Catalog=tabletest;Pooling=true</DbconnectString>
<Weight>3</Weight>
</DbSnapInfo>
</DbSnapInfoList>
</DbSnapAppConfig>


有關相應配置節點和負載均衡演算法會在後面提到,這裡為了保持文章內容的連續性暫且跳過,下面接著瀏覽一下上面呼叫的‘UserSnapDatabase’方法:



程式碼
/// <summary>
/// 是否使用快照資料庫
/// </summary>
/// <param name="commandText">查詢</param>
/// <returns></returns>
private static bool UserSnapDatabase(string commandText)
{
// 如果上次重新整理cookie間隔小於5分鐘, 則不重新整理資料庫最後活動時間
if (commandText.StartsWith(BaseConfigs.GetTablePrefix + "create"))
{
Utils.WriteCookie("JumpAfterWrite", Environment.TickCount.ToString());
return false;
}
else if (!String.IsNullOrEmpty(Utils.GetCookie("JumpAfterWrite")) && (Environment.TickCount - TypeConverter.StrToInt(Utils.GetCookie("JumpAfterWrite"), Environment.TickCount)) < DbSnapConfigs.GetConfig().WriteWaitTime * 1000)
return false;
else if (!commandText.StartsWith(BaseConfigs.GetTablePrefix + "get"))
return false;

return true;
}

該方法的作用很簡單,就是當資料庫有CUD操作時,通過寫cookie的方式向客戶端寫一個鍵值‘JumpAfterWrite’,這個鍵值很重要,就是提供一個標籤(flag)來指示:‘當前使用者執行cud操作時,頁面跳轉到其它頁面而主資料庫還沒來得及將資料推送到從資料庫’這一情況而造成的‘資料不同步’問題。
舉了例子,當在一個版塊中‘發表主題’後系統自動跳轉到‘顯示該主題頁面’時,如果主資料庫中插入了一個新主題而從資料庫沒有被及時更新這一主題資訊時,就會報‘主題不存在’這個錯誤。所以這裡加了一個設定,就是下面這一行:
(Environment.TickCount - TypeConverter.StrToInt(Utils.GetCookie("JumpAfterWrite"), Environment.TickCount)) < DbSnapConfigs.GetConfig().WriteWaitTime * 1000)




它所做的就是確保使用者cud操作之後,在規定的時間內還是訪問主資料庫,當時間超過時,才將當前使用者的訪問請求(select)均衡到其它從資料庫中。

當然,在GetRealConnectionString()方法中,還有一行程式碼很重要,就是下面這一行:

DbSnapInfo dbSnapInfo = GetLoadBalanceScheduling.GetConnectDbSnap();




它的作用就是載入配置檔案資訊,其中最主要的就是相應的‘負載均衡演算法例項’來獲取相應的從資料庫連結串,下面先看一
下‘靜態屬性’GetLoadBalanceScheduling的相關資訊:



程式碼
/// <summary>
/// 負載均衡排程介面
/// </summary>
private static ILoadBalanceScheduling m_loadBalanceSche;
/// <summary>
/// 初始化負載均衡排程介面例項
/// </summary>
private static ILoadBalanceScheduling GetLoadBalanceScheduling
{
get
{
if (m_loadBalanceSche == null)
{
try
{
m_loadBalanceSche = (ILoadBalanceScheduling)Activator.CreateInstance(Type.GetType(string.Format("Discuz.EntLib.{0}, Discuz.EntLib", DbSnapConfigs.GetConfig().LoadBalanceScheduling), false, true));
}
catch
{
throw new Exception("請檢查config/dbsnap.config中配置是否正確");
}
}
return m_loadBalanceSche;
}
}






它主要是通過反射的方法將Discuz.EntLib.dll檔案中的相應負載均衡演算法例項進行繫結,然後以m_loadBalanceSche這個靜態變數進行儲存,而m_loadBalanceSche本身就是ILoadBalanceScheduling介面變數,該介面即是相應負載均衡演算法的實現介面。同樣因為文章內容的連續性,這裡先不深挖相應的實現演算法,我會在後面進行介紹。下面再來看一下GetRealConnectionString()中還有一段程式碼,如下:

程式碼
if (DbSnapConfigs.GetConfig().RecordeLog && snapLogList.Capacity > snapLogList.Count)
snapLogList.Add(string.Format("{{'SouceID' : {0}, 'DbconnectString' : '{1}', 'CommandText' : '{2}', 'PostDateTime' : '{3}'}},",
dbSnapInfo.SouceID,
dbSnapInfo.DbconnectString,
commandText.Replace("'",""),
Discuz.Common.Utils.GetDateTime()));

return dbSnapInfo.DbconnectString;

上面程式碼將當前的負載均衡得到的連結串儲存到一個snapLogList列表中,該列表宣告如下:

List<string> snapLogList = new List<string>(400)


為什麼要提供這個列表並進行記錄?主要是為了考查負載均衡演算法的工作情況,因為在資料訪問層獲取相應連結串資訊並進行記錄很不方便,所以我用這個變數記錄大約400條‘負載均衡’資料連結串,以便在相應的Discuz.EntLib.ToolKit工具包中進行觀察,監視其‘工作情況’。這裡我們只要知道通過GetRealConnectionString()方法就實現了對sql語句或儲存過程進行分析並進行負載均衡的效果了(注:該操作可能會耗時,所以在DbSnapConfigs中提供了一個開關‘RecordeLog’來進行控制,後面會介紹)。


下面再來簡單介紹一下,如何改造DbHelper.cs中原有方法,使其支援負載均衡功能。這裡強調一點,就是:

GetRealConnectionString()方法只是造了一個房子,裡面的傢俱還是要自己搬。

而傢俱就是那些老的方法,比如:


程式碼
public static object ExecuteScalar(DbConnection connection, CommandType commandType, string commandText, params DbParameter[] commandParameters)
{
if (connection == null) throw new ArgumentNullException("connection");

//connection.Close();
connection.ConnectionString = GetRealConnectionString(commandText);//負載均衡改造完成的方法
connection.Open();

// 建立DbCommand命令,並進行預處理
DbCommand cmd = Factory.CreateCommand();

bool mustCloseConnection = false;
PrepareCommand(cmd, connection, (DbTransaction)null, commandType, commandText, commandParameters, out mustCloseConnection);

// 執行DbCommand命令,並返回結果.
object retval = cmd.ExecuteScalar();

// 清除引數,以便再次使用.
cmd.Parameters.Clear();

if (mustCloseConnection)
connection.Close();

return retval;
}

上面的 ‘connection.ConnectionString =’之前繫結的ConnectionString這個靜態屬性,而這個屬性連結的就是‘主資料庫’,
這裡我們只要將GetRealConnectionString(commandText)賦值給它就可以了,還是那句話,在GetRealConnectionString()就實現了
資料庫連結串的負載均衡,呵呵。類似上面的變動在DbHelper.cs還有幾處,好在變化不太大,當然更不需要改變原有的資料訪問層
(比如IDataProvider.cs檔案)了。

其實本文中介紹的資料庫層負載均衡實現方法在MYSQL中早有相應的外掛實現了,參見這篇文章。


該文章中的LUA指令碼實現方式與本文類似,如下:


程式碼
--傳送所有的非事務性SELECT到一個從資料庫
if is_in_transaction == 0 and packet:byte() == proxy.COM_QUERY and packet:sub(2, 7) == "SELECT" then
local max_conns = -1 
local max_conns_ndx = 0 
for i = 1, #proxy.servers do 
local s = proxy.servers[i]
-- 選擇一個擁有空閒連線的從資料庫
if s.type == proxy.BACKEND_TYPE_RO and s.idling_connections > 0 then 
if max_conns == -1 or s.connected_clients < max_conns then 
max_conns = s.connected_clients 
max_conns_ndx = i 
end 
end
end
.....

接著,我再介紹一下相應的配置檔案和負載均衡演算法的實現情況:)


配置檔案(比如:Discuz.EntLib.ToolKit\config\dbsnap.config):


程式碼
<?xml version="1.0"?>
<DbSnapAppConfig xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<AppDbSnap>true</AppDbSnap>
<WriteWaitTime>1</WriteWaitTime>
<LoadBalanceScheduling>RoundRobinScheduling</LoadBalanceScheduling> --WeightedRoundRobinScheduling
<RecordeLog>false</RecordeLog>
<DbSnapInfoList>
<DbSnapInfo>
<SouceID>1</SouceID>
<Enable>true</Enable>
<DbconnectString>Data Source=DAIZHJ\DNT_DAIZHJ;User ID=sa;Password=123123;Initial Catalog=dnt_snap;Pooling=true</DbconnectString>
<Weight>4</Weight>
</DbSnapInfo>
<DbSnapInfo>
<SouceID>2</SouceID>
<Enable>true</Enable>
<DbconnectString>Data Source=DAIZHJ-PC\2222;User ID=sa;Password=123;Initial Catalog=tabletest;Pooling=true</DbconnectString>
<Weight>3</Weight>
</DbSnapInfo>
<DbSnapInfo>
<SouceID>3</SouceID>
<Enable>true</Enable>
<DbconnectString>Data Source=DAIZHJ-PC\333333;User ID=sa;Password=123;Initial Catalog=tabletest;Pooling=true</DbconnectString>
<Weight>2</Weight>
</DbSnapInfo>
<DbSnapInfo>
<SouceID>4</SouceID>
<Enable>true</Enable>
<DbconnectString>Data Source=DAIZHJ-PC\44444444;User ID=sa;Password=123;Initial Catalog=tabletest;Pooling=true</DbconnectString>
<Weight>2</Weight>
</DbSnapInfo>
</DbSnapInfoList>
</DbSnapAppConfig>


上面的DbSnapInfoList就是相應的slave資料庫連結列表,其中它的相應節點資訊說明如下(Discuz.Config\DbSnapInfo.cs):

程式碼
[Serializable]
public class DbSnapInfo 
{
/// <summary>
/// 源ID,用於唯一標識快照在資料庫負載均衡中的資訊
/// </summary>
private int _souceID;
/// <summary>
/// 源ID,用於唯一標識快照在資料庫負載均衡中的資訊
/// </summary>
public int SouceID
{
get { return _souceID; }
set { _souceID = value; }
}

/// <summary>
/// 快照是否有效
/// </summary>
private bool _enable; 
/// <summary>
/// 是否有效
/// </summary>
public bool Enable
{
get { return _enable; }
set { _enable = value; }
}

/// <summary>
/// 快照連結
/// </summary>
private string _dbConnectString;
/// <summary>
/// 快照連結
/// </summary>
public string DbconnectString
{
get { return _dbConnectString; }
set { _dbConnectString = value; }
}

/// <summary>
/// 權重資訊,該值越高則意味著被輪循到的次數越多
/// </summary>
private int _weight;
/// <summary>
/// 權重資訊,該值越高則意味著被輪循到的次數越多
/// </summary>
public int Weight
{
get { return _weight; }
set { _weight = value; }
}
}


當然DbSnapAppConfig作為DbSnapInfo列表的容器,其結構如下:

程式碼
[Serializable]
public class DbSnapAppConfig : Discuz.Config.IConfigInfo
{
private bool _appDbSnap;
/// <summary>
/// 是否啟用快照,如不使用,則即使DbSnapInfoList已設定有效快照資訊也不會使用。
/// </summary>
public bool AppDbSnap
{
get { return _appDbSnap; }
set { _appDbSnap = value; }
}

private int _writeWaitTime = 6;
/// <summary>
/// 寫操作等待時間(單位:秒), 說明:在執行完寫操作之後,在該時間內的sql請求依舊會被髮往master資料庫
/// </summary>
public int WriteWaitTime
{
get { return _writeWaitTime; }
set { _writeWaitTime = value; }
}

private string _loadBalanceScheduling = "WeightedRoundRobinScheduling";
/// <summary>
/// 負載均衡排程演算法,預設為權重輪詢排程演算法 http://www.pcjx.com/Cisco/zhong/209068.html
/// </summary>
public string LoadBalanceScheduling
{
get { return _loadBalanceScheduling; }
set { _loadBalanceScheduling = value; }
}

private bool _recordeLog = false;
/// <summary>
/// 是否記錄日誌
/// </summary>
public bool RecordeLog
{
get { return _recordeLog; }
set { _recordeLog = value; }
}


private List<DbSnapInfo> _dbSnapInfoList;
/// <summary>
/// 快照輪循列表
/// </summary>
public List<DbSnapInfo> DbSnapInfoList
{
get { return _dbSnapInfoList; }
set { _dbSnapInfoList = value; }
}
}

通過這兩個配置檔案,就可以實現對資料訪問層負載均衡的靈活配置了,不過上面的DbSnapAppConfig還有一個非常重要的
屬性沒有介紹清楚,就是‘LoadBalanceScheduling’,其介面宣告如下:



程式碼
/// <summary>
/// 負載均衡排程介面
/// </summary>
public interface ILoadBalanceScheduling
{
/// <summary>
/// 獲取應用當前負載均衡排程演算法下的快照連結資訊
/// </summary>
/// <returns></returns>
DbSnapInfo GetConnectDbSnap();
}


它就是負載均衡演算法的實現介面,為了便於說明在Discuz.EntLib中內建的兩個負載均衡演算法的實現情況,請先看下圖:


內建的兩個負載均衡演算法,一個是RoundRobinScheduling,即輪叫排程(Round Robin Scheduling)演算法,它的實現比較簡單,就是對從資料庫連結列表的依次遍歷,如下:

程式碼
/// <summary>
/// 輪叫排程(Round Robin Scheduling)演算法
/// </summary>
public class RoundRobinScheduling : ILoadBalanceScheduling
{
private static object lockHelper = new object();
/// <summary>
/// 當前的快照索引和權重資訊
/// </summary>
static int curentSnapIndex = 0;

static RoundRobinScheduling()
{}

public DbSnapInfo GetConnectDbSnap()
{
lock (lockHelper)
{
if (curentSnapIndex >= DbSnapConfigs.GetEnableSnapList().Count)
curentSnapIndex = (curentSnapIndex) % DbSnapConfigs.GetEnableSnapList().Count;

return DbSnapConfigs.GetEnableSnapList()[curentSnapIndex++];
}
}
}



而另一種負載均衡演算法就相對負載了,不過它也更符合實際的應用場景,它使用了權重的方法來讓效能優良的機器分到
更多的任務來均衡整個方案的效能,即權重輪詢排程演算法,實現程式碼如下:



程式碼
/// <summary>
/// 權重輪詢排程演算法 
/// http://www.pcjx.com/Cisco/zhong/209068.html 
/// http://id-phatman.spaces.live.com/blog/cns!CA763CA8DB2378D1!627.entry
/// </summary>
public class WeightedRoundRobinScheduling : ILoadBalanceScheduling
{
private static object lockHelper = new object();
/// <summary>
/// 快照的權重列表
/// </summary>
static List<int> snapWeightList = new List<int>();
/// <summary>
/// 當前的快照索引和權重資訊
/// </summary>
static int curentSnapIndex, currentWeight;
/// <summary>
/// 快照權重列表中最大的權重值和最大公約數
/// </summary>
static int maxWeight, gcd;

static WeightedRoundRobinScheduling()
{
curentSnapIndex = -1;
currentWeight = 0;

snapWeightList = GetSnapWeightList();
maxWeight = GetMaxWeight(snapWeightList);
gcd = GCD(snapWeightList);
}

/// <summary>
/// 獲取應用當前負載均衡排程演算法下的快照連結資訊
/// </summary>
/// <returns></returns>
public DbSnapInfo GetConnectDbSnap()
{
lock (lockHelper)
{
DbSnapInfo current = RoundRobinScheduling();
if (current != null)
return current;
else
return DbSnapConfigs.GetEnableSnapList()[0];
}
}

/// <summary>
/// 獲取快照權重的列表
/// </summary>
/// <returns></returns>
static List<int> GetSnapWeightList()
{
List<int> snapWeightList = new List<int>();

foreach (DbSnapInfo dbSnapInfo in DbSnapConfigs.GetEnableSnapList())
{
snapWeightList.Add(dbSnapInfo.Weight);
}
return snapWeightList;
}

/// <summary>
/// 權重輪詢排程演算法
/// </summary>
static DbSnapInfo RoundRobinScheduling()
{
while (true)
{
curentSnapIndex = (curentSnapIndex + 1) % DbSnapConfigs.GetEnableSnapList().Count;
if (curentSnapIndex == 0)
{
currentWeight = currentWeight - gcd;
if (currentWeight <= 0)
{
currentWeight = maxWeight;
if (currentWeight == 0)
return null;
}
}
if (DbSnapConfigs.GetEnableSnapList()[curentSnapIndex].Weight >= currentWeight)
return DbSnapConfigs.GetEnableSnapList()[curentSnapIndex];
}
}

/// <summary>
/// 獲取最大權重
/// </summary>
/// <param name="snapList"></param>
/// <returns></returns>
static int GetMaxWeight(List<int> snapWeightList)
{
int maxWeight = 0;
foreach (int snapWeight in snapWeightList)
{
if (maxWeight < snapWeight)
maxWeight = snapWeight;
}
return maxWeight;
}

/// <summary>
/// 獲取權重的最大公約數
/// </summary>
/// <returns></returns>
static int GCD(List<int> snapWeightList)
{
// 排序,得到數字中最小的一個 
snapWeightList.Sort(new WeightCompare());
int minNum = snapWeightList[0];

// 最大公約數肯定大於等於1,且小於等於最小的那個數。 
// 依次整除,如果餘數全部為0說明是一個約數,直到打出最大的那個約數 
int gcd = 1;
for (int i = 1; i <= minNum; i++)
{
bool isFound = true;
foreach (int snapWeight in snapWeightList)
{
if (snapWeight % i != 0)
{
isFound = false;
break;
}
}
if (isFound)
gcd = i;
}
return gcd;
}

/// <summary>
/// 實現IComparer介面,用於對數字列表進行排序
/// </summary> 
private class WeightCompare : System.Collections.Generic.IComparer<int>
{
public int Compare(int weightA, int weightB)
{
return weightA - weightB;
}
}
}


到這裡,主要的功能程式碼就介紹的差不多了,我們可以通過對dbsnap.config的相應節點配置,來靈活定製我們的負載均衡方案。同時,對一般開發者而言,這種架構是透明的,大家可以完全在不瞭解它的情況下開發自己的資料訪問功能,並通過相應開關來讓自己的程式碼支援均衡負載。

當然這個方案還有一些沒考慮到的問題比如:
1.對‘主從資料庫的健康度檢查’,即如果主或從資料庫出現故障的時候該如何處理,當然在sqlserver中還提供了映象功能來解決類似問題,所以它也可做為一個備選方案。

2.當主資料庫被髮布出去後,主資料庫的表和儲存過程就會被‘鎖定’,其不允許被再次修改了,所以還要繼續研究如何解決這一問題。



原文連結:http://www.cnblogs.com/daizhj/archive/2010/06/21/dbsnap_master_slave_database.html

相關文章