WCDB简介
WCDB(wechat dataBase)是一个高效、完整、易用的开源移动数据库框架,基于SQLCipher,支持iOS, macOS和Android。
基本特性
易用
WCDB支持一句代码即可将数据取出并组合为object。
- WINQ(WCDB语言集成查询):通过WINQ,开发者无须为了拼接SQL的字符串而写一大坨胶水代码。
- ORM(Object Relational Mapping):WCDB支持灵活、易用的ORM。开发者可以很便捷地定义表、索引、约束,并进行增删改查操作。
高效
WCDB通过框架层和sqlcipher源码优化,使其更高效的表现
- 多线程并发:WCDB支持多线程读与读、读与写并发执行,写与写串行执行。
- 批量写操作性能测试
完整
WCDB覆盖了数据库相关各种场景的所需功能
- 加密:WCDB提供基于SQLCipher的数据库加密。
- 损坏修复:WCDB内建了Repair Kit用于修复损坏的数据库。
- 反注入:WCDB内建了对SQL注入的保护。
WCDB for Android
基本功能
基于SQLCipher的数据库加密
- WCDB使用了SQLCipher的C层库,但没有直接使用 SQLCipher Android 的封装层。
SQLCipher Android 封装层中很多设置需要手写PRAGMA语句,还需了解PRAGMA指令的正确调用顺序,这对开发者而言,成本较高。
所以WCDB对此进行改进:
-
封装了SQLiteCipherSpec类,提供了一些设置方法
setPageSize(int size)
setKDFIteration(int iter) (密钥导出函数的迭代次数,迭代次数越多,破解越困难)
setKdfAlgorithm(int algo)
用于设置加密参数,此方式屏蔽了许多细节:SQLCipher的PRAGMA语法和调用顺序等。 -
SQLiteCipherSpec类的结构可以传给RepairKit用于恢复损坏DB。
-
WCDB将String类型的密码改为byte[]类型,可以支持非打印字符作为密码,
原来字符类型密码只要转换为 UTF-8 的 byte 数组即可,和 SQLCipher Android 兼容。
使用连接池实现并发读写
数据库连接池
在数据库操作中,与数据库建立连接(Connection)是最为耗时的操作之一.
数据库都有最大连接数目的限制,为每一个访问数据库的用户建立连接显然不合理,于是有了连接池的概念.
数据库连接是关键的、有限的、昂贵的资源,对数据库连接的管理能显著影响到整个应用程序的伸缩性和健壮性. 响到程序的性能指标。
连接池大小
连接池的最大数据库连接数量限定了这个连接池能占有的最大连接数,
目前Android系统的实现中,如果以非WAL模式打开数据库,连接池中只会保持一个数据库连接,
如果以WAL模式打开数据库,连接池中的最大连接数量则根据系统配置决定,默认配置是两个。
连接池的大小设置需考虑几个因素:
- 最小连接个数 如果应用程序对数据库连接的使用量不大,则连接资源被浪费
- 最大连接个数 请求数量>超过Max,则进入等待队列
- 如果二者差值很大,那么最先连接请求将会获利,之后超过最小连接数量的连接请求会建立一个新的数据库连接。大于最小连接数的数据库连接在使用完不会马上被释放,它将被放到连接池中等待重复使用或是超时后被释放。
WCDB使用连接池实现并发读写数据库,连接池负责分配,管理和释放数据库连接,类似线程池,它允许应用程序重复使用一个现有的数据库连接,减少链接不断创建和销毁带来的资源浪费。
原理:使用WAL(Write Ahead Log)机制实现原子事务。
在打开DB前设置WAL的开启
SQLiteOpenHelper dbHelper = new SQLiteOpenHelper(...);
dbHelper.setWriteAheadLoggingEnabled(true);
SQLiteDatabase db = dbHelper.getWritableDatabase();
WAL原理:修改并不直接写入数据库文件中,而是写入到另外一个称为WAL的文件中;
如果事务失败,WAL中的记录会被忽略,撤销修改; 如果事务成功,它将在随后的某个时间被写回到数据库文件中,提交修改。
- WAL实现读写,读读并发,写写互斥。
- WAL在实现过程中,使用了共享内存。
- 关于连接池并发,提供了性能监控接口
SQLiteTrace
日志输出重定向以及性能跟踪接口
- WCDB 提供接口来完成日志重定向
/**
* Use user defined logger for log outputs.
* @param callback logger callback
*/
public static void setLogger(LogCallback callback) {
mCallback = callback;
nativeSetLogger(-1, callback);
}
// 实现LogCallback,可定制日志逻辑。
-
在数据库连接的重要阶段进行日志记录
operationlog.png
场景:
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

将接口的实现与SQLiteDataBase绑定,则可以在执行SQL语句或连接池拥堵时收到回调,可用于排查SQL性能问题。
数据库修复
SQLite DB结构
每个SQLite DB都有一个sqlite_master
表来存储每个表的元数据
(表名
、根节点地址
、表scheme
等,通过这些信息,足够对一个表寻址)


数据库修复目标
- 恢复成功率高
- 支持加密DB
- 能处理超大的数据量
- 不影响体验
为实现上述方案,微信的技术选型历程:
修复DB的技术选型
备份恢复方案
备份数据,损坏后使用备份数据恢复。
SQLite提供的备份机制:
- 拷贝: 由于SQLite DB本身是文件(主DB + journal 或 WAL), 直接把文件复制就能达到备份的目的。
- Dump: 在DB完好的时候执行.dump, 把 DB所有内容输出为 SQL语句,达到备份目的,恢复的时候执行SQL即可。
- Backup API: SQLite自身提供的一套备份机制,按 Page 为单位复制到新 DB, 支持热备份。
经过测试,DB大小约50MB, 数据条目数大约为10万条,从三个维度对比:

折中的选择是 Dump + 压缩
,备份大小具有明显优势,备份性能尚可,恢复性能较差但由于需要恢复的场景较少,算是可以接受的短板。
官方Dump
原理:每个SQLite DB都有一个sqlite_master
表,里面保存着全部table
和index
的信息(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之上做了优化:
- 由于格式化SQL语句输出耗时较长,使用自定义二进制格式承载Dump输出,并做加密处理。
- 压缩操作放到其他线程同时进行。
策略:充电并灭屏时进行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);
优点:
- 直接对SQlite处理,天然支持加密SQLCipher,无需额外处理
- 备份大小小,备份性能好,成功率≈72%
问题:
sqlite_master表读不出来,特别是第一页损坏, 会导致后续所有内容无法读出,则完全不能恢复
需解决的痛点:为了让sqlite_master受损的DB也能打开,需要想办法使文件头或sqltree绕过SQLite引擎的逻辑。由于SQLite引擎初始化逻辑比较复杂,为了避免副作用,没有采用hack的方式复用其逻辑,而是模拟仿造一个"能够only-read数据的最小化系统"Repair Kit
Repair Kit(解析 B-tree 修复)
- 备份sqlite_master(仅改变表结构的时候,每次执行完数据库创建或升级时,sqlite_master才会改变,只需要在创建于升级时时候重新备份一次即可,所以备份成本低)
- 备份生成密钥的必要信息(备份数据的加密,恢复数据的解密)
- 恢复任务需遍历解析B-tree所有节点,读出数据Database File Format 详细描述了SQLite文件格式, 参照之实现B-tree解析可读取 SQLite DB
优点:
- 常规使用的读取DB的方法(包括dump方式恢复), 都是通过执行SQL语句实现的,这涉及到SQLite系统最复杂的子系统——SQL执行引擎,而此方案略过此环节。
- only-read数据的最小化系统,在写入恢复数据到新 DB 只要直接调用 SQLite 接口即可,因而可以省略同样比较复杂的B-tree平衡、Journal和同步等逻辑。
- Repair Kit 可以选择恢复一部分表,提供了相关接口,只需在 MasterInfo.load(...) 或者 MasterInfo.make(...) 里指定白名单即可(表过滤器)。
- 需备份的数据准备成本低,备份成功率高-78%
缺点:
- 损坏的B-tree的非叶子节点,会导致后续节点无法读取
//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表.
-
全文搜索
允许用户有效地搜索大量文档,以查找包含一个或多个搜索项实例的子集 (eg: 微信搜索聊天记录)
因为全文搜索,首先需要对文本进行分词处理(将长文本分解为以字词为单位的数据结构,方便后续的处理分析工作)
分词处理需要使用icu(International Component for Unicode/Unicode)相关函数 -
官方ICU的问题:
- ICU库不同版本会在函数名称后面,带上版本号后缀,直接编译时连接行不通。
- 其动态库和自带的数据文件体积很大,超过 10MB
WCDB Android 自带了一个 FTS3/4 分词器MMICU,用于实现 SQLite 全文搜索。
- MMICU的优化
WCDB提供兼容层 icucompat :
通过推断ICU的版本,动态加载不同的符号名称,然后通过宏来模拟直接调用方便开发
最终实现解无需自带ICU库,依然可以使用它的功能,并使得apk包瘦身10M+。
接入与迁移
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 接入与迁移
网友评论