WCDB for Android
前言
最近自己項目記錄數(shù)據(jù)庫有用戶反饋數(shù)據(jù)會丟失,我們一直都沒找到初步原因,因此也是懷疑部分用戶數(shù)據(jù)庫損壞導致,查看了下sqlite官網(wǎng)的說法有導致?lián)p壞db文件的如下幾點原因:
- 文件錯寫
- 文件鎖 bug
- 文件 sync 失敗
- 設備損壞
- 內(nèi)存覆蓋
- 操作系統(tǒng) bug
- SQLite bug
具體的大家可以看下這篇文章:微信客戶端SQLite數(shù)據(jù)庫損壞修復實踐
因此我們才會調(diào)研考慮要不要使用微信自己出的這個WCDB數(shù)據(jù)庫,下面先具體的講解下WCDB
具體的功能
- 基于SQLCipher的數(shù)據(jù)庫加密
- 使用連接池實現(xiàn)并發(fā)讀寫
- Reparir Kit工具類用于修復損壞數(shù)據(jù)庫
- 針對占用空間大小優(yōu)化的數(shù)據(jù)庫備份和恢復功能
- 日志輸出重定向和性能跟蹤接口
- 內(nèi)建用于全文搜索的mmicu FTS3/4的分詞器
接入
在build.gradle下面配置
dependencies {
...
compile 'com.tencent.wcdb:wcdb-android:1.0.2'
}
選擇接入的CPU架構,WCDB包含 armeabi, armeabi-v7a, arm64-v8a, x86四種架構的動態(tài)庫,具體的就想用哪個用哪個了具體配置在build.gradle:
android {
defaultConfig {
...
ndk {
// 接入 armeabi ,armeabi-v7a ,x86
abiFilters 'armeabi', 'armeabi-v7a','x86'
}
}
}
加密:WCDB在android上語法和官方再帶的sqlite是一樣的,記得導包的時候引用tencent的,下面開始看一個具體的列子:
import android.content.Context;
import com.tencent.wcdb.DatabaseErrorHandler;
import com.tencent.wcdb.database.SQLiteCipherSpec;
import com.tencent.wcdb.database.SQLiteDatabase;
import com.tencent.wcdb.database.SQLiteOpenHelper;
public class DBHelper extends SQLiteOpenHelper {
static final String DATABASE_NAME = "test-repair.db";
static final int DATABASE_VERSION = 1;
static final byte[] PASSPHRASE = "testkey".getBytes();
// The test database is taken from SQLCipher test-suit.
//
// To be compatible with databases created by the official SQLCipher
// library, a SQLiteCipherSpec must be specified with page size of
// 1024 bytes.
static final SQLiteCipherSpec CIPHER_SPEC = new SQLiteCipherSpec()
.setPageSize(1024);
// We don't want corrupted databases get deleted or renamed on this sample,
// so use an empty DatabaseErrorHandler.
static final DatabaseErrorHandler ERROR_HANDLER = new DatabaseErrorHandler() {
@Override
public void onCorruption(SQLiteDatabase dbObj) {
// Do nothing
}
};
public DBHelper(Context context) {
super(context, DATABASE_NAME, null, CIPHER_SPEC, null,
DATABASE_VERSION, ERROR_HANDLER);
// super(context,DATABASE_NAME,null,DATABASE_VERSION);
}
@Override
public void onCreate(SQLiteDatabase db) {
db.execSQL("CREATE TABLE t1(a,b);");
// OPTIONAL: backup master info for corruption recovery.
// However, we want to test recovery feature, so omit backup here.
//RepairKit.MasterInfo.save(db, db.getPath() + "-mbak", PASSPHRASE);
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
// Do nothing.
}
}
- 也是繼承SQLiteOpenHelper去做事情。WCDB 使用了 SQLCipher 的 C 層庫,但沒有直接使用 SQLCipher Android 的封裝層。SQLCipher Android 封裝層中很多設置需要手寫 PRAGMA 語句實現(xiàn),比如設置 KDF 迭代次數(shù)(兼容老版本 SQLCipher DB)、設置 Page Size 等操作。
- 構造方法中直接傳入一個byte[]作為密碼加密操作,很簡單,WCDB 將 String 類型的密碼改為 byte[] 類型,可以支持非打印字符作為密碼(比如 hash(user id) 方式),原來字符類型密碼只要轉(zhuǎn)換為 UTF-8 的 byte 數(shù)組即可,和 SQLCipher Android 兼容。
數(shù)據(jù)遷移
SQLCipher 提供了 sqlcipher_export SQL 函數(shù)用于導出數(shù)據(jù)到掛載的另一個 DB,可以用于數(shù)據(jù)遷移。 但這個函數(shù)用于 Android 的 SQLiteOpenHelper 并不方便。
SQLiteOpenHelper 主要幫助開發(fā)者做 Schema 版本管理,通過它打開 SQLite 數(shù)據(jù)庫,會讀取 user_version 字段來判斷是否需要升級,并調(diào)用子類實現(xiàn)的 onCreate、onUpgrade 等接口來完成創(chuàng)建或升級操作。 sqlcipher_export 由于是導出而非導入,就跟 onCreate 等接口不搭了,因為要關閉原來的 DB, 打開老的 DB,執(zhí)行 export 到新 DB,再重打開。
為了方便使用,WCDB 就做了擴展,將 sqlcipher_export 擴展為可以接受第二個參數(shù)表示從哪里導出, 從而實現(xiàn)了導入,列子看下:
@Override
public void onCreate(SQLiteDatabase db) {
// Check whether old plain-text database exists, if so, export it
// to the new, encrypted one.
File oldDbFile = mContext.getDatabasePath(OLD_DATABASE_NAME);
if (oldDbFile.exists()) {
Log.i(TAG, "Migrating plain-text database to encrypted one.");
// SQLiteOpenHelper begins a transaction before calling onCreate().
// We have to end the transaction before we can attach a new database.
db.endTransaction();
// Attach old database to the newly created, encrypted database.
String sql = String.format("ATTACH DATABASE %s AS old KEY '';",
DatabaseUtils.sqlEscapeString(oldDbFile.getPath()));
db.execSQL(sql);
// Export old database.
db.beginTransaction();
//從old舊的數(shù)據(jù)庫倒出數(shù)據(jù)庫到main
DatabaseUtils.stringForQuery(db, "SELECT sqlcipher_export('main', 'old');", null);
db.setTransactionSuccessful();
db.endTransaction();
// Get old database version for later upgrading.
int oldVersion = (int) DatabaseUtils.longForQuery(db, "PRAGMA old.user_version;", null);
// Detach old database and enter a new transaction.
db.execSQL("DETACH DATABASE old;");
// Old database can be deleted now.
oldDbFile.delete();
// Before further actions, restore the transaction.
db.beginTransaction();
// Check if we need to upgrade the schema.
if (oldVersion > DATABASE_VERSION) {
onDowngrade(db, oldVersion, DATABASE_VERSION);
} else if (oldVersion < DATABASE_VERSION) {
onUpgrade(db, oldVersion, DATABASE_VERSION);
}
} else {
Log.i(TAG, "Creating new encrypted database.");
// Do the real initialization if the old database is absent.
db.execSQL("CREATE TABLE message (content TEXT, "
+ "sender TEXT);");
}
// OPTIONAL: backup master info for corruption recovery.
RepairKit.MasterInfo.save(db, db.getPath() + "-mbak", /*mPassphrase.getBytes()*/null);
}
如此就可以不關閉原來的數(shù)據(jù)庫實現(xiàn)數(shù)據(jù)導入,可以兼容 SQLiteOpenHelper 的接口了。
數(shù)據(jù)庫修復
Android 接口支持三種修復方法,如下:
| 修復方法 | 簡介 | 相關接口 |
|---|---|---|
| Repair Kit | 解析 B-tree 修復 | RepairKit類 |
| 備份恢復 | 壓縮備份完整數(shù)據(jù),使用備份數(shù)據(jù)恢復 | BackupKit 和 RecoverKit |
| Dump | .dump 命令,已廢棄 | DBDumpUtil |
一,Repair Kit
使用 Repair Kit 可以直接從損壞的數(shù)據(jù)庫里盡量讀出未損壞的數(shù)據(jù),不需要事先準備, 但是先備份 Master 信息可以大大增加恢復成功率。 如果有意使用 Repair Kit 恢復數(shù)據(jù)庫, 建議備份 Master 信息。Master 信息保存了數(shù)據(jù)庫的 Schema,建議每次執(zhí)行完數(shù)據(jù)庫創(chuàng)建或升級時執(zhí)行備份,可以保證備份 是最新的。不修改 Schema 的話 Master 信息不會改變。如果你使用 SQLiteOpenHelper,最佳 實踐是在 SQLiteOpenHelper.onCreate(...) 和 SQLiteOpenHelper.onUpgrade(...) 的 最后進行備份。備份 Master 信息只需要調(diào)用 RepairKit.MasterInfo.save(...) 即可。備份 Master 信息 典型消耗為幾kB ~ 幾十kB,幾毫秒 ~ 幾十毫秒,但如果你有非常非常多的表和索引(萬數(shù)量級), 這個過程可能會有點慢,建議放在子線程完成.如下:
public class DBHelper extends SQLiteOpenHelper {
public DBHelper(Context context) {
super(context, DATABASE_NAME, PASSPHRASE, CIPHER_SPEC, null,
DATABASE_VERSION, ERROR_HANDLER);
}
@Override
public void onCreate(SQLiteDatabase db) {
// 執(zhí)行 CREATE TABLE 創(chuàng)建 Schema
db.execSQL("CREATE TABLE t1(a,b);");
db.execSQL("CREATE TABLE t2(c,d);");
// ......
// 備份 Master 信息
RepairKit.MasterInfo.save(db, db.getPath() + "-mbak", BACKUP_PASSPHRASE);
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
// 執(zhí)行升級
db.execSQL("ALTER TABLE t1 ADD COLUMN x TEXT;");
// 備份 Master 信息
RepairKit.MasterInfo.save(db, db.getPath() + "-mbak", BACKUP_PASSPHRASE);
}
}
二,恢復損壞數(shù)據(jù)庫
恢復損壞數(shù)據(jù)庫,首先加載之前備份的 Master 信息(如果有)。
RepairKit.MasterInfo master = RepairKit.MasterInfo.load('/path/to/database.db-mbak',
BACKUP_PASSPHRASE, null);
if (master == null) {
// 加載不成功,可能是不存在或者損壞
}
使用 RepairKit 打開損壞的數(shù)據(jù)庫,使用 SQLiteDatabase 打開新的數(shù)據(jù)庫,調(diào)用 output(...) 即可將損壞數(shù)據(jù)庫的內(nèi)容轉(zhuǎn)移到新數(shù)據(jù)庫。
RepairKit repair = new RepairKit(
"/path/to/corrupted.db" // 損壞的數(shù)據(jù)庫文件
PASSPHRASE, // 數(shù)據(jù)庫密鑰(不是備份文件密鑰)
CIPHER_SPEC, // 加密描述,與打開DB時一樣
master // 之前加載的 Master 信息
);
SQLiteDatabase newDb = SQLiteDatabase.openOrCreateDatabase(...);
// 打開新DB用于承載恢復數(shù)據(jù),是否加密沒所謂
boolean result = repair.output(newDb, 0);
// 輸出恢復數(shù)據(jù)到新DB
if (!result) {
// 恢復失敗
}
repair.release();
// 最后要 release 釋放資源
恢復的過程需時較長,請務必在子線程完成,如數(shù)據(jù)庫較大請考慮持有 Wake Lock。
三,選擇性恢復
Repair Kit 可以只恢復一部分表,只需要在 MasterInfo.load(...) 或者 MasterInfo.make(...) 里指定白名單即可。
// 白名單,只有白名單里列到的表才會恢復,表對應的索引也會相應恢復
String[] tables = new String[] {
"t1", "t2" // 只恢復 t1 和 t2 兩個表
};
RepairKit.MasterInfo master = RepairKit.MasterInfo.load('/path/to/database.db-mbak',
BACKUP_PASSPHRASE, tables);
日志重定向與性能監(jiān)控
SQLite 和 WCDB 框架在運行中會產(chǎn)生日志,這些日志默認會打印到系統(tǒng)日志(logcat),但這可能不是 所有開發(fā)者都希望的行為。比如擔心日志里帶有敏感信息,直接輸出到系統(tǒng)不妥,或者希望將日志寫到文件 用于上報和分析,WCDB 提供接口來完成日志重定向。使用情況:
//不打印任何日志
Log.setLogger(Log.LOGGER_NONE);
//或者自定義日志
Log.setLogger(new Log.LogCallback() {
@Override
public void println(int priority, String tag, String msg) {
//處理日志
}
});
WCDB 還提供了性能監(jiān)控接口 SQLiteTrace,實現(xiàn)接口并綁定到 SQLiteDatabase 可以在每次 執(zhí)行 SQL 語句或連接池擁堵的時候得到回調(diào)
SQLiteTrace trace=new SQLiteTrace() {
@Override
public void onSQLExecuted(SQLiteDatabase db, String sql, int type, long time) {
//每次之行完一條sql的語句執(zhí)行的回調(diào)
}
@Override
public void onConnectionObtained(SQLiteDatabase db, String sql, long waitTime, boolean isPrimary) {
//從連接池獲得了鏈接成功
}
@Override
public void onConnectionPoolBusy(SQLiteDatabase db, String sql, List<String> requests, String message) {
//等待連接池超過3秒的回調(diào),因為存在別的操作占用著連接池
}
@Override
public void onDatabaseCorrupted(SQLiteDatabase db) {
//數(shù)據(jù)庫損壞時回調(diào)
}
};
mDB.setTraceCallback(trace);
SQLiteDatabase 也開放了 dump 方法,可以打印出數(shù)據(jù)庫的當前狀態(tài),包括連接池內(nèi)所有連接 被持有的狀態(tài)以及最近執(zhí)行的 SQL 語句和耗時,對排查性能和死鎖問題也有很大幫助。
優(yōu)化 Cursor 實現(xiàn)
Android 框架查詢數(shù)據(jù)庫使用的是 Cursor 接口,調(diào)用 SQLiteDatabase.query(...) 會返回一個Cursor 對象,之后就可以使用 Cursor 遍歷結(jié)果集了。Android SDK SQLite Cursor 的實現(xiàn)是分配一個固定 2MB 大小的緩沖區(qū),稱作 Cursor Window,用于存放查詢結(jié)果集。
查詢時,先分配Cursor Window,然后執(zhí)行 SQL 獲取結(jié)果集填充之,直到 Cursor Window 放滿或者遍歷完結(jié)果集,之后將 Cursor 返回給調(diào)用者。
假如 Cursor 遍歷到緩沖區(qū)以外的行,Cursor 會丟棄之前緩沖區(qū)的所有內(nèi)容,重新查詢,跳過前面的行,重新選定一個開始位置填充 Cursor Window 直到緩沖區(qū)再次填滿或遍歷完結(jié)果集。
這樣的實現(xiàn)能保證大部分情況正常工作,在很多情況下卻不是最優(yōu)實現(xiàn)。微信對 DB 操作最多的場景是獲取 Cursor 直接遍歷獲取數(shù)據(jù)后關閉,獲取到的數(shù)據(jù),一般是生成對應的實體對象(通過 ORM 或者自行從 Cursor 轉(zhuǎn)換)后放到 List 或 Map 等容器里返回,或用于顯示,或用于其他邏輯。
在這種場景下,先將數(shù)據(jù)保存到 Cursor Window 后再取出,中間要經(jīng)歷兩次內(nèi)存拷貝和轉(zhuǎn)換(SQLite → CursorWindow → Java),這是完全沒有必要的。另外,由于 Cursor Window 是定長的,對于較小的結(jié)果集,需要無故分配 2MB 內(nèi)存,對于大結(jié)果集,如果 2MB 不足以放下,遍歷到途中還會引發(fā) Cursor 重查詢,這個消耗就相當大了。
Cursor Window,其實也是在 JNI 層通過 SQLite 庫的 Statement 填充的,Statement 這里可以理解為一個輕量但只能往前遍歷,沒有緩存的 Cursor。這個不就跟我們的場景一致嗎?何不直接使用底層的 Statement 呢?我們對 Statement 做了簡單的封裝,暴露了 Cursor 接口, SQLiteDirectCursor 就誕生了,它直接操作底層 SQLite 獲取數(shù)據(jù),只能執(zhí)行往前迭代的操作,但這完全滿足需要。
com.tencent.wcdb.Cursor cursor=mDB.rawQueryWithFactory(SQLiteDirectCursor.FACTORY,sql,null);
try {
while (cursor.moveToNext()) {
//處理數(shù)據(jù)
}
}catch (Exception e){
e.printStackTrace();
}
在大部分不需要將 Cursor 傳遞出去的場景,能很好的解決 Cursor 的額外消耗,特別是結(jié)果集大于 2MB 的場合。