要分享的議題
- 性能提升:在訪問量逐漸增大的同時,如何增大單台服務器的 PV2 上限,增加 TPS3 ?
- RESTful:相較於傳統的 SOAP1,RESTful 風格架構有哪些優點?做法有哪些區別?
- 微服務:隨着企業越來越大,系統會越來越大,越來越難維護,如何在保證“穩”的同時,還保證有小企業的“靈活”?
簡要的介紹
性能提升
最常用的性能提高方式可以通過使用服務器的集群來解決,簡單粗暴的理解就是增加銀行櫃員的數量。但是,一味的只考慮從服務端提供性能,並不是聰明的做法 —— 應該講求性價比。當然,核心必須是提高服務器的 TPS,即在最短的時間內給最多的客戶提供服務。服務器集群可以大幅提升整體的性能,但是我們要討論的是如何提升單台服務器的性能。
- 服務器的壓力主要來源於三個方面:CPU、網絡和磁盤 IO。磁盤作為最容易達到瓶頸的一方,必須想辦法減少 IO 操作。數據庫作為數據持久性存儲、磁盤開銷的大戶,這里主要就是要減少或合並數據庫操作。
- 系統的流暢性取決於服務端和客戶端的良好配合。網站類的項目,充分利用瀏覽器資源,不僅能降低服務器壓力,還能提供更好地客戶體驗。現代化的瀏覽器,一般都符合 RFC26164 規范。其中很重要的報頭有:ETag 和 Last-Modified 報頭 —— 瀏覽器的緩存設置開關,可以最大限度的利用客戶端資源。
RESTful 架構風格
好比面向過程編程和面向對象編程,這兩者並沒有明確的界限。在適當的地方用適當風格的架構,重點是物盡其用。但 RESTful 作為新興的風格,必然有其優勢:
- 在 RESTful 架構中,關注點在於資源。每個都有一個地址,資源本身就是方法調用的目標。方法列表對所有資源都是一樣的。這些方法都是標准方法,包括 HTTP GET、POST、PUT、DELETE,還可能包括 PATCH、HEADER 和 OPTIONS。其指導思想是遠端提供了一系列資源,客戶端需要下載、展現、編輯和提交更改,重點放在本地。
- 在 RPC 架構中,關注點在於方法。在客戶端看來,就是在客戶端組合條件,然后在服務器中執行,最終再反饋給客戶端。其指導思想是隱藏實現細節,或者關聯其它 RPC 服務運算,重點放在了服務器。
微服務和單體式應用
現代化的單體式應用,通常采用模塊化的方式,圍繞核心模塊並行開發。最終他們需要聯合測試,部署成一個單體式的應用:C# 會部署成 IIS 的一個網站,Java 會打包成 War 格式部署 Tomcat 上。
隨着時間的推移,單體式在應對越來越多的新需求后,會變得越來越大。更不幸的是,因為公司資源和需求的不對等,許多倉促應對的代碼會添加到應用中。這些代碼在短期內不會出現問題,但是修正 Bug 和正常的新功能添加會變得越來越困難,因為通常會涉及到多個模塊,牽一發而動全身。此時就是單體式應用的瓶頸期,會考慮拆分成多個子系統。當然,這將會再維持一段時間,直到再出現相似的問題。
許多公司,比如 Amazon、eBay 和 NetFlix,通過采用微處理結構模式解決了上述問題。其思路不是開發一個巨大的單體式的應用,而是將應用分解為小的、互相連接的微服務。
一個微服務一般完成某個特定的功能,比如下單管理、客戶管理等等。每一個微服務都有自己的業務邏輯和適配器。一些微服務還會發布 Api 給其它微服務和應用客戶端使用。其它微服務完成一個 Web UI。
性能提升
- 數據庫靜態化:數據庫不包含運算邏輯,所有運算邏輯在程序內完成。
- 減少外部 IO:使用數據緩存、合並數據庫操作、讀寫分離。
- 異步化:對於非必須的方法,異步執行使其不影響當前邏輯。
- 子系統拆分:拆分長時間運行的邏輯為 Windows 服務或 Job 。
一個栗子:電子商務系統,下單操作起始涉及到了對多個模塊的調用。但是用戶下單的時候,並不關心這些,只要得到一個下單成功的結果就可以了。我們可以分析一下:系統首先要對用戶提交的信息有效性校驗,再就是業務數據准確性校驗,最后提交到數據庫。一個成功的電商系統,前兩者必須能在很短的時間內完成,並且在秒殺特賣這種場景時不會造成數據庫的崩潰。
基於以上兩點,我們分析下如何優化秒殺特賣這種場景下的操作流程。
- 服務端對信息有效性的校驗,操作頻率最密集、速度要最快,所以不應該涉及除內存運算之外的操作,比如:Redis 和數據庫讀寫、TCP/HTTP 遠程調用等。
- 業務性數據校驗,關聯模塊很多、速度要求較快,所以不應涉及慢速的 IO 操作,比如:數據庫讀寫、HTTP 遠程調用等。
- 寫數據庫頻繁,在較短的時間內給數據庫造成很大的持續壓力、速度要求很快,所以這里可以采用立即反饋,稍后寫入的方式執行。
數據庫靜態化
數據庫的操作都是有鎖的:Select 語句發布共享鎖5,Insert、Update 和 Delete 發布排它鎖6。所以說在操作同一張表的前提下,數據庫操作都是串行7的。
基於以上考慮,讓數據庫只做存儲容器,不負責運算才是正途。正是因為數據庫的操作是串行的,在大並發量寫入時,任何一點的提升都是要爭取的,所以這里要把運算的任務提到程序中執行。
- 存儲過程因為把程序邏輯放在數據庫,一般來說肯定包含運算任務,考慮一般開發的水平不能保證先用臨時表存儲預先計算好的數據(能做到也太繁瑣了,很容易出現異常),最后再統一執行,所以首先要摒棄包含數據庫寫入類的存儲過程。
- 程序內的運算,如果是在開啟事務后仍然存在,也要算入數據庫的運算任務。因為數據庫事務開啟后,獨占的串行已經開始了,程序的運算時間不僅占用了程序的運算時間,還占用了數據庫事務的開啟時長,在本質上並沒有減少數據庫事務的開啟時長。嚴格來算的話,這種做法甚至還不如上一條的做法優化。
所以,真正的數據庫靜態化是:首先在程序內運算,產生數據庫要執行的 SQL 寫入語句和參數,持續運算直到產生所有的數據庫寫入命令;再開啟數據庫事務,按照先進先出原則順序連續執行數據庫寫入命令(此段時間內不能包含其它非數據庫運算)。只有這樣,才能保證命令的執行都是靜態化的寫入,並且鎖定數據庫的時間最短,保證最大化的降低數據庫壓力。
另外一個,正是因為數據庫的操作是串行的,所以在執行數據庫寫入的情況下,是不能讀取的,要避免出現臟讀,數據庫的讀寫分離就很有必要了。建立從庫,由主庫負責寫入,從庫負責讀取,將數據庫的壓力均分到多台上。
數據庫的讀寫分離要注意:剛寫入數據庫的數據,同步到從庫需要 2 到 3 秒的時間,需要在業務上更改流程,以便於在用戶檢索時數據已同步。
減少外部 IO
由於磁盤的限制,其讀寫速度和內存不成比例,所以這里是第二個可能出現瓶頸的地方。可以考慮將配置信息預先讀取到緩存的方式解決。
因為數據庫是依托於硬盤而存在的,所以數據庫的讀寫相對於有效性驗證和業務驗證來說,是時間消耗大戶。在單純的考慮數據庫寫入的情況下,可以從系統內剝離訂單的數據庫寫入業務。一來可以省掉無意義等待數據庫寫入的時間;二來可以減少 CPU 時間片的占用,將時間
另一種是數據庫的讀寫操作,在一個數據庫事務內,是不能有第二個數據庫執行相同的操作的。考慮到數據靜態化中的介紹,數據庫事務開啟后,程序內的運算其實也是數據庫的運算時間的。此時可以考慮推遲數據庫事務的開啟時間:首先在程序內運算產生要執行的數據庫命令,再開啟數據庫事務,在連續的時間內執行批量執行數據庫事務。即合並數據庫操作到一次數據庫執行中。
異步化
在開發的過程中,不可避免的要和其關聯的模塊交互,而這些交互並不會對當前的業務邏輯產生影響。這種操作就應該改成異步的方式。在數據庫交互的過程中,如果用戶不需要等待數據庫的返回值,還可以將數據庫執行異步化,在最短的時間內反饋執行結果。
一個栗子:用戶下單的過程中,提交訂單的操作,其實並不關心提交成功失敗,只是在后續跳轉到訂單詳情的時候才會看到訂單詳情。這個流程就可以將數據庫執行異步化,在服務器接收到用戶的提交請求時,可以在校驗數據后,直接反饋提交成功的響應給用戶。接下來,通過異步隊列的方式,保存到數據庫。客戶端在接收到提交成功的反饋后,提示用戶提交成功,但是不給出訂單的任何信息。用戶只有主動點擊了查看訂單列表,才會執行數據庫查詢。這個時間差足夠系統處理訂單的真正提交操作了。
這樣做最直接的好處是提高了網站的響應速度,優化了用戶體驗,在提升服務器 TPS 的同時,還沒有提升數據庫的壓力。在秒殺特賣時,能夠最大限度的避免超賣的情況。
子系統拆分
繼續上面的例子,數據庫的提交訂單操作,和網站並沒有多少的關系。此時就可以考慮到將這一部分拆分出來,做成一個 Windows 服務,兩者通過消息隊列的方式通訊。隊列的串行讀取正好符合了數據庫的串行執行,在高峰時段也沒有超過數據庫的極限,造成宕機的情況。在超過服務極限的情況下,處理慢比不能處理總是要好的。
從操作系統上來說,系統調度針對每個進程都是平等的。此處將數據庫執行操作從網站拆分出來,減少了數據庫操作對 CPU 時間片的占用,側面提升了網站的服務能力。
RESTful 架構風格與 SOAP 架構風格
- 屬性路由:使用漸進式的 URI 替代傳統平板式的方法名稱 URI。
- 客戶端緩存報頭:使用 ETag 和 Last-Modified 報頭減輕服務器壓力。
- CRUD:使用 GET、POST、PUT 和 DELETE 方法區分數據庫 CRUD 操作。
屬性路由
第一個 WebApi 版本使用的是基於公約的路由。在該類型的路由中, 你可以定義一個或多個被參數化字符串的模版。當這個框架接收到一個請求時,它匹配一個 URI 到路由模版。
基於公約的路由的一個優勢就是:這個模版被定義在一個單獨的地方,路由規則一致的被應用於所有的控制器。不幸的是,基於公約的路由是很難支持確切的URI模式,而這個確切的 URI 模式在 RESTful Api 中是很普遍的。比如,資源經常包含子資源:客戶下了訂單,電影有演員,書有作者等等,它是很自然的創建這些 URI 來反應這些關系:
/customers/3/orders
這種類型的 URI 在基於公約的路由下是比較難實現的。盡管它能做到,但是如果你有許多控制器或者很多資源類型時,不能很好的被擴展。但對於屬性路由,它是很容易的為這個 URI 定義一個路由,你可以簡單的添加一個屬性到控制器的動作上:
[Route("customers/{customerId}/orders")]
public IEnumerable<Order> GetOrdersByCustomerId(int customerId) { ... }
方便的 Api 版本控制
有時候我們需要開發一個功能的新版本,但是並不想對現有的功能產生影響,比如:api/v1/products
和 api/v2/products
可以被路由到不同的控制器。在開發階段就做出較好了區分,並且當新的版本正式商用后,也可以方便的對 V1 版本的控制器過期或停用。
重載 URI 片段
在下面的例子中,12306
表示一個特定的車票,而 notravelled
表示未出行的車票集合。
/tickets/12306
/tickets/notravelled
通過自然語義,人們可以很容易的理解這些 URI 的含義,但是基於公約的方式並不能很方便的解決這個問題。
路由約束
屬性路由添加了公約路由時代所沒有的約束特性,可以讓你在路由模版中限制參數被匹配。常規的語法是 {parameter:constraint}
,例如:
[Route("users/{id:int}"]
public User GetUserById(int id) { ... }
[Route("users/{name}"]
public User GetUserByName(string name) { ... }
如果 URI 的 id
片段是一個 int
類型的,那么第一個路由將會被選擇,否則第二個路由將會被選擇。屬性路由約定特殊規則的路由優先匹配,最后才匹配沒有任何約束的路由。注意不要出現兩種可能的匹配,否則會出現多匹配的問題,比如:
[Route("{id:int}")]
public string Get(int id)
[Route("{id:decimal}")]
public string Get(decimal id)
這里需要注意的是,WebApi 框架有一個 Bug,不支持小數點,比如:
/values/v1/8.3
將不會被解析成decimal
類型。
下面是被支持的約束列表:
約束 | 描述 | 用法演示 |
---|---|---|
bool | 類型匹配(Boolean 類型) | |
datetime | 類型匹配(DateTime 類型) | {x:datetime8} |
decimal | 類型匹配(Decimal 類型) | {x:decimal9} |
double | 類型匹配(64 位浮點數) | {x:double9} |
float | 類型匹配(32 位浮點數) | |
guid | 類型匹配(Guid) | |
int | 類型匹配(32 位整數) | |
long | 類型匹配(64 位整數) | |
alpha | 字符組成(必須由拉丁字母組成) | |
regex | 字符組成(必須與指定的正則表達式匹配) | |
max | 值范圍(小於或等於指定的最大值) | |
min | 值范圍(大於或等於指定的最小值) | |
range | 值范圍(在指定的最小值和最大值之間) | |
maxlength | 字符串最大長度(小於或等於指定的長度) | |
minlength | 字符串最小長度(大於或等於指定的長度) | |
length | 字符串長度(等於指定的長度或者長度在指定的范圍內) |
客戶端緩存報頭
基礎知識
Last-Modified
?
什么是 在瀏覽器第一次請求某一個 URL 時,服務器端的返回狀態會是 200,內容是你請求的資源,同時有一個 Last-Modified
的屬性標記此文件在服務期端最后被修改的時間,格式類似這樣:
Last-Modified: Fri, 12 May 2006 18:53:33 GMT
客戶端第二次請求此 URL 時,根據 HTTP 協議的規定,瀏覽器會向服務器傳送 If-Modified-Since
報頭,詢問該時間之后文件是否有被修改過:
If-Modified-Since: Fri, 12 May 2006 18:53:33 GMT
如果服務器端的資源沒有變化,則自動返回 HTTP 304(Not Modified)狀態碼,內容為空,這樣就節省了傳輸數據量。當服務器端代碼發生改變或者重啟服務器時,則重新發出資源,返回和第一次請求時類似。從而保證不向客戶端重復發出資源,也保證當服務器有變化時,客戶端能夠得到最新的資源。
ETag
?
什么是 HTTP 協議規格說明定義 ETag
為被請求變量的實體值;另一種說法是,ETag
是一個可以與 Web 資源關聯的記號(Token):典型的 Web 資源可以一個 HTML 頁,但也可能是 JSON 或 XML 文檔。服務器單獨負責判斷記號是什么及其含義,並在 HTTP 響應頭中將其傳送到客戶端,以下是服務器端返回的格式:
ETag: W/"9e10cdada3f741f6b0802ee31179837d"
客戶端的查詢更新格式是這樣的:
If-None-Match: W/"9e10cdada3f741f6b0802ee31179837d"
如果 ETag
沒改變,則返回狀態碼 304 內容不返回,這也和 Last-Modified
一樣。本人測試 ETag
主要在斷點下載時比較有用。
Last-Modified
和 ETag
如何幫助提高性能?
聰明的開發者會把 Last-Modified
和 ETag
跟請求的 HTTP 報頭一起使用,這樣可利用客戶端(例如瀏覽器)的緩存。因為服務器首先產生 Last-Modified
/ETag
標記,服務器可在稍后使用它來判斷頁面是否已經被修改。本質上,客戶端通過將該記號傳回服務器要求服務器驗證其(客戶端)緩存。過程如下:
- 客戶端請求一個頁面(A)。
- 服務器返回頁面A,並在給A加上一個
Last-Modified
/ETag
。 - 客戶端展現該頁面,並將頁面連同
Last-Modified
/ETag
一起緩存。 - 客戶再次請求頁面A,並將上次請求時服務器返回的
Last-Modified
/ETag
一起傳遞給服務器。 - 服務器檢查該
Last-Modified
或ETag
,並判斷出該頁面自上次客戶端請求之后還未被修改,直接返回狀態碼 304 和一個空的響應體。
這里的客戶端一般指瀏覽器,通過編程方式使用的客戶端,一般不會處理這兩個 HTTP 請求頭。
微服務
目前不做深入討論。
-
SOAP(Simple Object Access Protocol)簡單對象訪問協議,是交換數據的一種協議規范,是一種輕量的、簡單的、基於XML(標准通用標記語言下的一個子集)的協議,它被設計成在WEB上交換結構化的和固化的信息。 ↩
-
PV:(Page View)即頁面瀏覽量,通常是衡量一個網絡新聞頻道或網站甚至一條網絡新聞的主要指標。網頁瀏覽數是評價網站流量最常用的指標之一,簡稱為 PV。監測網站 PV 的變化趨勢和分析其變化原因是很多站長定期要做的工作。Page Views 中的 Page 一般是指普通的 HTML 網頁,也包含 PHP、JSP 等動態產生的 HTML 內容。來自瀏覽器的一次 HTML 內容請求會被看作一個 PV,逐漸累計成為 PV 總數。 ↩
-
TPS:(Transaction Per Second)每秒鍾系統能夠處理的交易或事務的數量。它是衡量系統處理能力的重要指標。TPS 是 LoadRunner 中重要的性能參數指標。 ↩
-
RFC2616:目前該規范已有部分更新。 ↩
-
共享鎖:類似於讀寫鎖中的讀鎖。可以多個一起讀,但是排斥寫鎖。只有讀鎖釋放后,才能進入寫鎖。 ↩
-
排它鎖:類似於讀寫鎖中的寫鎖。只能一個寫,其余的操作都必須等待,直到當前寫鎖釋放后。 ↩
-
串行:同一時間只允許一個線程操作,其余線程只能等待完成后,才能繼續執行操作。 ↩
-
datetime 類型的約束,如果采用
/
做分隔符,必須放在最后一個,並且采用*
前導:{*x:datetime}
。目前,也只有這種寫法可以跨多個 URI 段。 ↩ -
decimal
和double
兩種數字類型,如果包含小數點將不能被正常解析,目前可以算 WebApi 框架的一個 Bug 。 ↩