結構梳理:大并發下,你的數據庫表可能成為性能隱患
用戶中心是典型的讀多寫少系統,我們的許多系統也屬于這種類型。這類系統通過引入緩存技術可以顯著提升性能。在流量增大時,用戶中心通常成為系統優化的首要模塊,因為它通常與多個系統有高度耦合。因此,梳理和優化該模塊對于整個系統的高并發改造至關重要。
我們將專注于優化讀多寫少的用戶中心數據整理,使其更容易進行緩存。數據梳理是一項關鍵技能,對于任何需要高并發改造的老系統,建議先對數據庫表進行梳理。老系統在使用數據庫時,通常存在諸多問題,例如實體表字段過多、表查詢維度和用途多樣、表關系混亂,甚至存在m:n關系。這些問題都會增加緩存改造的難度,嚴重影響改造進度。
通過從數據結構入手,先在特定場景下進行優化,再實施緩存技術,將會極大簡化后續的高并發改造。因此,梳理數據庫結構是進行系統高并發改造的重要一步。
精簡數據會有更好的性能
圖片
用戶中心的主要功能是維護用戶信息、用戶權限和登錄狀態,它保存的數據大部分都屬于讀多寫少的數據。用戶中心常見的優化方式主要是將用戶中心和業務徹底拆開,不再與業務耦合,并適當增加緩存來提高系統性能。
我舉一個簡單的例子:當時整表內有接近 2000 萬的賬號信息,我對表的功能和字段進行了業務解耦和精簡,讓用戶中心的賬戶表里只會保留用戶登陸所需的賬號、密碼:
CREATE TABLE `account` (
`id` int(10) NOT NULL AUTO_INCREMENT,
`account` char(32) COLLATE utf8mb4_unicode_ci NOT NULL,
`password` char(32) COLLATE utf8mb4_unicode_ci NOT NULL,
`salt` char(16) COLLATE utf8mb4_unicode_ci NOT NULL,
`status` tinyint(3) NOT NULL DEFAULT '0',
`update_time` int(10) NOT NULL,
`create_time` int(10) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `login_account` (`account`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
數據庫是系統的核心,如果它表現緩慢,所有業務都會受到影響,整個服務的性能很難超過核心數據庫的上限。精簡賬號表字段的核心在于:更短的數據長度在吞吐、查詢、傳輸上更快,管理和緩存也更方便。精簡后的表字段更少,業務用途單一,通常只用于檢測用戶登錄賬號密碼是否正確,而不涉及其他訪問或范圍查詢。這種精簡的表在性能上表現出色,即使存儲了兩千萬個賬號,整體表現依然優異。
不過,需要注意的是,雖然精簡數據可以提高響應速度,但過度精簡并不可取。如果表字段缺乏適當的冗余,會導致業務實現復雜化。例如,如果賬戶表精簡掉用戶昵稱和頭像字段,那么每次登錄都需要額外讀取一次數據庫,并始終關注緩存同步更新;相反,如果保留這些字段,登錄驗證后就可以直接進行其他業務操作,無需再次查詢數據庫。由此可見,精簡幾個字段往往會導致額外的數據庫查詢,同時增加緩存同步負擔,得不償失。因此,我們需要在“更多字段”和“更少職能”之間找到合理的平衡點。
數據的歸類及深入整理
除了通過精簡表的職能來提高表的性能和維護性外,我們還可以針對不同類型的表做不同方向的緩存優化,如下圖用戶中心表例子:
圖片
數據主要分為四種類型:實體對象主表、輔助查詢表、實體關系和歷史數據。不同類型的數據需要采用不同的緩存策略。如果將一些職能不清晰的數據強行放入緩存,使用時可能會遇到許多復雜問題。
我曾遇到一個典型的錯誤做法:將用戶來訪記錄這種持續增長的操作歷史放入緩存。這個記錄的主要用途是統計好友和陌生人來訪的數量,但它同時保存了用戶的好友關系標志。這意味著,一旦用戶關系發生變化,這些歷史數據就需要同步更新,否則好友關系將變得“過時”。
圖片
將歷史記錄和需要實時更新的好友狀態混在一起,顯然不合理。如果我們做歸類梳理的話,應該拆分成三個職能表,分別進行管理:歷史記錄表,不做緩存,僅展示最近幾條,極端情況臨時緩存;好友關系(緩存關系,用于統計有幾個好友);來訪統計數字(臨時緩存)。
數據實體表
首先來看用戶賬號表,這是一個實體表,通常作為主表,每行數據代表一個獨立的實體,并且每個實體都有一個獨立且唯一的 ID 作為標識。在這里,“實體”指的是一個抽象事物,而具體字段表示該實體的實時狀態屬性。這個 ID 在高并發環境下的緩存中至關重要,用戶登錄后可以通過自己的賬戶 ID 快速查找對應的訂單、昵稱、頭像和好友列表信息。如果業務主要通過這種方式查找,性能會非常好,且非常適合長期緩存。
然而,除了按 ID 查找外,還有一些業務需要通過組合條件進行查詢,比如:7 月 4 日購買耳機的訂單有哪些?在天津的新注冊用戶有多少?老用戶的數量又是多少?昨天是否有用戶名以 rick 開頭的賬戶注冊?這類基于條件的查詢和統計數據并不適合做緩存,因為高并發服務中的緩存數據通常是能通過 Hash 快速匹配的,而帶條件查詢的統計數據容易出現不一致性和數據量不確定性,導致性能不穩定。此外,如果相關數據發生變化,我們也很難確定應該同步更新哪些緩存。
因此,這類數據更適合存放在關系數據庫中,或者提前計算結果并放入緩存中進行使用,并定期更新。
除了組合條件查詢難以緩存外,像 count()、sum() 這類需要實時計算的操作也存在更新不及時的問題,只能定期緩存匯總結果,避免頻繁查詢。因此,在后續開發中,我們應盡量避免使用數據庫來進行實時計算。
回到實體表的設計,這類表通常針對業務的主要查詢需求而設計。如果我們偏離這個設計用途來查詢表,性能往往會大打折扣。比如,用于賬戶登錄的表,當我們用它來查詢昵稱中是否包含“極客”時,需要額外增加對“用戶昵稱”字段的索引。這類 LIKE 查詢會掃描全表數據進行計算,并且若查詢頻率較高,可能會嚴重影響其他用戶的登錄體驗。同時,增加的昵稱索引會降低該表插入數據的性能,這也是為何在后臺系統中,通常會單獨分出一個從庫,做特殊的索引查詢。
在高并發場景中,為了優化讀取性能,緩存通常用于保存實體數據。常見的方法是通過“key 前綴 + 實體 ID”獲取數據(例如 user_info_9527),然后利用緩存中的關聯關系進一步獲取指定數據。例如,通過 ID 直接獲取用戶好友關系的 key,從而獲取用戶好友 ID 列表。通過這種方式,我們可以在 Redis 中實現用戶的常用關聯查詢操作。
總體來說,實體數據是我們業務的主要承載體,當我們找到實體主體的時候,就可以根據這個主體在緩存中查到所有和它有關聯的數據,來服務用戶。現在我們來稍微總結一下,我們整理實體表的核心思路主要有以下幾點:精簡數據總長度;減少表承擔的業務職能;減少統計計算查詢;實體數據更適合放在緩存當中;盡量讓實體能夠通過 ID 或關系方式查找;減少實時條件篩選方式的對外服務。
實體輔助表
為了精簡數據并便于管理,我們常常根據不同用途對主表進行拆分,常見的方式是縱向表拆分。縱向表拆分的主要目的有兩個:一是將使用頻率較低的數據摘出來,以精簡主表的職能;二是輔助表的主鍵通常與主表一致或通過記錄 ID 關聯,它們之間的關系多為 1:1。輔助表中保存的數據一般在主要業務查詢中不使用,僅在特定場景下取用,比如用戶賬號表用于用戶登錄,而輔助信息表保存家庭住址、省份、微信和郵編等不常展示的信息。
輔助表的另一個作用是輔助查詢。當原有業務數據結構無法滿足其他維度的實體查詢時,可以通過輔助表實現。例如,一個以“教師”為主體的表,通常根據“當前教師 ID + 條件”查詢學生和班級數據。但當系統從學生的角度出發時,需要頻繁以“學生和班級”為基礎查詢教師數據,這時就需先查出“學生 ID”或“班級 ID”,再查找教師 ID,既不方便又低效。因此,可以將學生和班級數據拆分到一個輔助表中,方便這些查詢。
值得提醒的是,輔助表和主體表之間可能存在 1或 m的關系,因此我們需要定期整理和核對數據,以確保冗余數據的同步和完整。然而,維護非 1:1 數據關系的輔助表并不容易,容易導致數據不一致或延遲,有時還需刷新所有相關關系的緩存,既耗時又費力。通過腳本定期執行數據核對,找出差異會更加簡單。此外,為提高查詢效率,我們常常在多個表中冗余同一數據,數據更新時需同步更新冗余表和緩存。
行業內也常用一些開源搜索引擎輔助進行類似的關系業務查詢,例如使用 ElasticSearch 進行商品檢索,使用 OpenSearch 進行文章檢索等。這些可橫向擴容的服務能夠顯著減輕數據庫查詢壓力,但其唯一缺點是很難實現數據的強一致性,因此需要人工檢測和核對兩個系統的數據。
實體關系表
接下來我們再談談實體之間的關系。
圖片
對于關系型數據,我強烈建議使用一個額外的關系表來記錄實體間的 m
關聯關系,這樣兩個實體無需相互依賴,更容易維護。對于 1:n或 m:n關系的數據緩存,建議提前評估可能涉及的數據量,防止緩存數據量過大影響性能。一般情況下,我們會用主體的 ID 作為緩存 key,value 中保存多個關聯 ID 以記錄數據間的關系。對于訪問頻率特別高的業務緩存,可以將數據按關系提前組織好,整體緩存,以便快速查詢和使用。
需要注意的是,這種關聯數據往往會產生多級依賴,使得數據整理非常復雜。當相關表或查詢條件更新時,我們必須及時同步緩存中的數據。因此,多級依賴在高并發系統中很難維護,通常會降低一致性要求以滿足高并發需求。
總結一下,哪些數據適合做緩存?通常來說,能夠通過 ID 精確匹配的數據實體非常適合緩存;通過 String、List 或 Set 指令形成的多條 value 數據結構適合用于(1:1、1:n、m:n)輔助或關系查詢。另外,雖然 Hash 結構適合用于實體表的屬性和狀態存儲,但 Hgetall 指令性能較差,容易導致緩存卡頓,不建議使用。
圖片
動作歷史表
一般來說,動作歷史數據表用于記錄數據實體的動作或狀態變化過程,比如用戶登錄日志、積分消費或獲取記錄等。這類數據隨著時間不斷增長,主要用于記錄和展示近期的信息,不建議將其用于業務的實時統計計算。
你可能對這個建議有疑問,那我舉個例子來說明:假設我們有一個積分領取記錄表,包含 2000 萬條記錄,現在需要統計某個用戶領取 ID 為 15 的商品的數量。這種情況下,如果直接對這張表進行實時統計計算,不僅效率低,還會增加數據庫的負擔。因此,不建議將這類歷史數據用于高頻的實時統計。
CREATE TABLE `user_score_history` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`uid` int(10) NOT NULL DEFAULT '',
`action` varchar(32) NOT NULL,
`action_id` char(16) NOT NULL,
`status` tinyint(3) NOT NULL DEFAULT '0'
`extra` TEXT NOT NULL DEFAULT '',
`update_time` int(10) NOT NULL DEFAULT '0',
`create_time` int(10) NOT NULL DEFAULT '0',
PRIMARY KEY (`id`),
KEY uid(`uid`,`action`),
) ENGINE=InnoDB AUTO_INCREMENT=1
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_unicode_ci;
select uid, count(*) as action_count, product_id
from user_score_history
where uid = 9527 and action = "fetch_gift"
and action_id = 15 and status = 1
group by uid,action_id
可以看出,這類表的數據量非常大,記錄了大量的實體操作歷史,并且字段和索引并不適合進行這種查詢。當我們計算某個用戶領取 ID 為 15 的商品數量時,只能先通過 UID 索引過濾數據以縮小范圍。然而,即便這樣篩選,數據量依然龐大。隨著時間的推移,這張表的數據會不斷增長,查詢效率也會逐漸降低。
因此,對于這種需要依賴大量數據統計得出的結論數據,不建議對外提供實時統計計算服務,因為這種查詢會嚴重拖慢數據庫,影響系統的穩定性。即使使用緩存來臨時保存統計結果,這也只是權宜之計。更好的方案是借助其他表來完成這類需求,比如設置一個實時查詢領取記錄表,以獲得更高的查詢效率。
智能總結:
1. 數據梳理是關鍵技巧,對表進行梳理可解決老系統在高并發改造中的問題。
2. 平衡“更多字段”和“更少職能”可提高性能,避免過度設計。
3. 對不同類型數據進行歸類處理,拆分成不同表管理,可提高系統性能和維護性。
4. 實體數據適合放在緩存中,通過ID或關系方式查找,減少實時條件篩選對外服務。
5. 輔助表數據不適合放在緩存中,維護一致性較為麻煩,需要定期核對和同步更新。
6. 實體關系表的緩存管理復雜,需降低一致性要求以滿足高并發情況。
7. 根據ID能夠精準匹配的數據實體適合做緩存,而通過String、List或Set指令形成的有多條value的結構適合做輔助或關系查詢。
8. Hash結構適合做實體表的屬性和狀態,但Hgetall指令性能并不好,不建議使用。