本篇文章也发布在公司内部实践者论坛中。
内容概要:之前曾经介绍过Android流行ORM框架性能对比及Room踩坑总结,除了ORM框架的优化,我们还完成了WAL、索引优化等内容。这篇文章主要介绍这些优化及遇到的问题。

一、开启WAL
首先,我们的项目使用SQLCipher加密数据层,SQLCipher是基于SQLite的一款加密数据库,虽然其基于SQLite,但是其Java层并没有暴露出SQLite连接池相关的方法,我们无法通过开启SQLite的WAL功能来开启并发连接,这样就导致我们只有一个数据库连接,读写会相互阻塞,影响应用的速度。为了解决这个问题,我们需要开发一套基于SQLCipher的连接池,但是工作量较大,通过调研我们发现了wcdb数据库框架:wcdb是基于SQLCipher,支持IOS、Android的数据库框架,它使用连接池实现并发读写,可以修复损坏的数据库等功能。
由于项目中已经使用了Room框架,我们需要引入wcdb的Room分支,该分支提供了Room初始化需要的WCDBOpenHelperFactory:

1
2
3
4
5
6
7
8
9
WCDBOpenHelperFactory factory = new WCDBOpenHelperFactory()
.passphrase("passphrase".getBytes()) // passphrase to the database, remove this line for plain-text
.cipherSpec(cipherSpec) // cipher to use, remove for default settings
.writeAheadLoggingEnabled(true); // enable WAL mode, remove if not needed

AppDatabase db = Room.databaseBuilder(this, AppDatabase.class, "app-db")
.allowMainThreadQueries()
.openHelperFactory(factory) // specify WCDBOpenHelperFactory when opening database
.build();

除了直接使用wcdb的Room分支,我们也可以自己编译wcdb的Room分支来解决一些问题:
比如”一次性读取大量数据,每次都出现警告信息”这个问题,这个问题因为CursorWindow一次性分配2MB的内存,如果查询的数据结果集超过2MB,会自动分页,当我们的cursor滚动到后面时会再次查询,影响速度,我们的做法是修改wcdb的Room分支源码,将WCDBDatabase的query方法的返回值从SQLiteCursor.FACTORY.newCursor这个继承于AbstractWindowedCursor的类修改为SQLiteDirectCursor.FACTORY.newCursor(db, masterQuery, editTable, query)。当然我们也可以自定义返回的Cursor,只需要在WCDBOpenHelper的构造函数中添加SQLiteDatabase.CursorFactory参数,就可以定制query返回的cursor:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
WCDBOpenHelper(Context context, String name, byte[] passphrase, SQLiteCipherSpec cipherSpec,
SQLiteDatabase.CursorFactory factory, Callback callback) {
mDelegate = createDelegate(context, name, passphrase, cipherSpec, factory, callback);
}

OpenHelper(Context context, String name, final WCDBDatabase[] dbRef,
byte[] passphrase, SQLiteCipherSpec cipherSpec, SQLiteDatabase.CursorFactory cursorFactory,
final Callback callback) {
super(context, name, passphrase, cipherSpec, cursorFactory, callback.version,
new DatabaseErrorHandler() {
@Override
public void onCorruption(SQLiteDatabase dbObj) {
WCDBDatabase db = dbRef[0];
if (db != null) {
callback.onCorruption(db);
}
}
});
mCallback = callback;
mDbRef = dbRef;
mAsyncCheckpoint = false;
}

这样在引入wcdb后,我们只需要在创建Factory时,并开启WAL即可:

1
2
3
4
5
6
7
8
9
10
@VisibleForTesting
public static SupportSQLiteOpenHelper.Factory getFactory() {
final SQLiteCipherSpec cipherSpec = new SQLiteCipherSpec()
.setPageSize(1024)
.setSQLCipherVersion(3);
return new WCDBOpenHelperFactory()
.passphrase(getDBKey().getBytes()) // 设置sqlcipher的密码
.cipherSpec(cipherSpec) // 设置sqlite的配置,兼容以前sqlcipher创建的数据库
.writeAheadLoggingEnabled(DB_OPTIMIZE_WAL); // 开启wal,支持并发访问db
}

二、引入WAL的一些问题
(一)stetho崩溃问题
在引入wcdb和开启WAL之后,我们发现chrome浏览器查看数据库的工具stetho无法用了,主要原因是stetho使用了一个sqlite,wcdb也使用了一个sqlite,存在两个版本不一致的sqlite库,而官方不建议使用两个版本不一致的sqlite访问db。为了解决该问题,我们需要自己编译stetho库,也可以使用github上的开源库来编译。但是该开源库是基于wcdb的master分支的,若我们使用的是wcdb的Room分支版本,就需要修改一些内容来进行兼容:

1.签名不一致造成的方法参数类型修改
比如:

1
2
SqliteDatabseDriver#getTableNames中
Cursor cursor = database.rawQuery("SELECT name FROM sqlite_master WHERE type IN (?, ?)", new String[]{"table", "view"});

需要改为:

1
Cursor cursor = database.rawQuery("SELECT name FROM sqlite_master WHERE type IN (?, ?)", (Object[])new String[]{"table", "view"});

2.将android.database.Cursor改为com.tencent.wcdb.Cursor。解决找不到wcdb的Cursor问题NoSuchMethodError。

(二)读写线程池分离
我们在开启WAL后,便可以拥有多个数据库连接,但是由于WAL为单写多读的特性,如果将读写线程都放在一个线程池里,就有可能造成读线程等待写线程的完成,造成了性能的浪费,我们的实现方式是将读写线程分别放在单独的线程池中,每个线程池对外暴露不同的方法,业务层根据自己的需要调用不同方法即可。

(三)旧版本的wcdb-room分支不支持并发
这是因为Room的实现使用了TEMP TABLE,他不能在数据库连接之间共享,因此基于wcdb的room分支也只支持一个数据库连接,无法支持并发,开启WAL也并没有意义。Room在1.1修复了这个问题,最新版本的wcdb-room分支也支持多个数据库连接了。

三、开启WAL的效果展示
开启WAL后,读写都有优化,如下图,读29000条账单,可以节约1s的时间,写1000条账单数据,可以节约30ms左右的时间。

wal-1

wal-2

四、其他优化
我们除了完成WAL的优化,还完成了索引的优化、开启mmap的优化、ORM框架的优化:
(一)索引优化
索引优化分为两个部分,一个是删除无用的索引,一个是选用合适的索引进行查询。这两者都需要使用analyze命令。使用analyze命令,SQLite会创建sqlite_stat1表,如下图:

wal-3

包含表名、索引名以及处于最后一列的索引统计信息,比如showTime索引这一行对应的索引信息”200811 2”,代表一共有200811行数据,使用showTime索引,可以直接从20w条数据中区分出两条数据。我们可以删除那些区分度不够明显的索引,比如上图中的deleteTime、isSynced、imported等索引。减少建立索引的开销。
使用analyze命令的另一个好处就是可以让sqlite的查询优化器选择最优的索引进行查询。如下图,未使用analyze命令时选用了区分数据较差的索引deleteTime,而使用analyze后,则使用了区分度较高的索引showTime来查询数据。

wal-4

wal-5

(二)使用内存映射I/O
SQLite在读写时会浪费一定的性能开销在内核态和用户态的切换上,又由于我们是读写密集型的应用,十分有必要开启mmap,我们可以使用PRAGMA mmap_size=268435456;来开启,需要注意的是,开启mmap后,主要优化的是读,写并不会有显著的更改,这是因为SQLite底层在读时,会首先将需要读的页面内容复制到堆内存块后,再进行读,如果使用内存映射I/O,会尝试直接将请求的页面映射到应用程序的地址空间中,不必再进行复制了,而SQLite在写之前总是需要将页面内容拷贝到堆内存中,这样做一是为了防止在事务提交之前,数据库的更改对其他进程可见,二是防止应用程序中的stray pointers直接毁坏或者覆盖了数据库文件,所以需要将更改放在单独的私有内存中,因此内存映射I/O无法提升写数据库性能。

(三)ORM框架优化
正如上面提到,我们使用Room框架替换了ORMLite,提升了ORM的性能,也在迁移过程遇到不少问题,包括ORMLite和Room对于原始类型的字段限制不一样、不支持SQLCipher、insert后不会自动设置主键等问题,之前在这篇文章中介绍过这里就不再赘述了。