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