SQLite剖析之異步IO模式、共享緩存模式和解鎖通知


1、異步I/O模式
    通常,當SQLite寫一個數據庫文件時,會等待,直到寫操作完成,然后控制返回到調用程序。相比於CPU操作,寫文件系統是非常耗時的,這是一個性能瓶頸。異步I/O后端是SQLite的一個擴展模塊,允許SQLite使用一個獨立的后台線程來執行所有的寫請求。雖然這並不會減少整個系統的資源消耗(CPU、磁盤帶寬等),但它允許SQLite在正在寫數據庫時立刻返回到調用者,從用戶角度看,無疑提高了前端的響應速度。對異步I/O,寫請求在一個獨立的后台線程中被處理,這意味着啟動數據庫寫操作的線程不必等待磁盤I/O的發生。寫操作看起來似乎很快就發生了,但實際上速度跟通常是一樣的,只不過在后台進行。
    異步I/O似乎提供了更好的響應能力,但這是有代價的。你會失去ACID中的持久性(Durable)屬性。在SQLite的缺省I/O后端中,一旦寫操作完成,你知道更改的數據已經安全地在磁盤上了。而異步I/O卻不是這樣的情況。如果應用程序在數據寫操作之后,異步寫線程完成之前發生崩潰或掉電,則數據庫更改可能根本沒有被寫到磁盤,下一次使用數據庫時就看不到更改。
    異步I/O失去了持久性,但仍然保持ACID的其他三個屬性:原子性(Atomic)、一致性(Consistent)和隔離性(Isolated)。很多應用程序沒有持久性也能很好地工作。
    我們通過創建一個SQLite VFS對象並且用sqlite3_vfs_register()注冊它來使用異步I/O模式。當用這個VFS打開數據庫文件並進行寫操作時(使用vfs的xWrite()方法),數據不會立刻寫到磁盤,而是放在由后台線程維護的寫隊列中。當用異步VFS打開數據庫文件並進行讀操作時(使用vfs的xRead()方法),數據從磁盤讀出,而寫隊列從vfs讀進程的角度看,其xWrite()已經完成了。異步I/O的虛擬文件系統(VFS)通過sqlite3async_initialize()來注冊,通過sqlite3async_shutdown()來關閉。
    為了積累經驗,異步I/O的實現有意保持簡單。更多的功能會在將來的版本中添加。例如,在當前的實現中,如果寫操作正在一個穩定的流上發生,而這個流超過了后台寫線程的I/O能力,則掛起的寫操作隊列將會無限地增長,可能會耗盡主機系統的內存。復雜一點的模塊則可以跟蹤掛起的寫操作數量,在超過一定數目后停止接收新的寫請求。
    在單個進程中、使用異步IO的多個連接可以並發地訪問單個數據庫。從用戶的角度看,如果所有連接都位於單個進程中,則正常SQLite和使用異步IO的SQLite,其並發性並沒有什么不同。如果文件鎖是激活的(缺省是激活的),來自多個進程的連接都要讀和寫數據庫文件,則並發性在下面的情況下會減弱:
    (1)當使用異步IO的連接啟動一個數據庫事務時,數據庫會立刻被鎖住。然而鎖只有在寫隊列中的所有操作已經刷新到磁盤后才能釋放。這意味着有時即使在一個"COMMIT"或"ROLLBACK"執行完后,數據庫可能仍然處於鎖住狀態。
    (2)如果應用程序使用異步IO連續地執行多個事務,其他數據庫用戶可能會因為數據庫一直被鎖住而不能使用數據庫。這是因為當一個BEGIN執行后,數據庫鎖會立刻建立起來。但當對應的COMMIT或ROLLBACK發生時,鎖不一定釋放了,要到后台寫隊列全部刷新到磁盤后才能釋放。如果后台寫隊列還沒刷新完,數據庫就一直處於鎖住狀態,其他進程不能訪問數據庫。
    文件鎖可以在運行時通過sqlite3async_control()函數禁用。對NFS這可以提高性能,因為可以避免對服務器的來回異步操作建立文件鎖。但是如果多個連接嘗試訪問同一個數據庫,而文件鎖被禁用了,則應用程序崩潰和數據庫損壞就可能發生。
    異步IO擴展模塊由單個源文件sqlite3async.c,和一個頭文件sqlite3async.h組成,位於源碼樹的ext/async/子目錄下。應用程序可以用其中定義的C API來激活和控制這個模塊的功能。為了使用異步IO擴展,把sqlite3async.c編譯成使用SQLite的應用程序的一部分,然后使用sqlite3async.h中定義的API來初始化和配置這個模塊。這些API在sqlite3async.h的注釋中有詳細說明,使用這些API通常有以下步驟:
    (1)調用sqlite3async_initialize()來給SQLite注冊異步IO VFS(虛擬文件系統)。
    (2)創建一個后台線程來執行寫操作,並調用sqlite3async_run()。
    (3)通過異步IO VFS,使用正常的SQLite API來讀寫數據庫。
    當前的異步IO擴展兼容win32系統和支持pthread接口的系統,包括Mac OS X, Linux和其他Unix變體。為了移植異步IO擴展到其他的平台,用戶必須在新平台上實現互斥鎖和條件變量原語。當前並沒有外部可用接口來允許做這樣的控制,但是修改sqlite3async.c中的代碼以包含新平台的並發控制原語是相當容易的,更多細節可搜索sqlite3async.c中的注釋串"PORTING FUNCTIONS"。然后實現下面這些函數的新版本:

static void async_mutex_enter(int eMutex);
static void async_mutex_leave(int eMutex);
static void async_cond_wait(int eCond, int eMutex);
static void async_cond_signal(int eCond);
static void async_sched_yield(void);

    上面這些函數的功能在sqlite3async.c的注釋中有詳細描述。


2、共享緩存模式
    從3.3.0版開始,SQLite包含一個特別的“共享緩存”模式(缺省情況下禁用),主要用在嵌入式服務器中。如果共享緩存模式激活,並且一個線程在同一個數據庫上建立多個連接,則這些連接共享一個數據和模式緩存。這能夠顯著減少系統的內存和IO消耗。在3.5.0版中,共享緩存模式被修改以便同一緩存的共享可以跨越整個進程而不只是單個線程。在這個修改之前,在線程間傳遞數據連接是受限制的。從3.5.0版開始這個限制就消除了。

    從另一個進程或線程的角度看,使用共享緩存的兩個或多個數據庫連接看起來就像是一個連接。鎖協議用來在多個共享緩存或數據庫用戶之間進行仲裁。

圖1 共享緩存模式

    圖1描述一個運行時配置的例子,有三個數據庫連接。連接1是一個正常的SQLite數據庫連接,連接2和3共享一個緩存。正常的鎖協議用來在連接1和共享緩存之間串行化數據庫訪問。而連接2和連接3對共享緩存訪問的串行化則有專門的內部協議。見下面的描述。
    有三個級別的共享緩存加鎖模型,事務級別的加鎖,表級別的加鎖和模式級別的加鎖。

    (1)事務級別的加鎖
    SQLite連接可能打開兩種類型的事務,讀事務和寫事務。這不是顯式完成的,一個事務隱式地含有一個讀事務,直到它首次寫一個數據庫文件,這時成為一個寫事務。在任何時候共享緩存上最多只能有一個連接打開一個寫事務,這個寫事務可以和任何數量的讀事務共存。這與非共享緩存模式不同,非共享緩存模式下有讀操作時不允許有寫操作。

    (2)表級別的加鎖
    當兩個或更多的連接使用一個共享緩存,用鎖來串行化每個表格的並發訪問。表支持兩種類型的鎖,讀鎖和寫鎖。鎖被授予連接,任何時候每個數據庫連接上的每個表格可以有讀鎖、寫鎖或沒有鎖。一個表格上可以任何數量的讀鎖,但只能有一個寫鎖。讀數據庫表格時必須首先獲得一個讀鎖。寫表格時必須獲得一個寫鎖。如果不能獲取需要的鎖,查詢失敗並返回SQLITE_LOCKED給調用者。表級別的鎖在獲取之后,要到當前事務(讀或寫)結束時才釋放。
    如果使用read_uncommitted pragma指令把事務隔離模式從串行(serialized,缺省模式,即查詢數據時會加上共享瑣,阻塞其他事務修改真實數據)改成允許臟讀(read-uncommitted,即SELECT會讀取其他事務修改而還沒有提交的數據),則上面描述的行為會有稍許的變化。事務隔離模式還有另外兩種,無法重復讀read-comitted是同一個事務中兩次執行同樣的查詢語句,若在第一次與第二次查詢之間時間段,其他事務又剛好修改了其查詢的數據且提交了,則兩次讀到的數據不一致。可以重復讀read-repeatable是指同一個事務中兩次執行同樣的查詢語句,得到的數據始終都是一致的。

/* Set the value of the read-uncommitted flag: 
  ** 
  **   True  -> Set the connection to read-uncommitted mode. 
  **   False -> Set the connection to serialized (the default) mode. 
  */  
  PRAGMA read_uncommitted = <boolean>;  
  
  /* Retrieve the current value of the read-uncommitted flag */  
  PRAGMA read_uncommitted; 

    允許臟讀模式的數據庫連接在讀數據庫表時不會獲取讀鎖,如果這時另外一個數據庫連接修改了正在被讀的表數據,則可能導致查詢結果不一致,因為允許臟讀模式的讀事務不會被打斷。允許臟讀模式不會影響寫事務,它必須獲取寫鎖,因此數據庫寫操作可以被阻塞。允許臟讀模式也不會影響sqlite_master級別的鎖。

    (3)模式(sqlite_master)級別的加鎖
    sqlite_master表支持與其他數據庫表相同的共享緩存讀鎖和寫鎖。還會使用下面的特殊規則:
    * 在訪問任何數據庫表格或者獲取任何其他的讀鎖和寫鎖之前,連接必須先獲取一個sqlite_master表上的讀鎖。
    * 在執行修改數據庫模式的語句(例如CREATE TABLE或DROP TABLE)之前,連接必須先獲取一個sqlite_master表上的寫鎖。
    * 如果任何其他的連接持有關聯數據庫(包括缺省的主數據庫)的sqlite_master表上的寫鎖,則連接不可以編譯一個SQL語句。
    在SQLite 3.3.0到3.4.2之間,數據庫連接只能被調用sqlite3_open()創建它的線程使用,一個連接只能與同一線程中的其他連接共享緩存。從SQLite 3.5.0開始,這個限制消除了。在老版本的SQLite上,共享緩存模式不能使用在虛擬表上,從SQLite 3.6.17開始,這個限制消除了。
    共享緩存模式在每個進程級別上激活。C接口int sqlite3_enable_shared_cache(int)用來全局地激活或禁用共享緩存模式。每次調用sqlite3_enable_shared_cache()影響后續的使用sqlite3_open(), sqlite3_open16()或sqlite3_open_v2()創建的數據庫連接,已經存在的數據庫連接則不受影響。每次sqlite3_enable_shared_cache()的調用覆蓋進程上的前面各次調用。
    使用sqlite3_open_v2()創建的單個數據庫連接,通過在第三個參數上使用SQLITE_OPEN_SHAREDCACHE或SQLITE_OPEN_PRIVATECACHE標志,可能選擇參與或不參與共享緩存模式。在該數據庫連接上這些標志會覆蓋全局的sqlite3_enable_shared_cache()設置。如果同時使用這兩個標志,則行為是未定義的。
    當使用URI文件名時,"cache"查詢參數可以用來指定連接是否使用共享緩存模式。"cache=shared"激活共享緩存,"cache=private"禁用共享緩存。例如:
    ATTACH 'file:aux.db?cache=shared' AS aux;
    從SQLite 3.7.13開始,倘若數據庫使用URI文件名創建,共享緩存模式可以在內存數據庫上使用。為了向后兼容,使用未修飾的":memory:"名稱打開內存數據庫時缺省是禁用共享緩存的。而在SQLite 3.7.13之前,無論使用的內存數據庫名、當前系統的共享緩存設置、以及查詢參數或標志是什么,內存數據庫上共享緩存總是被禁用的。
    在內存數據庫上激活共享緩存,會允許同一進程上的兩個或更多數據庫連接訪問同一段內存。當最后一個連接關閉時,內存數據庫會自動刪除,這段內存也會被重置。
    

3、解鎖通知
    當多個連接在共享緩存模式下訪問同一個數據庫時,單個表上的讀鎖和寫鎖(即共享鎖和排他鎖)用來確保並發執行的事務是隔離的。如果連接不能獲取到需要的鎖,sqlite3_step()調用返回SQLITE_LOCKED。如果不能獲取到每個關聯數據庫的sqlite_master表上的讀鎖(雖然這種情況並不常見),sqlite3_prepare()或sqlite3_prepare_v2()調用也會返回SQLITE_LOCKED。
    通過使用SQLite的sqlite3_unlock_notify()接口,我們可以讓sqlite3_step()或sqlite3_prepare_v2()調用阻塞直到獲得需要的鎖,而不是立刻返回SQLITE_LOCKED。下面的例子展示解鎖通知的使用。

/* 本例子使用pthreads API */  
#include <pthread.h>  
  
/* 
** 當注冊一個解鎖通知時,傳遞本結構實例的指針,以作為用戶上下文中的實例 
*/  
typedef struct UnlockNotification UnlockNotification;  
struct UnlockNotification {  
  int fired;                         /* 在解鎖事件發生后為True */  
  pthread_cond_t cond;               /* 要等待的條件變量 */  
  pthread_mutex_t mutex;             /* 保護本結構的互斥量 */  
};  
  
/* 
** 解鎖通知回調函數 
*/  
static void unlock_notify_cb(void **apArg, int nArg){  
  int i;  
  for(i=0; i<nArg; i++){  
    UnlockNotification *p = (UnlockNotification *)apArg[i];  
    pthread_mutex_lock(&p->mutex);  /* 對臨界區加鎖 */  
    p->fired = 1;  /* 觸發解鎖事件,本變量只能互斥訪問 */  
    pthread_cond_signal(&p->cond);  
    pthread_mutex_unlock(&p->mutex);  
  }  
}  
  
/* 
** 本函數假設SQLite API調用(sqlite3_prepare_v2()或sqlite3_step())返回SQLITE_LOCKED。 
** 參數為關聯的數據庫連接。 
** 本函數調用sqlite3_unlock_notify()注冊一個解鎖通知回調函數,然后阻塞直到 
** 回調函數執行完並返回SQLITE_OK。調用者應該重試失敗的操作。 
** 或者,如果sqlite3_unlock_notify()指示阻塞將會導致系統死鎖,則本函數立刻 
** 返回SQLITE_LOCKED。調用者不應該重試失敗的操作,而是回滾當前事務 
*/  
static int wait_for_unlock_notify(sqlite3 *db){  
  int rc;  
  UnlockNotification un;  
  
  /* 初始化UnlockNotification結構 */  
  un.fired = 0;  
  pthread_mutex_init(&un.mutex, 0);  
  pthread_cond_init(&un.cond, 0);  
  
  /* 注冊一個解鎖通知回調函數 */  
  rc = sqlite3_unlock_notify(db, unlock_notify_cb, (void *)&un);  
  assert( rc==SQLITE_LOCKED || rc==SQLITE_OK );  
  
  /* sqlite3_unlock_notify()調用總是返回SQLITE_LOCKED或SQLITE_OK。 
  ** 如果返回SQLITE_LOCKED,則系統死鎖。本函數需要返回SQLITE_LOCKED給調用者以 
  ** 便當前事務能夠回滾。否則阻塞直到解鎖通知回調函數執行,然后返回SQLITE_OK 
  */  
  if( rc==SQLITE_OK ){  
    pthread_mutex_lock(&un.mutex);  
    if( !un.fired ){ /* 如果解鎖事件沒有發生,則阻塞 */  
      pthread_cond_wait(&un.cond, &un.mutex);  
    }  
    pthread_mutex_unlock(&un.mutex);  
  }  
  
  /* 銷毀互斥量和條件變量 */  
  pthread_cond_destroy(&un.cond);  
  pthread_mutex_destroy(&un.mutex);  
  
  return rc;  
}  
  
/* 
** 本函數是SQLite函數sqlite3_step()的包裝,它的工作方式與sqlite3_step()相同。 
** 但如果沒有獲得共享緩存鎖,則本函數阻塞以等待鎖可用。 
** 如果本函數返回SQLITE_LOCKED,調用者應該回滾當前事務,之后再嘗試。否則系統可能死鎖了 
*/  
int sqlite3_blocking_step(sqlite3_stmt *pStmt){  
  int rc;  
  while( SQLITE_LOCKED==(rc = sqlite3_step(pStmt)) ){  
    rc = wait_for_unlock_notify(sqlite3_db_handle(pStmt));  
    if( rc!=SQLITE_OK ) break;  
    sqlite3_reset(pStmt);  
  }  
  return rc;  
}  
  
/* 
** 本函數是SQLite函數sqlite3_prepare_v2()的包裝,它的工作方式與sqlite3_prepare_v2()相同。 
** 但如果沒有獲得共享緩存鎖,則本函數阻塞以等待鎖可用。 
** 如果本函數返回SQLITE_LOCKED,調用者應該回滾當前事務,之后再嘗試。否則系統可能死鎖了 
*/  
int sqlite3_blocking_prepare_v2(  
  sqlite3 *db,              /* 數據庫句柄 */  
  const char *zSql,         /* UTF-8編碼的SQL語句 */  
  int nSql,                 /* zSql的字節數 */  
  sqlite3_stmt **ppStmt,    /* OUT: 指向預處理語句的指針 */  
  const char **pz           /* OUT: 解析過的字符串尾部位置 */  
){  
  int rc;  
  while( SQLITE_LOCKED==(rc = sqlite3_prepare_v2(db, zSql, nSql, ppStmt, pz)) ){  
    rc = wait_for_unlock_notify(db);  
    if( rc!=SQLITE_OK ) break;  
  }  
  return rc;  
}

    如果例子中的sqlite3_blocking_step()或sqlite3_blocking_prepare_v2()函數返回SQLITE_LOCKED,則表明阻塞將導致系統死鎖。
    只有在編譯時定義預處理宏SQLITE_ENABLE_UNLOCK_NOTIFY,才能使用sqlite3_unlock_notify()接口。該接口被設計成用在這樣的系統中:每個數據庫連接分配單獨的線程。如果在一個線程中運行多個數據庫連接,則不能使用該接口。sqlite3_unlock_notify()接口一次只在一個線程上工作,因此上面的鎖控制邏輯只能工作於一個線程的單個數據庫連接上。
    上面的例子中,在sqlite3_step()或sqlite3_prepare_v2()返回SQLITE_LOCKED后,sqlite3_unlock_notify()被調用以注冊一個解鎖通知回調函數。在數據庫連接持有表級別的鎖后,解鎖通知函數被執行以防止sqlite3_step()或sqlite3_prepare_v2()隨后完成事務並釋放所有鎖。例如,如果sqlite3_step()嘗試讀表格X,而其他某個連接Y正持有表格X的寫鎖,sqlite3_step()將返回SQLITE_LOCKED。如果隨后調用sqlite3_unlock_notify(),解鎖通知函數將在連接Y的事務結束后被調用。解鎖通知函數正在等待的連接(這里的Y),被稱為“阻塞式連接”。
    如果sqlite3_step()嘗試寫一個數據庫,但返回SQLITE_LOCKED,則可能有多個進程持有當前數據庫表格的讀鎖。這時SQLite隨意地選擇其中的一個連接,當這個連接的事務完成時執行解鎖通知函數。解鎖通知函數從sqlite3_step()(或sqlite3_close())里執行,它關聯有一個阻塞式進程。解鎖通知函數里面可以調用任何的sqlite3_XXX()函數,可以向其他等待線程發信號,或者安排一些在以后要發生的行為。

    sqlite3_blocking_step()函數使用的算法描述如下:
    (1)在指定的SQL語句對象上調用sqlite3_step(),如果返回除SQLITE_LOCKED之外的值,則直接返回這個值給調用者。如果返回SQLITE_LOCKED則繼續。
    (2)調用sqlite3_unlock_notify()注冊一個解鎖通知回調函數。如果sqlite3_unlock_notify()返回SQLITE_LOCKED,說明系統死鎖,返回這個值給調用者以便回滾。否則繼續。
    (3)阻塞,直到解鎖通知函數被另外一個線程執行。
    (4)在SQL語句對象上調用sqlite3_reset()。因為SQLITE_LOCKED錯誤可能只發生在第一次調用sqlite3_step()時(不可能有sqlite3_step()先返回SQLITE_ROW而下一次卻返回SQLITE_LOCKED的情況)。這時SQL語句對象會被重置,從而不會影響查詢結果。如果不調用sqlite3_reset(),下一次調用sqlite3_step()將返回SQLITE_MISUSE。
    (5)轉向步驟(1)。
    sqlite3_blocking_prepare_v2()使用的算法也類似,只不過第4步(重置SQL語句對象)忽略。

    對於“寫飢餓”現象,SQLite能幫助應用程序避免出現寫飢餓的情況。當在一個表上獲取寫鎖的任何嘗試失敗后(因為有連接一直持有讀鎖),共享緩存上啟動新事務的所有嘗試都會失敗,直到下面有一種情況變成true為止:
    * 當前寫事務完成,或者
    * 共享緩存上打開的讀事務數量減為0。
    啟動新的讀事務失敗會返回SQLITE_LOCKED給調用者。如果調用者然后調用sqlite3_unlock_notify()注冊一個解鎖通知函數,阻塞式連接當前在共享緩存上會有一個寫事務。這就避免了寫飢餓,因為沒有新的讀鎖可以打開了。當所有存在的讀鎖完成時,寫操作最終能有機會獲得需要的寫鎖。
    在wait_for_unlock_notify()調用sqlite3_unlock_notify()時,有可能阻塞式線程已經完成它的事務,這樣在sqlite3_unlock_notify()返回前解鎖通知函數會立刻被調用。解鎖通知函數也有可能被另一個線程調用,正好發生在sqlite3_unlock_notify()調用之后,而在這個線程開始等待異步信號之前。這樣的競爭條件怎么處理,取決於應用程序使用的線程和同步原語。本例子中使用pthread,這是現代Unix風格的系統(包括Linux)提供的接口。
    pthread提供pthread_cond_wait()函數,它允許調用者同時釋放一個互斥量並開始等待一個異步信號。使用這個函數、一個"fired"標志和一個互斥量,競爭狀態可以消除,如下:
    當解鎖通知函數被調用時,這可能發生在調用sqlite3_unlock_notify()的線程開始等待一個異步信號之前,它做下面的工作:
    (1)獲取互斥量。
    (2)設置"fired"標志為true。
    (3)向等待線程發信號。
    (4)釋放互斥量。
    當wait_for_unlock_notify()線程開始等待解鎖通知函數到達時,它:
    (1)獲取互斥量。
    (2)檢查"fired"標志是否設置。如果已設置,解鎖通知函數已經被調用,直接釋放互斥量,然后繼續。
    (3)如果沒設置,原子性地釋放互斥量,並開始等待異步信號。當信號到達時,繼續。
    通過這種方式,當wait_for_unlock_notify()開始阻塞時,解鎖通知函數不管是已經被調用,還是正在被調用,都沒有問題。

    本文例子中的代碼至少在以下兩個方面可以改進:
    * 能管理線程優先級。
    * 能處理SQLITE_LOCKED的特殊情形,這可能發生在刪除一個表或索引時。
    雖然sqlite3_unlock_notify()只允許調用者指定單個的用戶上下文指針,但一個解鎖通知回調是傳給這種上下文指針數組的。這是因為當一個阻塞式線程完成它的事務時,如果有多個解鎖通知被注冊用於調用同一個C函數,則上下文指針就要排列成一個數組。如果每個線程分配一個優先級,則高優先級的線程就會比低優先級的線程先得到信號通知,而不是以任意的順序來通知線程。
    如果執行一個"DROP TABLE"或"DROP INDEX"命令,而當前數據庫連接上有一個或多個正在執行的SELECT語句,則會返回SQLITE_LOCKED。如果調用了sqlite3_unlock_notify(),指定的回調函數立刻會被調用。重新嘗試"DROP TABLE"或"DROP INDEX"將返回另外一個SQLITE_LOCKED錯誤。在上面的sqlite3_blocking_step()實現中,這會導致死循環。
    調用者可以使用擴展錯誤碼來區別這種特殊的"DROP TABLE|INDEX"情形和其他情形。當它正常調用sqlite3_unlock_notify()時,擴展錯誤碼是SQLITE_LOCKED_SHAREDCACHE。在"DROP TABLE|INDEX"情形中,是普通的SQLITE_LOCKED。另外一種解決方法是限制重試單個查詢的次數(如100次)。雖然這會導致效率低一點,但我們這里討論的情況並不是經常發生的。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM