一、背景
由于我们现在维护的是一款IM类的应用,需要在本地存储消息、好友、群、群成员等数据,这就涉及到使用数据库。由于之前是以开发工具类应用为主,数据库方面使用的较少,自18年开始使用数据库以来也走了不少弯路。
所以接下来打算利用几篇文章介绍我们项目如何使用数据库,主要讲复杂业务中数据库的应用,以及过程中遇到了哪些问题和我们都是怎么解决的。这里尽可能从实际使用出发,解决实际生产过程中的问题。
当然这是站在一个Android APP 应用开发者的角度,描述在Android APP上使用数据遇到的问题,请路过的数据库大佬嘴下留情~
二、数据库ORM选型
在18年做项目之初,我们主要调研了两款ORM框架,分别是GreenDao以及Room。
GreenDao地址:github.com/greenrobot/...
Room介绍地址:developer.android.com/training/da...
最终我们确定使用ROOM,原因也很简单ROOM作为官方开发的数据库ORM框架虽然刚刚推出不久(2018年初调研的),但后面更新维护有保障。同时还可以与 LiveData 配合使用,用于观察某一张表的数据变化。所以果断决定使用了Room。
三、Room如何使用
1、Room的使用
Room 包含三个主要组件:
- 数据库类
- 数据实体
- 数据访问对象 (DAO)
具体使用方法在官网有详细的介绍,这里我就不赘述了。
官网地址:developer.android.com/training/da... 。
官网中主要介绍的是Room框架的使用,这里我想重点聊的是,复杂业务中数据库要怎么设计。
2、分库
在我们的项目中,大致可以分为以下几个模块:IM模块、日历模块、音视频模块、小程序、其他。
其中IM是重度依赖数据库存储数据的,消息、联系人、群、群成员等各类信息都需要持久化到本地。日历模块涉及到日程信息、日历本的存储。 我们为什么要将数据库拆分呢?
起初时,由于经验不足我们是将以上所有信息都存储在同一库中的,也就是整个APP只有一个数据库。不过很快我们就发现数据库的性能比较差。
我们都知道Android SQLite是存在连接池概念的,连接池由一个主链接+多个非主链接构成。SQLite为了保证数据的一致性与完整性,设计为单写多读模式。它们会被序列化,即一个接一个地执行,以避免写冲突。因此,虽然存在多个读连接是可能的,但是对于写操作,就算在WAL模式下,也只能有一个主连接在任意时刻对数据库执行写操作。
当所有的数据存储在同一个库中,通过业务同时修改数据就需要阻塞等待了。我们的项目中作为核心的IM模块收发消息非常频繁,需要不断的操作数据库,这就影响了其他业务模块。
基于此我们将数据库重新划分为:
- 消息库:用于存储IM模块消息
- IM信息库:用于存储消息外其他配置信息,如联系人、群、群成员、各类配置
- 日历库:用于存储日程等信息
- 搜索库:用于存储需要全文检索的数据,减少对于业务库的影响
3、分表
当一个SQLite数据库表中的数据变得庞大时,它可能会导致性能问题,包括查询速度慢和数据插入效率低下。在我们的业务中大部分的数据表都是不需要考虑分表的,只有消息表需要考虑该问题。基于线上埋点统计,部分高频的用户,一天能够发送+接收消息数量超过5万条(主要是接收来自机器人的消息)。
常见的分表策略大致有:
水平分表
将一张表的数据按照某种规则分散到多张结构相同的表中。
- 优势:分散IO压力,提高查询性能
- 劣势:增加数据管理的复杂性,跨表操作更加复杂
垂直分表
将一张表按列分割成多张表,每张表存储部分列
- 优势:减少单次查询的数据量,提高查询效率
- 劣势:增加了JOIN操作的复杂性和成本,数据不在同一个表中,可能会影响数据一致性的维护
基于时间分表
根据时间将数据分配到不同的表中,例如按年、月、日来分表
- 优势:可以根据数据的时效性采取不同的备份策略
- 劣势:数据跨多个表,复杂查询可能需要跨表JOIN,复杂度增加
基于业务分表
根据业务特性将数据分配到不同的表中
- 优势:不同的业务表可以独立优化和扩展;更好的符合业务逻辑
- 劣势:跨表查询;分表策略可能会随着业务发展而变得不适用
我们业务中方式
在我们的业务中,针对消息表同时采取了水平分表以及垂直分表的方式。
水平分表
首先根据消息所在的会话类型进行区分。不同的会话类型所在的表不同。这里我们主要针对联系人、群进行分表。同时根据联系人ID以及群ID计算哈希值,基于哈希值分成各十张表。即存在10张联系人消息表,10张群消息表,这样水平方向就存在20张表。
不过这样拆分也带来了一个很大的问题,就是在Room中,每张表都要写对应的Dao类,那么就存在20个Dao类。在Dao中除了对应的表名称不同,其他都是相同的。在后续开发中,每次修改都需要同时修改20个类。初期我们都是硬着头皮来修改,后面针对此我们开发一个简单AS插件,每次仅需要操作其中一类,AS插件会自动匹配其他19个类动态修改。
同时以上所有计算哈希获取不同表Dao的方法都被我们封装在了底层,上层业务则不需要关心该获取那张表,只需要将ID传入给底层逻辑就可以了。
垂直分表
垂直方向上,我们将消息相关字段做了拆分。一部分作为基础字段放入基础表中,一部分作为扩展字段放入到可扩展的扩展表中。这样在垂直方向就将一张消息表拆分成了两张表。所以最终在消息库中共有40张表存储消息数据。
4、关于WAL
WAL 的全称是 Write Ahead Logging(预写日志),它是很多数据库中用于提高并发性能实现原子事务的一种机制。SQLite 在 3.7.0 版本引入该特性,在此之前 SQLite 实现原子提交和回滚的方法是 rollback journal(回滚日志)。
在WAL模式下,所有的写操作(如INSERT、UPDATE、DELETE)不是直接修改数据库文件,而是首先写入一个单独的日志文件,称为WAL文件(通常是数据库文件名后跟"-wal"的文件)。这些变更在事务提交时被记录,并最终在适当的时间被"检查点"(checkpoint)操作合并到主数据库文件。
在这种模式下,读取操作可以在写入操作进行时并行进行。因为读取操作读取的是主数据库文件,在写入期间,主数据库文件不会发生变化。这允许多个读取操作和一个写入操作同时发生,从而提高了并发性能。
关于WAL的介绍来自于:www.sqlite.org/wal.html
在工程中,我们要确保WAL处于开启状态,这样才可以将提高数据库的性能。
Room中判断是否开启WAL代码如下:
j
JournalMode resolve(Context context) {
if (this != AUTOMATIC) {
return this;
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
ActivityManager manager = (ActivityManager)
context.getSystemService(Context.ACTIVITY_SERVICE);
if (manager != null && !isLowRamDevice(manager)) {
return WRITE_AHEAD_LOGGING;
}
}
return TRUNCATE;
}
private static boolean isLowRamDevice(@NonNull ActivityManager activityManager) {
if (Build.VERSION.SDK_INT >= 19) {
return SupportSQLiteCompat.Api19Impl.isLowRamDevice(activityManager);
}
return false;
}
可以看到当API大于16(4.1)时 或API大于19(4.4)且非低RAM设备,就会开启WAL模式。也可以手动调用接口开启WAL模式。
四、切换到WCDB
在后面的调研中,我们了解到微信开源了他们的数据库:WCDB。同时WCDB提供了基于Room的封装,所以我们决定将数据库切换到WCDB。
WCDB是WeChat Database的缩写,顾名思义就是微信开源的数据库,GitHub的wiki如下: github.com/Tencent/wcd...。
由于WCDB支持使用Room ORM 与数据绑定,所以对于我们工程而言切换非常简单。核心代码:
java
SQLiteCipherSpec cipherSpec = new SQLiteCipherSpec() // 指定加密方式,使用默认加密可以省略
.setPageSize(4096)
.setKDFIteration(64000);
WCDBOpenHelperFactory factory = new WCDBOpenHelperFactory()
.passphrase("passphrase".getBytes()) // 指定加密DB密钥,非加密DB去掉此行
.cipherSpec(cipherSpec) // 指定加密方式,使用默认加密可以省略
.writeAheadLoggingEnabled(true) // 打开WAL以及读写并发,可以省略让Room决定是否要打开
.asyncCheckpointEnabled(true); // 打开异步Checkpoint优化,不需要可以省略
AppDatabase db = Room.databaseBuilder(this, AppDatabase.class, "app-db")
//.allowMainThreadQueries() // 允许主线程执行DB操作,一般不推荐
.openHelperFactory(factory) // 重要:使用WCDB打开Room
.build();
1、WCDB开启WAL
我们可以在构建WCDBOpenHelperFactory时,调用writeAheadLoggingEnabled()方法,开启WAL模式。
对应源码:
java
public WCDBOpenHelperFactory writeAheadLoggingEnabled(boolean wal) {
mWALMode = wal;
return this;
}
@Override
public SupportSQLiteOpenHelper create(SupportSQLiteOpenHelper.Configuration configuration) {
WCDBOpenHelper result = new WCDBOpenHelper(configuration.context, configuration.name,
mPassphrase, mCipherSpec, configuration.callback);
result.setWriteAheadLoggingEnabled(mWALMode);
result.setAsyncCheckpointEnabled(mAsyncCheckpoint);
return result;
}
2、遭遇大坑:参数超过999
就在我们感叹切换是如此简单时,现实就给了一个大闷棍,在灰度期间出现大量的崩溃,崩溃信息是:too many SQL variables。
经过排查,WCDB有针对SQL参数做限制,最大是999。而在我们的工程历史代码中有SQL语句使用【x IN list】的用法。这种写法导致SQL的参数具有不确定性,很可能会超过999。所以我们针对这种情况兵分两路,进行优化。
一路针对我们的工程进行排查,看看哪些业务逻辑存在这种写法。当list非常大时对查询性能、内存等都有影响。所以我们倾向于将这类SQL语句结合业务场景将其优化掉。如果不能改,那么就将List集合进行拆分,分批查询。封装分批操作底层工具类,提供给业务逻辑使用。
一路查看WCDB源码,从源码角度看看能否调整其参数限制。最终团队小伙伴通过翻WCDB代码找到了对应的位置:
c++
#ifndef SQLITE_MAX_VARIABLE_NUMBER
# define SQLITE_MAX_VARIABLE_NUMBER 999
#endif
assert( aHardLimit[SQLITE_LIMIT_VARIABLE_NUMBER]==SQLITE_MAX_VARIABLE_NUMBER);
if( x>db->aLimit[SQLITE_LIMIT_VARIABLE_NUMBER] ){
sqlite3ErrorMsg(pParse, "too many SQL variables");
}
后面我们将999参数调整更大,然后重新编译了一个包集成到工程中。
五、预告
在后面的文章中,会介绍在我们的业务中,我们又遇到了哪些与数据库相关的问题,以及我们是怎么优化的。主要有以下几点:
1、WCDB全文检索的引入
2、SQL治理与数据库相关崩溃解决
3、连接池忙碌与WCDB源码