介紹
通常情況下,sqlite中每個連接都會一個獨立的pager對象,pager對象中管理了該連接的緩存信息,通過pragma cache_size指令可以設置緩存大小,默認是2000個page,每個page是1024B。這樣導致了對於同一個數據文件,多個連接各自維護了自己的一份緩存,在高並發情況下,可能導致使用大量的內存。而sqlite作為一個嵌入式數據庫,通常用於嵌入式設備,內存可能比較有限,為了應對這種問題,sqlite提供了一種方法,通過讓多個連接公用一個pager對象,共享同一份緩存。
當打開這個特性后,多個連接可以共享一個pager對象,這樣在一定程度上減少了內存的消耗和文件的IO次數。一個線程的多個連接對象,以及多個線程的多個連接對象都可以采用這種方式來共享緩存。由於sqlite以動態庫方式嵌入在應用程序中,每個應用程序有自己獨立的進程空間,因此該特性不能用於進程之間,同一個進程可以共享一份緩存,進程之間各自維護自己的緩存。關於緩存的實現后面會單獨寫一篇文章。下圖展示了啟用共享緩存后的結構圖。
圖1
從圖1中可以看到,Process1中的兩個連接共享BtShared對象,BtShared對象對應一個Pager對象,而緩存由Pager對象管理,因此整個Process1中的所有連接公用一個緩存,通過Pager模塊操作Page,IO操作對上層模塊透明。
實現原理
調用接口sqlite3_open_v2打開連接時,通過指定參數SQLITE_OPEN_SHAREDCACHE來聲明連接采用共享緩存模式。之前SQLite系列(二):常規性能測試 中的單表主鍵查詢測試章節中提到,開啟共享緩存模式下,導致應用的程序的並發性能大大下降,多線程情況下,CPU也只能用一個核。因此大家在實際使用中,要權衡內存和並行度,來確定是否開啟共享緩存模式。
我們的測試場景都是只讀,理論上不應該存在並發沖突,那么為什么不能並行?這里要看共享緩存的實現了。從圖1中也可以看到,每個連接有一個btree對象,多個btree對象通過共享BtShared對象來共享緩存,BtShared對象通過mutux來維護里面的成員,包括page cache的管理,table-lock信息等,因此這個mutex是一個熱點,在高並發場景下,多個線程同時訪問BtShared對象,會由於競爭mutex,無法充分並發,導致並行度差。
table-lock
默認情況下,sqlite只通過文件鎖就可以實現讀寫互斥,讀讀並發的效果,關於這點我在SQLite系列(五):SQLITE封鎖機制中已經說明。那么為什么要引入table-lock?這個也是拜共享緩存所賜。共享緩存模式下,多個btree對象對應同一個BtShared對象,在執行更新時,首先會修改pager中cache,此時更新事務加了reserved-lock,與讀事務的shared-lock不互斥。為了避免讀到臟頁,在共享緩存模式下,增加了table-lock,避免讀寫事務同時訪問同一個cache,導致臟讀的情況發生。用戶訪問具體某個表的page之前,會首先調用sqlite3BtreeLockTable對該表設置一個READ-LOCK(select 操作)或WRITE-LOCK(DML操作),如果發現沖突,則拋出SQLITE_LOCKED錯誤。通過這種方式,保證了多個線程不會同時對一個表進行讀寫操作。函數sqlite3BtreeLockTable部分實現如下:
sqlite3BtreeEnter(p); //判斷加鎖是否沖突 rc = querySharedCacheTableLock(p, iTab, lockType); if( rc==SQLITE_OK ){ //加鎖 rc = setSharedCacheTableLock(p, iTab, lockType); } sqlite3BtreeLeave(p);
從上面的代碼可以看到,若加鎖沖突,則直接報SQLITE_LOCKED錯誤;否則加上鎖。注意看到判斷加鎖和加鎖過程通過BtShared對象的mutex來保護(sqlite3BtreeEnter,sqlite3BtreeLeave),因此加鎖過程是串行的,table-lock鏈表不會被多個線程同時操作。
querySharedCacheTableLock代碼邏輯
/* If some other connection is holding an exclusive lock, the ** requested lock may not be obtained. */ if( pBt->pWriter!=p && (pBt->btsFlags & BTS_EXCLUSIVE)!=0 ){ sqlite3ConnectionBlocked(p->db, pBt->pWriter->db); return SQLITE_LOCKED_SHAREDCACHE; }
setSharedCacheTableLock邏輯
for(pIter=pBt->pLock; pIter; pIter=pIter->pNext){ if( pIter->iTable==iTable && pIter->pBtree==p ){ pLock = pIter; break; } } /*create a table lock*/ if( !pLock ){ pLock = (BtLock *)sqlite3MallocZero(sizeof(BtLock)); if( !pLock ){ return SQLITE_NOMEM; } pLock->iTable = iTable; pLock->pBtree = p; pLock->pNext = pBt->pLock; pBt->pLock = pLock; }
遍歷BtShared對象中已有鎖鏈表,比較iTable和對應的pBtree是否與自身相同(自己是否已經加過),若沒有,則申請鎖對象,加入鏈表。
加鎖流程
我們知道sqlite有兩種日志模式,默認的DELETE模式和WAL模式,下面我會介紹開啟共享緩存模式后,更新操作的加鎖流程,主要變化在於table-lock。
普通日志模式+共享緩存模式
- 開啟事務:shared-lock[sqlite3BtreeBeginTrans]
- DML操作:
- 文件鎖,reserved-lock
- table-lock,
將對應的表加鎖,同一個表的讀鎖與寫鎖互斥 - 讀取表對應的page
- 提交:
- 加execlusive-lock [sqlite3PagerCommitPhaseOne,刷日志]
- 刪除日志文件
- 釋放execlusive-lock
- 釋放table-lock
WAL日志模式+共享緩存模式
- 開啟事務,shared-lock[sqlite3BtreeBeginTrans]
- DML操作
- 數據文件鎖,shared-lock
- wal日志文件write-lock
- table-lock
- 提交
- 釋放table-lock
- 釋放日志文件write-lock
- 釋放數據文件shared-lock