最新需要給軟件做數據庫讀寫方面的優化,之前無論讀寫,都是用一個 SQLiteOpenHelper.getWriteableDataBase() 來操作數據庫,現在需要多線程並發讀寫,項目用的是2.2的SDK。
android 的數據庫系統用的是sqlite ,sqlite的每一個數據庫其實都是一個.db文件,它的同步鎖也就精確到數據庫級了,不能跟別的數據庫有表鎖,行鎖。
所以對寫實在有要求的,可以使用多個數據庫文件。
哎,這數據庫在多線程並發讀寫方面本身就挺操蛋的。
下面分析一下不同情況下,在同一個數據庫文件上操作,sqlite的表現。
測試程序在2.2虛擬手機,4.2.1虛擬手機,4.2.1真手機上跑。
1,多線程寫,使用一個SQLiteOpenHelper。也就保證了多線程使用一個SQLiteDatabase。
先看看相關的源碼
//SQLiteDatabase.java public long insertWithOnConflict(String table, String nullColumnHack, ContentValues initialValues, int conflictAlgorithm) { if (!isOpen()) { throw new IllegalStateException("database not open"); } .... 省略 lock(); SQLiteStatement statement = null; try { statement = compileStatement(sql.toString()); // Bind the values if (entrySet != null) { int size = entrySet.size(); Iterator<Map.Entry<String, Object>> entriesIter = entrySet.iterator(); for (int i = 0; i < size; i++) { Map.Entry<String, Object> entry = entriesIter.next(); DatabaseUtils.bindObjectToProgram(statement, i + 1, entry.getValue()); } } // Run the program and then cleanup statement.execute(); long insertedRowId = lastInsertRow(); if (insertedRowId == -1) { Log.e(TAG, "Error inserting " + initialValues + " using " + sql); } else { if (Config.LOGD && Log.isLoggable(TAG, Log.VERBOSE)) { Log.v(TAG, "Inserting row " + insertedRowId + " from " + initialValues + " using " + sql); } } return insertedRowId; } catch (SQLiteDatabaseCorruptException e) { onCorruption(); throw e; } finally { if (statement != null) { statement.close(); } unlock(); } }
//SQLiteDatabase.java private final ReentrantLock mLock = new ReentrantLock(true); /* package */ void lock() { if (!mLockingEnabled) return; mLock.lock(); if (SQLiteDebug.DEBUG_LOCK_TIME_TRACKING) { if (mLock.getHoldCount() == 1) { // Use elapsed real-time since the CPU may sleep when waiting for IO mLockAcquiredWallTime = SystemClock.elapsedRealtime(); mLockAcquiredThreadTime = Debug.threadCpuTimeNanos(); } } }
通過源碼可以知道,在執行插入時,會請求SQLiteDatabase對象的成員對象 mlock 的鎖,來保證插入不會並發執行。
經測試不會引發異常。
但是我們可以通過使用多個SQLiteDatabase對象同時插入,來繞過這個鎖。
2,多線程寫,使用多個SQLiteOpenHelper,插入時可能引發異常,導致插入錯誤。
E/Database(1471): android.database.sqlite.SQLiteException: error code 5: database is locked08-01
E/Database(1471): at android.database.sqlite.SQLiteStatement.native_execute(Native Method)
E/Database(1471): at android.database.sqlite.SQLiteStatement.execute(SQLiteStatement.java:55)
E/Database(1471): at android.database.sqlite.SQLiteDatabase.insertWithOnConflict(SQLiteDatabase.java:1549)
多線程寫,每個線程使用一個SQLiteOpenHelper,也就使得每個線程使用一個SQLiteDatabase對象。多個線程同時執行insert, 最后調用到本地方法 SQLiteStatement.native_execute
拋出異常,可見android 框架,多線程寫數據庫的本地方法里沒有同步鎖保護,並發寫會拋出異常。
所以,多線程寫必須使用同一個SQLiteOpenHelper對象。
3,多線程讀
看SQLiteDatabase的源碼可以知道,insert , update , execSQL 都會 調用lock(), 乍一看唯有query 沒有調用lock()。可是。。。
仔細看,發現
最后,查詢結果是一個SQLiteCursor對象。
SQLiteCursor保存了查詢條件,但是並沒有立即執行查詢,而是使用了lazy的策略,在需要時加載部分數據。
在加載數據時,調用了SQLiteQuery的fillWindow方法,而該方法依然會調用SQLiteDatabase.lock()
/** * Reads rows into a buffer. This method acquires the database lock. * * @param window The window to fill into * @return number of total rows in the query */ /* package */ int fillWindow(CursorWindow window, int maxRead, int lastPos) { long timeStart = SystemClock.uptimeMillis(); mDatabase.lock(); mDatabase.logTimeStat(mSql, timeStart, SQLiteDatabase.GET_LOCK_LOG_PREFIX); try { acquireReference(); try { window.acquireReference(); // if the start pos is not equal to 0, then most likely window is // too small for the data set, loading by another thread // is not safe in this situation. the native code will ignore maxRead int numRows = native_fill_window(window, window.getStartPosition(), mOffsetIndex, maxRead, lastPos); // Logging if (SQLiteDebug.DEBUG_SQL_STATEMENTS) { Log.d(TAG, "fillWindow(): " + mSql); } mDatabase.logTimeStat(mSql, timeStart); return numRows; } catch (IllegalStateException e){ // simply ignore it return 0; } catch (SQLiteDatabaseCorruptException e) { mDatabase.onCorruption(); throw e; } finally { window.releaseReference(); } } finally { releaseReference(); mDatabase.unlock(); } }
所以想要多線程讀,讀之間沒有同步鎖,也得每個線程使用各自的SQLiteOpenHelper對象,經測試,沒有問題。
4,多線程讀寫
我們最終想要達到的目的,是多線程並發讀寫
多線程寫之前已經知道結果了,同一時間只能有一個寫。
多線程讀可以並發
所以,使用下面的策略:
一個線程寫,多個線程同時讀,每個線程都用各自SQLiteOpenHelper。
這樣,在java層,所有線程之間都不會鎖住,也就是說,寫與讀之間不會鎖,讀與讀之間也不會鎖。
發現有插入異常。
E/SQLiteDatabase(18263): Error inserting descreption=InsertThread#01375493606407
E/SQLiteDatabase(18263): android.database.sqlite.SQLiteDatabaseLockedException: database is locked (code 5)
E/SQLiteDatabase(18263): at android.database.sqlite.SQLiteConnection.nativeExecuteForLastInsertedRowId(Native Method)
插入異常,說明在有線程讀的時候寫數據庫,會拋出異常。
分析源碼可以知道, SQLiteOpenHelper.getReadableDatabase() 不見得獲得的就是只讀SQLiteDatabase 。
// SQLiteOpenHelper.java
public synchronized SQLiteDatabase getReadableDatabase() {
if (mDatabase != null && mDatabase.isOpen()) {
return mDatabase; // The database is already open for business
}
if (mIsInitializing) {
throw new IllegalStateException("getReadableDatabase called recursively");
}
try {
return getWritableDatabase();
} catch (SQLiteException e) {
if (mName == null) throw e; // Can't open a temp database read-only!
Log.e(TAG, "Couldn't open " + mName + " for writing (will try read-only):", e);
}
SQLiteDatabase db = null;
try {
mIsInitializing = true;
String path = mContext.getDatabasePath(mName).getPath();
db = SQLiteDatabase.openDatabase(path, mFactory, SQLiteDatabase.OPEN_READONLY);
if (db.getVersion() != mNewVersion) {
throw new SQLiteException("Can't upgrade read-only database from version " +
db.getVersion() + " to " + mNewVersion + ": " + path);
}
onOpen(db);
Log.w(TAG, "Opened " + mName + " in read-only mode");
mDatabase = db;
return mDatabase;
} finally {
mIsInitializing = false;
if (db != null && db != mDatabase) db.close();
}
}
因為它先看有沒有已經創建的SQLiteDatabase,沒有的話先嘗試創建讀寫 SQLiteDatabase ,失敗后才嘗試創建只讀SQLiteDatabase 。
所以寫了個新方法,來獲得只讀SQLiteDatabase
//DbHelper.java //DbHelper extends SQLiteOpenHelper public SQLiteDatabase getOnlyReadDatabase() { try{ getWritableDatabase(); //保證數據庫版本最新 }catch(SQLiteException e){ Log.e(TAG, "Couldn't open " + mName + " for writing (will try read-only):",e); } SQLiteDatabase db = null; try { String path = mContext.getDatabasePath(mName).getPath(); db = SQLiteDatabase.openDatabase(path, mFactory, SQLiteDatabase.OPEN_READONLY); if (db.getVersion() != mNewVersion) { throw new SQLiteException("Can't upgrade read-only database from version " + db.getVersion() + " to " + mNewVersion + ": " + path); } onOpen(db); readOnlyDbs.add(db); return db; } finally { } }
使用策略:一個線程寫,多個線程同時讀,只用一個SQLiteOpenHelper,讀線程使用自己寫的getOnlyReadDatabase()方法獲得只讀。
但是經過測試,還是會拋出異常,2.2上只有插入異常,4.1.2上甚至還有讀異常。
4.1.2上測試,讀異常。
E/SQLiteLog(18263): (5) database is locked
W/dalvikvm(18263): threadid=21: thread exiting with uncaught exception (group=0x41e2c300)
E/AndroidRuntime(18263): FATAL EXCEPTION: onlyReadThread#8
E/AndroidRuntime(18263): android.database.sqlite.SQLiteDatabaseLockedException: database is locked (code 5): , while compiling: SELECT * FROM test_t
看來此路不同啊。
其實SQLiteDataBase 在API 11 多了一個 屬性 ENABLE_WRITE_AHEAD_LOGGING。
可以打,enableWriteAheadLogging(),可以關閉disableWriteAheadLogging(),默認是關閉的。
這個屬性是什么意思呢?
參考api文檔,這個屬性關閉時,不允許讀,寫同時進行,通過 鎖 來保證。
當打開時,它允許一個寫線程與多個讀線程同時在一個SQLiteDatabase上起作用。實現原理是寫操作其實是在一個單獨的文件,不是原數據庫文件。所以寫在執行時,不會影響讀操作,讀操作讀的是原數據文件,是寫操作開始之前的內容。
在寫操作執行成功后,會把修改合並會原數據庫文件。此時讀操作才能讀到修改后的內容。但是這樣將花費更多的內存。
有了它,多線程讀寫問題就解決了,可惜只能在API 11 以上使用。
所以只能判斷sdk版本,如果3.0以上,就打開這個屬性
public DbHelper(Context context , boolean enableWAL) { this(context, DEFAULT_DB_NAME, null, DEFAULT_VERSION); if( enableWAL && Build.VERSION.SDK_INT >= 11){ getWritableDatabase().enableWriteAheadLogging(); } }
關於SQLiteDatabase的這個屬性,參考api文檔,也可以看看SQLiteSession.java里對多線程數據庫讀寫的描述。
結論
想要多線程並發讀寫,3.0以下就不要想了,3.0以上,直接設置enableWriteAheadLogging()就ok。
如果還是達不到要求,就使用多個db文件吧。
另:
單位有一個三星 note2手機,上面所有的例子跑起來都啥問題也沒有。。。。很好很強大。
最后,附上我的測試程序。
https://github.com/zebulon988/SqliteTest.git
獨家之言,如有問題請回復我,謝謝!