電商系統架構, 常見的九個大坑
本文轉載自微信公眾號「微觀技術」,作者微觀技術。轉載本文請聯系微觀技術公眾號。
大家好,我是Tom哥。
做為一名程序員,發展方向大致可以分為兩個方面:一個是業務架構,一個是技術架構(中間件方向)。
業務架構,取其核心關鍵詞,主要是圍繞這不同的業務場景、業務規則,完成業務系統的落地建設,為用戶提供在線化的信息服務。
既然說到業務,那方向可就多了去了,如:出行、外賣、充電寶、O2O、內容、社交、生鮮、電商,不同的業務有不同的特點。
面對這么多的業務域,有沒有通用技術經驗可以抽取,讓我們可以以一應百。
這里,首推電商業務,電商系統的復雜性很高,對高并發、高性能、高可用、高擴展,等方面要求很高。你在其他業務中可能遇到的問題,在電商系統中基本都會遇到。
作為開發,希望自己成為某幾個業務領域的技術專家,最好能先精通電商領域,有很強的借鑒意義。對于你后續拓展熟悉其他業務領域的個性化玩法有很大幫助。
那么,電商領域的技術架構有哪些常見問題?
一、避免重復下單
用戶快速點了兩次 “提交訂單” 按鈕,瀏覽器會向后端發送兩條創建訂單的請求,最終會創建兩條一模一樣的訂單。
解決方案:
解決方案就是采用冪等機制,多次請求和一次請求產生的效果是一樣的。
方案一:
利用數據庫自身特性 “主鍵唯一約束”,在插入訂單記錄時,帶上主鍵值,如果訂單重復,記錄插入會失敗。
操作過程:
- 引入一個服務,用于生成一個“全局唯一的訂單號”。
- 進入創建訂單頁面時,前端請求該服務,預生成訂單ID。
- 提交訂單時,請求參數除了業務參數外,還要帶上這個預生成訂單ID。
方案二:
前端通過js腳本控制,無法解決用戶刷新提交的請求。另外也無法解決惡意提交。
不建議采用該方案,如果想用,也只是作為一個補充方案。
方案三:
前后約定附加參數校驗。
當用戶點擊購買按鈕時,渲染下單頁面,展示商品、收貨地址、運費、價格等信息,同時頁面會埋上Token 信息,用戶提交訂單時,后端業務邏輯會校驗token,有且匹配才認為是合理請求。
注意:同一個 Token 只能用一次,用完后立馬失效掉。
<form action="/add-name-v2" method="post">
{% csrf_token %}
<input type="text" name="name">
<input type="submit" value="提交">
</form>
二、訂單快照,減少存儲成本
商品信息是可以修改的,當用戶下單后,為了更好解決后面可能存在的買賣糾紛,創建訂單時會同步保存一份商品詳情信息,稱之為訂單快照。
同一件商品,會有很多用戶會購買,如果熱銷商品,短時間就會有上萬的訂單。如果每個訂單都創建一份快照,存儲成本太高。另外商品信息雖然支持修改,但畢竟是一個低頻動作。我們可以理解成,大部分訂單的商品快照信息都是一樣的,除非下單時用戶修改過。
如何實時識別修改動作是解決快照成本的關鍵所在。我們采用摘要比對的方法?。創建訂單時,先檢查商品信息摘要是否已經存在,如果不存在,會創建快照記錄。訂單明細會關聯商品的快照主鍵。
public class DigestTest {
public static void encodeStr(String data) {
String encodeS = DigestUtils.md5Hex(data);
System.out.println(encodeS);
}
public static void main(String[] args) {
String data = "網銷投連險是保險公司的一款保險產品,在互聯網金融上還是很常見的。" + "比如京東天天盈,網易有錢零錢++。這些保險削弱了保險的保障功能,降低成本,從而提高保險的理財功能提高理財收益。"
+ "投連險基本和銀行結構性理財產品一樣,信息披露度不高,但是有保險公司兜底,不至于整個平臺跑路。"
+ "投資投連險可以想象為投資一個起點低的銀行理財產品吧。網銷投連險一般都受益在4-6%,不承諾保本。"
+ "經常爆出保險公司的保障型長期投連險出現投資虧損新聞,但是網銷短期投連險投資型投連險目前沒有出現虧損,基本也能按照預期收益兌付。"
+ "網銷投連險安全性和收益性都比較居中,短期產品危險系數不高,但是在債券違約的大環境下,長期產品安全性沒有太大保障。" + "不過好在保險公司沒有跑路風險,至少不會把本金損失殆盡啊。";
encodeStr(data);
}
}
由于訂單快照屬于非核心操作,即使失敗也不應該影響用戶正常購買流程,所以通常采用異步流程執行。
三、購物車,混合存儲
購物車是電商系統的標配功能,暫存用戶想要購買的商品。分為添加商品、列表查看、結算下單三個動作。
技術設計并不是特別復雜,存儲的信息也相對有限(用戶id、商品id、sku_id、數量、添加時間)。這里特別拿出來單講主要是用戶體驗層面要注意幾個問題:
添加購物車時,后端校驗用戶未登錄,常規思路,引導用戶跳轉登錄頁,待登錄成功后,再添加購物車。多了一步操作,給用戶一種強迫的感覺,體驗會比較差。有沒有更好的方式?
如果細心體驗京東、淘寶等大平臺,你會發現即使未登錄態也可以添加購物車,這到底是怎么實現的?
細細琢磨其實原理并不復雜,服務端這邊在用戶登錄態校驗時,做了分支路由,當用戶未登錄時,會創建一個臨時Token,作為用戶的唯一標識,購物車數據掛載在該Token下,為了避免購物車數據相互影響以及設計的復雜度,這里會有一個臨時購物車表。
當然,臨時購物車表的數據量并不會太大,why?用戶不會一直閑著添加購物車玩,當用戶登錄后,查看自己的購物車,服務端會從請求的cookie里查找購物車Token標識,并查詢臨時購物車表是否有數據,然后合并到正式購物車表里。
特別說明:
臨時購物車是不是一定要在服務端存儲?未必。
有架構師傾向前置存儲,將數據存儲在瀏覽器或者APP LocalStorage,這部分數據畢竟不是共享的,但是不太好的增加了設計的復雜度。
- 客戶端需要借助本地數據索引,遠程請求查完整信息。
- 如果是登錄態,還要增加數據合并邏輯。
考慮到這兩部分數據只是用戶標識的差異性,所以作者還是建議統一存到服務端,日后即使業務邏輯變更,只需要改一處就可以了,畢竟自運營系統,良好的可維護性也需要我們非常關注的。
四、庫存超賣
常見的庫存扣減方式有:
- 下單減庫存:即當買家下單后,在商品的總庫存中減去買家購買數量。下單減庫存是最簡單的減庫存方式,也是控制最精確的一種,下單時直接通過數據庫的事務機制控制商品庫存,這樣一定不會出現超賣的情況。但是你要知道,有些人下完單可能并不會付款。
- 付款減庫存:即買家下單后,并不立即減庫存,而是等到有用戶付款后才真正減庫存,否則庫存一直保留給其他買家。但因為付款時才減庫存,如果并發比較高,有可能出現買家下單后付不了款的情況,因為可能商品已經被其他人買走了。
- 預扣庫存:這種方式相對復雜一些,買家下單后,庫存為其保留一定的時間(如 30 分鐘),超過這個時間,庫存將會自動釋放,釋放后其他買家就可以繼續購買。在買家付款前,系統會校驗該訂單的庫存是否還有保留:如果沒有保留,則再次嘗試預扣;如果庫存不足(也就是預扣失敗)則不允許繼續付款;如果預扣成功,則完成付款并實際地減去庫存。
至于采用哪一種減庫存方式更多是業務層面的考慮,減庫存最核心的是大并發請求時保證數據庫中的庫存字段值不能為負數。
方案一:
通常在扣減庫存的場景下使用行級鎖,通過數據庫引擎本身對記錄加鎖的控制,保證數據庫的更新的安全性,并且通過where語句的條件,保證庫存不會被減到 0 以下,也就是能夠有效的控制超賣的場景。
update ... set amount = amount - 1 where id = $id and amount - 1 >=0
方案二:
設置數據庫的字段數據為無符號整數,這樣減后庫存字段值小于零時 SQL 語句會報錯。
五、商家發貨,物流單更新 ABA 問題
舉個例子:
商家發貨,填寫運單號,開始填了 123,后來發現填錯了,然后又修改為 456。
此時,如果就為某種特殊場景埋下錯誤伏筆,具體我們來看下:
過程:
- 開始「請求A」發貨,調訂單服務接口,更新運單號 123。
- 但是響應有點慢,超時了。
- 此時,商家發現運單號填錯了,發起了「請求B」,更新運單號為456 ,訂單服務也響應成功了。
- 這時,「請求A」觸發了重試,再次調用訂單服務,更新運單號123,訂單服務也響應成功了。
- 訂單服務最后保存的 運單號 是 123。
是不是犯錯了!!!!
那么有什么好的解決方案嗎?
很多人可能會說,不重試不就可以了,要知道重試機制 是高可用服務的重要保障手段,很多重試是框架自動發起的。
理想的解決方案:
數據庫表引入一個額外字段 version,每次更新時,判斷表中的版本號與請求參數攜帶的版本號是否一致。
update order
set logistics_num = #{logistics_num} , version = #{version} + 1
where order_id= 1111 and version = #{version}
- 一致:才觸發更新。
- 不一致:說明這期間執行過數據更新,可能會引發錯誤,拒絕執行。
六、賬戶余額更新,保證事務
用戶支付,我們要從買家賬戶減掉一定金額,再往賣家增加一定金額,為了保證數據的完整性、可追溯性,變更余額時,我們通常會同時插入一條記錄流水。
賬戶流水核心字段:流水ID、金額、交易雙方賬戶、交易時間戳、訂單號。
注意:賬戶流水只能新增,不能修改和刪除。流水號必須是自增的。
后續,系統對賬時,我們只需要對交易流水明細數據做累計即可,如果出現和余額不一致情況,一般以交易流水為準來修復余額數據。
更新余額、記錄流水 雖屬于兩個操作,但是要保證要么都成功,要么都失敗。要做到事務。
數據庫的事務隔離級別有:讀未提交(RU)、讀已提交(RC)、可重復讀(RR)、串行化(Serializable)。
常用的隔離級別是 RC 和 RR ,因為這兩種隔離級別都可以避免臟讀。
當然,如果涉及多個微服務調用,會用到分布式事務。
分布式事務,細想下也很容易理解,就是將一個大事務拆分為多個本地事務,本地事務依然借助于數據庫自身事務來解決,難點在于解決這個分布式一致性問題,借助重試機制,保證最終一致是我們常用的方案。
七、MySQL讀寫分離帶來的數據不一致問題
互聯網業務大部分都是 讀多寫少,為了提升數據庫集群的吞吐性能,我們通常會采用 主從架構、讀寫分離。
部署一個主庫實例,客戶端請求所有寫操作全部寫到主庫,然后借助 MySQL 自帶的 主從同步 功能,做一些簡單配置,可以近乎實時的將主庫的數據同步給 多個從庫實例,主從延遲非常小,一般不超過 1 毫秒。
客戶端請求的所有讀操作全部打到 從庫,借助多實例集群提升讀請求的整體處理能力。
這個方案看似天衣無縫,但實際有個 副作用。
主從同步雖然近乎實時,但還是有個 時間差 ,主庫數據剛更新完,但數據還沒來得及同步到從庫,后續讀請求直接訪問了從庫,看到的還是舊數據,影響用戶體驗。
任何事情都不是完美的,從主同步也是一樣,沒有完美的解決方案,我們要找到其中的平衡取舍點。
我們以電商為例,看看如何從 產品層面 來化解這個問題。
為了實驗的真實性,Tom哥 特意在淘寶下了一筆購物訂單。
在下單確認頁面,點擊購買按鈕,進入了支付頁面:
輸入支付寶支付密碼,進入支付成功頁面,頁面有查看訂單詳情的入口。
點擊 查看交易詳情 ,才跳到真正的 訂單詳情頁,可以查看訂單的支付狀態(訂單數據取自從庫)。
看懂了嗎?
我們在支付成功后,并沒有立即跳到 訂單詳情頁,而是增加了一個 無關緊要的 中間頁(支付成功頁),一是告訴你支付的結果是成功的,錢沒丟,不要擔心;另外也可以增加一些推薦商品,引流提升網站的GMV。最重要的,增加了一個緩沖期,為 訂單的主從庫數據同步爭取了更多的時間。
可謂一舉多得,其他互聯網業務也是類似道理。
是不是又學了一招。????
八、歷史訂單,歸檔
根據二八定律,系統絕大部分的性能開銷花在20%的業務。數據也不例外,從數據的使用頻率來看,經常被業務訪問的數據稱為熱點數據;反之,稱之為冷數據。
在了解的數據的冷、熱特性后,便可以指導我們做一些有針對性的性能優化。這里面有業務層面的優化,也有技術層面的優化。比如:電商網站,一般只能查詢3個月內的訂單,如果你想看看3個月前的訂單,需要訪問歷史訂單頁面。
實現思路:
1.冷熱數據區分的標準是什么?要結合業務思考,可能要找產品同學一塊討論才能做決策,切記不要拍腦袋。以電商訂單為例:
- 方案一:以“下單時間”為標準,將3 個月前的訂單數據當作冷數據,3 個月內的當作熱數據。
- 方案二:根據“訂單狀態”字段來區分,已完結的訂單當作冷數據,未完結的訂單當作熱數據。
- 方案三:組合方式,把下單時間 > 3 個月且狀態為“已完結”的訂單標識為冷數據,其他的當作熱數據。
2.如何觸發冷熱數據的分離
- 方案一:直接修改業務代碼,每次業務請求觸發冷熱數據判斷,根據結果路由到對應的冷數據表或熱數據表。缺點:如果判斷標準是 。 時間維度,數據過期了無法主動感知。
- 方案二:如果覺得修改業務代碼,耦合性高,不易于后期維護。可以通過監聽數據庫變更日志 binlog 方式來觸發。
- 方案三:常用的手段是跑定時任務,一般是選擇凌晨系統壓力小的時候,通過跑批任務,將滿足條件的冷數據遷移到其他存儲介質。在途業務表中只留下來少量的熱點數據。
3.如何實現冷熱數據分離,過程大概分為三步:
- 判斷數據是冷、還是熱。
- 將冷數據插入冷數據表中。
- 然后,從原來的熱庫中刪除遷移的數據。
4.如何使用冷熱數據
- 方案一:界面設計時會有選項區分,如上面舉例的電商訂單。
- 方案二:直接在業務代碼里區分。
九、訂單分庫分表,多維度查詢
如果電商網站的訂單數過多,我們一般會想到 分庫分表 解決策略。沒問題,這個方向是對的。
但是查詢維度很多
1.買家,查詢 我的訂單 列表,需要根據 buyer_id 來查詢。
2.查看訂單詳情,需要根據 order_id 來查詢。
3.賣家,查詢 我的銷售 列表,需要根據 seller_id 來查詢。
而訂單分表只有一個分表鍵,如何滿足多維度 SQL 操作呢?
我們一般是基于買家維度來設計,下圖是 淘寶 的訂單列表:
一個訂單號 19 位,我們會發現同一個用戶不同訂單的最后 6 位都是一樣的,沒錯,那是用戶id的后6位。
這樣,上文中 場景1、場景2 的查詢可以共性抽取, 采用 buyer_id 或 order_id 的 后六位 作為分表鍵,對 1 000 000 取模,得到買家維度的訂單分表的編號。
至于 場景3 賣家維度的訂單查詢,我們可以采用數據異構方式,按seller_id 維度另外存儲一份數據,專門供賣家使用。