大輝談-備戰雙十一之動靜分離實戰

Hex發表於2021-11-06

今天準備給大家講講我在工作中遇到的困難以及經過各種實踐到認知再實踐最終實現目標的過程之一: 我是如何思考動靜分離架構並最終實現的.

先來說說需求, 我之前所在的團隊的商業方向是做電商平臺saas,類似於有贊和微盟, 電商saas顧名思義, 它是電商+saas,意思就是普通的電商那一套不夠,還得加上saas:)

商品詳情的動靜分離已經上線了, 截圖如下:

現在不是要雙十一了麼? 他們都想搞私域的雙十一直播,平時的那點流量一下子躥了十幾倍甚至幾十倍, 怎麼應對? 啊! 動靜分離! 團隊的研發小夥伴們和問過的朋友以及CEO特意請的顧問都這麼說. ok! 那就搞動靜分離. 但是具體咋搞? 沒人給我好的方案, 因為具體情況具體分析, 動靜分離每家每個程式設計師每個架構師都有自己的想法, 在電商saas的場景下,商品詳情, 店鋪裝修, 商品列表 都是高頻請求, 都要實現動靜分離, 而且複雜的一點是, 作為saas平臺,每個商家的頁面和分傭和細節需求都會不一樣,這給靜態化和動態化增加了困難.

經過一週的頭禿思考和實踐認知再實踐再認知的迭代過程, 我還是最終把動靜分離方案實現了出來, 下面我就詳細說說我的方法.

首先說下為啥要實現動靜分離, 我們們都清楚啊, 首先關係型資料庫是有連線數限制的, 如果只是讀, 增加只讀例項就可以以低成本的方式增加連線數, 但是如果涉及寫, 就需要對資料庫進行升級. 簡單來說, 對於使用者的請求, 每次都從資料庫獲取資料如果連線數夠用並且沒有額外的sql執行開銷其實並沒有什麼問題, 問題就在於大量資料的io響應依然會阻礙併發數的提升, 並且會導致系統中的其它業務受影響. 所以解決方案就是nosql, 對常見的使用者請求,並不會到達資料庫這一級.

先拿商品詳情頁面來舉例子吧, 商品詳情頁面是商品的展示頁面, 在視訊號直播時訪問的頻率最高, 肯定首先要實現商品詳情的動靜分離, 我們們來先區分下哪些歸為靜態, 哪些歸為動態.

靜態內容:

與當前訪問者無關的內容為靜態內容, 如:
- 商品基本資訊
- 優惠券列表資訊
- 評價資訊
- 商品所屬的商家資訊
- 商家最新的商品列表

動態內容:

與當前訪問者有關的內容為動態內容, 如:
- 已領取的優惠券
- 針對訪問者單獨顯示的優惠券
- 訪問者能夠拿到的商品的自購返獎金等

出於篇幅問題, 本文只說靜態部分, 也就是與訪問者無關的頁面資訊, 我們們來個小目標, 假定有100萬使用者通過抖音或視訊號同時搶購某個限量商品A, 商品A假定庫存只有5萬件, 可以理解為100萬個使用者不停的在刷相同的頁面, 可以理解為理想情況下要達到100萬qps.

如果採用增加資料庫讀節點的方案,我們們來分析下情況:

  1. 要求使用者的請求2秒內返回.
  2. 每個請求的執行時間理想情況下是200-300毫秒
  3. 考慮到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萬使用者的請求.
方案如下:

  1. 客戶端向伺服器請求商品詳情的meta資訊. 請求引數為product_id
  2. 伺服器響應返回商品詳情的meta資訊, meta資訊的組成如下:
    • 商品標識
    • 商品的商家標識
    • 商品基本資訊的cdn地址, cdn地址格式為http://cdn.domain.com/product/[product_id]/base_[product_version].json
    • 商品評價的cdn地址, cdn地址格式為http://cdn.domain.com/product/[product_id]/comments_[comments_version].json
    • 商品的商家的資訊和商家最新商品的cdn地址,cdn地址格式為http://cdn.domain.com/merchant/[merchant_id]/base_[merchant_version].json
    • 商品的優惠券列表的cdn地址,cdn地址格式為http://cdn.domain.com/product/[product_id]/coupons_[coupons_version].json
  3. 客戶端併發請求cdn地址資訊和當前訪問者和商品關係的動態資料
  4. 部分cdn請求回源到伺服器, 伺服器通過redis快取或檢索es返回對應的json
  5. 客戶端渲染靜態資料

大家注意到cdn地址裡帶了一堆的version欄位, 我來解釋下這些version是怎麼來的.

靜態化的redis前面我們們都加個字首 static: 用來區分不同的業務. 商品詳情需要兩個redis 雜湊表來支援.

  1. static:products:[product_id] 商品雜湊表

    • product_id 商品標識
    • merchant_id 商家標識
    • product_version 商品基本資訊版本號
    • comments_version 商品評價版本號
  2. static:merchants:[merchant_id] 商家雜湊表

    • merchant_id 商家標識
    • coupons_version 優惠券版本
    • latest_version 最新商品版本

然後通過領域事件訂閱我們們來更新這些版本號

  1. 商品建立事件: 初始化商品雜湊表, 其中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
    })

  1. 商品修改和刪除事件: 重新整理商品雜湊表的商品版本和商家的版本號
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)
  1. 優惠券建立和修改事件: 重新整理商家雜湊表的優惠券版本號
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)
  1. 評論建立事件: 重新整理商品雜湊表的評論版本號
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)

  1. 商家資訊變更事件: 重新整理商家雜湊表的商家版本號
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

lua指令碼

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地址是http://cdn.domain.com/product/[product_id]/coupons_[coupons_version].json, 攜帶了product_id, 但是我們們關注的其實是coupons_version資訊, 只要coupons_version發生了變化,cdn地址肯定是要回源的,通過這種方式保障了實時的靜態更新.

現在我們們來算下總成本, 其中cdn我設定的是按天過期, 就是1天就過期, 這樣當秒殺請求過去後, cdn也不用承擔儲存成本.

redis+CDN方案成本計算:

  1. CDN成本 假定商品詳情頁面載入完整是耗費了1M, 這個不包括圖片, 因為按照資料庫連線計算也沒計算圖片的流量費用. 那麼100萬使用者刷一次是耗費了1000000/1024=976G的流量, 我們們買它個資源包, 按照之前跟資料庫的請求一樣的演算法, 1T的下行資源包的價格是144元.
  2. 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元, 當然在業務活躍期進行切換是不切實際的, 通常是業務低峰期, 如晚上切.
  3. 伺服器成本 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點寫完的文章, 寫的挺糙的, 因為大輝很久沒寫這種文章了, 大輝特別能噴, 但是寫作自打高中後就沒啥自信了, 所以後續的我針對這篇文章肯定還要繼續優化.
大家如果有什麼問題, 可以私信我或留言.

相關文章