關於MySQL引擎特性以及InnoDB崩潰復原詳解

黄舟
發布: 2017-07-24 13:11:16
原創
1486 人瀏覽過

前言

資料庫系統與檔案系統最大的差別在於資料庫能保證操作的原子性,一個操作要不是不做都做,即使在資料庫宕機的情況下,也不會出現操作一半的情況,這個就需要資料庫的日誌和一套完善的崩潰復原機制來確保。本文仔細剖析了InnoDB的崩潰復原流程,程式碼基於5.6分支。

基本知識

lsn: 可以理解為資料庫從建立以來產生的redo日誌量,這個值越大,說明資料庫的更新越多,也可以理解為更新的時刻。此外,每個資料頁上也有一個lsn,表示最後被修改時的lsn,數值越大表示越晚被修改。例如,資料頁A的lsn為100,資料頁B的lsn為200,checkpoint lsn為150,系統lsn為300,表示目前系統已經更新到300,小於150的資料頁已經被刷到磁碟上,因此數據頁A的最新資料一定在磁碟上,而資料頁B則不一定,有可能還在記憶體中。
redo日誌: 現代資料庫都需要寫redo日誌,例如修改一條數據,先寫redo日誌,然後再寫數據。在寫完redo日誌後,就直接給客戶端回傳成功。這樣雖然看過去多寫了一次盤,但是由於把對磁碟的隨機寫入(寫資料)轉換成了順序的寫入(寫redo日誌),效能有很大幅度的提高。當資料庫掛了之後,透過掃描redo日誌,就能找出那些沒有刷盤的資料頁(在崩潰之前可能資料頁僅在記憶體中修改了,但是還沒來得及寫盤),保證資料不丟。
undo日誌: 資料庫也提供類似撤銷的功能,當你發現修改錯一些資料時,可以使用rollback指令回滾之前的動作。這個功能需要undo日誌來支援。此外,現代的關係型資料庫為了提高並發(同一條記錄,不同執行緒的讀取不衝突,讀寫和寫讀不衝突,只有同時寫才衝突),都實現了類似MVCC的機制,在InnoDB中,這個也依賴undo日誌。為了實現統一的管理,與redo日誌不同,undo日誌在Buffer Pool中有對應的資料頁,與普通的資料頁一起管理,依據LRU規則也會被淘汰出內存,後續再從磁碟讀取。與普通的資料頁一樣,undo頁的修改,也需要先寫redo日誌。
檢查點: 英文名為checkpoint。資料庫為了提高效能,資料頁在記憶體修改後並不是每次都會刷到磁碟上。 checkpoint之前的資料頁保證一定落盤了,這樣之前的日誌就沒有用了(由於InnoDB redolog日誌循環使用,這時這部分日誌就可以被覆蓋),checkpoint之後的資料頁有可能落盤,也有可能沒有落盤,所以checkpoint之後的日誌在崩潰復原的時候還是需要被使用的。 InnoDB會依據髒頁的刷新情況,定期推進checkpoint,進而減少資料庫崩潰復原的時間。檢查點的資訊在第一個日誌檔案的頭部。
崩潰恢復: 用戶修改了數據,並且收到了成功的消息,然而對數據庫來說,可能這個時候修改後的數據還沒有落盤,如果這時候資料庫掛了,重新啟動後,資料庫需要從日誌中把這些修改過的資料給撈出來,重新寫入磁碟,並確保使用者的資料不會遺失。這個從日誌中撈資料的過程就是崩潰復原的主要任務,也可以成為資料庫前滾。當然,在崩潰復原中還需要回滾沒有提交的事務,提交沒有提交成功的事務。由於回滾操作需要undo日誌的支持,undo日誌的完整性和可靠性需要redo日誌來保證,所以崩潰恢復先做redo前滾,然後做undo回滾。

我們從原始碼角度仔細剖析一下資料庫崩潰復原過程。整個過程都在引擎初始化階段完成(innobase_init),其中最主要的函數是innobase_start_or_create_for_mysql,innodb透過這個函數完成建立和初始化,包括崩潰復原。首先來介紹一下資料庫的前滾。

redo日誌前滾資料庫

前滾資料庫,主要分為兩個階段,首先是日誌掃描階段,掃描階段依照資料頁的space_id和page_no分發redo日誌到hash_table中,保證同一個數據頁的日誌被分發到同一個哈希桶中,並且按照lsn大小從小到大排序。掃描完後,再遍歷整個雜湊表,依序應用每個資料頁的日誌,應用完後,在資料頁的狀態上至少恢復到了崩潰之前的狀態。我們來詳細分析一下程式碼。
首先,開啟所有的ibdata檔案(open_or_create_data_files)(ibdata可以有多個),每個ibdata檔案有個flush_lsn在頭部,計算出這些檔案中的max_flush_lsn和min_flush_lsn,因為ibdata也有可能有資料沒寫完整,需要恢復,後續(recv_recovery_from_checkpoint_start_func)透過比較checkpont_lsn和這兩個值來決定是否需要對ibdata前滾。
接著,開啟系統表空間和日誌表空間的所有檔案(fil_open_log_and_system_tablespace_files),防止檔案句柄不足,清空buffer pool(buf_pool_invalidate)。接下來就進入最最核心的函式:recv_recovery_from_checkpoint_start_func,注意,即使資料庫是正常關閉的,也會進入。
雖然recv_recovery_from_checkpoint_start_func看過去很冗長,但是很多程式碼都是為了LOG_ARCHIVE特性而寫的,真正資料崩潰恢復的程式碼其實不多。
首先,初始化一些變量,查看srv_force_recovery這個變量,如果使用者設定跳過前滾階段,函數直接返回。
接著,初始化recv_sys結構,分配hash_table的大小,同時初始化flush list rbtree。 recv_sys結構主要在當機復原前滾階段使用。 hash_table就是之前說的用來存不同資料頁日誌的雜湊表,哈希表的大小被初始化為buffer_size_in_bytes/512, 這個是哈希表最大的長度,超過就存不下了,幸運的是,需要恢復的資料頁的數量不會超過這個值,因為buffer poll最多(資料庫崩潰之前髒頁的上線)只能存放buffer_size_in_bytes/16KB個資料頁,即使考慮壓縮頁,最多也只有buffer_size_in_bytes/1KB個,此外關於這個哈希表記憶體分配的大小,可以參考bug#53122。 flush list rbtree這個主要是為了加入插入髒頁列表,InnoDB的flush list必須按照資料頁的最老修改lsn(oldest_modifcation)從小到大排序,在資料庫正常運行時,可以透過log_sys->mutex和log_sys- >log_flush_order_mutex保證順序,在崩潰恢復則沒有這種保證,應用資料的時候,是從第一個元素開始遍歷哈希表,不能保證資料頁按照最老修改lsn(oldest_modifcation)從小到大排序,這樣就需要線性遍歷flush_list來尋找插入位置,效率太低,因此引入紅黑樹,加快查找插入的位置。
接著,從ib_logfile0的頭中讀取checkpoint訊息,主要包括checkpoint_lsn和checkpoint_no。由於InnoDB日誌是循環使用的,且最少要有2個,所以ib_logfile0一定存在,把checkpoint資訊存在裡面很安全,不用擔心被刪除。 checkpoint資訊其實會寫在文件頭的兩個地方,兩個checkpoint域輪流寫。為什麼要兩個地方輪流寫呢?假設只有一個checkpoint域,一直更新這個域,而checkpoint域有512位元組(OS_FILE_LOG_BLOCK_SIZE),如果剛好在寫這個512位元組的時候,資料庫掛了,伺服器也掛了(先不考慮硬體的原子寫特性,早期的硬體沒有這個特性),這個512位元組可能只寫了一半,導致整個checkpoint域不可用。這樣資料庫將無法做崩潰恢復,因此無法啟動。如果有兩個checkpoint域,那麼即使一個寫壞了,還可以用另外一個嘗試恢復,雖然有可能這個時候日誌已經被覆蓋,但是至少提高了恢復成功的機率。兩個checkpoint域輪流寫,也能減少磁碟區故障帶來的影響。 checkpoint_lsn之前的資料頁都已經落盤,不需要前滾,之後的資料頁可能還沒落盤,需要重新恢復出來,即使已經落盤也沒關係,因為redo日誌時冪等的,應用一次和應用兩次都一樣(底層實作: 如果資料頁上的lsn大於等於目前redo日誌的lsn,就不應用,否則應用。checkpoint_no可以理解為checkpoint域寫盤的次數,每次刷盤遞增1,同時這個值取模2可以用來實現checkpoint_no域的輪流寫。接著,使用checkpoint域的資訊初始化recv_sys結構體的一些資訊後,就進入日誌解析的核心函數recv_group_scan_log_recs,這個函數後續我們再分析,主要作用就是解析redo日誌,如果記憶體不夠了,就直接呼叫應用(recv_apply_hashed_log_recs)日誌,然後再接著解析。如果需要應用的日誌很少,就只解析分發日誌,到recv_recovery_from_checkpoint_finish函數中在應用程式日誌。
接著,依據目前刷盤的資料頁狀態做一次checkpoint,因為在recv_group_scan_log_recs裡可能已經套用部分日誌了。至此recv_recovery_from_checkpoint_start_func函數結束。
recv_recovery_from_checkpoint_finish函數中,如果srv_force_recovery設定正確,就開始呼叫函數recv_apply_hashed_log_recs應用程式日誌,然後等待刷髒的執行緒退出(執行緒是當機復原時暫時啟動的) ,最後釋放recv_sys的相關資源以及hash_table所佔用的記憶體。
至此,資料庫前滾結束。接下來,我們詳細分析redo日誌解析函數以及redo日誌應用程式的實作細節。

redo日誌解析函數

解析函數的最上層是recv_group_scan_log_recs,這個函式呼叫底層函式(log_group_read_log_seg),依照RECV_SCAN_SIZE(64KB)大小分批讀取。讀取出來後,首先透過block_no和lsn之間的關係以及日誌checksum判斷是否讀到了日誌最後(所以可以看出,並沒一個標記在日誌頭標記日誌的有效位置,完全是按照上述兩個條件判斷是否到達了日誌尾部),如果讀到最後則返回(之前說了,即使資料庫是正常關閉的,也要走崩潰恢復邏輯,那麼在這裡就返回了,因為正常關閉的checkpoint值一定是指向日誌最後),否則則把日誌去頭掐尾放到一個recv_sys->buf中,日誌頭裡面存了一些控制資訊和checksum值,只是用來校驗和定位,在真正的應用中沒有用。在放到recv_sys->buf之前,需要檢驗一下recv_sys->buf有沒有滿(RECV_PARSING_BUF_SIZE,2M),滿了就報錯(如果上一批解析有不完整的日誌,日誌解析函數不會分發,而是把這些不完整的日誌留在recv_sys->buf中,直到解析到完整的日誌)。接著的事情就是從recv_sys->buf中解析日誌(recv_parse_log_recs)。日誌分為兩種:single_rec和multi_rec,前者表示只對一個資料頁進行一種操作,後者表示對一個或多個資料頁進行多種操作。日誌中也包括對應資料頁的space_id,page_no,操作的type以及操作的內容(recv_parse_log_rec)。解析出對應的日誌後,依照space_id和page_no進行哈希(如果對應的表空間在記憶體中不存在,則表示表已經被刪除了),放到hash_table裡面(日誌真正存放的位置依然在buffer pool)即可,等待後續應用。這裡有幾個點值得注意:

  • 如果是multi_rec類型,則只有遇到MLOG_MULTI_REC_END這個標記,日誌才算完整,才會被分發到hash_table中。查看程式碼,我們可以發現multi_rec類型的日誌被解析了兩次,一次用來校驗完整性(尋找MLOG_MULTI_REC_END),第二次才用來分發日誌,感覺這是一個可以優化的點。

  • 目前日誌的操作type有50多種,每個操作後面的內容都不一樣,所以長度也不一樣,目前日誌的解析邏輯,需要依序解析出所有的內容,然後確定長度,從而定位下一日誌的開始位置。這種方法效率略低,其實可以在每種操作的頭上加上一個字段,儲存後面內容的長度,這樣就不需要解析太多的內容,從而提高解析速度,進一步提高崩潰恢復速度,從結果看,可以提高一倍的速度(從38秒到14秒,詳情可以參考bug#82937)。

  • 如果發現checkpoint之後還有日誌,表示資料庫之前沒有正常關閉,需要做崩潰恢復,因此需要做一些額外的操作(recv_init_crash_recovery),例如在錯誤日誌中印出我們常見的“Database was not shutdown normally!”和“Starting crash recovery.”,還要從double write buffer中檢查是否發生了資料頁半寫,如果有需要恢復(buf_dblwr_process) ,還需要啟動一個執行緒用來刷新應用程式日誌產生的髒頁(因為這個時候buf_flush_page_cleaner_thread還沒啟動)。最後還需要打開所有的表空間。 。注意是所有的表。 。 。我們在阿里雲RDS MySQL的運維中,常常發現資料庫hang在了崩潰復原階段,在錯誤日誌中有類似「Reading tablespace information from the .ibd files...」字樣,這表示資料庫正在開啟所有的表,然後一看表的數量,發現有幾十甚至上百萬張表。 。 。資料庫之所以要打開所有的表,是因為在分發日誌的時候,需要確定space_id對應哪個ibd文件,透過打開所有的表,讀取space_id信息來確定,另外一個原因是方便double write buffer檢查半寫數據頁。針對這個表數量過多導致恢復過慢的問題,MySQL 5.7做了優化,WL#7142, 主要想法就是在每次checkpoint後,在第一次修改某個表時,先寫一個新日誌mlog_file_name(包括space_id和filename的映射),來表示對這個表進行了操作,後續對這個表的操作就不用寫這個新日誌了,當需要崩潰恢復時候,多一次掃描,通過蒐集mlog_file_name來確定哪些表被修改過,這樣就不需要打開所有的表來確定space_id了。

  • 最後一個值得注意的地方是記憶體。之前說過,如果有太多的日誌已經被分發,佔用了太多的內存,日誌解析函數會在適當的時候應用日誌,而不是等到最後才一起應用。那麼問題來了,使用了多大的記憶體就會出發應用日誌邏輯。答案是:buffer_pool_size_in_bytes - 512 * buffer_pool_instance_num * 16KB。由於buffer_pool_instance_num一般不會太大,所以可以任務,buffer pool的大部分記憶體都被用來存放日誌。剩下的那些主要留給應用日誌時讀取的資料頁,因為目前來說日誌應用是單線程的,讀取一個日誌,把所有日誌應用完,然後就可以刷回磁碟了,不需要太多的內存。

redo日誌應用程式

應用程式日誌的上層函數為recv_apply_hashed_log_recs(應用程式日誌也可能在io_helper函式中進行),主要功能就是遍歷hash_table,從磁碟讀取對每個資料頁,依序套用哈希桶中的日誌。套用完所有的日誌後,如果需要則把buffer_pool的頁面都刷盤,畢竟空間有限。有以下幾點值得注意:

  • 同一個資料頁的日誌必須依照lsn從小到大應用,否則資料會被覆寫。只應用redo日誌lsn大於page_lsn的日誌,只有這些日誌需要重做,其餘的忽略。應用完日誌後,把髒頁加入髒頁列表,由於臟頁列表是按照最老修改lsn(oldest_modification)來排序的,這裡通過引入一顆紅黑樹來加速查找插入的位置,時間複雜度從之前的線性查找降為對數等級。

  • 當需要某個資料頁的時候,如果發現其沒有在Buffer Pool中,則會查看這個資料頁周圍32個資料頁,是否也需要做恢復,如果需要則可以一起讀取出來,相當於做了一次io合併,減少io操作(recv_read_in_area)。由於這個是非同步讀取,所以最終應用日誌的活兒是由io_helper線程來做的(buf_page_io_complete),此外,為了防止短時間發起太多的io,在程式碼中加了流量控制的邏輯(buf_read_recv_pages)。如果發現某個資料頁在記憶體中,則直接呼叫recv_recover_page應用程式日誌。由此我們可以看出,InnoDB應用程式日誌其實並不是單執行緒的來應用日誌的,除了崩潰復原的主執行緒外,io_helper執行緒也會參與復原。並發線程數取決於io_helper中讀取線程的個數。

執行完了redo前滾資料庫,資料庫的所有資料頁已經處於一致的狀態,undo回溯資料庫就可以安全的執行了。資料庫崩潰的時候可能會有一些沒有提交的事務或是已經提交的事務,這個時候就需要決定是否要提交。主要分為三步,首先是掃描undo日誌,重新建立起undo日誌鍊錶,接著是,依據上一步建立起的鍊錶,重建崩潰前的事務,即恢復當時事務的狀態。最後,就是依據事務的不同狀態,進行回滾或提交。

undo日誌回滾資料庫

recv_recovery_from_checkpoint_start_func之後,recv_recovery_from_checkpoint_finish之前,呼叫了trx_sys_init_at_db_startpoint_finish之前,呼叫了
trx_sys_init_at_db_start中的前兩步。 第一步在函數trx_rseg_array_init中處理,遍歷整個undo日誌空間(最多TRX_SYS_N_RSEGS(128)個segment),如果發現某個undo segment非空,就進行初始化(trx_rseg_create_instance )。整個每個undo segment,如果發現undo slot非空(最多TRX_RSEG_N_SLOTS(1024)個slot),也就行初始化(trx_undo_lists_init)。在初始化undo slot後,就把不同型別的undo日誌放到不同鍊錶中(trx_undo_mem_create_at_db_start
)。 undo日誌主要分為兩種:TRX_UNDO_INSERT和TRX_UNDO_UPDATE。前者主要是提供給insert操作用的,後者是給update和delete操作使用。之前說過,undo日誌有兩種作用,事務回滾時候用和MVCC快照讀取時候用。由於insert的資料不需要提供給其他執行緒用,所以只要交易提交,就可以刪除TRX_UNDO_INSERT類型的undo日誌。 TRX_UNDO_UPDATE在事務提交後還不能刪除,需要保證沒有快照使用它的時候,才能透過後台的purge執行緒清理。 第二步在函數trx_lists_init_at_db_start中進行,由於第一步中,已經在記憶體中建立起了undo_insert_list和undo_update_list(鍊錶每個undo segment獨立),所以這一步只需要遍歷所有鍊錶,重建起交易的狀態(trx_resurrect_inserttrx_resurrect_update)。簡單的說,如果undo日誌的狀態是TRX_UNDO_ACTIVE,則事務的狀態為TRX_ACTIVE,如果undo日誌的狀態是TRX_UNDO_PREPARED,則事務的狀態為TRX_PREPARED。這裡也要考慮變數srv_force_recovery的設置,如果這個變數值為非0,所有的事務都會回溯(即事務被設定為TRX_ACTIVE),即使事務的狀態應該為TRX_STATE_PREPARED。重建起交易後,依照交易id加入trx_sys->trx_list鍊錶。最後,在函數trx_sys_init_at_db_start
中,會統計所有需要回滾的事務(事務狀態為TRX_ACTIVE)一共需要回滾多少行數據,輸出到錯誤日誌中,類似:5 transaction(s) which must be rolled back or cleaned up。 InnoDB: in total 342232 row operations to undo的字樣。 第三步的操作在兩個地方被呼叫。一個是在recv_recovery_from_checkpoint_finish的最後,另一個是在recv_recovery_rollback_active。前者主要是回滾對資料字典的操作,也就是回滾DDL語句的操作,後者是回滾DML語句。前者是在資料庫可提供服務之前必須完成,後者則可以在資料庫提供服務(也即是崩潰復原結束)之後繼續進行(透過新開一個後台執行緒trx_rollback_or_clean_all_recovered來處理)。因為InnoDB認為資料字典是最重要的,必須回滾到一致的狀態才行,而用戶表的資料可以稍微慢一點,對外提供服務後,慢慢恢復即可。因此我們常常在會發現資料庫已經啟動起來了,然後錯誤日誌中還在不斷的印出回滾交易的資訊。交易回滾的核心函式是trx_rollback_or_clean_recovered,邏輯很簡單,只需要遍歷trx_sys->trx_list,依照交易不同的狀態回滾或提交即可(trx_rollback_resurrected

)。這裡要注意的是,如果事務是TRX_STATE_PREPARED狀態,那麼在InnoDB層,不做處理,需要在Server層依據binlog的情況來決定是否回滾事務,如果binlog已經寫了,事務就提交,因為binlog寫了就可能被傳到備庫,如果主庫回滾會導致主備資料不一致,如果binlog沒有寫,就回溯事務。

崩潰復原相關參數解析

innodb_fast_shutdown:

innodb_fast_shutdown = 0。這個表示在MySQL關閉的時候,執行slow shutdown,不但包括日誌的刷盤,數據頁的刷盤,還包括數據的清理(purge),ibuf的合併,buffer pool dump以及lazy table drop操作(如果表上有未完成的操作,即使執行了drop table且返回成功了,表也不一定立刻被刪除)。
innodb_fast_shutdown = 1。這個是預設值,表示在MySQL關閉的時候,只是把日誌和資料刷盤。
innodb_fast_shutdown = 2。這個表示關閉的時候,僅僅日誌刷盤,其他什麼都不做,就好像MySQL crash了一樣。

這個參數值越大,MySQL關閉的速度越快,但是啟動速度越慢,相當於把關閉時候需要做的工作挪到了崩潰恢復上。另外,如果MySQL要升級,建議使用第一種方式進行一次乾淨的shutdown。 ###

innodb_force_recovery:
這個參數主要用來控制InnoDB啟動時候做哪些工作,數值越大,做的工作越少,啟動也更容易,但是數據不一致的風險也越大。當MySQL因為某些不可控的原因不能啟動時,可以設定這個參數,從1開始逐步遞增,知道MySQL啟動,然後使用SELECT INTO OUTFILE把資料匯出,盡最大的努力減少資料遺失。
innodb_force_recovery = 0。這是預設的參數,啟動的時候會做所有的事情,包括redo日誌應用,undo日誌回滾,啟動後台master和purge線程,ibuf合併。偵測到了資料頁損壞了,如果是系統表空間的,則會crash,使用者表空間的,則打錯誤日誌。
innodb_force_recovery = 1。如果偵測到資料頁損壞了,不會crash也不會報錯(buf_page_io_complete),啟動的時候也不會校驗表空間第一個資料頁的正確性(fil_check_first_page),表空間無法存取也繼續做崩潰復原(fil_open_single_table_tablespacefil_load_single_table_tablespace),ddl操作不能進行(check_if_supported_inplace_alter),同時資料庫也無法進行寫入操作(row_insert_for_mysqlrow_update_for_mysql等),所有的prepare交易也會被回滾(trx_resurrect_inserttrx_resurrect_update_in_resurrect_insert
trx_resurrect_update_in_prepared_fstatein_pred)。這個選項還是很常用的,資料頁可能是因為磁碟壞了而損壞了,設定為1,能確保資料庫正常啟動。 innodb_force_recovery = 2。除了設定1之後的操作不會運行,後台的master和purge線程就不會啟動了(srv_master_threadsrv_purge_coordinator_thread
等),當你發現資料庫因為這兩個執行緒的原因而無法啟動時,可以設定。 innodb_force_recovery = 3。除了設定2之後的操作不會運行,undo回滾資料庫也不會進行,但是回滾段依然會被掃描,undo鍊錶也依然會被建立(trx_sys_init_at_db_start
)。 srv_read_only_mode會被開啟。 innodb_force_recovery = 4。除了設定3之後的操作不會運行,ibuf的操作也不會運行(ibuf_merge_or_delete_for_page),表資訊統計的執行緒也不會運行(因為一個壞的索引頁會導致資料庫崩潰)(info_lowdict_stats_update
等)。從這個選項開始,之後的所有選項,都會損壞數據,慎重使用。 innodb_force_recovery = 5。除了設定4之後的操作不會運行,回滾段也不會被掃描(recv_recovery_rollback_active
),undo鍊錶也不會被創建,這個主要用在undo日誌被寫壞的情況下。 innodb_force_recovery = 6。除了設定5之後的操作不會執行,資料庫前滾操作也不會進行,包括解析和應用(recv_recovery_from_checkpoint_start_func

)。

總結

InnoDB實作了一套完善的崩潰恢復機制,保證在任何狀態下(包括在崩潰恢復狀態下)資料庫掛了,都能正常恢復,這個是與文件系統最大的差異。此外,崩潰復原透過redo日誌這種實體日誌來應用資料頁的方法,為MySQL Replication帶來了新的思路,備庫是否可以透過類似應用redo日誌的方式來同步資料呢?阿里雲端RDS MySQL團隊在後續的產品中,為大家帶來了類似的特性,敬請期待。 ###

以上是關於MySQL引擎特性以及InnoDB崩潰復原詳解的詳細內容。更多資訊請關注PHP中文網其他相關文章!

來源:php.cn
本網站聲明
本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn
熱門教學
更多>
最新下載
更多>
網站特效
網站源碼
網站素材
前端模板