從企業級架構到互聯網架構遷移的工程實踐
摘要:隨著業務的快速增長,一個線上交易系統在有限的時間內,不但需要維持線上系統的穩定,還要支撐新需求的開發,否則將由于技術支撐不利錯失業務發展關鍵時間窗口。本文分享了一次從企業級架構到互聯網架構遷移的工程實踐。
因工作變動接手了一個云平臺改造項目,該項目屬于己經上線且每月有大量交易訂單的云平臺,之前采用的是SpringMVC+Hibernate+FreeMarker+MySql架構,集web前端和接口為一體。經過對業務增長趨勢的評估,預計將在數月之后無法支撐原有業務的增長。
當前架構主要存在如下問題:
1、擴展維護困難
2、性能逐漸緩慢。
隨著業務的快速增長逼迫我們對現有架構進行重構。由于是線上交易系統,留個我們改造的時間非常有限,不但需要維持線上系統的穩定還要支撐新需求的開發,否則將由于技術支撐不利錯失業務發展關鍵時間窗口,基于務實的原則我們制定如下步驟進行逐步改造。
一、去hibernate遷移至mybatis
從hibernate遷移至mybatis, DAO層基本上需要重寫一遍,其中主要工作量為理解原hibernate DAO層邏輯并翻譯成sql,主要是細心活。其中需要注意的是mybatis動態表名的傳入,需要將mapper的statementType類型修改為STATEMENT,并將SQL語句中#{}都改為${}。在使用${}傳參過程中,需要特別注意SQL注入攻擊危險。一般會在SpringMVC層將敏感字符轉義。比如 ">" 用“>”表示,網上有很多封裝函數,或者apache common lang包的StringEscapeUtils.escapeHtml()。
二、取掉sql之間的表關聯
去掉sql之間的表關聯,傳統關系型數據庫理論中的三范式在互聯網的數據庫模型中是不適用的,主要造成的問題是無法進行分表分庫。這就要求所有dao方法必須保持單表操作。
保持單表操作為分表改造奠定了改造基礎。
三、Service層對原子DAO業務邏輯進行組裝
在取掉表關聯后需要改造所有實體結構。首先取掉實體之間的一對一,多對一,多對多關聯關系,將實體之間的引用關系修改為對實體ID的引用。同時為了上層方便使用需要引入業務BO對象,在service層調用多個原子的dao方法并組裝成業務BO對象。
四、分表
在單張表超過2000萬條記錄后,mysql的查詢性能開始降低,表變更字段等待時間漫長。分表后提升性能和擴展性后又帶點以下問題:
-
路由策略的選擇。
-
如何根據主鍵、訂單號等路由到正確的表。
-
分表后分頁查詢
-
如何保證上線后分表數據平滑從老庫過渡到新庫。
1、路由策略的選擇
首先,我們對數據庫中所有的表記錄進行分析,統計每張表的數據量大小。經過統計后我們發現隨著業務的增長業務數據也會快速進行增長的表主要為訂單表、訂單明細表。其它的表在近兩年內并不會隨著業務的增長而快速增長。所以只需要對訂單表、訂單明細表進行拆分。
路由策略選擇不可能做到完美,世界本來也是不完美的,關鍵是在合適的階段選擇合適的策略,即能滿足商業戰略時間窗口點又能在追求技術完美型中尋找平衡點。我們預測了業務近5年的發展目標為現有業5倍的增長,發現按月進行拆分可以保證每月數據量均低于2000萬條,基于務實的原則我們選擇了按月進行分表的路由策略。
經過多方面考慮在能夠兼固效率和降低改造復雜度的思路提出老數據老辦法,新數據新辦法。老數據中的主鍵己經生成,如果按新的主鍵策略重新生成,會牽扯到所有關聯表中的ID都需要進行替換,這樣會增加改造的復雜度和工作量,所以最終考慮將新數據按照新的主鍵生成策略進行生成。當按月分表仍不能滿足業務支持要求時,可以再次以日信息計算更細粒度的拆分策略,例如可按周為單位進行表折分。
2、根據主鍵或訂單號選擇正確的表
主鍵的生成算法 自增ID的生成,參考推ter Snowflake算法
在Java語言系統中,可以通過Long來表示主鍵,Long類型包含64個位,正好可以存儲該ID, 1至41位的二進制數值用來表示日期時間戳,43至53位可以表示1024臺主機,我們可以為每臺API服務器分配一個工作機器ID,43至55位可以生成線程唯一的序列號。預留的工作機器ID可以作為南北雙活機房的路由判斷條件,如1,2,3,4號工作機器ID路由到北機房API服務器,5,6,7,8工作機號ID路由到南機房API服務器。
當按訂單號查詢時系統首先根據訂單號長度的不同,來選擇是路由到新的切分訂單表,還是路由到原訂單表。因為新主鍵ID會包含日期信息,系統會根據主鍵解讀出日期信息,根據月份的不同來選擇該數據庫對應的月份表,如果讀取不出日期信息就可以判斷出為原訂單表。
3、如何保證上線后分表數據平滑從老表過渡到新表
首先系統配置統一的分表切割時間公共變量,在插入訂單時先判斷是否在分表切換時間點之前,如果在分表切換時間點之前則將訂單數據插入到老表,否則將訂單數據按當前月份不同插入到新的拆分月表。
4、分表后分頁查詢
在訂單分表后存在的主要難點是分表后數據的分頁查詢操作。假設以2016-07-26 00:00:01 開始按月分表,查詢2016-05-01 00:00:01至2016-09-11 12:00:05期間的所有訂單分解為如下幾步:
(1)通過開始時間、結束時間、分表時間計算出需要的路由信息集合。
-
格式:表名|起始日期|結束日期
-
路由集合:
Order|2016-05-01 00:00:01|2016-07-26 00:00:01
Order_2016_07|2016-07-26 00:00:01|2016-07-01 23:59:59
Order_2016_08|2016-08-01 00:00:01|2016-08-31 23:59:59
Order_2016_09|2016-09-01 00:00:01|2016-09-11 23:59:59
(2)按分頁信息(pageNo,pageSize)及路由信息集合查詢訂單基本信息集合。
a.遍歷路由集合返回總記錄數及表概括信息集合。
i. 表概括信息定義:表名、起始行數、記錄數、路由信息;
ii. 表概括信息集合:
-
Order、1、137、Order|2016-05-0100:00:01|2016-07-26 00:00:01
-
Order_2016_07、137、10、Order_2016_07|2016-07-2600:00:01|2016-07-01 23:59:59
-
Order_2016_08、147、32、2016-08-0100:00:01|2016-08-31 23:59:59
-
Order_2016_09、179、10、2016-09-0100:00:01|2016-09-11 23:59:59
iii. 方法描述:
private RouteTableResult getRouteTableResult(OrderSearchModel searchModel ,List<String> routeTables ) {
Integer sumRow = new Integer(0);
Map<String, RouteTable> routeTableCountMap = new TreeMap<String,RouteTable>();
RouteTableResult routeTableResult = new RouteTableResult();
for (String routeTable : routeTables ) {
String[] routeTableArray = routeTable .split( "\\|" );
if ( routeTableArray . length == 3) {
String tableName =getTableByRouteTableAndSetSearchModel( searchModel , routeTableArray );
Integer orderCount = ticketOrderDao .searchOrderCount( tableName , searchModel );
Integer startIndex = sumRow .intValue();
RouteTable routeInfo = new RouteTable( startIndex , orderCount , routeTable );
routeTableCountMap .put( tableName , routeInfo );
sumRow += orderCount ;
}
}
routeTableResult .setRouteTableCountMap( routeTableCountMap );
routeTableResult .setSumRow( sumRow );
return routeTableResult ;
}
b.根據分頁信息,查詢出該分頁需要跨越的表路由信息集合,具體算法如下:
i. 遍歷概括信息集合
ii. 當開始行和結束行與當前路由區間有交集則說明有數據在該表內并將該表加入遍歷路由集合;
iii. 如果路由表信息集合中有數據且不滿足上述條件則退出;
iv. 返回需要跨越的表路由信息集合;
v. 方法描述:
private List<String> getRouteTables(OrderSearchModel searchModel ,Map<String,RouteTable> routeTableCountMap ) {
List<String> routeTableInfoList = new ArrayList<String>();
Integer startIndex = ( searchModel .getPageNo() - 1) * searchModel .getPageSize();
Integer endIndex = startIndex + searchModel .getPageSize() -1;
for (Entry<String, RouteTable> entry : routeTableCountMap .entrySet()) {
RouteTable routeTable = entry .getValue();
// 當 開始行和結束行與當間路由區有交集
if ( !( startIndex > routeTable .getEndIndex()) && !( endIndex < routeTable .getStartIndex())){
routeTableInfoList .add( routeTable .getRouteInfo());
// 如果路由表信息集合中有數據且不滿足上述條件則退出
} else if ( routeTableInfoList .size()>0) {
break ;
}
}
return routeTableInfoList ;
}
c.查詢該分頁下的訂單列表,具體算法如下:
i.首先設置最后一次遍歷的表為需要跨越路由信息集合的第一張表;
ii.設置己讀條數readCount等于0;
iii.遍歷需要跨越路由信息集合;
iv. 根據路由信息返回表名及設置搜索條件;
v. 根據表名獲取路由概要信息;
vi.計算開始行號,如果當前表名和最后遍歷的表名相同,則開始行號等于(當前的頁數-1)*原請求頁面大小(originalPageSize)-當前表路由概要信息起始行,否則開行號設置為0;
vii. 計算當前頁面大小pageSize為原請求頁面大小(originalPageSize) – 己讀條數(readCount);
viii. 設置搜索條件起始行號、當前頁面大小;
ix.設置最后一次遍歷的表為當前表;
x.根據當前表名、搜索條件調用dao返回訂單基本信息列表,并加入訂單總列表集合;
xi.己讀數增加當前訂單列表大小;
xii. 如果己讀數大于等于原請求頁面大小則跳出循環,否則繼續循環;
xiii.返回訂單總列表集合;
xiv. 方法描述:
private List<Order> getOrderListByRoutePageTable(OrderSearchModel searchModel ,
Integer originalPageSize , Map<String, RouteTable> routeTableCountMap ,
List<String> routePageTables ) {
Integer readCount = 0;
List<Order> orderList = new ArrayList<Order>();
if ( routePageTables != null && routePageTables .size() > 0) {
String[] routeTableArrayFirst = routePageTables .get(0).split( "\\|" );
String lastTableName = null ;
if ( routeTableArrayFirst . length == 3) {
lastTableName = routeTableArrayFirst [0];
}
for (String routeTable : routePageTables ) {
String[] routeTableArray = routeTable .split( "\\|" );
if ( routeTableArray . length != 3) {
break ;
}
String tableName = getTableByRouteTableAndSetSearchModel( searchModel , routeTableArray );
RouteTable routeTableInfo = routeTableCountMap .get( tableName );
Integer startRow = 0;
if ( tableName .equals( lastTableName )){
startRow = ( searchModel .getPageNo()-1)* originalPageSize - routeTableInfo .getStartIndex();
}
Integer pageSize = originalPageSize - readCount ;
searchModel .setStartRow( startRow );
searchModel .setPageSize( pageSize );
lastTableName = tableName ;
List<Order> orderListPage = orderDao .searchOrderList( tableName , searchModel );
orderList .addAll( orderListPage );
readCount += orderListPage .size();
if ( readCount .intValue() >= originalPageSize ) {
break ;
}
}
}
return orderList ;
}
(3)根據Order集合組裝OrderBo集合
(4)根據返回的總數及分頁信息組裝分頁結果
五、mysql主從分離、引入三級緩存
為了提高性能,首先配置mysql主從分離,通過Spring多數據源來實現動態切換。三級緩存主要分為:(1)、線程級:當同一線程請求時,線程級緩存綁定在線程間ThreadLocal變量上,可以降低線程間切換造成的時間開銷。(2)、進程級:進程級緩存在同一jvm中共享緩存,減速少跨進程間網絡開銷。(3)、跨進程的集中式緩存:一般使用redis、memcache內存緩存來降低對數據庫系統的沖擊。在做完以上優化后,我們的接口響應速度提高了近5倍。
六、結束語
關于分布式服務化、異地南北機房雙活,這里留個作業,日后成文和大家分享。
來自:http://mp.weixin.qq.com/s?__biz=MzI3MzEzMDI1OQ==&mid=2651815120&idx=1&sn=cfc7fc347e9247b8e2054c78e2931c64&chksm=f0dc2aacc7aba3bad8bf06b82a8abeaaa36b79b14d0ab58f768b784daf17e87fdd758fa70b70&scene=0