今天準備給大家講講我在工作中遇到的困難以及經過各種實踐到認知再實踐最終實作目标的過程之一: 我是如何思考動靜分離架構并最終實作的.
先來說說需求, 我之前所在的團隊的商業方向是做電商平台saas,類似于有贊和微盟, 電商saas顧名思義, 它是電商+saas,意思就是普通的電商那一套不夠,還得加上saas:)
商品詳情的動靜分離已經上線了, 截圖如下:

現在不是要雙十一了麼? 他們都想搞私域的雙十一直播,平時的那點流量一下子蹿了十幾倍甚至幾十倍, 怎麼應對? 啊! 動靜分離! 團隊的研發小夥伴們和問過的朋友以及CEO特意請的顧問都這麼說. ok! 那就搞動靜分離. 但是具體咋搞? 沒人給我好的方案, 因為具體情況具體分析, 動靜分離每家每個程式員每個架構師都有自己的想法, 在電商saas的場景下,商品詳情, 店鋪裝修, 商品清單 都是高頻請求, 都要實作動靜分離, 而且複雜的一點是, 作為saas平台,每個商家的頁面和分傭和細節需求都會不一樣,這給靜态化和動态化增加了困難.
經過一周的頭秃思考和實踐認知再實踐再認知的疊代過程, 我還是最終把動靜分離方案實作了出來, 下面我就詳細說說我的方法.
首先說下為啥要實作動靜分離, 咱們都清楚啊, 首先關系型資料庫是有連接配接數限制的, 如果隻是讀, 增加隻讀執行個體就可以以低成本的方式增加連接配接數, 但是如果涉及寫, 就需要對資料庫進行更新. 簡單來說, 對于使用者的請求, 每次都從資料庫擷取資料如果連接配接數夠用并且沒有額外的sql執行開銷其實并沒有什麼問題, 問題就在于大量資料的io響應依然會阻礙并發數的提升, 并且會導緻系統中的其它業務受影響. 是以解決方案就是nosql, 對常見的使用者請求,并不會到達資料庫這一級.
先拿商品詳情頁面來舉例子吧, 商品詳情頁面是商品的展示頁面, 在視訊号直播時通路的頻率最高, 肯定首先要實作商品詳情的動靜分離, 咱們來先區分下哪些歸為靜态, 哪些歸為動态.
靜态内容:
與目前通路者無關的内容為靜态内容, 如:
- 商品基本資訊
- 優惠券清單資訊
- 評價資訊
- 商品所屬的商家資訊
- 商家最新的商品清單
動态内容:
與目前通路者有關的内容為動态内容, 如:
- 已領取的優惠券
- 針對通路者單獨顯示的優惠券
- 通路者能夠拿到的商品的自購返獎金等
出于篇幅問題, 本文隻說靜态部分, 也就是與通路者無關的頁面資訊, 咱們來個小目标, 假定有100萬使用者通過抖音或視訊号同時搶購某個限量商品A, 商品A假定庫存隻有5萬件, 可以了解為100萬個使用者不停的在刷相同的頁面, 可以了解為理想情況下要達到100萬qps.
如果采用增加資料庫讀節點的方案,咱們來分析下情況:
- 要求使用者的請求2秒内傳回.
- 每個請求的執行時間理想情況下是200-300毫秒
- 考慮到sqlalchemy對協程的支援仍處于早期階段, 對于資料庫的請求采用多線程模式
基于以上情況, 因為可以2秒内傳回, 是以咱們可以假定下每秒隻需要達到50萬qps就可以了,再看每次請求需要200-300毫秒, 因為在fastapi線程模式下或者flask或者django來說, 每個請求一個線程, 可以了解為每個線程每秒能執行3個請求. 也就是說需要50/3=16.6萬個線程. 按照python線程的實際情況, 一般線程數是核數的2-4倍, 假定就是純io情況, 這裡咱們取2倍, 在GIL的情況下, 2倍和4倍其實沒什麼變化, 實際我測試下來2倍反而更好一些, 每個程序就是cpu_count() 2個線程, 程序數一般也是cpu數的2倍, 按照阿裡雲的ecs最高配置256核1024G記憶體的配置, 2562(2562)=262144, 可以了解為需要2台頂級配置的ecs伺服器就能夠支撐商品詳情的請求, 但是考慮到用戶端的并發請求情況,咱們豪爽的來4台, 每台阿裡雲的頂級配置的ecs每小時的費用是56.32元, 4台就是228元, 假定活動前後執行4個小時,可以了解為1000元的成本.
按照每個線程一個連接配接的映射理論, 就需要17萬的資料庫連接配接池連接配接數. 阿裡雲按量付費的postgresql資料庫最高配置是64核512G, 最大支援51200的連接配接數,如果要達到17萬的連接配接數, 也就需要至少1台主執行個體,3台讀執行個體才能夠覆寫. 每小時的費用在57*4=228元. 假定活動前後要經曆4個小時, 那麼總成本就是1000元, 這個還好, 另外存儲的成本可以忽略不計.
實際情況是如果前端有并發請求或者還有其他業務也在正常請求, 線程數和資料庫連接配接數上面的計算方法其實根本就不夠, 但是上面的計算方式是一個基礎數, 在這個基礎上, 根據線上業務情況肯定要增加資料庫隻讀執行個體和ecs伺服器數量.
嗯, 100萬使用者才2000的成本? 錯啦!! 阿裡雲的api網關也要錢, 負載均衡也按小時和流量算錢, 我看了下阿裡雲, 如果按照每個使用者1M的資料傳回量來算, 100萬使用者就是996G的流量, 也就是說這些使用者每個都請求一次的成本就是(0.049+0.8)*996=845.6元. 但是這仍然隻是一個基礎的算法, 前端并發, 大表的請求寫入問題, 傳輸的流量費用, 負載均衡, api網關的費用這些都仍未計算在内呢, 而且每次活動都要預先通知研發團隊, 這個對于标準的電商網站都好說, 但是對于做saas平台的就是個噩夢了, 因為根本無法跟商家解釋清楚為啥他想賣自己的貨需要向平台報備.
是以咱們換個方案, 嘗試下使用redis+cdn來抗這100萬使用者的請求.
方案如下:
- 用戶端向伺服器請求商品詳情的meta資訊. 請求參數為product_id
- 伺服器響應傳回商品詳情的meta資訊, meta資訊的組成如下:
- 商品辨別
- 商品的商家辨別
- 商品基本資訊的cdn位址, cdn位址格式為 http://cdn.domain.com/product/ [product_id]/base_[product_version].json
- 商品評價的cdn位址, cdn位址格式為 [product_id]/comments_[comments_version].json
- 商品的商家的資訊和商家最新商品的cdn位址,cdn位址格式為 http://cdn.domain.com/merchant/ [merchant_id]/base_[merchant_version].json
- 商品的優惠券清單的cdn位址,cdn位址格式為 [product_id]/coupons_[coupons_version].json
- 用戶端并發請求cdn位址資訊和目前通路者和商品關系的動态資料
- 部分cdn請求回源到伺服器, 伺服器通過redis緩存或檢索es傳回對應的json
- 用戶端渲染靜态資料
大家注意到cdn位址裡帶了一堆的version字段, 我來解釋下這些version是怎麼來的.
靜态化的redis前面咱們都加個字首 static: 用來區分不同的業務. 商品詳情需要兩個redis 哈希表來支援.
- static:products:[product_id] 商品哈希表
- product_id 商品辨別
- merchant_id 商家辨別
- product_version 商品基本資訊版本号
- comments_version 商品評價版本号
- static:merchants:[merchant_id] 商家哈希表
- coupons_version 優惠券版本
- latest_version 最新商品版本
然後通過領域事件訂閱咱們來更新這些版本号
- 商品建立事件: 初始化商品哈希表, 其中product_version初始化為time.time()
def create_product(rc: Redis,
product_id: int,
supplier_id: int):
rk = f"static:products:{product_id}"
version = int(time.time())
rc.hmset(rk, {
"supplier_id": supplier_id,
"product_version": version,
"comments_version": version
})
- 商品修改和删除事件: 重新整理商品哈希表的商品版本和商家的版本号
def refresh_product_version(rc: Redis, product_id: int):
product_rk = f"static:products:{product_id}"
merchant_base_rk = "static:merchants:%s"
version = int(time.time())
redis_eval("refresh_product_version.lua", product_rk, merchant_base_rk, version)
lua腳本
local product_key = KEYS[1]
local merchant_base_key = KEYS[2]
local version = KEYS[3]
local supplier_id = redis.call('hget', product_key,"supplier_id")
local merchant_key = string.format(merchant_base_key,tostring(supplier_id))
redis.call('hset', product_key,"product_version",version)
redis.call('hset', merchant_key,"merchant_version",version)
- 優惠券建立和修改事件: 重新整理商家哈希表的優惠券版本号
def refresh_coupons_version(rc: Redis,
merchant_id: int):
version = int(time.time())
rk = f"static:merchants:{merchant_id}"
rc.hset(rk, "coupons_version", version)
- 評論建立事件: 重新整理商品哈希表的評論版本号
def refresh_comments_version(rc: Redis, product_id: int):
rk = f"static:products:{product_id}"
version = int(time.time())
rc.hset(rk, "comments_version", version)
- 商家資訊變更事件: 重新整理商家哈希表的商家版本号
def refresh_merchant_version(rc: Redis,
merchant_id: int):
version = int(time.time())
rk = f"static:merchants:{merchant_id}"
rc.hset(rk, "merchant_version", version)
通過訂閱商品詳情頁面關聯的領域事件, 資料的版本号就發生了變更, 這樣當用戶端請求商品詳情的meta資訊的時候, 就可以通過lua腳本在redis中讀取相關的版本号
def get_product_details_meta(product_id: int) -> Optional[ProductDetailsMeta]:
product_rk = f"static:products:{product_id}"
merchant_base_rk ="static:merchants:s%"
versions = redis_eval("get_product_details_meta.lua", product_rk, merchant_base_rk)
versions_dict = json.loads(versions)
product_version, merchant_version = versions_dict["product_version"], versions_dict["merchant_version"]
meta = ProductDetailsMeta(
supplier_id=product_version.get("supplier_id"),
product_version=product_version.get("product_version"),
comments_version=product_version.get("comments_version"),
coupons_version=merchant_version.get("coupons_version"),
supplier_version=merchant_version.get("merchant_version")
)
return meta
local versions = {}
local product_key = KEYS[1]
local merchant_base_key = KEYS[2]
local supplier_id = redis.call('hget', product_key,"supplier_id")
local merchant_key = string.format(merchant_base_key,tostring(supplier_id))
local function hgetall(hash_key)
local result = redis.call('hgetall', hash_key)
local ret={}
for i=1,#result,2 do
ret[result[i]]=result[i+1]
end
return ret
end
local product_version = hgetall(product_key)
local merchant_version = hgetall(merchant_key)
versions["product_version"] = product_version
versions["merchant_version"] = merchant_version
return cjson.encode(versions)
這裡再強調下為什麼優惠券的變更是跟随商家的, 因為優惠券的操作肯定是商家操作的, 優惠券的範圍可能包含指定商品或集合, 也可能排除指定商品, 但是優惠券肯定是商家建立和修改的, 是以跟蹤關系就要建立在商家哈希表上, 雖然商品詳情擷取優惠券的cdn位址是
[product_id]/coupons_[coupons_version].json, 攜帶了product_id, 但是咱們關注的其實是coupons_version資訊, 隻要coupons_version發生了變化,cdn位址肯定是要回源的,通過這種方式保障了實時的靜态更新.
現在咱們來算下總成本, 其中cdn我設定的是按天過期, 就是1天就過期, 這樣當秒殺請求過去後, cdn也不用承擔存儲成本.
redis+CDN方案成本計算:
- CDN成本 假定商品詳情頁面加載完整是耗費了1M, 這個不包括圖檔, 因為按照資料庫連接配接計算也沒計算圖檔的流量費用. 那麼100萬使用者刷一次是耗費了1000000/1024=976G的流量, 咱們買它個資源包, 按照之前跟資料庫的請求一樣的算法, 1T的下行資源包的價格是144元.
- redis成本 由于使用了redis,是以資料庫的成本就可以省略掉了, 另外雖然會有回源的情況, 但是考慮到秒殺之前,很少有商家會修改商品資訊, 是以咱們可以假定回源的情況不存在. 或者回源導緻的成本就可以忽略不計了. 那麼如果使用redis來傳回商品詳情的meta資訊需要什麼樣的配置呢? 因為使用了redis, 連接配接數就沒有了限制, 也就是說在使用者請求到來的時候, 我們可以不用一個請求一個線程的模式, 而是使用協程的方式來異步擷取redis資料, 很不幸的是, 我尚未對這個部分進行測試, 但是我們可以看下阿裡雲的redis的qps資料, 一般考慮到業務情況, 基本上打個6折就與業務比對了. 那麼仍要求在2秒内響應資料, 每秒的qps要求就是50萬,咱們買個讀寫分離版本,買3個隻讀節點, 每個節點的get性能按照阿裡雲的說法是44萬個每秒,打個6折是24萬, 三個隻讀節點加上寫節點是4個節點, 性能是244=96萬, ok, 夠用了! 活動期間費用是1.4204=5.6元, 當然在業務活躍期進行切換是不切實際的, 通常是業務低峰期, 如晚上切.
- 伺服器成本 redis的響應時間按照阿裡雲的文檔最高是0.7毫秒, 因為咱們讀了哈希表, 還讀了倆, 還用了lua腳本, 咱們打個折, 就當10毫秒好了. 也就是說按照單程序來說1秒能夠處理的請求是1000/10=100. 由于我們改成使用了協程, python的GIL就權且當做沒有了, 可以了解為其它語言正常的1個請求一個線程. 按照阿裡雲的ecs最高配置256核1024G記憶體的配置, 2562(2562)=262144, 但是因為每個線程能執行100個請求, 就是理論上能達到2621400qps, 是以這個配置其實就過高了, 1台基本就夠支撐了.但是1台就有點風險太高了, 咱們豪爽的來它2台, 確定性能足夠! 費用就是56.322*4=450.6元.
是以算下來使用redis+cdn的基礎成本就是450.6+5.6+144=600.2元. 當然大部分使用者肯定會刷頁面, cdn的下行資源包實際得買個10T的, 由于通過資料庫擷取資料的計算也是隻計算了1次使用者請求的成本,是以粗估成本的時候cdn也是按照隻請求一次的成本進行累加.
是以其實成本計算隻是個粗估, 并不靠譜, 隻是用于判斷成本和技術方案, 以及當商家确認要搞個這麼大的活動的時候, 确認需要準備多少資源才能支撐.
這是我從晚上6點肝到淩晨2點寫完的文章, 寫的挺糙的, 因為大輝很久沒寫這種文章了, 大輝特别能噴, 但是寫作自打高中後就沒啥自信了, 是以後續的我針對這篇文章肯定還要繼續優化.
大家如果有什麼問題, 可以私信我或留言.