WCDB for Android
前言
最近自己項目記錄數據庫有用戶反饋數據會丟失,我們一直都沒找到初步原因,因此也是懷疑部分用戶數據庫損壞導致,查看了下sqlite官網的說法有導致損壞db文件的如下幾點原因:
- 文件錯寫
- 文件鎖 bug
- 文件 sync 失敗
- 設備損壞
- 內存覆蓋
- 操作系統 bug
- SQLite bug
具體的大家可以看下這篇文章:微信客戶端SQLite數據庫損壞修復實踐
因此我們才會調研考慮要不要使用微信自己出的這個WCDB數據庫,下面先具體的講解下WCDB
具體的功能
- 基于SQLCipher的數據庫加密
- 使用連接池實現并發讀寫
- Reparir Kit工具類用于修復損壞數據庫
- 針對占用空間大小優化的數據庫備份和恢復功能
- 日志輸出重定向和性能跟蹤接口
- 內建用于全文搜索的mmicu FTS3/4的分詞器
接入
在build.gradle下面配置
dependencies {
...
compile 'com.tencent.wcdb:wcdb-android:1.0.2'
}
選擇接入的CPU架構,WCDB包含 armeabi, armeabi-v7a, arm64-v8a, x86四種架構的動態庫,具體的就想用哪個用哪個了具體配置在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 語句實現,比如設置 KDF 迭代次數(兼容老版本 SQLCipher DB)、設置 Page Size 等操作。
- 構造方法中直接傳入一個byte[]作為密碼加密操作,很簡單,WCDB 將 String 類型的密碼改為 byte[] 類型,可以支持非打印字符作為密碼(比如 hash(user id) 方式),原來字符類型密碼只要轉換為 UTF-8 的 byte 數組即可,和 SQLCipher Android 兼容。
數據遷移
SQLCipher 提供了 sqlcipher_export SQL 函數用于導出數據到掛載的另一個 DB,可以用于數據遷移。 但這個函數用于 Android 的 SQLiteOpenHelper 并不方便。
SQLiteOpenHelper 主要幫助開發者做 Schema 版本管理,通過它打開 SQLite 數據庫,會讀取 user_version 字段來判斷是否需要升級,并調用子類實現的 onCreate、onUpgrade 等接口來完成創建或升級操作。 sqlcipher_export 由于是導出而非導入,就跟 onCreate 等接口不搭了,因為要關閉原來的 DB, 打開老的 DB,執行 export 到新 DB,再重打開。
為了方便使用,WCDB 就做了擴展,將 sqlcipher_export 擴展為可以接受第二個參數表示從哪里導出, 從而實現了導入,列子看下:
@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舊的數據庫倒出數據庫到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);
}
如此就可以不關閉原來的數據庫實現數據導入,可以兼容 SQLiteOpenHelper 的接口了。
數據庫修復
Android 接口支持三種修復方法,如下:
修復方法 | 簡介 | 相關接口 |
---|---|---|
Repair Kit | 解析 B-tree 修復 | RepairKit類 |
備份恢復 | 壓縮備份完整數據,使用備份數據恢復 | BackupKit 和 RecoverKit |
Dump | .dump 命令,已廢棄 | DBDumpUtil |
一,Repair Kit
使用 Repair Kit 可以直接從損壞的數據庫里盡量讀出未損壞的數據,不需要事先準備, 但是先備份 Master 信息可以大大增加恢復成功率。 如果有意使用 Repair Kit 恢復數據庫, 建議備份 Master 信息。Master 信息保存了數據庫的 Schema,建議每次執行完數據庫創建或升級時執行備份,可以保證備份 是最新的。不修改 Schema 的話 Master 信息不會改變。如果你使用 SQLiteOpenHelper,最佳 實踐是在 SQLiteOpenHelper.onCreate(...) 和 SQLiteOpenHelper.onUpgrade(...) 的 最后進行備份。備份 Master 信息只需要調用 RepairKit.MasterInfo.save(...) 即可。備份 Master 信息 典型消耗為幾kB ~ 幾十kB,幾毫秒 ~ 幾十毫秒,但如果你有非常非常多的表和索引(萬數量級), 這個過程可能會有點慢,建議放在子線程完成.如下:
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) {
// 執行 CREATE TABLE 創建 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) {
// 執行升級
db.execSQL("ALTER TABLE t1 ADD COLUMN x TEXT;");
// 備份 Master 信息
RepairKit.MasterInfo.save(db, db.getPath() + "-mbak", BACKUP_PASSPHRASE);
}
}
二,恢復損壞數據庫
恢復損壞數據庫,首先加載之前備份的 Master 信息(如果有)。
RepairKit.MasterInfo master = RepairKit.MasterInfo.load('/path/to/database.db-mbak',
BACKUP_PASSPHRASE, null);
if (master == null) {
// 加載不成功,可能是不存在或者損壞
}
使用 RepairKit 打開損壞的數據庫,使用 SQLiteDatabase 打開新的數據庫,調用 output(...) 即可將損壞數據庫的內容轉移到新數據庫。
RepairKit repair = new RepairKit(
"/path/to/corrupted.db" // 損壞的數據庫文件
PASSPHRASE, // 數據庫密鑰(不是備份文件密鑰)
CIPHER_SPEC, // 加密描述,與打開DB時一樣
master // 之前加載的 Master 信息
);
SQLiteDatabase newDb = SQLiteDatabase.openOrCreateDatabase(...);
// 打開新DB用于承載恢復數據,是否加密沒所謂
boolean result = repair.output(newDb, 0);
// 輸出恢復數據到新DB
if (!result) {
// 恢復失敗
}
repair.release();
// 最后要 release 釋放資源
恢復的過程需時較長,請務必在子線程完成,如數據庫較大請考慮持有 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);
日志重定向與性能監控
SQLite 和 WCDB 框架在運行中會產生日志,這些日志默認會打印到系統日志(logcat),但這可能不是 所有開發者都希望的行為。比如擔心日志里帶有敏感信息,直接輸出到系統不妥,或者希望將日志寫到文件 用于上報和分析,WCDB 提供接口來完成日志重定向。使用情況:
//不打印任何日志
Log.setLogger(Log.LOGGER_NONE);
//或者自定義日志
Log.setLogger(new Log.LogCallback() {
@Override
public void println(int priority, String tag, String msg) {
//處理日志
}
});
WCDB 還提供了性能監控接口 SQLiteTrace,實現接口并綁定到 SQLiteDatabase 可以在每次 執行 SQL 語句或連接池擁堵的時候得到回調
SQLiteTrace trace=new SQLiteTrace() {
@Override
public void onSQLExecuted(SQLiteDatabase db, String sql, int type, long time) {
//每次之行完一條sql的語句執行的回調
}
@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秒的回調,因為存在別的操作占用著連接池
}
@Override
public void onDatabaseCorrupted(SQLiteDatabase db) {
//數據庫損壞時回調
}
};
mDB.setTraceCallback(trace);
SQLiteDatabase 也開放了 dump 方法,可以打印出數據庫的當前狀態,包括連接池內所有連接 被持有的狀態以及最近執行的 SQL 語句和耗時,對排查性能和死鎖問題也有很大幫助。
優化 Cursor 實現
Android 框架查詢數據庫使用的是 Cursor 接口,調用 SQLiteDatabase.query(...) 會返回一個Cursor 對象,之后就可以使用 Cursor 遍歷結果集了。Android SDK SQLite Cursor 的實現是分配一個固定 2MB 大小的緩沖區,稱作 Cursor Window,用于存放查詢結果集。
查詢時,先分配Cursor Window,然后執行 SQL 獲取結果集填充之,直到 Cursor Window 放滿或者遍歷完結果集,之后將 Cursor 返回給調用者。
假如 Cursor 遍歷到緩沖區以外的行,Cursor 會丟棄之前緩沖區的所有內容,重新查詢,跳過前面的行,重新選定一個開始位置填充 Cursor Window 直到緩沖區再次填滿或遍歷完結果集。
這樣的實現能保證大部分情況正常工作,在很多情況下卻不是最優實現。微信對 DB 操作最多的場景是獲取 Cursor 直接遍歷獲取數據后關閉,獲取到的數據,一般是生成對應的實體對象(通過 ORM 或者自行從 Cursor 轉換)后放到 List 或 Map 等容器里返回,或用于顯示,或用于其他邏輯。
在這種場景下,先將數據保存到 Cursor Window 后再取出,中間要經歷兩次內存拷貝和轉換(SQLite → CursorWindow → Java),這是完全沒有必要的。另外,由于 Cursor Window 是定長的,對于較小的結果集,需要無故分配 2MB 內存,對于大結果集,如果 2MB 不足以放下,遍歷到途中還會引發 Cursor 重查詢,這個消耗就相當大了。
Cursor Window,其實也是在 JNI 層通過 SQLite 庫的 Statement 填充的,Statement 這里可以理解為一個輕量但只能往前遍歷,沒有緩存的 Cursor。這個不就跟我們的場景一致嗎?何不直接使用底層的 Statement 呢?我們對 Statement 做了簡單的封裝,暴露了 Cursor 接口, SQLiteDirectCursor 就誕生了,它直接操作底層 SQLite 獲取數據,只能執行往前迭代的操作,但這完全滿足需要。
com.tencent.wcdb.Cursor cursor=mDB.rawQueryWithFactory(SQLiteDirectCursor.FACTORY,sql,null);
try {
while (cursor.moveToNext()) {
//處理數據
}
}catch (Exception e){
e.printStackTrace();
}
在大部分不需要將 Cursor 傳遞出去的場景,能很好的解決 Cursor 的額外消耗,特別是結果集大于 2MB 的場合。