你有沒有想過,為什麼跨行轉賬要告訴你2小時內到賬,而不是立即到賬?為什麼抖音那麼多使用者同時在使用,卻很少出現崩潰的情況?電商網站是如何支撐住雙十一全國人民買買買的?
效能優化對一個產品的重要性不言而喻,它直接影響網站的使用者留存率,APP在商店的評分和使用者粘性。一個響應慢的應用,即便它功能再強大,也留不住使用者。
效能優化對一個程式設計師同樣非常重要——如果你是一個有追求的程式設計師的話。我們說,大多數人的職業生涯發展都應該是一個T字型,要在某一方面有深度,也要有廣度。而效能優化,恰恰是一個既需要深度,又需要廣度的話題。相信很多程式設計師都有一個成為架構師的夢,如果想要成為一個系統架構師,那效能優化是不得不學習瞭解的。
下面將從各個方面,把我知道的關於效能優化的各種“套路”介紹給大家,歡迎大家看完後留言交流探討。
找到最慢的節點
我們談效能時,一般來說有兩個指標,一個是響應時間,一個是吞吐量。
說到響應時間,最直觀的感受就是快與慢。開啟一個網頁需要多少毫秒、點選一個按鈕需要等待多長時間、刷一個抖音視訊需要載入多久?這些都是在說響應時間。
吞吐量指的是系統在單位時間能夠承受的請求數量,反映是系統的承載能力。
下面列出一些常見的影響效能的地方。
網路傳輸
影響響應時間的因素有很多,我們可以通過一些監控去測試。但我可以很負責任的告訴你,在大多數場景,一次完整的網路請求中,最多的時間往往是消耗在網路傳輸上。
網路傳輸有很多種,比如服務端到客戶端的請求和響應,還有服務端各個微服務之間的介面呼叫耗時,還有應用與資料庫、應用與快取、應用與訊息中介軟體等等之間的網路傳輸。
當然,大多數時候,我們會把後端的一些東西儘量放在一個機房裡面,這樣可以直接走內網,網路傳輸時間不會很高。但相比於大多數應用程式碼的執行時間來說,這些網路傳輸也是一筆不小的消耗。
SQL查詢
我們應用的資料可能會持久化在不同的地方,不管是關係型資料庫、非關係型資料庫、還是搜尋引擎,一旦資料量上去了,如果沒有做好效能優化,查詢的時候很容易就耗費大量的時間。
最常見的就是SQL慢查詢了,一旦產生了慢查詢,輕則響應時間變慢,重則拖滿執行緒池導致整個服務不可用。所以SQL查詢也是我們經常會考慮到的效能優化方向。
執行緒等待
執行緒等待指的是執行緒同步造成的問題。現代伺服器往往是多核的,我們通常會使用執行緒池來發揮多核伺服器的優勢。但使用多執行緒經常會遇到的問題就是執行緒的同步問題。
在高併發下,執行緒同步其實是一個很危險的操作。如果臨界區的操作比較耗時,就會導致大量的執行緒等待、堆積,最終撐滿執行緒池。比如上面提到的慢SQL就常常會導致這個問題。
所以我們在使用多執行緒的時候,一定要小心謹慎。儘量弄清楚它的原理,想辦法避免或者更輕量級地上鎖。比如Yasin前幾天發的幾篇關於ThreadLocal的文章,裡面就介紹了在某些場景可以用ThreadLocal避免執行緒的同步。
多執行緒是一塊比較大,也比較難的知識點,也是網際網路大廠的入門門檻,面試必問,工作中也會經常用到。這裡推薦我之前參與寫作的一本關於多執行緒的開源電子書《深入淺出Java多執行緒》,在我的公眾號“編了個程”回覆“多執行緒”即可領取這本書的電子版。
Full GC
Full GC其實影響整個應用效能的概率比較小。但如果你的程式沒寫好,或者JVM引數沒有設定好,造成了頻繁Full GC或者Full GC時間過長,也是有可能會影響效能的。
對JVM有所瞭解的朋友都知道,Full GC會STW (Stop The World),這段時間程式會暫停,也就沒法響應使用者。
❝G1對Full GC做了優化,把單執行緒的Full GC變成了多執行緒並行Full GC。但如果Full GC頻繁,仍然會影響應用的效能。
❞
這裡列一下Full GC頻繁的原因,有興趣的朋友可以自己再深入瞭解一下:
老年代設定的空間太小 永久代空間不足 程式中寫了很多大物件 晉升老年代的代數閾值設定太小
高頻優化思路
針對上面提到的幾個最容易影響程式效能的點,下面介紹一些高頻的效能優化思路,大多數都是針對網路傳輸的,從各種角度去減少網路傳輸的消耗。從這些思路入手,大概率可以很明顯地提升效能,小夥伴們可以作為參考。
CDN
前面提到,一般來說,一個使用者請求大多數時間是消耗在網路傳輸上的,尤其是客戶端與服務端之間的網路傳輸。
CDN全稱是“Content Delivery Network”,翻譯過來叫內容分發網路。原理其實很簡單,就是把資源分發到全國各地甚至是世界各地,使得使用者可以就近取得資源,「縮短網路傳輸的距離」,降低延遲,所以可以很明顯地提升響應速度和成功率。
所以CDN多是用於「檔案分發」,比如網站的css、js、圖片、視訊等資原始檔。曾經聽過一句話:如果你的網站沒有使用CDN,那麼使用CDN基本上可以讓你的網站效能得到「大幅度提升」。
CDN一般是和OSS配合起來使用的。現在各大主流雲廠商基本都提供了OSS和CDN的產品,我自己的個人網站是使用的七牛雲,有「10G的免費容量」,比較適合於個人站長。
壓縮
另一個優化網路傳輸消耗的思路就是壓縮了。CDN的思路是讓網路傳輸的距離更短,壓縮的思路是「讓網路傳輸的內容更小」。尤其適用於js/css等文字檔案,壓縮收益非常高,往往能節省很多的網路傳輸開銷。
圖片和視訊當然也可以壓縮,不過需要選擇合適的壓縮演算法。比如我們用微信傳送圖片時,如果不點選“傳送原圖”,那圖片就會被微信壓縮,雖然可能沒那麼高清,但是檔案小很多,使得使用者可以更快收到圖片,提升使用者體驗。
現在大多數網站都會使用nginx作為前端伺服器或者負載均衡器,在nginx裡面可以非常方便地啟動gzip壓縮功能:
server{
gzip on;
gzip_buffers 32 4K;
gzip_comp_level 6;
gzip_min_length 100;
gzip_types application/javascript text/css text/xml;
gzip_vary on;
}
複製程式碼
在瀏覽器開啟“開發者控制檯”,檢視資源的網路請求,可以檢視該資源是否啟用了壓縮演算法。
❝注意:gzip壓縮演算法比較適用於html、js、css等文字檔案,不適用於圖片等二進位制檔案。對圖片使用gzip壓縮收益不高,反而可能會增加體積。
❞
預載入
前面講到了兩個網路傳輸的優化思路。我們可以另闢蹊徑:既然網路傳輸那麼消耗時間,那我們「偷偷在不忙的時候提前下載好」不行嗎?
大家可以做一個實驗:刷抖音刷到一半,等一會兒,然後停掉自己的網路,再往下刷,可以發現還能刷好幾個視訊。
這就是因為抖音使用了預載入技術,當你在專心致志地看一個有趣的視訊的時候,這個時候網路其實是空閒的,抖音就在悄悄下載後面幾個視訊,這樣你就可以一直刷刷刷,使用者體驗就會很順暢。
試想一下,如果不使用預載入,使用者每次往下刷,都得等幾秒鐘把這個視訊下載下來才能看,那自然使用者體驗極差。
當然了,預載入並不適用於所有場景。畢竟預載入是提前下載,並不是不下載。如果不是有特定的業務場景,其實也沒必要使用預載入。不合理地使用預載入甚至有可能會影響正常的業務,還有可能造成資料不一致的問題。
慢SQL優化
很多應用後端會使用關係型資料庫來持久化資料。如果資料量大了,索引設定不合理,就很有可能會產生慢SQL。
生產環境最好加上慢SQL監控和分析的工具。阿里出品的Druid就很不錯。對很多中小型專案來說已經足夠了。
慢SQL優化一般有幾個思路。
先看是不是沒有命中索引,如果沒有命中,是否可以調整索引或者SQL語句? 看能不能從業務程式碼層面解決? 能不能在應用層面加快取? 考慮是否需要分庫分表?
索引這個東西大家應該或多或少都接觸過或者聽說過。分析索引需要有一定的資料庫基礎,這裡推薦《高效能MySQL》這本書,對索引講得比較清晰。我的個人網站yasinshaw.com上面也有我之前寫的關於MySQL的系列文章:
從業務層面解決也是可以思考的一個點。比如我之前優化過的一個慢SQL。優化前的做法是用count
查詢資料庫還有多少資料,如果大於0,就delete掉這批資料,每次delete 3k條。結果每天資料庫都有幾十萬條符合這個查詢條件的資料,導致每次count查詢都會耗費許多時間,即使走了索引,也得掃描幾十行。而delete也因為資料量太大,執行時間超過1秒,收到告警。
long count = dao.count(condition);
while(count > 0) {
dao.delete(condition);
count = dao.count(condition);
}
複製程式碼
優化思路也很簡單,因為delete的時候會返回刪除的行數。所以我們直接用這個資料來決定是否退出迴圈就行了,根本不需要count查詢。同時把批量刪除的行數從3k條修改為1k條,這樣兩個慢SQL問題就都解決了。
long deletedNumber = 0;
do {
deletedNumber = dao.delete(condition);
} while (deletedNumber > 0)
複製程式碼
快取和分庫分表會在下**詳細介紹,這裡不贅述。
JVM調優和升級
頻繁的GC會佔用大量的JVM資源,還會浪費CPU的資源。所以如果是生產環境,推薦給JVM也加上監控和告警,這樣能夠隨時觀察JVM的狀況是否健康。
尤其是對於Full GC,要格外注意,因為Full GC會Stop The World。前段時間我們有一個應用就是因為永久代空間不足,觸發Full GC,而每次回收效果又不好,導致一遍又一遍地Full GC,影響應用的正常工作。
Java也在對JVM不斷進行優化和升級,比如最新的ZGC,在大堆下效能表現優異。G1也非常不錯,如果專案條件允許,建議使用稍微新一點的垃圾收集器。
後端架構優化思路
前面介紹了一些高頻的效能優化思路,這部分主要從架構的視角去談談如何優化你的應用,不只是效能,有包括穩定性和吞吐量。
一開始我們的應用可能是一個很簡單的單體應用,慢慢地使用者變得越來越多,這個時候最開始那種簡單的架構可能已經不足以支撐這個使用者量和資料量,這個時候就需要對它進行升級,那如何升級呢?核心就是一個字:拆。
微服務是從業務視角把一個大的應用拆成許多小的應用。使應用減小依賴,更容易開發、部署、管理。而拆成一個個微服務後,一些高頻使用的微服務需要搭建叢集對外提供能力,這個時候可能就要依賴微服務框架的負載均衡和彈性擴容/縮容能力了。
負載均衡
單臺伺服器的處理能力是有限的。所以我們可以在多臺伺服器上部署一模一樣的應用。這樣進來的請求就可以分攤到多臺伺服器上。
負載均衡的手段和演算法有很多種。軟體的話有nginx,可以支撐好幾萬的併發。一些微服務框架比如Spring Cloud也提供了負載均衡的能力(Ribbon)。也有一些專門的負載均衡硬體,比如F5等。各大雲廠商也提高了負載均衡產品(比如AWS的ELB),可以很方便地與其它雲元件結合起來使用。
❝使用負載均衡需要注意,應用最好是“無狀態”的,比如HTTP session最好不要放在應用記憶體裡。
❞
彈性擴容/縮容
使用負載均衡能夠讓應用支撐住更多的請求。但伺服器是要花錢滴,很多應用的訪問量在一天中並不是均衡的,可能在某個時間段會顯著比其它時間段要高一些,比如下班後到睡覺這段時間。 也可能會有一些訪問量激增的情況。不管是意料之中(比如電商網站的秒殺活動),還是意料之外(比如某明星緋聞上了微博熱搜)。
彈性擴容和彈性縮容,可以在檢測到流量上升的時候增加伺服器的數量,在流量下降的時候減小伺服器的數量。這樣就可以在保證業務正常執行的情況下,儘可能地節約成本。
容器時代已經來臨,k8s提供了容器的彈性擴容和縮容功能,使用者只需要進行很簡單的配置,即可實現彈性擴容和縮容,非常的nice。
讀寫分離
解決了應用層面的問題,接下來我們就需要解決資料庫層面的問題了。因為Web應用都是木桶原理,如果在資料庫層面效能不行,哪怕你的應用層面做得再好,那也無濟於事。
在大多數業務場景中,讀的請求一般是遠遠大於寫的請求數量的。而寫的時候,往往比較耗時,還有可能鎖住某些行,導致讀資料庫的請求阻塞。
所以我們可以把單個數據庫拆成主庫和從庫,然後把所有到資料庫的請求分成讀請求和寫請求。一般為一主一從或者一主多從。其中,主資料庫負責處理寫請求,然後同步到從資料庫。從資料庫主要負責讀請求。如果是多個從節點,一般會在前面負載均衡。由於資料庫請求一般是走的TCP協議,所以比較推薦的負載均衡開源工具是LVS。
使用讀寫分離也是有一定的代價的。比如主從同步的時間間隙可能會造成使用者讀到的資料不是實時資料,只能保證主從節點上的資料是最終一致的。所以這點要從業務上考慮清楚,是否可以接受這種資料不一致。
分庫分表
讀寫分離後可以在一定程度上緩解資料庫壓力。但如果資料量持續增多,使用讀寫分離也不能解決問題。分庫分表或者分散式資料庫可以解決這個問題。比如MySQL,如果一個表資料上了千萬級別,就可以考慮拆分了。
如何拆分呢?對於分表來說,我們一般有垂直分表和水平分表這兩種思路。
垂直分表
垂直分表是把一個表按照不同的欄位分成多個表。舉個常見的例子,商品列表頁和商品詳情頁。商品列表頁往往是分頁查詢一個列表,所以需要高效能的一個索引。而商品列表頁往往不會展示太多的資訊,所以商品列表頁的欄位和商品詳情頁的欄位可以拆開成兩個表,它們之間用商品id關聯起來就行了。
這樣商品列表頁所在的表,一行的欄位就比較少,也可以維護各種查詢索引。而商品詳情頁,只需要維護一個商品id的索引就行了。
當然了,垂直分表需要從業務視角去考慮,需要匹配業務的需求,不能盲目拆分。上面舉的商品列表頁和商品詳情頁應該在開發的時候就考慮到這一層。不過有些老舊的設計,可能還存在這種把很多欄位放在一個表裡面的情況。
垂直拆分也不能完全解決資料量過大的問題,比如商品數量上億、甚至是幾十億、幾百億的時候,資料庫一樣承受不住。這個時候就要考慮水平分表了。
水平分表
水平分表指的是把資料放到不同的表裡。水平分表一般適用於兩種情況,這裡分別介紹一下。
「單機水平分表」 第一種是資料“冷熱不均”,比如經常用到的就是最近一段時間新寫入的資料。比較典型的業務場景是微信朋友圈。最近幾天新發的朋友圈的讀寫請求明顯比幾個月前要大得多。
那這種情況我們可以很簡單的按照寫入的順序把資料分成多個表。比如每張表放一千萬資料,寫完後就新建下一個表。或者按照時間來,比如2020年5月份的資料放在202005,6月份的資料放在202006。
單機水平分表非常適合那種資料“冷熱不均”的場景,比如日誌記錄等等。可以保證熱點資料的讀寫效能,也能支援資料量的無限增加(只要磁碟夠大)。最重要的是,它的成本不大,不需要額外的機器。所以如果業務支援的話,可以考慮單機水平分表喲。
「分庫分表」 另一種是資料熱度相對分佈均勻的,比如前面提到的商品表,或者是併發量確實太大的。這種情況一般會使用分庫分表,把資料按一定的分佈演算法(比如根據某個欄位雜湊),水平分佈到多個庫裡,一般來說都是一個庫對應一個伺服器節點,這樣就可以把流量拆分到每個節點,大大減小資料庫的壓力。
「水平分表的問題」 水平分表會帶來問題,很多複雜一點的查詢都需要去查詢所有的表,然後把結果聚合起來,經過加工整理再返回。比如想count查詢一個數量,需要去每個節點都count一次,然後把結果加起來。至於涉及order、join之類的查詢就更復雜了。
還有自增ID的問題,如何才能保證全域性的自增ID,也是一個需要注意的問題。
解決這些問題有兩個辦法,第一種辦法是使用資料庫中介軟體,這方面的開源產品有MyCat、shardingsphere(推薦)等。另一種辦法是實現一個分散式資料庫,使用者就像使用單機資料庫一樣去使用它。比較典型的產品有TiDB等。分散式資料庫由於需要自己實現查詢引擎,所以不一定能夠100%相容所有最新語法,不過也能夠滿足絕大多數的需求了。
程式碼中的優化思路
前面介紹了一些架構上的優化思路。最終我們還是迴歸到程式碼本身,聊一聊在程式碼中的一些效能優化思路。因為改動架構的成本是非常大的,其實很多時候時候可能瓶頸並不在架構上,而是因為不合理的程式碼,造成了效能瓶頸。
我們的T字型發展,架構的優化思路是橫向,那程式碼優化思路就是縱向了,是作為一個程式設計師的基本功,是我們吃飯的根本。
應用內快取
俗話說,要想快,加快取。一層不夠加兩層,兩層不夠加三層。
快取的原理是把計算結果暫時儲存在記憶體中。這樣就可以在下次需要這個計算結果的時候,直接從記憶體中去取,可以省去重複計算和重複的網路開銷。
許多框架都利用了快取的技術,尤其是許多持久化框架,比如Hibernate和Mybatis,都支援兩級快取。如果你的應用是分散式的,也可以在一些地方加上快取。比如你的程式在好幾個地方都會去調同一個介面。那可以在第一次呼叫的時候把它存起來,後面需要用到的時候直接去記憶體中取,不需要再次呼叫。
加快取是一種思想,實現快取有很多種工具和方式。如果你的快取需要在多個服務節點之間共享,那推薦使用redis這種記憶體資料庫。如果你的快取僅僅是在單個節點裡面臨時使用,那推薦Ehcache等工具,Google提供的Guava Cache也很不錯。
使用快取有幾個需要注意的點。一個是注意快取的「生命週期」,該清理的時候要清理,該過期的時候要過期,不要讓無效的快取佔據大量的記憶體,因為記憶體是很貴的。另外需要注意「資料的一致性」,如果業務上要求資料的一致性和及時性,就要好好考慮使用快取會不會讓應用受到影響。
使用快取也會帶來一些列的問題,常見的有快取穿透、快取擊穿、快取雪崩、雙寫不一致等問題。我的個人網站有一篇文章《快取常見問題及解決方案》介紹了這些問題及常見的解決方案,有興趣的讀者朋友可以參考。
序列改並行
另外一種常見的程式碼優化思路是序列改並行。比如,有時候你執行多個任務,他們可能彼此之間並沒有資料的前後關係。那是不是可以由改到並行提升計算效率呢?Java 8的函數語言程式設計提供了序列和並行兩種Stream,如果資料量比較大,可以使用並行的Stream來利用計算機的多核優勢。
collections.stream(); // 序列stream
collections.parallelStream(); // 並行stream
複製程式碼
❝並行stream底層是使用的ForkJoin框架來實現的
❞
這個方法同樣適用於網路請求。比如下面這段程式碼中,需要去呼叫三個介面,然後把他們的結果收集起來進行下一步處理。這個時候我們就可以利用多執行緒,讓他們同時去請求這幾個介面,而不是序列的去做這個事情。
// 序列方式:
OneDTO one = oneService.get();
TwoDTO two = twoService.get();
ThreeDTO three = threeService.get();
nextHandle(new Result(one, two, three));
// 並行方式:
Result result = new Result();
CompletableFuture oneFuture = CompletableFuture.runAsync(
() -> result.setOne(oneService.get()));
CompletableFuture twoFuture = CompletableFuture.runAsync(
() -> result.setTwo(twoService.get()));
CompletableFuture threeFuture = CompletableFuture.runAsync(
() -> result.setThree(threeService.get()));
CompletableFuture.allOf(oneFuture, twoFuture, threeFuture)
.thenRun( () -> nextHandle(result))
複製程式碼
非同步
我在這篇文章的開頭提到過一個問題,就是我們使用支付寶轉賬的時候,為什麼支付寶不會告訴你立即到賬,而是說兩個小時之內到賬呢?因為轉賬是一件非常複雜的操作,比較耗時。如果每次轉賬都要等轉賬完全結束之後再返回給使用者,那需要等很久,而且會產生大量的長連線,沒有必要。
這個優化的思路就是從業務上把它變成非同步的。使用者點選轉賬之後就非同步進行轉賬操作,立即返回結果。等轉賬完成後,再發通知告訴使用者已完成。
比如我的個人網站,每次我新寫了文章,都會給之前留過郵箱的讀者朋友傳送一封郵件。傳送郵件這個操作其實是可以非同步進行的,這樣的話我的響應就會比較快。
非同步是一種思想。我們程式設計師最常用的使用非同步的方式就是用多執行緒、NIO、訊息中介軟體。但是多執行緒和NIO又比較複雜,自己去使用多執行緒很容易出問題。所以誕生了一些非同步框架,比如ReactiveX,也很多語言的實現版本,Java的版本是RXJava。
Spring現在也在提倡“響應式程式設計”,提供了WebFlux來支援使用者使用響應式程式設計。
Nginx、Nodejs、Netty、使用訊息中介軟體等等,歸根結底都是利用了“非同步”的思想來實現更高的效能。
能不能使用非同步,還是取決於業務。如果業務上可以使用非同步,那才可以使用它。另外使用非同步也會造成一些資料不一致的問題,往往非同步只能保證最終一致性,並且很難保證事務。
避免執行緒同步
使用多執行緒可以利用伺服器多個CPU的優勢,但很多時候可能我們需要多個執行緒之間的協作。比如多個執行緒去獲取同一個資源,可能需要上鎖排隊。如果這個過程比較長,就有可能越來越多的執行緒卡在那,造成執行緒池爆滿,嚴重的時候甚至拒絕服務。慢SQL導致伺服器宕機就是一個典型的例子。
其實有時候我們是可以使用一些手段,去「避免執行緒同步,或者縮小執行緒同步和範圍」的。比如我在前段時間寫的一篇文章《ThreadLocal是如何避免執行緒同步的?》有提到過一種思路。
這裡有一些避免同步的經驗,也歡迎讀者朋友在評論區交流更多相關的經驗。
弄清楚JMM模型和HappensBefore原則,如果可以使用volatile等輕量級的實現,就儘量不要上鎖。
JDK提供了非常多優秀的多執行緒工具類,儘量不要自己去實現多執行緒方面的工具類,因為很容易出錯,推薦看我們《深入淺出Java多執行緒》的第17章。
鎖有很多種,有些場景可以使用“讀寫鎖”來增加讀的效能。推薦看我們《深入淺出Java多執行緒》的第14章。
使用合理的資料結構和演算法
合理使用資料結構和演算法是作為一個程式設計師的「基本素養」。
Java的工具類提供了很多十分方便的資料結構,比如List、Map、Set等。至少常用的幾種常用的工具類底層的資料結構我們要明白,這裡也列一些最最基礎的:
陣列和連結串列實現的區別 紅黑樹有什麼用,是什麼原理 ArrayList和HashMap的擴容過程 Map是如何做到查詢時間複雜度是O(1)的
很多時候,我們用List的時候,先問自己一句,這個地方是不是可以利用Set和Map來提升效能?在初始化ArrayList和HashMap的時候,是不是可以考慮到初始容量,避免它在後面頻繁擴容?
前面提到的MySQL索引,也是需要理解索引底層的資料結構,才能更好地理解和掌握索引。
❝Mysql的InnoDB索引的資料結構是帶順序索引的B+Tree
❞
使用Redis的時候同樣需要注意資料結構。五大基礎的資料結構底層是如何實現的?布隆過濾器、bitmaps、HyperLogLog是什麼原理,他們分別有什麼應用場景?這些都可以去了解一下。在我的個人網站上搜“redis”,也有幾篇這方面的文章:
資料
效能優化是一個很大的概念,它需要我們考慮到方方面面的細節。涉及的知識點也比較多,需要慢慢積累。
那麼問題來了,如何才能快速學習到這些效能優化知識呢?
Yasin這邊給大家準備好了學習資料,在我的公眾號回覆“效能優化”,就可以獲得一整套效能優化方面的學習視訊,快來領取吧~
關於作者
我是Yasin,一個有顏有料又有趣的程式設計師。
微信公眾號:編了個程
個人網站:https://yasinshaw.com
關注我的公眾號,和我一起成長~
本文使用 mdnice 排版