重構,於我而言,很大的快樂在於能夠解決問題。
第一次重構是重構一個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 工程
分庫分表一般有三種模式:
- 代理模式,相容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);
- 根據班級編號查詢出聚合頁面feedIdList;
- 根據列表快取的策略分別載入 動態,點贊,收藏,評論資料,並組裝起來。
模組 | redis儲存格式 |
---|---|
動態 | HASH 動態詳情 |
點贊 | ZSET 儲存userId ,前端顯示使用者頭像,使用者快取使用string儲存 |
收藏 | STRING 儲存userId和FeedId的對映 |
評論 | ZSET 儲存評論Id,評論詳情儲存在string儲存 |
快取全部命中的情況下,動態聚合頁查詢在5毫秒以內,全部走資料庫的情況下50~80ms之間。
05 訊息佇列
我們參考阿里ons client 模仿他的設計模式,做了rocketmq的簡單封裝。
封裝的目的在於方便工程師接入,減少工程師在各種配置上心智的消耗。
- 支援批量消費和單條消費;
- 支援順序傳送;
- 簡單優化了rocketmq broker限流情況下,傳送訊息失敗的場景。
寫在最後
這篇文字主要和大家分享應用重構的架構設計。
其實重構有很多細節需要處理。
- 資料遷移方案
- 團隊協作,新人培養
- 應用平滑升級
每一個細節都需要花費很大的精力,才可能把系統重構好。