WCDB简介

2020-10-06  本文已影响0人  Shmily鱼

WCDB简介

WCDB(wechat dataBase)是一个高效、完整、易用的开源移动数据库框架,基于SQLCipher,支持iOS, macOS和Android。

基本特性

易用

WCDB支持一句代码即可将数据取出并组合为object。


高效

WCDB通过框架层和sqlcipher源码优化,使其更高效的表现


完整

WCDB覆盖了数据库相关各种场景的所需功能


WCDB for Android

基本功能

基于SQLCipher的数据库加密


  1. 封装了SQLiteCipherSpec类,提供了一些设置方法
    setPageSize(int size)
    setKDFIteration(int iter) (密钥导出函数的迭代次数,迭代次数越多,破解越困难)
    setKdfAlgorithm(int algo)
    用于设置加密参数,此方式屏蔽了许多细节:SQLCipher的PRAGMA语法和调用顺序等。

    SQLCipher在WCDB中的使用

  2. SQLiteCipherSpec类的结构可以传给RepairKit用于恢复损坏DB。

  3. WCDB将String类型的密码改为byte[]类型,可以支持非打印字符作为密码,
    原来字符类型密码只要转换为 UTF-8 的 byte 数组即可,和 SQLCipher Android 兼容。

使用连接池实现并发读写

数据库连接池

在数据库操作中,与数据库建立连接(Connection)是最为耗时的操作之一.
数据库都有最大连接数目的限制,为每一个访问数据库的用户建立连接显然不合理,于是有了连接池的概念.
数据库连接是关键的、有限的、昂贵的资源,对数据库连接的管理能显著影响到整个应用程序的伸缩性和健壮性. 响到程序的性能指标。

连接池大小

连接池的最大数据库连接数量限定了这个连接池能占有的最大连接数,
目前Android系统的实现中,如果以非WAL模式打开数据库,连接池中只会保持一个数据库连接,
如果以WAL模式打开数据库,连接池中的最大连接数量则根据系统配置决定,默认配置是两个。
连接池的大小设置需考虑几个因素:

数据库连接池源码解析

WCDB使用连接池实现并发读写数据库,连接池负责分配,管理和释放数据库连接,类似线程池,它允许应用程序重复使用一个现有的数据库连接,减少链接不断创建和销毁带来的资源浪费。
原理:使用WAL(Write Ahead Log)机制实现原子事务。


在打开DB前设置WAL的开启

SQLiteOpenHelper dbHelper = new SQLiteOpenHelper(...);
dbHelper.setWriteAheadLoggingEnabled(true);
SQLiteDatabase db = dbHelper.getWritableDatabase();

WAL原理:修改并不直接写入数据库文件中,而是写入到另外一个称为WAL的文件中;
如果事务失败,WAL中的记录会被忽略,撤销修改; 如果事务成功,它将在随后的某个时间被写回到数据库文件中,提交修改。

  1. WAL实现读写,读读并发,写写互斥。
  2. WAL在实现过程中,使用了共享内存。
  3. 关于连接池并发,提供了性能监控接口SQLiteTrace

日志输出重定向以及性能跟踪接口

/**
 * Use user defined logger for log outputs.
 * @param callback logger callback
 */
public static void setLogger(LogCallback callback) {
    mCallback = callback;
    nativeSetLogger(-1, callback);
}
// 实现LogCallback,可定制日志逻辑。

场景:
prepare、dispose、dump、getNativeHandle、endNativeHandle、execute各种语句执行以及executeForCursorWindow

记录参数

    long mStartTime,mEndTime;
    String mKind;
    String mSql;
    ArrayList<Object> mBindArgs;
    boolean mFinished;
    Exception mException;
    int mType;
    int mTid;

WCDB提供性能监控接口SQListeTrace

SQListeTrace.png

将接口的实现与SQLiteDataBase绑定,则可以在执行SQL语句或连接池拥堵时收到回调,可用于排查SQL性能问题。

数据库修复

SQLite DB结构

每个SQLite DB都有一个sqlite_master表来存储每个表的元数据
表名根节点地址表scheme等,通过这些信息,足够对一个表寻址)

SQLite1架构.png
sql_master.png
数据库修复目标

为实现上述方案,微信的技术选型历程:

修复DB的技术选型
备份恢复方案

备份数据,损坏后使用备份数据恢复。
SQLite提供的备份机制:

1a036b594b1a45489a40e86f9bc434bb_image.png

折中的选择是 Dump + 压缩,备份大小具有明显优势,备份性能尚可,恢复性能较差但由于需要恢复的场景较少,算是可以接受的短板。

官方Dump

原理:每个SQLite DB都有一个sqlite_master表,里面保存着全部tableindex的信息(table本身的信息,不包括里面的数据),
在DB完好的时候,遍历sqlite_master将整个DB dump出来.
具体实现: 遍历sqlite_master就可以得到所有表的名称和 CREATE TABLE ...的SQL语句,输出CREATE TABLE语句,接着使用SELECT * FROM ...通过表名遍历整个表,每读出一行就输出一个INSERT语句,遍历完后就把整个DB dump出来了。
DB所有内容输出为SQL语句,即得出与原DB等效的新DB表结构,实现备份,在恢复时执行SQL语句即可。

微信在Dump+gzip之上做了优化:

  1. 由于格式化SQL语句输出耗时较长,使用自定义二进制格式承载Dump输出,并做加密处理。
  2. 压缩操作放到其他线程同时进行。
    策略:充电并灭屏时进行DB备份,若备份过程中退出以上状态,备份会中止,等待下次机会。
//DBDumpUtil.java
public static boolean doRecoveryDb(SQLiteDatabase db, String crashDbPath, String key,
            String outputPath, List<String> filterTable, List<String> destTables,
            ExecuteSqlCallback callback, boolean needDeleteAfterSuccess) {

        // check db valid 
        ...
        //native dump result 
        boolean dumpOk = nativeDumpDB(crashDbPath, key, outputPath);
        if (!dumpOk) {
            return false;
        }

        // output to BufferedReader
        BufferedReader reader;
        try {
            reader = new BufferedReader(new FileReader(outputPath));
        } catch (FileNotFoundException e) {
            ...
            return false;
        }
        // 通过计数判断执行结果
        int failureCount = 0;
        int allCount = 0;
        int executeCount = 0;
        db.beginTransaction();
        try {
            String temp = null;
            boolean contact = false;
            //key:tableName  value:buildColumnsString(String append all columns) 
            HashMap<String, String> tables = new HashMap<>();
            // 把DB所有内容输出为SQL语句
            while ((line = reader.readLine()) != null) {
                if (contact) {
                    temp += "\n" + line;
                    if (!temp.endsWith(";") || !nativeIsSqlComplete(temp)) {
                        continue;
                    }
                } else if (line.startsWith("INSERT") || line.startsWith("CREATE TABLE")) {
                    if (!line.endsWith(";") || !nativeIsSqlComplete(line)) {
                        // temp拼接语句
                        ...
                } 
                ...
                tableName = getTableNameFromSql(temp);
                try {
                    if (temp.startsWith("CREATE TABLE")) {
                        ArrayList<String> columns = getColumnNamesFromSql(temp);
                        String bindStr = buildColumnsString(columns);
                        tables.put(tableName, bindStr);
                    } else if (temp.startsWith("INSERT INTO")) {
                        String bindStr = tables.get(tableName);
                        if (!TextUtils.isEmpty(bindStr)) {
                            StringBuilder sb = new StringBuilder("INSERT INTO ");
                            sb.append("\"").append(tableName).append("\"");
                            String prefix = sb.toString();
                            sb.append(bindStr);
                            temp = temp.replace(prefix, sb.toString());
                        }
                    }
                    String tempSql = null;
                    if (callback != null) {
                        tempSql = callback.preExecute(temp);
                    }
                    if (!TextUtils.isEmpty(tempSql)) {
                        temp = tempSql;
                    }
                    allCount++;
                    db.execSQL(temp); //执行语句
                    executeCount++;
                    ...
                    db.setTransactionSuccessful();
                    db.endTransaction();
                    ...
                } catch (Exception e) {
                    failureCount++;
                }
                temp = null;
            }
        } catch (IOException e) {
            ...
            return false;
        } finally {
            xxx.close();
        }
        if (allCount > failureCount) {
            ...
            //delete file if needDeleteAfterSuccess
            Log.i(TAG, "restore : %d , fail:%d ", allCount, failureCount);
            return true;
        } else {
            return false;
        }
    }

    //string append all columns
    public static String buildColumnsString(ArrayList<String> columns) {
        ... 遍历并拼接所有列
        return buildStr;
    }

    public static String getTableNameFromSql(String sql) {
        ...
        return tableName;
    }

    //read FileInputStream to bytes[] by file path
    public static byte[] readFromFile(String path) {
        ...
        FileInputStream fin = null;
        try {
            int size = (int) file.length();
            fin = new FileInputStream(file);
            byte[] buf = new byte[size];
            int count = fin.read(buf);
            if (count != size) {
                return null;
            } else {
                return buf;
            }
        } catch (Exception e) {
            ...
        } finally {
            ...
            fin.close();
        }

        Log.e(TAG, "readFromFile failed!");
        return null;
    }

   // 通过sql的结构 读取ColumnNames
    public static ArrayList<String> getColumnNamesFromSql(String sql) {
        ArrayList<String> columns = new ArrayList<>();
        String temp = sql.substring(sql.indexOf("(") + 1, sql.lastIndexOf(")"));
        String[] Str = temp.trim().split(",");
        for (int i = 0; i < Str.length; i++) {
            Str[i] = Str[i].trim();
            int secondIndex = Str[i].indexOf(" ");
            columns.add(Str[i].substring(0, secondIndex));
        }
        return columns;
    }

    private static native boolean nativeDumpDB(String dbPath, String key, String outputPath);
    private static native boolean nativeIsSqlComplete(String sql);

优点:

  1. 直接对SQlite处理,天然支持加密SQLCipher,无需额外处理
  2. 备份大小小,备份性能好,成功率≈72%
    问题:
    sqlite_master表读不出来,特别是第一页损坏, 会导致后续所有内容无法读出,则完全不能恢复
    需解决的痛点:为了让sqlite_master受损的DB也能打开,需要想办法使文件头或sqltree绕过SQLite引擎的逻辑。由于SQLite引擎初始化逻辑比较复杂,为了避免副作用,没有采用hack的方式复用其逻辑,而是模拟仿造一个"能够only-read数据的最小化系统"Repair Kit
Repair Kit(解析 B-tree 修复)
  1. 备份sqlite_master(仅改变表结构的时候,每次执行完数据库创建或升级时,sqlite_master才会改变,只需要在创建于升级时时候重新备份一次即可,所以备份成本低)
  2. 备份生成密钥的必要信息(备份数据的加密,恢复数据的解密)
  3. 恢复任务需遍历解析B-tree所有节点,读出数据Database File Format 详细描述了SQLite文件格式, 参照之实现B-tree解析可读取 SQLite DB

优点:

缺点:

  //RepairKit.java
  // 1. 在数据库未毁坏时,调用方法 MasterInfo#save(SQLiteDatabase db, String path, byte[] key)保存备份信息
  // 2. 用备份信息恢复时,调用MasterInfo#load(String path, byte[] key, String[] tables),并将其作为{@code master}的参数来传递
  // 3. 表过滤器可选择恢复指定的表 (MasterInfo#make 或 MasterInfo#load生成表过滤器)
  
  /**
     * Create and initialize a backup task.
   * @param path          path to the corrupted database to be repaired
   * @param key           password to the encrypted database, or null for plain-text database
   * @param cipherSpec    cipher description, or null for default settings
   * @param master        backup master info and/or table filters
   * @throws SQLiteException when corrupted database cannot be opened or error
   * @throws IllegalArgumentException when path is null.
     */
        public RepairKit(String path, byte[] key, SQLiteCipherSpec cipherSpec, MasterInfo master) {
        //check the path
        if (path == null)
            throw new IllegalArgumentException();
        mNativePtr = nativeInit(path, key, cipherSpec, (master == null) ? null : master.mKDFSalt);
        // 由于文件打开错误,错误的密码或无法恢复的损坏而导致的失败
        if (mNativePtr == 0)
            throw new SQLiteException("Failed initialize RepairKit.");
        mIntegrityFlags = nativeIntegrityFlags(mNativePtr);
        mMasterInfo = master;
    }

    public static class MasterInfo {
        private long mMasterPtr;
        private byte[] mKDFSalt;

        private MasterInfo(long ptr, byte[] salt) {
            mMasterPtr = ptr;
            mKDFSalt = salt;
        }

        public static MasterInfo make(String[] tables) {
            long ptr = RepairKit.nativeMakeMaster(tables);
            ...
            return new MasterInfo(ptr, null);
        }

        /**
         * Load backup information from file and create a {@code MasterInfo}
         * object. Table filters can be applied while loading.
         * @param path      path to the backup file
         * @param key       passphrase to the encrypted backup file, or null for
         *                  plain-text backup file
         * @param tables    array of table names to include in the filter
         */
        public static MasterInfo load(String path, byte[] key, String[] tables) {
            if (path == null)
                return make(tables);

            byte[] salt = new byte[16];
            long ptr = RepairKit.nativeLoadMaster(path, key, tables, salt);
            if (ptr == 0)
                throw new SQLiteException("Cannot create MasterInfo.");
            return new MasterInfo(ptr, salt);
        }

        /**
         * Save backup information from an opened {@link SQLiteDatabase} for later
         * corruption recovery.
         */
        public static boolean save(SQLiteDatabase db, String path, byte[] key) {
            long dbPtr = db.acquireNativeConnectionHandle("backupMaster", true, false);
            boolean ret = RepairKit.nativeSaveMaster(dbPtr, path, key);
            db.releaseNativeConnection(dbPtr, null);
            return ret;
        }

        /**
         * Close corrupted database and release all resources. This should be
         * called when recovery is finished.
         */
        public void release() {
            if (mMasterPtr == 0) return;

            RepairKit.nativeFreeMaster(mMasterPtr);
            mMasterPtr = 0;-
        }

        @Override
        protected void finalize() throws Throwable {
            release();
            super.finalize();
        }
    }
    

由于B-tree恢复原理与备份恢复不同,失败场景也有差别。
WCDB采用不同方案的组合:
解析B-tree的方案+备份方案 能覆盖更多损坏场景.
当修复过程中遇到错误,很可能是B-tree损坏,此时使用备份方案,能挽救一些缺失。
对于技术选型,没有最好的,只有最适合的。

内建用于全文搜索的 mmicu FTS3/4 分词器

FTS3/4/5是SQLite 虚拟表模块,它为数据库应用程序提供全文本搜索功能。
创建虚拟表, 并可以像其他任何表一样使用INSERT,UPDATE或DELETE语句填充FTS表.

WCDB Android 自带了一个 FTS3/4 分词器MMICU,用于实现 SQLite 全文搜索。

接入与迁移

SQLCipher 提供了 sqlcipher_export SQL 函数用于导出数据到挂载的另一个 DB,可以用于数据迁移。 但这个函数用于 Android 的 SQLiteOpenHelper 并不方便。

SQLiteOpenHelper 用于Schema 版本管理,通过它打开 SQLite 数据库,再读取 user_version 字段来判断是否需要升级,并调用子类实现的 onCreate、onUpgrade 等接口来完成创建或升级操作。
通过sqlcipher_export 导出,要关闭原来的 DB, 打开老的 DB,执行 export 到新 DB,再重打开。

WCDB 对此做了扩展:
将 sqlcipher_export 扩展为两个参数参数,表示从哪里导出, 从而实现了导入。

// 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();
DatabaseUtils.stringForQuery(db, "SELECT sqlcipher_export('main', 'old');", null);
db.setTransactionSuccessful();
db.endTransaction();

如此就可以不关闭原来的数据库实现数据导入,且可以兼容 SQLiteOpenHelper 的接口
Android 接入与迁移

参考文档

WCDBwiki
API reference

上一篇 下一篇

猜你喜欢

热点阅读