天天看點

feed流系統重構-架構篇01 應用背景03 分庫分表04 Feed流05 消息隊列寫在最後

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

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

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

01 應用背景

1. 應用介紹

移動網際網路時代,Feed流産品是非常常見的,比如我們每天都會用到的朋友圈,微網誌,就是一種非常典型的Feed流産品。

Feed(動态):Feed流中的每一條狀态或者消息都是Feed,比如朋友圈中的一個狀态就是一個Feed,微網誌中的一條微網誌就是一個Feed。

Feed流:持續更新并呈現給使用者内容的資訊流。每個人的朋友圈,微網誌關注頁等等都是一個Feed流。

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

feed流系統重構-架構篇01 應用背景03 分庫分表04 Feed流05 消息隊列寫在最後

為什麼要重構呢?

▍ 代碼可維護性

服務端端代碼已經有四年左右的曆史,随着時間的推移,人員的變動,不斷的修複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。
feed流系統重構-架構篇01 應用背景03 分庫分表04 Feed流05 消息隊列寫在最後

我們重點實作了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的分庫方式。

feed流系統重構-架構篇01 應用背景03 分庫分表04 Feed流05 消息隊列寫在最後
舉例:使用者編号23838,crc32(userId)%1024=562,562在區間[256,511]之間。是以該使用者的Feed動态會存儲在t_space_feed1表。

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. 通過classId查詢feedIdList
  2. 分别通過pipeline的模式,依次擷取動态, 點贊,收藏,評論資料
子產品 redis存儲格式
動态 HASH 動态詳情
點贊 ZSET 存儲userId ,前端顯示使用者頭像,使用者緩存使用string存儲
收藏 string存儲使用者是否收藏過該feed
評論 ZSET 存儲評論Id,評論詳情存儲在string存儲

05 消息隊列

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

封裝的目的在于友善工程師接入,減少工程師在各種配置上心智的消耗。

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

寫在最後

這篇文字主要和大家分享應用重構的架構設計。

其實重構有很多細節需要處理。

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

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

feed流系統重構-架構篇01 應用背景03 分庫分表04 Feed流05 消息隊列寫在最後

繼續閱讀