那些不得不説的性能優化套路

語言: CN / TW / HK

你有沒有想過,為什麼跨行轉賬要告訴你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原理
CDN原理

所以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
使用gzip

注意:gzip壓縮算法比較適用於html、js、css等文本文件,不適用於圖片等二進制文件。對圖片使用gzip壓縮收益不高,反而可能會增加體積。

預加載

前面講到了兩個網絡傳輸的優化思路。我們可以另闢蹊徑:既然網絡傳輸那麼消耗時間,那我們偷偷在不忙的時候提前下載好不行嗎?

大家可以做一個實驗:刷抖音刷到一半,等一會兒,然後停掉自己的網絡,再往下刷,可以發現還能刷好幾個視頻。

這就是因為抖音使用了預加載技術,當你在專心致志地看一個有趣的視頻的時候,這個時候網絡其實是空閒的,抖音就在悄悄下載後面幾個視頻,這樣你就可以一直刷刷刷,用户體驗就會很順暢。

試想一下,如果不使用預加載,用户每次往下刷,都得等幾秒鐘把這個視頻下載下來才能看,那自然用户體驗極差。

當然了,預加載並不適用於所有場景。畢竟預加載是提前下載,並不是不下載。如果不是有特定的業務場景,其實也沒必要使用預加載。不合理地使用預加載甚至有可能會影響正常的業務,還有可能造成數據不一致的問題。

慢SQL優化

很多應用後端會使用關係型數據庫來持久化數據。如果數據量大了,索引設置不合理,就很有可能會產生慢SQL。

生產環境最好加上慢SQL監控和分析的工具。阿里出品的Druid就很不錯。對很多中小型項目來説已經足夠了。

慢SQL優化一般有幾個思路。

  1. 先看是不是沒有命中索引,如果沒有命中,是否可以調整索引或者SQL語句?
  2. 看能不能從業務代碼層面解決?
  3. 能不能在應用層面加緩存?
  4. 考慮是否需要分庫分表?

索引這個東西大家應該或多或少都接觸過或者聽説過。分析索引需要有一定的數據庫基礎,這裏推薦《高性能MySQL》這本書,對索引講得比較清晰。我的個人網站yasinshaw.com上面也有我之前寫的關於MySQL的系列文章:

搜索關鍵字MySQL
搜索關鍵字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來支持用户使用響應式編程。

Spring大圖
Spring大圖

Nginx、Nodejs、Netty、使用消息中間件等等,歸根結底都是利用了“異步”的思想來實現更高的性能。

能不能使用異步,還是取決於業務。如果業務上可以使用異步,那才可以使用它。另外使用異步也會造成一些數據不一致的問題,往往異步只能保證最終一致性,並且很難保證事務。

避免線程同步

使用多線程可以利用服務器多個CPU的優勢,但很多時候可能我們需要多個線程之間的協作。比如多個線程去獲取同一個資源,可能需要上鎖排隊。如果這個過程比較長,就有可能越來越多的線程卡在那,造成線程池爆滿,嚴重的時候甚至拒絕服務。慢SQL導致服務器宕機就是一個典型的例子。

其實有時候我們是可以使用一些手段,去避免線程同步,或者縮小線程同步和範圍的。比如我在前段時間寫的一篇文章《ThreadLocal是如何避免線程同步的?》有提到過一種思路。

這裏有一些避免同步的經驗,也歡迎讀者朋友在評論區交流更多相關的經驗。

  1. 弄清楚JMM模型和HappensBefore原則,如果可以使用volatile等輕量級的實現,就儘量不要上鎖。

  2. JDK提供了非常多優秀的多線程工具類,儘量不要自己去實現多線程方面的工具類,因為很容易出錯,推薦看我們《深入淺出Java多線程》的第17章。

  3. 鎖有很多種,有些場景可以使用“讀寫鎖”來增加讀的性能。推薦看我們《深入淺出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”,也有幾篇這方面的文章:

搜索Redis
搜索Redis

資料

性能優化是一個很大的概念,它需要我們考慮到方方面面的細節。涉及的知識點也比較多,需要慢慢積累。

那麼問題來了,如何才能快速學習到這些性能優化知識呢?

Yasin這邊給大家準備好了學習資料,在我的公眾號回覆“性能優化”,就可以獲得一整套性能優化方面的學習視頻,快來領取吧~

學習資源
學習資源

關於作者

我是Yasin,一個有顏有料又有趣的程序員。

微信公眾號:編了個程

個人網站:http://yasinshaw.com

關注我的公眾號,和我一起成長~

公眾號
公眾號

本文使用 mdnice 排版