一、背景
隨著用戶量級的快速增長茬高,vivo 官方商城 v1.0 的單體架構(gòu)逐漸暴露出弊端:模塊愈發(fā)臃腫假抄、開發(fā)效率低下慨亲、性能出現(xiàn)瓶頸、系統(tǒng)維護(hù)困難巴刻。
從2017年開始啟動的 v2.0 架構(gòu)升級蛉签,基于業(yè)務(wù)模塊進(jìn)行垂直的系統(tǒng)物理拆分,拆分出來業(yè)務(wù)線各司其職柠座,提供服務(wù)化的能力,共同支撐主站業(yè)務(wù)淮野。
訂單模塊是電商系統(tǒng)的交易核心骤星,不斷累積的數(shù)據(jù)即將達(dá)到單表存儲瓶頸爆哑,系統(tǒng)難以支撐新品發(fā)布和大促活動期間的流量揭朝,服務(wù)化改造勢在必行。
本文將介紹 vivo 商城 訂單系統(tǒng)建設(shè)的過程中遇到的問題和解決方案柱嫌,分享架構(gòu)設(shè)計經(jīng)驗慎式。
二趟径、系統(tǒng)架構(gòu)
將訂單模塊從商城拆分出來蜗巧,獨立為訂單系統(tǒng),使用獨立的數(shù)據(jù)庫蓝丙,為商城相關(guān)系統(tǒng)提供訂單望拖、支付说敏、物流、售后等標(biāo)準(zhǔn)化服務(wù)医咨。
系統(tǒng)架構(gòu)如下圖所示:
三拟淮、技術(shù)挑戰(zhàn)
3.1 數(shù)據(jù)量和高并發(fā)問題
首先面對的挑戰(zhàn)來自存儲系統(tǒng):
-
數(shù)據(jù)量問題
隨著歷史訂單不斷累積很泊,MySQL中訂單表數(shù)據(jù)量已達(dá)千萬級。
我們知道InnoDB存儲引擎的存儲結(jié)構(gòu)是B+樹上遥,查找時間復(fù)雜度是O(log n),因此當(dāng)數(shù)據(jù)總量n變大時辣恋,檢索速度必然會變慢伟骨, 不論如何加索引或者優(yōu)化都無法解決携狭,只能想辦法減小單表數(shù)據(jù)量。
數(shù)據(jù)量大的解決方案有:數(shù)據(jù)歸檔稀并、分表
-
高并發(fā)問題
商城業(yè)務(wù)處于高速發(fā)展期碘举,下單量屢創(chuàng)新高搁廓,業(yè)務(wù)復(fù)雜度也在提升境蜕,應(yīng)用程序?qū)ySQL的訪問量越來越高。
單機(jī)MySQL的處理能力是有限的售滤,當(dāng)壓力過大時趴泌,所有請求的訪問速度都會下降,甚至有可能使數(shù)據(jù)庫宕機(jī)秃励。
并發(fā)量高的解決方案有:使用緩存夺鲜、讀寫分離呐舔、分庫
下面對這些方案進(jìn)行簡單描述:
-
數(shù)據(jù)歸檔
訂單數(shù)據(jù)具備時間屬性珊拼,存在熱尾效應(yīng),大部分情況下檢索的都是最近的訂單仅胞,而訂單表里卻存儲了大量使用頻率較低的老數(shù)據(jù)干旧。
那么就可以將新老數(shù)據(jù)分開存儲妹蔽,將歷史訂單移入另一張表中胳岂,并對代碼中的查詢模塊做一些相應(yīng)改動旦万,便能有效解決數(shù)據(jù)量大的問題。
-
使用緩存
使用Redis作為MySQL的前置緩存赏半,可以擋住大部分的查詢請求断箫,并降低響應(yīng)時延秋冰。
緩存對商品系統(tǒng)這類與用戶關(guān)系不大的系統(tǒng)效果特別好,但對訂單系統(tǒng)而言赵颅,每個用戶的訂單數(shù)據(jù)都不一樣饺谬,緩存命中率不算高谣拣,效果不是太好森缠。
-
讀寫分離
主庫負(fù)責(zé)執(zhí)行數(shù)據(jù)更新請求贵涵,然后將數(shù)據(jù)變更實時同步到所有從庫,用多個從庫來分擔(dān)查詢請求例书。
但訂單數(shù)據(jù)的更新操作較多,下單高峰時主庫的壓力依然沒有得到解決坟奥。且存在主從同步延遲拇厢,正常情況下延遲非常小孝偎,不超過1ms衣盾,但也會導(dǎo)致在某一個時刻的主從數(shù)據(jù)不一致。
那就需要對所有受影響的業(yè)務(wù)場景進(jìn)行兼容處理阻塑,可能會做一些妥協(xié)陈莽,比如下單成功后先跳轉(zhuǎn)到一個下單成功頁,用戶手動點擊查看訂單后才能看到這筆訂單独柑。
-
分庫
分庫又包含垂直分庫和水平分庫忌栅。
① 水平分庫:把同一個表的數(shù)據(jù)按一定規(guī)則拆到不同的數(shù)據(jù)庫中狂秘,每個庫可以放在不同的服務(wù)器上者春。
② 垂直分庫:按照業(yè)務(wù)將表進(jìn)行分類清女,分布到不同的數(shù)據(jù)庫上面嫡丙,每個庫可以放在不同的服務(wù)器上曙博,它的核心理念是專庫專用。
-
分表
分表又包含垂直分表和水平分表般哼。
****① 水平分表:在同一個數(shù)據(jù)庫內(nèi)蒸眠,把一個表的數(shù)據(jù)按一定規(guī)則拆到多個表中杆融。
****② 垂直分表:將一個表按照字段分成多表脾歇,每個表存儲其中一部分字段藕各。
我們綜合考慮了改造成本座韵、效果和對現(xiàn)有業(yè)務(wù)的影響踢京,決定直接使用最后一招:分庫分表
3.2 分庫分表技術(shù)選型
分庫分表的技術(shù)選型主要從這幾個方向考慮:
客戶端sdk開源方案
中間件proxy開源方案
公司中間件團(tuán)隊提供的自研框架
自己動手造輪子
參考之前項目經(jīng)驗瓣距,并與公司中間件團(tuán)隊溝通后蹈丸,采用了開源的Sharding-JDBC方案∧沤妫現(xiàn)已更名為Sharding-Sphere思瘟。
文檔:官方文檔比較粗糙滨攻,但是網(wǎng)上資料光绕、源碼解析、demo比較豐富
社區(qū):活躍
特點:jar包方式提供欣尼,屬于client端分片媒至,支持xa事務(wù)
3.2.1 分庫分表策略
結(jié)合業(yè)務(wù)特性,選取用戶標(biāo)識作為分片鍵完慧,通過計算用戶標(biāo)識的哈希值再取模來得到用戶訂單數(shù)據(jù)的庫表編號.
假設(shè)共有n個庫屈尼,每個庫有m張表拴孤,
則庫表編號的計算方式為:
- 庫序號:Hash(userId) / m % n
- 表序號:Hash(userId) % m
路由過程如下圖所示:
3.2.2 分庫分表的局限性和應(yīng)對方案
分庫分表解決了數(shù)據(jù)量和并發(fā)問題鞭执,但它會極大限制數(shù)據(jù)庫的查詢能力,有一些之前很簡單的關(guān)聯(lián)查詢大溜,在分庫分表之后可能就沒法實現(xiàn)了钦奋,那就需要單獨對這些Sharding-JDBC不支持的SQL進(jìn)行改寫。
除此之外岛琼,還遇到了這些挑戰(zhàn):
(1)全局唯一ID設(shè)計
分庫分表后,數(shù)據(jù)庫自增主鍵不再全局唯一葵诈,不能作為訂單號來使用作喘,但很多內(nèi)部系統(tǒng)間的交互接口只有訂單號晕城,沒有用戶標(biāo)識這個分片鍵砖顷,如何用訂單號來找到對應(yīng)的庫表呢滤蝠?
原來物咳,我們在生成訂單號時,就將庫表編號隱含在其中了芯肤。這樣就能在沒有用戶標(biāo)識的場景下崖咨,從訂單號中獲取庫表編號击蹲。
(2)歷史訂單號沒有隱含庫表信息
用一張表單獨存儲歷史訂單號和用戶標(biāo)識的映射關(guān)系,隨著時間推移芯丧,這些訂單逐漸不在系統(tǒng)間交互缨恒,就慢慢不再被用到骗露。
(3)管理后臺需要根據(jù)各種篩選條件萧锉,分頁查詢所有滿足條件的訂單
將訂單數(shù)據(jù)冗余存儲在搜索引擎Elasticsearch中柿隙,僅用于后臺查詢鲫凶。
3.3 怎么做 MySQL 到 ES 的數(shù)據(jù)同步
上面說到為了便于管理后臺的查詢螟炫,我們將訂單數(shù)據(jù)冗余存儲在Elasticsearch中昼钻,那么然评,如何在MySQL的訂單數(shù)據(jù)變更后,同步到ES中呢盏求?
這里要考慮的是數(shù)據(jù)同步的時效性和一致性、對業(yè)務(wù)代碼侵入小缕探、不影響服務(wù)本身的性能等爹耗。
-
MQ方案
ES更新服務(wù)作為消費者潭兽,接收訂單變更MQ消息后對ES進(jìn)行更新
-
Binlog方案
ES更新服務(wù)借助canal等開源項目山卦,把自己偽裝成MySQL的從節(jié)點账蓉,接收Binlog并解析得到實時的數(shù)據(jù)變更信息铸本,然后根據(jù)這個變更信息去更新ES箱玷。
其中BinLog方案比較通用锡足,但實現(xiàn)起來也較為復(fù)雜舱污,我們最終選用的是MQ方案扩灯。
因為ES數(shù)據(jù)只在管理后臺使用,對數(shù)據(jù)可靠性和同步實時性的要求不是特別高惧磺。
考慮到宕機(jī)和消息丟失等極端情況磨隘,在后臺增加了按某些條件手動同步ES數(shù)據(jù)的功能來進(jìn)行補償番捂。
3.4 如何安全地更換數(shù)據(jù)庫
如何將數(shù)據(jù)從原來的單實例數(shù)據(jù)庫遷移到新的數(shù)據(jù)庫集群设预,也是一大技術(shù)挑戰(zhàn)
不但要確保數(shù)據(jù)的正確性鳖枕,還要保證每執(zhí)行一個步驟后宾符,一旦出現(xiàn)問題,能快速地回滾到上一個步驟魏烫。
我們考慮了停機(jī)遷移和不停機(jī)遷移的兩種方案:
(1)不停機(jī)遷移方案:
把舊庫的數(shù)據(jù)復(fù)制到新庫中辣苏,上線一個同步程序,使用 Binlog等方案實時同步舊庫數(shù)據(jù)到新庫则奥。
上線雙寫訂單新舊庫服務(wù)考润,只讀寫舊庫。
開啟雙寫读处,同時停止同步程序糊治,開啟對比補償程序,確保新庫數(shù)據(jù)和舊庫一致井辜。
逐步將讀請求切到新庫上。
讀寫都切換到新庫上管闷,對比補償程序確保舊庫數(shù)據(jù)和新庫一致粥脚。
下線舊庫,下線訂單雙寫功能包个,下線同步程序和對比補償程序刷允。
(2)停機(jī)遷移方案:
上線新訂單系統(tǒng),執(zhí)行遷移程序?qū)蓚€月之前的訂單同步到新庫碧囊,并對數(shù)據(jù)進(jìn)行稽核树灶。
將商城V1應(yīng)用停機(jī),確保舊庫數(shù)據(jù)不再變化糯而。
執(zhí)行遷移程序天通,將第一步未遷移的訂單同步到新庫并進(jìn)行稽核。
上線商城V2應(yīng)用熄驼,開始測試驗證像寒,如果失敗則回退到商城V1應(yīng)用(新訂單系統(tǒng)有雙寫舊庫的開關(guān))。
[圖片上傳失敗...(image-29486c-1609122783223)]
考慮到不停機(jī)方案的改造成本較高瓜贾,而夜間停機(jī)方案的業(yè)務(wù)損失并不大诺祸,最終選用的是停機(jī)遷移方案。
3.5 分布式事務(wù)問題
電商的交易流程中祭芦,分布式事務(wù)是一個經(jīng)典問題筷笨,比如:
用戶支付成功后,需要通知發(fā)貨系統(tǒng)給用戶發(fā)貨。
用戶確認(rèn)收貨后奥秆,需要通知積分系統(tǒng)給用戶發(fā)放購物獎勵的積分。
我們是如何保證微服務(wù)架構(gòu)下數(shù)據(jù)的一致性呢咸灿?
不同業(yè)務(wù)場景對數(shù)據(jù)一致性的要求不同构订,業(yè)界的主流方案中,用于解決強(qiáng)一致性的有兩階段提交(2PC)避矢、三階段提交(3PC)悼瘾,解決最終一致性的有TCC、本地消息审胸、事務(wù)消息和最大努力通知等亥宿。
這里不對上述方案進(jìn)行詳細(xì)的描述,介紹一下我們正在使用的本地消息表方案:在本地事務(wù)中將要執(zhí)行的異步操作記錄在消息表中砂沛,如果執(zhí)行失敗烫扼,可以通過定時任務(wù)來補償。
下圖以訂單完成后通知積分系統(tǒng)贈送積分為例碍庵。
3.6 系統(tǒng)安全和穩(wěn)定性
-
網(wǎng)絡(luò)隔離
只有極少數(shù)第三方接口可通過外網(wǎng)訪問映企,且都會驗證簽名,內(nèi)部系統(tǒng)交互使用內(nèi)網(wǎng)域名和RPC接口静浴。
-
并發(fā)鎖
任何訂單更新操作之前堰氓,會通過數(shù)據(jù)庫行級鎖加以限制,防止出現(xiàn)并發(fā)更新苹享。
-
冪等性
所有接口均具備冪等性双絮,不用擔(dān)心對方網(wǎng)絡(luò)超時重試所造成的影響。
-
熔斷
使用Hystrix組件得问,對外部系統(tǒng)的實時調(diào)用添加熔斷保護(hù)囤攀,防止某個系統(tǒng)故障的影響擴(kuò)大到整個分布式系統(tǒng)中。
-
監(jiān)控和告警
通過配置日志平臺的錯誤日志報警椭赋、調(diào)用鏈的服務(wù)分析告警抚岗,再加上公司各中間件和基礎(chǔ)組件的監(jiān)控告警功能,讓我們能夠能夠第一時間發(fā)現(xiàn)系統(tǒng)異常哪怔。
3.7 踩過的坑
采用MQ消費的方式同步數(shù)據(jù)庫的訂單相關(guān)數(shù)據(jù)到ES中宣蔚,遇到的寫入數(shù)據(jù)不是訂單最新數(shù)據(jù)問題
下圖左邊是原方案:
在消費訂單數(shù)據(jù)同步的MQ時,如果線程A在先執(zhí)行认境,查出數(shù)據(jù)胚委,這時候訂單數(shù)據(jù)被更新了,線程B開始執(zhí)行同步操作叉信,查出訂單數(shù)據(jù)后先于線程A一步寫入ES中亩冬,線程A執(zhí)行寫入時就會將線程B寫入的數(shù)據(jù)覆蓋,導(dǎo)致ES中的訂單數(shù)據(jù)不是最新的。
解決方案是在查詢訂單數(shù)據(jù)時加行鎖硅急,整個業(yè)務(wù)執(zhí)行在事務(wù)中覆享,執(zhí)行完成后再執(zhí)行下一個線程。
sharding-jdbc 分組后排序分頁查詢出所有數(shù)據(jù)問題
示例:select a from temp group by a营袜,b order by a desc limit 1,10撒顿。
執(zhí)行是Sharding-jdbc里group by 和 order by 字段和順序不一致是將10置為Integer.MAX_VALUE, 導(dǎo)致分頁查詢失效。
io.shardingsphere.core.routing.router.sharding.ParsingSQLRouter#processLimit
private void processLimit(final List<Object> parameters, final SelectStatement selectStatement, final boolean isSingleRouting) {
boolean isNeedFetchAll = (!selectStatement.getGroupByItems().isEmpty() || !selectStatement.getAggregationSelectItems().isEmpty()) && !selectStatement.isSameGroupByAndOrderByItems();
selectStatement.getLimit().processParameters(parameters, isNeedFetchAll, databaseType, isSingleRouting);
}
io.shardingsphere.core.parsing.parser.context.limit.Limit#processParameters
/**
* Fill parameters for rewrite limit.
*
* @param parameters parameters
* @param isFetchAll is fetch all data or not
* @param databaseType database type
* @param isSingleRouting is single routing or not
*/
public void processParameters(final List<Object> parameters, final boolean isFetchAll, final DatabaseType databaseType, final boolean isSingleRouting) {
fill(parameters);
rewrite(parameters, isFetchAll, databaseType, isSingleRouting);
}
private void rewrite(final List<Object> parameters, final boolean isFetchAll, final DatabaseType databaseType, final boolean isSingleRouting) {
int rewriteOffset = 0;
int rewriteRowCount;
if (isFetchAll) {
rewriteRowCount = Integer.MAX_VALUE;
} else if (isNeedRewriteRowCount(databaseType) && !isSingleRouting) {
rewriteRowCount = null == rowCount ? -1 : getOffsetValue() + rowCount.getValue();
} else {
rewriteRowCount = rowCount.getValue();
}
if (null != offset && offset.getIndex() > -1 && !isSingleRouting) {
parameters.set(offset.getIndex(), rewriteOffset);
}
if (null != rowCount && rowCount.getIndex() > -1) {
parameters.set(rowCount.getIndex(), rewriteRowCount);
}
}
正確的寫法應(yīng)該是 select a from temp group by a desc 荚板,b limit 1,10 凤壁; 使用的版本是sharing-jdbc的3.1.1。
ES分頁查詢?nèi)绻判蜃侄未嬖谥貜?fù)的值跪另,最好加一個唯一的字段作為第二排序條件拧抖,避免分頁查詢時漏掉數(shù)據(jù)、查出重復(fù)數(shù)據(jù)免绿,比如用的是訂單創(chuàng)建時間作為唯一排序條件唧席,同一時間如果存在很多數(shù)據(jù),就會導(dǎo)致查詢的訂單存在遺漏或重復(fù)嘲驾,需要增加一個唯一值作為第二排序條件或者直接使用唯一值作為排序條件袱吆。
四、成果
一次性上線成功距淫,穩(wěn)定運行了一年多
核心服務(wù)性能提升十倍以上
系統(tǒng)解耦绞绒,迭代效率大幅提升
能夠支撐商城至少五年的高速發(fā)展
五、結(jié)語
我們在系統(tǒng)設(shè)計時并沒有一味追求前沿技術(shù)和思想榕暇,面對問題時也不是直接采用主流電商的解決方案蓬衡,而是根據(jù)業(yè)務(wù)實際狀況來選取最合適的辦法。
個人覺得彤枢,一個好的系統(tǒng)不是在一開始就被大牛設(shè)計出來的狰晚,一定是隨著業(yè)務(wù)的發(fā)展和演進(jìn)逐漸被迭代出來的,持續(xù)預(yù)判業(yè)務(wù)發(fā)展方向缴啡,提前制定架構(gòu)演進(jìn)方案壁晒,簡單來說就是:走到業(yè)務(wù)的前面去!
作者:vivo官網(wǎng)商城開發(fā)團(tuán)隊