前言
金九銀十,又是一年校招季。
經(jīng)歷過,才深知不易。最近,和作為校招面試官的同事聊了聊,問他們是如何去考察一個學(xué)生的,我簡單歸為以下幾點:
聰明、反應(yīng)快,這點自不必說,聰明意味著學(xué)習(xí)能力、適應(yīng)力強,能夠快速勝任工作。
算法不錯,代碼基本功好,這點其實考察的是算法能力和代碼是否寫得優(yōu)雅。
基礎(chǔ)過硬,技術(shù)崗面試最核心的還是考察「技術(shù)儲備」,包括了語言基本功,操作系統(tǒng)、網(wǎng)絡(luò)、體系結(jié)構(gòu)、系統(tǒng)設(shè)計。
語言組織和表達能力,這點很重要,很多同學(xué)懂得某個知識點,卻很難用簡潔準(zhǔn)確的語言表述出來。
想必有很多同學(xué)在刷題、刷面經(jīng),不過我想說“面經(jīng)雖好,不要貪杯哦~”,面經(jīng)可以刷,看看面試官都是怎么提問的,但不要寄希望于原題。
因為面試過程中的問題往往是一環(huán)扣一環(huán)的,這意味著你需要有足夠的技術(shù)深度,將知識由點連接成面,而不是停留在相互孤立的知識點上。
所以還是建議系統(tǒng)性的看書,如果覺得時間不夠,可以關(guān)注書里的重點章節(jié)。
那么回到技術(shù)面試上,除了算法和網(wǎng)絡(luò)、操作系統(tǒng)這種基礎(chǔ)之外,還有一類系統(tǒng)設(shè)計和優(yōu)化的問題。這類問題需要你有一個全局的技術(shù)視野,以及熟悉一些常用的系統(tǒng)優(yōu)化方法論,也就是工程上的一些 Best Practice,而不至于自己臨時拍腦袋瞎設(shè)計。
在互聯(lián)網(wǎng)公司,經(jīng)常面臨一個“三高”問題:
高并發(fā)
高性能
高可用
這篇文章將總結(jié)一下后臺服務(wù)器開發(fā)中有哪些常用的解決“三高”問題的方法和思想。
希望這些知識,能夠給你一絲啟發(fā)和幫助,助力你收割 各大公司 Offer~
先上本文思維導(dǎo)圖:
如何解決三高
正文
一、緩存
什么是緩存?看看維基百科怎么說:
In computing, a cache is a hardware or software component that stores data so that future requests for that data can be served faster; the data stored in a cache might be the result of an earlier computation or a copy of data stored elsewhere.
在計算機中,緩存是存儲數(shù)據(jù)的硬件或軟件組件,以便可以更快地滿足將來對該數(shù)據(jù)的請求。存儲在緩存中的數(shù)據(jù)可能是之前計算結(jié)果,也可能是存儲在其他位置的數(shù)據(jù)副本。
緩存本質(zhì)來說是使用空間換時間的思想,它在計算機世界中無處不在, 比如 CPU 就自帶 L1、L2、L3 Cache,這個一般應(yīng)用開發(fā)可能關(guān)注較少。但是在一些實時系統(tǒng)、大規(guī)模計算模擬、圖像處理等追求極致性能的領(lǐng)域,就特別注重編寫緩存友好的代碼。
什么是緩存友好?簡單來說,就是代碼在訪問數(shù)據(jù)的時候,盡量使用緩存命中率高的方式。這個后面可以單獨寫一篇 CPU 緩存系統(tǒng)以及如何編寫緩存友好代碼的文章。
1.1 緩存為什么有效?
緩存之所以能夠大幅提高系統(tǒng)的性能,關(guān)鍵在于數(shù)據(jù)的訪問具有局部性,也就是二八定律:「百分之八十的數(shù)據(jù)訪問是集中在 20% 的數(shù)據(jù)上」。這部分數(shù)據(jù)也被叫做熱點數(shù)據(jù)。
緩存一般使用內(nèi)存作為存儲,內(nèi)存讀寫速度快于磁盤,但容量有限,十分寶貴,不可能將所有數(shù)據(jù)都緩存起來。
如果應(yīng)用訪問數(shù)據(jù)沒有熱點,不遵循二八定律,即大部分數(shù)據(jù)訪問并沒有集中在小部分數(shù)據(jù)上,那么緩存就沒有意義,因為大部分數(shù)據(jù)還沒有被再次訪問就已經(jīng)被擠出緩存了。每次訪問都會回源到數(shù)據(jù)庫查詢,那么反而會降低數(shù)據(jù)訪問效率。
1.2 緩存分類
1. 本地緩存:
使用進程內(nèi)成員變量或者靜態(tài)變量,適合簡單的場景,不需要考慮緩存一致性、過期時間、清空策略等問題。
可以直接使用語言標(biāo)準(zhǔn)庫內(nèi)的容器來做存儲。例如:
本地緩存2. 分布式緩存:
當(dāng)緩存的數(shù)據(jù)量增大以后,單機不足以承載緩存服務(wù)時,就要考慮對緩存服務(wù)做水平擴展,引入緩存集群。
將數(shù)據(jù)分片后分散存儲在不同機器中,如何決定每個數(shù)據(jù)分片存放在哪臺機器呢?一般是采用一致性 Hash 算法,它能夠保證在緩存集群動態(tài)調(diào)整,不斷增加或者減少機器后,客戶端訪問時依然能夠根據(jù) key 訪問到數(shù)據(jù)。
一致性 Hash 算法也是值得用一篇文章來講的,如果暫時還不懂的話可以去搜一下。
常用的組件有 Memcache、 Redis Cluster 等,第二個是在高性能內(nèi)存存儲 Redis 的基礎(chǔ)上,提供分布式存儲的解決方案。
1.3 緩存使用指南
1. 適合緩存的場景:
讀多寫少:
比如電商里的商品詳情頁面,訪問頻率很高,但是一般寫入只在店家上架商品和修改信息的時候發(fā)生。如果把熱點商品的信息緩存起來,這將攔截掉很多對數(shù)據(jù)庫的訪問,提高系統(tǒng)整體的吞吐量。
因為一般數(shù)據(jù)庫的 QPS 由于有「ACID」約束、并且數(shù)據(jù)是持久化在硬盤的,所以比 Redis 這類基于內(nèi)存的 NoSQL 存儲低不少。常常是一個系統(tǒng)的瓶頸,如果我們把大部分的查詢都在 Redis 緩存中命中了,那么系統(tǒng)整體的 QPS 也就上去了。
計算耗時大,且實時性不高:
比如王者榮耀里的全區(qū)排行榜,一般一周更新一次,并且計算的數(shù)據(jù)量也比較大,所以計算后緩存起來,請求排行榜直接從緩存中取出,就不用實時計算了。
2. 不適合緩存的場景:
寫多讀少,頻繁更新。
對數(shù)據(jù)一致性要求嚴格: 因為緩存會有更新策略,所以很難做到和數(shù)據(jù)庫實時同步。
數(shù)據(jù)訪問完全隨機: 因為這樣會導(dǎo)致緩存的命中率極低。
1.4 緩存更新的策略
如何更新緩存其實已經(jīng)有總結(jié)得非常好的「最佳實踐」,我們按照套路來,大概率不會犯錯。
主要分為兩類 Cache-Aside 和 Cache-As-SoR。 SoR 即「System Of Record,記錄系統(tǒng)」,表示數(shù)據(jù)源,一般就是指數(shù)據(jù)庫。
1、Cache-Aside:
Cache-Aside架構(gòu)圖
這應(yīng)該是最容易想到的模式了,獲取數(shù)據(jù)時先從緩存讀,如果 cache hit 則直接返回,沒命中就從數(shù)據(jù)源獲取,然后更新緩存。
寫數(shù)據(jù)的時候則先更新數(shù)據(jù)源,然后設(shè)置緩存失效,下一次獲取數(shù)據(jù)的時候必然 cache miss,然后觸發(fā)回源。
直接看偽代碼:
Cache-Aside 代碼示范
可以看到這種方式對于緩存的使用者是不透明的,需要使用者手動維護緩存。
2、Cache-As-SoR:
Cache-As-SoR架構(gòu)圖
從字面上來看,就是把 Cache 當(dāng)作 SoR,也就是數(shù)據(jù)源,所以一切讀寫操作都是針對 Cache 的,由 Cache 內(nèi)部自己維護和數(shù)據(jù)源的一致性。
這樣對于使用者來說就和直接操作 SoR 沒有區(qū)別了,完全感知不到 Cache 的存在。
CPU 內(nèi)部的 L1、L2、L3 Cache 就是這種方式,作為數(shù)據(jù)的使用方應(yīng)用程序,是完全感知不到在內(nèi)存和我們之間還存在幾層的 Cache,但是我們之前又提到編寫 “緩存友好”的代碼,不是透明的嗎?這是不是沖突呢?
其實不然,緩存友好是指我們通過學(xué)習(xí)了解緩存內(nèi)部實現(xiàn)、更新策略之后,通過調(diào)整數(shù)據(jù)訪問順序提高緩存的命中率。
Cache-As-SoR 又分為以下三種方式:
Read Through:這種方式和 Cache-Aside 非常相似,都是在查詢時發(fā)生 cache miss 去更新緩存,但是區(qū)別在于 Cache-Aside 需要調(diào)用方手動更新緩存,而 Cache-As-SoR 則是由緩存內(nèi)部實現(xiàn)自己負責(zé),對應(yīng)用層透明。
Write Through:直寫式,就是在將數(shù)據(jù)寫入緩存的同時,緩存也去更新后面的數(shù)據(jù)源,并且必須等到數(shù)據(jù)源被更新成功后才可返回。這樣保證了緩存和數(shù)據(jù)庫里的數(shù)據(jù)一致性。
Write Back:回寫式,數(shù)據(jù)寫入緩存即可返回,緩存內(nèi)部會異步的去更新數(shù)據(jù)源,這樣好處是寫操作特別快,因為只需要更新緩存。并且緩存內(nèi)部可以合并對相同數(shù)據(jù)項的多次更新,但是帶來的問題就是數(shù)據(jù)不一致,可能發(fā)生寫丟失。
二、預(yù)處理和延后處理
預(yù)先延后,這其實是一個事物的兩面,不管是預(yù)先還是延后核心思想都是將本來該在實時鏈路上處理的事情剝離,要么提前要么延后處理。降低實時鏈路的路徑長度, 這樣能有效提高系統(tǒng)性能。
2.1 預(yù)處理
舉個我們團隊實際中遇到的問題:
前兩個月支付寶聯(lián)合杭州市政府發(fā)放消費劵,但是要求只有杭州市常駐居民才能領(lǐng)取,那么需要在搶卷請求進入后臺的時候就判斷一下用戶是否是杭州常駐居民。
而判斷用戶是否是常駐居民這個是另外一個微服務(wù)接口,如果直接實時的去調(diào)用那個接口,短時的高并發(fā)很有可能把這個服務(wù)也拖掛,最終導(dǎo)致整個系統(tǒng)不可用,并且 RPC 本身也是比較耗時的,所以就考慮在這里進行優(yōu)化。
那么該怎么做呢?很簡單的一個思路,提前將杭州所有常駐居民的 user_id 存到緩存中, 比如可以直接存到 Redis。大概就是千萬量級,這樣,當(dāng)請求到來的時候我們直接通過緩存可以快速判斷是否來自杭州常駐居民。如果不是則直接在這里返回前端。
這里通過預(yù)先處理減少了實時鏈路上的 RPC 調(diào)用,既減少了系統(tǒng)的外部依賴,也極大的提高了系統(tǒng)的吞吐量。
預(yù)處理在 CPU 和操作系統(tǒng)中也廣泛使用,比如 CPU 基于歷史訪存信息,將內(nèi)存中的指令和數(shù)據(jù)預(yù)取到 Cache 中,這樣可以大大提高Cache 命中率。 還比如在 Linux 文件系統(tǒng)中,預(yù)讀算法會預(yù)測即將訪問的 page,然后批量加載比當(dāng)前讀請求更多的數(shù)據(jù)緩存在 page cache 中,這樣當(dāng)下次讀請求到來時可以直接從 cache 中返回,大大減少了訪問磁盤的時間。
2.2 延后處理
還是支付寶,上栗子:
集五福活動
這是支付寶春節(jié)集五?;顒娱_獎當(dāng)晚,不過,作為非酋的我一般是不屑于參與這種活動的。
大家發(fā)現(xiàn)沒有,這類活動中獎獎金一般會顯示 「稍后到賬」,為什么呢?那當(dāng)然是到賬這個操作不簡單!
到賬即轉(zhuǎn)賬,A 賬戶給 B 賬戶轉(zhuǎn)錢,A 減錢, B 就必須要同時加上錢,也就是說不能 A 減了錢但 B 沒有加上,這就會導(dǎo)致資金損失。資金安全是支付業(yè)務(wù)的生命線,這可不行。
這兩個動作必須一起成功或是一起都不成功,不能只成功一半,這是保證數(shù)據(jù)一致性。 保證兩個操作同時成功或者失敗就需要用到事務(wù)。
如果去實時的做到賬,那么大概率數(shù)據(jù)庫的 TPS(每秒處理的事務(wù)數(shù)) 會是瓶頸。通過產(chǎn)品提示,將到賬操作延后處理,解決了數(shù)據(jù)庫 TPS 瓶頸。
延后處理還有一個非常著名的例子,COW(Copy On Write,寫時復(fù)制)。 Linux 創(chuàng)建進程的系統(tǒng)調(diào)用 fork,fork 產(chǎn)生的子進程只會創(chuàng)建虛擬地址空間,而不會分配真正的物理內(nèi)存,子進程共享父進程的物理空間,只有當(dāng)某個進程需要寫入的時候,才會真正分配物理頁,拷貝該物理頁,通過 COW 減少了很多不必要的數(shù)據(jù)拷貝。
三、池化
后臺開發(fā)過程中你一定離不開各種 「池子」: 內(nèi)存池、連接池、線程池、對象池……
內(nèi)存、連接、線程這些都是資源,創(chuàng)建線程、分配內(nèi)存、數(shù)據(jù)庫連接這些操作都有一個特征, 那就是創(chuàng)建和銷毀過程都會涉及到很多系統(tǒng)調(diào)用或者網(wǎng)絡(luò) IO。 每次都在請求中去申請創(chuàng)建這些資源,就會增加請求處理耗時,但是如果我們用一個 容器(池) 把它們保存起來,下次需要的時候,直接拿出來使用,避免重復(fù)創(chuàng)建和銷毀浪費的時間。
3.1 內(nèi)存池
在 C/C++ 中,經(jīng)常使用 malloc、new 等 API 動態(tài)申請內(nèi)存。由于申請的內(nèi)存塊大小不一,如果頻繁的申請、釋放會導(dǎo)致大量的內(nèi)存碎片,并且這些 API 底層依賴系統(tǒng)調(diào)用,會有額外的開銷。
內(nèi)存池就是在使用內(nèi)存前,先向系統(tǒng)申請一塊空間留做備用,使用者需要內(nèi)池時向內(nèi)存池申請,用完后還回來。
內(nèi)存池的思想非常簡單,實現(xiàn)卻不簡單,難點在于以下幾點:
如何快速分配內(nèi)存
降低內(nèi)存碎片率
維護內(nèi)存池所需的額外空間盡量少
如果不考慮效率,我們完全可以將內(nèi)存分為不同大小的塊,然后用鏈表連接起來,分配的時候找到大小最合適的返回,釋放的時候直接添加進鏈表。如:
空閑鏈表
當(dāng)然這只是玩具級別的實現(xiàn),業(yè)界有性能非常好的實現(xiàn)了,我們可以直接拿來學(xué)習(xí)和使用。
比如 Google 的 「tcmalloc」 和 Facebook 的 「jemalloc」。
限于篇幅我們不在這里詳細講解它們的實現(xiàn)原理,如果感興趣可以搜來看看,也推薦去看看被譽為神書的 CSAPP(《深入理解計算機系統(tǒng)》)第 10 章,那里也講到了動態(tài)內(nèi)存分配算法。
3.2 線程池
線程是干嘛的?線程就是我們程序執(zhí)行的實體。在服務(wù)器開發(fā)領(lǐng)域,我們經(jīng)常會為每個請求分配一個線程去處理,但是線程的創(chuàng)建銷毀、調(diào)度都會帶來額外的開銷,線程太多也會導(dǎo)致系統(tǒng)整體性能下降。在這種場景下,我們通常會提前創(chuàng)建若干個線程,通過線程池來進行管理。當(dāng)請求到來時,只需從線程池選一個線程去執(zhí)行處理任務(wù)即可。
線程池常常和隊列一起使用來實現(xiàn)任務(wù)調(diào)度,主線程收到請求后將創(chuàng)建對應(yīng)的任務(wù),然后放到隊列里,線程池中的工作線程等待隊列里的任務(wù)。
線程池實現(xiàn)上一般有四個核心組成部分:
管理器(Manager): 用于創(chuàng)建并管理線程池。
工作線程(Worker): 執(zhí)行任務(wù)的線程。
任務(wù)接口(Task): 每個具體的任務(wù)必須實現(xiàn)任務(wù)接口,工作線程將調(diào)用該接口來完成具體的任務(wù)。
任務(wù)隊列(TaskQueue): 存放還未執(zhí)行的任務(wù)。
線程池模型
線程池在 C、C++ 中沒有具體的實現(xiàn),需要應(yīng)用開發(fā)者手動實現(xiàn)上訴幾個部分。
在 Java 中 「ThreadPoolExecutor」 類就是線程池的實現(xiàn)。后續(xù)我也會寫文章分析 C++ 如何寫一個簡單的線程池以及 Java 中線程池是如何實現(xiàn)的。
3.3 連接池
顧名思義,連接池是創(chuàng)建和管理連接的。
大家最熟悉的莫過于數(shù)據(jù)庫連接池,這里我們簡單分析下如果不用數(shù)據(jù)庫連接池,一次 SQL 查詢請求會經(jīng)過哪些步驟:
和 MySQL server 建立 TCP 連接:
三次握手
MySQL 權(quán)限認證:
Server 向 Client 發(fā)送 密鑰
Client 使用密鑰加密用戶名、密碼等信息,將加密后的報文發(fā)送給 Server
Server 根據(jù) Client 請求包,驗證是否是合法用戶,然后給 Client 發(fā)送認證結(jié)果
Client 發(fā)送 SQL 語句
Server 返回語句執(zhí)行結(jié)果
MySQL 關(guān)閉
TCP 連接斷開
四次揮手
可以看出不使用連接池的話,為了執(zhí)行一條 SQL,會花很多時間在安全認證、網(wǎng)絡(luò)IO上。
如果使用連接池,執(zhí)行一條 SQL 就省去了建立連接和斷開連接所需的額外開銷。
還能想起哪里用到了連接池的思想嗎?我認為 HTTP 長鏈接也算一個變相的鏈接池,雖然它本質(zhì)上只有一個連接,但是思想?yún)s和連接池不謀而合,都是為了復(fù)用同一個連接發(fā)送多個 HTTP 請求,避免建立和斷開連接的開銷。
池化實際上是預(yù)處理和延后處理的一種應(yīng)用場景,通過池子將各類資源的創(chuàng)建提前和銷毀延后。
四、同步變異步
對于處理耗時的任務(wù),如果采用同步的方式,那么會增加任務(wù)耗時,降低系統(tǒng)并發(fā)度。
可以通過將同步任務(wù)變?yōu)楫惒竭M行優(yōu)化。
舉個例子,比如我們?nèi)?KFC 點餐,遇到排隊的人很多,當(dāng)點完餐后,大多情況下我們會隔幾分鐘就去問好了沒,反復(fù)去問了好幾次才拿到,在這期間我們也沒法干活了,這時候我們是這樣的:
同步寫法
這個就叫同步輪訓(xùn), 這樣效率顯然太低了。
服務(wù)員被問煩了,就在點完餐后給我們一個號碼牌,每次準(zhǔn)備好了就會在服務(wù)臺叫號,這樣我們就可以在被叫到的時候再去取餐,中途可以繼續(xù)干自己的事。
這就叫異步,在很多編程語言中有異步編程的庫,比如 C++ std::future、Python asyncio 等,但是異步編程往往需要回調(diào)函數(shù)(Callback function),如果回調(diào)函數(shù)的層級太深,這就是回調(diào)地獄(Callback hell)?;卣{(diào)地獄如何優(yōu)化又是一個龐大的話題。。。。
這個例子相當(dāng)于函數(shù)調(diào)用的異步化,還有的是情況是處理流程異步化,這個會在接下來消息隊列中講到。
五、消息隊列
消息隊列示意圖
這是一個非常簡化的消息隊列模型,上游生產(chǎn)者將消息通過隊列發(fā)送給下游消費者。在這之間,消息隊列可以發(fā)揮很多作用,比如:
5.1 服務(wù)解耦
有些服務(wù)被其它很多服務(wù)依賴,比如一個論壇網(wǎng)站,當(dāng)用戶成功發(fā)布一條帖子有一系列的流程要做,有積分服務(wù)計算積分,推送服務(wù)向發(fā)布者的粉絲推送一條消息….. 對于這類需求,常見的實現(xiàn)方式是直接調(diào)用:
直接調(diào)用
這樣如果需要新增一個數(shù)據(jù)分析的服務(wù),那么又得改動發(fā)布服務(wù),這違背了依賴倒置原則,即上層服務(wù)不應(yīng)該依賴下層服務(wù),那么怎么辦呢?
發(fā)布訂閱模式
引入消息隊列作為中間層,當(dāng)帖子發(fā)布完成后,發(fā)送一個事件到消息隊列里,而關(guān)心帖子發(fā)布成功這件事的下游服務(wù)就可以訂閱這個事件,這樣即使后續(xù)繼續(xù)增加新的下游服務(wù),只需要訂閱該事件即可,完全不用改動發(fā)布服務(wù),完成系統(tǒng)解耦。
5.2 異步處理
有些業(yè)務(wù)涉及到的處理流程非常多,但是很多步驟并不要求實時性。那么我們就可以通過消息隊列異步處理。比如淘寶下單,一般包括了風(fēng)控、鎖庫存、生成訂單、短信/郵件通知等步驟。但是核心的就風(fēng)控和鎖庫存, 只要風(fēng)控和扣減庫存成功,那么就可以返回結(jié)果通知用戶成功下單了。后續(xù)的生成訂單,短信通知都可以通過消息隊列發(fā)送給下游服務(wù)異步處理。大大提高了系統(tǒng)響應(yīng)速度。
這就是處理流程異步化。
5.3 流量削峰
一般像秒殺、抽獎、搶卷這種活動都伴隨著短時間海量的請求, 一般超過后端的處理能力,那么我們就可以在接入層將請求放到消息隊列里,后端根據(jù)自己的處理能力不斷從隊列里取出請求進行業(yè)務(wù)處理。
就像最近長江汛期,上游短時間大量的洪水匯聚直奔下游,但是通過三峽大壩將這些水緩存起來,然后勻速的向下游釋放,起到了很好的削峰作用。
起到了平均流量的作用。
5.4 總結(jié)
消息隊列的核心思想就是把同步的操作變成異步處理,異步處理會帶來相應(yīng)的好處,比如:
服務(wù)解耦
提高系統(tǒng)的并發(fā)度,將非核心操作異步處理,不會阻塞住主流程
但是軟件開發(fā)沒有銀彈,所有的方案選擇都是一種 trade-off。 同樣,異步處理也不全是好處,也會導(dǎo)致一些問題:
降低了數(shù)據(jù)一致性,從強一致性變?yōu)樽罱K一致性
有消息丟失的風(fēng)險,比如宕機,需要有容災(zāi)機制
六、批量處理
在涉及到網(wǎng)絡(luò)連接、IO等情況時,將操作批量進行處理能夠有效提高系統(tǒng)的傳輸速率和吞吐量。
在前后端通信中,通過合并一些頻繁請求的小資源可以獲得更快的加載速度。
比如我們后臺 RPC 框架,經(jīng)常有更新數(shù)據(jù)的需求,而有的數(shù)據(jù)更新的接口往往只接受一項,這個時候我們往往會優(yōu)化下更新接口,
使其能夠接受批量更新的請求,這樣可以將批量的數(shù)據(jù)一次性發(fā)送,大大縮短網(wǎng)絡(luò) RPC 調(diào)用耗時。
七、數(shù)據(jù)庫
我們常把后臺開發(fā)調(diào)侃為「CRUD」,數(shù)據(jù)庫在整個應(yīng)用開發(fā)過程中的重要性不言而喻。
而且很多時候系統(tǒng)的瓶頸也往往處在數(shù)據(jù)庫這里,慢的原因也有很多,比如可能是沒用索引、沒用對索引、讀寫鎖沖突等等。
那么如何使用數(shù)據(jù)才能又快又好呢?下面這幾點需要重點關(guān)注:
7.1 索引
索引可能是我們平時在使用數(shù)據(jù)庫過程中接觸得最多的優(yōu)化方式。索引好比圖書館里的書籍索引號,想象一下,如果我讓你去一個沒有書籍索引號的圖書館找《人生》這本書,你是什么樣的感受?當(dāng)然是懷疑人生,同理,你應(yīng)該可以理解當(dāng)你查詢數(shù)據(jù),卻不用索引的時候數(shù)據(jù)庫該有多崩潰了吧。
數(shù)據(jù)庫表的索引就像圖書館里的書籍索引號一樣,可以提高我們檢索數(shù)據(jù)的效率。索引能提高查找效率,可是你有沒有想過為什么呢?這是因為索引一般而言是一個排序列表,排序意味著可以基于二分思想進行查找,將查詢時間復(fù)雜度做到 O(log(N)),快速的支持等值查詢和范圍查詢。
二叉搜索樹查詢效率無疑是最高的,因為平均來說每次比較都能縮小一半的搜索范圍,但是一般在數(shù)據(jù)庫索引的實現(xiàn)上卻會選擇 B 樹或 B+ 樹而不用二叉搜索樹,為什么呢?
這就涉及到數(shù)據(jù)庫的存儲介質(zhì)了,數(shù)據(jù)庫的數(shù)據(jù)和索引都是存放在磁盤,并且是 InnoDB 引擎是以頁為基本單位管理磁盤的,一頁一般為 16 KB。AVL 或紅黑樹搜索效率雖然非常高,但是同樣數(shù)據(jù)項,它也會比 B、B+ 樹更高,高就意味著平均來說會訪問更多的節(jié)點,即磁盤IO次數(shù)!
根據(jù) Google 工程師 Jeff Dean 的統(tǒng)計,訪問內(nèi)存數(shù)據(jù)耗時大概在 100 ns,訪問磁盤則是 10,000,000 ns。
所以表面上來看我們使用 B、B+ 樹沒有 二叉查找樹效率高,但是實際上由于 B、B+ 樹降低了樹高,減少了磁盤 IO 次數(shù),反而大大提升了速度。
這也告訴我們,沒有絕對的快和慢,系統(tǒng)分析要抓主要矛盾,先分析出決定系統(tǒng)瓶頸的到底是什么,然后才是針對瓶頸的優(yōu)化。
其實關(guān)于索引想寫的也還有很多,但還是受限于篇幅,以后再單獨寫。
先把我認為索引必知必會的知識列出來,大家可以查漏補缺:
主鍵索引和普通索引,以及它們之間的區(qū)別
最左前綴匹配原則
索引下推
覆蓋索引、聯(lián)合索引
7.2 讀寫分離
一般業(yè)務(wù)剛上線的時候,直接使用單機數(shù)據(jù)庫就夠了,但是隨著用戶量上來之后,系統(tǒng)就面臨著大量的寫操作和讀操作,單機數(shù)據(jù)庫處理能力有限,容易成為系統(tǒng)瓶頸。
由于存在讀寫鎖沖突,并且很多大型互聯(lián)網(wǎng)業(yè)務(wù)往往讀多寫少,讀操作會首先成為數(shù)據(jù)庫瓶頸,我們希望消除讀寫鎖沖突從而提升數(shù)據(jù)庫整體的讀寫能力。
那么就需要采用讀寫分離的數(shù)據(jù)庫集群方式,一主多從,主庫會同步數(shù)據(jù)到從庫。寫操作都到主庫,讀操作都去從庫。
讀寫分離
讀寫分離到之后就避免了讀寫鎖爭用,這里解釋一下,什么叫讀寫鎖爭用:
MySQL 中有兩種鎖:
排它鎖( X 鎖): 事務(wù) T 對數(shù)據(jù) A 加上 X 鎖時,只允許事務(wù) T 讀取和修改數(shù)據(jù) A。
共享鎖( S 鎖): 事務(wù) T 對數(shù)據(jù) A 加上 S 鎖時,其他事務(wù)只能再對數(shù)據(jù) A 加 S 鎖,而不能加 X 鎖,直到 T 釋放 A 上的 S 鎖。
讀寫分離解決問題的同時也會帶來新問題,比如主庫和從庫數(shù)據(jù)不一致
MySQL 的主從同步依賴于 binlog,binlog(二進制日志)是 MySQL Server 層維護的一種二進制日志,是獨立于具體的存儲引擎。它主要存儲對數(shù)據(jù)庫更新(insert、delete、update)的 SQL 語句,由于記錄了完整的 SQL 更新信息,所以 binlog 是可以用來數(shù)據(jù)恢復(fù)和主從同步復(fù)制的。
從庫從主庫拉取 binlog 然后依次執(zhí)行其中的 SQL 即可達到復(fù)制主庫的目的,由于從庫拉取 binlog 存在網(wǎng)絡(luò)延遲等,所以主從數(shù)據(jù)存在延遲問題。
那么這里就要看業(yè)務(wù)是否允許短時間內(nèi)的數(shù)據(jù)不一致,如果不能容忍,那么可以通過如果讀從庫沒獲取到數(shù)據(jù)就去主庫讀一次來解決。
7.3 分庫分表
如果用戶越來越多,寫請求暴漲,對于上面的單 Master 節(jié)點肯定扛不住,那么該怎么辦呢?多加幾個 Master?不行,這樣會帶來更多的數(shù)據(jù)不一致的問題,增加系統(tǒng)的復(fù)雜度。那該怎么辦?就只能對庫表進行拆分了。
常見的拆分類型有垂直拆分和水平拆分。
考慮拼夕夕電商系統(tǒng),一般有 訂單表、用戶表、支付表、商品表、商家表等, 最初這些表都在一個數(shù)據(jù)庫里。
后來隨著砍一刀帶來的海量用戶,拼夕夕后臺扛不住了! 于是緊急從阿貍粑粑那里挖來了幾個 P8、P9 大佬對系統(tǒng)進行重構(gòu)。
P9 大佬第一步先對數(shù)據(jù)庫進行垂直分庫,
根據(jù)業(yè)務(wù)關(guān)聯(lián)性強弱,將它們分到不同的數(shù)據(jù)庫, 比如訂單庫,商家?guī)?、支付庫、用戶庫?/p>第二步是對一些大表進行垂直分表,將一個表按照字段分成多表,每個表存儲其中一部分字段。 比如商品詳情表可能最初包含了幾十個字段,但是往往最多訪問的是商品名稱、價格、產(chǎn)地、圖片、介紹等信息,所以我們將不常訪問的字段單獨拆成一個表。
由于垂直分庫已經(jīng)按照業(yè)務(wù)關(guān)聯(lián)切分到了最小粒度,數(shù)據(jù)量任然非常大,P9 大佬開始水平分庫,比如可以把訂單庫分為訂單1庫、訂單2庫、訂單3庫…… 那么如何決定某個訂單放在哪個訂單庫呢?可以考慮對主鍵通過哈希算法計算放在哪個庫。
分完庫,單表數(shù)據(jù)量任然很大,查詢起來非常慢,P9 大佬決定按日或者按月將訂單分表,叫做日表、月表。
分庫分表同時會帶來一些問題,比如平時單庫單表使用的主鍵自增特性將作廢,因為某個分區(qū)庫表生成的主鍵無法保證全局唯一,這就需要引入全局 UUID 服務(wù)了。
經(jīng)過一番大刀闊斧的重構(gòu),拼夕夕恢復(fù)了往日的活力,大家又可以愉快的在上面互相砍一刀了。
(分庫分表會引入很多問題,并沒有一一介紹,這里只是為了講解什么是分庫分表)
八、具體技法
8.1 零拷貝
高性能的服務(wù)器應(yīng)當(dāng)避免不必要數(shù)據(jù)復(fù)制,特別是在用戶空間和內(nèi)核空間之間的數(shù)據(jù)復(fù)制。 比如 HTTP 靜態(tài)服務(wù)器發(fā)送靜態(tài)文件的時候,一般我們會這樣寫:
發(fā)送文件
如果了解 Linux IO 的話就知道這個過程包含了內(nèi)核空間和用戶空間之間的多次拷貝:
IO示意圖
內(nèi)核空間和用戶空間之間數(shù)據(jù)拷貝需要 CPU 親自完成,但是對于這類數(shù)據(jù)不需要在用戶空間進行處理的程序來說,這樣的兩次拷貝顯然是浪費。什么叫 「不需要在用戶空間進行處理」?
比如 FTP 或者 HTTP 靜態(tài)服務(wù)器,它們的作用只是將文件從磁盤發(fā)送到網(wǎng)絡(luò),不需要在中途對數(shù)據(jù)進行編解碼之類的計算操作。
如果能夠直接將數(shù)據(jù)在內(nèi)核緩存之間移動,那么除了減少拷貝次數(shù)以外,還能避免內(nèi)核態(tài)和用戶態(tài)之間的上下文切換。
而這正是零拷貝(Zero copy)干的事,主要就是利用各種零拷貝技術(shù),減少不必要的數(shù)據(jù)拷貝,將 CPU 從數(shù)據(jù)拷貝這樣簡單的任務(wù)解脫出來,讓 CPU 專注于別的任務(wù)。
常用的零拷貝技術(shù):
mmap
mmap通過內(nèi)存映射,將文件映射到內(nèi)核緩沖區(qū),同時,用戶空間可以共享內(nèi)核空間的數(shù)據(jù)。這樣,在進行網(wǎng)絡(luò)傳輸時,就可以減少內(nèi)核空間到用戶空間的拷貝次數(shù)。
mmap
sendfile
sendfile是 Linux2.1 版本提供的,數(shù)據(jù)不經(jīng)過用戶態(tài),直接從頁緩存拷貝到 socket 緩存,同時由于和用戶態(tài)完全無關(guān),就減少了一次上下文切換。
在 Linux 2.4 版本,對 sendfile 進行了優(yōu)化,直接通過 DMA 將磁盤文件數(shù)據(jù)讀取到 socket 緩存,真正實現(xiàn)了 ”0” 拷貝。前面 mmap 和 2.1 版本的 sendfile 實際上只是消除了用戶空間和內(nèi)核空間之間拷貝,而頁緩存和 socket 緩存之間的拷貝依然存在。
8.2 無鎖化
在多線程環(huán)境下,為了避免 競態(tài)條件(race condition), 我們通常會采用加鎖來進行并發(fā)控制,鎖的代價也是比較高的,鎖會導(dǎo)致上線文切換,甚至被掛起直到鎖被釋放。
基于硬件提供的原子操作 CAS(Compare And Swap) 實現(xiàn)一些高性能無鎖的數(shù)據(jù)結(jié)構(gòu),比如無鎖隊列,可以在保證并發(fā)安全的情況下,提供更高的性能。
首先需要理解什么是 CAS,CAS 有三個操作數(shù),內(nèi)存里當(dāng)前值M,預(yù)期值 E,修改的新值 N,CAS 的語義就是:
如果當(dāng)前值等于預(yù)期值,則將內(nèi)存修改為新值,否則不做任何操作。
用 C 語言來表達就是:
CAS
注意,上面 CAS 函數(shù)實際上是一條原子指令,那么是如何用的呢?
假設(shè)我需要實現(xiàn)這樣一個功能:
對一個全局變量 global 在兩個不同線程分別對它加 100 次,這里多線程訪問一個全局變量存在 race condition,所以我們需要采用線程同步操作,下面我分別用鎖和CAS的方法來實現(xiàn)這個功能。
CAS和鎖示范
通過使用原子操作大大降低了鎖沖突的可能性,提高了程序的性能。
除了 CAS,還有一些硬件原子指令:
Fetch-And-Add,對變量原子性 + 1
Test-And-Set,這是各種鎖算法的核心,在 AT&T/GNU 匯編語法下,叫 xchg 指令,我會單獨寫一篇如何使用 xchg 實現(xiàn)各種鎖。
8.3 序列化與反序列化
先看看維基百科怎么定義的序列化:
In computing, serialization (US spelling) or serialisation (UK spelling) is the process of translating a data structure or object state into a format that can be stored (for example, in a file or memory data buffer) or transmitted (for example, across a computer network) and reconstructed later (possibly in a different computer environment). When the resulting series of bits is reread according to the serialization format, it can be used to create a semantically identical clone of the original object. For many complex objects, such as those that make extensive use of references, this process is not straightforward. Serialization of object-oriented objects does not include any of their associated methods with which they were previously linked.
我相信你大概率沒有看完上面的英文描述,其實我也不愛看英文資料,總覺得很慢,但是計算機領(lǐng)域一手的學(xué)習(xí)資料都是美帝那邊的,所以沒辦法,必須逼自己去試著讀一些英文的資料。
實際上也沒有那么難,熟悉常用的幾百個專業(yè)名詞,句子都是非常簡單的一些從句。沒看的話,再倒回去看看?
這里我就不做翻譯了,主要是水平太低,估計做到「信達雅」的信都很難。
扯遠了,還是回到序列化來。
所有的編程一定是圍繞數(shù)據(jù)展開的,而數(shù)據(jù)呈現(xiàn)形式往往是結(jié)構(gòu)化的,比如結(jié)構(gòu)體(Struct)、類(Class)。 但是當(dāng)我們 通過網(wǎng)絡(luò)、磁盤等傳輸、存儲數(shù)據(jù)的時候卻要求是二進制流。 比如 TCP 連接,它提供給上層應(yīng)用的是面向連接的可靠字節(jié)流服務(wù)。那么如何將這些結(jié)構(gòu)體和類轉(zhuǎn)化為可存儲和可傳輸?shù)淖止?jié)流呢?這就是序列化要干的事情,反之,從字節(jié)流如何恢復(fù)為結(jié)構(gòu)化的數(shù)據(jù)就是反序列化。
序列化解決了對象持久化和跨網(wǎng)絡(luò)數(shù)據(jù)交換的問題。
序列化一般按照序列化后的結(jié)果是否可讀,可分為以下兩類:
文本類型:
如 JSON、XML,這些類型可讀性非常好,是自解釋的。也常常用在前后端數(shù)據(jù)交互上,因為接口調(diào)試,可讀性高非常方便。但是缺點就是信息密度低,序列化后占用空間大。
二進制類型
如 Protocol Buffer、Thrift等,這些類型采用二進制編碼,數(shù)據(jù)組織得更加緊湊,信息密度高,占用空間小,但是帶來的問題就是基本不可讀。
還有 Java 、Go 這類語言內(nèi)置了序列化方式,比如在 Java 里實現(xiàn)了 Serializable 接口即表示該對象可序列化。
說到這讓我想起了大一寫的的兩個程序,一個是用剛 C 語言寫的公交管理系統(tǒng),當(dāng)時需要將公交線路、站點信息持久化保存,當(dāng)時的方案就是每個公交線路寫在一行,用 "|"分割信息,比如:
5|6:00-22:00|大學(xué)城|南山站|北京站
123|6:30-23:00|南湖大道|茶山劉|世界
第一列就是線路編號、第二項是發(fā)車時間、后面就是途徑的站點。是不是非常原始?實際上這也是一種序列化方式,只是效率很低,也不通用。而且存在一個問題就是如果信息中包含 “|”怎么辦?當(dāng)然是用轉(zhuǎn)義。
第二個程序是用 Java 寫的網(wǎng)絡(luò)五子棋,當(dāng)時需要通過網(wǎng)絡(luò)傳輸表示棋子位置的對象,查了一圈最后發(fā)現(xiàn)只需要實現(xiàn) Serializable 接口,自己什么都不用干,就能自己完成對象的序列化,然后通過網(wǎng)絡(luò)傳輸后反序列化。當(dāng)時哪懂得這就叫序列化,只覺得牛逼、神奇!
最后完成了一個可以網(wǎng)絡(luò)五子棋,拉著隔壁室友一起玩。。。真的是成就感滿滿哈哈哈。
說來在編程方面,已經(jīng)很久沒有這樣的成就感了。
總結(jié)
這篇文章主要是粗淺的介紹了一些系統(tǒng)設(shè)計、系統(tǒng)優(yōu)化的套路和最佳實踐。
不知道你發(fā)現(xiàn)沒有,從緩存到消息隊列、CAS……,很多看起來很牛逼的架構(gòu)設(shè)計其實都來源于操作系統(tǒng)、體系結(jié)構(gòu)。
所以我非常熱衷學(xué)習(xí)一些底層的基礎(chǔ)知識,這些看似古老的技術(shù)是經(jīng)過時間洗禮留下來的好東西?,F(xiàn)在很多的新技術(shù)、框架看似非常厲害,實則不少都是新瓶裝舊酒,每幾年又會被淘汰一批。
絮叨
大家好,我是小林,一個專為大家圖解的工具人,如果覺得文章對你有幫助,歡迎分享給你的朋友,我們下次見!
免責(zé)聲明:本文內(nèi)容由21ic獲得授權(quán)后發(fā)布,版權(quán)歸原作者所有,本平臺僅提供信息存儲服務(wù)。文章僅代表作者個人觀點,不代表本平臺立場,如有問題,請聯(lián)系我們,謝謝!