重構,于我而言,很大的快樂在于能夠解決問題。
第一次重構是重構一個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在區間[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 工程
分庫分表一般有三種模式:
- 代理模式,相容mysql協定。如cobar,mycat,drds。
- 代理模式,自定義協定。如藝龍的DDA。
- 用戶端模式,最有名的是shardingsphere的sharding-jdbc。
分庫分表選型使用的是sharding-jdbc,最重要的原因是輕便簡單,而且早期的代碼曾經看過一兩次,原理有基礎的認識。
核心代碼邏輯其實還是蠻清晰的。
ShardingRule shardingRule = new ShardingRule(
shardingRuleConfiguration,
customShardingConfig.getDatasourceNames());
DataSource dataSource = new ShardingDataSource(
dataSourceMap,
shardingRule,
properties);
請注意: 對于整個應用來講,client模式的最終結果是初始化了DataSource的接口。
-
需要定義初始化資料源資訊
datasourceNames是資料源名清單,
dataSourceMap是資料源名和資料源映射。
- 這裡有一個概念邏輯表和實體表。
邏輯表 | 實體表 |
---|---|
t_space_feed (動态表) | t_space_feed_0~3 |
-
分庫算法:
DataSourceHashSlotAlgorithm:分庫算法
TableHashSlotAlgorithm:分表算法
兩個類的核心算法基本是一樣的。
- 支援多分片鍵
- 支援主鍵查詢
-
配置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);
聚合頁面查詢流程:
- 通過classId查詢feedIdList
- 分别通過pipeline的模式,依次擷取動态, 點贊,收藏,評論資料
子產品 | redis存儲格式 |
---|---|
動态 | HASH 動态詳情 |
點贊 | ZSET 存儲userId ,前端顯示使用者頭像,使用者緩存使用string存儲 |
收藏 | string存儲使用者是否收藏過該feed |
評論 | ZSET 存儲評論Id,評論詳情存儲在string存儲 |
05 消息隊列
我們參考阿裡ons client 模仿他的設計模式,做了rocketmq的簡單封裝。
封裝的目的在于友善工程師接入,減少工程師在各種配置上心智的消耗。
- 支援批量消費和單條消費;
- 支援順序發送;
- 簡單優化了rocketmq broker限流情況下,發送消息失敗的場景。
寫在最後
這篇文字主要和大家分享應用重構的架構設計。
其實重構有很多細節需要處理。
- 資料遷移方案
- 團隊協作,新人培養
- 應用平滑更新
每一個細節都需要花費很大的精力,才可能把系統重構好。