Feed流系統重構-架構篇

勇哥技術遊記發表於2022-01-13

重構,於我而言,很大的快樂在於能夠解決問題。

第一次重構是重構一個c#版本的彩票算獎系統。當時的算獎系統在開獎後,算獎經常超時,導致使用者經常投訴。接到重構的任務,既興奮又緊張,花了兩天時間,除了吃飯睡覺,都在擼程式碼。重構效果也很明顯,算獎耗時從原來的1個小時減少到10分鐘。

去年,我以架構師的身份參與了家校朋友圈應用的重構。應用麻雀雖小,五臟俱全,和諸君分享架構設計的思路。

01 應用背景

1. 應用介紹

移動網際網路時代,Feed流產品是非常常見的,比如我們每天都會用到的朋友圈,微博,就是一種非常典型的Feed流產品。
Feed(動態):Feed流中的每一條狀態或者訊息都是Feed,比如朋友圈中的一個狀態就是一個Feed,微博中的一條微博就是一個Feed。
Feed流:持續更新並呈現給使用者內容的資訊流。每個人的朋友圈,微博關注頁等等都是一個Feed流。

家校朋友圈是校信app的一個子功能。學生和老師可以傳送圖片,視訊,聲音等動態資訊,學生和老師可以檢視班級下的動態聚合。

為什麼要重構呢?

▍ 程式碼可維護性

服務端端程式碼已經有四年左右的歷史,隨著時間的推移,人員的變動,不斷的修復Bug,不斷的新增新功能,程式碼的可讀性越來越差。而且很多維護的功能是在沒有完全理解程式碼的情況下做修改的。新功能的維護越來越艱難,程式碼質量越來越腐化。

▍ 查詢瓶頸
服務端使用的mysql作為資料庫。Feed表資料有兩千萬,Feed詳情表七千萬左右。
服務端大量使用儲存過程(200+)。動態查詢基本都是多張千萬級大表關聯,查詢耗時在5s左右。DBA同學反饋sql頻繁超時。

2. 重構過程

《重構:改善既有程式碼的設計》這本書重點強調: “不要為了重構而重構”。 重構要考慮時間(2個月),人力成本(3人),需要解決核心問題。

1、功能模組化, 便於擴充套件和維護

2、靈活擴充套件Feed型別, 支撐新業務接入

3、優化動態聚合頁響應速度

基於以上目標, 我和小夥伴按照如下的工作。

1)梳理朋友圈業務,按照清晰的原則,將單個家校服務端拆分出兩個模組

  • 1 space-app: 提供rest介面,供app呼叫
  • 2 space-task: 推送訊息, 任務處理

2)分庫分表設計, 去儲存過程, 資料庫表設計

資料庫Feed表已達到2000萬, Feed詳情表已達到7000萬+。為了提升查詢效率,肯定需要分庫分表。但考慮到資料寫入量每天才2萬的量級,所以分表即可。

資料庫裡有200+的儲存過程,為了提升資料庫表設計效率,整理核心介面呼叫儲存過程邏輯。在設計表的時候,需要考慮shardingKey冗餘。 按照這樣的思路,梳理核心邏輯以及新表設計的時間也花了10個工作日。

產品大致有三種Feed查詢場景

  • 班級維度: 查詢某班級下Feed動態列表
  • 使用者維度:查詢某使用者下Feed動態列表
  • Feed維度: 查詢feed下點贊列表

3)架構設計
在梳理業務,設計資料庫表的過程中,並行完成各個基礎元件的研發。

基礎元件的封裝包含以下幾點:

  • 分庫分表元件,Id生成器,springboot starter
  • rocketmq client封裝
  • 分散式快取封裝

03 分庫分表

3.1 主鍵

分庫分表的場景下我選擇非常成熟的snowflake演算法。

第一位不使用,預設都是0,41位時間戳精確到毫秒,可以容納69年的時間,10位工作機器ID高5位是資料中心ID,低5位是節點ID,12位序列號每個節點每毫秒累加,累計可以達到2^12 4096個ID。

我們重點實現了12位序列號生成方式。中間10位工作機器ID儲存的是

 Long workerId = Math.abs(crc32(shardingKeyValue) % 1024)
 //這裡我們也可以認為是在1024個槽裡的slot

底層使用的是redis的自增incrby命令。

   //轉換成中間10位編碼
   Integer workerId = Math.abs(crc32(shardingKeyValue) % 1024);
   String idGeneratorKey = 
   IdConstants.ID_REDIS_PFEFIX + currentTime;
   Long counter = atomicCommand.incrByEx(
    idGeneratorKey,
    IdConstants.STEP_LENGTH,
    IdConstants.SEQ_EXPIRE_TIME);
   Long uniqueId = SnowFlakeIdGenerator.getUniqueId(
      currentTime, 
      workerId.intValue(), 
   counter);

為了避免頻繁的呼叫redis命令,還加了一層薄薄的本地快取。每次呼叫命令的時候,一次步長可以設定稍微長一點,保持在本地快取裡,每次生成唯一主鍵的時候,先從本地快取裡預取一次,若沒有,然後再通過redis的命令獲取。

3.2 策略

因為早些年閱讀cobar原始碼的關係,所以採用了類似cobar的分庫方式。

舉例:使用者編號23838,crc32(userId)%1024=562,562在區間[512,767]之間。所以該使用者的Feed動態會儲存在t_space_feed2表。

3.3 查詢

帶shardingkey的查詢,比如就通過使用者編號查詢t_space_feed表,可以非常容易的定位表名。

假如不是shardingkey,比如通過Feed編號(主鍵)查詢t_space_feed表,因為主鍵是通過snowflake演算法生成的,我們可以通過Feed編號獲取workerId(10位機器編號), 通過workerId也就確定資料位於哪張表了。

模糊查詢場景很少。方案就是走ES查詢,Feed資料落庫之後,通過MQ訊息形式,把資料同步ES,這種方式稍微有延遲的,但是這種可控範圍的延遲是可以接受的。

3.4 工程

分庫分表一般有三種模式:

  1. 代理模式,相容mysql協議。如cobar,mycat,drds。
  2. 代理模式,自定義協議。如藝龍的DDA。
  3. 客戶端模式,最有名的是shardingsphere的sharding-jdbc。

分庫分表選型使用的是sharding-jdbc,最重要的原因是輕便簡單,而且早期的程式碼曾經看過一兩次,原理有基礎的認識。

核心程式碼邏輯其實還是蠻清晰的。

ShardingRule shardingRule = new ShardingRule(
shardingRuleConfiguration, 
customShardingConfig.getDatasourceNames());
DataSource dataSource = new ShardingDataSource(
   dataSourceMap,
   shardingRule, 
   properties);

請注意: 對於整個應用來講,client模式的最終結果是初始化了DataSource的介面

  1. 需要定義初始化資料來源資訊
    datasourceNames是資料來源名列表,
    dataSourceMap是資料來源名和資料來源對映。
  2. 這裡有一個概念邏輯表和物理表。
邏輯表 物理表
t_space_feed (動態表) t_space_feed_0~3
  1. 分庫演算法:
    DataSourceHashSlotAlgorithm:分庫演算法
    TableHashSlotAlgorithm:分表演算法
    兩個類的核心演算法基本是一樣的。

    • 支援多分片鍵
    • 支援主鍵查詢
  2. 配置shardingRuleConfiguration。
    這裡需要為每個邏輯表配置相關的分庫分表測試。
    表規則配置類:TableRuleConfiguration。它有兩個方法

  • setDatabaseShardingStrategyConfig
  • setTableShardingStrategyConfig

整體來看,shardingjdbc的api使用起來還是比較流暢的。符合工程師思考的邏輯。

04 Feed流

班級動態聚合頁面,每一條Feed包含如下元素:

  • 動態內容(文字,音訊,視訊)
  • 前N個點贊使用者
  • 當前使用者是否收藏,點贊數,收藏數
  • 前N個評論

聚合首頁需要顯示15條首頁動態列表,每條資料從資料資料庫裡讀取,那介面效能肯定不會好。所以我們應該用快取。那麼這裡就引申出一個問題,列表如何快取?

4.1 列表快取

列表如何快取是我非常渴望和大家分享的技能點。這個知識點也是我 2012 年從開源中國上學到的,下面我以「查詢部落格列表」的場景為例。

我們先說第1種方案:對分頁內容進行整體快取。這種方案會 按照頁碼和每頁大小組合成一個快取key,快取值就是部落格資訊列表。 假如某一個部落格內容發生修改, 我們要重新載入快取,或者刪除整頁的快取。

這種方案,快取的顆粒度比較大,如果部落格更新較為頻繁,則快取很容易失效。下面我介紹下第 2 種方案:僅對部落格進行快取。流程大致如下:

1)先從資料庫查詢當前頁的部落格id列表,sql類似:

select id from blogs limit 0,10 

2)批量從快取中獲取部落格id列表對應的快取資料 ,並記錄沒有命中的部落格id,若沒有命中的id列表大於0,再次從資料庫中查詢一次,並放入快取,sql類似:

select id from blogs where id in (noHitId1, noHitId2)

3)將沒有快取的部落格物件存入快取中

4)返回部落格物件列表

理論上,要是快取都預熱的情況下,一次簡單的資料庫查詢,一次快取批量獲取,即可返回所有的資料。另外,關於 緩 存批量獲取,如何實現?

  • 本地快取:效能極高,for 迴圈即可
  • memcached:使用 mget 命令
  • Redis:若快取物件結構簡單,使用 mget 、hmget命令;若結構複雜,可以考慮使用 pipleline,lua指令碼模式

第 1 種方案適用於資料極少發生變化的場景,比如排行榜,首頁新聞資訊等。

第 2 種方案適用於大部分的分頁場景,而且能和其他資源整合在一起。舉例:在搜尋系統裡,我們可以通過篩選條件查詢出部落格 id 列表,然後通過如上的方式,快速獲取部落格列表。

4.2 聚合

Redis:若快取物件結構簡單,使用 mget 、hmget命令;若結構複雜,可以考慮使用 pipleline,lua指令碼模式

這裡我們使用的是pipeline模式。客戶端採用了redisson
虛擬碼:

//新增like zset列表
 ZsetAddCommand zsetAddCommand = new ZsetAddCommand(LIKE_CACHE_KEY + feedId, spaceFeedLike.getCreateTime().getTime(), userId);
pipelineCommandList.add(zsetAddCommand);
//設定feed 快取的載入數量
HashMsetCommand hashMsetCommand = new HashMsetCommand(FeedCacheConstant.FEED_CACHE_KEY + feedId, map);
pipelineCommandList.add(hashMsetCommand);
//一次執行兩個命令
List<?> result = platformBatchCommand.executePipelineCommands(pipelineCommandList);
  1. 根據班級編號查詢出聚合頁面feedIdList;
  2. 根據列表快取的策略分別載入 動態,點贊,收藏,評論資料,並組裝起來。
模組 redis儲存格式
動態 HASH 動態詳情
點贊 ZSET 儲存userId ,前端顯示使用者頭像,使用者快取使用string儲存
收藏 STRING 儲存userId和FeedId的對映
評論 ZSET 儲存評論Id,評論詳情儲存在string儲存

快取全部命中的情況下,動態聚合頁查詢在5毫秒以內,全部走資料庫的情況下50~80ms之間。

05 訊息佇列

我們參考阿里ons client 模仿他的設計模式,做了rocketmq的簡單封裝。

封裝的目的在於方便工程師接入,減少工程師在各種配置上心智的消耗。

  1. 支援批量消費和單條消費;
  2. 支援順序傳送;
  3. 簡單優化了rocketmq broker限流情況下,傳送訊息失敗的場景。

寫在最後

這篇文字主要和大家分享應用重構的架構設計。
其實重構有很多細節需要處理。

  1. 資料遷移方案
  2. 團隊協作,新人培養
  3. 應用平滑升級

每一個細節都需要花費很大的精力,才可能把系統重構好。

相關文章