WCDB for Android

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 的場合。

以上就是我自己對WCDB的理解總結,很多知識點都是看微信WCDB官網上的知識,大家可以自己去看下。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,048評論 6 542
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,414評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,169評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,722評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,465評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,823評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,813評論 3 446
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,000評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,554評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,295評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,513評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,035評論 5 363
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,722評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,125評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,430評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,237評論 3 398
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,482評論 2 379

推薦閱讀更多精彩內容