Feed流系統重構-架構篇


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

第一次重構是重構一個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. 應用平滑升級

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM